From 15b474172a922a1c80769e75af1b15fb27f4061e Mon Sep 17 00:00:00 2001 From: lewisxhe Date: Wed, 21 Jan 2026 17:33:50 +0800 Subject: [PATCH 001/225] Added compatibility with LilyGo T-Deck-Pro V1.1 --- src/detect/ScanI2C.h | 3 +- src/detect/ScanI2CTwoWire.cpp | 27 +++- src/graphics/EInkDisplay2.cpp | 3 + src/main.cpp | 4 + .../extra_variants/t_deck_pro/variant.cpp | 116 +++++++++++++++++- .../esp32s3/t-deck-pro-v1_1/pins_arduino.h | 19 +++ .../esp32s3/t-deck-pro-v1_1/platformio.ini | 41 +++++++ variants/esp32s3/t-deck-pro-v1_1/variant.h | 106 ++++++++++++++++ 8 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 variants/esp32s3/t-deck-pro-v1_1/pins_arduino.h create mode 100644 variants/esp32s3/t-deck-pro-v1_1/platformio.ini create mode 100644 variants/esp32s3/t-deck-pro-v1_1/variant.h diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index dffcd8fb65a..7b6fdc7a278 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -88,7 +88,8 @@ class ScanI2C BH1750, DA217, CHSC6X, - CST226SE + CST226SE, + CST3530, } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index c6ef3484679..cb20a85c739 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -508,8 +508,33 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) SCAN_SIMPLE_CASE(DFROBOT_RAIN_ADDR, DFROBOT_RAIN, "DFRobot Rain Gauge", (uint8_t)addr.address); SCAN_SIMPLE_CASE(LTR390UV_ADDR, LTR390UV, "LTR390UV", (uint8_t)addr.address); SCAN_SIMPLE_CASE(PCT2075_ADDR, PCT2075, "PCT2075", (uint8_t)addr.address); + case CST328_ADDR: - // Do we have the CST328 or the CST226SE + // Do we have the CST328 or the CST226SE,CST3530 + { + // T-Deck pro V1.1 new touch panel use CST3530 + int retry = 5; + while(retry--) { + uint8_t buffer[7]; + uint8_t r_cmd[] = {0x0d0,0x03,0x00,0x00}; + i2cBus->beginTransmission(addr.address); + i2cBus->write(r_cmd, sizeof(r_cmd)); + if(i2cBus->endTransmission() == 0){ + i2cBus->requestFrom((int)addr.address,7); + i2cBus->readBytes(buffer,7); + if(buffer[2] == 0xCA && buffer[3] == 0xCA){ + logFoundDevice("CST3530", (uint8_t)addr.address); + type = CST3530; + break; + } + } + uint8_t cmd1[] = {0xD0,0x00,0x04,0x00}; + i2cBus->beginTransmission(addr.address); + i2cBus->write(cmd1, sizeof(cmd1)); + i2cBus->endTransmission(); + delay(50); + } + } registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xAB), 1); if (registerValue == 0xA9) { type = CST226SE; diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index 1678da7939d..6f53a56b99a 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -104,8 +104,11 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit) // End the update process - virtual method, overriden in derived class void EInkDisplay::endUpdate() { +#ifndef EINK_NOT_HIBERNATE // Power off display hardware, then deep-sleep (Except Wireless Paper V1.1, no deep-sleep) adafruitDisplay->hibernate(); +#endif + } // Write the buffer to the display memory diff --git a/src/main.cpp b/src/main.cpp index c1096a240ea..d28dbb809c2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -413,6 +413,10 @@ void setup() digitalWrite(SDCARD_CS, HIGH); pinMode(PIN_EINK_CS, OUTPUT); digitalWrite(PIN_EINK_CS, HIGH); + pinMode(PIN_EINK_RES, OUTPUT); + digitalWrite(PIN_EINK_RES, HIGH); + pinMode(CST328_PIN_RST, OUTPUT); + digitalWrite(CST328_PIN_RST, HIGH); #elif defined(T_LORA_PAGER) pinMode(LORA_CS, OUTPUT); digitalWrite(LORA_CS, HIGH); diff --git a/src/platform/extra_variants/t_deck_pro/variant.cpp b/src/platform/extra_variants/t_deck_pro/variant.cpp index eae9335ce7b..77fba53b9be 100644 --- a/src/platform/extra_variants/t_deck_pro/variant.cpp +++ b/src/platform/extra_variants/t_deck_pro/variant.cpp @@ -8,20 +8,126 @@ CSE_CST328 tsPanel = CSE_CST328(EINK_WIDTH, EINK_HEIGHT, &Wire, CST328_PIN_RST, CST328_PIN_INT); +static bool is_cst3530 = false; +volatile bool touch_isr = false; +#define CST3530_ADDR 0x1A + +bool read_cst3530_touch(int16_t *x, int16_t *y) { + uint8_t buffer[9] = {0}; + uint8_t r_cmd[] = {0xD0, 0x07, 0x00, 0x00}; + uint8_t clear_cmd[] = {0xD0, 0x00, 0x02, 0xAB}; + + Wire.beginTransmission(CST3530_ADDR); + Wire.write(r_cmd, sizeof(r_cmd)); + if (Wire.endTransmission() != 0) { + LOG_DEBUG("CST3530 I2C send addr failed"); + return false; + } + + int read_len = Wire.requestFrom((int)CST3530_ADDR, sizeof(buffer)); + if (read_len != sizeof(buffer)) { + LOG_DEBUG("CST3530 read len error: %d (expect 9)", read_len); + return false; + } + int actual_read = Wire.readBytes(buffer, sizeof(buffer)); + if (actual_read != sizeof(buffer)) { + LOG_DEBUG("CST3530 read bytes error: %d (expect 9)", actual_read); + return false; + } + + uint8_t report_typ = buffer[2]; + if (report_typ != 0xFF) { + return false; + } + + uint8_t touch_points = buffer[3] & 0x0F; + if (touch_points == 0 || touch_points > 1) { + LOG_DEBUG("CST3530 touch points invalid: %d", touch_points); + return false; + } + + *x = buffer[4] + ((uint16_t)(buffer[7] & 0x0F) << 8); + *y = buffer[5] + ((uint16_t)(buffer[7] & 0xF0) << 4); + + // LOG_DEBUG("CST3530 touch: num:%d x=%d,y=%d", touch_points, *x, *y); + + Wire.beginTransmission(CST3530_ADDR); + Wire.write(clear_cmd, sizeof(clear_cmd)); + if (Wire.endTransmission() != 0) { + LOG_DEBUG("CST3530 clear cmd failed"); + } + + return true; +} + bool readTouch(int16_t *x, int16_t *y) { - if (tsPanel.getTouches()) { - *x = tsPanel.getPoint(0).x; - *y = tsPanel.getPoint(0).y; - return true; + + if(is_cst3530){ + if(touch_isr){ + touch_isr = false; + return read_cst3530_touch(x, y); + } + return false; + }else{ + if (tsPanel.getTouches()) { + *x = tsPanel.getPoint(0).x; + *y = tsPanel.getPoint(0).y; + return true; + } } return false; } + +static void touchInterruptHandler(){ + touch_isr = true; +} + // T-Deck Pro specific init void lateInitVariant() { - tsPanel.begin(); + // Reset touch + pinMode(CST328_PIN_RST, OUTPUT); + digitalWrite(CST328_PIN_RST, HIGH); + delay(20); + digitalWrite(CST328_PIN_RST, LOW); + delay(80); + digitalWrite(CST328_PIN_RST, HIGH); + delay(20); + + int retry = 5; + uint8_t buffer[7]; + uint8_t r_cmd[] = {0x0d0,0x03,0x00,0x00}; + + // Probe touch chip + while(retry--) { + Wire.beginTransmission(CST3530_ADDR); + Wire.write(r_cmd, sizeof(r_cmd)); + if(Wire.endTransmission() == 0){ + Wire.requestFrom((int)CST3530_ADDR,7); + Wire.readBytes(buffer,7); + if(buffer[2] == 0xCA && buffer[3] == 0xCA){ + LOG_DEBUG("CST3530 detected"); + is_cst3530 = true; + + // The CST3530 will automatically enter sleep mode; + // polling should not be used, but rather an interrupt method should be employed. + pinMode(CST328_PIN_INT, INPUT); + attachInterrupt(digitalPinToInterrupt(CST328_PIN_INT), touchInterruptHandler, FALLING); + + break; + }else{ + LOG_DEBUG("CST3530 not response ~!"); + } + } + uint8_t cmd1[] = {0xD0,0x00,0x04,0x00}; + Wire.beginTransmission(CST3530_ADDR); + Wire.write(cmd1, sizeof(cmd1)); + Wire.endTransmission(); + delay(50); + } + touchScreenImpl1 = new TouchScreenImpl1(EINK_WIDTH, EINK_HEIGHT, readTouch); touchScreenImpl1->init(); } diff --git a/variants/esp32s3/t-deck-pro-v1_1/pins_arduino.h b/variants/esp32s3/t-deck-pro-v1_1/pins_arduino.h new file mode 100644 index 00000000000..af0ba80b36e --- /dev/null +++ b/variants/esp32s3/t-deck-pro-v1_1/pins_arduino.h @@ -0,0 +1,19 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// used for keyboard, touch controller, beam sensor, and gyroscope +static const uint8_t SDA = 13; +static const uint8_t SCL = 14; + +// Default SPI will be mapped to Radio +static const uint8_t SS = 3; +static const uint8_t MOSI = 33; +static const uint8_t MISO = 47; +static const uint8_t SCK = 36; + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini new file mode 100644 index 00000000000..b765229340a --- /dev/null +++ b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini @@ -0,0 +1,41 @@ +[env:t-deck-pro-v1_1] +custom_meshtastic_hw_model = 102 +custom_meshtastic_hw_model_slug = T_DECK_PRO +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = LILYGO T-Deck Pro +custom_meshtastic_images = tdeck_pro.svg +custom_meshtastic_tags = LilyGo +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = esp32s3_base +board = t-deck-pro +board_check = true +upload_protocol = esptool + +build_flags = + ${esp32s3_base.build_flags} -I variants/esp32s3/t-deck-pro-v1_1 + -D T_DECK_PRO + -D USE_EINK + -D EINK_DISPLAY_MODEL=GxEPD2_310_GDEQ031T10 + -D EINK_WIDTH=240 + -D EINK_HEIGHT=320 + ;-D USE_EINK_DYNAMICDISPLAY ; Enable Dynamic EInk + -D EINK_LIMIT_FASTREFRESH=10 ; How many consecutive fast-refreshes are permitted + -D EINK_LIMIT_GHOSTING_PX=2000 ; (Optional) How much image ghosting is tolerated + -D EINK_NOT_HIBERNATE ; Disable hibernate to avoid issues with elink + +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 + zinggjm/GxEPD2@1.6.5 + # renovate: datasource=git-refs depName=CSE_Touch packageName=https://github.com/CIRCUITSTATE/CSE_Touch gitBranch=main + https://github.com/CIRCUITSTATE/CSE_Touch/archive/b44f23b6f870b848f1fbe453c190879bc6cfaafa.zip + # renovate: datasource=github-tags depName=CSE_CST328 packageName=CIRCUITSTATE/CSE_CST328 + https://github.com/CIRCUITSTATE/CSE_CST328/archive/refs/tags/v0.0.4.zip + # renovate: datasource=git-refs depName=BQ27220 packageName=https://github.com/mverch67/BQ27220 gitBranch=main + https://github.com/mverch67/BQ27220/archive/07d92be846abd8a0258a50c23198dac0858b22ed.zip + # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library + adafruit/Adafruit DRV2605 Library@1.2.4 diff --git a/variants/esp32s3/t-deck-pro-v1_1/variant.h b/variants/esp32s3/t-deck-pro-v1_1/variant.h new file mode 100644 index 00000000000..8584fd8a10f --- /dev/null +++ b/variants/esp32s3/t-deck-pro-v1_1/variant.h @@ -0,0 +1,106 @@ +// Display (E-Ink) +#define PIN_EINK_CS 34 +#define PIN_EINK_BUSY 37 +#define PIN_EINK_DC 35 +#define PIN_EINK_RES 16 +#define PIN_EINK_SCLK 36 +#define PIN_EINK_MOSI 47 +#define TFT_BL 45 // option , default not backlight + +#define I2C_SDA SDA +#define I2C_SCL SCL + +// CST328 touch screen (implementation in src/platform/extra_variants/t_deck_pro/variant.cpp) +#define HAS_TOUCHSCREEN 1 +#define CST328_PIN_INT 12 +#define CST328_PIN_RST 38 + +#define USE_POWERSAVE +#define SLEEP_TIME 120 + +// GNNS +#define HAS_GPS 1 +#define GPS_BAUDRATE 38400 +#define PIN_GPS_EN 39 +#define GPS_EN_ACTIVE 1 +#define GPS_RX_PIN 44 +#define GPS_TX_PIN 43 +#define PIN_GPS_PPS 1 + +#define BUTTON_PIN 0 + +// vibration motor +#define HAS_DRV2605 +#define PIN_DRV_EN 2 + +// Have SPI interface SD card slot +#define HAS_SDCARD +#define SDCARD_USE_SPI1 +#define SPI_MOSI (33) +#define SPI_SCK (36) +#define SPI_MISO (47) +#define SPI_CS (48) +#define SDCARD_CS SPI_CS +#define SD_SPI_FREQUENCY 75000000U + +// TCA8418 keyboard +#define KB_BL_PIN 42 +#define CANNED_MESSAGE_MODULE_ENABLE 1 + +// microphone PCM5102A +#define PCM5102A_SCK 47 +#define PCM5102A_DIN 17 +#define PCM5102A_LRCK 18 + +// LTR_553ALS light sensor +#define HAS_LTR553ALS + +// gyroscope BHI260AP +// #define BOARD_1V8_EN 38 //Deck-Pro remove 1.8v en pin +#define HAS_BHI260AP + +// battery charger BQ25896 +#define HAS_PPM 1 +#define XPOWERS_CHIP_BQ25896 + +// battery quality management BQ27220 +#define HAS_BQ27220 1 +#define BQ27220_I2C_SDA SDA +#define BQ27220_I2C_SCL SCL +#define BQ27220_DESIGN_CAPACITY 1400 + +// LoRa +#define USE_SX1262 +#define USE_SX1268 + +#define LORA_EN 46 // LoRa enable pin +#define LORA_SCK 36 +#define LORA_MISO 47 +#define LORA_MOSI 33 +#define LORA_CS 3 + +#define LORA_DIO0 -1 // a No connect on the SX1262 module +#define LORA_RESET 4 +#define LORA_DIO1 5 // SX1262 IRQ +#define LORA_DIO2 6 // SX1262 BUSY +#define LORA_DIO3 // Not connected on PCB, but internally on the TTGO SX1262, if DIO3 is high the TXCO is enabled + +#define SX126X_CS LORA_CS // FIXME - we really should define LORA_CS instead +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET +// Not really an E22 but TTGO seems to be trying to clone that +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 2.4 +// Internally the TTGO module hooks the SX1262-DIO2 in to control the TX/RX switch (which is the default for the sx1262interface +// code) + +#define MODEM_POWER_EN 41 +#define MODEM_PWRKEY 40 +#define MODEM_RST 9 +#define MODEM_RI 7 +#define MODEM_DTR 8 +#define MODEM_RX 10 +#define MODEM_TX 11 + +#define HAS_PHYSICAL_KEYBOARD 1 \ No newline at end of file From edf660ccb399cb6d9d4490af6d7737bcf5013271 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 16 Apr 2026 07:48:19 -0500 Subject: [PATCH 002/225] Update variants/esp32s3/t-deck-pro-v1_1/variant.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- variants/esp32s3/t-deck-pro-v1_1/variant.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/esp32s3/t-deck-pro-v1_1/variant.h b/variants/esp32s3/t-deck-pro-v1_1/variant.h index 8584fd8a10f..af761d64dbb 100644 --- a/variants/esp32s3/t-deck-pro-v1_1/variant.h +++ b/variants/esp32s3/t-deck-pro-v1_1/variant.h @@ -18,7 +18,7 @@ #define USE_POWERSAVE #define SLEEP_TIME 120 -// GNNS +// GNSS #define HAS_GPS 1 #define GPS_BAUDRATE 38400 #define PIN_GPS_EN 39 From 5cae9e0183b1f84ad9502c28eb175219decee7b4 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 16 Apr 2026 07:48:35 -0500 Subject: [PATCH 003/225] Update src/platform/extra_variants/t_deck_pro/variant.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/platform/extra_variants/t_deck_pro/variant.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/extra_variants/t_deck_pro/variant.cpp b/src/platform/extra_variants/t_deck_pro/variant.cpp index 77fba53b9be..ff8e34ebd76 100644 --- a/src/platform/extra_variants/t_deck_pro/variant.cpp +++ b/src/platform/extra_variants/t_deck_pro/variant.cpp @@ -80,7 +80,7 @@ bool readTouch(int16_t *x, int16_t *y) } -static void touchInterruptHandler(){ +static void IRAM_ATTR touchInterruptHandler(){ touch_isr = true; } From 7d957f8c7ba87905e1a7c25b652809642155cf6c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 16 Apr 2026 07:48:50 -0500 Subject: [PATCH 004/225] Update variants/esp32s3/t-deck-pro-v1_1/platformio.ini Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- variants/esp32s3/t-deck-pro-v1_1/platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini index b765229340a..1a9b20f760d 100644 --- a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini +++ b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini @@ -30,7 +30,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.5 + zinggjm/GxEPD2@1.6.8 # renovate: datasource=git-refs depName=CSE_Touch packageName=https://github.com/CIRCUITSTATE/CSE_Touch gitBranch=main https://github.com/CIRCUITSTATE/CSE_Touch/archive/b44f23b6f870b848f1fbe453c190879bc6cfaafa.zip # renovate: datasource=github-tags depName=CSE_CST328 packageName=CIRCUITSTATE/CSE_CST328 From d5af07e4584cef54c28a5247830b426931b43dc8 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 16 Apr 2026 07:49:08 -0500 Subject: [PATCH 005/225] Update src/main.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.cpp b/src/main.cpp index 6b714f1cfe5..937df88cb5e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -374,8 +374,10 @@ void setup() digitalWrite(SDCARD_CS, HIGH); pinMode(PIN_EINK_CS, OUTPUT); digitalWrite(PIN_EINK_CS, HIGH); +#if PIN_EINK_RES >= 0 pinMode(PIN_EINK_RES, OUTPUT); digitalWrite(PIN_EINK_RES, HIGH); +#endif pinMode(CST328_PIN_RST, OUTPUT); digitalWrite(CST328_PIN_RST, HIGH); #elif defined(T_LORA_PAGER) From 79e7ed30f1f7b44da205bb1e37e5ff0202318a00 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 16 Apr 2026 07:49:34 -0500 Subject: [PATCH 006/225] Update src/graphics/EInkDisplay2.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/graphics/EInkDisplay2.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index 96321f9c4e3..28b956bb104 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -105,10 +105,12 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit) void EInkDisplay::endUpdate() { #ifndef EINK_NOT_HIBERNATE - // Power off display hardware, then deep-sleep (Except Wireless Paper V1.1, no deep-sleep) + // By default, power off the E-Ink display hardware and enter hibernate(). + // Boards/panels that define EINK_NOT_HIBERNATE intentionally skip this step. + // Skipping hibernate() can help avoid panel-specific wake/refresh or ghosting issues, + // but it typically trades lower power savings for that compatibility. adafruitDisplay->hibernate(); #endif - } // Write the buffer to the display memory From 2c8dec2fbdaea2860851841a0fbd38eb129da13c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:48:07 -0500 Subject: [PATCH 007/225] Update meshtastic/device-ui digest to 56e1da4 (#10195) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 3cd0cc9d05a..4d66cf53875 100644 --- a/platformio.ini +++ b/platformio.ini @@ -126,7 +126,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/5305670b68eb5b92d14e62b5b536969ca4bb441f.zip + https://github.com/meshtastic/device-ui/archive/56e1da4e7d30abcd746a2092a30e422f8cf5fc2b.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From aab4cd086f87606f1bb381fcb28dab8e6efba0e9 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:53:22 -0400 Subject: [PATCH 008/225] Compass improvements/refactoring (#10166) * Infinite calibration loop fix * Save calibration * Screen refresh * reduce repeated code * reduce repeated code to reduce flash * fix Waypoint compass size and no fix no heading labels * Don't show compass unless we have a heading and location * If no calculated heading from moving, we should have no heading * Slow walking calculated heading and auto stale heading when not moving * Triming flash space * cleanup * show "?" when no location or heading for distance and heading screen * cleanup * Stale heading logic * final trim * Compass Calibration screen redesign * Trunk Fix * Compile fix * patch * Update src/motion/MotionSensor.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update WaypointModule.cpp --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/graphics/Screen.cpp | 102 +++++++++- src/graphics/Screen.h | 10 +- src/graphics/draw/CompassRenderer.cpp | 76 ++++++-- src/graphics/draw/CompassRenderer.h | 5 +- src/graphics/draw/NodeListRenderer.cpp | 71 ++++--- src/graphics/draw/NodeListRenderer.h | 6 +- src/graphics/draw/UIRenderer.cpp | 237 ++++++++++++---------- src/modules/WaypointModule.cpp | 160 +++++++++------ src/motion/BMM150Sensor.cpp | 24 +-- src/motion/BMX160Sensor.cpp | 68 +------ src/motion/BMX160Sensor.h | 3 +- src/motion/ICM20948Sensor.cpp | 88 ++------- src/motion/ICM20948Sensor.h | 3 +- src/motion/MotionSensor.cpp | 259 +++++++++++++++++++++++-- src/motion/MotionSensor.h | 18 +- 15 files changed, 726 insertions(+), 404 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index fa9d98a0e65..0fc34ddb3b5 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -60,6 +60,7 @@ along with this program. If not, see . #include "main.h" #include "mesh-pb-constants.h" #include "mesh/Channels.h" +#include "mesh/Default.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" #include "modules/ExternalNotificationModule.h" #include "modules/TextMessageModule.h" @@ -98,6 +99,7 @@ namespace graphics // This means the *visible* area (sh1106 can address 132, but shows 128 for example) #define IDLE_FRAMERATE 1 // in fps +#define COMPASS_ACTIVE_FRAMERATE 20 // DEBUG #define NUM_EXTRA_FRAMES 3 // text message and debug frame @@ -135,6 +137,60 @@ static bool heartbeat = false; extern bool hasUnreadMessage; +static inline float wrapHeading360(float heading) +{ + if (heading < 0.0f) { + heading += 360.0f; + } else if (heading >= 360.0f) { + heading -= 360.0f; + } + return heading; +} + +void Screen::setHeading(float heading) +{ + const float wrappedHeading = wrapHeading360(heading); + + if (!hasCompass) { + hasCompass = true; + compassHeading = wrappedHeading; + return; + } + + // Interpolate using shortest-path angular delta to avoid jumps around 0/360. + float delta = wrappedHeading - compassHeading; + if (delta > 180.0f) { + delta -= 360.0f; + } else if (delta < -180.0f) { + delta += 360.0f; + } + + // Adaptive filtering: + // - Strong damping for tiny deltas (jitter) + // - Faster response for larger turns + const float absDelta = (delta >= 0.0f) ? delta : -delta; + if (absDelta < 1.0f) { + return; + } + + float alpha = 0.35f; + if (absDelta > 25.0f) { + alpha = 0.85f; + } else if (absDelta > 10.0f) { + alpha = 0.65f; + } + + float step = delta * alpha; + const float maxStep = 12.0f; + if (step > maxStep) { + step = maxStep; + } else if (step < -maxStep) { + step = -maxStep; + } + + compassHeading = wrapHeading360(compassHeading + step); +} + // ============================== // Overlay Alert Banner Renderer // ============================== @@ -272,10 +328,25 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int float Screen::estimatedHeading(double lat, double lon) { static double oldLat, oldLon; - static float b; + static float b = -1.0f; + static uint32_t lastHeadingAtMs = 0; + const uint32_t now = millis(); + const uint32_t gpsUpdateIntervalSecs = + Default::getConfiguredOrDefault(config.position.gps_update_interval, default_gps_update_interval); + uint32_t effectiveUpdateIntervalSecs = gpsUpdateIntervalSecs; + if (config.position.position_broadcast_smart_enabled) { + const uint32_t smartMinIntervalSecs = Default::getConfiguredOrDefault( + config.position.broadcast_smart_minimum_interval_secs, default_broadcast_smart_minimum_interval_secs); + if (smartMinIntervalSecs > effectiveUpdateIntervalSecs) { + effectiveUpdateIntervalSecs = smartMinIntervalSecs; + } + } + // Two expected update windows; keep arithmetic 32-bit to avoid pulling in larger 64-bit helpers. + const uint32_t headingStaleMs = + (effectiveUpdateIntervalSecs > (UINT32_MAX / 2000U)) ? UINT32_MAX : (effectiveUpdateIntervalSecs * 2000U); if (oldLat == 0) { - // just prepare for next time + // Need at least two position points before we can infer heading. oldLat = lat; oldLon = lon; @@ -283,12 +354,20 @@ float Screen::estimatedHeading(double lat, double lon) } float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon); - if (d < 10) // haven't moved enough, just keep current bearing + if (d < 10) { // haven't moved enough, keep previous heading (invalid until first real movement) + if (lastHeadingAtMs != 0 && (now - lastHeadingAtMs) >= headingStaleMs) { + // Heading is stale after prolonged no-movement; force reacquire. + b = -1.0f; + oldLat = lat; + oldLon = lon; + } return b; + } b = GeoCoord::bearing(oldLat, oldLon, lat, lon) * RAD_TO_DEG; oldLat = lat; oldLon = lon; + lastHeadingAtMs = now; return b; } @@ -923,9 +1002,22 @@ int32_t Screen::runOnce() // but we should only call setTargetFPS when framestate changes, because // otherwise that breaks animations. - if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) { + uint32_t desiredFramerate = IDLE_FRAMERATE; +#if HAS_GPS && !defined(USE_EINK) + if (showingNormalScreen && hasCompass) { + const uint8_t currentFrame = ui->getUiState()->currentFrame; + if ((framesetInfo.positions.gps != 255 && currentFrame == framesetInfo.positions.gps) || + (framesetInfo.positions.waypoint != 255 && currentFrame == framesetInfo.positions.waypoint) || + (framesetInfo.positions.firstFavorite != 255 && currentFrame >= framesetInfo.positions.firstFavorite && + currentFrame <= framesetInfo.positions.lastFavorite)) { + desiredFramerate = COMPASS_ACTIVE_FRAMERATE; + } + } +#endif + + if (targetFramerate != desiredFramerate && ui->getUiState()->frameState == FIXED) { // oldFrameState = ui->getUiState()->frameState; - targetFramerate = IDLE_FRAMERATE; + targetFramerate = desiredFramerate; ui->setTargetFPS(targetFramerate); forceDisplay(); diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index e259f7691e0..5a1a2d6da2c 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -330,15 +330,11 @@ class Screen : public concurrency::OSThread // Function to allow the AccelerometerThread to set the heading if a sensor provides it // Mutex needed? - void setHeading(long _heading) - { - hasCompass = true; - compassHeading = fmod(_heading, 360); - } + void setHeading(float heading); bool hasHeading() { return hasCompass; } - long getHeading() { return compassHeading; } + float getHeading() { return compassHeading; } void setEndCalibration(uint32_t _endCalibrationAt) { endCalibrationAt = _endCalibrationAt; } uint32_t getEndCalibration() { return endCalibrationAt; } @@ -782,4 +778,4 @@ extern std::vector functionSymbol; extern std::string functionSymbolString; extern graphics::Screen *screen; -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/CompassRenderer.cpp b/src/graphics/draw/CompassRenderer.cpp index 42600ce96e1..fe54d68e714 100644 --- a/src/graphics/draw/CompassRenderer.cpp +++ b/src/graphics/draw/CompassRenderer.cpp @@ -1,10 +1,6 @@ #include "configuration.h" #if HAS_SCREEN #include "CompassRenderer.h" -#include "NodeDB.h" -#include "UIRenderer.h" -#include "configuration.h" -#include "gps/GeoCoord.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include @@ -21,8 +17,8 @@ struct Point { void rotate(float angle) { - float cos_a = cos(angle); - float sin_a = sin(angle); + float cos_a = cosf(angle); + float sin_a = sinf(angle); float new_x = x * cos_a - y * sin_a; float new_y = x * sin_a + y * cos_a; x = new_x; @@ -51,21 +47,30 @@ void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, if (currentResolution == ScreenResolution::High) { radius += 4; } - Point north(0, -radius); - if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) - north.rotate(-myHeading); - north.translate(compassX, compassY); + float northX = 0.0f; + float northY = -radius; + if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) { + const float c = cosf(-myHeading); + const float s = sinf(-myHeading); + const float rx = northX * c - northY * s; + const float ry = northX * s + northY * c; + northX = rx; + northY = ry; + } + northX += compassX; + northY += compassY; display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_CENTER); display->setColor(BLACK); + const int16_t nLabelWidth = display->getStringWidth("N"); if (currentResolution == ScreenResolution::High) { - display->fillRect(north.x - 8, north.y - 1, display->getStringWidth("N") + 3, FONT_HEIGHT_SMALL - 6); + display->fillRect(northX - 8, northY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6); } else { - display->fillRect(north.x - 4, north.y - 1, display->getStringWidth("N") + 2, FONT_HEIGHT_SMALL - 6); + display->fillRect(northX - 4, northY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6); } display->setColor(WHITE); - display->drawString(north.x, north.y - 3, "N"); + display->drawString(northX, northY - 3, "N"); } void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian) @@ -113,11 +118,46 @@ void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, f display->fillTriangle(tip.x, tip.y, right.x, right.y, tail.x, tail.y); } -float estimatedHeading(double lat, double lon) +bool getHeadingRadians(double lat, double lon, float &headingRadian) +{ + headingRadian = 0.0f; + + if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING) + return true; + + if (!screen) + return false; + + if (screen->hasHeading()) { + headingRadian = screen->getHeading() * DEG_TO_RAD; + return true; + } + + const float estimatedHeadingDeg = screen->estimatedHeading(lat, lon); + if (!(estimatedHeadingDeg >= 0.0f)) + return false; + + headingRadian = estimatedHeadingDeg * DEG_TO_RAD; + return true; +} + +float adjustBearingForCompassMode(float bearingRadian, float headingRadian) { - // Simple magnetic declination estimation - // This is a very basic implementation - the original might be more sophisticated - return 0.0f; // Return 0 for now, indicating no heading available + if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) + return bearingRadian - headingRadian; + + return bearingRadian; +} + +float radiansToDegrees360(float angleRadian) +{ + constexpr float fullTurnDeg = 360.0f; + float degrees = angleRadian * RAD_TO_DEG; + if (degrees < 0.0f) + degrees += fullTurnDeg; + else if (degrees >= fullTurnDeg) + degrees -= fullTurnDeg; + return degrees; } uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight) @@ -137,4 +177,4 @@ uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight) } // namespace CompassRenderer } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/CompassRenderer.h b/src/graphics/draw/CompassRenderer.h index ca7532b6671..d7762384769 100644 --- a/src/graphics/draw/CompassRenderer.h +++ b/src/graphics/draw/CompassRenderer.h @@ -1,7 +1,6 @@ #pragma once #include "graphics/Screen.h" -#include "mesh/generated/meshtastic/mesh.pb.h" #include #include @@ -25,7 +24,9 @@ void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, u void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing); // Navigation and location functions -float estimatedHeading(double lat, double lon); +bool getHeadingRadians(double lat, double lon, float &headingRadian); +float adjustBearingForCompassMode(float bearingRadian, float headingRadian); +float radiansToDegrees360(float angleRadian); uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight); } // namespace CompassRenderer diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 654c272229d..e0c5df1249f 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -409,14 +409,13 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 } } - if (strlen(distStr) > 0) { - int offset = (currentResolution == ScreenResolution::High) - ? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column) - : (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column) - int rightEdge = x + columnWidth - offset; - int textWidth = display->getStringWidth(distStr); - display->drawString(rightEdge - textWidth, y, distStr); - } + const char *distanceLabel = (strlen(distStr) > 0) ? distStr : "?"; + int offset = (currentResolution == ScreenResolution::High) + ? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column) + : (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column) + int rightEdge = x + columnWidth - offset; + int textWidth = display->getStringWidth(distanceLabel); + display->drawString(rightEdge - textWidth, y, distanceLabel); } void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) @@ -467,8 +466,8 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 } } -void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading, - double userLat, double userLon) +void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, + float myHeadingRadian, double userLat, double userLon) { if (!nodeDB->hasValidPosition(node)) return; @@ -482,11 +481,11 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 double nodeLat = node->position.latitude_i * 1e-7; double nodeLon = node->position.longitude_i * 1e-7; float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon); - float bearingToNode = RAD_TO_DEG * bearing; - float relativeBearing = fmod((bearingToNode - myHeading + 360), 360); + float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian); + float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing); // Shrink size by 2px int size = FONT_HEIGHT_SMALL - 5; - CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearing); + CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg); /* float angle = relativeBearing * DEG_TO_RAD; float halfSize = size / 2.0; @@ -516,12 +515,27 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 */ } +void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float, double, + double) +{ + if (!nodeDB->hasValidPosition(node)) + return; + + bool isLeftCol = (x < SCREEN_WIDTH / 2); + int arrowXOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18); + int centerX = x + columnWidth - arrowXOffset; + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(centerX, y, "?"); +} + // ============================= // Main Screen Functions // ============================= void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, - EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon) + EntryRenderer renderer, NodeExtrasRenderer extras, float headingRadian, double lat, double lon) { const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1; const int rowYOffset = FONT_HEIGHT_SMALL - 3; @@ -606,7 +620,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t renderer(display, node, xPos, yPos, columnWidth); if (extras) - extras(display, node, xPos, yPos, columnWidth, heading, lat, lon); + extras(display, node, xPos, yPos, columnWidth, headingRadian, lat, lon); lastNodeY = max(lastNodeY, yPos + FONT_HEIGHT_SMALL); yOffset += rowYOffset; @@ -801,9 +815,13 @@ void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t #endif void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { - float heading = 0; - bool validHeading = false; + float headingRadian = 0.0f; auto ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + if (!ourNode || !nodeDB->hasValidPosition(ourNode)) { + drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, 0.0, 0.0); + return; + } + double lat = DegD(ourNode->position.latitude_i); double lon = DegD(ourNode->position.longitude_i); @@ -815,21 +833,12 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, lastSwitchTime = now; } #endif - if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) { -#if HAS_GPS - if (screen->hasHeading()) { - heading = screen->getHeading(); // degrees - validHeading = true; - } else { - heading = screen->estimatedHeading(lat, lon); - validHeading = !isnan(heading); - } -#endif - - if (!validHeading) - return; + if (!CompassRenderer::getHeadingRadians(lat, lon, headingRadian)) { + drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, lat, lon); + return; } - drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon); + + drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, headingRadian, lat, lon); } /// Draw a series of fields in a column, wrapping to multiple columns if needed diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h index be80a7d80bc..4aa21714111 100644 --- a/src/graphics/draw/NodeListRenderer.h +++ b/src/graphics/draw/NodeListRenderer.h @@ -32,7 +32,7 @@ enum ListMode_Location { MODE_DISTANCE = 0, MODE_BEARING = 1, MODE_COUNT_LOCATIO // Main node list screen function void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, - EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0, + EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float headingRadian = 0, double lat = 0, double lon = 0); // Entry renderers @@ -43,8 +43,8 @@ void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); // Extras renderers -void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading, - double userLat, double userLon); +void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, + float myHeadingRadian, double userLat, double userLon); // Screen frame functions void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 78d10988157..b94c25a277e 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -41,6 +41,15 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y) } } +static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_t compassY, const char *statusLine1, + const char *statusLine2) +{ + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(compassX, compassY - FONT_HEIGHT_SMALL, statusLine1); + display->drawString(compassX, compassY, statusLine2); + display->setTextAlignment(TEXT_ALIGN_LEFT); +} + void graphics::UIRenderer::rebuildFavoritedNodes() { favoritedNodes.clear(); @@ -692,51 +701,54 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat display->drawString(x, getTextPositions(display)[line++], batLine); } + bool showCompass = false; + float myHeading = 0.0f; + float bearing = 0.0f; + const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode)); + const bool hasNodePositionFix = nodeDB->hasValidPosition(node); + const char *statusLine1 = nullptr; + const char *statusLine2 = nullptr; + if (hasOwnPositionFix && hasNodePositionFix) { + const auto &op = ourNode->position; + showCompass = CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading); + if (showCompass) { + const auto &p = node->position; + bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i)); + bearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeading); + } else { + statusLine1 = "No"; + statusLine2 = "Heading"; + } + } else if (!hasOwnPositionFix || !hasNodePositionFix) { + statusLine1 = "No"; + statusLine2 = "Fix"; + } + // --- Compass Rendering: landscape (wide) screens use the original side-aligned logic --- if (SCREEN_WIDTH > SCREEN_HEIGHT) { - bool showCompass = false; - if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) { - showCompass = true; - } - if (showCompass) { + if (showCompass || statusLine1) { const int16_t topY = getTextPositions(display)[1]; const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); const int16_t usableHeight = bottomY - topY - 5; int16_t compassRadius = usableHeight / 2; if (compassRadius < 8) compassRadius = 8; - const int16_t compassDiam = compassRadius * 2; const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8; const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; + const int16_t compassDiam = compassRadius * 2; - const auto &op = ourNode->position; - float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180 - : screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i)); - - const auto &p = node->position; - /* unused - float d = - GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); - */ - float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i)); - if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING) { - myHeading = 0; + display->drawCircle(compassX, compassY, compassRadius); + if (showCompass) { + CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); + CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing); } else { - bearing -= myHeading; + drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); } - - display->drawCircle(compassX, compassY, compassRadius); - CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); - CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing); } // else show nothing } else { // Portrait or square: put compass at the bottom and centered, scaled to fit available space - bool showCompass = false; - if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) { - showCompass = true; - } - if (showCompass) { + if (showCompass || statusLine1) { int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2) : getTextPositions(display)[1]; const int margin = 4; @@ -747,8 +759,8 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat #else const int navBarHeight = 0; #endif - int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin; // --------- END PATCH FOR EINK NAV BAR ----------- + int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin; if (availableHeight < FONT_HEIGHT_SMALL * 2) return; @@ -762,25 +774,13 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat int compassX = x + SCREEN_WIDTH / 2; int compassY = yBelowContent + availableHeight / 2; - const auto &op = ourNode->position; - float myHeading = 0; - if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) { - myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180 - : screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i)); - } - graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); - - const auto &p = node->position; - /* unused - float d = - GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); - */ - float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i)); - if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) - bearing -= myHeading; - graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing); - display->drawCircle(compassX, compassY, compassRadius); + if (showCompass) { + graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); + graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing); + } else { + drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); + } } // else show nothing } @@ -1216,6 +1216,7 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU // === Header === graphics::drawCommonHeader(display, x, y, titleStr); + const int *textPos = getTextPositions(display); // === First Row: My Location === #if HAS_GPS @@ -1230,12 +1231,12 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU } else { displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; } - drawSatelliteIcon(display, x, getTextPositions(display)[line]); + drawSatelliteIcon(display, x, textPos[line]); int xOffset = (currentResolution == ScreenResolution::High) ? 6 : 0; - display->drawString(x + 11 + xOffset, getTextPositions(display)[line++], displayLine); + display->drawString(x + 11 + xOffset, textPos[line++], displayLine); } else { // Onboard GPS - UIRenderer::drawGps(display, 0, getTextPositions(display)[line++], gpsStatus); + UIRenderer::drawGps(display, 0, textPos[line++], gpsStatus); } config.display.heading_bold = origBold; @@ -1244,18 +1245,36 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()), int32_t(gpsStatus->getAltitude())); - // === Determine Compass Heading === - float heading = 0; + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode)); + const bool hasLiveGpsFix = + (gpsStatus && gpsStatus->getHasLock() && (gpsStatus->getLatitude() != 0 || gpsStatus->getLongitude() != 0)); + const bool hasSensorHeading = screen->hasHeading(); + float heading = 0.0f; bool validHeading = false; - if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING) { - validHeading = true; - } else { - if (screen->hasHeading()) { - heading = radians(screen->getHeading()); - validHeading = true; + const char *statusLine1 = nullptr; + const char *statusLine2 = nullptr; + if (hasSensorHeading || hasLiveGpsFix || hasOwnPositionFix) { + double headingLat = 0.0; + double headingLon = 0.0; + if (hasLiveGpsFix) { + headingLat = DegD(gpsStatus->getLatitude()); + headingLon = DegD(gpsStatus->getLongitude()); + } else if (hasOwnPositionFix) { + const auto &op = ourNode->position; + headingLat = DegD(op.latitude_i); + headingLon = DegD(op.longitude_i); + } + validHeading = CompassRenderer::getHeadingRadians(headingLat, headingLon, heading); + } + + if (!validHeading) { + if (hasSensorHeading || hasLiveGpsFix || hasOwnPositionFix) { + statusLine1 = "No"; + statusLine2 = "Heading"; } else { - heading = screen->estimatedHeading(geoCoord.getLatitude() * 1e-7, geoCoord.getLongitude() * 1e-7); - validHeading = !isnan(heading); + statusLine1 = "No"; + statusLine2 = "Fix"; } } @@ -1273,18 +1292,18 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), true); #endif - display->drawString(0, getTextPositions(display)[line++], uptimeStr); + display->drawString(0, textPos[line++], uptimeStr); } else { - display->drawString(0, getTextPositions(display)[line++], "Last: ?"); + display->drawString(0, textPos[line++], "Last: ?"); } // === Third Row: Line 1 GPS Info === - UIRenderer::drawGpsCoordinates(display, x, getTextPositions(display)[line++], gpsStatus, "line1"); + UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line1"); if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC && uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) { // === Fourth Row: Line 2 GPS Info === - UIRenderer::drawGpsCoordinates(display, x, getTextPositions(display)[line++], gpsStatus, "line2"); + UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line2"); } // === Final Row: Altitude === @@ -1295,14 +1314,14 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU } else { snprintf(altitudeLine, sizeof(altitudeLine), "Alt: %.0im", alt); } - display->drawString(x, getTextPositions(display)[line++], altitudeLine); + display->drawString(x, textPos[line++], altitudeLine); } #if !defined(M5STACK_UNITC6L) - // === Draw Compass if heading is valid === - if (validHeading) { + // === Draw Compass === + if (validHeading || statusLine1) { // --- Compass Rendering: landscape (wide) screens use original side-aligned logic --- if (SCREEN_WIDTH > SCREEN_HEIGHT) { - const int16_t topY = getTextPositions(display)[1]; + const int16_t topY = textPos[1]; const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); // nav row height const int16_t usableHeight = bottomY - topY - 5; @@ -1315,29 +1334,33 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU // Center vertically and nudge down slightly to keep "N" clear of header const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; - CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading); display->drawCircle(compassX, compassY, compassRadius); - - // "N" label - float northAngle = 0; - if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) - northAngle = -heading; - float radius = compassRadius; - int16_t nX = compassX + (radius - 1) * sin(northAngle); - int16_t nY = compassY - (radius - 1) * cos(northAngle); - int16_t nLabelWidth = display->getStringWidth("N") + 2; - int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; - - display->setColor(BLACK); - display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); - display->setColor(WHITE); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); + if (validHeading) { + CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading); + + // "N" label + float northAngle = 0; + if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) + northAngle = -heading; + float radius = compassRadius; + int16_t nX = compassX + (radius - 1) * sin(northAngle); + int16_t nY = compassY - (radius - 1) * cos(northAngle); + int16_t nLabelWidth = display->getStringWidth("N") + 2; + int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; + + display->setColor(BLACK); + display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); + display->setColor(WHITE); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); + } else { + drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); + } } else { // Portrait or square: put compass at the bottom and centered, scaled to fit available space // For E-Ink screens, account for navigation bar at the bottom! - int yBelowContent = getTextPositions(display)[5] + FONT_HEIGHT_SMALL + 2; + int yBelowContent = textPos[5] + FONT_HEIGHT_SMALL + 2; const int margin = 4; int availableHeight = #if defined(USE_EINK) @@ -1358,25 +1381,29 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU int compassX = x + SCREEN_WIDTH / 2; int compassY = yBelowContent + availableHeight / 2; - CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading); display->drawCircle(compassX, compassY, compassRadius); - - // "N" label - float northAngle = 0; - if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) - northAngle = -heading; - float radius = compassRadius; - int16_t nX = compassX + (radius - 1) * sin(northAngle); - int16_t nY = compassY - (radius - 1) * cos(northAngle); - int16_t nLabelWidth = display->getStringWidth("N") + 2; - int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; - - display->setColor(BLACK); - display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); - display->setColor(WHITE); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); + if (validHeading) { + CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading); + + // "N" label + float northAngle = 0; + if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) + northAngle = -heading; + float radius = compassRadius; + int16_t nX = compassX + (radius - 1) * sin(northAngle); + int16_t nY = compassY - (radius - 1) * cos(northAngle); + int16_t nLabelWidth = display->getStringWidth("N") + 2; + int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; + + display->setColor(BLACK); + display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); + display->setColor(WHITE); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); + } else { + drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); + } } } #endif diff --git a/src/modules/WaypointModule.cpp b/src/modules/WaypointModule.cpp index 4db80ba183c..632727b9240 100644 --- a/src/modules/WaypointModule.cpp +++ b/src/modules/WaypointModule.cpp @@ -15,15 +15,6 @@ WaypointModule *waypointModule; -static inline float degToRad(float deg) -{ - return deg * PI / 180.0f; -} -static inline float radToDeg(float rad) -{ - return rad * 180.0f / PI; -} - ProcessMessage WaypointModule::handleReceived(const meshtastic_MeshPacket &mp) { #if defined(DEBUG_PORT) && !defined(DEBUG_MUTE) @@ -91,9 +82,7 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, // === Header === graphics::drawCommonHeader(display, x, y, titleStr); - - const int w = display->getWidth(); - const int h = display->getHeight(); + const int *textPos = graphics::getTextPositions(display); // Decode the waypoint const meshtastic_MeshPacket &mp = devicestate.rx_waypoint; @@ -108,71 +97,118 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, getTimeAgoStr(sinceReceived(&mp), lastStr, sizeof(lastStr)); // Will contain distance information, passed as a field to drawColumns - char distStr[20]; + char distStr[20] = ""; // Get our node, to use our own position meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - // Dimensions / co-ordinates for the compass/circle - const uint16_t compassDiam = graphics::CompassRenderer::getCompassDiam(w, h); - const int16_t compassX = x + w - (compassDiam / 2) - 5; - const int16_t compassY = (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) - ? y + h / 2 - : y + FONT_HEIGHT_SMALL + (h - FONT_HEIGHT_SMALL) / 2; + // Match compass sizing/placement to favorite node screen logic. + const int w = display->getWidth(); + int16_t compassRadius = 8; + int16_t compassX = x + w - compassRadius - 8; + int16_t compassY = y + display->getHeight() / 2; + + if (SCREEN_WIDTH > SCREEN_HEIGHT) { + const int16_t topY = textPos[1]; + const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); + const int16_t usableHeight = bottomY - topY - 5; + compassRadius = usableHeight / 2; + if (compassRadius < 8) + compassRadius = 8; + compassX = x + SCREEN_WIDTH - compassRadius - 8; + compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; + } else { + // Waypoint content uses rows 1..4, so place the compass below that block. + const int yBelowContent = textPos[4] + FONT_HEIGHT_SMALL + 2; + const int margin = 4; +#if defined(USE_EINK) + const int iconSize = (graphics::currentResolution == graphics::ScreenResolution::High) ? 16 : 8; + const int navBarHeight = iconSize + 6; +#else + const int navBarHeight = 0; +#endif + const int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin; + if (availableHeight > 0) { + compassRadius = availableHeight / 2; + if (compassRadius < 8) + compassRadius = 8; + if (compassRadius * 2 > SCREEN_WIDTH - 16) + compassRadius = (SCREEN_WIDTH - 16) / 2; + if (compassRadius < 8) + compassRadius = 8; + compassX = x + SCREEN_WIDTH / 2; + compassY = yBelowContent + availableHeight / 2; + } + } + const uint16_t compassDiam = compassRadius * 2; + + const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode)); + const char *statusLine1 = nullptr; + const char *statusLine2 = nullptr; - // If our node has a position: - if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading())) { + // Distance only needs our own position fix; compass/bearing additionally needs heading. + if (hasOwnPositionFix) { const meshtastic_PositionLite &op = ourNode->position; - float myHeading; - if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING) { - myHeading = 0; - } else { - if (screen->hasHeading()) - myHeading = degToRad(screen->getHeading()); - else - myHeading = screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i)); - } - graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, (compassDiam / 2)); - - // Compass bearing to waypoint - float bearingToOther = - GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(wp.latitude_i), DegD(wp.longitude_i)); - // If the top of the compass is a static north then bearingToOther can be drawn on the compass directly - // If the top of the compass is not a static north we need adjust bearingToOther based on heading - if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) - bearingToOther -= myHeading; - graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); - - float bearingToOtherDegrees = (bearingToOther < 0) ? bearingToOther + 2 * PI : bearingToOther; - bearingToOtherDegrees = radToDeg(bearingToOtherDegrees); - - // Distance to Waypoint - float d = GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); + const float d = + GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); + + // Always show distance once we have an own-position fix, even without heading. if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { float feet = d * METERS_TO_FEET; - snprintf(distStr, sizeof(distStr), feet < (2 * MILES_TO_FEET) ? "%.0fft %.0f°" : "%.1fmi %.0f°", - feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET, bearingToOtherDegrees); + snprintf(distStr, sizeof(distStr), feet < (2 * MILES_TO_FEET) ? "%.0fft" : "%.1fmi", + feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET); } else { - snprintf(distStr, sizeof(distStr), d < 2000 ? "%.0fm %.0f°" : "%.1fkm %.0f°", d < 2000 ? d : d / 1000, - bearingToOtherDegrees); + snprintf(distStr, sizeof(distStr), d < 2000 ? "%.0fm" : "%.1fkm", d < 2000 ? d : d / 1000); } - } - else { - display->drawString(compassX - FONT_HEIGHT_SMALL / 4, compassY - FONT_HEIGHT_SMALL / 2, "?"); + float myHeading = 0.0f; + const bool hasHeading = + graphics::CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading); + if (hasHeading) { + // Draw compass circle + display->drawCircle(compassX, compassY, compassRadius); + graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); + + // Compass bearing to waypoint + float bearingToOther = + GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(wp.latitude_i), DegD(wp.longitude_i)); + bearingToOther = graphics::CompassRenderer::adjustBearingForCompassMode(bearingToOther, myHeading); + graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); + + const float bearingToOtherDegrees = graphics::CompassRenderer::radiansToDegrees360(bearingToOther); + + // Distance to waypoint with relative bearing when heading is available. + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { + float feet = d * METERS_TO_FEET; + snprintf(distStr, sizeof(distStr), feet < (2 * MILES_TO_FEET) ? "%.0fft %.0f°" : "%.1fmi %.0f°", + feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET, bearingToOtherDegrees); + } else { + snprintf(distStr, sizeof(distStr), d < 2000 ? "%.0fm %.0f°" : "%.1fkm %.0f°", d < 2000 ? d : d / 1000, + bearingToOtherDegrees); + } - // ? in the distance field - snprintf(distStr, sizeof(distStr), "? %s ?°", - (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? "mi" : "km"); + } else { + statusLine1 = "No"; + statusLine2 = "Heading"; + } + } else { + // No own fix yet, so compass/bearing data would be misleading. + statusLine1 = "No"; + statusLine2 = "Fix"; } - // Draw compass circle - display->drawCircle(compassX, compassY, compassDiam / 2); + if (statusLine1) { + display->drawCircle(compassX, compassY, compassRadius); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(compassX, compassY - FONT_HEIGHT_SMALL, statusLine1); + display->drawString(compassX, compassY, statusLine2); + } display->setTextAlignment(TEXT_ALIGN_LEFT); // Something above me changes to a different alignment, forcing a fix here! - display->drawString(0, graphics::getTextPositions(display)[line++], lastStr); - display->drawString(0, graphics::getTextPositions(display)[line++], wp.name); - display->drawString(0, graphics::getTextPositions(display)[line++], wp.description); - display->drawString(0, graphics::getTextPositions(display)[line++], distStr); + display->drawString(0, textPos[line++], lastStr); + display->drawString(0, textPos[line++], wp.name); + display->drawString(0, textPos[line++], wp.description); + if (distStr[0]) + display->drawString(0, textPos[line++], distStr); } #endif diff --git a/src/motion/BMM150Sensor.cpp b/src/motion/BMM150Sensor.cpp index 4b3a1215c13..f48d20288b1 100644 --- a/src/motion/BMM150Sensor.cpp +++ b/src/motion/BMM150Sensor.cpp @@ -7,9 +7,6 @@ extern graphics::Screen *screen; #endif -// Flag when an interrupt has been detected -volatile static bool BMM150_IRQ = false; - BMM150Sensor::BMM150Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} bool BMM150Sensor::init() @@ -23,24 +20,7 @@ int32_t BMM150Sensor::runOnce() { #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN float heading = sensor->getCompassDegree(); - - switch (config.display.compass_orientation) { - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_0_INVERTED: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_0: - break; - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_90: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_90_INVERTED: - heading += 90; - break; - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_180: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_180_INVERTED: - heading += 180; - break; - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED: - heading += 270; - break; - } + heading = applyCompassOrientation(heading); if (screen) screen->setHeading(heading); #endif @@ -90,4 +70,4 @@ bool BMM150Singleton::init(ScanI2C::FoundDevice device) return true; } -#endif \ No newline at end of file +#endif diff --git a/src/motion/BMX160Sensor.cpp b/src/motion/BMX160Sensor.cpp index 5888c20bec1..02303faa4ff 100644 --- a/src/motion/BMX160Sensor.cpp +++ b/src/motion/BMX160Sensor.cpp @@ -16,6 +16,7 @@ bool BMX160Sensor::init() if (sensor.begin()) { // set output data rate sensor.ODR_Config(BMX160_ACCEL_ODR_100HZ, BMX160_GYRO_ODR_100HZ); + loadMagnetometerCalibration(compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); LOG_DEBUG("BMX160 init ok"); return true; } @@ -33,42 +34,12 @@ int32_t BMX160Sensor::runOnce() sensor.getAllData(&magAccel, NULL, &gAccel); if (doCalibration) { - - if (!showingScreen) { - powerFSM.trigger(EVENT_PRESS); // keep screen alive during calibration - showingScreen = true; - if (screen) - screen->startAlert((FrameCallback)drawFrameCalibration); - } - - if (magAccel.x > highestX) - highestX = magAccel.x; - if (magAccel.x < lowestX) - lowestX = magAccel.x; - if (magAccel.y > highestY) - highestY = magAccel.y; - if (magAccel.y < lowestY) - lowestY = magAccel.y; - if (magAccel.z > highestZ) - highestZ = magAccel.z; - if (magAccel.z < lowestZ) - lowestZ = magAccel.z; - - uint32_t now = millis(); - if (now > endCalibrationAt) { - doCalibration = false; - endCalibrationAt = 0; - showingScreen = false; - if (screen) - screen->endAlert(); - } - - // LOG_DEBUG("BMX160 min_x: %.4f, max_X: %.4f, min_Y: %.4f, max_Y: %.4f, min_Z: %.4f, max_Z: %.4f", lowestX, highestX, - // lowestY, highestY, lowestZ, highestZ); + beginCalibrationDisplay(showingScreen); + updateCalibrationExtrema(magAccel.x, magAccel.y, magAccel.z, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + finishCalibrationIfExpired(showingScreen, compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ, + lowestZ); } - int highestRealX = highestX - (highestX + lowestX) / 2; - magAccel.x -= (highestX + lowestX) / 2; magAccel.y -= (highestY + lowestY) / 2; magAccel.z -= (highestZ + lowestZ) / 2; @@ -88,23 +59,7 @@ int32_t BMX160Sensor::runOnce() float heading = FusionCompassCalculateHeading(FusionConventionNed, ga, ma); - switch (config.display.compass_orientation) { - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_0_INVERTED: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_0: - break; - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_90: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_90_INVERTED: - heading += 90; - break; - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_180: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_180_INVERTED: - heading += 180; - break; - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED: - heading += 270; - break; - } + heading = applyCompassOrientation(heading); if (screen) screen->setHeading(heading); #endif @@ -119,15 +74,8 @@ void BMX160Sensor::calibrate(uint16_t forSeconds) sBmx160SensorData_t gAccel; LOG_DEBUG("BMX160 calibration started for %is", forSeconds); sensor.getAllData(&magAccel, NULL, &gAccel); - highestX = magAccel.x, lowestX = magAccel.x; - highestY = magAccel.y, lowestY = magAccel.y; - highestZ = magAccel.z, lowestZ = magAccel.z; - - doCalibration = true; - uint16_t calibrateFor = forSeconds * 1000; // calibrate for seconds provided - endCalibrationAt = millis() + calibrateFor; - if (screen) - screen->setEndCalibration(endCalibrationAt); + seedCalibrationExtrema(magAccel.x, magAccel.y, magAccel.z, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + startCalibrationWindow(forSeconds); #endif } diff --git a/src/motion/BMX160Sensor.h b/src/motion/BMX160Sensor.h index ddca5767c74..d60477521c9 100644 --- a/src/motion/BMX160Sensor.h +++ b/src/motion/BMX160Sensor.h @@ -17,6 +17,7 @@ class BMX160Sensor : public MotionSensor private: RAK_BMX160 sensor; bool showingScreen = false; + static constexpr const char *compassCalibrationFileName = "/prefs/compass_bmx160.dat"; float highestX = 0, lowestX = 0, highestY = 0, lowestY = 0, highestZ = 0, lowestZ = 0; public: @@ -39,4 +40,4 @@ class BMX160Sensor : public MotionSensor #endif -#endif \ No newline at end of file +#endif diff --git a/src/motion/ICM20948Sensor.cpp b/src/motion/ICM20948Sensor.cpp index ecada208575..e44994a6006 100644 --- a/src/motion/ICM20948Sensor.cpp +++ b/src/motion/ICM20948Sensor.cpp @@ -26,7 +26,11 @@ bool ICM20948Sensor::init() return false; // Enable simple Wake on Motion - return sensor->setWakeOnMotion(); + bool wakeOnMotionOk = sensor->setWakeOnMotion(); + if (wakeOnMotionOk) { + loadMagnetometerCalibration(compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + } + return wakeOnMotionOk; } #ifdef ICM_20948_INT_PIN @@ -47,7 +51,8 @@ int32_t ICM20948Sensor::runOnce() int32_t ICM20948Sensor::runOnce() { #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN - if (screen && !screen->isScreenOn() && !config.display.wake_on_tap_or_motion && !config.device.double_tap_as_button_press) { + if (screen && !doCalibration && !screen->isScreenOn() && !config.display.wake_on_tap_or_motion && + !config.device.double_tap_as_button_press) { if (!isAsleep) { LOG_DEBUG("sleeping IMU"); sensor->sleep(true); @@ -69,38 +74,10 @@ int32_t ICM20948Sensor::runOnce() } if (doCalibration) { - - if (!showingScreen) { - powerFSM.trigger(EVENT_PRESS); // keep screen alive during calibration - showingScreen = true; - if (screen) - screen->startAlert((FrameCallback)drawFrameCalibration); - } - - if (magX > highestX) - highestX = magX; - if (magX < lowestX) - lowestX = magX; - if (magY > highestY) - highestY = magY; - if (magY < lowestY) - lowestY = magY; - if (magZ > highestZ) - highestZ = magZ; - if (magZ < lowestZ) - lowestZ = magZ; - - uint32_t now = millis(); - if (now > endCalibrationAt) { - doCalibration = false; - endCalibrationAt = 0; - showingScreen = false; - if (screen) - screen->endAlert(); - } - - // LOG_DEBUG("ICM20948 min_x: %.4f, max_X: %.4f, min_Y: %.4f, max_Y: %.4f, min_Z: %.4f, max_Z: %.4f", lowestX, highestX, - // lowestY, highestY, lowestZ, highestZ); + beginCalibrationDisplay(showingScreen); + updateCalibrationExtrema(magX, magY, magZ, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + finishCalibrationIfExpired(showingScreen, compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ, + lowestZ); } magX -= (highestX + lowestX) / 2; @@ -122,23 +99,7 @@ int32_t ICM20948Sensor::runOnce() float heading = FusionCompassCalculateHeading(FusionConventionNed, ga, ma); - switch (config.display.compass_orientation) { - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_0_INVERTED: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_0: - break; - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_90: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_90_INVERTED: - heading += 90; - break; - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_180: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_180_INVERTED: - heading += 180; - break; - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270: - case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED: - heading += 270; - break; - } + heading = applyCompassOrientation(heading); if (screen) screen->setHeading(heading); #endif @@ -169,26 +130,16 @@ int32_t ICM20948Sensor::runOnce() void ICM20948Sensor::calibrate(uint16_t forSeconds) { #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN - LOG_DEBUG("Old calibration data: highestX = %f, lowestX = %f, highestY = %f, lowestY = %f, highestZ = %f, lowestZ = %f", - highestX, lowestX, highestY, lowestY, highestZ, lowestZ); - LOG_DEBUG("BMX160 calibration started for %is", forSeconds); + LOG_DEBUG("ICM20948 cal start %is", forSeconds); if (sensor->dataReady()) { sensor->getAGMT(); - highestX = sensor->agmt.mag.axes.x; - lowestX = sensor->agmt.mag.axes.x; - highestY = sensor->agmt.mag.axes.y; - lowestY = sensor->agmt.mag.axes.y; - highestZ = sensor->agmt.mag.axes.z; - lowestZ = sensor->agmt.mag.axes.z; + seedCalibrationExtrema(sensor->agmt.mag.axes.x, sensor->agmt.mag.axes.y, sensor->agmt.mag.axes.z, highestX, lowestX, + highestY, lowestY, highestZ, lowestZ); } else { - highestX = 0, lowestX = 0, highestY = 0, lowestY = 0, highestZ = 0, lowestZ = 0; + seedCalibrationExtrema(0.0f, 0.0f, 0.0f, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); } - doCalibration = true; - uint16_t calibrateFor = forSeconds * 1000; // calibrate for seconds provided - endCalibrationAt = millis() + calibrateFor; - if (screen) - screen->setEndCalibration(endCalibrationAt); + startCalibrationWindow(forSeconds); #endif } // ---------------------------------------------------------------------- @@ -314,11 +265,6 @@ bool ICM20948Singleton::setWakeOnMotion() status = intEnableWOM(true); LOG_DEBUG("ICM20948 init set intEnableWOM - %s", statusString()); return status == ICM_20948_Stat_Ok; - - // Clear any current interrupts - ICM20948_IRQ = false; - clearInterrupts(); - return true; } #endif diff --git a/src/motion/ICM20948Sensor.h b/src/motion/ICM20948Sensor.h index 091cb9a1e95..d8369b3ca16 100644 --- a/src/motion/ICM20948Sensor.h +++ b/src/motion/ICM20948Sensor.h @@ -83,6 +83,7 @@ class ICM20948Sensor : public MotionSensor ICM20948Singleton *sensor = nullptr; bool showingScreen = false; bool isAsleep = false; + static constexpr const char *compassCalibrationFileName = "/prefs/compass_icm20948.dat"; #ifdef MUZI_BASE float highestX = 449.000000, lowestX = -140.000000, highestY = 422.000000, lowestY = -232.000000, highestZ = 749.000000, lowestZ = 98.000000; @@ -103,4 +104,4 @@ class ICM20948Sensor : public MotionSensor #endif -#endif \ No newline at end of file +#endif diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp index d0bfe4e2ce3..83231aea90c 100644 --- a/src/motion/MotionSensor.cpp +++ b/src/motion/MotionSensor.cpp @@ -1,10 +1,37 @@ #include "MotionSensor.h" +#include "FSCommon.h" +#include "SPILock.h" +#include "SafeFile.h" #include "graphics/draw/CompassRenderer.h" #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C char timeRemainingBuffer[12]; +namespace +{ +constexpr uint32_t COMPASS_CALIBRATION_MAGIC = 0x4D43414CL; // "MCAL" +constexpr uint16_t COMPASS_CALIBRATION_VERSION = 1; + +struct CompassCalibrationRecord { + uint32_t magic; + uint16_t version; + uint16_t reserved; + float highestX; + float lowestX; + float highestY; + float lowestY; + float highestZ; + float lowestZ; +}; + +bool isRangeValid(float highest, float lowest) +{ + // NaN/Inf guard without pulling in extra math helpers. + return (highest == highest) && (lowest == lowest) && (highest > lowest); +} +} // namespace + // screen is defined in main.cpp extern graphics::Screen *screen; @@ -32,33 +59,237 @@ ScanI2C::I2CPort MotionSensor::devicePort() return device.address.port; } +bool MotionSensor::saveMagnetometerCalibration(const char *filePath, float highestX, float lowestX, float highestY, float lowestY, + float highestZ, float lowestZ) +{ +#ifdef FSCom + if (!isRangeValid(highestX, lowestX) || !isRangeValid(highestY, lowestY) || !isRangeValid(highestZ, lowestZ)) { + return false; + } + + FSCom.mkdir("/prefs"); + CompassCalibrationRecord record = { + COMPASS_CALIBRATION_MAGIC, COMPASS_CALIBRATION_VERSION, 0, highestX, lowestX, highestY, lowestY, highestZ, lowestZ}; + + auto file = SafeFile(filePath, true); + const size_t written = file.write(reinterpret_cast(&record), sizeof(record)); + return (written == sizeof(record)) && file.close(); +#else + return false; +#endif +} + +bool MotionSensor::loadMagnetometerCalibration(const char *filePath, float &highestX, float &lowestX, float &highestY, + float &lowestY, float &highestZ, float &lowestZ) +{ +#ifdef FSCom + CompassCalibrationRecord record = {}; + size_t bytesRead = 0; + + spiLock->lock(); + auto file = FSCom.open(filePath, FILE_O_READ); + if (!file) { + spiLock->unlock(); + return false; + } + bytesRead = file.read(reinterpret_cast(&record), sizeof(record)); + file.close(); + spiLock->unlock(); + + const bool headerValid = (bytesRead == sizeof(record)) && (record.magic == COMPASS_CALIBRATION_MAGIC) && + (record.version == COMPASS_CALIBRATION_VERSION) && (record.reserved == 0U); + const bool rangeValid = isRangeValid(record.highestX, record.lowestX) && isRangeValid(record.highestY, record.lowestY) && + isRangeValid(record.highestZ, record.lowestZ); + if (!headerValid || !rangeValid) { + return false; + } + + highestX = record.highestX; + lowestX = record.lowestX; + highestY = record.highestY; + lowestY = record.lowestY; + highestZ = record.highestZ; + lowestZ = record.lowestZ; + + return true; +#else + return false; +#endif +} + +void MotionSensor::beginCalibrationDisplay(bool &showingScreen) +{ +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN + if (!showingScreen) { + powerFSM.trigger(EVENT_PRESS); // keep screen alive during calibration + showingScreen = true; + if (screen) + screen->startAlert((FrameCallback)drawFrameCalibration); + } +#else + (void)showingScreen; +#endif +} + +void MotionSensor::finishCalibrationIfExpired(bool &showingScreen, const char *filePath, float highestX, float lowestX, + float highestY, float lowestY, float highestZ, float lowestZ) +{ + const uint32_t now = millis(); + if ((int32_t)(now - endCalibrationAt) < 0) + return; + + doCalibration = false; + endCalibrationAt = 0; + showingScreen = false; + saveMagnetometerCalibration(filePath, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN + if (screen) { + screen->setEndCalibration(0); + screen->endAlert(); + } +#endif +} + +void MotionSensor::startCalibrationWindow(uint16_t forSeconds) +{ + doCalibration = true; + const uint32_t calibrateFor = static_cast(forSeconds) * 1000U; + endCalibrationAt = millis() + calibrateFor; +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN + if (screen) + screen->setEndCalibration(endCalibrationAt); +#endif +} + +void MotionSensor::seedCalibrationExtrema(float x, float y, float z, float &highestX, float &lowestX, float &highestY, + float &lowestY, float &highestZ, float &lowestZ) +{ + highestX = lowestX = x; + highestY = lowestY = y; + highestZ = lowestZ = z; +} + +void MotionSensor::updateCalibrationExtrema(float x, float y, float z, float &highestX, float &lowestX, float &highestY, + float &lowestY, float &highestZ, float &lowestZ) +{ + if (x > highestX) + highestX = x; + if (x < lowestX) + lowestX = x; + if (y > highestY) + highestY = y; + if (y < lowestY) + lowestY = y; + if (z > highestZ) + highestZ = z; + if (z < lowestZ) + lowestZ = z; +} + +float MotionSensor::applyCompassOrientation(float heading) +{ + switch (config.display.compass_orientation) { + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_90: + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_90_INVERTED: + return heading + 90; + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_180: + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_180_INVERTED: + return heading + 180; + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270: + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED: + return heading + 270; + default: + return heading; + } +} + #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { if (screen == nullptr) return; - // int x_offset = display->width() / 2; - // int y_offset = display->height() <= 80 ? 0 : 32; - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_MEDIUM); - display->drawString(x, y, "Calibrating\nCompass"); - uint8_t timeRemaining = (screen->getEndCalibration() - millis()) / 1000; - sprintf(timeRemainingBuffer, "( %02d )", timeRemaining); - display->setFont(FONT_SMALL); - display->drawString(x, y + 40, timeRemainingBuffer); + const int16_t width = display->getWidth(); + const int16_t height = display->getHeight(); + const bool compactLayout = (height <= 80); + const int16_t margin = 4; + + const uint32_t now = millis(); + const uint32_t endCalibrationAt = screen->getEndCalibration(); + uint32_t timeRemaining = 0; + if (endCalibrationAt > now) { + timeRemaining = (endCalibrationAt - now + 999) / 1000; + } int16_t compassX = 0, compassY = 0; - uint16_t compassDiam = graphics::CompassRenderer::getCompassDiam(display->getWidth(), display->getHeight()); + uint16_t compassDiam = graphics::CompassRenderer::getCompassDiam(width, height); + const int16_t compassRadius = compassDiam / 2; // coordinates for the center of the compass/circle if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { - compassX = x + display->getWidth() - compassDiam / 2 - 5; - compassY = y + display->getHeight() / 2; + compassX = x + width - compassRadius - margin; + compassY = y + height / 2; + } else { + compassX = x + width - compassRadius - margin; + compassY = y + FONT_HEIGHT_SMALL + (height - FONT_HEIGHT_SMALL) / 2; + } + + const int16_t textLeft = x + 1; + const int16_t textRight = compassX - compassRadius - margin; + const int16_t textWidth = textRight - textLeft; + int16_t lineY = y; + + display->setTextAlignment(TEXT_ALIGN_LEFT); + if (textWidth > 12) { + const char *title = "Cal"; + const char *line1 = "Figure-8"; + const char *line2 = "Rotate axes"; + const char *line3 = "Away from metal"; + + display->setFont(FONT_SMALL); + if (!compactLayout && display->getStringWidth("Compass Calibration") <= textWidth) { + display->setFont(FONT_MEDIUM); + title = "Compass Calibration"; + line1 = "Move in figure-8"; + line2 = "Rotate all axes"; + line3 = "Keep from metal"; + display->drawString(textLeft, lineY, title); + lineY += FONT_HEIGHT_MEDIUM; + display->setFont(FONT_SMALL); + } else if (display->getStringWidth("Compass Cal") <= textWidth) { + title = "Compass Cal"; + if (textWidth >= display->getStringWidth("Move in figure-8")) { + line1 = "Move in figure-8"; + line2 = "Rotate all axes"; + line3 = "Keep from metal"; + } + display->drawString(textLeft, lineY, title); + lineY += FONT_HEIGHT_SMALL; + } else { + display->drawString(textLeft, lineY, title); + lineY += FONT_HEIGHT_SMALL; + } + + display->drawString(textLeft, lineY, line1); + lineY += FONT_HEIGHT_SMALL; + display->drawString(textLeft, lineY, line2); + lineY += FONT_HEIGHT_SMALL; + if (!compactLayout || textWidth >= display->getStringWidth(line3)) { + display->drawString(textLeft, lineY, line3); + } + } + + if (textWidth >= display->getStringWidth("000s left")) { + snprintf(timeRemainingBuffer, sizeof(timeRemainingBuffer), "%lus left", (unsigned long)timeRemaining); } else { - compassX = x + display->getWidth() - compassDiam / 2 - 5; - compassY = y + FONT_HEIGHT_SMALL + (display->getHeight() - FONT_HEIGHT_SMALL) / 2; + snprintf(timeRemainingBuffer, sizeof(timeRemainingBuffer), "%lus", (unsigned long)timeRemaining); + } + display->setFont(FONT_SMALL); + if (textWidth > 12) { + display->drawString(textLeft, y + height - FONT_HEIGHT_SMALL - 1, timeRemainingBuffer); } + display->drawCircle(compassX, compassY, compassDiam / 2); graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, screen->getHeading() * PI / 180, (compassDiam / 2)); } diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h index 8eb3bf95b88..71b71f73ac0 100644 --- a/src/motion/MotionSensor.h +++ b/src/motion/MotionSensor.h @@ -2,7 +2,7 @@ #ifndef _MOTION_SENSOR_H_ #define _MOTION_SENSOR_H_ -#define MOTION_SENSOR_CHECK_INTERVAL_MS 100 +#define MOTION_SENSOR_CHECK_INTERVAL_MS 50 #define MOTION_SENSOR_CLICK_THRESHOLD 40 #include "../configuration.h" @@ -54,6 +54,20 @@ class MotionSensor static void drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); #endif + bool saveMagnetometerCalibration(const char *filePath, float highestX, float lowestX, float highestY, float lowestY, + float highestZ, float lowestZ); + bool loadMagnetometerCalibration(const char *filePath, float &highestX, float &lowestX, float &highestY, float &lowestY, + float &highestZ, float &lowestZ); + void beginCalibrationDisplay(bool &showingScreen); + void finishCalibrationIfExpired(bool &showingScreen, const char *filePath, float highestX, float lowestX, float highestY, + float lowestY, float highestZ, float lowestZ); + void startCalibrationWindow(uint16_t forSeconds); + static void seedCalibrationExtrema(float x, float y, float z, float &highestX, float &lowestX, float &highestY, + float &lowestY, float &highestZ, float &lowestZ); + static void updateCalibrationExtrema(float x, float y, float z, float &highestX, float &lowestX, float &highestY, + float &lowestY, float &highestZ, float &lowestZ); + static float applyCompassOrientation(float heading); + ScanI2C::FoundDevice device; // Do calibration if true @@ -63,4 +77,4 @@ class MotionSensor #endif -#endif \ No newline at end of file +#endif From c8dac1034869067b3cce43ccc7b75eb150c7a557 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 18 Apr 2026 08:17:44 -0500 Subject: [PATCH 009/225] Add MCP server for interacting with meshtastic devices and testing framework / TUI (#10194) * Start of MCP server and test suite * Add MCP server for interacting with meshtastic devices and testing framework / TUI * Update mcp-server/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix mcp-server review feedback from thread Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/91dc128a-ed50-4d07-8bb2-3dc6623a05f7 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Enhance StreamAPI and PhoneAPI for improved log record handling and concurrency control * Semgrep fixes * Trunk and semgrep fixes * optimize pio streaming tee file writes Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * chore: remove redundant log handle assignment Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Consolidate type imports and remove placeholder test files * Add tests for config persistence and more exchange messages * Refactor position test to validate on-demand request/reply behavior * Remove position request/reply test and update README for telemetry behavior * Fix transmit history file to get removed on factory reset --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .claude/commands/README.md | 49 + .claude/commands/diagnose.md | 55 + .claude/commands/repro.md | 65 + .claude/commands/test.md | 42 + .github/copilot-instructions.md | 160 ++ .github/prompts/mcp-diagnose.prompt.md | 57 + .github/prompts/mcp-repro.prompt.md | 67 + .github/prompts/mcp-test.prompt.md | 51 + .gitignore | 2 + .mcp.json | 11 + .trunk/configs/.bandit | 28 +- AGENTS.md | 113 ++ mcp-server/.gitignore | 26 + mcp-server/README.md | 270 +++ mcp-server/pyproject.toml | 39 + mcp-server/run-tests.sh | 236 +++ mcp-server/src/meshtastic_mcp/__init__.py | 3 + mcp-server/src/meshtastic_mcp/__main__.py | 11 + mcp-server/src/meshtastic_mcp/admin.py | 377 ++++ mcp-server/src/meshtastic_mcp/boards.py | 159 ++ mcp-server/src/meshtastic_mcp/cli/__init__.py | 6 + .../src/meshtastic_mcp/cli/_flashlog.py | 73 + mcp-server/src/meshtastic_mcp/cli/_fwlog.py | 96 + mcp-server/src/meshtastic_mcp/cli/_history.py | 127 ++ .../src/meshtastic_mcp/cli/_reproducer.py | 214 ++ mcp-server/src/meshtastic_mcp/cli/test_tui.py | 1782 +++++++++++++++++ mcp-server/src/meshtastic_mcp/config.py | 137 ++ mcp-server/src/meshtastic_mcp/connection.py | 84 + mcp-server/src/meshtastic_mcp/devices.py | 75 + mcp-server/src/meshtastic_mcp/flash.py | 447 +++++ mcp-server/src/meshtastic_mcp/hw_tools.py | 243 +++ mcp-server/src/meshtastic_mcp/info.py | 103 + mcp-server/src/meshtastic_mcp/pio.py | 295 +++ mcp-server/src/meshtastic_mcp/registry.py | 98 + .../src/meshtastic_mcp/serial_session.py | 216 ++ mcp-server/src/meshtastic_mcp/server.py | 590 ++++++ mcp-server/src/meshtastic_mcp/userprefs.py | 532 +++++ mcp-server/tests/README.md | 116 ++ mcp-server/tests/__init__.py | 0 mcp-server/tests/_port_discovery.py | 118 ++ mcp-server/tests/admin/__init__.py | 0 .../tests/admin/test_channel_url_roundtrip.py | 57 + .../tests/admin/test_config_roundtrip.py | 106 + .../tests/admin/test_owner_survives_reboot.py | 59 + mcp-server/tests/conftest.py | 1041 ++++++++++ mcp-server/tests/fleet/__init__.py | 0 .../fleet/test_psk_seed_isolates_runs.py | 43 + mcp-server/tests/mesh/__init__.py | 0 mcp-server/tests/mesh/_receive.py | 220 ++ mcp-server/tests/mesh/test_bidirectional.py | 83 + .../tests/mesh/test_broadcast_delivers.py | 45 + mcp-server/tests/mesh/test_direct_with_ack.py | 105 + mcp-server/tests/mesh/test_mesh_formation.py | 39 + mcp-server/tests/mesh/test_traceroute.py | 147 ++ mcp-server/tests/monitor/__init__.py | 0 .../tests/monitor/test_boot_log_no_panic.py | 63 + mcp-server/tests/provisioning/__init__.py | 0 .../provisioning/test_admin_key_baked.py | 83 + .../test_bake_region_modem_slot.py | 60 + .../test_unset_region_blocks_tx.py | 108 + .../test_userprefs_survive_factory_reset.py | 90 + mcp-server/tests/telemetry/__init__.py | 0 .../test_device_telemetry_broadcast.py | 77 + .../telemetry/test_telemetry_request_reply.py | 187 ++ mcp-server/tests/test_00_bake.py | 291 +++ mcp-server/tests/tool_coverage.py | 145 ++ mcp-server/tests/unit/__init__.py | 0 mcp-server/tests/unit/test_boards.py | 72 + mcp-server/tests/unit/test_pio_wrapper.py | 61 + mcp-server/tests/unit/test_testing_profile.py | 120 ++ mcp-server/tests/unit/test_userprefs_parse.py | 115 ++ src/mesh/NodeDB.cpp | 7 + src/mesh/PhoneAPI.cpp | 20 +- src/mesh/StreamAPI.cpp | 45 +- src/mesh/StreamAPI.h | 24 + src/mesh/TransmitHistory.cpp | 21 + src/mesh/TransmitHistory.h | 7 + 77 files changed, 10701 insertions(+), 13 deletions(-) create mode 100644 .claude/commands/README.md create mode 100644 .claude/commands/diagnose.md create mode 100644 .claude/commands/repro.md create mode 100644 .claude/commands/test.md create mode 100644 .github/prompts/mcp-diagnose.prompt.md create mode 100644 .github/prompts/mcp-repro.prompt.md create mode 100644 .github/prompts/mcp-test.prompt.md create mode 100644 .mcp.json create mode 100644 AGENTS.md create mode 100644 mcp-server/.gitignore create mode 100644 mcp-server/README.md create mode 100644 mcp-server/pyproject.toml create mode 100755 mcp-server/run-tests.sh create mode 100644 mcp-server/src/meshtastic_mcp/__init__.py create mode 100644 mcp-server/src/meshtastic_mcp/__main__.py create mode 100644 mcp-server/src/meshtastic_mcp/admin.py create mode 100644 mcp-server/src/meshtastic_mcp/boards.py create mode 100644 mcp-server/src/meshtastic_mcp/cli/__init__.py create mode 100644 mcp-server/src/meshtastic_mcp/cli/_flashlog.py create mode 100644 mcp-server/src/meshtastic_mcp/cli/_fwlog.py create mode 100644 mcp-server/src/meshtastic_mcp/cli/_history.py create mode 100644 mcp-server/src/meshtastic_mcp/cli/_reproducer.py create mode 100644 mcp-server/src/meshtastic_mcp/cli/test_tui.py create mode 100644 mcp-server/src/meshtastic_mcp/config.py create mode 100644 mcp-server/src/meshtastic_mcp/connection.py create mode 100644 mcp-server/src/meshtastic_mcp/devices.py create mode 100644 mcp-server/src/meshtastic_mcp/flash.py create mode 100644 mcp-server/src/meshtastic_mcp/hw_tools.py create mode 100644 mcp-server/src/meshtastic_mcp/info.py create mode 100644 mcp-server/src/meshtastic_mcp/pio.py create mode 100644 mcp-server/src/meshtastic_mcp/registry.py create mode 100644 mcp-server/src/meshtastic_mcp/serial_session.py create mode 100644 mcp-server/src/meshtastic_mcp/server.py create mode 100644 mcp-server/src/meshtastic_mcp/userprefs.py create mode 100644 mcp-server/tests/README.md create mode 100644 mcp-server/tests/__init__.py create mode 100644 mcp-server/tests/_port_discovery.py create mode 100644 mcp-server/tests/admin/__init__.py create mode 100644 mcp-server/tests/admin/test_channel_url_roundtrip.py create mode 100644 mcp-server/tests/admin/test_config_roundtrip.py create mode 100644 mcp-server/tests/admin/test_owner_survives_reboot.py create mode 100644 mcp-server/tests/conftest.py create mode 100644 mcp-server/tests/fleet/__init__.py create mode 100644 mcp-server/tests/fleet/test_psk_seed_isolates_runs.py create mode 100644 mcp-server/tests/mesh/__init__.py create mode 100644 mcp-server/tests/mesh/_receive.py create mode 100644 mcp-server/tests/mesh/test_bidirectional.py create mode 100644 mcp-server/tests/mesh/test_broadcast_delivers.py create mode 100644 mcp-server/tests/mesh/test_direct_with_ack.py create mode 100644 mcp-server/tests/mesh/test_mesh_formation.py create mode 100644 mcp-server/tests/mesh/test_traceroute.py create mode 100644 mcp-server/tests/monitor/__init__.py create mode 100644 mcp-server/tests/monitor/test_boot_log_no_panic.py create mode 100644 mcp-server/tests/provisioning/__init__.py create mode 100644 mcp-server/tests/provisioning/test_admin_key_baked.py create mode 100644 mcp-server/tests/provisioning/test_bake_region_modem_slot.py create mode 100644 mcp-server/tests/provisioning/test_unset_region_blocks_tx.py create mode 100644 mcp-server/tests/provisioning/test_userprefs_survive_factory_reset.py create mode 100644 mcp-server/tests/telemetry/__init__.py create mode 100644 mcp-server/tests/telemetry/test_device_telemetry_broadcast.py create mode 100644 mcp-server/tests/telemetry/test_telemetry_request_reply.py create mode 100644 mcp-server/tests/test_00_bake.py create mode 100644 mcp-server/tests/tool_coverage.py create mode 100644 mcp-server/tests/unit/__init__.py create mode 100644 mcp-server/tests/unit/test_boards.py create mode 100644 mcp-server/tests/unit/test_pio_wrapper.py create mode 100644 mcp-server/tests/unit/test_testing_profile.py create mode 100644 mcp-server/tests/unit/test_userprefs_parse.py diff --git a/.claude/commands/README.md b/.claude/commands/README.md new file mode 100644 index 00000000000..3767dac987a --- /dev/null +++ b/.claude/commands/README.md @@ -0,0 +1,49 @@ +# Claude Code slash commands for the mcp-server test suite + +Three AI-assisted workflows wrapping `mcp-server/run-tests.sh` and the meshtastic MCP tools. Each one has a twin in `.github/prompts/` for Copilot users. + +| Slash command | What it does | Copilot equivalent | +| --------------------- | ------------------------------------------------------------------------- | ---------------------------------------- | +| `/test [args]` | Runs the test suite (auto-detects hardware) and interprets failures | `.github/prompts/mcp-test.prompt.md` | +| `/diagnose [role]` | Read-only device health report via the meshtastic MCP tools | `.github/prompts/mcp-diagnose.prompt.md` | +| `/repro [n=5]` | Re-runs one test N times, diffs firmware logs between passes and failures | `.github/prompts/mcp-repro.prompt.md` | + +## Why two surfaces + +The Claude Code commands and Copilot prompts cover the same three workflows but each speaks its host's idiom: + +- **Claude Code** (`/test`) uses `$ARGUMENTS` for pass-through, has direct access to Bash + all MCP tools registered in the user's settings, and runs in the terminal context. +- **Copilot** (`/mcp-test`) runs in VS Code's agent mode; it has terminal + MCP access too but typically asks the operator to confirm inputs interactively. + +A contributor using either IDE gets equivalent assistance. Keep the two in sync when behavior changes — the diff of intent should be minimal. + +## House rules + +- **No destructive writes without explicit operator approval.** Skills that could reflash, factory-reset, or reboot a device must describe the action and stop — the operator authorizes. +- **Interpret failures, don't just echo them.** The skill body should pull firmware log lines from `mcp-server/tests/report.html` (the `Meshtastic debug` section, attached by `tests/conftest.py::pytest_runtest_makereport`) and classify the failure. +- **Keep MCP tool calls sequential per port.** SerialInterface holds an exclusive port lock; two parallel tool calls on the same port deadlock. +- **Never speculate about root cause.** If the evidence doesn't support a classification, say "unknown" and list what you'd need to disambiguate. + +## Adding a new command + +1. Write the Claude Code version at `.claude/commands/.md` with YAML frontmatter: + + ```yaml + --- + description: one-line purpose (used for auto-invocation by the model) + argument-hint: [optional-hint] + --- + ``` + +2. Write the Copilot equivalent at `.github/prompts/mcp-.prompt.md` with: + + ```yaml + --- + mode: agent + description: ... + --- + ``` + +3. Add the row to the table above. Cross-link in both bodies. + +4. Smoke-test on Claude Code first (`/` should appear in autocomplete), then in VS Code Copilot (`/mcp-` in Chat). diff --git a/.claude/commands/diagnose.md b/.claude/commands/diagnose.md new file mode 100644 index 00000000000..45aa937a5b7 --- /dev/null +++ b/.claude/commands/diagnose.md @@ -0,0 +1,55 @@ +--- +description: Produce a device health report using the meshtastic MCP tools (device_info, list_nodes, get_config, short serial log capture) +argument-hint: [role=all|nrf52|esp32s3|] +--- + +# `/diagnose` — device health report + +Call the meshtastic MCP tool bundle and format a structured health report for one or all detected devices. Zero guesswork for the operator. + +## What to do + +1. **Enumerate hardware.** Call `mcp__meshtastic__list_devices(include_unknown=True)`. For each entry where `likely_meshtastic=True`, capture `port`, `vid`, `pid`, `description`. + +2. **Filter by `$ARGUMENTS`**: + - No args, `all` → every likely-meshtastic device. + - `nrf52` → only devices with `vid == 0x239a`. + - `esp32s3` → only devices with `vid == 0x303a` or `vid == 0x10c4`. + - A `/dev/cu.*` path → only that one port. + - Anything else → treat as a substring match against the `port` string. + +3. **For each selected device, in sequence (NOT parallel — SerialInterface holds an exclusive port lock):** + - `mcp__meshtastic__device_info(port=

)` — captures `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel`. + - `mcp__meshtastic__list_nodes(port=

)` — count of peers, which ones have `publicKey` set, SNR/RSSI distribution. + - `mcp__meshtastic__get_config(section="lora", port=

)` — region, preset, channel_num, tx_power, hop_limit. + - Optionally, if the device seems unhappy (fails to connect, `num_nodes==1` when ≥2 are plugged in, missing firmware*version), open a short firmware log window: `mcp__meshtastic__serial_open(port=

, env=)`, wait 3s, `serial_read(session_id=, max_lines=100)`, `serial_close(session_id=)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*` is set. + +4. **Render per-device report** as: + + ```text + [nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631 + owner : Meshtastic 40eb / 40eb + region/band : US, channel 88, LONG_FAST + tx_power : 30 dBm, hop_limit=3 + peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm) + primary ch : McpTest + firmware : no panics in last 3s; NodeInfoModule emitted 2 broadcasts + ``` + + Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub), flag it inline with a short `⚠︎ `. + +5. **Cross-device correlation** (only when >1 device is inspected): + - Do both sides see each other in `nodesByNum`? If one does and the other doesn't, that's asymmetric NodeInfo — flag it. + - Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh) + - Do the primary channel NAMES match? Mismatch = different PSK = no decode. + +6. **Suggest next actions only for specific, recognisable failure modes**: + - Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path." + - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." + - Device unreachable → point at touch_1200bps + the CP2102-wedged-driver note in run-tests.sh. + +## What NOT to do + +- No writes. No `set_config`, no `reboot`, no `factory_reset`. This is a read-only diagnostic skill — if the operator wants to change state, they'll ask explicitly. +- No `flash` / `erase_and_flash`. Those are separate escalations. +- No holding SerialInterface across tool calls — open, query, close; next device. The port lock is exclusive. diff --git a/.claude/commands/repro.md b/.claude/commands/repro.md new file mode 100644 index 00000000000..52dcf222b93 --- /dev/null +++ b/.claude/commands/repro.md @@ -0,0 +1,65 @@ +--- +description: Re-run a specific test N times in isolation to triage flakes, diff firmware logs between passes and failures +argument-hint: [count=5] +--- + +# `/repro` — flakiness triage for one test + +Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y." + +## What to do + +1. **Parse `$ARGUMENTS`**: first token is the pytest node id (e.g. `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[nrf52->esp32s3]`); second token is an integer count (default `5`, cap at `20`). If the first token doesn't look like a test path (no `::` and no `tests/` prefix), treat the whole `$ARGUMENTS` as a `-k` filter instead. + +2. **Sanity-check the hub first** (so we're not measuring "nothing plugged in" N times): call `mcp__meshtastic__list_devices`. If the test name contains `nrf52` or `esp32s3` and the matching VID isn't present, stop and report — re-running won't help. + +3. **Loop N times**. For each iteration: + + ```bash + ./mcp-server/run-tests.sh --tb=short -p no:cacheprovider + ``` + + Capture: exit code, duration, and (on failure) the `Meshtastic debug` firmware log section from `mcp-server/tests/report.html`. `-p no:cacheprovider` suppresses pytest's `.pytest_cache` writes so iterations don't influence each other. + +4. **Track a small structured tally**: + + ```text + attempt 1: PASS (42s) + attempt 2: FAIL (128s) ← firmware log 200-line tail captured + attempt 3: PASS (39s) + attempt 4: FAIL (121s) + attempt 5: PASS (41s) + -------------------------------------- + pass rate: 3/5 (60%) | mean duration: 74s + ``` + +5. **On mixed outcomes**: diff the firmware log tails between a representative passing attempt and a representative failing attempt. Focus on: + - Error-level lines only present in failures (`PKI_UNKNOWN_PUBKEY`, `Alloc an err=`, `Skip send`, `No suitable channel`) + - Timing around the assertion event — did a broadcast go out, was there an ACK, did NAK fire? + - Device state fields that changed (nodesByNum entries, region/preset, channel_num) + + Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs — pull specific lines with uptime timestamps. + +6. **Classify the flake** into one of: + - **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body. + - **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this. + - **NodeInfo cooldown** → `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs `broadcast_nodeinfo_ping()` warmup. + - **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. + - **Genuinely unknown** → say so; don't invent a root cause. + +7. **Report back** with: + - Pass rate and mean duration. + - Classification + evidence (the specific log lines that support it). + - A suggested next step (re-run with specific args, open `/diagnose`, edit a specific test file, nothing). + +## Examples + +- `/repro tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[esp32s3->nrf52] 10` — runs 10 times, diffs firmware logs. +- `/repro broadcast_delivers` — no `::`, no `tests/`, so interpreted as `-k broadcast_delivers`; runs every matching test the default 5 times. +- `/repro tests/telemetry/test_device_telemetry_broadcast.py 3` — shorter run for a slow test. + +## Constraints + +- Don't exceed `count=20` per invocation — airtime and USB wear add up. If the user asks for 50, negotiate down. +- Don't rebuild firmware as part of triage; flakes that only reproduce under different firmware belong in a separate session. +- If the FIRST attempt fails AND the rest all pass, that's a classic "state leak from a prior test" → say so and suggest running with `--force-bake` or starting from a clean state rather than chasing the first failure. diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 00000000000..986ee1f31f6 --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,42 @@ +--- +description: Run the mcp-server test suite (auto-detects devices) and interpret the results +argument-hint: [pytest-args] +--- + +# `/test` — mcp-server test runner with interpretation + +Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn't have to. + +## What to do + +1. **Invoke the wrapper.** From the firmware repo root, run: + + ```bash + ./mcp-server/run-tests.sh $ARGUMENTS + ``` + + The wrapper auto-detects connected Meshtastic devices, maps each to its PlatformIO env, exports the required `MESHTASTIC_MCP_ENV_*` env vars, and invokes pytest. If the user passed no arguments, the wrapper supplies a sensible default set (`tests/ --html=tests/report.html --self-contained-html --junitxml=tests/junit.xml -v --tb=short`). A `--report-log=tests/reportlog.jsonl` arg is always appended (unless the operator passed their own). `--assume-baked` is deliberately NOT in the defaults — `test_00_bake.py` has its own skip-if-already-baked check and runs the ~8 s verification by default. Operators can opt into the fast path with `--assume-baked`, or force a reflash with `--force-bake`. + +2. **Read the pre-flight header.** First ~6 lines print the detected hub (role → port → env). If that line reads `detected hub : (none)`, the wrapper will narrow to `tests/unit` only — say so explicitly in your summary so the operator knows hardware tiers were skipped. + +3. **On pass**: one-line summary of the form `N passed, M skipped in `. Don't enumerate the 52 test names — the user can read those. Do mention if any test was SKIPPED for a NON-placeholder reason (e.g. "role not present on hub" is worth flagging). + +4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). + +5. **Classify the failure** as one of: + - **Transient/flake**: LoRa collision, timing-sensitive assertion, first-attempt NAK + successful retry pattern. Propose `/repro ` to confirm. + - **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery (replug USB, `touch_1200bps`, check `git status userPrefs.jsonc`). + - **Regression**: same assertion fails repeatedly, firmware log shows a new/unusual error. Surface the diff between expected and observed, identify the module likely responsible. + +6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, or USB replug, \_describe what to do* — don't execute. The operator decides. + +## Arguments handling + +- No args → wrapper's defaults (full suite). +- `$ARGUMENTS` passed verbatim to the wrapper, which passes them to pytest. +- Common operator invocations: `/test tests/mesh`, `/test tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip`, `/test --force-bake`, `/test -k telemetry`. + +## Side-effects to mention in summary + +- The session fixture snapshots `userPrefs.jsonc` at session start and restores at teardown (plus on `atexit`). After a clean run, `git status userPrefs.jsonc` should be empty. If the wrapper's pre-flight printed a warning about a stale sidecar, call that out — means a prior session crashed. +- `mcp-server/tests/report.html` and `junit.xml` are regenerated on every run; the HTML is self-contained (shareable). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 24e11bd4ddb..d12244229e6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -429,6 +429,8 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing. ## Testing +### Native unit tests (C++) + Unit tests in `test/` directory with 12 test suites: - `test_crypto/` - Cryptography @@ -446,6 +448,164 @@ Run with: `pio test -e native` Simulation testing: `bin/test-simulator.sh` +### Hardware-in-the-loop tests (`mcp-server/tests/`) + +Separate pytest suite that exercises real USB-connected Meshtastic devices. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules. + +## MCP Server & Hardware Test Harness + +The `mcp-server/` directory houses a firmware-aware [MCP](https://modelcontextprotocol.io/) server plus a pytest-based integration suite. AI agents that speak MCP get a well-defined tool surface for flashing, configuring, and inspecting physical Meshtastic devices — use it instead of hand-rolling `pio` or `meshtastic --port` calls where possible. `mcp-server/README.md` is the operator-facing setup doc; this section is the agent-facing usage contract. + +The repo registers the server via `.mcp.json` at the repo root — Claude Code picks it up automatically once `mcp-server/.venv/` is built (`cd mcp-server && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'`). + +### When to use which surface + +| Goal | Tool | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Find a connected device | `mcp__meshtastic__list_devices` | +| Read a live node's config/state | `mcp__meshtastic__device_info`, `list_nodes`, `get_config` | +| Mutate a device (owner, region, channels, reboot) | `set_owner`, `set_config`, `set_channel_url`, `reboot`, `shutdown`, `factory_reset` — all require `confirm=True` | +| Flash firmware to a variant | `pio_flash` (any arch) or `erase_and_flash` (ESP32 factory install) | +| Stream serial logs while debugging | `serial_open` → `serial_read` loop → `serial_close` | +| Administer `userPrefs.jsonc` build-time constants | `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest` | +| Run the regression suite | `./mcp-server/run-tests.sh` (or `/test` slash command) | +| Diagnose a specific device | `/diagnose [role]` slash command (read-only) | +| Triage a flaky test | `/repro [count]` slash command | + +**One MCP call per port at a time.** `SerialInterface` holds an exclusive OS-level lock on the serial port for its lifetime. If a `serial_*` session is open on `/dev/cu.usbmodem101`, calling `device_info` on the same port will fail fast pointing at the active session. Sequence calls: open → read/mutate → close, then next device. Never parallelize tool calls on the same port. + +### MCP tool surface (~32 tools) + +Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-value signatures are called out here. + +- **Discovery & metadata**: `list_devices`, `list_boards`, `get_board` +- **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 only), `update_flash` (ESP32 OTA), `touch_1200bps` +- **Serial sessions** (long-running, 10k-line ring buffer): `serial_open`, `serial_read`, `serial_list`, `serial_close` +- **Device reads**: `device_info`, `list_nodes` +- **Device writes** (all require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api` +- **userPrefs admin** (build-time constants, not runtime config): `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` +- **Vendor escape hatches**: `esptool_chip_info`, `esptool_erase_flash`, `esptool_raw`, `nrfutil_dfu`, `nrfutil_raw`, `picotool_info`, `picotool_load`, `picotool_raw` + +`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset` and `erase_and_flash`. + +### Hardware test suite (`mcp-server/run-tests.sh`) + +The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_`), then invokes pytest. Zero pre-flight config needed from the operator. + +Suite tiers (collected + run in this order via `pytest_collection_modifyitems`): + +1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile). No hardware. +2. `tests/test_00_bake.py` — flashes each detected device with current `userPrefs.jsonc` merged with the session's test profile. Has its own skip-if-already-baked check comparing region + primary channel to the session profile; skips cheaply on warm devices. +3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`. +4. `tests/telemetry/` — `DEVICE_METRICS_APP` broadcast timing. +5. `tests/monitor/` — boot-log panic check. +6. `tests/fleet/` — PSK seed session isolation. +7. `tests/admin/` — channel URL roundtrip, owner persistence across reboot. +8. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset. + +Invocation patterns: + +```bash +./mcp-server/run-tests.sh # full suite (auto-bake-if-needed) +./mcp-server/run-tests.sh --force-bake # reflash before testing +./mcp-server/run-tests.sh --assume-baked # skip bake (caller vouches for device state) +./mcp-server/run-tests.sh tests/mesh # one tier +./mcp-server/run-tests.sh tests/mesh/test_direct_with_ack.py # one file +./mcp-server/run-tests.sh -k telemetry # name filter +``` + +**No hardware detected?** The wrapper auto-narrows to `tests/unit/` only and prints `detected hub : (none)` in the pre-flight header. Agents interpreting the output should call this out explicitly — a 52-test green run without hardware is qualitatively different from a 12-unit-test green run. + +**Artifacts every run produces:** + +- `mcp-server/tests/report.html` — self-contained pytest-html. Each test gets a `Meshtastic debug` section with the tail of firmware log + device state dump. **Open this first** on failures; it's the canonical evidence source. +- `mcp-server/tests/junit.xml` — CI-parseable. +- `mcp-server/tests/reportlog.jsonl` — pytest-reportlog stream (`$report_type` keyed JSONL). Consumed by the live TUI. +- `mcp-server/tests/fwlog.jsonl` — firmware log mirror from the `meshtastic.log.line` pubsub topic. Populated by the `_firmware_log_stream` autouse session fixture. + +### Live TUI (`meshtastic-mcp-test-tui`) + +A Textual-based live view that wraps `run-tests.sh`. Tails reportlog for per-test state, streams firmware logs, polls device state at startup + post-run (gated out of the active run because `hub_devices` holds exclusive port locks). Key bindings: + +| Key | Action | +| --- | ------------------------------------------------------------------------------------------------------------ | +| `r` | re-run focused test (leaf → that node id; internal node → directory or `-k`) | +| `f` | filter tree by substring | +| `d` | failure detail modal (pulls `longrepr` + captured stdout from the reportlog) | +| `g` | export reproducer bundle (tar.gz with README, test_report.json, time-filtered fwlog, devices.json, env.json) | +| `l` | toggle firmware log pane | +| `x` | tool coverage modal | +| `c` | cross-run history sparkline | +| `q` | quit (SIGINT → SIGTERM → SIGKILL escalation, 5-s windows each) | + +Launch: + +```bash +cd mcp-server +.venv/bin/meshtastic-mcp-test-tui # full suite +.venv/bin/meshtastic-mcp-test-tui tests/mesh # args pass through to pytest +``` + +The plain CLI stays primary; the TUI is for operators who want a live dashboard. Both consume the same `run-tests.sh`. + +### Slash commands (Claude Code + Copilot) + +Three AI-assisted workflows wrap the test harness. Claude Code operators get `/test`, `/diagnose`, `/repro`; Copilot operators get `/mcp-test`, `/mcp-diagnose`, `/mcp-repro`. Bodies: + +- `.claude/commands/{test,diagnose,repro}.md` +- `.github/prompts/mcp-{test,diagnose,repro}.prompt.md` + +`.claude/commands/README.md` is the index. + +House rules for agents running these prompts: + +- **Interpret failures, don't just echo them.** Pull firmware log tails from `report.html` and classify each failure as transient / environmental / regression. Use the exact format in `.claude/commands/test.md`. +- **No destructive writes without operator approval.** Any skill that could reflash, factory-reset, or reboot a device must describe the action and stop. The operator authorizes. +- **Sequential MCP calls per port.** See above. +- **"Unknown" is a valid classification.** If evidence doesn't support a root cause, say so and list what would disambiguate. Do not invent. + +### Key fixtures (test authors + agents debugging) + +`mcp-server/tests/conftest.py` provides: + +- **`_session_userprefs`** (autouse session) — snapshots `userPrefs.jsonc` at session start, merges the session test profile via `userprefs.merge_active(test_profile)`, restores at teardown. Four layers of safety: pytest teardown + `atexit` + sidecar file (`userPrefs.jsonc.mcp-session-bak`) + startup self-heal in `run-tests.sh`. **Do not edit `userPrefs.jsonc` from inside a test.** +- **`_firmware_log_stream`** (autouse session) — subscribes to `meshtastic.log.line` pubsub on every connected `SerialInterface` and mirrors lines to `tests/fwlog.jsonl`. Drives the TUI firmware-log pane. +- **`_debug_log_buffer`** (autouse per-test) — captures last 200 firmware log lines + device state for attachment to the pytest-html `Meshtastic debug` section on failure. +- **`hub_devices`** (session) — `dict[role, SerialInterface]` with session-long exclusive port locks. Reason the TUI's device poller is gated to startup + post-run only. +- **`baked_mesh`** — parametrized mesh-pair fixture; depends on `test_00_bake`. `pytest_generate_tests` in `conftest.py` auto-generates `[nrf52->esp32s3]` and `[esp32s3->nrf52]` variants. +- **`test_profile`** — session-scoped dict: region, primary channel, admin key, PSK seed. Derived from `MESHTASTIC_MCP_SEED` (defaults to `mcp--`). + +### Firmware integration points tied to the test harness + +Two firmware changes exist specifically so the test harness works reliably. **Keep these in mind when touching related code.** + +- **`src/mesh/StreamAPI.cpp` + `StreamAPI.h`** — `emitLogRecord` uses a dedicated `fromRadioScratchLog` + `txBufLog` pair and a `concurrency::Lock streamLock`. Before this fix, `debug_log_api_enabled=true` would tear `FromRadio` protobufs on the serial transport because `emitTxBuffer` and `emitLogRecord` shared a single scratch buffer. The conftest enables the log stream session-wide; without this fix the device would corrupt its own FromRadio replies mid-session. +- **`src/mesh/PhoneAPI.cpp`** — `ToRadio` `Heartbeat(nonce=1)` triggers `nodeInfoModule->sendOurNodeInfo(NODENUM_BROADCAST, true, 0, true)` for serial clients, mirroring the pre-existing behavior for TCP/UDP clients in `PacketAPI.cpp`. The mesh tests rely on this to force a NodeInfo broadcast right after connect so the peer discovers them before the test's first assertion. + +If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` flow, run `./mcp-server/run-tests.sh` at minimum before asking for review. + +### Recovery playbooks + +| Symptom | First check | Fix | +| ---------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. | +| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. | +| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. | +| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. | +| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. | +| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. | +| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. | + +### Never do these without asking + +- `factory_reset` — wipes node identity; regenerates PKI keypair. Mesh peers will reject old DMs until re-exchange. Legitimate only when the operator explicitly wants it. +- `erase_and_flash` — full chip erase; destroys all on-device state. +- `esptool_erase_flash` / `esptool_raw` write/erase — bypasses pio's safety chain. +- `set_config` on `lora.region` — changes regulatory domain; requires physical-location context the operator has and the agent doesn't. +- `reboot` / `shutdown` mid-test — breaks fixture invariants. +- `push -f`, `rebase -i`, `reset --hard`, or any history-rewriting git operation. +- Clicking computer-use tools on web links in Mail/Messages/PDFs — open URLs via the claude-in-chrome MCP so the extension's link-safety checks apply. + ## Resources - [Documentation](https://meshtastic.org/docs/) diff --git a/.github/prompts/mcp-diagnose.prompt.md b/.github/prompts/mcp-diagnose.prompt.md new file mode 100644 index 00000000000..c86826030d9 --- /dev/null +++ b/.github/prompts/mcp-diagnose.prompt.md @@ -0,0 +1,57 @@ +--- +mode: agent +description: Device health report via the meshtastic MCP tools (Copilot equivalent of the Claude Code /diagnose slash command) +--- + +# `/mcp-diagnose` — device health report + +Equivalent of `.claude/commands/diagnose.md`. Use when the operator asks to "check the devices", "what's the mesh looking like", "is nrf52 alive", etc. + +This prompt assumes the meshtastic MCP server is registered with your VS Code Copilot agent. If it isn't, fall back to running `./mcp-server/run-tests.sh tests/unit` plus a short `device_info` script via the terminal. + +## What to do + +1. **Enumerate hardware** via the `list_devices` MCP tool (with `include_unknown=True`). For each entry where `likely_meshtastic=True`, capture `port`, `vid`, `pid`, `description`. + +2. **Apply the operator's filter** (if any): + - No filter → every likely-meshtastic device. + - `nrf52` → `vid == 0x239a` + - `esp32s3` → `vid == 0x303a` or `vid == 0x10c4` + - A `/dev/cu.*` path → only that port. + - Anything else → substring match on port. + +3. **For each selected device, in sequence (don't parallelize — SerialInterface holds an exclusive port lock):** + - `device_info(port=

)` → `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel` + - `list_nodes(port=

)` → peer count, which peers have `publicKey`, SNR/RSSI distribution + - `get_config(section="lora", port=

)` → region, preset, channel_num, tx_power, hop_limit + - If anything looks off (can't connect, `num_nodes` wrong, missing `firmware_version`), open a short firmware-log window: `serial_open(port=

, env=)`, wait 3 seconds, `serial_read(session_id, max_lines=100)`, `serial_close(session_id)`. Infer env from VID (0x239a → `rak4631`, 0x303a/0x10c4 → `heltec-v3`) unless an `MESHTASTIC_MCP_ENV_` env var overrides it. + +4. **Render per-device report** as a compact block: + + ```text + [nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631 + owner : Meshtastic 40eb / 40eb + region/band : US, channel 88, LONG_FAST + tx_power : 30 dBm, hop_limit=3 + peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm) + primary ch : McpTest + firmware : no panics in last 3s + ``` + + Flag abnormalities inline with `⚠︎ ` — missing pubkey on a known peer, region UNSET, mismatched channel name, etc. + +5. **Cross-device correlation** (when >1 device selected): + - Do both see each other in `nodesByNum`? + - Do `region`, `channel_num`, `modem_preset` match across devices? + - Do the primary channel names match? (Different name → different PSK → no decode.) + +6. **Suggest next steps only for recognizable failure modes**, never speculatively: + - Stale PKI one-way → "`/mcp-test tests/mesh/test_direct_with_ack.py` — the test's retry+nodeinfo-ping heals this." + - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." + - Device unreachable → refer operator to the touch_1200bps + CP2102-wedged-driver notes in `run-tests.sh`. + +## Hard constraints + +- **Read-only.** No `set_config`, no `reboot`, no `factory_reset`, no `flash`. If the operator wants mutation, they'll escalate explicitly. +- **Open/query/close per device.** Never hold multiple SerialInterfaces to the same port. The port lock is exclusive. +- **Don't infer env beyond the VID map** — if the operator has an unusual board, ask them which env to use rather than guessing. diff --git a/.github/prompts/mcp-repro.prompt.md b/.github/prompts/mcp-repro.prompt.md new file mode 100644 index 00000000000..be2963c3318 --- /dev/null +++ b/.github/prompts/mcp-repro.prompt.md @@ -0,0 +1,67 @@ +--- +mode: agent +description: Re-run a specific test N times to triage flakes; diff firmware logs between passes and failures (Copilot equivalent of the Claude Code /repro slash command) +--- + +# `/mcp-repro` — flakiness triage for one test + +Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one test is flaky — dig in", "repro the direct_with_ack failure", "why does X sometimes fail?". + +## What to do + +1. **Parse the operator's input** into two pieces: + - **Test identifier** — either a pytest node id (has `::` or starts with `tests/`) or a `-k`-style filter (plain substring like `direct_with_ack`). + - **Count** — integer, default `5`, cap at `20`. If the operator asks for 50, negotiate down and explain (airtime + USB wear). + +2. **Sanity-check the hub** via the `list_devices` MCP tool. If the test name references `nrf52` or `esp32s3` and the matching VID isn't present, stop and report — re-running won't help. + +3. **Loop** N times. Each iteration: + + ```bash + ./mcp-server/run-tests.sh --tb=short -p no:cacheprovider + ``` + + `-p no:cacheprovider` keeps pytest from caching anything between iterations. Capture: exit code, duration, and (on failure) the `Meshtastic debug` firmware-log section from `mcp-server/tests/report.html`. + +4. **Tally** results as you go: + + ```text + attempt 1: PASS (42s) + attempt 2: FAIL (128s) ← fw log captured + attempt 3: PASS (39s) + attempt 4: FAIL (121s) + attempt 5: PASS (41s) + -------------------------------------------------- + pass rate: 3/5 (60%) | mean duration: 74s + ``` + +5. **On mixed outcomes, diff the firmware logs** between one representative pass and one representative fail. Focus on: + - Error-level lines present only in failures (`PKI_UNKNOWN_PUBKEY`, `Alloc an err=`, `Skip send`, `No suitable channel`, `NAK`) + - Timing around the assertion point (broadcast sent? ACK received? retry fired?) + - Device-state fields that changed between attempts + + Surface the top 3 differences as a compact "passes when / fails when" table with uptime timestamps. Don't dump full logs. + +6. **Classify** the flake into one of: + - **LoRa airtime collision** — pass rate improves with fewer concurrent transmitters. Suggest a `time.sleep` gap or retry bump in the test body. + - **PKI key staleness** — first attempt fails, subsequent ones pass; existing retry-loop pattern in `test_direct_with_ack.py` is the fix. + - **NodeInfo cooldown** — `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs a `broadcast_nodeinfo_ping()` warmup. + - **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc. + - **Unknown** — say so. Don't invent a root cause. + +7. **Report back** with: + - Pass rate + mean duration. + - Classification + the specific log evidence for it. + - A concrete next step (tighter assertion, more retries, open `/mcp-diagnose`, file a bug, nothing). + +## Examples + +- `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[esp32s3->nrf52] 10` — 10 runs of that parametrized case. +- `broadcast_delivers` — no `::`, no `tests/`; treat as `-k broadcast_delivers`; runs every match 5 times. +- `tests/telemetry/test_device_telemetry_broadcast.py 3` — shorter count for a slow test. + +## Notes + +- If the FIRST attempt fails and the rest pass, that's a state-leak signature — suggest starting from `--force-bake` or a clean device state rather than chasing the first-failure firmware logs. +- If ALL N fail, this isn't a flake — it's a regression. Say so, stop iterating, escalate to `/mcp-test` for full-suite context. +- Don't rebuild firmware during triage. Flakes that only reproduce under different firmware belong in a separate session with a plan. diff --git a/.github/prompts/mcp-test.prompt.md b/.github/prompts/mcp-test.prompt.md new file mode 100644 index 00000000000..092ad3d856c --- /dev/null +++ b/.github/prompts/mcp-test.prompt.md @@ -0,0 +1,51 @@ +--- +mode: agent +description: Run the mcp-server test suite and interpret results (Copilot equivalent of the Claude Code /test slash command) +--- + +# `/mcp-test` — mcp-server test runner with interpretation + +Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md`. Use this when the operator asks you to "run the tests", "check the mcp test suite", "run the mesh tests", etc. + +## What to do + +1. **Invoke the wrapper** from the firmware repo root: + + ```bash + ./mcp-server/run-tests.sh [pytest-args] + ``` + + If the operator specified a subset (e.g. "just the mesh tests"), pass it through as `tests/mesh` or a pytest `-k filter`. If they said nothing, use the wrapper's defaults (full suite with pytest-html report). + + The wrapper auto-detects connected Meshtastic devices, maps each to its PlatformIO env, exports the required env vars, and invokes pytest. Zero pre-flight config needed from the operator. + +2. **Read the pre-flight header** (first few lines of wrapper output). The `detected hub :` line lists role → port → env mappings. If it reads `(none)`, the wrapper narrowed to `tests/unit` only — call that out explicitly so the operator knows hardware tiers were skipped. + +3. **On pass**: one-line summary like `N passed, M skipped in `. Don't enumerate test names. DO mention any non-placeholder SKIPs (things like "role not present on hub") because they indicate missing hardware or setup issues. + +4. **On failure**: open `mcp-server/tests/report.html` (pytest-html output, self-contained) and extract the `Meshtastic debug` section for each failed test. That section includes a firmware log stream (last 200 lines) and device state dump. For each failure, summarise: + - test name + - one-line assertion message + - the specific firmware log lines that explain why (look for `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`, `No suitable channel`) + +5. **Classify each failure** as one of: + - **Transient flake** — LoRa collision, first-attempt NAK with self-heal pattern, timing-sensitive assertion. Suggest `/mcp-repro ` to confirm. + - **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest specific recovery (USB replug, `touch_1200bps`, `git status userPrefs.jsonc`). + - **Regression** — same assertion fails repeatedly on re-runs, firmware log shows novel errors. Identify the firmware module likely responsible. + +6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval. + +## Arguments convention + +Operators generally invoke this prompt either with no arguments (full suite) or with a specific subset. Examples: + +- `tests/mesh` — one tier +- `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip` — one test +- `--force-bake` — reflash devices first +- `-k telemetry` — name-filter + +## Side-effects to confirm in your summary + +- `userPrefs.jsonc` should be clean after a successful run. The session fixture in `mcp-server/tests/conftest.py` (`_session_userprefs`) snapshots and restores. Check `git status --porcelain userPrefs.jsonc` and report if it's non-empty. +- `mcp-server/tests/report.html` and `junit.xml` regenerate on every run. +- The wrapper prints a warning if a `.mcp-session-bak` sidecar was left over from a crashed prior session and auto-restores from it — mention that if it happened. diff --git a/.gitignore b/.gitignore index 43cee78db73..f1eb9d852d7 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ CMakeLists.txt # PYTHONPATH used by the Nix shell .python3 +.claude/scheduled_tasks.lock +userPrefs.jsonc.mcp-session-bak diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000000..c5cf2e55e5a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "meshtastic": { + "command": "./mcp-server/.venv/bin/python", + "args": ["-m", "meshtastic_mcp"], + "env": { + "MESHTASTIC_FIRMWARE_ROOT": "." + } + } + } +} diff --git a/.trunk/configs/.bandit b/.trunk/configs/.bandit index d286ded8974..c70e7743b67 100644 --- a/.trunk/configs/.bandit +++ b/.trunk/configs/.bandit @@ -1,2 +1,28 @@ [bandit] -skips = B101 \ No newline at end of file +# Rule IDs: https://bandit.readthedocs.io/en/latest/plugins/index.html +# +# B101 assert_used +# pytest assertions + internal invariants; required for pytest. +# B110 try_except_pass +# best-effort cleanup paths (atexit handlers, pubsub unsubscribe, +# session-end file close, socket shutdown). Logging inside the +# except block would be worse than the silent pass — teardown is +# already at end-of-session and the surrounding caller has context. +# B112 try_except_continue +# defensive loops over flaky sources (pubsub handlers, device +# re-enumeration polls). One failed iteration shouldn't abort the loop. +# B404 import_subprocess +# mcp-server wraps PlatformIO, esptool, nrfutil, picotool, and the +# pytest test-runner — subprocess is a load-bearing import here, not +# a smell. The "consider possible security implications" advisory is +# redundant given the file-level review already applied. +# B603 subprocess_without_shell_equals_true +# all subprocess calls use a static argv list; `shell=False` is the +# default and we never string-interpolate user input into the command. +# B606 start_process_with_no_shell +# same invariant as B603 — running a binary via argv list (not +# `shell=True`) is the safe pattern bandit is asking for. +# +# Higher-severity checks (B102 exec_used, B301 pickle, B307 eval, +# B602 shell=True, etc.) remain enabled. +skips = B101,B110,B112,B404,B603,B606 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..cd043c08787 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +# Agent instructions + +This repository is the [Meshtastic](https://meshtastic.org) firmware — a C++17 embedded codebase targeting ESP32 / nRF52 / RP2040 / STM32WL / Linux-Portduino LoRa mesh radios — plus a Python MCP server in `mcp-server/` that AI agents use to flash, configure, and test connected devices. + +## Primary instruction file + +**Read `.github/copilot-instructions.md` first.** That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions (naming, module framework, Observer pattern, thread safety), the build system, CI/CD, the native C++ test suite, and — most importantly for automation work — the **MCP Server & Hardware Test Harness** section. Read it top-to-bottom before starting any non-trivial change. + +This file (`AGENTS.md`) is a short pointer + quick reference for agents that don't read `.github/copilot-instructions.md` by default. + +## Quick command reference + +| Action | Command | +| -------------------------------- | ----------------------------------------------------------------------------------- | +| Build a firmware variant | `pio run -e ` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) | +| Clean + rebuild | `pio run -e -t clean && pio run -e ` | +| Flash a device | `pio run -e -t upload --upload-port ` (or use the `pio_flash` MCP tool) | +| Run firmware unit tests (native) | `pio test -e native` | +| Run MCP hardware tests | `./mcp-server/run-tests.sh` | +| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` | +| Format before commit | `trunk fmt` | +| Regenerate protobuf bindings | `bin/regen-protos.sh` | +| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` | + +## MCP server (device + test automation) + +The `mcp-server/` package exposes ~32 MCP tools for device discovery, building, flashing, serial monitoring, and live-node administration. Tools are grouped as: + +- **Discovery**: `list_devices`, `list_boards`, `get_board` +- **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 factory), `update_flash` (ESP32 OTA), `touch_1200bps` +- **Serial sessions**: `serial_open`, `serial_read`, `serial_list`, `serial_close` +- **Device reads**: `device_info`, `list_nodes` +- **Device writes** (require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api` +- **userPrefs admin**: `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` +- **Vendor escape hatches**: `esptool_*`, `nrfutil_*`, `picotool_*` + +Setup: `cd mcp-server && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'`. The repo registers the server via `.mcp.json` — Claude Code picks it up automatically. + +See `mcp-server/README.md` for argument shapes and the **MCP Server & Hardware Test Harness** section of `.github/copilot-instructions.md` for agent usage rules (tool surface, fixture contract, firmware integration points, recovery playbooks). + +## Slash commands (AI-assisted workflows) + +Three test-and-diagnose workflows exist as slash commands: + +- **`/test` (Claude Code) / `/mcp-test` (Copilot)** — run the hardware test suite and interpret failures +- **`/diagnose` / `/mcp-diagnose`** — read-only device health report +- **`/repro` / `/mcp-repro`** — flakiness triage: re-run one test N times, diff firmware logs between passes and failures + +Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude/commands/README.md` is the index. + +## House rules + +- **No destructive device operations without operator approval.** `factory_reset`, `erase_and_flash`, `reboot`, `shutdown`, history-rewriting git ops — describe the action and stop. Operator authorizes. +- **One MCP call per serial port at a time.** The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device. +- **`userPrefs.jsonc` is session state during tests.** The `_session_userprefs` fixture snapshots + restores it; never edit it from inside a test. +- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate. +- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. +- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings. + +## Typical agent workflows + +### Flashing a device + +1. `list_devices` → find the port + likely VID +2. `list_boards` → confirm the env, or use the known default for the hardware +3. `pio_flash(env=..., port=..., confirm=True)` for any arch, or `erase_and_flash(env=..., port=..., confirm=True)` for an ESP32 factory install + +### Inspecting live node state + +1. `device_info(port=...)` — short summary (node num, firmware version, region, peer count) +2. `list_nodes(port=...)` — full peer table (SNR, RSSI, pubkey presence, last_heard) +3. `get_config(section="lora", port=...)` — LoRa settings for cross-device comparison + +Sequence these; don't parallelize on the same port. + +### Testing a firmware change + +1. Build locally: `pio run -e ` +2. Flash the test device: `pio_flash(env=..., port=..., confirm=True)` +3. Run the suite: `./mcp-server/run-tests.sh tests/` or `/test tests/` +4. On failure, open `mcp-server/tests/report.html` → `Meshtastic debug` section for the firmware log tail + device state dump +5. Iterate + +### Debugging a flaky test + +1. `/repro [count]` — re-runs the test N times, diffs firmware logs between passes and failures +2. If the first attempt always fails and the rest pass, that's a state-leak pattern → suggest `--force-bake` or a clean device state, don't chase the first failure +3. If all N fail, this isn't a flake — it's a regression. Stop iterating and escalate to `/test` for full-suite context. + +## Where to look + +| Path | What's there | +| --------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) | +| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI | +| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers | +| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) | +| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` | +| `test/` | Firmware unit tests (12 suites; `pio test -e native`) | +| `mcp-server/` | Python MCP server + pytest hardware integration tests | +| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `fleet/`, `admin/`, `provisioning/` | +| `.claude/commands/` | Claude Code slash command bodies | +| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) | +| `.github/copilot-instructions.md` | **Primary agent instructions — read this** | +| `.github/workflows/` | CI pipelines | +| `.mcp.json` | MCP server registration for Claude Code | + +## Recovery one-liners + +- **`userPrefs.jsonc` dirty after a test run?** Re-run `./mcp-server/run-tests.sh` once (pre-flight self-heals from the sidecar). If still dirty: `git checkout userPrefs.jsonc`. +- **nRF52 not responding?** `mcp__meshtastic__touch_1200bps(port=...)` drops it into the DFU bootloader, then `pio_flash` re-installs. +- **Port busy?** `lsof ` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it. +- **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned. diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 00000000000..f5180bc71a1 --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,26 @@ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +dist/ +build/ + +# Test harness artifacts +tests/report.html +tests/junit.xml +tests/reportlog.jsonl +tests/fwlog.jsonl +# Subprocess-output tee from pio/esptool/nrfutil/picotool (live flash +# progress for the TUI; also a post-run diagnostic for plain CLI runs). +tests/flash.log +tests/tool_coverage.json +tests/.coverage +htmlcov/ +# Persistent run counter for meshtastic-mcp-test-tui header. +tests/.tui-runs +# Cross-run history (TUI duration sparkline). +tests/.history/ +# Reproducer bundles (TUI `x` export on failed tests). +tests/reproducers/ diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 00000000000..7d5fc551a7b --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,270 @@ +# Meshtastic MCP Server + +An [MCP](https://modelcontextprotocol.io) server for working with the Meshtastic firmware repo and connected devices. Lets Claude Code / Claude Desktop: + +- Discover USB-connected Meshtastic devices +- Enumerate PlatformIO board variants (166+) with Meshtastic metadata +- Build, clean, flash, erase-and-flash (factory), and OTA-update firmware +- Read serial logs via `pio device monitor` (with board-specific exception decoders) +- Trigger 1200bps touch-reset for bootloader entry (nRF52, ESP32-S3, RP2040) +- Query and administer a running node via the [`meshtastic` Python API](https://github.com/meshtastic/python): owner name, config (LocalConfig + ModuleConfig), channels, messaging, reboot/shutdown/factory-reset +- Call `esptool`, `nrfutil`, `picotool` directly when PlatformIO doesn't cover the operation + +## Design principle + +**PlatformIO first.** Its `pio run -t upload` knows the correct protocol, offsets, and post-build chain for every variant in `variants/`. Direct vendor-tool wrappers (`esptool_*`, `nrfutil_*`, `picotool_*`) exist as escape hatches for operations pio doesn't cover (blank-chip erase, DFU `.zip` packages, BOOTSEL-mode inspection). + +## Prerequisites + +- Python ≥ 3.11 +- [PlatformIO Core](https://platformio.org/install/cli) — `pio` on `$PATH` or at `~/.platformio/penv/bin/pio` +- The Meshtastic firmware repo checked out somewhere (set via `MESHTASTIC_FIRMWARE_ROOT`) +- Optional: `esptool`, `nrfutil`, `picotool` on `$PATH` (or under the firmware venv at `.venv/bin/`) if you want to use the direct-tool wrappers + +## Install + +```bash +cd /mcp-server +python3 -m venv .venv +.venv/bin/pip install -e . +``` + +Verify: + +```bash +MESHTASTIC_FIRMWARE_ROOT= .venv/bin/python -m meshtastic_mcp +``` + +The server blocks on stdin (that's correct — it speaks MCP over stdio). Ctrl-C to exit. + +## Register with Claude Code + +Edit `~/.claude/settings.json` (global) or `/.claude/settings.local.json` (project-only): + +```json +{ + "mcpServers": { + "meshtastic": { + "command": "/mcp-server/.venv/bin/python", + "args": ["-m", "meshtastic_mcp"], + "env": { + "MESHTASTIC_FIRMWARE_ROOT": "" + } + } + } +} +``` + +Replace `` with the absolute path, e.g. `/Users/you/GitHub/firmware`. Restart Claude Code after editing. + +## Register with Claude Desktop + +Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows). + +## Tools (38) + +### Discovery & metadata + +| Tool | What it does | +| -------------- | ------------------------------------------------------------------------------------------ | +| `list_devices` | USB/serial port listing, flags likely-Meshtastic candidates | +| `list_boards` | PlatformIO envs with `custom_meshtastic_*` metadata; filters by arch/supported/query/level | +| `get_board` | Full env dict incl. raw pio config | + +### Build & flash + +| Tool | What it does | +| ----------------- | -------------------------------------------------------------------- | +| `build` | `pio run -e ` (+ mtjson target) | +| `clean` | `pio run -e -t clean` | +| `pio_flash` | `pio run -e -t upload --upload-port ` — any architecture | +| `erase_and_flash` | ESP32 full factory flash via `bin/device-install.sh` | +| `update_flash` | ESP32 OTA app-partition update via `bin/device-update.sh` | +| `touch_1200bps` | 1200-baud open/close to trigger USB CDC bootloader entry | + +### Serial log sessions + +Backed by long-running `pio device monitor` subprocesses with a 10k-line ring buffer per session and board-specific filters (`esp32_exception_decoder` auto-selected when you pass `env=`). + +| Tool | What it does | +| -------------- | ------------------------------------------------------------------ | +| `serial_open` | Start a monitor session; returns `session_id` | +| `serial_read` | Cursor-based pull; reports `dropped` if lines aged out of the ring | +| `serial_list` | All active sessions | +| `serial_close` | Terminate a session | + +### Device reads + +| Tool | What it does | +| ------------- | --------------------------------------------------------------------------- | +| `device_info` | my_node_num, long/short name, firmware version, region, channel, node count | +| `list_nodes` | Full node database with position, SNR, RSSI, last_heard, battery | + +_The tool tables below document 38 currently registered MCP server tools._ + +### Device writes + +| Tool | What it does | +| ------------------- | -------------------------------------------------------------------------- | +| `set_owner` | Long name + optional short name (≤4 chars) | +| `get_config` | One section or all (LocalConfig + ModuleConfig) | +| `set_config` | Dot-path field write: `lora.region`=`"US"`, `device.role`=`"ROUTER"`, etc. | +| `get_channel_url` | Primary-only or include_all=admin URL | +| `set_channel_url` | Import channels from a Meshtastic URL | +| `set_debug_log_api` | Enable or disable debug logging for the Meshtastic Python API client | +| `send_text` | Broadcast or direct text message | +| `reboot` | `localNode.reboot(secs)` — requires `confirm=True` | +| `shutdown` | `localNode.shutdown(secs)` — requires `confirm=True` | +| `factory_reset` | `localNode.factoryReset(full?)` — requires `confirm=True` | + +### Direct hardware tools (escape hatches) + +| Tool | What it does | +| --------------------- | --------------------------------------------------------- | +| `esptool_chip_info` | Read chip, MAC, crystal, flash size | +| `esptool_erase_flash` | Full-chip erase (destructive) | +| `esptool_raw` | Pass-through; confirm=True required for write/erase/merge | +| `nrfutil_dfu` | DFU-flash a `.zip` package | +| `nrfutil_raw` | Pass-through | +| `picotool_info` | Read Pico BOOTSEL-mode info | +| `picotool_load` | Load a UF2 | +| `picotool_raw` | Pass-through | + +## Safety + +- **All destructive flash/admin tools require `confirm=True`** as a tool-level gate, on top of any permission prompt from Claude. +- **Serial port is exclusive.** If a `serial_*` session is active on a port, `device_info`/admin tools on the same port will fail fast with a pointer at the active `session_id`. Close the session first. +- **Flash confirmation by architecture**: `erase_and_flash` / `update_flash` error if the env's architecture isn't ESP32 — use `pio_flash` for nRF52/RP2040/STM32. + +## Environment variables + +| Var | Default | Purpose | +| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | +| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo | +| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location | +| `MESHTASTIC_ESPTOOL_BIN` | `/.venv/bin/esptool` → `$PATH` | Override esptool | +| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil | +| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool | +| `MESHTASTIC_MCP_SEED` | `mcp--` | PSK seed for test-harness session (CI override) | +| `MESHTASTIC_MCP_FLASH_LOG` | `/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) | + +## Hardware Test Suite + +`mcp-server/tests/` holds a pytest-based integration suite that exercises +real USB-connected Meshtastic devices against the MCP server surface. Separate +from the native C++ unit tests in the firmware repo's top-level `test/` +directory — this one validates the device-facing behavior end-to-end. + +### Invocation + +```bash +./mcp-server/run-tests.sh # full suite (auto-detect + auto-bake-if-needed) +./mcp-server/run-tests.sh --force-bake # reflash devices before testing +./mcp-server/run-tests.sh --assume-baked # skip the bake step (caller vouches for state) +./mcp-server/run-tests.sh tests/mesh # one tier +./mcp-server/run-tests.sh tests/mesh/test_traceroute.py # one file +./mcp-server/run-tests.sh -k telemetry # pytest name filter +``` + +The wrapper auto-detects connected devices (VID `0x239A` → `nrf52` → env +`rak4631`; `0x303A` or `0x10C4` → `esp32s3` → env `heltec-v3`), exports +`MESHTASTIC_MCP_ENV_` env vars, and invokes pytest. Overrides via +per-role env vars: `MESHTASTIC_MCP_ENV_NRF52=heltec-mesh-node-t114 ./run-tests.sh`. + +No hardware connected? The wrapper narrows to `tests/unit/` only and says so +in the pre-flight header. + +### Tiers (run in this order) + +- **`bake`** (`tests/test_00_bake.py`) — flashes both hub roles with the + session's test profile. Has a skip-if-already-baked check (region + channel + match); `--force-bake` overrides. +- **`unit`** — pure Python, no hardware. boards / PIO wrapper / + userPrefs-parse / testing-profile fixtures. +- **`mesh`** — 2-device mesh: formation, broadcast delivery, direct+ACK, + traceroute, bidirectional. Parametrized over both directions. +- **`telemetry`** — periodic telemetry broadcast + on-demand request/reply + (`TELEMETRY_APP` with `wantResponse=True`). +- **`monitor`** — boot log has no panic markers within 60 s of reboot. +- **`fleet`** — PSK-seed isolation: two labs with different seeds never + overlap. +- **`admin`** — owner persistence across reboot, channel URL round-trip, + `lora.hop_limit` persistence. +- **`provisioning`** — region/channel baking, userPrefs survive + `factory_reset(full=False)`. + +### Artifacts (regenerated every run, under `tests/`) + +- `report.html` — self-contained pytest-html report. Each test gets a + **Meshtastic debug** section attached on failure with a 200-line firmware + log tail + device-state dump. Open this first on failures. +- `junit.xml` — CI-parseable. +- `reportlog.jsonl` — `pytest-reportlog` event stream; consumed by the TUI. +- `fwlog.jsonl` — firmware log mirror (`meshtastic.log.line` pubsub → JSONL). +- `flash.log` — tee of all pio / esptool / nrfutil / picotool subprocess + output during the run (driven by `MESHTASTIC_MCP_FLASH_LOG`). + +### Live TUI + +```bash +.venv/bin/meshtastic-mcp-test-tui +.venv/bin/meshtastic-mcp-test-tui tests/mesh # pytest args pass through +``` + +Textual-based wrapper over `run-tests.sh` with a live test tree, tier +counters, pytest output pane, firmware-log pane, and a device-status strip. +Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open +`report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q` +quit (SIGINT → SIGTERM → SIGKILL escalation). + +### Slash commands + +Three AI-assisted workflows are wired up for Claude Code operators +(`.claude/commands/`) and Copilot operators (`.github/prompts/`): +`/test` (run + interpret), `/diagnose` (read-only health report), `/repro` +(flake triage, N-times re-run with log diff). + +### House rules (for human + agent contributors) + +- Session-scoped fixtures in `tests/conftest.py` snapshot + restore + `userPrefs.jsonc`; **never edit `userPrefs.jsonc` from inside a test**. + Use the `test_profile` / `no_region_profile` fixtures for ephemeral + overrides. +- `SerialInterface` holds an **exclusive port lock**; sequence calls + open → mutate → close, then next device. No parallel calls to the + same port. +- Directed PKI-encrypted sends need **bilateral NodeInfo warmup** — + both sides must hold the other's current pubkey. See + `tests/mesh/_receive.py::nudge_nodeinfo_port` and the three directed- + send tests (`test_direct_with_ack`, `test_traceroute`, + `test_telemetry_request_reply`) for the canonical pattern. + +## Layout + +```text +mcp-server/ +├── pyproject.toml +├── README.md +└── src/meshtastic_mcp/ + ├── __main__.py # entry: python -m meshtastic_mcp + ├── server.py # FastMCP app + @app.tool() registrations (thin) + ├── config.py # firmware_root, pio_bin, esptool_bin, etc. + ├── pio.py # subprocess wrapper (timeouts, JSON, tail_lines) + ├── devices.py # list_devices (findPorts + comports) + ├── boards.py # list_boards / get_board (pio project config parse + cache) + ├── flash.py # build, clean, flash, erase_and_flash, update_flash, touch_1200bps + ├── serial_session.py # SerialSession + reader thread + ring buffer + ├── registry.py # session registry + per-port locks + ├── connection.py # connect(port) ctx mgr — SerialInterface + port lock + ├── info.py # device_info, list_nodes + ├── admin.py # set_owner, get/set_config, channels, send_text, reboot/shutdown/factory_reset + └── hw_tools.py # esptool / nrfutil / picotool wrappers +``` + +## Troubleshooting + +- **"Could not locate Meshtastic firmware root"** — set `MESHTASTIC_FIRMWARE_ROOT`. +- **"Could not find `pio`"** — install PlatformIO or set `MESHTASTIC_PIO_BIN`. +- **"Port is held by serial session ..."** — call `serial_close(session_id)` or `serial_list` to find it. +- **`factory.bin` not found after build** — the env may not be ESP32; only ESP32 envs produce a `.factory.bin`. +- **`touch_1200bps` reported `new_port: null`** — the device may not have 1200bps-reset stdio, or the bootloader re-uses the same port name. Check `list_devices` manually. diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 00000000000..d73bf795f5f --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "meshtastic-mcp" +version = "0.1.0" +description = "MCP server for Meshtastic firmware development: device discovery, PlatformIO tooling, flashing, serial monitoring, and device administration via the meshtastic Python API." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "GPL-3.0-only" } +authors = [{ name = "thebentern" }] +dependencies = ["mcp>=1.2", "pyserial>=3.5", "meshtastic>=2.7.8"] + +[project.optional-dependencies] +dev = ["pytest>=7"] +test = [ + "pytest>=8", + "pytest-html>=4", + "pytest-reportlog>=0.4", + "pytest-timeout>=2.3", + "coverage[toml]>=7", + "pyyaml>=6", + # textual is required by the `meshtastic-mcp-test-tui` script (see + # `src/meshtastic_mcp/cli/test_tui.py`). Bundled into `test` rather than a + # separate `[tui]` extra because v1 expects test operators are the only + # consumers; revisit if install cost pushes back. + "textual>=0.50", +] + +[project.scripts] +meshtastic-mcp = "meshtastic_mcp.__main__:main" +# Live TUI wrapping run-tests.sh — shells out to the same script the plain +# CLI uses, tails pytest-reportlog for per-test state, and polls the device +# list at startup + post-run (port lock forces it to stay idle during the run). +meshtastic-mcp-test-tui = "meshtastic_mcp.cli.test_tui:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/meshtastic_mcp"] diff --git a/mcp-server/run-tests.sh b/mcp-server/run-tests.sh new file mode 100755 index 00000000000..292e6e3a2f7 --- /dev/null +++ b/mcp-server/run-tests.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# mcp-server hardware test runner. +# +# Auto-detects connected Meshtastic devices, maps each to its PlatformIO env +# via the same role table the pytest fixtures use, exports the right +# MESHTASTIC_MCP_ENV_* env vars, and invokes pytest. +# +# Usage: +# ./run-tests.sh # full suite, default pytest args +# ./run-tests.sh tests/mesh # subset (any pytest args pass through) +# ./run-tests.sh --force-bake # override one default with another +# MESHTASTIC_MCP_ENV_NRF52=foo ./run-tests.sh # override env per role +# MESHTASTIC_MCP_SEED=ci-run-42 ./run-tests.sh # override PSK seed +# +# If zero supported devices are detected, only the unit tier runs. +# +# Also restores `userPrefs.jsonc` from the session-backup sidecar if a prior +# run exited abnormally (belt to conftest.py's atexit suspenders). + +set -euo pipefail + +# cd to the script's directory so relative paths resolve consistently no +# matter where the user invoked from. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +VENV_PY="$SCRIPT_DIR/.venv/bin/python" +if [[ ! -x $VENV_PY ]]; then + echo "error: $VENV_PY not found or not executable." >&2 + echo " Bootstrap the venv first:" >&2 + echo " cd $SCRIPT_DIR && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'" >&2 + exit 2 +fi + +# Resolve firmware root the same way conftest.py does (this script sits in +# mcp-server/, firmware repo root is one level up). +FIRMWARE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +USERPREFS_PATH="$FIRMWARE_ROOT/userPrefs.jsonc" +USERPREFS_SIDECAR="$USERPREFS_PATH.mcp-session-bak" + +# ---------- Pre-flight: recover stale userPrefs.jsonc from prior crash ---- +# If conftest.py's atexit hook didn't fire (SIGKILL, kernel panic, OS +# restart), the sidecar is the ground truth. Self-heal before running so we +# don't bake the previous run's dirty state into this run's firmware. +if [[ -f $USERPREFS_SIDECAR ]]; then + echo "[pre-flight] found $USERPREFS_SIDECAR from a prior abnormal exit;" >&2 + echo " restoring userPrefs.jsonc before starting." >&2 + cp "$USERPREFS_SIDECAR" "$USERPREFS_PATH" + rm -f "$USERPREFS_SIDECAR" +fi + +# If userPrefs.jsonc has uncommitted changes BEFORE the run starts, that's +# worth warning about — tests will snapshot this dirty state and restore to +# it at the end, which may not be what the operator wants. +if command -v git >/dev/null 2>&1; then + cd "$FIRMWARE_ROOT" + # Capture the git status into a local first — SC2312 flags command + # substitution inside `[[ -n ... ]]` because the exit code of `git + # status` is masked. A two-step assignment makes the failure path + # explicit (non-git, missing file) and keeps the bracket test clean. + _git_status_porcelain="$(git status --porcelain userPrefs.jsonc 2>/dev/null || true)" + if [[ -n $_git_status_porcelain ]]; then + echo "[pre-flight] warning: userPrefs.jsonc has uncommitted changes." >&2 + echo " Tests will snapshot THIS state and restore to it" >&2 + echo " at teardown. If that's not intended, run:" >&2 + echo " git checkout userPrefs.jsonc" >&2 + echo " and re-invoke." >&2 + fi + cd "$SCRIPT_DIR" +fi + +# ---------- Seed default -------------------------------------------------- +# Per-machine default so repeated runs from the same operator land on the +# same PSK (makes --assume-baked valid across invocations). Operator can +# override with an explicit env var if they want isolation (e.g. CI). +if [[ -z ${MESHTASTIC_MCP_SEED-} ]]; then + WHO="$(whoami 2>/dev/null || echo anon)" + HOST="$(hostname -s 2>/dev/null || echo host)" + export MESHTASTIC_MCP_SEED="mcp-${WHO}-${HOST}" +fi + +# ---------- Flash progress log -------------------------------------------- +# pio.py / hw_tools.py tee subprocess output (pio run -t upload, esptool, +# nrfutil, picotool) to this file line-by-line as it arrives when this env +# var is set. The TUI tails it so the operator sees live flash progress +# instead of 3 minutes of silence during `test_00_bake.py`. Plain CLI users +# also benefit — the log is a post-run diagnostic even without the TUI. +# Truncate at session start so each run gets a clean log. +export MESHTASTIC_MCP_FLASH_LOG="$SCRIPT_DIR/tests/flash.log" +: >"$MESHTASTIC_MCP_FLASH_LOG" + +# ---------- Detect connected hardware ------------------------------------- +# In-process call to the same Python API the test fixtures use, so the +# script never drifts from what pytest sees. Returns a JSON object +# {role: port, ...}. +ROLES_JSON="$( + "$VENV_PY" - <<'PY' +import json +import sys + +sys.path.insert(0, "src") +from meshtastic_mcp import devices + +# Role → canonical VID map. Kept in sync with +# `tests/conftest.py::hub_profile` defaults; if that changes, this must too. +ROLE_BY_VID = { + 0x239A: "nrf52", # Adafruit / RAK nRF52 native USB (app + DFU) + 0x303A: "esp32s3", # Espressif native USB (ESP32-S3) + 0x10C4: "esp32s3", # CP2102 USB-UART (common on Heltec/LilyGO ESP32 boards) +} + +out: dict[str, str] = {} +for dev in devices.list_devices(include_unknown=True): + vid_raw = dev.get("vid") or "" + try: + if isinstance(vid_raw, str) and vid_raw.startswith("0x"): + vid = int(vid_raw, 16) + else: + vid = int(vid_raw) + except (TypeError, ValueError): + continue + role = ROLE_BY_VID.get(vid) + # First port wins per role — matches hub_devices fixture semantics. + if role and role not in out: + out[role] = dev["port"] + +json.dump(out, sys.stdout) +PY +)" + +# ---------- Map role → pio env -------------------------------------------- +# Honor MESHTASTIC_MCP_ENV_ operator overrides; fall back to the +# same defaults hardcoded in tests/conftest.py::_DEFAULT_ROLE_ENVS. +resolve_env() { + local role="$1" + local default="$2" + local upper + upper="$(echo "$role" | tr '[:lower:]' '[:upper:]')" + local var="MESHTASTIC_MCP_ENV_${upper}" + eval "local override=\${$var:-}" + if [[ -n $override ]]; then + echo "$override" + else + echo "$default" + fi +} + +NRF52_PORT="$(echo "$ROLES_JSON" | "$VENV_PY" -c 'import json,sys; print(json.loads(sys.stdin.read()).get("nrf52", ""))')" +ESP32S3_PORT="$(echo "$ROLES_JSON" | "$VENV_PY" -c 'import json,sys; print(json.loads(sys.stdin.read()).get("esp32s3", ""))')" + +DETECTED="" +if [[ -n $NRF52_PORT ]]; then + NRF52_ENV="$(resolve_env nrf52 rak4631)" + export MESHTASTIC_MCP_ENV_NRF52="$NRF52_ENV" + DETECTED="${DETECTED} nrf52 @ ${NRF52_PORT} -> env=${NRF52_ENV}\n" +fi +if [[ -n $ESP32S3_PORT ]]; then + ESP32S3_ENV="$(resolve_env esp32s3 heltec-v3)" + export MESHTASTIC_MCP_ENV_ESP32S3="$ESP32S3_ENV" + DETECTED="${DETECTED} esp32s3 @ ${ESP32S3_PORT} -> env=${ESP32S3_ENV}\n" +fi + +# ---------- Pre-flight summary -------------------------------------------- +# Surface what pytest is about to do with respect to the bake phase: the +# operator should see "will verify + bake if needed" by default, so a +# 3-minute flash appearing mid-run isn't a surprise. Detection of the +# explicit overrides is best-effort — we just scan $@ for the known flags. +_bake_mode="auto (verify + bake if needed)" +for _arg in "$@"; do + case "$_arg" in + --assume-baked) _bake_mode="skip (--assume-baked)" ;; + --force-bake) _bake_mode="force (--force-bake)" ;; + *) ;; # any other arg: pass-through; bake mode unchanged + esac +done + +echo "mcp-server test runner" +echo " firmware root : $FIRMWARE_ROOT" +echo " seed : $MESHTASTIC_MCP_SEED" +echo " bake : $_bake_mode" +if [[ -n $DETECTED ]]; then + echo " detected hub :" + printf "%b" "$DETECTED" +else + echo " detected hub : (none)" +fi +echo + +# ---------- Invoke pytest ------------------------------------------------- +# If no devices detected, only the unit tier would produce meaningful +# PASS/FAIL — every hardware test would SKIP with "role not present". We +# narrow to tests/unit explicitly so the summary reads as "no hardware, +# unit suite only" instead of "big skip count looks suspicious". +if [[ -z $DETECTED && $# -eq 0 ]]; then + echo "[pre-flight] no supported devices detected; running unit tier only." + echo + exec "$VENV_PY" -m pytest tests/unit -v --report-log=tests/reportlog.jsonl +fi + +# Default pytest args when the user passed none. Power users can invoke +# `./run-tests.sh tests/mesh -v --tb=long` and skip all of these defaults. +# +# NOTE: `--assume-baked` is DELIBERATELY omitted here. `tests/test_00_bake.py` +# has an internal skip-if-already-baked check (`_bake_role`: query device_info, +# compare region + primary_channel to the session profile, skip on match). +# So the fast path is ~8-10 s of verification overhead when the devices are +# already baked — negligible next to the 2-6 min suite runtime. Letting +# test_00_bake.py run means a fresh device, a re-seeded session, or a post- +# factory-reset device gets flashed automatically instead of silently +# skipping half the hardware tests with "not baked with session profile" +# errors. Power users who know their hardware is current and want to shave +# those seconds can pass `--assume-baked` explicitly. +if [[ $# -eq 0 ]]; then + set -- tests/ \ + --html=tests/report.html --self-contained-html \ + --junitxml=tests/junit.xml \ + -v --tb=short +fi + +# Always emit `tests/reportlog.jsonl` (unless the operator explicitly passed +# their own `--report-log=...`). Consumers — notably the +# `meshtastic-mcp-test-tui` TUI — tail the reportlog for live per-test state. +# Appending here means power-user invocations like `./run-tests.sh tests/mesh` +# also produce it, not just the all-defaults invocation. +_has_report_log=0 +for _arg in "$@"; do + case "$_arg" in + --report-log | --report-log=*) _has_report_log=1 ;; + *) ;; # any other arg: no-op; loop continues + esac +done +if [[ $_has_report_log -eq 0 ]]; then + set -- "$@" --report-log=tests/reportlog.jsonl +fi + +exec "$VENV_PY" -m pytest "$@" diff --git a/mcp-server/src/meshtastic_mcp/__init__.py b/mcp-server/src/meshtastic_mcp/__init__.py new file mode 100644 index 00000000000..bd696afe01d --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/__init__.py @@ -0,0 +1,3 @@ +"""Meshtastic MCP server — device discovery, PlatformIO tooling, and device admin.""" + +__version__ = "0.1.0" diff --git a/mcp-server/src/meshtastic_mcp/__main__.py b/mcp-server/src/meshtastic_mcp/__main__.py new file mode 100644 index 00000000000..4ed67db3821 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/__main__.py @@ -0,0 +1,11 @@ +"""Entry point for `python -m meshtastic_mcp`.""" + +from meshtastic_mcp.server import app + + +def main() -> None: + app.run() + + +if __name__ == "__main__": + main() diff --git a/mcp-server/src/meshtastic_mcp/admin.py b/mcp-server/src/meshtastic_mcp/admin.py new file mode 100644 index 00000000000..6da92d860a4 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/admin.py @@ -0,0 +1,377 @@ +"""Device administration: owner, config, channels, messaging, admin actions. + +All operations use the same `connect()` context manager so port selection, +port-busy detection, and cleanup are handled uniformly. + +Config writes use a dot-path: the first segment names a section (e.g. +`"lora"` in LocalConfig or `"mqtt"` in LocalModuleConfig), remaining segments +walk protobuf fields. Enum fields accept their string names (`"US"` for +`lora.region`) so callers don't need to know the numeric values. +""" + +from __future__ import annotations + +from typing import Any + +from google.protobuf import descriptor as pb_descriptor +from google.protobuf import json_format +from meshtastic.protobuf import localonly_pb2 + +from .connection import connect + + +class AdminError(RuntimeError): + pass + + +LOCAL_CONFIG_SECTIONS = {f.name for f in localonly_pb2.LocalConfig.DESCRIPTOR.fields} +MODULE_CONFIG_SECTIONS = { + f.name for f in localonly_pb2.LocalModuleConfig.DESCRIPTOR.fields +} + + +def _require_confirm(confirm: bool, operation: str) -> None: + if not confirm: + raise AdminError(f"{operation} is destructive and requires confirm=True.") + + +def _message_to_dict(msg: Any) -> dict[str, Any]: + # `including_default_value_fields` was renamed to + # `always_print_fields_with_no_presence` in protobuf 5.26+. Pick whichever + # kwarg the installed version accepts so we work against both. + kwargs: dict[str, Any] = {"preserving_proto_field_name": True} + import inspect + + sig = inspect.signature(json_format.MessageToDict) + if "always_print_fields_with_no_presence" in sig.parameters: + kwargs["always_print_fields_with_no_presence"] = False + elif "including_default_value_fields" in sig.parameters: + kwargs["including_default_value_fields"] = False + return json_format.MessageToDict(msg, **kwargs) + + +# ---------- owner ---------------------------------------------------------- + + +def set_owner( + long_name: str, + short_name: str | None = None, + port: str | None = None, +) -> dict[str, Any]: + if short_name is not None and len(short_name) > 4: + raise AdminError("short_name must be 4 characters or fewer") + with connect(port=port) as iface: + iface.localNode.setOwner(long_name=long_name, short_name=short_name) + return { + "ok": True, + "long_name": long_name, + "short_name": short_name, + } + + +# ---------- config reads --------------------------------------------------- + + +def _section_container(node, section: str) -> tuple[Any, str]: + """Return (container_message, parent_name) for a section name. + + Parent is 'localConfig' or 'moduleConfig' so callers know where to call + writeConfig() after mutating. + """ + if section in LOCAL_CONFIG_SECTIONS: + return getattr(node.localConfig, section), "localConfig" + if section in MODULE_CONFIG_SECTIONS: + return getattr(node.moduleConfig, section), "moduleConfig" + raise AdminError( + f"Unknown config section: {section!r}. " + f"Valid sections: {sorted(LOCAL_CONFIG_SECTIONS | MODULE_CONFIG_SECTIONS)}" + ) + + +def get_config(section: str | None = None, port: str | None = None) -> dict[str, Any]: + """Read one or all config sections. + + `section` may be any name in LocalConfig (device, lora, position, power, + network, display, bluetooth, security) or LocalModuleConfig (mqtt, serial, + telemetry, ...). Omit `section` or pass `"all"` for everything. + """ + with connect(port=port) as iface: + node = iface.localNode + if section in (None, "all"): + lc = _message_to_dict(node.localConfig) + mc = _message_to_dict(node.moduleConfig) + return { + "config": { + "localConfig": lc, + "moduleConfig": mc, + } + } + container, _parent = _section_container(node, section) + return {"config": {section: _message_to_dict(container)}} + + +# ---------- config writes -------------------------------------------------- + + +def _coerce_enum(field: pb_descriptor.FieldDescriptor, value: Any) -> int: + """Accept an enum value as either its int or its string name.""" + enum_type = field.enum_type + if isinstance(value, bool): + raise AdminError(f"{field.name}: expected enum {enum_type.name}, got bool") + if isinstance(value, int): + if enum_type.values_by_number.get(value) is None: + raise AdminError( + f"{field.name}: {value} is not a valid {enum_type.name} value" + ) + return value + if isinstance(value, str): + upper = value.upper() + ev = enum_type.values_by_name.get(upper) + if ev is None: + valid = sorted(enum_type.values_by_name.keys()) + raise AdminError( + f"{field.name}: {value!r} is not a valid {enum_type.name}. " + f"Valid: {valid}" + ) + return ev.number + raise AdminError( + f"{field.name}: expected enum {enum_type.name}, got {type(value).__name__}" + ) + + +def _coerce_scalar(field: pb_descriptor.FieldDescriptor, value: Any) -> Any: + t = field.type + FT = pb_descriptor.FieldDescriptor + if t == FT.TYPE_ENUM: + return _coerce_enum(field, value) + if t == FT.TYPE_BOOL: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "yes", "1", "on") + if isinstance(value, int): + return bool(value) + if t in ( + FT.TYPE_INT32, + FT.TYPE_INT64, + FT.TYPE_UINT32, + FT.TYPE_UINT64, + FT.TYPE_SINT32, + FT.TYPE_SINT64, + FT.TYPE_FIXED32, + FT.TYPE_FIXED64, + ): + return int(value) + if t in (FT.TYPE_FLOAT, FT.TYPE_DOUBLE): + return float(value) + if t == FT.TYPE_STRING: + return str(value) + if t == FT.TYPE_BYTES: + if isinstance(value, (bytes, bytearray)): + return bytes(value) + return str(value).encode("utf-8") + raise AdminError( + f"{field.name}: unsupported field type {t}. Use raw protobuf for this field." + ) + + +def _walk_to_field( + root_msg: Any, path_segments: list[str] +) -> tuple[Any, pb_descriptor.FieldDescriptor]: + """Walk `root_msg` by field names until the leaf; return (parent_msg, leaf_field_descriptor).""" + msg = root_msg + for i, name in enumerate(path_segments): + desc = msg.DESCRIPTOR + field = desc.fields_by_name.get(name) + if field is None: + trail = ".".join(path_segments[:i] or [""]) + valid = [f.name for f in desc.fields] + raise AdminError(f"No field {name!r} in {trail}. Valid: {valid}") + is_last = i == len(path_segments) - 1 + if is_last: + return msg, field + if field.type != pb_descriptor.FieldDescriptor.TYPE_MESSAGE: + raise AdminError( + f"{'.'.join(path_segments[:i+1])} is a scalar; cannot descend into it" + ) + msg = getattr(msg, name) + # path_segments was empty + raise AdminError("Empty config path") + + +def set_config(path: str, value: Any, port: str | None = None) -> dict[str, Any]: + """Set a single config field by dot-path and write it to the device. + + Examples: + set_config("lora.region", "US") + set_config("lora.modem_preset", "LONG_FAST") + set_config("device.role", "ROUTER") + set_config("mqtt.enabled", True) + set_config("mqtt.address", "mqtt.example.com") + + """ + segments = [s for s in path.split(".") if s] + if not segments: + raise AdminError("path cannot be empty") + section = segments[0] + + with connect(port=port) as iface: + node = iface.localNode + container, parent_name = _section_container(node, section) + + # Treat the section as the root; the rest of the path walks into it. + leaf_parent, field = _walk_to_field(container, segments[1:] or []) + # Use `is_repeated` (modern upb protobuf API) rather than the + # deprecated `label == LABEL_REPEATED` check — the C-extension + # FieldDescriptor in protobuf >= 5.x doesn't expose `.label` at + # all, and `is_repeated` is the supported replacement that works + # across both the pure-python and upb backends. + if field.is_repeated: + raise AdminError( + f"{path!r} is a repeated field; v1 only supports scalar sets. " + "Use the raw meshtastic CLI for now." + ) + old_raw = getattr(leaf_parent, field.name) + coerced = _coerce_scalar(field, value) + try: + setattr(leaf_parent, field.name, coerced) + except (TypeError, ValueError) as exc: + raise AdminError(f"{path}: {exc}") from exc + + node.writeConfig(section) + + # Stringify enums for the response (so the caller can see the change in + # the same vocabulary they used to set it). + if field.type == pb_descriptor.FieldDescriptor.TYPE_ENUM: + try: + old_display = field.enum_type.values_by_number[old_raw].name + new_display = field.enum_type.values_by_number[coerced].name + except Exception: + old_display, new_display = old_raw, coerced + else: + old_display, new_display = old_raw, coerced + + return { + "ok": True, + "path": path, + "section": section, + "parent": parent_name, + "old_value": old_display, + "new_value": new_display, + } + + +# ---------- channels ------------------------------------------------------- + + +def get_channel_url( + include_all: bool = False, port: str | None = None +) -> dict[str, Any]: + with connect(port=port) as iface: + url = iface.localNode.getURL(includeAll=include_all) + return {"url": url} + + +def set_channel_url(url: str, port: str | None = None) -> dict[str, Any]: + with connect(port=port) as iface: + # setURL replaces the channel set from the URL's contents. It does not + # return a count; we infer by counting non-DISABLED channels after. + iface.localNode.setURL(url) + channels = iface.localNode.channels or [] + active = sum(1 for c in channels if getattr(c, "role", 0) != 0) + return {"ok": True, "channels_imported": active} + + +# ---------- messaging ------------------------------------------------------ + + +def send_text( + text: str, + to: str | int | None = None, + channel_index: int = 0, + want_ack: bool = False, + port: str | None = None, +) -> dict[str, Any]: + destination = to if to is not None else "^all" + with connect(port=port) as iface: + packet = iface.sendText( + text, + destinationId=destination, + wantAck=want_ack, + channelIndex=channel_index, + ) + packet_id = getattr(packet, "id", None) + return {"ok": True, "packet_id": packet_id, "destination": destination} + + +# ---------- diagnostics ---------------------------------------------------- + + +def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]: + """Toggle `config.security.debug_log_api_enabled` on the local node. + + When enabled, firmware emits log lines as protobuf `LogRecord` messages + over the StreamAPI instead of raw text. meshtastic-python surfaces them + on pubsub topic `meshtastic.log.line`, which flows through the SAME + SerialInterface our tests already hold open — no `pio device monitor` + needed, no port-contention with admin/info calls. + + Firmware gate: `src/SerialConsole.cpp` (`usingProtobufs && + config.security.debug_log_api_enabled`). Setting persists in NVS; it + survives reboot. `factory_reset(full=False)` clears it unless it's + re-applied after reset. + + Previously-documented concurrency hazard (emitLogRecord sharing the + main packet-emission buffers) has been fixed — see `StreamAPI.h` + where the log path now owns dedicated `fromRadioScratchLog` / + `txBufLog` buffers, and `StreamAPI::emitTxBuffer` + + `StreamAPI::emitLogRecord` both serialize their `stream->write` + calls via `streamLock`. Leaving the flag on under traffic is safe. + """ + with connect(port=port) as iface: + sec = iface.localNode.localConfig.security + sec.debug_log_api_enabled = bool(enabled) + iface.localNode.writeConfig("security") + return {"ok": True, "debug_log_api_enabled": bool(enabled)} + + +# ---------- admin actions -------------------------------------------------- + + +def reboot( + port: str | None = None, confirm: bool = False, seconds: int = 10 +) -> dict[str, Any]: + _require_confirm(confirm, "reboot") + with connect(port=port) as iface: + iface.localNode.reboot(secs=seconds) + return {"ok": True, "rebooting_in_s": seconds} + + +def shutdown( + port: str | None = None, confirm: bool = False, seconds: int = 10 +) -> dict[str, Any]: + _require_confirm(confirm, "shutdown") + with connect(port=port) as iface: + iface.localNode.shutdown(secs=seconds) + return {"ok": True, "shutting_down_in_s": seconds} + + +def factory_reset( + port: str | None = None, confirm: bool = False, full: bool = False +) -> dict[str, Any]: + """Tell the node to factory-reset its config. + + Works around a meshtastic-python 2.7.8 bug: `Node.factoryReset(full=True)` + internally does `p.factory_reset_config = True` where the field is + int32. protobuf 5.x rejects bool→int assignment as a TypeError. We build + the AdminMessage directly with int values (1=non-full, 2=full) and call + `_sendAdmin` to sidestep the SDK bug entirely. + """ + _require_confirm(confirm, "factory_reset") + from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped] + + with connect(port=port) as iface: + msg = admin_pb2.AdminMessage() + msg.factory_reset_config = 2 if full else 1 + iface.localNode._sendAdmin(msg) + return {"ok": True, "full": full} diff --git a/mcp-server/src/meshtastic_mcp/boards.py b/mcp-server/src/meshtastic_mcp/boards.py new file mode 100644 index 00000000000..df5024800a6 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/boards.py @@ -0,0 +1,159 @@ +"""Board / PlatformIO env enumeration. + +Parses `pio project config --json-output` — a nested list of +`[section_name, [[key, value], ...]]` pairs — into a dict keyed by env name, +extracting the `custom_meshtastic_*` metadata the firmware variants expose. + +The parsed config is cached and invalidated when `platformio.ini`'s mtime +changes, so subsequent calls don't pay the 1–2s pio startup cost. +""" + +from __future__ import annotations + +import threading +from typing import Any + +from . import config, pio + +_CACHE_LOCK = threading.Lock() +_CACHE: dict[str, Any] = {"mtime": None, "envs": None} + + +def _parse_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "yes", "1", "on") + return bool(value) + + +def _parse_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _parse_tags(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + return [t.strip() for t in str(value).replace(",", " ").split() if t.strip()] + + +def _env_record(env_name: str, items: list[list[Any]]) -> dict[str, Any]: + """Build a normalized dict for one env section.""" + d = dict(items) + return { + "env": env_name, + "architecture": d.get("custom_meshtastic_architecture"), + "hw_model": _parse_int(d.get("custom_meshtastic_hw_model")), + "hw_model_slug": d.get("custom_meshtastic_hw_model_slug"), + "display_name": d.get("custom_meshtastic_display_name"), + "actively_supported": _parse_bool( + d.get("custom_meshtastic_actively_supported") + ), + "support_level": _parse_int(d.get("custom_meshtastic_support_level")), + "board_level": d.get("board_level"), # "pr", "extra", or None + "tags": _parse_tags(d.get("custom_meshtastic_tags")), + "images": _parse_tags(d.get("custom_meshtastic_images")), + "board": d.get("board"), + "upload_speed": _parse_int(d.get("upload_speed")), + "upload_protocol": d.get("upload_protocol"), + "monitor_speed": _parse_int(d.get("monitor_speed")), + "monitor_filters": d.get("monitor_filters") or [], + "_raw": d, # Full dict for get_board + } + + +def _load_all() -> dict[str, dict[str, Any]]: + """Parse `pio project config` into `{env_name: record}`.""" + raw = pio.run_json(["project", "config"], timeout=pio.TIMEOUT_PROJECT_CONFIG) + result: dict[str, dict[str, Any]] = {} + for section_name, items in raw: + if not isinstance(section_name, str) or not section_name.startswith("env:"): + continue + env_name = section_name.split(":", 1)[1] + result[env_name] = _env_record(env_name, items) + return result + + +def _get_cached() -> dict[str, dict[str, Any]]: + root = config.firmware_root() + platformio_ini = root / "platformio.ini" + try: + mtime = platformio_ini.stat().st_mtime + except FileNotFoundError: + mtime = None + + with _CACHE_LOCK: + if _CACHE["envs"] is not None and _CACHE["mtime"] == mtime: + return _CACHE["envs"] + envs = _load_all() + _CACHE["envs"] = envs + _CACHE["mtime"] = mtime + return envs + + +def invalidate_cache() -> None: + with _CACHE_LOCK: + _CACHE["envs"] = None + _CACHE["mtime"] = None + + +def _public_record(rec: dict[str, Any]) -> dict[str, Any]: + """Strip the `_raw` field for list outputs.""" + return {k: v for k, v in rec.items() if not k.startswith("_")} + + +def list_boards( + architecture: str | None = None, + actively_supported_only: bool = False, + query: str | None = None, + board_level: str | None = None, # "release" | "pr" | "extra" +) -> list[dict[str, Any]]: + """Enumerate PlatformIO envs with Meshtastic metadata. + + Filters are cumulative (AND). `board_level="release"` means envs with no + explicit `board_level` set (the default release targets). + """ + envs = _get_cached() + q = query.lower().strip() if query else None + + out = [] + for rec in envs.values(): + if architecture and rec.get("architecture") != architecture: + continue + if actively_supported_only and not rec.get("actively_supported"): + continue + if board_level is not None: + rec_level = rec.get("board_level") + if board_level == "release": + if rec_level not in (None, ""): + continue + elif rec_level != board_level: + continue + if q: + display = (rec.get("display_name") or "").lower() + env_name = rec.get("env", "").lower() + slug = (rec.get("hw_model_slug") or "").lower() + if q not in display and q not in env_name and q not in slug: + continue + out.append(_public_record(rec)) + + out.sort(key=lambda r: (r.get("architecture") or "", r.get("env"))) + return out + + +def get_board(env: str) -> dict[str, Any]: + """Full metadata for one env, including the raw pio config dict.""" + envs = _get_cached() + rec = envs.get(env) + if rec is None: + raise KeyError( + f"Unknown env: {env!r}. Use list_boards() to see available envs." + ) + public = _public_record(rec) + public["raw_config"] = rec["_raw"] + return public diff --git a/mcp-server/src/meshtastic_mcp/cli/__init__.py b/mcp-server/src/meshtastic_mcp/cli/__init__.py new file mode 100644 index 00000000000..04729b643e1 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/__init__.py @@ -0,0 +1,6 @@ +"""Command-line entry points that sit alongside the MCP server. + +Modules here are loaded on-demand by `[project.scripts]` entries in +`pyproject.toml`. They are NOT imported by `meshtastic_mcp.server` or the +admin/info tool surface — the MCP server stays pure stdio JSON-RPC. +""" diff --git a/mcp-server/src/meshtastic_mcp/cli/_flashlog.py b/mcp-server/src/meshtastic_mcp/cli/_flashlog.py new file mode 100644 index 00000000000..889183bb30e --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/_flashlog.py @@ -0,0 +1,73 @@ +"""Flash progress log tailer for ``meshtastic-mcp-test-tui``. + +``pio.py`` / ``hw_tools.py`` tee subprocess output (``pio run -t upload``, +``esptool erase_flash``, ``nrfutil dfu``, etc.) to ``tests/flash.log`` +line-by-line as it arrives — controlled by the ``MESHTASTIC_MCP_FLASH_LOG`` +env var that ``run-tests.sh`` sets. The TUI tails that file so the operator +sees live flash progress in the pytest pane instead of 3 minutes of silence +during ``test_00_bake``. + +Separate from ``_fwlog.py`` because that one parses JSONL, this one +streams plain text lines. Same daemon-thread + EOF-backoff structure. +""" + +from __future__ import annotations + +import pathlib +import threading +import time +from typing import Callable + + +class FlashLogTailer(threading.Thread): + """Tail a plain-text log file, publish each stripped line via ``post``. + + ``post`` is invoked with a single ``str`` for every new line. Lines are + stripped of trailing newlines; empty lines after stripping are dropped. + + The file may not exist yet when this thread starts — it's truncated by + ``run-tests.sh`` at session start, but if the tailer races the shell, + we tolerate FileNotFoundError for up to ``wait_s`` seconds. + """ + + def __init__( + self, + path: pathlib.Path, + post: Callable[[str], None], + stop: threading.Event, + *, + wait_s: float = 30.0, + ) -> None: + super().__init__(daemon=True, name="flashlog-tail") + self._path = path + self._post = post + self._stop = stop + self._wait_s = wait_s + + def run(self) -> None: + deadline = time.monotonic() + self._wait_s + while not self._path.is_file(): + if self._stop.is_set() or time.monotonic() > deadline: + return + time.sleep(0.1) + try: + fh = self._path.open("r", encoding="utf-8", errors="replace") + except OSError: + return + try: + while not self._stop.is_set(): + line = fh.readline() + if not line: + time.sleep(0.05) + continue + line = line.rstrip("\r\n") + if not line: + continue + try: + self._post(line) + except Exception: + # A post failure (e.g. closed app) is terminal for this + # thread but we still want to close the file handle. + return + finally: + fh.close() diff --git a/mcp-server/src/meshtastic_mcp/cli/_fwlog.py b/mcp-server/src/meshtastic_mcp/cli/_fwlog.py new file mode 100644 index 00000000000..7db20f81cc8 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/_fwlog.py @@ -0,0 +1,96 @@ +"""Firmware log tail worker for ``meshtastic-mcp-test-tui``. + +Complements v1's reportlog-tail worker. ``tests/conftest.py`` owns a +session-scoped autouse fixture (``_firmware_log_stream``) that mirrors +every ``meshtastic.log.line`` pubsub event to ``tests/fwlog.jsonl`` — +one JSON object per line: + + {"ts": 1729100000.123, "port": "/dev/cu.usbmodem1101", "line": "..."} + +The TUI tails that file from a worker thread; each new line becomes a +:class:`FirmwareLogLine` message posted to the App. Same pattern as the +reportlog tail worker — truncate on launch, tolerate missing file for +30 s, back off at EOF. + +Kept in its own module so the (large) ``test_tui.py`` stays focused on +the Textual App shell. +""" + +from __future__ import annotations + +import json +import pathlib +import threading +import time +from typing import Any, Callable + + +class FirmwareLogTailer(threading.Thread): + """Tail ``tests/fwlog.jsonl``, publish parsed records via ``post``. + + ``post`` is the App's ``post_message`` (or any callable that accepts a + single payload arg). We pass parsed dicts rather than constructing + Textual Message objects here — keeps this module free of the + textual dependency so it's unit-testable in a bare venv. + + Parameters + ---------- + path: + Path to ``tests/fwlog.jsonl``. The file may not exist yet at + startup — pytest only creates it once the session fixture runs. + post: + Callable invoked with a dict ``{"ts", "port", "line"}`` for every + new line parsed from the file. + stop: + An event the App sets to signal shutdown. + wait_s: + How long to poll for the file's creation before giving up. Default + 30 s; pytest collection on a cold cache can be slow. + + """ + + def __init__( + self, + path: pathlib.Path, + post: Callable[[dict[str, Any]], None], + stop: threading.Event, + *, + wait_s: float = 30.0, + ) -> None: + super().__init__(daemon=True, name="fwlog-tail") + self._path = path + self._post = post + self._stop = stop + self._wait_s = wait_s + + def run(self) -> None: + deadline = time.monotonic() + self._wait_s + while not self._path.is_file(): + if self._stop.is_set() or time.monotonic() > deadline: + return + time.sleep(0.1) + try: + fh = self._path.open("r", encoding="utf-8") + except OSError: + return + try: + while not self._stop.is_set(): + line = fh.readline() + if not line: + time.sleep(0.05) + continue + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + # Defensive: require the three fields we rely on. + if not isinstance(record, dict): + continue + if "line" not in record: + continue + self._post(record) + finally: + fh.close() diff --git a/mcp-server/src/meshtastic_mcp/cli/_history.py b/mcp-server/src/meshtastic_mcp/cli/_history.py new file mode 100644 index 00000000000..639dcec5f55 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/_history.py @@ -0,0 +1,127 @@ +"""Cross-run history for ``meshtastic-mcp-test-tui``. + +Persists one JSON object per pytest run to +``mcp-server/tests/.history/runs.jsonl``. The TUI reads the last N +entries on launch to render a duration sparkline in the header — a +quick read on whether the suite is slowing down over time. + +Schema (keep small; the file can grow for months): + + {"run": 42, "ts": 1729100000.0, "duration_s": 387.2, + "passed": 52, "failed": 0, "skipped": 23, "exit_code": 0, + "seed": "mcp-user-host"} +""" + +from __future__ import annotations + +import json +import pathlib +import time +from dataclasses import asdict, dataclass +from typing import Iterable + +# Sparkline glyphs, low → high. 8 levels is the Unicode convention. +_SPARK_BLOCKS = "▁▂▃▄▅▆▇█" + + +@dataclass +class RunRecord: + run: int + ts: float + duration_s: float + passed: int + failed: int + skipped: int + exit_code: int + seed: str + + +class HistoryStore: + """Append-only JSONL store with bounded read. + + Writes are fsynced after each append (the file is tiny; fsync cost + is negligible and protects against truncation on a crash). + """ + + def __init__(self, path: pathlib.Path, *, keep_last: int = 50) -> None: + self._path = path + self._keep_last = keep_last + + def append(self, record: RunRecord) -> None: + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(asdict(record)) + "\n") + fh.flush() + except Exception: + # Non-fatal: history is cosmetic. + pass + + def read_recent(self) -> list[RunRecord]: + """Return the last ``keep_last`` records in chronological order.""" + if not self._path.is_file(): + return [] + try: + lines = self._path.read_text(encoding="utf-8").splitlines() + except OSError: + return [] + out: list[RunRecord] = [] + # Parse tail-first so we don't waste work on a huge history. + for line in lines[-self._keep_last :]: + line = line.strip() + if not line: + continue + try: + raw = json.loads(line) + except json.JSONDecodeError: + continue + try: + out.append(RunRecord(**raw)) + except TypeError: + # Schema drift; skip the record rather than crash. + continue + return out + + def record_run( + self, + *, + run: int, + duration_s: float, + passed: int, + failed: int, + skipped: int, + exit_code: int, + seed: str, + ) -> RunRecord: + rec = RunRecord( + run=run, + ts=time.time(), + duration_s=float(duration_s), + passed=int(passed), + failed=int(failed), + skipped=int(skipped), + exit_code=int(exit_code), + seed=seed, + ) + self.append(rec) + return rec + + +def sparkline(values: Iterable[float], *, width: int = 20) -> str: + """Render a Unicode block-character sparkline from the last ``width`` values. + + Returns an empty string for empty input so the header handles + "no history yet" gracefully. + """ + buf = [v for v in values if v >= 0][-width:] + if not buf: + return "" + lo, hi = min(buf), max(buf) + if hi - lo < 1e-9: + return _SPARK_BLOCKS[len(_SPARK_BLOCKS) // 2] * len(buf) + n = len(_SPARK_BLOCKS) - 1 + out = [] + for v in buf: + idx = int(round((v - lo) / (hi - lo) * n)) + out.append(_SPARK_BLOCKS[max(0, min(n, idx))]) + return "".join(out) diff --git a/mcp-server/src/meshtastic_mcp/cli/_reproducer.py b/mcp-server/src/meshtastic_mcp/cli/_reproducer.py new file mode 100644 index 00000000000..420da3c76a7 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/_reproducer.py @@ -0,0 +1,214 @@ +"""Reproducer bundle builder for ``meshtastic-mcp-test-tui``. + +When the operator presses ``x`` on a failed test leaf, we package the +minimum viable failure context into a tarball under +``mcp-server/tests/reproducers/``: + +:: + + repro--.tar.gz + ├── README.md human-readable overview + ├── test_report.json the failing TestReport event from reportlog + ├── fwlog.jsonl firmware log filtered to the failure window + ├── devices.json per-device device_info + lora config snapshot + └── env.json seed, run #, pytest version, platform, hostname + +Separate module so the logic can be unit-tested without Textual. The +TUI glue is thin — one key binding calls :func:`build_reproducer_bundle` +with the focused test's state and shows the path in a modal. +""" + +from __future__ import annotations + +import io +import json +import pathlib +import platform +import re +import socket +import tarfile +import time +from dataclasses import dataclass +from typing import Any, Iterable + + +@dataclass +class ReproContext: + """Everything :func:`build_reproducer_bundle` needs. Shaped to map + cleanly onto the state the TUI already tracks — no extra data + collection required at export time.""" + + nodeid: str + longrepr: str + sections: list[tuple[str, str]] + start_ts: float | None + stop_ts: float | None + seed: str + run_number: int + exit_code: int | None + fwlog_path: pathlib.Path + output_dir: pathlib.Path + extra_device_rows: list[dict[str, Any]] # [{role, port, info, ...}, ...] + + +def _short_nodeid(nodeid: str) -> str: + """Collapse a pytest nodeid into a filename-safe slug (<= 60 chars).""" + # Drop the file path prefix; keep test name + parametrization. + tail = nodeid.split("::", 1)[-1] if "::" in nodeid else nodeid + slug = re.sub(r"[^A-Za-z0-9_.\-]", "_", tail) + return slug[:60].strip("_.-") or "test" + + +def _filtered_fwlog( + fwlog_path: pathlib.Path, + start_ts: float | None, + stop_ts: float | None, + *, + pad_s: float = 5.0, +) -> bytes: + """Return fwlog.jsonl lines whose ``ts`` lies in [start-pad, stop+pad].""" + if not fwlog_path.is_file(): + return b"" + if start_ts is None or stop_ts is None: + # Without a time window, include the whole file — rare; happens + # when a test fails in setup before pytest emitted a start ts. + try: + return fwlog_path.read_bytes() + except OSError: + return b"" + lo, hi = start_ts - pad_s, stop_ts + pad_s + out = io.BytesIO() + try: + with fwlog_path.open("r", encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if not stripped: + continue + try: + record = json.loads(stripped) + except json.JSONDecodeError: + continue + ts = record.get("ts") + if not isinstance(ts, (int, float)): + continue + if lo <= ts <= hi: + out.write(line.encode("utf-8")) + except OSError: + return b"" + return out.getvalue() + + +def _readme(ctx: ReproContext) -> str: + t = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime()) + return f"""# Reproducer bundle + +Exported by `meshtastic-mcp-test-tui` on {t}. + +## Failing test + +- **nodeid:** `{ctx.nodeid}` +- **seed:** `{ctx.seed}` +- **run #:** {ctx.run_number} +- **suite exit code (at export time):** {ctx.exit_code if ctx.exit_code is not None else "in progress"} + +## Files in this archive + +| File | Contents | +|---|---| +| `test_report.json` | The pytest-reportlog `TestReport` event for the failing test — includes `longrepr`, captured `sections` (stdout/stderr/log), `duration`, `location`, `keywords`. | +| `fwlog.jsonl` | Firmware log lines (from `meshtastic.log.line` pubsub) filtered to [start−5s, stop+5s] around the test's run window. Each line is `{{ts, port, line}}`. | +| `devices.json` | Per-device snapshot at export time: `device_info` + `lora` config per detected role. | +| `env.json` | Python version, platform, hostname, seed, run number. | + +## How to triage + +1. Open `test_report.json` and read `longrepr` + `sections` — most failures explain themselves there. +2. If the failure is a mesh/telemetry assertion, `fwlog.jsonl` is where the answer usually lives. Grep for `Error=`, `NAK`, `PKI_UNKNOWN_PUBKEY`, `Skip send`, `Guru Meditation`, or the uptime timestamps around the assertion event. +3. Compare `devices.json` against the expected state (e.g. `num_nodes >= 2`, `primary_channel == "McpTest"`, `region == "US"`). If fields disagree with the seed-derived USERPREFS profile, the device probably wasn't baked with this session's profile. + +## Reproducing locally + +```bash +cd mcp-server +MESHTASTIC_MCP_SEED='{ctx.seed}' .venv/bin/pytest '{ctx.nodeid}' --tb=long -v +``` +""" + + +def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path: + """Build a tarball under ``ctx.output_dir`` and return its path. + + Parent dirs are created as needed. Errors during optional sections + (devices, env) are swallowed — the bundle is still useful without + them; refusing to export because the device poller had a hiccup + would be worse than the export missing a file. + """ + ctx.output_dir.mkdir(parents=True, exist_ok=True) + ts = int(time.time()) + slug = _short_nodeid(ctx.nodeid) + archive_path = ctx.output_dir / f"repro-{ts}-{slug}.tar.gz" + + with tarfile.open(archive_path, "w:gz") as tar: + + def _add(name: str, data: bytes) -> None: + info = tarfile.TarInfo(name=name) + info.size = len(data) + info.mtime = ts + tar.addfile(info, io.BytesIO(data)) + + # README + _add("README.md", _readme(ctx).encode("utf-8")) + + # test_report.json — reconstruct from the fields the TUI stashes. + test_report = { + "nodeid": ctx.nodeid, + "outcome": "failed", + "longrepr": ctx.longrepr, + "sections": [list(s) for s in ctx.sections], + "start": ctx.start_ts, + "stop": ctx.stop_ts, + } + _add( + "test_report.json", + json.dumps(test_report, indent=2, default=str).encode("utf-8"), + ) + + # fwlog.jsonl (filtered) + _add("fwlog.jsonl", _filtered_fwlog(ctx.fwlog_path, ctx.start_ts, ctx.stop_ts)) + + # devices.json + try: + devices_payload = json.dumps( + ctx.extra_device_rows or [], indent=2, default=str + ) + except Exception: + devices_payload = "[]" + _add("devices.json", devices_payload.encode("utf-8")) + + # env.json + try: + from importlib.metadata import version as _pkg_version + + pytest_version = _pkg_version("pytest") + except Exception: + pytest_version = "unknown" + env_payload = { + "seed": ctx.seed, + "run": ctx.run_number, + "exit_code": ctx.exit_code, + "export_ts": ts, + "python": platform.python_version(), + "pytest": pytest_version, + "platform": f"{platform.system()} {platform.release()} {platform.machine()}", + "hostname": socket.gethostname(), + } + _add("env.json", json.dumps(env_payload, indent=2).encode("utf-8")) + + return archive_path + + +def iter_entries(archive_path: pathlib.Path) -> Iterable[str]: + """Yield member names — used by callers that want to confirm the bundle shape.""" + with tarfile.open(archive_path, "r:gz") as tar: + for m in tar.getmembers(): + yield m.name diff --git a/mcp-server/src/meshtastic_mcp/cli/test_tui.py b/mcp-server/src/meshtastic_mcp/cli/test_tui.py new file mode 100644 index 00000000000..33201101b1a --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/test_tui.py @@ -0,0 +1,1782 @@ +"""Textual TUI wrapping `mcp-server/run-tests.sh`. + +Launch: ``meshtastic-mcp-test-tui [pytest-args]`` + +The TUI *wraps* ``run-tests.sh``; it never replaces it. Same script, same +env-var resolution, same ``userPrefs.jsonc`` session fixture. Four data +sources drive live state: + +1. ``tests/reportlog.jsonl`` — written by ``pytest-reportlog``. Tailed in a + worker thread; each JSON line is published as a :class:`ReportLogEvent` + message. This is the authoritative source for tree population + per-test + outcome. +2. The pytest subprocess ``stdout`` + ``stderr`` streams — line-by-line, + published as :class:`PytestLine` messages and rendered verbatim in the + pytest pane. +3. ``tests/fwlog.jsonl`` — firmware log stream. Written by the + ``_firmware_log_stream`` autouse session fixture in ``conftest.py`` + (mirrors every ``meshtastic.log.line`` pubsub event), tailed by the + :class:`FirmwareLogTailer` worker, displayed in a wrap-enabled + RichLog with cycleable port filter. +4. ``devices.list_devices()`` + ``info.device_info(port)`` — polled only at + startup and again after ``RunFinished``. Device polling while pytest + holds a SerialInterface would deadlock on the exclusive port lock; the + existing ``hub_devices`` fixture is session-scoped so there is no safe + "between tests" window. The header reflects this with a "(stale)" + marker while the run is active. + +Key bindings (see :class:`TestTuiApp.BINDINGS`): + ``r`` re-run focused ``f`` filter tree ``d`` failure detail + ``g`` open report.html ``l`` cycle firmware-log port filter + ``x`` export reproducer bundle ``c`` tool-coverage panel + ``q`` / Ctrl-C graceful quit with SIGINT → SIGTERM → SIGKILL escalation + +Shipped today (v1 + v2 slice): test tree + tier counters with progress bars, +pytest tail, live firmware log with port filter, device strip with +"currently running" status column, failure-detail modal, reproducer bundle +export (filters fwlog by test's start/stop timestamps), tool-coverage +modal, cross-run history sparkline in the header, clean SIGINT +propagation. Still open (see the plan file): mesh topology mini-diagram +and airtime / channel-utilization gauges. +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import signal +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Iterator + +# --------------------------------------------------------------------------- +# Configuration constants +# --------------------------------------------------------------------------- + +# Tier names that map nodeids like "tests//..." to counter buckets. +# Order here == display order in the tier-counters table. Matches the order +# `pytest_collection_modifyitems` in `conftest.py` uses: +# bake → unit → mesh → telemetry → monitor → fleet → admin → provisioning +# so the counters table reads top-to-bottom in execution order. +# +# "bake" is the synthetic tier for `tests/test_00_bake.py` — the file sits +# at the `tests/` root rather than under a tier subdirectory, so without +# this mapping `_tier_of_nodeid` would return "other" and the bake outcomes +# would be silently dropped from both the tier table and the history +# record (which sums tier counters to compute passed/failed/skipped). +TIERS = ( + "bake", + "unit", + "mesh", + "telemetry", + "monitor", + "fleet", + "admin", + "provisioning", +) + +# Relative paths from the mcp-server root. +_REPORTLOG_RELATIVE = "tests/reportlog.jsonl" +_FWLOG_RELATIVE = "tests/fwlog.jsonl" +# pio / esptool / nrfutil / picotool tee subprocess output here when +# `MESHTASTIC_MCP_FLASH_LOG` is set (see `pio._run_capturing`). run-tests.sh +# sets that env var; the TUI also sets it for direct `_spawn_pytest` calls +# so `r`-key re-runs that skip the wrapper still get tee'd output. +_FLASHLOG_RELATIVE = "tests/flash.log" +_REPORT_HTML_RELATIVE = "tests/report.html" +_TOOL_COVERAGE_RELATIVE = "tests/tool_coverage.json" +_HISTORY_RELATIVE = "tests/.history/runs.jsonl" +_REPRODUCERS_RELATIVE = "tests/reproducers" +_RUN_TESTS_RELATIVE = "run-tests.sh" +_RUN_COUNTER_RELATIVE = "tests/.tui-runs" + +# Graceful-shutdown budgets (seconds) for the pytest subprocess when the +# user hits `q`. Matches what the existing CLI's atexit + userprefs sidecar +# self-heal expects. +_SIGINT_GRACE_S = 5.0 +_SIGTERM_GRACE_S = 5.0 + + +# --------------------------------------------------------------------------- +# Path resolution +# --------------------------------------------------------------------------- + + +def _mcp_server_root() -> pathlib.Path: + """Locate the mcp-server directory (the one containing run-tests.sh).""" + here = pathlib.Path(__file__).resolve() + # Walk up until we find pyproject.toml with a matching project name, or + # default to the three-up ancestor (src/meshtastic_mcp/cli/test_tui.py → + # .../mcp-server). The walk-up protects against unusual checkouts. + for parent in (here.parent, *here.parents): + if (parent / "pyproject.toml").is_file() and ( + parent / "run-tests.sh" + ).is_file(): + return parent + return here.parents[3] + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class LeafReport: + """Per-test state drawn from reportlog events. + + Outcomes mirror pytest's: "passed" | "failed" | "skipped" | "running". + """ + + nodeid: str + tier: str + outcome: str = "pending" + duration_s: float = 0.0 + longrepr: str = "" + # Captured stdout / stderr / firmware-log sections from the test's + # `TestReport.sections` — shown in the failure-detail modal. + sections: list[tuple[str, str]] = field(default_factory=list) + # Wall-clock start/stop from the TestReport event. Used by the + # reproducer exporter (`x`) to filter `tests/fwlog.jsonl` down to + # just the lines around the failure window. + start_ts: float | None = None + stop_ts: float | None = None + + +@dataclass +class TierCounters: + tier: str + passed: int = 0 + failed: int = 0 + skipped: int = 0 + running: int = 0 + remaining: int = 0 + + +@dataclass +class DeviceRow: + role: str | None + port: str + vid: str + pid: str + description: str + # Populated from info.device_info when available; empty dict when we + # haven't queried (or when the poller is paused). + info: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class State: + """Shared state owned by the App; written by workers under `lock`. + + UI code reads via Textual Message handlers which run on the UI thread + in the order workers called `post_message` — so reads don't need the + lock themselves. + """ + + lock: threading.Lock = field(default_factory=threading.Lock) + tiers: dict[str, TierCounters] = field( + default_factory=lambda: {t: TierCounters(tier=t) for t in TIERS} + ) + leaves: dict[str, LeafReport] = field(default_factory=dict) + # Ordered list of nodeids in the order they were first seen — lets us + # rebuild the tree deterministically. + nodeid_order: list[str] = field(default_factory=list) + devices: list[DeviceRow] = field(default_factory=list) + run_active: bool = False + exit_code: int | None = None + # nodeid of the currently-running test. Set on `when="setup"` + + # outcome="passed" (body about to execute); cleared on `when="call"` + # (any outcome) or on `when="setup"` + outcome="failed" (no body + # window). Drives the device-table "Status" column so the operator + # can see which test is touching a given device right now. + running_nodeid: str | None = None + # `time.monotonic()` captured when `running_nodeid` was set. Surfaced + # as live-updating elapsed-time ("RUNNING: test_bake_nrf52 (1:23)") so + # an operator staring at a ~3 min `test_00_bake` or a `mesh_formation` + # with a 60 s ceiling has concrete evidence the test isn't stuck. + running_started_at: float | None = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _tier_of_nodeid(nodeid: str) -> str: + """Map a pytest nodeid to its tier bucket. Unknown → 'other'. + + `tests/test_00_bake.py::...` is special-cased to the synthetic `bake` + tier — it's a top-level file (no tier subdirectory) so the generic + "second path segment" logic would miss it and route the bake outcomes + into the non-existent `other` bucket. + """ + parts = nodeid.split("/", 2) + if len(parts) >= 2 and parts[0] == "tests": + # Bake file sits at `tests/test_00_bake.py` — dedicated bucket. + if parts[1].startswith("test_00_bake"): + return "bake" + candidate = parts[1] + if candidate in TIERS: + return candidate + return "other" + + +def _file_of_nodeid(nodeid: str) -> str: + """Extract the test file name (e.g. 'test_boards.py') from a nodeid.""" + left = nodeid.split("::", 1)[0] + return left.rsplit("/", 1)[-1] + + +def _testname_of_nodeid(nodeid: str) -> str: + """Extract the 'test_foo[param]' suffix from a nodeid, or the full thing.""" + if "::" in nodeid: + return nodeid.split("::", 1)[1] + return nodeid + + +def _roles_from_nodeid(nodeid: str) -> set[str]: + """Infer which device roles a parametrized test touches. + + Patterns we recognize (from the existing ``conftest.py`` parametrization + in ``pytest_generate_tests``): + + - ``test_foo[nrf52]`` → {"nrf52"} (baked_single) + - ``test_foo[nrf52->esp32s3]`` → {"nrf52", "esp32s3"} (mesh_pair) + + Unparametrized tests (no bracket) return an empty set — the caller + should fall back to "this test involves ALL detected devices" rather + than pretending it touches none. + """ + if "[" not in nodeid or not nodeid.endswith("]"): + return set() + try: + inner = nodeid.rsplit("[", 1)[1][:-1] + except Exception: + return set() + # Split on "->" for directed mesh pairs; otherwise treat as single role. + parts = [p.strip() for p in inner.split("->")] if "->" in inner else [inner.strip()] + return {p for p in parts if p} + + +def _parse_events(path: pathlib.Path) -> Iterator[dict[str, Any]]: + """Yield parsed JSON dicts from a reportlog file, skipping malformed lines. + + Used for smoke-testing the parser against a finished file; the live + worker has its own tail loop. + """ + if not path.is_file(): + return + with path.open("r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + + +def _load_run_number(counter_path: pathlib.Path) -> int: + """Bump + persist a monotonic run counter used in the TUI header.""" + try: + n = int(counter_path.read_text().strip()) + except Exception: + n = 0 + n += 1 + try: + counter_path.parent.mkdir(parents=True, exist_ok=True) + counter_path.write_text(str(n)) + except Exception: + # Non-fatal: the counter is cosmetic. + pass + return n + + +def _resolve_seed() -> str: + """Mirror the default-seed resolution from run-tests.sh. + + Operator can override via MESHTASTIC_MCP_SEED. Matches the + per-user/per-host default so repeated invocations land on the same PSK + (makes --assume-baked valid across invocations). + """ + if explicit := os.environ.get("MESHTASTIC_MCP_SEED"): + return explicit + try: + who = os.environ.get("USER") or os.environ.get("LOGNAME") or "anon" + except Exception: + who = "anon" + try: + import socket + + host = socket.gethostname().split(".", 1)[0] + except Exception: + host = "host" + return f"mcp-{who}-{host}" + + +def _format_duration(seconds: float) -> str: + if seconds < 60: + return f"{seconds:5.1f}s" + m, s = divmod(int(seconds), 60) + return f"{m:d}:{s:02d}" + + +# --------------------------------------------------------------------------- +# Textual imports (lazy — only when main() runs, so `_parse_events` can be +# imported by smoke tests without requiring textual installed in every env) +# --------------------------------------------------------------------------- + + +def _import_textual() -> Any: + """Return a namespace carrying every Textual class we use. + + Deferred import keeps `_parse_events` + `_tier_of_nodeid` importable + from tests / smoke scripts without pulling in the UI stack. + """ + import textual + from textual.app import App, ComposeResult + from textual.binding import Binding + from textual.containers import Horizontal, Vertical + from textual.message import Message + from textual.screen import ModalScreen + from textual.widgets import DataTable, Footer, Input, RichLog, Static, Tree + + ns = argparse.Namespace() + ns.App = App + ns.Binding = Binding + ns.ComposeResult = ComposeResult + ns.DataTable = DataTable + ns.Footer = Footer + ns.Horizontal = Horizontal + ns.Input = Input + ns.Message = Message + ns.ModalScreen = ModalScreen + ns.RichLog = RichLog + ns.Static = Static + ns.Tree = Tree + ns.Vertical = Vertical + ns.textual = textual + return ns + + +# --------------------------------------------------------------------------- +# main() — the important scaffolding lives here so that when we bail out +# before entering the Textual event loop (missing terminal, --help, etc.) +# nothing has grabbed the screen yet. +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> int: + """Entry point for `meshtastic-mcp-test-tui`.""" + argv = list(argv if argv is not None else sys.argv[1:]) + + parser = argparse.ArgumentParser( + prog="meshtastic-mcp-test-tui", + description=( + "Live Textual TUI wrapping mcp-server/run-tests.sh. " + "Passes any unrecognized arguments through to pytest." + ), + allow_abbrev=False, + ) + parser.add_argument( + "--no-tui", + action="store_true", + help=( + "Skip the TUI and exec run-tests.sh directly. Useful as a health " + "check that the wrapper argv+env resolution is working." + ), + ) + args, pytest_args = parser.parse_known_args(argv) + + root = _mcp_server_root() + run_tests = root / _RUN_TESTS_RELATIVE + reportlog = root / _REPORTLOG_RELATIVE + fwlog = root / _FWLOG_RELATIVE + flashlog = root / _FLASHLOG_RELATIVE + counter = root / _RUN_COUNTER_RELATIVE + + if not run_tests.is_file(): + print( + f"error: could not locate {_RUN_TESTS_RELATIVE} relative to " + f"{root}. Is this the mcp-server checkout?", + file=sys.stderr, + ) + return 2 + + # Always clear stale log files before launching pytest. The TUI's tail + # workers race pytest file-creation; starting from a known-empty state + # avoids mid-line-decode confusion from the prior run. The fwlog session + # fixture also truncates on its end, and run-tests.sh truncates the + # flashlog — triple-truncate is deliberate (whichever side creates the + # file first, it starts empty). + for p in (reportlog, fwlog, flashlog): + try: + p.unlink(missing_ok=True) + except Exception: + pass + + # Compute + persist the run counter for the header (cosmetic). + run_number = _load_run_number(counter) + seed = _resolve_seed() + # Export the seed so the subprocess inherits the SAME value the TUI + # displays. run-tests.sh computes its own fallback if unset, and we'd + # end up with a header / wrapper-header mismatch if we let that happen. + os.environ.setdefault("MESHTASTIC_MCP_SEED", seed) + # Turn on subprocess-output tee'ing so `pio._run_capturing` writes each + # line of pio / esptool / nrfutil / picotool output to `tests/flash.log` + # as it arrives. The TUI tails that file and routes each line to the + # pytest pane so the operator sees live flash progress during long + # `pio run -t upload` / `esptool erase_flash` operations. run-tests.sh + # also sets this when invoked directly — `setdefault` so the wrapper's + # value wins when present. + os.environ.setdefault("MESHTASTIC_MCP_FLASH_LOG", str(flashlog)) + + # --no-tui: exec run-tests.sh directly. Useful for diagnosing wrapper + # env / argv handling without getting into Textual's alternate screen. + if args.no_tui: + cmd = [str(run_tests), *pytest_args] + os.execv(str(run_tests), cmd) # noqa: S606 — intentional + + # Textual UI import is deferred so `--help` and `--no-tui` do not pay + # the ~40 MB startup cost. + try: + tx = _import_textual() + except ImportError as exc: + print( + f"error: textual is not installed ({exc}). Install with: " + f"pip install -e '.[test]'", + file=sys.stderr, + ) + return 2 + + # Narrow-terminal warning (see plan §8 risk 2). Textual itself degrades, + # but a heads-up helps a first-time user. + term = os.environ.get("TERM", "") + if term in ("", "dumb", "screen") and not os.environ.get("TEXTUAL_NO_TERM_HINT"): + print( + f"[hint] TERM={term!r} may render poorly. Try " + f"`TERM=xterm-256color meshtastic-mcp-test-tui ...` if the layout " + f"looks broken.", + file=sys.stderr, + ) + + app = _build_app( + tx=tx, + root=root, + run_tests=run_tests, + reportlog=reportlog, + fwlog=fwlog, + flashlog=flashlog, + seed=seed, + run_number=run_number, + pytest_args=pytest_args, + ) + + # App.run() returns the subprocess exit code via `app.exit(returncode)`. + return_value = app.run() + if isinstance(return_value, int): + return return_value + return 0 + + +# --------------------------------------------------------------------------- +# Everything below is only reachable once Textual is importable. `tx` is +# the namespace returned by `_import_textual()` so we don't scatter `from +# textual import ...` across the file. +# --------------------------------------------------------------------------- + + +def _build_app( + *, + tx: Any, + root: pathlib.Path, + run_tests: pathlib.Path, + reportlog: pathlib.Path, + fwlog: pathlib.Path, + flashlog: pathlib.Path, + seed: str, + run_number: int, + pytest_args: list[str], +) -> Any: + """Assemble TestTuiApp with its Textual-dependent inner classes. + + Keeping the class definitions inside a factory means `main()` can + short-circuit (--no-tui, terminal-check, argparse error) before we + force Textual's import cost. + """ + + # Helper modules — lazy-imported here so the top-of-file import cost + # only kicks in when main() has decided to run the TUI. + from . import _flashlog as _flashlog_mod + from . import _fwlog as _fwlog_mod + from . import _history as _history_mod + from . import _reproducer as _reproducer_mod + + # ---------------- Messages ---------------- + + class ReportLogEvent(tx.Message): + def __init__(self, event: dict[str, Any]) -> None: + self.event = event + super().__init__() + + class PytestLine(tx.Message): + def __init__(self, source: str, line: str) -> None: + self.source = source # "stdout" | "stderr" + self.line = line + super().__init__() + + class FirmwareLogLine(tx.Message): + def __init__(self, record: dict[str, Any]) -> None: + # {"ts": float, "port": str | None, "line": str} + self.record = record + super().__init__() + + class FlashLogLine(tx.Message): + """Plain-text line from `tests/flash.log` — pio / esptool / nrfutil / + picotool output tee'd by `pio._run_capturing`. Routed to the pytest + pane so the operator sees live flash progress during `test_00_bake` + instead of 3 minutes of pytest-captured silence.""" + + def __init__(self, line: str) -> None: + self.line = line + super().__init__() + + class DeviceSnapshot(tx.Message): + def __init__(self, rows: list[DeviceRow]) -> None: + self.rows = rows + super().__init__() + + class RunFinished(tx.Message): + def __init__(self, returncode: int) -> None: + self.returncode = returncode + super().__init__() + + # ---------------- Workers ---------------- + + class ReportlogWorker(threading.Thread): + """Tail `reportlog.jsonl`, publish each event.""" + + def __init__(self, app: Any, path: pathlib.Path, stop: threading.Event) -> None: + super().__init__(daemon=True, name="reportlog-tail") + self._app = app + self._path = path + self._stop = stop + + def run(self) -> None: + # Wait up to 30 s for pytest to create the file (first call on + # a cold cache can be slow). + wait_deadline = time.monotonic() + 30.0 + while not self._path.is_file(): + if self._stop.is_set() or time.monotonic() > wait_deadline: + return + time.sleep(0.1) + try: + fh = self._path.open("r", encoding="utf-8") + except OSError: + return + try: + while not self._stop.is_set(): + line = fh.readline() + if not line: + time.sleep(0.05) + continue + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + self._app.post_message(ReportLogEvent(event)) + finally: + fh.close() + + class SubprocessReaderWorker(threading.Thread): + """Read one stream line-by-line and publish PytestLine messages.""" + + def __init__( + self, + app: Any, + stream: Any, + source: str, + stop: threading.Event, + ) -> None: + super().__init__(daemon=True, name=f"subprocess-{source}") + self._app = app + self._stream = stream + self._source = source + self._stop = stop + + def run(self) -> None: + try: + for line in iter(self._stream.readline, ""): + if self._stop.is_set(): + break + self._app.post_message( + PytestLine(source=self._source, line=line.rstrip("\n")) + ) + except Exception: + # stream closed / subprocess died; not fatal. + pass + + class DevicePollerWorker(threading.Thread): + """Poll list_devices() + device_info() at startup and after RunFinished. + + Deliberately NOT polling during the run — `hub_devices` is a + session-scoped fixture holding SerialInterfaces across the whole + session, and device_info() would deadlock on the exclusive port + lock. Header shows "(stale)" during the gap. + """ + + def __init__(self, app: Any, state: State, stop: threading.Event) -> None: + super().__init__(daemon=True, name="device-poller") + self._app = app + self._state = state + self._stop = stop + self._trigger = threading.Event() + + def trigger(self) -> None: + self._trigger.set() + + def run(self) -> None: + # Perform one poll at startup; then wait for explicit triggers. + self._poll_once() + while not self._stop.is_set(): + if self._trigger.wait(timeout=0.5): + self._trigger.clear() + if self._stop.is_set(): + break + with self._state.lock: + active = self._state.run_active + if active: + continue + self._poll_once() + + def _poll_once(self) -> None: + try: + from meshtastic_mcp import devices as devices_mod + from meshtastic_mcp import info as info_mod + except Exception as exc: # pragma: no cover + self._app.post_message( + PytestLine( + source="stderr", line=f"[tui] device import failed: {exc!r}" + ) + ) + return + rows: list[DeviceRow] = [] + try: + raw = devices_mod.list_devices(include_unknown=True) + except Exception as exc: + self._app.post_message( + PytestLine( + source="stderr", line=f"[tui] list_devices failed: {exc!r}" + ) + ) + return + for d in raw: + vid_raw = d.get("vid") or "" + try: + vid_i = ( + int(vid_raw, 16) + if isinstance(vid_raw, str) and vid_raw.startswith("0x") + else int(vid_raw) + ) + except (TypeError, ValueError): + vid_i = 0 + role = None + if vid_i == 0x239A: + role = "nrf52" + elif vid_i in (0x303A, 0x10C4): + role = "esp32s3" + if not role and not d.get("likely_meshtastic"): + continue + row = DeviceRow( + role=role, + port=d.get("port", ""), + vid=str(vid_raw), + pid=str(d.get("pid") or ""), + description=d.get("description", "") or "", + ) + if role: + try: + row.info = info_mod.device_info(port=row.port, timeout_s=6.0) + except Exception as exc: + row.info = {"error": repr(exc)} + rows.append(row) + self._app.post_message(DeviceSnapshot(rows=rows)) + + # ---------------- Modals ---------------- + + class FailureDetailScreen(tx.ModalScreen): + """Show a failed test's longrepr + captured sections.""" + + BINDINGS = [tx.Binding("escape,q", "dismiss", "close")] + + def __init__(self, leaf: LeafReport, report_html: pathlib.Path) -> None: + self._leaf = leaf + self._report_html = report_html + super().__init__() + + def compose(self) -> Any: + yield tx.Static( + f"[bold]{self._leaf.nodeid}[/bold] " + f"outcome=[red]{self._leaf.outcome}[/red] " + f"duration={_format_duration(self._leaf.duration_s)}", + id="failure-detail-header", + ) + log = tx.RichLog( + highlight=False, markup=False, wrap=False, id="failure-detail-log" + ) + yield log + yield tx.Static( + f"[dim]Full HTML report: {self._report_html}[/dim] [esc] close", + id="failure-detail-footer", + ) + + def on_mount(self) -> None: + log = self.query_one("#failure-detail-log", tx.RichLog) + if self._leaf.longrepr: + log.write(self._leaf.longrepr) + log.write("") + for section_name, section_text in self._leaf.sections: + log.write(f"--- {section_name} ---") + log.write(section_text) + log.write("") + if not self._leaf.longrepr and not self._leaf.sections: + log.write("(no longrepr or captured sections in reportlog event)") + + def action_dismiss(self, _result: Any = None) -> None: + self.dismiss() + + class FilterInputScreen(tx.ModalScreen[str]): + """Prompt the user for a tree filter substring (empty clears).""" + + BINDINGS = [tx.Binding("escape", "cancel", "cancel")] + + def compose(self) -> Any: + yield tx.Static("filter test tree (substring, empty = clear):") + yield tx.Input(placeholder="nodeid substring", id="filter-input") + + def on_input_submitted(self, event: Any) -> None: + self.dismiss(event.value.strip()) + + def action_cancel(self) -> None: + self.dismiss(None) + + class CoverageModal(tx.ModalScreen): + """Read `tests/tool_coverage.json` (written by `tests/tool_coverage.py` + at `pytest_sessionfinish`) and render a two-column summary of which + MCP tools got exercised by the run. `(no coverage data yet)` while + the run is in flight.""" + + BINDINGS = [tx.Binding("escape,q,c", "dismiss", "close")] + + def __init__(self, coverage_path: pathlib.Path) -> None: + self._path = coverage_path + super().__init__() + + def compose(self) -> Any: + yield tx.Static("[bold]MCP tool coverage[/bold]", id="coverage-header") + yield tx.RichLog( + highlight=False, markup=True, wrap=False, id="coverage-log" + ) + yield tx.Static( + f"[dim]{self._path}[/dim] [esc] close", + id="coverage-footer", + ) + + def on_mount(self) -> None: + log = self.query_one("#coverage-log", tx.RichLog) + if not self._path.is_file(): + log.write("(no coverage data — tool_coverage.json not written yet)") + log.write("") + log.write("Coverage is emitted at pytest_sessionfinish; this") + log.write("file appears after the suite completes.") + return + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + except Exception as exc: + log.write(f"[red]failed to read {self._path}:[/red] {exc!r}") + return + calls = data.get("calls") or {} + if not calls: + log.write("(tool_coverage.json present but no calls recorded)") + return + exercised = sorted( + ((n, c) for n, c in calls.items() if c > 0), key=lambda x: -x[1] + ) + unexercised = sorted(n for n, c in calls.items() if c == 0) + log.write(f"[b]{len(exercised)} / {len(calls)} MCP tools exercised[/b]") + log.write("") + log.write("[green]exercised[/green] (count):") + for name, count in exercised: + log.write(f" {count:>4} {name}") + if unexercised: + log.write("") + log.write("[dim]not exercised:[/dim]") + for name in unexercised: + log.write(f" {name}") + + def action_dismiss(self, _result: Any = None) -> None: + self.dismiss() + + class ReproducerResultModal(tx.ModalScreen): + """Show the exported reproducer tarball path with a short instruction.""" + + BINDINGS = [tx.Binding("escape,q,enter", "dismiss", "close")] + + def __init__( + self, archive_path: pathlib.Path, error: str | None = None + ) -> None: + self._archive = archive_path + self._error = error + super().__init__() + + def compose(self) -> Any: + if self._error: + yield tx.Static(f"[red]Reproducer export failed:[/red] {self._error}") + else: + yield tx.Static("[bold green]Reproducer bundle written[/bold green]") + yield tx.Static(f"[cyan]{self._archive}[/cyan]") + yield tx.Static("") + yield tx.Static( + "Contains: README.md, test_report.json, fwlog.jsonl (time-filtered)," + ) + yield tx.Static( + "devices.json, env.json. Attach to an issue / paste the path in chat." + ) + yield tx.Static("") + yield tx.Static("[dim][esc] close[/dim]") + + def action_dismiss(self, _result: Any = None) -> None: + self.dismiss() + + # ---------------- App ---------------- + + class TestTuiApp(tx.App): + CSS = """ + Screen { layout: vertical; } + #header-bar { height: 2; padding: 0 1; background: $panel; } + #tier-table { height: auto; max-height: 11; } + #body { height: 1fr; } + #tree-pane { width: 50%; border-right: solid $primary-background; } + #right-pane { width: 50%; layout: vertical; } + #pytest-pane { height: 50%; border-bottom: solid $primary-background; } + #fwlog-header { height: 1; padding: 0 1; background: $panel; } + #fwlog-pane { height: 1fr; } + Tree { height: 100%; } + RichLog { height: 100%; } + #device-table { height: auto; max-height: 6; } + """ + + TITLE = "mcp-server test runner" + + BINDINGS = [ + tx.Binding("r", "rerun_focused", "re-run focused"), + tx.Binding("f", "filter_tree", "filter"), + tx.Binding("d", "failure_detail", "failure detail"), + tx.Binding("g", "open_html_report", "open report.html"), + tx.Binding("x", "export_reproducer", "export reproducer"), + tx.Binding("c", "coverage_panel", "coverage"), + tx.Binding("l", "cycle_fwlog_filter", "fw log filter"), + tx.Binding("q,ctrl+c", "quit_app", "quit"), + ] + + def __init__(self) -> None: + super().__init__() + self._state = State() + self._root = root + self._run_tests = run_tests + self._reportlog = reportlog + self._fwlog = fwlog + self._flashlog = flashlog + self._report_html = root / _REPORT_HTML_RELATIVE + self._tool_coverage = root / _TOOL_COVERAGE_RELATIVE + self._repro_dir = root / _REPRODUCERS_RELATIVE + self._seed = seed + self._run_number = run_number + self._pytest_args = pytest_args + self._start_time = time.monotonic() + self._proc: subprocess.Popen[str] | None = None + self._stop = threading.Event() + self._reportlog_worker: ReportlogWorker | None = None + self._stdout_worker: SubprocessReaderWorker | None = None + self._stderr_worker: SubprocessReaderWorker | None = None + self._device_worker: DevicePollerWorker | None = None + self._fwlog_worker: _fwlog_mod.FirmwareLogTailer | None = None + self._flashlog_worker: _flashlog_mod.FlashLogTailer | None = None + self._tree_filter: str = "" + self._sigint_count = 0 + # Firmware-log port filter: None = all, else exact port match. + self._fwlog_filter: str | None = None + # Ordered set of distinct ports we've seen firmware log lines + # from — the `l` key cycles through these. + self._fwlog_ports: list[str] = [] + # Cross-run history. + self._history_store = _history_mod.HistoryStore( + root / _HISTORY_RELATIVE, keep_last=40 + ) + self._history_cache = self._history_store.read_recent() + + # -------- composition / mount -------- + + def compose(self) -> Any: + yield tx.Static(self._header_text(), id="header-bar") + tier_table = tx.DataTable(id="tier-table", show_cursor=False) + yield tier_table + with tx.Horizontal(id="body"): + with tx.Vertical(id="tree-pane"): + yield tx.Tree("tests", id="test-tree") + with tx.Vertical(id="right-pane"): + with tx.Vertical(id="pytest-pane"): + yield tx.RichLog( + id="pytest-log", + highlight=False, + markup=False, + wrap=False, + max_lines=5000, + ) + yield tx.Static(self._fwlog_header_text(), id="fwlog-header") + with tx.Vertical(id="fwlog-pane"): + yield tx.RichLog( + id="fwlog-log", + highlight=False, + markup=False, + # `wrap=True` so long firmware log lines (some + # hit ~200 chars — full packet hex dumps plus + # source tags) don't get truncated at the + # right edge. The right pane is ~50% of the + # terminal so even a wide terminal has a + # ~90-char cap; plain truncation dropped the + # uptime counter or packet id off the end. + wrap=True, + max_lines=5000, + ) + yield tx.DataTable(id="device-table", show_cursor=False) + yield tx.Footer() + + def _fwlog_header_text(self) -> str: + filt = self._fwlog_filter or "(all ports)" + return f"firmware log filter: [b]{filt}[/b] [l] cycle" + + def on_mount(self) -> None: + # Tier-counters table. `add_column` (singular) lets us pick + # the key explicitly — `add_columns` (plural) in textual 8.x + # returns auto-generated keys that are tedious to track + # separately, and update_cell(column_key=

)` — captures `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel`. + - `mcp__meshtastic__list_nodes(port=

)` — count of peers, which ones have `publicKey` set, SNR/RSSI distribution. + - `mcp__meshtastic__get_config(section="lora", port=

)` — region, preset, channel_num, tx_power, hop_limit. + - Optionally, if the device seems unhappy (fails to connect, `num_nodes==1` when ≥2 are plugged in, missing firmware*version), open a short firmware log window: `mcp__meshtastic__serial_open(port=

, env=)`, wait 3s, `serial_read(session_id=, max_lines=100)`, `serial_close(session_id=)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*` is set. + +4. **Render per-device report** as: + + ```text + [nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631 + owner : Meshtastic 40eb / 40eb + region/band : US, channel 88, LONG_FAST + tx_power : 30 dBm, hop_limit=3 + peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm) + primary ch : McpTest + firmware : no panics in last 3s; NodeInfoModule emitted 2 broadcasts + ``` + + Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub), flag it inline with a short `⚠︎ `. + +5. **Cross-device correlation** (only when >1 device is inspected): + - Do both sides see each other in `nodesByNum`? If one does and the other doesn't, that's asymmetric NodeInfo — flag it. + - Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh) + - Do the primary channel NAMES match? Mismatch = different PSK = no decode. + +6. **Suggest next actions only for specific, recognisable failure modes**: + - Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path." + - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." + - Device unreachable → point at touch_1200bps + the CP2102-wedged-driver note in run-tests.sh. + +## What NOT to do + +- No writes. No `set_config`, no `reboot`, no `factory_reset`. This is a read-only diagnostic skill — if the operator wants to change state, they'll ask explicitly. +- No `flash` / `erase_and_flash`. Those are separate escalations. +- No holding SerialInterface across tool calls — open, query, close; next device. The port lock is exclusive. diff --git a/.claude/commands/repro.md b/.claude/commands/repro.md new file mode 100644 index 00000000000..52dcf222b93 --- /dev/null +++ b/.claude/commands/repro.md @@ -0,0 +1,65 @@ +--- +description: Re-run a specific test N times in isolation to triage flakes, diff firmware logs between passes and failures +argument-hint: [count=5] +--- + +# `/repro` — flakiness triage for one test + +Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y." + +## What to do + +1. **Parse `$ARGUMENTS`**: first token is the pytest node id (e.g. `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[nrf52->esp32s3]`); second token is an integer count (default `5`, cap at `20`). If the first token doesn't look like a test path (no `::` and no `tests/` prefix), treat the whole `$ARGUMENTS` as a `-k` filter instead. + +2. **Sanity-check the hub first** (so we're not measuring "nothing plugged in" N times): call `mcp__meshtastic__list_devices`. If the test name contains `nrf52` or `esp32s3` and the matching VID isn't present, stop and report — re-running won't help. + +3. **Loop N times**. For each iteration: + + ```bash + ./mcp-server/run-tests.sh --tb=short -p no:cacheprovider + ``` + + Capture: exit code, duration, and (on failure) the `Meshtastic debug` firmware log section from `mcp-server/tests/report.html`. `-p no:cacheprovider` suppresses pytest's `.pytest_cache` writes so iterations don't influence each other. + +4. **Track a small structured tally**: + + ```text + attempt 1: PASS (42s) + attempt 2: FAIL (128s) ← firmware log 200-line tail captured + attempt 3: PASS (39s) + attempt 4: FAIL (121s) + attempt 5: PASS (41s) + -------------------------------------- + pass rate: 3/5 (60%) | mean duration: 74s + ``` + +5. **On mixed outcomes**: diff the firmware log tails between a representative passing attempt and a representative failing attempt. Focus on: + - Error-level lines only present in failures (`PKI_UNKNOWN_PUBKEY`, `Alloc an err=`, `Skip send`, `No suitable channel`) + - Timing around the assertion event — did a broadcast go out, was there an ACK, did NAK fire? + - Device state fields that changed (nodesByNum entries, region/preset, channel_num) + + Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs — pull specific lines with uptime timestamps. + +6. **Classify the flake** into one of: + - **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body. + - **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this. + - **NodeInfo cooldown** → `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs `broadcast_nodeinfo_ping()` warmup. + - **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. + - **Genuinely unknown** → say so; don't invent a root cause. + +7. **Report back** with: + - Pass rate and mean duration. + - Classification + evidence (the specific log lines that support it). + - A suggested next step (re-run with specific args, open `/diagnose`, edit a specific test file, nothing). + +## Examples + +- `/repro tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[esp32s3->nrf52] 10` — runs 10 times, diffs firmware logs. +- `/repro broadcast_delivers` — no `::`, no `tests/`, so interpreted as `-k broadcast_delivers`; runs every matching test the default 5 times. +- `/repro tests/telemetry/test_device_telemetry_broadcast.py 3` — shorter run for a slow test. + +## Constraints + +- Don't exceed `count=20` per invocation — airtime and USB wear add up. If the user asks for 50, negotiate down. +- Don't rebuild firmware as part of triage; flakes that only reproduce under different firmware belong in a separate session. +- If the FIRST attempt fails AND the rest all pass, that's a classic "state leak from a prior test" → say so and suggest running with `--force-bake` or starting from a clean state rather than chasing the first failure. diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 00000000000..986ee1f31f6 --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,42 @@ +--- +description: Run the mcp-server test suite (auto-detects devices) and interpret the results +argument-hint: [pytest-args] +--- + +# `/test` — mcp-server test runner with interpretation + +Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn't have to. + +## What to do + +1. **Invoke the wrapper.** From the firmware repo root, run: + + ```bash + ./mcp-server/run-tests.sh $ARGUMENTS + ``` + + The wrapper auto-detects connected Meshtastic devices, maps each to its PlatformIO env, exports the required `MESHTASTIC_MCP_ENV_*` env vars, and invokes pytest. If the user passed no arguments, the wrapper supplies a sensible default set (`tests/ --html=tests/report.html --self-contained-html --junitxml=tests/junit.xml -v --tb=short`). A `--report-log=tests/reportlog.jsonl` arg is always appended (unless the operator passed their own). `--assume-baked` is deliberately NOT in the defaults — `test_00_bake.py` has its own skip-if-already-baked check and runs the ~8 s verification by default. Operators can opt into the fast path with `--assume-baked`, or force a reflash with `--force-bake`. + +2. **Read the pre-flight header.** First ~6 lines print the detected hub (role → port → env). If that line reads `detected hub : (none)`, the wrapper will narrow to `tests/unit` only — say so explicitly in your summary so the operator knows hardware tiers were skipped. + +3. **On pass**: one-line summary of the form `N passed, M skipped in `. Don't enumerate the 52 test names — the user can read those. Do mention if any test was SKIPPED for a NON-placeholder reason (e.g. "role not present on hub" is worth flagging). + +4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). + +5. **Classify the failure** as one of: + - **Transient/flake**: LoRa collision, timing-sensitive assertion, first-attempt NAK + successful retry pattern. Propose `/repro ` to confirm. + - **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery (replug USB, `touch_1200bps`, check `git status userPrefs.jsonc`). + - **Regression**: same assertion fails repeatedly, firmware log shows a new/unusual error. Surface the diff between expected and observed, identify the module likely responsible. + +6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, or USB replug, \_describe what to do* — don't execute. The operator decides. + +## Arguments handling + +- No args → wrapper's defaults (full suite). +- `$ARGUMENTS` passed verbatim to the wrapper, which passes them to pytest. +- Common operator invocations: `/test tests/mesh`, `/test tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip`, `/test --force-bake`, `/test -k telemetry`. + +## Side-effects to mention in summary + +- The session fixture snapshots `userPrefs.jsonc` at session start and restores at teardown (plus on `atexit`). After a clean run, `git status userPrefs.jsonc` should be empty. If the wrapper's pre-flight printed a warning about a stale sidecar, call that out — means a prior session crashed. +- `mcp-server/tests/report.html` and `junit.xml` are regenerated on every run; the HTML is self-contained (shareable). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 24e11bd4ddb..d12244229e6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -429,6 +429,8 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing. ## Testing +### Native unit tests (C++) + Unit tests in `test/` directory with 12 test suites: - `test_crypto/` - Cryptography @@ -446,6 +448,164 @@ Run with: `pio test -e native` Simulation testing: `bin/test-simulator.sh` +### Hardware-in-the-loop tests (`mcp-server/tests/`) + +Separate pytest suite that exercises real USB-connected Meshtastic devices. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules. + +## MCP Server & Hardware Test Harness + +The `mcp-server/` directory houses a firmware-aware [MCP](https://modelcontextprotocol.io/) server plus a pytest-based integration suite. AI agents that speak MCP get a well-defined tool surface for flashing, configuring, and inspecting physical Meshtastic devices — use it instead of hand-rolling `pio` or `meshtastic --port` calls where possible. `mcp-server/README.md` is the operator-facing setup doc; this section is the agent-facing usage contract. + +The repo registers the server via `.mcp.json` at the repo root — Claude Code picks it up automatically once `mcp-server/.venv/` is built (`cd mcp-server && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'`). + +### When to use which surface + +| Goal | Tool | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Find a connected device | `mcp__meshtastic__list_devices` | +| Read a live node's config/state | `mcp__meshtastic__device_info`, `list_nodes`, `get_config` | +| Mutate a device (owner, region, channels, reboot) | `set_owner`, `set_config`, `set_channel_url`, `reboot`, `shutdown`, `factory_reset` — all require `confirm=True` | +| Flash firmware to a variant | `pio_flash` (any arch) or `erase_and_flash` (ESP32 factory install) | +| Stream serial logs while debugging | `serial_open` → `serial_read` loop → `serial_close` | +| Administer `userPrefs.jsonc` build-time constants | `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest` | +| Run the regression suite | `./mcp-server/run-tests.sh` (or `/test` slash command) | +| Diagnose a specific device | `/diagnose [role]` slash command (read-only) | +| Triage a flaky test | `/repro [count]` slash command | + +**One MCP call per port at a time.** `SerialInterface` holds an exclusive OS-level lock on the serial port for its lifetime. If a `serial_*` session is open on `/dev/cu.usbmodem101`, calling `device_info` on the same port will fail fast pointing at the active session. Sequence calls: open → read/mutate → close, then next device. Never parallelize tool calls on the same port. + +### MCP tool surface (~32 tools) + +Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-value signatures are called out here. + +- **Discovery & metadata**: `list_devices`, `list_boards`, `get_board` +- **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 only), `update_flash` (ESP32 OTA), `touch_1200bps` +- **Serial sessions** (long-running, 10k-line ring buffer): `serial_open`, `serial_read`, `serial_list`, `serial_close` +- **Device reads**: `device_info`, `list_nodes` +- **Device writes** (all require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api` +- **userPrefs admin** (build-time constants, not runtime config): `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` +- **Vendor escape hatches**: `esptool_chip_info`, `esptool_erase_flash`, `esptool_raw`, `nrfutil_dfu`, `nrfutil_raw`, `picotool_info`, `picotool_load`, `picotool_raw` + +`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset` and `erase_and_flash`. + +### Hardware test suite (`mcp-server/run-tests.sh`) + +The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_`), then invokes pytest. Zero pre-flight config needed from the operator. + +Suite tiers (collected + run in this order via `pytest_collection_modifyitems`): + +1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile). No hardware. +2. `tests/test_00_bake.py` — flashes each detected device with current `userPrefs.jsonc` merged with the session's test profile. Has its own skip-if-already-baked check comparing region + primary channel to the session profile; skips cheaply on warm devices. +3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`. +4. `tests/telemetry/` — `DEVICE_METRICS_APP` broadcast timing. +5. `tests/monitor/` — boot-log panic check. +6. `tests/fleet/` — PSK seed session isolation. +7. `tests/admin/` — channel URL roundtrip, owner persistence across reboot. +8. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset. + +Invocation patterns: + +```bash +./mcp-server/run-tests.sh # full suite (auto-bake-if-needed) +./mcp-server/run-tests.sh --force-bake # reflash before testing +./mcp-server/run-tests.sh --assume-baked # skip bake (caller vouches for device state) +./mcp-server/run-tests.sh tests/mesh # one tier +./mcp-server/run-tests.sh tests/mesh/test_direct_with_ack.py # one file +./mcp-server/run-tests.sh -k telemetry # name filter +``` + +**No hardware detected?** The wrapper auto-narrows to `tests/unit/` only and prints `detected hub : (none)` in the pre-flight header. Agents interpreting the output should call this out explicitly — a 52-test green run without hardware is qualitatively different from a 12-unit-test green run. + +**Artifacts every run produces:** + +- `mcp-server/tests/report.html` — self-contained pytest-html. Each test gets a `Meshtastic debug` section with the tail of firmware log + device state dump. **Open this first** on failures; it's the canonical evidence source. +- `mcp-server/tests/junit.xml` — CI-parseable. +- `mcp-server/tests/reportlog.jsonl` — pytest-reportlog stream (`$report_type` keyed JSONL). Consumed by the live TUI. +- `mcp-server/tests/fwlog.jsonl` — firmware log mirror from the `meshtastic.log.line` pubsub topic. Populated by the `_firmware_log_stream` autouse session fixture. + +### Live TUI (`meshtastic-mcp-test-tui`) + +A Textual-based live view that wraps `run-tests.sh`. Tails reportlog for per-test state, streams firmware logs, polls device state at startup + post-run (gated out of the active run because `hub_devices` holds exclusive port locks). Key bindings: + +| Key | Action | +| --- | ------------------------------------------------------------------------------------------------------------ | +| `r` | re-run focused test (leaf → that node id; internal node → directory or `-k`) | +| `f` | filter tree by substring | +| `d` | failure detail modal (pulls `longrepr` + captured stdout from the reportlog) | +| `g` | export reproducer bundle (tar.gz with README, test_report.json, time-filtered fwlog, devices.json, env.json) | +| `l` | toggle firmware log pane | +| `x` | tool coverage modal | +| `c` | cross-run history sparkline | +| `q` | quit (SIGINT → SIGTERM → SIGKILL escalation, 5-s windows each) | + +Launch: + +```bash +cd mcp-server +.venv/bin/meshtastic-mcp-test-tui # full suite +.venv/bin/meshtastic-mcp-test-tui tests/mesh # args pass through to pytest +``` + +The plain CLI stays primary; the TUI is for operators who want a live dashboard. Both consume the same `run-tests.sh`. + +### Slash commands (Claude Code + Copilot) + +Three AI-assisted workflows wrap the test harness. Claude Code operators get `/test`, `/diagnose`, `/repro`; Copilot operators get `/mcp-test`, `/mcp-diagnose`, `/mcp-repro`. Bodies: + +- `.claude/commands/{test,diagnose,repro}.md` +- `.github/prompts/mcp-{test,diagnose,repro}.prompt.md` + +`.claude/commands/README.md` is the index. + +House rules for agents running these prompts: + +- **Interpret failures, don't just echo them.** Pull firmware log tails from `report.html` and classify each failure as transient / environmental / regression. Use the exact format in `.claude/commands/test.md`. +- **No destructive writes without operator approval.** Any skill that could reflash, factory-reset, or reboot a device must describe the action and stop. The operator authorizes. +- **Sequential MCP calls per port.** See above. +- **"Unknown" is a valid classification.** If evidence doesn't support a root cause, say so and list what would disambiguate. Do not invent. + +### Key fixtures (test authors + agents debugging) + +`mcp-server/tests/conftest.py` provides: + +- **`_session_userprefs`** (autouse session) — snapshots `userPrefs.jsonc` at session start, merges the session test profile via `userprefs.merge_active(test_profile)`, restores at teardown. Four layers of safety: pytest teardown + `atexit` + sidecar file (`userPrefs.jsonc.mcp-session-bak`) + startup self-heal in `run-tests.sh`. **Do not edit `userPrefs.jsonc` from inside a test.** +- **`_firmware_log_stream`** (autouse session) — subscribes to `meshtastic.log.line` pubsub on every connected `SerialInterface` and mirrors lines to `tests/fwlog.jsonl`. Drives the TUI firmware-log pane. +- **`_debug_log_buffer`** (autouse per-test) — captures last 200 firmware log lines + device state for attachment to the pytest-html `Meshtastic debug` section on failure. +- **`hub_devices`** (session) — `dict[role, SerialInterface]` with session-long exclusive port locks. Reason the TUI's device poller is gated to startup + post-run only. +- **`baked_mesh`** — parametrized mesh-pair fixture; depends on `test_00_bake`. `pytest_generate_tests` in `conftest.py` auto-generates `[nrf52->esp32s3]` and `[esp32s3->nrf52]` variants. +- **`test_profile`** — session-scoped dict: region, primary channel, admin key, PSK seed. Derived from `MESHTASTIC_MCP_SEED` (defaults to `mcp--`). + +### Firmware integration points tied to the test harness + +Two firmware changes exist specifically so the test harness works reliably. **Keep these in mind when touching related code.** + +- **`src/mesh/StreamAPI.cpp` + `StreamAPI.h`** — `emitLogRecord` uses a dedicated `fromRadioScratchLog` + `txBufLog` pair and a `concurrency::Lock streamLock`. Before this fix, `debug_log_api_enabled=true` would tear `FromRadio` protobufs on the serial transport because `emitTxBuffer` and `emitLogRecord` shared a single scratch buffer. The conftest enables the log stream session-wide; without this fix the device would corrupt its own FromRadio replies mid-session. +- **`src/mesh/PhoneAPI.cpp`** — `ToRadio` `Heartbeat(nonce=1)` triggers `nodeInfoModule->sendOurNodeInfo(NODENUM_BROADCAST, true, 0, true)` for serial clients, mirroring the pre-existing behavior for TCP/UDP clients in `PacketAPI.cpp`. The mesh tests rely on this to force a NodeInfo broadcast right after connect so the peer discovers them before the test's first assertion. + +If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` flow, run `./mcp-server/run-tests.sh` at minimum before asking for review. + +### Recovery playbooks + +| Symptom | First check | Fix | +| ---------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. | +| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. | +| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. | +| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. | +| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. | +| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. | +| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. | + +### Never do these without asking + +- `factory_reset` — wipes node identity; regenerates PKI keypair. Mesh peers will reject old DMs until re-exchange. Legitimate only when the operator explicitly wants it. +- `erase_and_flash` — full chip erase; destroys all on-device state. +- `esptool_erase_flash` / `esptool_raw` write/erase — bypasses pio's safety chain. +- `set_config` on `lora.region` — changes regulatory domain; requires physical-location context the operator has and the agent doesn't. +- `reboot` / `shutdown` mid-test — breaks fixture invariants. +- `push -f`, `rebase -i`, `reset --hard`, or any history-rewriting git operation. +- Clicking computer-use tools on web links in Mail/Messages/PDFs — open URLs via the claude-in-chrome MCP so the extension's link-safety checks apply. + ## Resources - [Documentation](https://meshtastic.org/docs/) diff --git a/.github/prompts/mcp-diagnose.prompt.md b/.github/prompts/mcp-diagnose.prompt.md new file mode 100644 index 00000000000..c86826030d9 --- /dev/null +++ b/.github/prompts/mcp-diagnose.prompt.md @@ -0,0 +1,57 @@ +--- +mode: agent +description: Device health report via the meshtastic MCP tools (Copilot equivalent of the Claude Code /diagnose slash command) +--- + +# `/mcp-diagnose` — device health report + +Equivalent of `.claude/commands/diagnose.md`. Use when the operator asks to "check the devices", "what's the mesh looking like", "is nrf52 alive", etc. + +This prompt assumes the meshtastic MCP server is registered with your VS Code Copilot agent. If it isn't, fall back to running `./mcp-server/run-tests.sh tests/unit` plus a short `device_info` script via the terminal. + +## What to do + +1. **Enumerate hardware** via the `list_devices` MCP tool (with `include_unknown=True`). For each entry where `likely_meshtastic=True`, capture `port`, `vid`, `pid`, `description`. + +2. **Apply the operator's filter** (if any): + - No filter → every likely-meshtastic device. + - `nrf52` → `vid == 0x239a` + - `esp32s3` → `vid == 0x303a` or `vid == 0x10c4` + - A `/dev/cu.*` path → only that port. + - Anything else → substring match on port. + +3. **For each selected device, in sequence (don't parallelize — SerialInterface holds an exclusive port lock):** + - `device_info(port=

)` → `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel` + - `list_nodes(port=

)` → peer count, which peers have `publicKey`, SNR/RSSI distribution + - `get_config(section="lora", port=

)` → region, preset, channel_num, tx_power, hop_limit + - If anything looks off (can't connect, `num_nodes` wrong, missing `firmware_version`), open a short firmware-log window: `serial_open(port=

, env=)`, wait 3 seconds, `serial_read(session_id, max_lines=100)`, `serial_close(session_id)`. Infer env from VID (0x239a → `rak4631`, 0x303a/0x10c4 → `heltec-v3`) unless an `MESHTASTIC_MCP_ENV_` env var overrides it. + +4. **Render per-device report** as a compact block: + + ```text + [nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631 + owner : Meshtastic 40eb / 40eb + region/band : US, channel 88, LONG_FAST + tx_power : 30 dBm, hop_limit=3 + peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm) + primary ch : McpTest + firmware : no panics in last 3s + ``` + + Flag abnormalities inline with `⚠︎ ` — missing pubkey on a known peer, region UNSET, mismatched channel name, etc. + +5. **Cross-device correlation** (when >1 device selected): + - Do both see each other in `nodesByNum`? + - Do `region`, `channel_num`, `modem_preset` match across devices? + - Do the primary channel names match? (Different name → different PSK → no decode.) + +6. **Suggest next steps only for recognizable failure modes**, never speculatively: + - Stale PKI one-way → "`/mcp-test tests/mesh/test_direct_with_ack.py` — the test's retry+nodeinfo-ping heals this." + - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." + - Device unreachable → refer operator to the touch_1200bps + CP2102-wedged-driver notes in `run-tests.sh`. + +## Hard constraints + +- **Read-only.** No `set_config`, no `reboot`, no `factory_reset`, no `flash`. If the operator wants mutation, they'll escalate explicitly. +- **Open/query/close per device.** Never hold multiple SerialInterfaces to the same port. The port lock is exclusive. +- **Don't infer env beyond the VID map** — if the operator has an unusual board, ask them which env to use rather than guessing. diff --git a/.github/prompts/mcp-repro.prompt.md b/.github/prompts/mcp-repro.prompt.md new file mode 100644 index 00000000000..be2963c3318 --- /dev/null +++ b/.github/prompts/mcp-repro.prompt.md @@ -0,0 +1,67 @@ +--- +mode: agent +description: Re-run a specific test N times to triage flakes; diff firmware logs between passes and failures (Copilot equivalent of the Claude Code /repro slash command) +--- + +# `/mcp-repro` — flakiness triage for one test + +Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one test is flaky — dig in", "repro the direct_with_ack failure", "why does X sometimes fail?". + +## What to do + +1. **Parse the operator's input** into two pieces: + - **Test identifier** — either a pytest node id (has `::` or starts with `tests/`) or a `-k`-style filter (plain substring like `direct_with_ack`). + - **Count** — integer, default `5`, cap at `20`. If the operator asks for 50, negotiate down and explain (airtime + USB wear). + +2. **Sanity-check the hub** via the `list_devices` MCP tool. If the test name references `nrf52` or `esp32s3` and the matching VID isn't present, stop and report — re-running won't help. + +3. **Loop** N times. Each iteration: + + ```bash + ./mcp-server/run-tests.sh --tb=short -p no:cacheprovider + ``` + + `-p no:cacheprovider` keeps pytest from caching anything between iterations. Capture: exit code, duration, and (on failure) the `Meshtastic debug` firmware-log section from `mcp-server/tests/report.html`. + +4. **Tally** results as you go: + + ```text + attempt 1: PASS (42s) + attempt 2: FAIL (128s) ← fw log captured + attempt 3: PASS (39s) + attempt 4: FAIL (121s) + attempt 5: PASS (41s) + -------------------------------------------------- + pass rate: 3/5 (60%) | mean duration: 74s + ``` + +5. **On mixed outcomes, diff the firmware logs** between one representative pass and one representative fail. Focus on: + - Error-level lines present only in failures (`PKI_UNKNOWN_PUBKEY`, `Alloc an err=`, `Skip send`, `No suitable channel`, `NAK`) + - Timing around the assertion point (broadcast sent? ACK received? retry fired?) + - Device-state fields that changed between attempts + + Surface the top 3 differences as a compact "passes when / fails when" table with uptime timestamps. Don't dump full logs. + +6. **Classify** the flake into one of: + - **LoRa airtime collision** — pass rate improves with fewer concurrent transmitters. Suggest a `time.sleep` gap or retry bump in the test body. + - **PKI key staleness** — first attempt fails, subsequent ones pass; existing retry-loop pattern in `test_direct_with_ack.py` is the fix. + - **NodeInfo cooldown** — `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs a `broadcast_nodeinfo_ping()` warmup. + - **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc. + - **Unknown** — say so. Don't invent a root cause. + +7. **Report back** with: + - Pass rate + mean duration. + - Classification + the specific log evidence for it. + - A concrete next step (tighter assertion, more retries, open `/mcp-diagnose`, file a bug, nothing). + +## Examples + +- `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[esp32s3->nrf52] 10` — 10 runs of that parametrized case. +- `broadcast_delivers` — no `::`, no `tests/`; treat as `-k broadcast_delivers`; runs every match 5 times. +- `tests/telemetry/test_device_telemetry_broadcast.py 3` — shorter count for a slow test. + +## Notes + +- If the FIRST attempt fails and the rest pass, that's a state-leak signature — suggest starting from `--force-bake` or a clean device state rather than chasing the first-failure firmware logs. +- If ALL N fail, this isn't a flake — it's a regression. Say so, stop iterating, escalate to `/mcp-test` for full-suite context. +- Don't rebuild firmware during triage. Flakes that only reproduce under different firmware belong in a separate session with a plan. diff --git a/.github/prompts/mcp-test.prompt.md b/.github/prompts/mcp-test.prompt.md new file mode 100644 index 00000000000..092ad3d856c --- /dev/null +++ b/.github/prompts/mcp-test.prompt.md @@ -0,0 +1,51 @@ +--- +mode: agent +description: Run the mcp-server test suite and interpret results (Copilot equivalent of the Claude Code /test slash command) +--- + +# `/mcp-test` — mcp-server test runner with interpretation + +Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md`. Use this when the operator asks you to "run the tests", "check the mcp test suite", "run the mesh tests", etc. + +## What to do + +1. **Invoke the wrapper** from the firmware repo root: + + ```bash + ./mcp-server/run-tests.sh [pytest-args] + ``` + + If the operator specified a subset (e.g. "just the mesh tests"), pass it through as `tests/mesh` or a pytest `-k filter`. If they said nothing, use the wrapper's defaults (full suite with pytest-html report). + + The wrapper auto-detects connected Meshtastic devices, maps each to its PlatformIO env, exports the required env vars, and invokes pytest. Zero pre-flight config needed from the operator. + +2. **Read the pre-flight header** (first few lines of wrapper output). The `detected hub :` line lists role → port → env mappings. If it reads `(none)`, the wrapper narrowed to `tests/unit` only — call that out explicitly so the operator knows hardware tiers were skipped. + +3. **On pass**: one-line summary like `N passed, M skipped in `. Don't enumerate test names. DO mention any non-placeholder SKIPs (things like "role not present on hub") because they indicate missing hardware or setup issues. + +4. **On failure**: open `mcp-server/tests/report.html` (pytest-html output, self-contained) and extract the `Meshtastic debug` section for each failed test. That section includes a firmware log stream (last 200 lines) and device state dump. For each failure, summarise: + - test name + - one-line assertion message + - the specific firmware log lines that explain why (look for `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`, `No suitable channel`) + +5. **Classify each failure** as one of: + - **Transient flake** — LoRa collision, first-attempt NAK with self-heal pattern, timing-sensitive assertion. Suggest `/mcp-repro ` to confirm. + - **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest specific recovery (USB replug, `touch_1200bps`, `git status userPrefs.jsonc`). + - **Regression** — same assertion fails repeatedly on re-runs, firmware log shows novel errors. Identify the firmware module likely responsible. + +6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval. + +## Arguments convention + +Operators generally invoke this prompt either with no arguments (full suite) or with a specific subset. Examples: + +- `tests/mesh` — one tier +- `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip` — one test +- `--force-bake` — reflash devices first +- `-k telemetry` — name-filter + +## Side-effects to confirm in your summary + +- `userPrefs.jsonc` should be clean after a successful run. The session fixture in `mcp-server/tests/conftest.py` (`_session_userprefs`) snapshots and restores. Check `git status --porcelain userPrefs.jsonc` and report if it's non-empty. +- `mcp-server/tests/report.html` and `junit.xml` regenerate on every run. +- The wrapper prints a warning if a `.mcp-session-bak` sidecar was left over from a crashed prior session and auto-restores from it — mention that if it happened. diff --git a/.gitignore b/.gitignore index 43cee78db73..f1eb9d852d7 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ CMakeLists.txt # PYTHONPATH used by the Nix shell .python3 +.claude/scheduled_tasks.lock +userPrefs.jsonc.mcp-session-bak diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000000..c5cf2e55e5a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "meshtastic": { + "command": "./mcp-server/.venv/bin/python", + "args": ["-m", "meshtastic_mcp"], + "env": { + "MESHTASTIC_FIRMWARE_ROOT": "." + } + } + } +} diff --git a/.trunk/configs/.bandit b/.trunk/configs/.bandit index d286ded8974..c70e7743b67 100644 --- a/.trunk/configs/.bandit +++ b/.trunk/configs/.bandit @@ -1,2 +1,28 @@ [bandit] -skips = B101 \ No newline at end of file +# Rule IDs: https://bandit.readthedocs.io/en/latest/plugins/index.html +# +# B101 assert_used +# pytest assertions + internal invariants; required for pytest. +# B110 try_except_pass +# best-effort cleanup paths (atexit handlers, pubsub unsubscribe, +# session-end file close, socket shutdown). Logging inside the +# except block would be worse than the silent pass — teardown is +# already at end-of-session and the surrounding caller has context. +# B112 try_except_continue +# defensive loops over flaky sources (pubsub handlers, device +# re-enumeration polls). One failed iteration shouldn't abort the loop. +# B404 import_subprocess +# mcp-server wraps PlatformIO, esptool, nrfutil, picotool, and the +# pytest test-runner — subprocess is a load-bearing import here, not +# a smell. The "consider possible security implications" advisory is +# redundant given the file-level review already applied. +# B603 subprocess_without_shell_equals_true +# all subprocess calls use a static argv list; `shell=False` is the +# default and we never string-interpolate user input into the command. +# B606 start_process_with_no_shell +# same invariant as B603 — running a binary via argv list (not +# `shell=True`) is the safe pattern bandit is asking for. +# +# Higher-severity checks (B102 exec_used, B301 pickle, B307 eval, +# B602 shell=True, etc.) remain enabled. +skips = B101,B110,B112,B404,B603,B606 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..cd043c08787 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +# Agent instructions + +This repository is the [Meshtastic](https://meshtastic.org) firmware — a C++17 embedded codebase targeting ESP32 / nRF52 / RP2040 / STM32WL / Linux-Portduino LoRa mesh radios — plus a Python MCP server in `mcp-server/` that AI agents use to flash, configure, and test connected devices. + +## Primary instruction file + +**Read `.github/copilot-instructions.md` first.** That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions (naming, module framework, Observer pattern, thread safety), the build system, CI/CD, the native C++ test suite, and — most importantly for automation work — the **MCP Server & Hardware Test Harness** section. Read it top-to-bottom before starting any non-trivial change. + +This file (`AGENTS.md`) is a short pointer + quick reference for agents that don't read `.github/copilot-instructions.md` by default. + +## Quick command reference + +| Action | Command | +| -------------------------------- | ----------------------------------------------------------------------------------- | +| Build a firmware variant | `pio run -e ` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) | +| Clean + rebuild | `pio run -e -t clean && pio run -e ` | +| Flash a device | `pio run -e -t upload --upload-port ` (or use the `pio_flash` MCP tool) | +| Run firmware unit tests (native) | `pio test -e native` | +| Run MCP hardware tests | `./mcp-server/run-tests.sh` | +| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` | +| Format before commit | `trunk fmt` | +| Regenerate protobuf bindings | `bin/regen-protos.sh` | +| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` | + +## MCP server (device + test automation) + +The `mcp-server/` package exposes ~32 MCP tools for device discovery, building, flashing, serial monitoring, and live-node administration. Tools are grouped as: + +- **Discovery**: `list_devices`, `list_boards`, `get_board` +- **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 factory), `update_flash` (ESP32 OTA), `touch_1200bps` +- **Serial sessions**: `serial_open`, `serial_read`, `serial_list`, `serial_close` +- **Device reads**: `device_info`, `list_nodes` +- **Device writes** (require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api` +- **userPrefs admin**: `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` +- **Vendor escape hatches**: `esptool_*`, `nrfutil_*`, `picotool_*` + +Setup: `cd mcp-server && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'`. The repo registers the server via `.mcp.json` — Claude Code picks it up automatically. + +See `mcp-server/README.md` for argument shapes and the **MCP Server & Hardware Test Harness** section of `.github/copilot-instructions.md` for agent usage rules (tool surface, fixture contract, firmware integration points, recovery playbooks). + +## Slash commands (AI-assisted workflows) + +Three test-and-diagnose workflows exist as slash commands: + +- **`/test` (Claude Code) / `/mcp-test` (Copilot)** — run the hardware test suite and interpret failures +- **`/diagnose` / `/mcp-diagnose`** — read-only device health report +- **`/repro` / `/mcp-repro`** — flakiness triage: re-run one test N times, diff firmware logs between passes and failures + +Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude/commands/README.md` is the index. + +## House rules + +- **No destructive device operations without operator approval.** `factory_reset`, `erase_and_flash`, `reboot`, `shutdown`, history-rewriting git ops — describe the action and stop. Operator authorizes. +- **One MCP call per serial port at a time.** The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device. +- **`userPrefs.jsonc` is session state during tests.** The `_session_userprefs` fixture snapshots + restores it; never edit it from inside a test. +- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate. +- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. +- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings. + +## Typical agent workflows + +### Flashing a device + +1. `list_devices` → find the port + likely VID +2. `list_boards` → confirm the env, or use the known default for the hardware +3. `pio_flash(env=..., port=..., confirm=True)` for any arch, or `erase_and_flash(env=..., port=..., confirm=True)` for an ESP32 factory install + +### Inspecting live node state + +1. `device_info(port=...)` — short summary (node num, firmware version, region, peer count) +2. `list_nodes(port=...)` — full peer table (SNR, RSSI, pubkey presence, last_heard) +3. `get_config(section="lora", port=...)` — LoRa settings for cross-device comparison + +Sequence these; don't parallelize on the same port. + +### Testing a firmware change + +1. Build locally: `pio run -e ` +2. Flash the test device: `pio_flash(env=..., port=..., confirm=True)` +3. Run the suite: `./mcp-server/run-tests.sh tests/` or `/test tests/` +4. On failure, open `mcp-server/tests/report.html` → `Meshtastic debug` section for the firmware log tail + device state dump +5. Iterate + +### Debugging a flaky test + +1. `/repro [count]` — re-runs the test N times, diffs firmware logs between passes and failures +2. If the first attempt always fails and the rest pass, that's a state-leak pattern → suggest `--force-bake` or a clean device state, don't chase the first failure +3. If all N fail, this isn't a flake — it's a regression. Stop iterating and escalate to `/test` for full-suite context. + +## Where to look + +| Path | What's there | +| --------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) | +| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI | +| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers | +| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) | +| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` | +| `test/` | Firmware unit tests (12 suites; `pio test -e native`) | +| `mcp-server/` | Python MCP server + pytest hardware integration tests | +| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `fleet/`, `admin/`, `provisioning/` | +| `.claude/commands/` | Claude Code slash command bodies | +| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) | +| `.github/copilot-instructions.md` | **Primary agent instructions — read this** | +| `.github/workflows/` | CI pipelines | +| `.mcp.json` | MCP server registration for Claude Code | + +## Recovery one-liners + +- **`userPrefs.jsonc` dirty after a test run?** Re-run `./mcp-server/run-tests.sh` once (pre-flight self-heals from the sidecar). If still dirty: `git checkout userPrefs.jsonc`. +- **nRF52 not responding?** `mcp__meshtastic__touch_1200bps(port=...)` drops it into the DFU bootloader, then `pio_flash` re-installs. +- **Port busy?** `lsof ` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it. +- **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned. diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 00000000000..f5180bc71a1 --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,26 @@ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +dist/ +build/ + +# Test harness artifacts +tests/report.html +tests/junit.xml +tests/reportlog.jsonl +tests/fwlog.jsonl +# Subprocess-output tee from pio/esptool/nrfutil/picotool (live flash +# progress for the TUI; also a post-run diagnostic for plain CLI runs). +tests/flash.log +tests/tool_coverage.json +tests/.coverage +htmlcov/ +# Persistent run counter for meshtastic-mcp-test-tui header. +tests/.tui-runs +# Cross-run history (TUI duration sparkline). +tests/.history/ +# Reproducer bundles (TUI `x` export on failed tests). +tests/reproducers/ diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 00000000000..7d5fc551a7b --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,270 @@ +# Meshtastic MCP Server + +An [MCP](https://modelcontextprotocol.io) server for working with the Meshtastic firmware repo and connected devices. Lets Claude Code / Claude Desktop: + +- Discover USB-connected Meshtastic devices +- Enumerate PlatformIO board variants (166+) with Meshtastic metadata +- Build, clean, flash, erase-and-flash (factory), and OTA-update firmware +- Read serial logs via `pio device monitor` (with board-specific exception decoders) +- Trigger 1200bps touch-reset for bootloader entry (nRF52, ESP32-S3, RP2040) +- Query and administer a running node via the [`meshtastic` Python API](https://github.com/meshtastic/python): owner name, config (LocalConfig + ModuleConfig), channels, messaging, reboot/shutdown/factory-reset +- Call `esptool`, `nrfutil`, `picotool` directly when PlatformIO doesn't cover the operation + +## Design principle + +**PlatformIO first.** Its `pio run -t upload` knows the correct protocol, offsets, and post-build chain for every variant in `variants/`. Direct vendor-tool wrappers (`esptool_*`, `nrfutil_*`, `picotool_*`) exist as escape hatches for operations pio doesn't cover (blank-chip erase, DFU `.zip` packages, BOOTSEL-mode inspection). + +## Prerequisites + +- Python ≥ 3.11 +- [PlatformIO Core](https://platformio.org/install/cli) — `pio` on `$PATH` or at `~/.platformio/penv/bin/pio` +- The Meshtastic firmware repo checked out somewhere (set via `MESHTASTIC_FIRMWARE_ROOT`) +- Optional: `esptool`, `nrfutil`, `picotool` on `$PATH` (or under the firmware venv at `.venv/bin/`) if you want to use the direct-tool wrappers + +## Install + +```bash +cd /mcp-server +python3 -m venv .venv +.venv/bin/pip install -e . +``` + +Verify: + +```bash +MESHTASTIC_FIRMWARE_ROOT= .venv/bin/python -m meshtastic_mcp +``` + +The server blocks on stdin (that's correct — it speaks MCP over stdio). Ctrl-C to exit. + +## Register with Claude Code + +Edit `~/.claude/settings.json` (global) or `/.claude/settings.local.json` (project-only): + +```json +{ + "mcpServers": { + "meshtastic": { + "command": "/mcp-server/.venv/bin/python", + "args": ["-m", "meshtastic_mcp"], + "env": { + "MESHTASTIC_FIRMWARE_ROOT": "" + } + } + } +} +``` + +Replace `` with the absolute path, e.g. `/Users/you/GitHub/firmware`. Restart Claude Code after editing. + +## Register with Claude Desktop + +Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows). + +## Tools (38) + +### Discovery & metadata + +| Tool | What it does | +| -------------- | ------------------------------------------------------------------------------------------ | +| `list_devices` | USB/serial port listing, flags likely-Meshtastic candidates | +| `list_boards` | PlatformIO envs with `custom_meshtastic_*` metadata; filters by arch/supported/query/level | +| `get_board` | Full env dict incl. raw pio config | + +### Build & flash + +| Tool | What it does | +| ----------------- | -------------------------------------------------------------------- | +| `build` | `pio run -e ` (+ mtjson target) | +| `clean` | `pio run -e -t clean` | +| `pio_flash` | `pio run -e -t upload --upload-port ` — any architecture | +| `erase_and_flash` | ESP32 full factory flash via `bin/device-install.sh` | +| `update_flash` | ESP32 OTA app-partition update via `bin/device-update.sh` | +| `touch_1200bps` | 1200-baud open/close to trigger USB CDC bootloader entry | + +### Serial log sessions + +Backed by long-running `pio device monitor` subprocesses with a 10k-line ring buffer per session and board-specific filters (`esp32_exception_decoder` auto-selected when you pass `env=`). + +| Tool | What it does | +| -------------- | ------------------------------------------------------------------ | +| `serial_open` | Start a monitor session; returns `session_id` | +| `serial_read` | Cursor-based pull; reports `dropped` if lines aged out of the ring | +| `serial_list` | All active sessions | +| `serial_close` | Terminate a session | + +### Device reads + +| Tool | What it does | +| ------------- | --------------------------------------------------------------------------- | +| `device_info` | my_node_num, long/short name, firmware version, region, channel, node count | +| `list_nodes` | Full node database with position, SNR, RSSI, last_heard, battery | + +_The tool tables below document 38 currently registered MCP server tools._ + +### Device writes + +| Tool | What it does | +| ------------------- | -------------------------------------------------------------------------- | +| `set_owner` | Long name + optional short name (≤4 chars) | +| `get_config` | One section or all (LocalConfig + ModuleConfig) | +| `set_config` | Dot-path field write: `lora.region`=`"US"`, `device.role`=`"ROUTER"`, etc. | +| `get_channel_url` | Primary-only or include_all=admin URL | +| `set_channel_url` | Import channels from a Meshtastic URL | +| `set_debug_log_api` | Enable or disable debug logging for the Meshtastic Python API client | +| `send_text` | Broadcast or direct text message | +| `reboot` | `localNode.reboot(secs)` — requires `confirm=True` | +| `shutdown` | `localNode.shutdown(secs)` — requires `confirm=True` | +| `factory_reset` | `localNode.factoryReset(full?)` — requires `confirm=True` | + +### Direct hardware tools (escape hatches) + +| Tool | What it does | +| --------------------- | --------------------------------------------------------- | +| `esptool_chip_info` | Read chip, MAC, crystal, flash size | +| `esptool_erase_flash` | Full-chip erase (destructive) | +| `esptool_raw` | Pass-through; confirm=True required for write/erase/merge | +| `nrfutil_dfu` | DFU-flash a `.zip` package | +| `nrfutil_raw` | Pass-through | +| `picotool_info` | Read Pico BOOTSEL-mode info | +| `picotool_load` | Load a UF2 | +| `picotool_raw` | Pass-through | + +## Safety + +- **All destructive flash/admin tools require `confirm=True`** as a tool-level gate, on top of any permission prompt from Claude. +- **Serial port is exclusive.** If a `serial_*` session is active on a port, `device_info`/admin tools on the same port will fail fast with a pointer at the active `session_id`. Close the session first. +- **Flash confirmation by architecture**: `erase_and_flash` / `update_flash` error if the env's architecture isn't ESP32 — use `pio_flash` for nRF52/RP2040/STM32. + +## Environment variables + +| Var | Default | Purpose | +| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | +| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo | +| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location | +| `MESHTASTIC_ESPTOOL_BIN` | `/.venv/bin/esptool` → `$PATH` | Override esptool | +| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil | +| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool | +| `MESHTASTIC_MCP_SEED` | `mcp--` | PSK seed for test-harness session (CI override) | +| `MESHTASTIC_MCP_FLASH_LOG` | `/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) | + +## Hardware Test Suite + +`mcp-server/tests/` holds a pytest-based integration suite that exercises +real USB-connected Meshtastic devices against the MCP server surface. Separate +from the native C++ unit tests in the firmware repo's top-level `test/` +directory — this one validates the device-facing behavior end-to-end. + +### Invocation + +```bash +./mcp-server/run-tests.sh # full suite (auto-detect + auto-bake-if-needed) +./mcp-server/run-tests.sh --force-bake # reflash devices before testing +./mcp-server/run-tests.sh --assume-baked # skip the bake step (caller vouches for state) +./mcp-server/run-tests.sh tests/mesh # one tier +./mcp-server/run-tests.sh tests/mesh/test_traceroute.py # one file +./mcp-server/run-tests.sh -k telemetry # pytest name filter +``` + +The wrapper auto-detects connected devices (VID `0x239A` → `nrf52` → env +`rak4631`; `0x303A` or `0x10C4` → `esp32s3` → env `heltec-v3`), exports +`MESHTASTIC_MCP_ENV_` env vars, and invokes pytest. Overrides via +per-role env vars: `MESHTASTIC_MCP_ENV_NRF52=heltec-mesh-node-t114 ./run-tests.sh`. + +No hardware connected? The wrapper narrows to `tests/unit/` only and says so +in the pre-flight header. + +### Tiers (run in this order) + +- **`bake`** (`tests/test_00_bake.py`) — flashes both hub roles with the + session's test profile. Has a skip-if-already-baked check (region + channel + match); `--force-bake` overrides. +- **`unit`** — pure Python, no hardware. boards / PIO wrapper / + userPrefs-parse / testing-profile fixtures. +- **`mesh`** — 2-device mesh: formation, broadcast delivery, direct+ACK, + traceroute, bidirectional. Parametrized over both directions. +- **`telemetry`** — periodic telemetry broadcast + on-demand request/reply + (`TELEMETRY_APP` with `wantResponse=True`). +- **`monitor`** — boot log has no panic markers within 60 s of reboot. +- **`fleet`** — PSK-seed isolation: two labs with different seeds never + overlap. +- **`admin`** — owner persistence across reboot, channel URL round-trip, + `lora.hop_limit` persistence. +- **`provisioning`** — region/channel baking, userPrefs survive + `factory_reset(full=False)`. + +### Artifacts (regenerated every run, under `tests/`) + +- `report.html` — self-contained pytest-html report. Each test gets a + **Meshtastic debug** section attached on failure with a 200-line firmware + log tail + device-state dump. Open this first on failures. +- `junit.xml` — CI-parseable. +- `reportlog.jsonl` — `pytest-reportlog` event stream; consumed by the TUI. +- `fwlog.jsonl` — firmware log mirror (`meshtastic.log.line` pubsub → JSONL). +- `flash.log` — tee of all pio / esptool / nrfutil / picotool subprocess + output during the run (driven by `MESHTASTIC_MCP_FLASH_LOG`). + +### Live TUI + +```bash +.venv/bin/meshtastic-mcp-test-tui +.venv/bin/meshtastic-mcp-test-tui tests/mesh # pytest args pass through +``` + +Textual-based wrapper over `run-tests.sh` with a live test tree, tier +counters, pytest output pane, firmware-log pane, and a device-status strip. +Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open +`report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q` +quit (SIGINT → SIGTERM → SIGKILL escalation). + +### Slash commands + +Three AI-assisted workflows are wired up for Claude Code operators +(`.claude/commands/`) and Copilot operators (`.github/prompts/`): +`/test` (run + interpret), `/diagnose` (read-only health report), `/repro` +(flake triage, N-times re-run with log diff). + +### House rules (for human + agent contributors) + +- Session-scoped fixtures in `tests/conftest.py` snapshot + restore + `userPrefs.jsonc`; **never edit `userPrefs.jsonc` from inside a test**. + Use the `test_profile` / `no_region_profile` fixtures for ephemeral + overrides. +- `SerialInterface` holds an **exclusive port lock**; sequence calls + open → mutate → close, then next device. No parallel calls to the + same port. +- Directed PKI-encrypted sends need **bilateral NodeInfo warmup** — + both sides must hold the other's current pubkey. See + `tests/mesh/_receive.py::nudge_nodeinfo_port` and the three directed- + send tests (`test_direct_with_ack`, `test_traceroute`, + `test_telemetry_request_reply`) for the canonical pattern. + +## Layout + +```text +mcp-server/ +├── pyproject.toml +├── README.md +└── src/meshtastic_mcp/ + ├── __main__.py # entry: python -m meshtastic_mcp + ├── server.py # FastMCP app + @app.tool() registrations (thin) + ├── config.py # firmware_root, pio_bin, esptool_bin, etc. + ├── pio.py # subprocess wrapper (timeouts, JSON, tail_lines) + ├── devices.py # list_devices (findPorts + comports) + ├── boards.py # list_boards / get_board (pio project config parse + cache) + ├── flash.py # build, clean, flash, erase_and_flash, update_flash, touch_1200bps + ├── serial_session.py # SerialSession + reader thread + ring buffer + ├── registry.py # session registry + per-port locks + ├── connection.py # connect(port) ctx mgr — SerialInterface + port lock + ├── info.py # device_info, list_nodes + ├── admin.py # set_owner, get/set_config, channels, send_text, reboot/shutdown/factory_reset + └── hw_tools.py # esptool / nrfutil / picotool wrappers +``` + +## Troubleshooting + +- **"Could not locate Meshtastic firmware root"** — set `MESHTASTIC_FIRMWARE_ROOT`. +- **"Could not find `pio`"** — install PlatformIO or set `MESHTASTIC_PIO_BIN`. +- **"Port is held by serial session ..."** — call `serial_close(session_id)` or `serial_list` to find it. +- **`factory.bin` not found after build** — the env may not be ESP32; only ESP32 envs produce a `.factory.bin`. +- **`touch_1200bps` reported `new_port: null`** — the device may not have 1200bps-reset stdio, or the bootloader re-uses the same port name. Check `list_devices` manually. diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 00000000000..d73bf795f5f --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "meshtastic-mcp" +version = "0.1.0" +description = "MCP server for Meshtastic firmware development: device discovery, PlatformIO tooling, flashing, serial monitoring, and device administration via the meshtastic Python API." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "GPL-3.0-only" } +authors = [{ name = "thebentern" }] +dependencies = ["mcp>=1.2", "pyserial>=3.5", "meshtastic>=2.7.8"] + +[project.optional-dependencies] +dev = ["pytest>=7"] +test = [ + "pytest>=8", + "pytest-html>=4", + "pytest-reportlog>=0.4", + "pytest-timeout>=2.3", + "coverage[toml]>=7", + "pyyaml>=6", + # textual is required by the `meshtastic-mcp-test-tui` script (see + # `src/meshtastic_mcp/cli/test_tui.py`). Bundled into `test` rather than a + # separate `[tui]` extra because v1 expects test operators are the only + # consumers; revisit if install cost pushes back. + "textual>=0.50", +] + +[project.scripts] +meshtastic-mcp = "meshtastic_mcp.__main__:main" +# Live TUI wrapping run-tests.sh — shells out to the same script the plain +# CLI uses, tails pytest-reportlog for per-test state, and polls the device +# list at startup + post-run (port lock forces it to stay idle during the run). +meshtastic-mcp-test-tui = "meshtastic_mcp.cli.test_tui:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/meshtastic_mcp"] diff --git a/mcp-server/run-tests.sh b/mcp-server/run-tests.sh new file mode 100755 index 00000000000..292e6e3a2f7 --- /dev/null +++ b/mcp-server/run-tests.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# mcp-server hardware test runner. +# +# Auto-detects connected Meshtastic devices, maps each to its PlatformIO env +# via the same role table the pytest fixtures use, exports the right +# MESHTASTIC_MCP_ENV_* env vars, and invokes pytest. +# +# Usage: +# ./run-tests.sh # full suite, default pytest args +# ./run-tests.sh tests/mesh # subset (any pytest args pass through) +# ./run-tests.sh --force-bake # override one default with another +# MESHTASTIC_MCP_ENV_NRF52=foo ./run-tests.sh # override env per role +# MESHTASTIC_MCP_SEED=ci-run-42 ./run-tests.sh # override PSK seed +# +# If zero supported devices are detected, only the unit tier runs. +# +# Also restores `userPrefs.jsonc` from the session-backup sidecar if a prior +# run exited abnormally (belt to conftest.py's atexit suspenders). + +set -euo pipefail + +# cd to the script's directory so relative paths resolve consistently no +# matter where the user invoked from. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +VENV_PY="$SCRIPT_DIR/.venv/bin/python" +if [[ ! -x $VENV_PY ]]; then + echo "error: $VENV_PY not found or not executable." >&2 + echo " Bootstrap the venv first:" >&2 + echo " cd $SCRIPT_DIR && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'" >&2 + exit 2 +fi + +# Resolve firmware root the same way conftest.py does (this script sits in +# mcp-server/, firmware repo root is one level up). +FIRMWARE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +USERPREFS_PATH="$FIRMWARE_ROOT/userPrefs.jsonc" +USERPREFS_SIDECAR="$USERPREFS_PATH.mcp-session-bak" + +# ---------- Pre-flight: recover stale userPrefs.jsonc from prior crash ---- +# If conftest.py's atexit hook didn't fire (SIGKILL, kernel panic, OS +# restart), the sidecar is the ground truth. Self-heal before running so we +# don't bake the previous run's dirty state into this run's firmware. +if [[ -f $USERPREFS_SIDECAR ]]; then + echo "[pre-flight] found $USERPREFS_SIDECAR from a prior abnormal exit;" >&2 + echo " restoring userPrefs.jsonc before starting." >&2 + cp "$USERPREFS_SIDECAR" "$USERPREFS_PATH" + rm -f "$USERPREFS_SIDECAR" +fi + +# If userPrefs.jsonc has uncommitted changes BEFORE the run starts, that's +# worth warning about — tests will snapshot this dirty state and restore to +# it at the end, which may not be what the operator wants. +if command -v git >/dev/null 2>&1; then + cd "$FIRMWARE_ROOT" + # Capture the git status into a local first — SC2312 flags command + # substitution inside `[[ -n ... ]]` because the exit code of `git + # status` is masked. A two-step assignment makes the failure path + # explicit (non-git, missing file) and keeps the bracket test clean. + _git_status_porcelain="$(git status --porcelain userPrefs.jsonc 2>/dev/null || true)" + if [[ -n $_git_status_porcelain ]]; then + echo "[pre-flight] warning: userPrefs.jsonc has uncommitted changes." >&2 + echo " Tests will snapshot THIS state and restore to it" >&2 + echo " at teardown. If that's not intended, run:" >&2 + echo " git checkout userPrefs.jsonc" >&2 + echo " and re-invoke." >&2 + fi + cd "$SCRIPT_DIR" +fi + +# ---------- Seed default -------------------------------------------------- +# Per-machine default so repeated runs from the same operator land on the +# same PSK (makes --assume-baked valid across invocations). Operator can +# override with an explicit env var if they want isolation (e.g. CI). +if [[ -z ${MESHTASTIC_MCP_SEED-} ]]; then + WHO="$(whoami 2>/dev/null || echo anon)" + HOST="$(hostname -s 2>/dev/null || echo host)" + export MESHTASTIC_MCP_SEED="mcp-${WHO}-${HOST}" +fi + +# ---------- Flash progress log -------------------------------------------- +# pio.py / hw_tools.py tee subprocess output (pio run -t upload, esptool, +# nrfutil, picotool) to this file line-by-line as it arrives when this env +# var is set. The TUI tails it so the operator sees live flash progress +# instead of 3 minutes of silence during `test_00_bake.py`. Plain CLI users +# also benefit — the log is a post-run diagnostic even without the TUI. +# Truncate at session start so each run gets a clean log. +export MESHTASTIC_MCP_FLASH_LOG="$SCRIPT_DIR/tests/flash.log" +: >"$MESHTASTIC_MCP_FLASH_LOG" + +# ---------- Detect connected hardware ------------------------------------- +# In-process call to the same Python API the test fixtures use, so the +# script never drifts from what pytest sees. Returns a JSON object +# {role: port, ...}. +ROLES_JSON="$( + "$VENV_PY" - <<'PY' +import json +import sys + +sys.path.insert(0, "src") +from meshtastic_mcp import devices + +# Role → canonical VID map. Kept in sync with +# `tests/conftest.py::hub_profile` defaults; if that changes, this must too. +ROLE_BY_VID = { + 0x239A: "nrf52", # Adafruit / RAK nRF52 native USB (app + DFU) + 0x303A: "esp32s3", # Espressif native USB (ESP32-S3) + 0x10C4: "esp32s3", # CP2102 USB-UART (common on Heltec/LilyGO ESP32 boards) +} + +out: dict[str, str] = {} +for dev in devices.list_devices(include_unknown=True): + vid_raw = dev.get("vid") or "" + try: + if isinstance(vid_raw, str) and vid_raw.startswith("0x"): + vid = int(vid_raw, 16) + else: + vid = int(vid_raw) + except (TypeError, ValueError): + continue + role = ROLE_BY_VID.get(vid) + # First port wins per role — matches hub_devices fixture semantics. + if role and role not in out: + out[role] = dev["port"] + +json.dump(out, sys.stdout) +PY +)" + +# ---------- Map role → pio env -------------------------------------------- +# Honor MESHTASTIC_MCP_ENV_ operator overrides; fall back to the +# same defaults hardcoded in tests/conftest.py::_DEFAULT_ROLE_ENVS. +resolve_env() { + local role="$1" + local default="$2" + local upper + upper="$(echo "$role" | tr '[:lower:]' '[:upper:]')" + local var="MESHTASTIC_MCP_ENV_${upper}" + eval "local override=\${$var:-}" + if [[ -n $override ]]; then + echo "$override" + else + echo "$default" + fi +} + +NRF52_PORT="$(echo "$ROLES_JSON" | "$VENV_PY" -c 'import json,sys; print(json.loads(sys.stdin.read()).get("nrf52", ""))')" +ESP32S3_PORT="$(echo "$ROLES_JSON" | "$VENV_PY" -c 'import json,sys; print(json.loads(sys.stdin.read()).get("esp32s3", ""))')" + +DETECTED="" +if [[ -n $NRF52_PORT ]]; then + NRF52_ENV="$(resolve_env nrf52 rak4631)" + export MESHTASTIC_MCP_ENV_NRF52="$NRF52_ENV" + DETECTED="${DETECTED} nrf52 @ ${NRF52_PORT} -> env=${NRF52_ENV}\n" +fi +if [[ -n $ESP32S3_PORT ]]; then + ESP32S3_ENV="$(resolve_env esp32s3 heltec-v3)" + export MESHTASTIC_MCP_ENV_ESP32S3="$ESP32S3_ENV" + DETECTED="${DETECTED} esp32s3 @ ${ESP32S3_PORT} -> env=${ESP32S3_ENV}\n" +fi + +# ---------- Pre-flight summary -------------------------------------------- +# Surface what pytest is about to do with respect to the bake phase: the +# operator should see "will verify + bake if needed" by default, so a +# 3-minute flash appearing mid-run isn't a surprise. Detection of the +# explicit overrides is best-effort — we just scan $@ for the known flags. +_bake_mode="auto (verify + bake if needed)" +for _arg in "$@"; do + case "$_arg" in + --assume-baked) _bake_mode="skip (--assume-baked)" ;; + --force-bake) _bake_mode="force (--force-bake)" ;; + *) ;; # any other arg: pass-through; bake mode unchanged + esac +done + +echo "mcp-server test runner" +echo " firmware root : $FIRMWARE_ROOT" +echo " seed : $MESHTASTIC_MCP_SEED" +echo " bake : $_bake_mode" +if [[ -n $DETECTED ]]; then + echo " detected hub :" + printf "%b" "$DETECTED" +else + echo " detected hub : (none)" +fi +echo + +# ---------- Invoke pytest ------------------------------------------------- +# If no devices detected, only the unit tier would produce meaningful +# PASS/FAIL — every hardware test would SKIP with "role not present". We +# narrow to tests/unit explicitly so the summary reads as "no hardware, +# unit suite only" instead of "big skip count looks suspicious". +if [[ -z $DETECTED && $# -eq 0 ]]; then + echo "[pre-flight] no supported devices detected; running unit tier only." + echo + exec "$VENV_PY" -m pytest tests/unit -v --report-log=tests/reportlog.jsonl +fi + +# Default pytest args when the user passed none. Power users can invoke +# `./run-tests.sh tests/mesh -v --tb=long` and skip all of these defaults. +# +# NOTE: `--assume-baked` is DELIBERATELY omitted here. `tests/test_00_bake.py` +# has an internal skip-if-already-baked check (`_bake_role`: query device_info, +# compare region + primary_channel to the session profile, skip on match). +# So the fast path is ~8-10 s of verification overhead when the devices are +# already baked — negligible next to the 2-6 min suite runtime. Letting +# test_00_bake.py run means a fresh device, a re-seeded session, or a post- +# factory-reset device gets flashed automatically instead of silently +# skipping half the hardware tests with "not baked with session profile" +# errors. Power users who know their hardware is current and want to shave +# those seconds can pass `--assume-baked` explicitly. +if [[ $# -eq 0 ]]; then + set -- tests/ \ + --html=tests/report.html --self-contained-html \ + --junitxml=tests/junit.xml \ + -v --tb=short +fi + +# Always emit `tests/reportlog.jsonl` (unless the operator explicitly passed +# their own `--report-log=...`). Consumers — notably the +# `meshtastic-mcp-test-tui` TUI — tail the reportlog for live per-test state. +# Appending here means power-user invocations like `./run-tests.sh tests/mesh` +# also produce it, not just the all-defaults invocation. +_has_report_log=0 +for _arg in "$@"; do + case "$_arg" in + --report-log | --report-log=*) _has_report_log=1 ;; + *) ;; # any other arg: no-op; loop continues + esac +done +if [[ $_has_report_log -eq 0 ]]; then + set -- "$@" --report-log=tests/reportlog.jsonl +fi + +exec "$VENV_PY" -m pytest "$@" diff --git a/mcp-server/src/meshtastic_mcp/__init__.py b/mcp-server/src/meshtastic_mcp/__init__.py new file mode 100644 index 00000000000..bd696afe01d --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/__init__.py @@ -0,0 +1,3 @@ +"""Meshtastic MCP server — device discovery, PlatformIO tooling, and device admin.""" + +__version__ = "0.1.0" diff --git a/mcp-server/src/meshtastic_mcp/__main__.py b/mcp-server/src/meshtastic_mcp/__main__.py new file mode 100644 index 00000000000..4ed67db3821 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/__main__.py @@ -0,0 +1,11 @@ +"""Entry point for `python -m meshtastic_mcp`.""" + +from meshtastic_mcp.server import app + + +def main() -> None: + app.run() + + +if __name__ == "__main__": + main() diff --git a/mcp-server/src/meshtastic_mcp/admin.py b/mcp-server/src/meshtastic_mcp/admin.py new file mode 100644 index 00000000000..6da92d860a4 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/admin.py @@ -0,0 +1,377 @@ +"""Device administration: owner, config, channels, messaging, admin actions. + +All operations use the same `connect()` context manager so port selection, +port-busy detection, and cleanup are handled uniformly. + +Config writes use a dot-path: the first segment names a section (e.g. +`"lora"` in LocalConfig or `"mqtt"` in LocalModuleConfig), remaining segments +walk protobuf fields. Enum fields accept their string names (`"US"` for +`lora.region`) so callers don't need to know the numeric values. +""" + +from __future__ import annotations + +from typing import Any + +from google.protobuf import descriptor as pb_descriptor +from google.protobuf import json_format +from meshtastic.protobuf import localonly_pb2 + +from .connection import connect + + +class AdminError(RuntimeError): + pass + + +LOCAL_CONFIG_SECTIONS = {f.name for f in localonly_pb2.LocalConfig.DESCRIPTOR.fields} +MODULE_CONFIG_SECTIONS = { + f.name for f in localonly_pb2.LocalModuleConfig.DESCRIPTOR.fields +} + + +def _require_confirm(confirm: bool, operation: str) -> None: + if not confirm: + raise AdminError(f"{operation} is destructive and requires confirm=True.") + + +def _message_to_dict(msg: Any) -> dict[str, Any]: + # `including_default_value_fields` was renamed to + # `always_print_fields_with_no_presence` in protobuf 5.26+. Pick whichever + # kwarg the installed version accepts so we work against both. + kwargs: dict[str, Any] = {"preserving_proto_field_name": True} + import inspect + + sig = inspect.signature(json_format.MessageToDict) + if "always_print_fields_with_no_presence" in sig.parameters: + kwargs["always_print_fields_with_no_presence"] = False + elif "including_default_value_fields" in sig.parameters: + kwargs["including_default_value_fields"] = False + return json_format.MessageToDict(msg, **kwargs) + + +# ---------- owner ---------------------------------------------------------- + + +def set_owner( + long_name: str, + short_name: str | None = None, + port: str | None = None, +) -> dict[str, Any]: + if short_name is not None and len(short_name) > 4: + raise AdminError("short_name must be 4 characters or fewer") + with connect(port=port) as iface: + iface.localNode.setOwner(long_name=long_name, short_name=short_name) + return { + "ok": True, + "long_name": long_name, + "short_name": short_name, + } + + +# ---------- config reads --------------------------------------------------- + + +def _section_container(node, section: str) -> tuple[Any, str]: + """Return (container_message, parent_name) for a section name. + + Parent is 'localConfig' or 'moduleConfig' so callers know where to call + writeConfig() after mutating. + """ + if section in LOCAL_CONFIG_SECTIONS: + return getattr(node.localConfig, section), "localConfig" + if section in MODULE_CONFIG_SECTIONS: + return getattr(node.moduleConfig, section), "moduleConfig" + raise AdminError( + f"Unknown config section: {section!r}. " + f"Valid sections: {sorted(LOCAL_CONFIG_SECTIONS | MODULE_CONFIG_SECTIONS)}" + ) + + +def get_config(section: str | None = None, port: str | None = None) -> dict[str, Any]: + """Read one or all config sections. + + `section` may be any name in LocalConfig (device, lora, position, power, + network, display, bluetooth, security) or LocalModuleConfig (mqtt, serial, + telemetry, ...). Omit `section` or pass `"all"` for everything. + """ + with connect(port=port) as iface: + node = iface.localNode + if section in (None, "all"): + lc = _message_to_dict(node.localConfig) + mc = _message_to_dict(node.moduleConfig) + return { + "config": { + "localConfig": lc, + "moduleConfig": mc, + } + } + container, _parent = _section_container(node, section) + return {"config": {section: _message_to_dict(container)}} + + +# ---------- config writes -------------------------------------------------- + + +def _coerce_enum(field: pb_descriptor.FieldDescriptor, value: Any) -> int: + """Accept an enum value as either its int or its string name.""" + enum_type = field.enum_type + if isinstance(value, bool): + raise AdminError(f"{field.name}: expected enum {enum_type.name}, got bool") + if isinstance(value, int): + if enum_type.values_by_number.get(value) is None: + raise AdminError( + f"{field.name}: {value} is not a valid {enum_type.name} value" + ) + return value + if isinstance(value, str): + upper = value.upper() + ev = enum_type.values_by_name.get(upper) + if ev is None: + valid = sorted(enum_type.values_by_name.keys()) + raise AdminError( + f"{field.name}: {value!r} is not a valid {enum_type.name}. " + f"Valid: {valid}" + ) + return ev.number + raise AdminError( + f"{field.name}: expected enum {enum_type.name}, got {type(value).__name__}" + ) + + +def _coerce_scalar(field: pb_descriptor.FieldDescriptor, value: Any) -> Any: + t = field.type + FT = pb_descriptor.FieldDescriptor + if t == FT.TYPE_ENUM: + return _coerce_enum(field, value) + if t == FT.TYPE_BOOL: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "yes", "1", "on") + if isinstance(value, int): + return bool(value) + if t in ( + FT.TYPE_INT32, + FT.TYPE_INT64, + FT.TYPE_UINT32, + FT.TYPE_UINT64, + FT.TYPE_SINT32, + FT.TYPE_SINT64, + FT.TYPE_FIXED32, + FT.TYPE_FIXED64, + ): + return int(value) + if t in (FT.TYPE_FLOAT, FT.TYPE_DOUBLE): + return float(value) + if t == FT.TYPE_STRING: + return str(value) + if t == FT.TYPE_BYTES: + if isinstance(value, (bytes, bytearray)): + return bytes(value) + return str(value).encode("utf-8") + raise AdminError( + f"{field.name}: unsupported field type {t}. Use raw protobuf for this field." + ) + + +def _walk_to_field( + root_msg: Any, path_segments: list[str] +) -> tuple[Any, pb_descriptor.FieldDescriptor]: + """Walk `root_msg` by field names until the leaf; return (parent_msg, leaf_field_descriptor).""" + msg = root_msg + for i, name in enumerate(path_segments): + desc = msg.DESCRIPTOR + field = desc.fields_by_name.get(name) + if field is None: + trail = ".".join(path_segments[:i] or [""]) + valid = [f.name for f in desc.fields] + raise AdminError(f"No field {name!r} in {trail}. Valid: {valid}") + is_last = i == len(path_segments) - 1 + if is_last: + return msg, field + if field.type != pb_descriptor.FieldDescriptor.TYPE_MESSAGE: + raise AdminError( + f"{'.'.join(path_segments[:i+1])} is a scalar; cannot descend into it" + ) + msg = getattr(msg, name) + # path_segments was empty + raise AdminError("Empty config path") + + +def set_config(path: str, value: Any, port: str | None = None) -> dict[str, Any]: + """Set a single config field by dot-path and write it to the device. + + Examples: + set_config("lora.region", "US") + set_config("lora.modem_preset", "LONG_FAST") + set_config("device.role", "ROUTER") + set_config("mqtt.enabled", True) + set_config("mqtt.address", "mqtt.example.com") + + """ + segments = [s for s in path.split(".") if s] + if not segments: + raise AdminError("path cannot be empty") + section = segments[0] + + with connect(port=port) as iface: + node = iface.localNode + container, parent_name = _section_container(node, section) + + # Treat the section as the root; the rest of the path walks into it. + leaf_parent, field = _walk_to_field(container, segments[1:] or []) + # Use `is_repeated` (modern upb protobuf API) rather than the + # deprecated `label == LABEL_REPEATED` check — the C-extension + # FieldDescriptor in protobuf >= 5.x doesn't expose `.label` at + # all, and `is_repeated` is the supported replacement that works + # across both the pure-python and upb backends. + if field.is_repeated: + raise AdminError( + f"{path!r} is a repeated field; v1 only supports scalar sets. " + "Use the raw meshtastic CLI for now." + ) + old_raw = getattr(leaf_parent, field.name) + coerced = _coerce_scalar(field, value) + try: + setattr(leaf_parent, field.name, coerced) + except (TypeError, ValueError) as exc: + raise AdminError(f"{path}: {exc}") from exc + + node.writeConfig(section) + + # Stringify enums for the response (so the caller can see the change in + # the same vocabulary they used to set it). + if field.type == pb_descriptor.FieldDescriptor.TYPE_ENUM: + try: + old_display = field.enum_type.values_by_number[old_raw].name + new_display = field.enum_type.values_by_number[coerced].name + except Exception: + old_display, new_display = old_raw, coerced + else: + old_display, new_display = old_raw, coerced + + return { + "ok": True, + "path": path, + "section": section, + "parent": parent_name, + "old_value": old_display, + "new_value": new_display, + } + + +# ---------- channels ------------------------------------------------------- + + +def get_channel_url( + include_all: bool = False, port: str | None = None +) -> dict[str, Any]: + with connect(port=port) as iface: + url = iface.localNode.getURL(includeAll=include_all) + return {"url": url} + + +def set_channel_url(url: str, port: str | None = None) -> dict[str, Any]: + with connect(port=port) as iface: + # setURL replaces the channel set from the URL's contents. It does not + # return a count; we infer by counting non-DISABLED channels after. + iface.localNode.setURL(url) + channels = iface.localNode.channels or [] + active = sum(1 for c in channels if getattr(c, "role", 0) != 0) + return {"ok": True, "channels_imported": active} + + +# ---------- messaging ------------------------------------------------------ + + +def send_text( + text: str, + to: str | int | None = None, + channel_index: int = 0, + want_ack: bool = False, + port: str | None = None, +) -> dict[str, Any]: + destination = to if to is not None else "^all" + with connect(port=port) as iface: + packet = iface.sendText( + text, + destinationId=destination, + wantAck=want_ack, + channelIndex=channel_index, + ) + packet_id = getattr(packet, "id", None) + return {"ok": True, "packet_id": packet_id, "destination": destination} + + +# ---------- diagnostics ---------------------------------------------------- + + +def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]: + """Toggle `config.security.debug_log_api_enabled` on the local node. + + When enabled, firmware emits log lines as protobuf `LogRecord` messages + over the StreamAPI instead of raw text. meshtastic-python surfaces them + on pubsub topic `meshtastic.log.line`, which flows through the SAME + SerialInterface our tests already hold open — no `pio device monitor` + needed, no port-contention with admin/info calls. + + Firmware gate: `src/SerialConsole.cpp` (`usingProtobufs && + config.security.debug_log_api_enabled`). Setting persists in NVS; it + survives reboot. `factory_reset(full=False)` clears it unless it's + re-applied after reset. + + Previously-documented concurrency hazard (emitLogRecord sharing the + main packet-emission buffers) has been fixed — see `StreamAPI.h` + where the log path now owns dedicated `fromRadioScratchLog` / + `txBufLog` buffers, and `StreamAPI::emitTxBuffer` + + `StreamAPI::emitLogRecord` both serialize their `stream->write` + calls via `streamLock`. Leaving the flag on under traffic is safe. + """ + with connect(port=port) as iface: + sec = iface.localNode.localConfig.security + sec.debug_log_api_enabled = bool(enabled) + iface.localNode.writeConfig("security") + return {"ok": True, "debug_log_api_enabled": bool(enabled)} + + +# ---------- admin actions -------------------------------------------------- + + +def reboot( + port: str | None = None, confirm: bool = False, seconds: int = 10 +) -> dict[str, Any]: + _require_confirm(confirm, "reboot") + with connect(port=port) as iface: + iface.localNode.reboot(secs=seconds) + return {"ok": True, "rebooting_in_s": seconds} + + +def shutdown( + port: str | None = None, confirm: bool = False, seconds: int = 10 +) -> dict[str, Any]: + _require_confirm(confirm, "shutdown") + with connect(port=port) as iface: + iface.localNode.shutdown(secs=seconds) + return {"ok": True, "shutting_down_in_s": seconds} + + +def factory_reset( + port: str | None = None, confirm: bool = False, full: bool = False +) -> dict[str, Any]: + """Tell the node to factory-reset its config. + + Works around a meshtastic-python 2.7.8 bug: `Node.factoryReset(full=True)` + internally does `p.factory_reset_config = True` where the field is + int32. protobuf 5.x rejects bool→int assignment as a TypeError. We build + the AdminMessage directly with int values (1=non-full, 2=full) and call + `_sendAdmin` to sidestep the SDK bug entirely. + """ + _require_confirm(confirm, "factory_reset") + from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped] + + with connect(port=port) as iface: + msg = admin_pb2.AdminMessage() + msg.factory_reset_config = 2 if full else 1 + iface.localNode._sendAdmin(msg) + return {"ok": True, "full": full} diff --git a/mcp-server/src/meshtastic_mcp/boards.py b/mcp-server/src/meshtastic_mcp/boards.py new file mode 100644 index 00000000000..df5024800a6 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/boards.py @@ -0,0 +1,159 @@ +"""Board / PlatformIO env enumeration. + +Parses `pio project config --json-output` — a nested list of +`[section_name, [[key, value], ...]]` pairs — into a dict keyed by env name, +extracting the `custom_meshtastic_*` metadata the firmware variants expose. + +The parsed config is cached and invalidated when `platformio.ini`'s mtime +changes, so subsequent calls don't pay the 1–2s pio startup cost. +""" + +from __future__ import annotations + +import threading +from typing import Any + +from . import config, pio + +_CACHE_LOCK = threading.Lock() +_CACHE: dict[str, Any] = {"mtime": None, "envs": None} + + +def _parse_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "yes", "1", "on") + return bool(value) + + +def _parse_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _parse_tags(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + return [t.strip() for t in str(value).replace(",", " ").split() if t.strip()] + + +def _env_record(env_name: str, items: list[list[Any]]) -> dict[str, Any]: + """Build a normalized dict for one env section.""" + d = dict(items) + return { + "env": env_name, + "architecture": d.get("custom_meshtastic_architecture"), + "hw_model": _parse_int(d.get("custom_meshtastic_hw_model")), + "hw_model_slug": d.get("custom_meshtastic_hw_model_slug"), + "display_name": d.get("custom_meshtastic_display_name"), + "actively_supported": _parse_bool( + d.get("custom_meshtastic_actively_supported") + ), + "support_level": _parse_int(d.get("custom_meshtastic_support_level")), + "board_level": d.get("board_level"), # "pr", "extra", or None + "tags": _parse_tags(d.get("custom_meshtastic_tags")), + "images": _parse_tags(d.get("custom_meshtastic_images")), + "board": d.get("board"), + "upload_speed": _parse_int(d.get("upload_speed")), + "upload_protocol": d.get("upload_protocol"), + "monitor_speed": _parse_int(d.get("monitor_speed")), + "monitor_filters": d.get("monitor_filters") or [], + "_raw": d, # Full dict for get_board + } + + +def _load_all() -> dict[str, dict[str, Any]]: + """Parse `pio project config` into `{env_name: record}`.""" + raw = pio.run_json(["project", "config"], timeout=pio.TIMEOUT_PROJECT_CONFIG) + result: dict[str, dict[str, Any]] = {} + for section_name, items in raw: + if not isinstance(section_name, str) or not section_name.startswith("env:"): + continue + env_name = section_name.split(":", 1)[1] + result[env_name] = _env_record(env_name, items) + return result + + +def _get_cached() -> dict[str, dict[str, Any]]: + root = config.firmware_root() + platformio_ini = root / "platformio.ini" + try: + mtime = platformio_ini.stat().st_mtime + except FileNotFoundError: + mtime = None + + with _CACHE_LOCK: + if _CACHE["envs"] is not None and _CACHE["mtime"] == mtime: + return _CACHE["envs"] + envs = _load_all() + _CACHE["envs"] = envs + _CACHE["mtime"] = mtime + return envs + + +def invalidate_cache() -> None: + with _CACHE_LOCK: + _CACHE["envs"] = None + _CACHE["mtime"] = None + + +def _public_record(rec: dict[str, Any]) -> dict[str, Any]: + """Strip the `_raw` field for list outputs.""" + return {k: v for k, v in rec.items() if not k.startswith("_")} + + +def list_boards( + architecture: str | None = None, + actively_supported_only: bool = False, + query: str | None = None, + board_level: str | None = None, # "release" | "pr" | "extra" +) -> list[dict[str, Any]]: + """Enumerate PlatformIO envs with Meshtastic metadata. + + Filters are cumulative (AND). `board_level="release"` means envs with no + explicit `board_level` set (the default release targets). + """ + envs = _get_cached() + q = query.lower().strip() if query else None + + out = [] + for rec in envs.values(): + if architecture and rec.get("architecture") != architecture: + continue + if actively_supported_only and not rec.get("actively_supported"): + continue + if board_level is not None: + rec_level = rec.get("board_level") + if board_level == "release": + if rec_level not in (None, ""): + continue + elif rec_level != board_level: + continue + if q: + display = (rec.get("display_name") or "").lower() + env_name = rec.get("env", "").lower() + slug = (rec.get("hw_model_slug") or "").lower() + if q not in display and q not in env_name and q not in slug: + continue + out.append(_public_record(rec)) + + out.sort(key=lambda r: (r.get("architecture") or "", r.get("env"))) + return out + + +def get_board(env: str) -> dict[str, Any]: + """Full metadata for one env, including the raw pio config dict.""" + envs = _get_cached() + rec = envs.get(env) + if rec is None: + raise KeyError( + f"Unknown env: {env!r}. Use list_boards() to see available envs." + ) + public = _public_record(rec) + public["raw_config"] = rec["_raw"] + return public diff --git a/mcp-server/src/meshtastic_mcp/cli/__init__.py b/mcp-server/src/meshtastic_mcp/cli/__init__.py new file mode 100644 index 00000000000..04729b643e1 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/__init__.py @@ -0,0 +1,6 @@ +"""Command-line entry points that sit alongside the MCP server. + +Modules here are loaded on-demand by `[project.scripts]` entries in +`pyproject.toml`. They are NOT imported by `meshtastic_mcp.server` or the +admin/info tool surface — the MCP server stays pure stdio JSON-RPC. +""" diff --git a/mcp-server/src/meshtastic_mcp/cli/_flashlog.py b/mcp-server/src/meshtastic_mcp/cli/_flashlog.py new file mode 100644 index 00000000000..889183bb30e --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/_flashlog.py @@ -0,0 +1,73 @@ +"""Flash progress log tailer for ``meshtastic-mcp-test-tui``. + +``pio.py`` / ``hw_tools.py`` tee subprocess output (``pio run -t upload``, +``esptool erase_flash``, ``nrfutil dfu``, etc.) to ``tests/flash.log`` +line-by-line as it arrives — controlled by the ``MESHTASTIC_MCP_FLASH_LOG`` +env var that ``run-tests.sh`` sets. The TUI tails that file so the operator +sees live flash progress in the pytest pane instead of 3 minutes of silence +during ``test_00_bake``. + +Separate from ``_fwlog.py`` because that one parses JSONL, this one +streams plain text lines. Same daemon-thread + EOF-backoff structure. +""" + +from __future__ import annotations + +import pathlib +import threading +import time +from typing import Callable + + +class FlashLogTailer(threading.Thread): + """Tail a plain-text log file, publish each stripped line via ``post``. + + ``post`` is invoked with a single ``str`` for every new line. Lines are + stripped of trailing newlines; empty lines after stripping are dropped. + + The file may not exist yet when this thread starts — it's truncated by + ``run-tests.sh`` at session start, but if the tailer races the shell, + we tolerate FileNotFoundError for up to ``wait_s`` seconds. + """ + + def __init__( + self, + path: pathlib.Path, + post: Callable[[str], None], + stop: threading.Event, + *, + wait_s: float = 30.0, + ) -> None: + super().__init__(daemon=True, name="flashlog-tail") + self._path = path + self._post = post + self._stop = stop + self._wait_s = wait_s + + def run(self) -> None: + deadline = time.monotonic() + self._wait_s + while not self._path.is_file(): + if self._stop.is_set() or time.monotonic() > deadline: + return + time.sleep(0.1) + try: + fh = self._path.open("r", encoding="utf-8", errors="replace") + except OSError: + return + try: + while not self._stop.is_set(): + line = fh.readline() + if not line: + time.sleep(0.05) + continue + line = line.rstrip("\r\n") + if not line: + continue + try: + self._post(line) + except Exception: + # A post failure (e.g. closed app) is terminal for this + # thread but we still want to close the file handle. + return + finally: + fh.close() diff --git a/mcp-server/src/meshtastic_mcp/cli/_fwlog.py b/mcp-server/src/meshtastic_mcp/cli/_fwlog.py new file mode 100644 index 00000000000..7db20f81cc8 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/_fwlog.py @@ -0,0 +1,96 @@ +"""Firmware log tail worker for ``meshtastic-mcp-test-tui``. + +Complements v1's reportlog-tail worker. ``tests/conftest.py`` owns a +session-scoped autouse fixture (``_firmware_log_stream``) that mirrors +every ``meshtastic.log.line`` pubsub event to ``tests/fwlog.jsonl`` — +one JSON object per line: + + {"ts": 1729100000.123, "port": "/dev/cu.usbmodem1101", "line": "..."} + +The TUI tails that file from a worker thread; each new line becomes a +:class:`FirmwareLogLine` message posted to the App. Same pattern as the +reportlog tail worker — truncate on launch, tolerate missing file for +30 s, back off at EOF. + +Kept in its own module so the (large) ``test_tui.py`` stays focused on +the Textual App shell. +""" + +from __future__ import annotations + +import json +import pathlib +import threading +import time +from typing import Any, Callable + + +class FirmwareLogTailer(threading.Thread): + """Tail ``tests/fwlog.jsonl``, publish parsed records via ``post``. + + ``post`` is the App's ``post_message`` (or any callable that accepts a + single payload arg). We pass parsed dicts rather than constructing + Textual Message objects here — keeps this module free of the + textual dependency so it's unit-testable in a bare venv. + + Parameters + ---------- + path: + Path to ``tests/fwlog.jsonl``. The file may not exist yet at + startup — pytest only creates it once the session fixture runs. + post: + Callable invoked with a dict ``{"ts", "port", "line"}`` for every + new line parsed from the file. + stop: + An event the App sets to signal shutdown. + wait_s: + How long to poll for the file's creation before giving up. Default + 30 s; pytest collection on a cold cache can be slow. + + """ + + def __init__( + self, + path: pathlib.Path, + post: Callable[[dict[str, Any]], None], + stop: threading.Event, + *, + wait_s: float = 30.0, + ) -> None: + super().__init__(daemon=True, name="fwlog-tail") + self._path = path + self._post = post + self._stop = stop + self._wait_s = wait_s + + def run(self) -> None: + deadline = time.monotonic() + self._wait_s + while not self._path.is_file(): + if self._stop.is_set() or time.monotonic() > deadline: + return + time.sleep(0.1) + try: + fh = self._path.open("r", encoding="utf-8") + except OSError: + return + try: + while not self._stop.is_set(): + line = fh.readline() + if not line: + time.sleep(0.05) + continue + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + # Defensive: require the three fields we rely on. + if not isinstance(record, dict): + continue + if "line" not in record: + continue + self._post(record) + finally: + fh.close() diff --git a/mcp-server/src/meshtastic_mcp/cli/_history.py b/mcp-server/src/meshtastic_mcp/cli/_history.py new file mode 100644 index 00000000000..639dcec5f55 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/_history.py @@ -0,0 +1,127 @@ +"""Cross-run history for ``meshtastic-mcp-test-tui``. + +Persists one JSON object per pytest run to +``mcp-server/tests/.history/runs.jsonl``. The TUI reads the last N +entries on launch to render a duration sparkline in the header — a +quick read on whether the suite is slowing down over time. + +Schema (keep small; the file can grow for months): + + {"run": 42, "ts": 1729100000.0, "duration_s": 387.2, + "passed": 52, "failed": 0, "skipped": 23, "exit_code": 0, + "seed": "mcp-user-host"} +""" + +from __future__ import annotations + +import json +import pathlib +import time +from dataclasses import asdict, dataclass +from typing import Iterable + +# Sparkline glyphs, low → high. 8 levels is the Unicode convention. +_SPARK_BLOCKS = "▁▂▃▄▅▆▇█" + + +@dataclass +class RunRecord: + run: int + ts: float + duration_s: float + passed: int + failed: int + skipped: int + exit_code: int + seed: str + + +class HistoryStore: + """Append-only JSONL store with bounded read. + + Writes are fsynced after each append (the file is tiny; fsync cost + is negligible and protects against truncation on a crash). + """ + + def __init__(self, path: pathlib.Path, *, keep_last: int = 50) -> None: + self._path = path + self._keep_last = keep_last + + def append(self, record: RunRecord) -> None: + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(asdict(record)) + "\n") + fh.flush() + except Exception: + # Non-fatal: history is cosmetic. + pass + + def read_recent(self) -> list[RunRecord]: + """Return the last ``keep_last`` records in chronological order.""" + if not self._path.is_file(): + return [] + try: + lines = self._path.read_text(encoding="utf-8").splitlines() + except OSError: + return [] + out: list[RunRecord] = [] + # Parse tail-first so we don't waste work on a huge history. + for line in lines[-self._keep_last :]: + line = line.strip() + if not line: + continue + try: + raw = json.loads(line) + except json.JSONDecodeError: + continue + try: + out.append(RunRecord(**raw)) + except TypeError: + # Schema drift; skip the record rather than crash. + continue + return out + + def record_run( + self, + *, + run: int, + duration_s: float, + passed: int, + failed: int, + skipped: int, + exit_code: int, + seed: str, + ) -> RunRecord: + rec = RunRecord( + run=run, + ts=time.time(), + duration_s=float(duration_s), + passed=int(passed), + failed=int(failed), + skipped=int(skipped), + exit_code=int(exit_code), + seed=seed, + ) + self.append(rec) + return rec + + +def sparkline(values: Iterable[float], *, width: int = 20) -> str: + """Render a Unicode block-character sparkline from the last ``width`` values. + + Returns an empty string for empty input so the header handles + "no history yet" gracefully. + """ + buf = [v for v in values if v >= 0][-width:] + if not buf: + return "" + lo, hi = min(buf), max(buf) + if hi - lo < 1e-9: + return _SPARK_BLOCKS[len(_SPARK_BLOCKS) // 2] * len(buf) + n = len(_SPARK_BLOCKS) - 1 + out = [] + for v in buf: + idx = int(round((v - lo) / (hi - lo) * n)) + out.append(_SPARK_BLOCKS[max(0, min(n, idx))]) + return "".join(out) diff --git a/mcp-server/src/meshtastic_mcp/cli/_reproducer.py b/mcp-server/src/meshtastic_mcp/cli/_reproducer.py new file mode 100644 index 00000000000..420da3c76a7 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/_reproducer.py @@ -0,0 +1,214 @@ +"""Reproducer bundle builder for ``meshtastic-mcp-test-tui``. + +When the operator presses ``x`` on a failed test leaf, we package the +minimum viable failure context into a tarball under +``mcp-server/tests/reproducers/``: + +:: + + repro--.tar.gz + ├── README.md human-readable overview + ├── test_report.json the failing TestReport event from reportlog + ├── fwlog.jsonl firmware log filtered to the failure window + ├── devices.json per-device device_info + lora config snapshot + └── env.json seed, run #, pytest version, platform, hostname + +Separate module so the logic can be unit-tested without Textual. The +TUI glue is thin — one key binding calls :func:`build_reproducer_bundle` +with the focused test's state and shows the path in a modal. +""" + +from __future__ import annotations + +import io +import json +import pathlib +import platform +import re +import socket +import tarfile +import time +from dataclasses import dataclass +from typing import Any, Iterable + + +@dataclass +class ReproContext: + """Everything :func:`build_reproducer_bundle` needs. Shaped to map + cleanly onto the state the TUI already tracks — no extra data + collection required at export time.""" + + nodeid: str + longrepr: str + sections: list[tuple[str, str]] + start_ts: float | None + stop_ts: float | None + seed: str + run_number: int + exit_code: int | None + fwlog_path: pathlib.Path + output_dir: pathlib.Path + extra_device_rows: list[dict[str, Any]] # [{role, port, info, ...}, ...] + + +def _short_nodeid(nodeid: str) -> str: + """Collapse a pytest nodeid into a filename-safe slug (<= 60 chars).""" + # Drop the file path prefix; keep test name + parametrization. + tail = nodeid.split("::", 1)[-1] if "::" in nodeid else nodeid + slug = re.sub(r"[^A-Za-z0-9_.\-]", "_", tail) + return slug[:60].strip("_.-") or "test" + + +def _filtered_fwlog( + fwlog_path: pathlib.Path, + start_ts: float | None, + stop_ts: float | None, + *, + pad_s: float = 5.0, +) -> bytes: + """Return fwlog.jsonl lines whose ``ts`` lies in [start-pad, stop+pad].""" + if not fwlog_path.is_file(): + return b"" + if start_ts is None or stop_ts is None: + # Without a time window, include the whole file — rare; happens + # when a test fails in setup before pytest emitted a start ts. + try: + return fwlog_path.read_bytes() + except OSError: + return b"" + lo, hi = start_ts - pad_s, stop_ts + pad_s + out = io.BytesIO() + try: + with fwlog_path.open("r", encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if not stripped: + continue + try: + record = json.loads(stripped) + except json.JSONDecodeError: + continue + ts = record.get("ts") + if not isinstance(ts, (int, float)): + continue + if lo <= ts <= hi: + out.write(line.encode("utf-8")) + except OSError: + return b"" + return out.getvalue() + + +def _readme(ctx: ReproContext) -> str: + t = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime()) + return f"""# Reproducer bundle + +Exported by `meshtastic-mcp-test-tui` on {t}. + +## Failing test + +- **nodeid:** `{ctx.nodeid}` +- **seed:** `{ctx.seed}` +- **run #:** {ctx.run_number} +- **suite exit code (at export time):** {ctx.exit_code if ctx.exit_code is not None else "in progress"} + +## Files in this archive + +| File | Contents | +|---|---| +| `test_report.json` | The pytest-reportlog `TestReport` event for the failing test — includes `longrepr`, captured `sections` (stdout/stderr/log), `duration`, `location`, `keywords`. | +| `fwlog.jsonl` | Firmware log lines (from `meshtastic.log.line` pubsub) filtered to [start−5s, stop+5s] around the test's run window. Each line is `{{ts, port, line}}`. | +| `devices.json` | Per-device snapshot at export time: `device_info` + `lora` config per detected role. | +| `env.json` | Python version, platform, hostname, seed, run number. | + +## How to triage + +1. Open `test_report.json` and read `longrepr` + `sections` — most failures explain themselves there. +2. If the failure is a mesh/telemetry assertion, `fwlog.jsonl` is where the answer usually lives. Grep for `Error=`, `NAK`, `PKI_UNKNOWN_PUBKEY`, `Skip send`, `Guru Meditation`, or the uptime timestamps around the assertion event. +3. Compare `devices.json` against the expected state (e.g. `num_nodes >= 2`, `primary_channel == "McpTest"`, `region == "US"`). If fields disagree with the seed-derived USERPREFS profile, the device probably wasn't baked with this session's profile. + +## Reproducing locally + +```bash +cd mcp-server +MESHTASTIC_MCP_SEED='{ctx.seed}' .venv/bin/pytest '{ctx.nodeid}' --tb=long -v +``` +""" + + +def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path: + """Build a tarball under ``ctx.output_dir`` and return its path. + + Parent dirs are created as needed. Errors during optional sections + (devices, env) are swallowed — the bundle is still useful without + them; refusing to export because the device poller had a hiccup + would be worse than the export missing a file. + """ + ctx.output_dir.mkdir(parents=True, exist_ok=True) + ts = int(time.time()) + slug = _short_nodeid(ctx.nodeid) + archive_path = ctx.output_dir / f"repro-{ts}-{slug}.tar.gz" + + with tarfile.open(archive_path, "w:gz") as tar: + + def _add(name: str, data: bytes) -> None: + info = tarfile.TarInfo(name=name) + info.size = len(data) + info.mtime = ts + tar.addfile(info, io.BytesIO(data)) + + # README + _add("README.md", _readme(ctx).encode("utf-8")) + + # test_report.json — reconstruct from the fields the TUI stashes. + test_report = { + "nodeid": ctx.nodeid, + "outcome": "failed", + "longrepr": ctx.longrepr, + "sections": [list(s) for s in ctx.sections], + "start": ctx.start_ts, + "stop": ctx.stop_ts, + } + _add( + "test_report.json", + json.dumps(test_report, indent=2, default=str).encode("utf-8"), + ) + + # fwlog.jsonl (filtered) + _add("fwlog.jsonl", _filtered_fwlog(ctx.fwlog_path, ctx.start_ts, ctx.stop_ts)) + + # devices.json + try: + devices_payload = json.dumps( + ctx.extra_device_rows or [], indent=2, default=str + ) + except Exception: + devices_payload = "[]" + _add("devices.json", devices_payload.encode("utf-8")) + + # env.json + try: + from importlib.metadata import version as _pkg_version + + pytest_version = _pkg_version("pytest") + except Exception: + pytest_version = "unknown" + env_payload = { + "seed": ctx.seed, + "run": ctx.run_number, + "exit_code": ctx.exit_code, + "export_ts": ts, + "python": platform.python_version(), + "pytest": pytest_version, + "platform": f"{platform.system()} {platform.release()} {platform.machine()}", + "hostname": socket.gethostname(), + } + _add("env.json", json.dumps(env_payload, indent=2).encode("utf-8")) + + return archive_path + + +def iter_entries(archive_path: pathlib.Path) -> Iterable[str]: + """Yield member names — used by callers that want to confirm the bundle shape.""" + with tarfile.open(archive_path, "r:gz") as tar: + for m in tar.getmembers(): + yield m.name diff --git a/mcp-server/src/meshtastic_mcp/cli/test_tui.py b/mcp-server/src/meshtastic_mcp/cli/test_tui.py new file mode 100644 index 00000000000..33201101b1a --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/test_tui.py @@ -0,0 +1,1782 @@ +"""Textual TUI wrapping `mcp-server/run-tests.sh`. + +Launch: ``meshtastic-mcp-test-tui [pytest-args]`` + +The TUI *wraps* ``run-tests.sh``; it never replaces it. Same script, same +env-var resolution, same ``userPrefs.jsonc`` session fixture. Four data +sources drive live state: + +1. ``tests/reportlog.jsonl`` — written by ``pytest-reportlog``. Tailed in a + worker thread; each JSON line is published as a :class:`ReportLogEvent` + message. This is the authoritative source for tree population + per-test + outcome. +2. The pytest subprocess ``stdout`` + ``stderr`` streams — line-by-line, + published as :class:`PytestLine` messages and rendered verbatim in the + pytest pane. +3. ``tests/fwlog.jsonl`` — firmware log stream. Written by the + ``_firmware_log_stream`` autouse session fixture in ``conftest.py`` + (mirrors every ``meshtastic.log.line`` pubsub event), tailed by the + :class:`FirmwareLogTailer` worker, displayed in a wrap-enabled + RichLog with cycleable port filter. +4. ``devices.list_devices()`` + ``info.device_info(port)`` — polled only at + startup and again after ``RunFinished``. Device polling while pytest + holds a SerialInterface would deadlock on the exclusive port lock; the + existing ``hub_devices`` fixture is session-scoped so there is no safe + "between tests" window. The header reflects this with a "(stale)" + marker while the run is active. + +Key bindings (see :class:`TestTuiApp.BINDINGS`): + ``r`` re-run focused ``f`` filter tree ``d`` failure detail + ``g`` open report.html ``l`` cycle firmware-log port filter + ``x`` export reproducer bundle ``c`` tool-coverage panel + ``q`` / Ctrl-C graceful quit with SIGINT → SIGTERM → SIGKILL escalation + +Shipped today (v1 + v2 slice): test tree + tier counters with progress bars, +pytest tail, live firmware log with port filter, device strip with +"currently running" status column, failure-detail modal, reproducer bundle +export (filters fwlog by test's start/stop timestamps), tool-coverage +modal, cross-run history sparkline in the header, clean SIGINT +propagation. Still open (see the plan file): mesh topology mini-diagram +and airtime / channel-utilization gauges. +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import signal +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Iterator + +# --------------------------------------------------------------------------- +# Configuration constants +# --------------------------------------------------------------------------- + +# Tier names that map nodeids like "tests//..." to counter buckets. +# Order here == display order in the tier-counters table. Matches the order +# `pytest_collection_modifyitems` in `conftest.py` uses: +# bake → unit → mesh → telemetry → monitor → fleet → admin → provisioning +# so the counters table reads top-to-bottom in execution order. +# +# "bake" is the synthetic tier for `tests/test_00_bake.py` — the file sits +# at the `tests/` root rather than under a tier subdirectory, so without +# this mapping `_tier_of_nodeid` would return "other" and the bake outcomes +# would be silently dropped from both the tier table and the history +# record (which sums tier counters to compute passed/failed/skipped). +TIERS = ( + "bake", + "unit", + "mesh", + "telemetry", + "monitor", + "fleet", + "admin", + "provisioning", +) + +# Relative paths from the mcp-server root. +_REPORTLOG_RELATIVE = "tests/reportlog.jsonl" +_FWLOG_RELATIVE = "tests/fwlog.jsonl" +# pio / esptool / nrfutil / picotool tee subprocess output here when +# `MESHTASTIC_MCP_FLASH_LOG` is set (see `pio._run_capturing`). run-tests.sh +# sets that env var; the TUI also sets it for direct `_spawn_pytest` calls +# so `r`-key re-runs that skip the wrapper still get tee'd output. +_FLASHLOG_RELATIVE = "tests/flash.log" +_REPORT_HTML_RELATIVE = "tests/report.html" +_TOOL_COVERAGE_RELATIVE = "tests/tool_coverage.json" +_HISTORY_RELATIVE = "tests/.history/runs.jsonl" +_REPRODUCERS_RELATIVE = "tests/reproducers" +_RUN_TESTS_RELATIVE = "run-tests.sh" +_RUN_COUNTER_RELATIVE = "tests/.tui-runs" + +# Graceful-shutdown budgets (seconds) for the pytest subprocess when the +# user hits `q`. Matches what the existing CLI's atexit + userprefs sidecar +# self-heal expects. +_SIGINT_GRACE_S = 5.0 +_SIGTERM_GRACE_S = 5.0 + + +# --------------------------------------------------------------------------- +# Path resolution +# --------------------------------------------------------------------------- + + +def _mcp_server_root() -> pathlib.Path: + """Locate the mcp-server directory (the one containing run-tests.sh).""" + here = pathlib.Path(__file__).resolve() + # Walk up until we find pyproject.toml with a matching project name, or + # default to the three-up ancestor (src/meshtastic_mcp/cli/test_tui.py → + # .../mcp-server). The walk-up protects against unusual checkouts. + for parent in (here.parent, *here.parents): + if (parent / "pyproject.toml").is_file() and ( + parent / "run-tests.sh" + ).is_file(): + return parent + return here.parents[3] + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class LeafReport: + """Per-test state drawn from reportlog events. + + Outcomes mirror pytest's: "passed" | "failed" | "skipped" | "running". + """ + + nodeid: str + tier: str + outcome: str = "pending" + duration_s: float = 0.0 + longrepr: str = "" + # Captured stdout / stderr / firmware-log sections from the test's + # `TestReport.sections` — shown in the failure-detail modal. + sections: list[tuple[str, str]] = field(default_factory=list) + # Wall-clock start/stop from the TestReport event. Used by the + # reproducer exporter (`x`) to filter `tests/fwlog.jsonl` down to + # just the lines around the failure window. + start_ts: float | None = None + stop_ts: float | None = None + + +@dataclass +class TierCounters: + tier: str + passed: int = 0 + failed: int = 0 + skipped: int = 0 + running: int = 0 + remaining: int = 0 + + +@dataclass +class DeviceRow: + role: str | None + port: str + vid: str + pid: str + description: str + # Populated from info.device_info when available; empty dict when we + # haven't queried (or when the poller is paused). + info: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class State: + """Shared state owned by the App; written by workers under `lock`. + + UI code reads via Textual Message handlers which run on the UI thread + in the order workers called `post_message` — so reads don't need the + lock themselves. + """ + + lock: threading.Lock = field(default_factory=threading.Lock) + tiers: dict[str, TierCounters] = field( + default_factory=lambda: {t: TierCounters(tier=t) for t in TIERS} + ) + leaves: dict[str, LeafReport] = field(default_factory=dict) + # Ordered list of nodeids in the order they were first seen — lets us + # rebuild the tree deterministically. + nodeid_order: list[str] = field(default_factory=list) + devices: list[DeviceRow] = field(default_factory=list) + run_active: bool = False + exit_code: int | None = None + # nodeid of the currently-running test. Set on `when="setup"` + + # outcome="passed" (body about to execute); cleared on `when="call"` + # (any outcome) or on `when="setup"` + outcome="failed" (no body + # window). Drives the device-table "Status" column so the operator + # can see which test is touching a given device right now. + running_nodeid: str | None = None + # `time.monotonic()` captured when `running_nodeid` was set. Surfaced + # as live-updating elapsed-time ("RUNNING: test_bake_nrf52 (1:23)") so + # an operator staring at a ~3 min `test_00_bake` or a `mesh_formation` + # with a 60 s ceiling has concrete evidence the test isn't stuck. + running_started_at: float | None = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _tier_of_nodeid(nodeid: str) -> str: + """Map a pytest nodeid to its tier bucket. Unknown → 'other'. + + `tests/test_00_bake.py::...` is special-cased to the synthetic `bake` + tier — it's a top-level file (no tier subdirectory) so the generic + "second path segment" logic would miss it and route the bake outcomes + into the non-existent `other` bucket. + """ + parts = nodeid.split("/", 2) + if len(parts) >= 2 and parts[0] == "tests": + # Bake file sits at `tests/test_00_bake.py` — dedicated bucket. + if parts[1].startswith("test_00_bake"): + return "bake" + candidate = parts[1] + if candidate in TIERS: + return candidate + return "other" + + +def _file_of_nodeid(nodeid: str) -> str: + """Extract the test file name (e.g. 'test_boards.py') from a nodeid.""" + left = nodeid.split("::", 1)[0] + return left.rsplit("/", 1)[-1] + + +def _testname_of_nodeid(nodeid: str) -> str: + """Extract the 'test_foo[param]' suffix from a nodeid, or the full thing.""" + if "::" in nodeid: + return nodeid.split("::", 1)[1] + return nodeid + + +def _roles_from_nodeid(nodeid: str) -> set[str]: + """Infer which device roles a parametrized test touches. + + Patterns we recognize (from the existing ``conftest.py`` parametrization + in ``pytest_generate_tests``): + + - ``test_foo[nrf52]`` → {"nrf52"} (baked_single) + - ``test_foo[nrf52->esp32s3]`` → {"nrf52", "esp32s3"} (mesh_pair) + + Unparametrized tests (no bracket) return an empty set — the caller + should fall back to "this test involves ALL detected devices" rather + than pretending it touches none. + """ + if "[" not in nodeid or not nodeid.endswith("]"): + return set() + try: + inner = nodeid.rsplit("[", 1)[1][:-1] + except Exception: + return set() + # Split on "->" for directed mesh pairs; otherwise treat as single role. + parts = [p.strip() for p in inner.split("->")] if "->" in inner else [inner.strip()] + return {p for p in parts if p} + + +def _parse_events(path: pathlib.Path) -> Iterator[dict[str, Any]]: + """Yield parsed JSON dicts from a reportlog file, skipping malformed lines. + + Used for smoke-testing the parser against a finished file; the live + worker has its own tail loop. + """ + if not path.is_file(): + return + with path.open("r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + + +def _load_run_number(counter_path: pathlib.Path) -> int: + """Bump + persist a monotonic run counter used in the TUI header.""" + try: + n = int(counter_path.read_text().strip()) + except Exception: + n = 0 + n += 1 + try: + counter_path.parent.mkdir(parents=True, exist_ok=True) + counter_path.write_text(str(n)) + except Exception: + # Non-fatal: the counter is cosmetic. + pass + return n + + +def _resolve_seed() -> str: + """Mirror the default-seed resolution from run-tests.sh. + + Operator can override via MESHTASTIC_MCP_SEED. Matches the + per-user/per-host default so repeated invocations land on the same PSK + (makes --assume-baked valid across invocations). + """ + if explicit := os.environ.get("MESHTASTIC_MCP_SEED"): + return explicit + try: + who = os.environ.get("USER") or os.environ.get("LOGNAME") or "anon" + except Exception: + who = "anon" + try: + import socket + + host = socket.gethostname().split(".", 1)[0] + except Exception: + host = "host" + return f"mcp-{who}-{host}" + + +def _format_duration(seconds: float) -> str: + if seconds < 60: + return f"{seconds:5.1f}s" + m, s = divmod(int(seconds), 60) + return f"{m:d}:{s:02d}" + + +# --------------------------------------------------------------------------- +# Textual imports (lazy — only when main() runs, so `_parse_events` can be +# imported by smoke tests without requiring textual installed in every env) +# --------------------------------------------------------------------------- + + +def _import_textual() -> Any: + """Return a namespace carrying every Textual class we use. + + Deferred import keeps `_parse_events` + `_tier_of_nodeid` importable + from tests / smoke scripts without pulling in the UI stack. + """ + import textual + from textual.app import App, ComposeResult + from textual.binding import Binding + from textual.containers import Horizontal, Vertical + from textual.message import Message + from textual.screen import ModalScreen + from textual.widgets import DataTable, Footer, Input, RichLog, Static, Tree + + ns = argparse.Namespace() + ns.App = App + ns.Binding = Binding + ns.ComposeResult = ComposeResult + ns.DataTable = DataTable + ns.Footer = Footer + ns.Horizontal = Horizontal + ns.Input = Input + ns.Message = Message + ns.ModalScreen = ModalScreen + ns.RichLog = RichLog + ns.Static = Static + ns.Tree = Tree + ns.Vertical = Vertical + ns.textual = textual + return ns + + +# --------------------------------------------------------------------------- +# main() — the important scaffolding lives here so that when we bail out +# before entering the Textual event loop (missing terminal, --help, etc.) +# nothing has grabbed the screen yet. +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> int: + """Entry point for `meshtastic-mcp-test-tui`.""" + argv = list(argv if argv is not None else sys.argv[1:]) + + parser = argparse.ArgumentParser( + prog="meshtastic-mcp-test-tui", + description=( + "Live Textual TUI wrapping mcp-server/run-tests.sh. " + "Passes any unrecognized arguments through to pytest." + ), + allow_abbrev=False, + ) + parser.add_argument( + "--no-tui", + action="store_true", + help=( + "Skip the TUI and exec run-tests.sh directly. Useful as a health " + "check that the wrapper argv+env resolution is working." + ), + ) + args, pytest_args = parser.parse_known_args(argv) + + root = _mcp_server_root() + run_tests = root / _RUN_TESTS_RELATIVE + reportlog = root / _REPORTLOG_RELATIVE + fwlog = root / _FWLOG_RELATIVE + flashlog = root / _FLASHLOG_RELATIVE + counter = root / _RUN_COUNTER_RELATIVE + + if not run_tests.is_file(): + print( + f"error: could not locate {_RUN_TESTS_RELATIVE} relative to " + f"{root}. Is this the mcp-server checkout?", + file=sys.stderr, + ) + return 2 + + # Always clear stale log files before launching pytest. The TUI's tail + # workers race pytest file-creation; starting from a known-empty state + # avoids mid-line-decode confusion from the prior run. The fwlog session + # fixture also truncates on its end, and run-tests.sh truncates the + # flashlog — triple-truncate is deliberate (whichever side creates the + # file first, it starts empty). + for p in (reportlog, fwlog, flashlog): + try: + p.unlink(missing_ok=True) + except Exception: + pass + + # Compute + persist the run counter for the header (cosmetic). + run_number = _load_run_number(counter) + seed = _resolve_seed() + # Export the seed so the subprocess inherits the SAME value the TUI + # displays. run-tests.sh computes its own fallback if unset, and we'd + # end up with a header / wrapper-header mismatch if we let that happen. + os.environ.setdefault("MESHTASTIC_MCP_SEED", seed) + # Turn on subprocess-output tee'ing so `pio._run_capturing` writes each + # line of pio / esptool / nrfutil / picotool output to `tests/flash.log` + # as it arrives. The TUI tails that file and routes each line to the + # pytest pane so the operator sees live flash progress during long + # `pio run -t upload` / `esptool erase_flash` operations. run-tests.sh + # also sets this when invoked directly — `setdefault` so the wrapper's + # value wins when present. + os.environ.setdefault("MESHTASTIC_MCP_FLASH_LOG", str(flashlog)) + + # --no-tui: exec run-tests.sh directly. Useful for diagnosing wrapper + # env / argv handling without getting into Textual's alternate screen. + if args.no_tui: + cmd = [str(run_tests), *pytest_args] + os.execv(str(run_tests), cmd) # noqa: S606 — intentional + + # Textual UI import is deferred so `--help` and `--no-tui` do not pay + # the ~40 MB startup cost. + try: + tx = _import_textual() + except ImportError as exc: + print( + f"error: textual is not installed ({exc}). Install with: " + f"pip install -e '.[test]'", + file=sys.stderr, + ) + return 2 + + # Narrow-terminal warning (see plan §8 risk 2). Textual itself degrades, + # but a heads-up helps a first-time user. + term = os.environ.get("TERM", "") + if term in ("", "dumb", "screen") and not os.environ.get("TEXTUAL_NO_TERM_HINT"): + print( + f"[hint] TERM={term!r} may render poorly. Try " + f"`TERM=xterm-256color meshtastic-mcp-test-tui ...` if the layout " + f"looks broken.", + file=sys.stderr, + ) + + app = _build_app( + tx=tx, + root=root, + run_tests=run_tests, + reportlog=reportlog, + fwlog=fwlog, + flashlog=flashlog, + seed=seed, + run_number=run_number, + pytest_args=pytest_args, + ) + + # App.run() returns the subprocess exit code via `app.exit(returncode)`. + return_value = app.run() + if isinstance(return_value, int): + return return_value + return 0 + + +# --------------------------------------------------------------------------- +# Everything below is only reachable once Textual is importable. `tx` is +# the namespace returned by `_import_textual()` so we don't scatter `from +# textual import ...` across the file. +# --------------------------------------------------------------------------- + + +def _build_app( + *, + tx: Any, + root: pathlib.Path, + run_tests: pathlib.Path, + reportlog: pathlib.Path, + fwlog: pathlib.Path, + flashlog: pathlib.Path, + seed: str, + run_number: int, + pytest_args: list[str], +) -> Any: + """Assemble TestTuiApp with its Textual-dependent inner classes. + + Keeping the class definitions inside a factory means `main()` can + short-circuit (--no-tui, terminal-check, argparse error) before we + force Textual's import cost. + """ + + # Helper modules — lazy-imported here so the top-of-file import cost + # only kicks in when main() has decided to run the TUI. + from . import _flashlog as _flashlog_mod + from . import _fwlog as _fwlog_mod + from . import _history as _history_mod + from . import _reproducer as _reproducer_mod + + # ---------------- Messages ---------------- + + class ReportLogEvent(tx.Message): + def __init__(self, event: dict[str, Any]) -> None: + self.event = event + super().__init__() + + class PytestLine(tx.Message): + def __init__(self, source: str, line: str) -> None: + self.source = source # "stdout" | "stderr" + self.line = line + super().__init__() + + class FirmwareLogLine(tx.Message): + def __init__(self, record: dict[str, Any]) -> None: + # {"ts": float, "port": str | None, "line": str} + self.record = record + super().__init__() + + class FlashLogLine(tx.Message): + """Plain-text line from `tests/flash.log` — pio / esptool / nrfutil / + picotool output tee'd by `pio._run_capturing`. Routed to the pytest + pane so the operator sees live flash progress during `test_00_bake` + instead of 3 minutes of pytest-captured silence.""" + + def __init__(self, line: str) -> None: + self.line = line + super().__init__() + + class DeviceSnapshot(tx.Message): + def __init__(self, rows: list[DeviceRow]) -> None: + self.rows = rows + super().__init__() + + class RunFinished(tx.Message): + def __init__(self, returncode: int) -> None: + self.returncode = returncode + super().__init__() + + # ---------------- Workers ---------------- + + class ReportlogWorker(threading.Thread): + """Tail `reportlog.jsonl`, publish each event.""" + + def __init__(self, app: Any, path: pathlib.Path, stop: threading.Event) -> None: + super().__init__(daemon=True, name="reportlog-tail") + self._app = app + self._path = path + self._stop = stop + + def run(self) -> None: + # Wait up to 30 s for pytest to create the file (first call on + # a cold cache can be slow). + wait_deadline = time.monotonic() + 30.0 + while not self._path.is_file(): + if self._stop.is_set() or time.monotonic() > wait_deadline: + return + time.sleep(0.1) + try: + fh = self._path.open("r", encoding="utf-8") + except OSError: + return + try: + while not self._stop.is_set(): + line = fh.readline() + if not line: + time.sleep(0.05) + continue + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + self._app.post_message(ReportLogEvent(event)) + finally: + fh.close() + + class SubprocessReaderWorker(threading.Thread): + """Read one stream line-by-line and publish PytestLine messages.""" + + def __init__( + self, + app: Any, + stream: Any, + source: str, + stop: threading.Event, + ) -> None: + super().__init__(daemon=True, name=f"subprocess-{source}") + self._app = app + self._stream = stream + self._source = source + self._stop = stop + + def run(self) -> None: + try: + for line in iter(self._stream.readline, ""): + if self._stop.is_set(): + break + self._app.post_message( + PytestLine(source=self._source, line=line.rstrip("\n")) + ) + except Exception: + # stream closed / subprocess died; not fatal. + pass + + class DevicePollerWorker(threading.Thread): + """Poll list_devices() + device_info() at startup and after RunFinished. + + Deliberately NOT polling during the run — `hub_devices` is a + session-scoped fixture holding SerialInterfaces across the whole + session, and device_info() would deadlock on the exclusive port + lock. Header shows "(stale)" during the gap. + """ + + def __init__(self, app: Any, state: State, stop: threading.Event) -> None: + super().__init__(daemon=True, name="device-poller") + self._app = app + self._state = state + self._stop = stop + self._trigger = threading.Event() + + def trigger(self) -> None: + self._trigger.set() + + def run(self) -> None: + # Perform one poll at startup; then wait for explicit triggers. + self._poll_once() + while not self._stop.is_set(): + if self._trigger.wait(timeout=0.5): + self._trigger.clear() + if self._stop.is_set(): + break + with self._state.lock: + active = self._state.run_active + if active: + continue + self._poll_once() + + def _poll_once(self) -> None: + try: + from meshtastic_mcp import devices as devices_mod + from meshtastic_mcp import info as info_mod + except Exception as exc: # pragma: no cover + self._app.post_message( + PytestLine( + source="stderr", line=f"[tui] device import failed: {exc!r}" + ) + ) + return + rows: list[DeviceRow] = [] + try: + raw = devices_mod.list_devices(include_unknown=True) + except Exception as exc: + self._app.post_message( + PytestLine( + source="stderr", line=f"[tui] list_devices failed: {exc!r}" + ) + ) + return + for d in raw: + vid_raw = d.get("vid") or "" + try: + vid_i = ( + int(vid_raw, 16) + if isinstance(vid_raw, str) and vid_raw.startswith("0x") + else int(vid_raw) + ) + except (TypeError, ValueError): + vid_i = 0 + role = None + if vid_i == 0x239A: + role = "nrf52" + elif vid_i in (0x303A, 0x10C4): + role = "esp32s3" + if not role and not d.get("likely_meshtastic"): + continue + row = DeviceRow( + role=role, + port=d.get("port", ""), + vid=str(vid_raw), + pid=str(d.get("pid") or ""), + description=d.get("description", "") or "", + ) + if role: + try: + row.info = info_mod.device_info(port=row.port, timeout_s=6.0) + except Exception as exc: + row.info = {"error": repr(exc)} + rows.append(row) + self._app.post_message(DeviceSnapshot(rows=rows)) + + # ---------------- Modals ---------------- + + class FailureDetailScreen(tx.ModalScreen): + """Show a failed test's longrepr + captured sections.""" + + BINDINGS = [tx.Binding("escape,q", "dismiss", "close")] + + def __init__(self, leaf: LeafReport, report_html: pathlib.Path) -> None: + self._leaf = leaf + self._report_html = report_html + super().__init__() + + def compose(self) -> Any: + yield tx.Static( + f"[bold]{self._leaf.nodeid}[/bold] " + f"outcome=[red]{self._leaf.outcome}[/red] " + f"duration={_format_duration(self._leaf.duration_s)}", + id="failure-detail-header", + ) + log = tx.RichLog( + highlight=False, markup=False, wrap=False, id="failure-detail-log" + ) + yield log + yield tx.Static( + f"[dim]Full HTML report: {self._report_html}[/dim] [esc] close", + id="failure-detail-footer", + ) + + def on_mount(self) -> None: + log = self.query_one("#failure-detail-log", tx.RichLog) + if self._leaf.longrepr: + log.write(self._leaf.longrepr) + log.write("") + for section_name, section_text in self._leaf.sections: + log.write(f"--- {section_name} ---") + log.write(section_text) + log.write("") + if not self._leaf.longrepr and not self._leaf.sections: + log.write("(no longrepr or captured sections in reportlog event)") + + def action_dismiss(self, _result: Any = None) -> None: + self.dismiss() + + class FilterInputScreen(tx.ModalScreen[str]): + """Prompt the user for a tree filter substring (empty clears).""" + + BINDINGS = [tx.Binding("escape", "cancel", "cancel")] + + def compose(self) -> Any: + yield tx.Static("filter test tree (substring, empty = clear):") + yield tx.Input(placeholder="nodeid substring", id="filter-input") + + def on_input_submitted(self, event: Any) -> None: + self.dismiss(event.value.strip()) + + def action_cancel(self) -> None: + self.dismiss(None) + + class CoverageModal(tx.ModalScreen): + """Read `tests/tool_coverage.json` (written by `tests/tool_coverage.py` + at `pytest_sessionfinish`) and render a two-column summary of which + MCP tools got exercised by the run. `(no coverage data yet)` while + the run is in flight.""" + + BINDINGS = [tx.Binding("escape,q,c", "dismiss", "close")] + + def __init__(self, coverage_path: pathlib.Path) -> None: + self._path = coverage_path + super().__init__() + + def compose(self) -> Any: + yield tx.Static("[bold]MCP tool coverage[/bold]", id="coverage-header") + yield tx.RichLog( + highlight=False, markup=True, wrap=False, id="coverage-log" + ) + yield tx.Static( + f"[dim]{self._path}[/dim] [esc] close", + id="coverage-footer", + ) + + def on_mount(self) -> None: + log = self.query_one("#coverage-log", tx.RichLog) + if not self._path.is_file(): + log.write("(no coverage data — tool_coverage.json not written yet)") + log.write("") + log.write("Coverage is emitted at pytest_sessionfinish; this") + log.write("file appears after the suite completes.") + return + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + except Exception as exc: + log.write(f"[red]failed to read {self._path}:[/red] {exc!r}") + return + calls = data.get("calls") or {} + if not calls: + log.write("(tool_coverage.json present but no calls recorded)") + return + exercised = sorted( + ((n, c) for n, c in calls.items() if c > 0), key=lambda x: -x[1] + ) + unexercised = sorted(n for n, c in calls.items() if c == 0) + log.write(f"[b]{len(exercised)} / {len(calls)} MCP tools exercised[/b]") + log.write("") + log.write("[green]exercised[/green] (count):") + for name, count in exercised: + log.write(f" {count:>4} {name}") + if unexercised: + log.write("") + log.write("[dim]not exercised:[/dim]") + for name in unexercised: + log.write(f" {name}") + + def action_dismiss(self, _result: Any = None) -> None: + self.dismiss() + + class ReproducerResultModal(tx.ModalScreen): + """Show the exported reproducer tarball path with a short instruction.""" + + BINDINGS = [tx.Binding("escape,q,enter", "dismiss", "close")] + + def __init__( + self, archive_path: pathlib.Path, error: str | None = None + ) -> None: + self._archive = archive_path + self._error = error + super().__init__() + + def compose(self) -> Any: + if self._error: + yield tx.Static(f"[red]Reproducer export failed:[/red] {self._error}") + else: + yield tx.Static("[bold green]Reproducer bundle written[/bold green]") + yield tx.Static(f"[cyan]{self._archive}[/cyan]") + yield tx.Static("") + yield tx.Static( + "Contains: README.md, test_report.json, fwlog.jsonl (time-filtered)," + ) + yield tx.Static( + "devices.json, env.json. Attach to an issue / paste the path in chat." + ) + yield tx.Static("") + yield tx.Static("[dim][esc] close[/dim]") + + def action_dismiss(self, _result: Any = None) -> None: + self.dismiss() + + # ---------------- App ---------------- + + class TestTuiApp(tx.App): + CSS = """ + Screen { layout: vertical; } + #header-bar { height: 2; padding: 0 1; background: $panel; } + #tier-table { height: auto; max-height: 11; } + #body { height: 1fr; } + #tree-pane { width: 50%; border-right: solid $primary-background; } + #right-pane { width: 50%; layout: vertical; } + #pytest-pane { height: 50%; border-bottom: solid $primary-background; } + #fwlog-header { height: 1; padding: 0 1; background: $panel; } + #fwlog-pane { height: 1fr; } + Tree { height: 100%; } + RichLog { height: 100%; } + #device-table { height: auto; max-height: 6; } + """ + + TITLE = "mcp-server test runner" + + BINDINGS = [ + tx.Binding("r", "rerun_focused", "re-run focused"), + tx.Binding("f", "filter_tree", "filter"), + tx.Binding("d", "failure_detail", "failure detail"), + tx.Binding("g", "open_html_report", "open report.html"), + tx.Binding("x", "export_reproducer", "export reproducer"), + tx.Binding("c", "coverage_panel", "coverage"), + tx.Binding("l", "cycle_fwlog_filter", "fw log filter"), + tx.Binding("q,ctrl+c", "quit_app", "quit"), + ] + + def __init__(self) -> None: + super().__init__() + self._state = State() + self._root = root + self._run_tests = run_tests + self._reportlog = reportlog + self._fwlog = fwlog + self._flashlog = flashlog + self._report_html = root / _REPORT_HTML_RELATIVE + self._tool_coverage = root / _TOOL_COVERAGE_RELATIVE + self._repro_dir = root / _REPRODUCERS_RELATIVE + self._seed = seed + self._run_number = run_number + self._pytest_args = pytest_args + self._start_time = time.monotonic() + self._proc: subprocess.Popen[str] | None = None + self._stop = threading.Event() + self._reportlog_worker: ReportlogWorker | None = None + self._stdout_worker: SubprocessReaderWorker | None = None + self._stderr_worker: SubprocessReaderWorker | None = None + self._device_worker: DevicePollerWorker | None = None + self._fwlog_worker: _fwlog_mod.FirmwareLogTailer | None = None + self._flashlog_worker: _flashlog_mod.FlashLogTailer | None = None + self._tree_filter: str = "" + self._sigint_count = 0 + # Firmware-log port filter: None = all, else exact port match. + self._fwlog_filter: str | None = None + # Ordered set of distinct ports we've seen firmware log lines + # from — the `l` key cycles through these. + self._fwlog_ports: list[str] = [] + # Cross-run history. + self._history_store = _history_mod.HistoryStore( + root / _HISTORY_RELATIVE, keep_last=40 + ) + self._history_cache = self._history_store.read_recent() + + # -------- composition / mount -------- + + def compose(self) -> Any: + yield tx.Static(self._header_text(), id="header-bar") + tier_table = tx.DataTable(id="tier-table", show_cursor=False) + yield tier_table + with tx.Horizontal(id="body"): + with tx.Vertical(id="tree-pane"): + yield tx.Tree("tests", id="test-tree") + with tx.Vertical(id="right-pane"): + with tx.Vertical(id="pytest-pane"): + yield tx.RichLog( + id="pytest-log", + highlight=False, + markup=False, + wrap=False, + max_lines=5000, + ) + yield tx.Static(self._fwlog_header_text(), id="fwlog-header") + with tx.Vertical(id="fwlog-pane"): + yield tx.RichLog( + id="fwlog-log", + highlight=False, + markup=False, + # `wrap=True` so long firmware log lines (some + # hit ~200 chars — full packet hex dumps plus + # source tags) don't get truncated at the + # right edge. The right pane is ~50% of the + # terminal so even a wide terminal has a + # ~90-char cap; plain truncation dropped the + # uptime counter or packet id off the end. + wrap=True, + max_lines=5000, + ) + yield tx.DataTable(id="device-table", show_cursor=False) + yield tx.Footer() + + def _fwlog_header_text(self) -> str: + filt = self._fwlog_filter or "(all ports)" + return f"firmware log filter: [b]{filt}[/b] [l] cycle" + + def on_mount(self) -> None: + # Tier-counters table. `add_column` (singular) lets us pick + # the key explicitly — `add_columns` (plural) in textual 8.x + # returns auto-generated keys that are tedious to track + # separately, and update_cell(column_key=

)` — region, preset, channel_num, tx_power, hop_limit. - Optionally, if the device seems unhappy (fails to connect, `num_nodes==1` when ≥2 are plugged in, missing firmware*version), open a short firmware log window: `mcp__meshtastic__serial_open(port=

, env=)`, wait 3s, `serial_read(session_id=, max_lines=100)`, `serial_close(session_id=)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*` is set. -4. **Render per-device report** as: +4. **Hub health** (call once, not per-device): `mcp__meshtastic__uhubctl_list()` — enumerates every USB hub the host can see. Note which hubs advertise `ppps=true` and which hub hosts each Meshtastic device (cross-reference by VID). Flag it in the report if: + - No hub advertises PPPS → `tests/recovery/` can't run on this setup; hard-recovery via `uhubctl_cycle` isn't available. + - A Meshtastic device is on a non-PPPS hub → note it; operator may want to move the device to a PPPS hub to unlock auto-recovery. + - `uhubctl_list` raises `ConfigError: uhubctl not found` → just say `uhubctl not installed` in the report; don't treat as a fault. + +5. **Render per-device report** as: ```text [nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631 @@ -33,20 +38,22 @@ Call the meshtastic MCP tool bundle and format a structured health report for on tx_power : 30 dBm, hop_limit=3 peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm) primary ch : McpTest + hub : 1-1.3 port 2 (PPPS, uhubctl-controllable) firmware : no panics in last 3s; NodeInfoModule emitted 2 broadcasts ``` - Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub), flag it inline with a short `⚠︎ `. + Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub, device on non-PPPS hub), flag it inline with a short `⚠︎ `. -5. **Cross-device correlation** (only when >1 device is inspected): +6. **Cross-device correlation** (only when >1 device is inspected): - Do both sides see each other in `nodesByNum`? If one does and the other doesn't, that's asymmetric NodeInfo — flag it. - Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh) - Do the primary channel NAMES match? Mismatch = different PSK = no decode. -6. **Suggest next actions only for specific, recognisable failure modes**: +7. **Suggest next actions only for specific, recognisable failure modes**: - Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path." - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." - - Device unreachable → point at touch_1200bps + the CP2102-wedged-driver note in run-tests.sh. + - Device unreachable, reachable via DFU → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds AND the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`. + - CP2102-wedged-driver on macOS → see the note in `run-tests.sh`. ## What NOT to do diff --git a/.claude/commands/repro.md b/.claude/commands/repro.md index 52dcf222b93..c5f466ce6d7 100644 --- a/.claude/commands/repro.md +++ b/.claude/commands/repro.md @@ -44,7 +44,8 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac - **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body. - **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this. - **NodeInfo cooldown** → `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs `broadcast_nodeinfo_ping()` warmup. - - **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. + - **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. For a device that's wedged past `touch_1200bps`, the next escalation is `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed). + - **Device went dark mid-run** → fails from some attempt onward, never recovers, firmware log stops arriving. Almost always hardware: a Guru crash + frozen CDC. Hard-power-cycle via `uhubctl_cycle(role=..., confirm=True)` before the next iteration; if that also fails, escalate to replug. - **Genuinely unknown** → say so; don't invent a root cause. 7. **Report back** with: diff --git a/.claude/commands/test.md b/.claude/commands/test.md index 986ee1f31f6..46a753749a3 100644 --- a/.claude/commands/test.md +++ b/.claude/commands/test.md @@ -19,16 +19,21 @@ Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn 2. **Read the pre-flight header.** First ~6 lines print the detected hub (role → port → env). If that line reads `detected hub : (none)`, the wrapper will narrow to `tests/unit` only — say so explicitly in your summary so the operator knows hardware tiers were skipped. -3. **On pass**: one-line summary of the form `N passed, M skipped in `. Don't enumerate the 52 test names — the user can read those. Do mention if any test was SKIPPED for a NON-placeholder reason (e.g. "role not present on hub" is worth flagging). +3. **On pass**: one-line summary of the form `N passed, M skipped in `. Don't enumerate the test names — the user can read those. Do mention any SKIPPED tests and name the cause: + - `"role not present on hub"` → device unplugged; operator knows to reconnect. + - `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped because the macro isn't in firmware yet; suggest `--force-bake`. + - `"uhubctl not installed"` → tests/recovery + peer-offline skipped; suggest `brew install uhubctl` / `apt install uhubctl`. + - `"no PPPS-capable hubs detected"` → tests/recovery skipped because the hub doesn't support per-port power; the tier will never run on that setup. + - `"opencv-python-headless is not installed"` → tests/ui auto-deselected by run-tests.sh; suggest `pip install -e 'mcp-server/.[ui]'`. -4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). +4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). For UI-tier failures also glance at `mcp-server/tests/ui_captures///transcript.md` — it records each step's frame + OCR. 5. **Classify the failure** as one of: - **Transient/flake**: LoRa collision, timing-sensitive assertion, first-attempt NAK + successful retry pattern. Propose `/repro ` to confirm. - - **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery (replug USB, `touch_1200bps`, check `git status userPrefs.jsonc`). + - **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery in escalation order: (a) replug USB, (b) `touch_1200bps(port=...)` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role="nrf52", confirm=True)` when a device is fully wedged past DFU (needs `uhubctl` installed — `baked_single`'s auto-recovery hook does this once automatically). Also check `git status userPrefs.jsonc`. - **Regression**: same assertion fails repeatedly, firmware log shows a new/unusual error. Surface the diff between expected and observed, identify the module likely responsible. -6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, or USB replug, \_describe what to do* — don't execute. The operator decides. +6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or USB replug, \_describe what to do* — don't execute. The operator decides. ## Arguments handling diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d12244229e6..7c71a501485 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -474,7 +474,7 @@ The repo registers the server via `.mcp.json` at the repo root — Claude Code p **One MCP call per port at a time.** `SerialInterface` holds an exclusive OS-level lock on the serial port for its lifetime. If a `serial_*` session is open on `/dev/cu.usbmodem101`, calling `device_info` on the same port will fail fast pointing at the active session. Sequence calls: open → read/mutate → close, then next device. Never parallelize tool calls on the same port. -### MCP tool surface (~32 tools) +### MCP tool surface (43 tools) Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-value signatures are called out here. @@ -482,11 +482,13 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v - **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 only), `update_flash` (ESP32 OTA), `touch_1200bps` - **Serial sessions** (long-running, 10k-line ring buffer): `serial_open`, `serial_read`, `serial_list`, `serial_close` - **Device reads**: `device_info`, `list_nodes` -- **Device writes** (all require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api` +- **Device writes**: `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `send_input_event` (inject a button/key press via the firmware's InputBroker), `set_debug_log_api`; destructive/power-state writes require `confirm=True`: `reboot`, `shutdown`, `factory_reset` - **userPrefs admin** (build-time constants, not runtime config): `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` - **Vendor escape hatches**: `esptool_chip_info`, `esptool_erase_flash`, `esptool_raw`, `nrfutil_dfu`, `nrfutil_raw`, `picotool_info`, `picotool_load`, `picotool_raw` +- **USB power control** (via `uhubctl`, per-port PPPS toggle): `uhubctl_list` (read-only), `uhubctl_power(action='on'|'off', confirm=True)`, `uhubctl_cycle(delay_s, confirm=True)`. Target by raw `(location, port)` or by `role` (`"nrf52"`, `"esp32s3"`); role lookup checks `MESHTASTIC_UHUBCTL_LOCATION_` + `_PORT_` env vars first, falls back to VID auto-detection. +- **Observability** (UI tier + operator ad-hoc): `capture_screen(role, ocr=True)` — grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requires `mcp-server[ui]` extras (`opencv-python-headless`, `easyocr`) and `MESHTASTIC_UI_CAMERA_DEVICE_` env var; falls through to a 1×1 black PNG `NullBackend` when unconfigured. -`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset` and `erase_and_flash`. +`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`. ### Hardware test suite (`mcp-server/run-tests.sh`) @@ -494,14 +496,16 @@ The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf5 Suite tiers (collected + run in this order via `pytest_collection_modifyitems`): -1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile). No hardware. +1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile, uhubctl parser). No hardware. 2. `tests/test_00_bake.py` — flashes each detected device with current `userPrefs.jsonc` merged with the session's test profile. Has its own skip-if-already-baked check comparing region + primary channel to the session profile; skips cheaply on warm devices. -3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`. +3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`. Includes `test_peer_offline_recovery` which uses uhubctl to physically power off one peer mid-conversation (requires uhubctl; skips without). 4. `tests/telemetry/` — `DEVICE_METRICS_APP` broadcast timing. 5. `tests/monitor/` — boot-log panic check. -6. `tests/fleet/` — PSK seed session isolation. -7. `tests/admin/` — channel URL roundtrip, owner persistence across reboot. -8. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset. +6. `tests/recovery/` — `uhubctl` power-cycle round-trip + NVS persistence across hard reset. Requires `uhubctl` installed and a PPPS-capable hub; entire tier auto-skips otherwise. +7. `tests/ui/` — input-broker-driven screen navigation with camera + OCR evidence. +8. `tests/fleet/` — PSK seed session isolation. +9. `tests/admin/` — channel URL roundtrip, owner persistence across reboot. +10. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset. Invocation patterns: @@ -586,15 +590,19 @@ If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` fl ### Recovery playbooks -| Symptom | First check | Fix | -| ---------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. | -| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. | -| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. | -| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. | -| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. | -| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. | -| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. | +| Symptom | First check | Fix | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. | +| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. | +| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. | +| Device fully wedged (Guru Meditation, frozen CDC, no DFU) | `list_devices` shows the VID but every admin call times out | `uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles the port via USB hub PPPS. `baked_single`'s auto-recovery hook does this once automatically if uhubctl is installed. Falls back to physical replug if no PPPS hub. | +| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. | +| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. | +| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. | +| Entire `tests/recovery/` tier skipped | `command -v uhubctl` | Expected if `uhubctl` isn't on PATH. Install via `brew install uhubctl` (macOS) or `apt install uhubctl` (Debian/Ubuntu). Also skips if no hub advertises PPPS. | +| Entire `tests/ui/` tier skipped ("firmware not baked with USERPREFS_UI_TEST_LOG") | reportlog.jsonl for the skip reason | Re-run with `--force-bake` so the UI-log macro gets compiled into the fresh firmware. First run after the Round-3 landing always re-bakes. | +| `tests/ui/` runs but captures are all 1×1 black PNGs | `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3` | Env var not set → `NullBackend`. Point a USB webcam at the heltec-v3 OLED and set the device index; `.venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]"` discovers it. | +| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. | ### Never do these without asking diff --git a/.github/prompts/mcp-diagnose.prompt.md b/.github/prompts/mcp-diagnose.prompt.md index c86826030d9..1049858f8ef 100644 --- a/.github/prompts/mcp-diagnose.prompt.md +++ b/.github/prompts/mcp-diagnose.prompt.md @@ -26,7 +26,12 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co - `get_config(section="lora", port=

)` → region, preset, channel_num, tx_power, hop_limit - If anything looks off (can't connect, `num_nodes` wrong, missing `firmware_version`), open a short firmware-log window: `serial_open(port=

, env=)`, wait 3 seconds, `serial_read(session_id, max_lines=100)`, `serial_close(session_id)`. Infer env from VID (0x239a → `rak4631`, 0x303a/0x10c4 → `heltec-v3`) unless an `MESHTASTIC_MCP_ENV_` env var overrides it. -4. **Render per-device report** as a compact block: +4. **Hub health** (call once, not per-device): `uhubctl_list()` — enumerates every USB hub the host sees. Cross-reference each Meshtastic device's VID to find which hub + port it's on. Flag in the report if: + - No hub advertises `ppps=true` → `tests/recovery/` can't run; hard-recovery via `uhubctl_cycle` isn't available. + - A Meshtastic device is on a non-PPPS hub → note it; moving to a PPPS hub unlocks auto-recovery. + - `uhubctl_list` raises `ConfigError: uhubctl not found` → report as "uhubctl not installed"; don't treat as a device fault. + +5. **Render per-device report** as a compact block: ```text [nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631 @@ -35,20 +40,22 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co tx_power : 30 dBm, hop_limit=3 peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm) primary ch : McpTest + hub : 1-1.3 port 2 (PPPS, uhubctl-controllable) firmware : no panics in last 3s ``` - Flag abnormalities inline with `⚠︎ ` — missing pubkey on a known peer, region UNSET, mismatched channel name, etc. + Flag abnormalities inline with `⚠︎ ` — missing pubkey on a known peer, region UNSET, mismatched channel name, device on non-PPPS hub, etc. -5. **Cross-device correlation** (when >1 device selected): +6. **Cross-device correlation** (when >1 device selected): - Do both see each other in `nodesByNum`? - Do `region`, `channel_num`, `modem_preset` match across devices? - Do the primary channel names match? (Different name → different PSK → no decode.) -6. **Suggest next steps only for recognizable failure modes**, never speculatively: +7. **Suggest next steps only for recognizable failure modes**, never speculatively: - Stale PKI one-way → "`/mcp-test tests/mesh/test_direct_with_ack.py` — the test's retry+nodeinfo-ping heals this." - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." - - Device unreachable → refer operator to the touch_1200bps + CP2102-wedged-driver notes in `run-tests.sh`. + - Device unreachable, DFU reachable → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds and the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`. + - CP2102-wedged-driver on macOS → see `run-tests.sh` notes. ## Hard constraints diff --git a/.github/prompts/mcp-repro.prompt.md b/.github/prompts/mcp-repro.prompt.md index be2963c3318..3a7c5c3de99 100644 --- a/.github/prompts/mcp-repro.prompt.md +++ b/.github/prompts/mcp-repro.prompt.md @@ -46,7 +46,8 @@ Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one - **LoRa airtime collision** — pass rate improves with fewer concurrent transmitters. Suggest a `time.sleep` gap or retry bump in the test body. - **PKI key staleness** — first attempt fails, subsequent ones pass; existing retry-loop pattern in `test_direct_with_ack.py` is the fix. - **NodeInfo cooldown** — `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs a `broadcast_nodeinfo_ping()` warmup. - - **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc. + - **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc. For a device wedged past `touch_1200bps`, recommend `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed). + - **Device went dark mid-run** — fails from some iteration onward and never recovers; firmware log stops arriving. Almost always a Guru crash with frozen CDC. Recommend `uhubctl_cycle` before the next iteration; escalate to replug if that also fails. - **Unknown** — say so. Don't invent a root cause. 7. **Report back** with: diff --git a/.github/prompts/mcp-test.prompt.md b/.github/prompts/mcp-test.prompt.md index 092ad3d856c..148569e83da 100644 --- a/.github/prompts/mcp-test.prompt.md +++ b/.github/prompts/mcp-test.prompt.md @@ -21,19 +21,25 @@ Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md 2. **Read the pre-flight header** (first few lines of wrapper output). The `detected hub :` line lists role → port → env mappings. If it reads `(none)`, the wrapper narrowed to `tests/unit` only — call that out explicitly so the operator knows hardware tiers were skipped. -3. **On pass**: one-line summary like `N passed, M skipped in `. Don't enumerate test names. DO mention any non-placeholder SKIPs (things like "role not present on hub") because they indicate missing hardware or setup issues. +3. **On pass**: one-line summary like `N passed, M skipped in `. Don't enumerate test names. DO mention any non-placeholder SKIPs and name the cause: + - `"role not present on hub"` → device unplugged; operator should reconnect. + - `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped; the UI-log compile macro isn't in the baked firmware. Suggest `--force-bake`. + - `"uhubctl not installed"` → tests/recovery + `test_peer_offline_recovery` skipped. Suggest `brew install uhubctl` / `apt install uhubctl`. + - `"no PPPS-capable hubs detected"` → tests/recovery skipped because the attached hub doesn't support per-port power switching; won't run on that setup. + - `"opencv-python-headless is not installed"` → tests/ui auto-deselected by `run-tests.sh`. Suggest `pip install -e 'mcp-server/.[ui]'`. 4. **On failure**: open `mcp-server/tests/report.html` (pytest-html output, self-contained) and extract the `Meshtastic debug` section for each failed test. That section includes a firmware log stream (last 200 lines) and device state dump. For each failure, summarise: - test name - one-line assertion message - the specific firmware log lines that explain why (look for `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`, `No suitable channel`) + - for UI-tier failures also check `mcp-server/tests/ui_captures///transcript.md` (per-step frame + OCR) 5. **Classify each failure** as one of: - **Transient flake** — LoRa collision, first-attempt NAK with self-heal pattern, timing-sensitive assertion. Suggest `/mcp-repro ` to confirm. - - **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest specific recovery (USB replug, `touch_1200bps`, `git status userPrefs.jsonc`). + - **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest recovery in escalation order: (a) replug USB, (b) `touch_1200bps` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role=..., confirm=True)` for a device wedged past DFU (needs `uhubctl` installed; `baked_single` does this once automatically when available). Also check `git status userPrefs.jsonc`. - **Regression** — same assertion fails repeatedly on re-runs, firmware log shows novel errors. Identify the firmware module likely responsible. -6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval. +6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval. ## Arguments convention diff --git a/AGENTS.md b/AGENTS.md index cd043c08787..b3fa1970c88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,25 +89,42 @@ Sequence these; don't parallelize on the same port. ## Where to look -| Path | What's there | -| --------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) | -| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI | -| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers | -| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) | -| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` | -| `test/` | Firmware unit tests (12 suites; `pio test -e native`) | -| `mcp-server/` | Python MCP server + pytest hardware integration tests | -| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `fleet/`, `admin/`, `provisioning/` | -| `.claude/commands/` | Claude Code slash command bodies | -| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) | -| `.github/copilot-instructions.md` | **Primary agent instructions — read this** | -| `.github/workflows/` | CI pipelines | -| `.mcp.json` | MCP server registration for Claude Code | +| Path | What's there | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) | +| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI | +| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers | +| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) | +| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` | +| `test/` | Firmware unit tests (12 suites; `pio test -e native`) | +| `mcp-server/` | Python MCP server + pytest hardware integration tests | +| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/` | +| `.claude/commands/` | Claude Code slash command bodies | +| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) | +| `.github/copilot-instructions.md` | **Primary agent instructions — read this** | +| `.github/workflows/` | CI pipelines | +| `.mcp.json` | MCP server registration for Claude Code | ## Recovery one-liners - **`userPrefs.jsonc` dirty after a test run?** Re-run `./mcp-server/run-tests.sh` once (pre-flight self-heals from the sidecar). If still dirty: `git checkout userPrefs.jsonc`. - **nRF52 not responding?** `mcp__meshtastic__touch_1200bps(port=...)` drops it into the DFU bootloader, then `pio_flash` re-installs. +- **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access. - **Port busy?** `lsof ` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it. - **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned. + +## Environment variables (test harness) + +| Var | Purpose | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `MESHTASTIC_MCP_ENV_` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. | +| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp--`. | +| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. | +| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. | +| `MESHTASTIC_UHUBCTL_LOCATION_` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. | +| `MESHTASTIC_UHUBCTL_PORT_` | Pin a role to a specific hub port number. Required alongside `LOCATION_`. | +| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). | +| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. | +| `MESHTASTIC_UI_CAMERA_DEVICE_` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). | +| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). | +| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. | diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore index f5180bc71a1..4cc892b2aca 100644 --- a/mcp-server/.gitignore +++ b/mcp-server/.gitignore @@ -24,3 +24,6 @@ tests/.tui-runs tests/.history/ # Reproducer bundles (TUI `x` export on failed tests). tests/reproducers/ +# UI-tier camera captures + per-test transcripts. Regenerated every run; +# left on disk for human review between runs. +tests/ui_captures/ diff --git a/mcp-server/README.md b/mcp-server/README.md index 7d5fc551a7b..7a36a6facb0 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -61,7 +61,7 @@ Replace `` with the absolute path, e.g. `/Users/you/GitHub/firmwa Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows). -## Tools (38) +## Tools (43) ### Discovery & metadata @@ -130,6 +130,34 @@ _The tool tables below document 38 currently registered MCP server tools._ | `picotool_load` | Load a UF2 | | `picotool_raw` | Pass-through | +### USB power control (uhubctl) + +| Tool | What it does | +| --------------- | ----------------------------------------------------------- | +| `uhubctl_list` | Enumerate USB hubs + attached-device VID/PID (read-only) | +| `uhubctl_power` | Drive a hub port `on` or `off`; `off` requires confirm=True | +| `uhubctl_cycle` | Off → wait `delay_s` → on; confirm=True required | + +Target a port by explicit `(location, port)` (raw uhubctl syntax like +`location="1-1.3", port=2`) or by `role` (`"nrf52"`, `"esp32s3"`). Role +lookup checks `MESHTASTIC_UHUBCTL_LOCATION_` + +`MESHTASTIC_UHUBCTL_PORT_` env vars first, then auto-detects via VID +against `uhubctl`'s output. + +Requires [`uhubctl`](https://github.com/mvp/uhubctl) on PATH: + +```bash +brew install uhubctl # macOS +apt install uhubctl # Debian/Ubuntu +``` + +Modern macOS + PPPS-capable hubs generally work without root. On Linux +without udev rules, or on old macOS with driver quirks, you may need +`sudo`. If uhubctl returns a permission error the MCP tool raises a +clear `UhubctlError` pointing at the +[udev-rules / sudo fallback](https://github.com/mvp/uhubctl#linux-usb-permissions) +rather than auto-`sudo`'ing mid-run. + ## Safety - **All destructive flash/admin tools require `confirm=True`** as a tool-level gate, on top of any permission prompt from Claude. @@ -182,10 +210,22 @@ in the pre-flight header. - **`unit`** — pure Python, no hardware. boards / PIO wrapper / userPrefs-parse / testing-profile fixtures. - **`mesh`** — 2-device mesh: formation, broadcast delivery, direct+ACK, - traceroute, bidirectional. Parametrized over both directions. + traceroute, bidirectional. Parametrized over both directions. Includes + `test_peer_offline_recovery` which uses uhubctl to power-cycle one peer + mid-conversation and verifies the mesh recovers (skips without uhubctl). - **`telemetry`** — periodic telemetry broadcast + on-demand request/reply (`TELEMETRY_APP` with `wantResponse=True`). - **`monitor`** — boot log has no panic markers within 60 s of reboot. +- **`recovery`** — `uhubctl` power-cycle round-trip: verifies the hub port + can be toggled off/on, the device re-enumerates with the same + `my_node_num`, and NVS-resident config (region, channel, modem preset) + survives a hard reset. Requires `uhubctl` on PATH; skips cleanly otherwise. +- **`ui`** — input-broker-driven screen navigation (`AdminMessage.send_input_event` + injection → `Screen::handleInputEvent` → frame transition). Parametrized + on the screen-bearing role (heltec-v3 OLED). Captures images via USB + webcam + OCRs them for HTML-report evidence. Requires `pip install -e '.[ui]'` + and `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=`; tier is auto-deselected + if `cv2` isn't importable. - **`fleet`** — PSK-seed isolation: two labs with different seeds never overlap. - **`admin`** — owner persistence across reboot, channel URL round-trip, @@ -193,6 +233,42 @@ in the pre-flight header. - **`provisioning`** — region/channel baking, userPrefs survive `factory_reset(full=False)`. +#### UI tier setup + +The `tests/ui/` tier drives the on-device OLED via the firmware's existing +`AdminMessage.send_input_event` RPC (no firmware changes required) and +verifies transitions via a macro-gated log line + camera + OCR. Summary: + +1. Install extras: `pip install -e 'mcp-server/.[ui]'` — pulls in + `opencv-python-headless`, `numpy`, `easyocr`, `Pillow`. First easyocr + run downloads ~100 MB of models to `~/.EasyOCR/`; an autouse session + fixture pre-warms the reader so per-test OCR is <100 ms after that. +2. Point a USB webcam at the heltec-v3 OLED. Discover its index: + ```bash + .venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]" + ``` +3. Export the per-role device env var: + ```bash + export MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0 + ``` +4. Run: + ```bash + ./run-tests.sh tests/ui -v + ``` + Captures land under `tests/ui_captures///`, one + PNG + `.ocr.txt` per `frame_capture()` call, with a per-test + `transcript.md` stepping through event → frame → OCR. The HTML report + embeds the full image strip inline (pass or fail). + +On macOS, `cv2.VideoCapture(0)` triggers the TCC Camera permission prompt +on first use. Pre-grant Terminal (or your IDE's terminal) before running. +The `OpenCVBackend` fails fast on 10 consecutive black frames so a silent +permission denial surfaces as a clear error, not an empty PNG strip. + +No camera? Set `MESHTASTIC_UI_CAMERA_BACKEND=null` (or leave the device var +unset). Tests still exercise the event-injection path and log assertions; +captures just become 1×1 black PNGs. + ### Artifacts (regenerated every run, under `tests/`) - `report.html` — self-contained pytest-html report. Each test gets a @@ -217,6 +293,14 @@ Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open `report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q` quit (SIGINT → SIGTERM → SIGKILL escalation). +Set `MESHTASTIC_UI_TUI_CAMERA=1` to mount a bottom-of-screen **UI camera** +panel. Left side: the latest capture PNG rendered as Unicode half-blocks +(via `rich-pixels`, works in any terminal — no kitty/sixel required). +Right side: live transcript tail ("step 3 — frame 4/8 name=nodelist_nodes +— OCR: Nodes 2/2") so you can see every event-injection and its result +as each UI test runs. Requires the `[ui]` extras for image rendering; the +transcript alone works without them. + ### Slash commands Three AI-assisted workflows are wired up for Claude Code operators diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml index d73bf795f5f..3241c843f4e 100644 --- a/mcp-server/pyproject.toml +++ b/mcp-server/pyproject.toml @@ -23,6 +23,21 @@ test = [ # consumers; revisit if install cost pushes back. "textual>=0.50", ] +# UI test tier + `capture_screen` MCP tool. Optional because the ML OCR +# model alone is ~100 MB and camera hardware is user-supplied. +# pip install -e '.[ui]' — full (OpenCV + easyocr) +# pip install -e '.[ui-min]' — image capture only, no OCR +ui = [ + "opencv-python-headless>=4.9", + "numpy>=1.26", + "easyocr>=1.7", + "Pillow>=10.0", + # Renders the latest camera capture as Unicode half-blocks in the TUI + # (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic — no kitty / sixel + # dependency. Pure Python, tiny. + "rich-pixels>=3.0", +] +ui-min = ["opencv-python-headless>=4.9", "numpy>=1.26"] [project.scripts] meshtastic-mcp = "meshtastic_mcp.__main__:main" diff --git a/mcp-server/run-tests.sh b/mcp-server/run-tests.sh index 292e6e3a2f7..c84a8f75153 100755 --- a/mcp-server/run-tests.sh +++ b/mcp-server/run-tests.sh @@ -217,6 +217,40 @@ if [[ $# -eq 0 ]]; then -v --tb=short fi +# UI tier requires opencv-python-headless (and ideally easyocr). If it's +# not installed, auto-deselect tests/ui so operators without the [ui] +# extra still get a green run. Printed in yellow; silent when cv2 is +# present. +_cv2_ok=0 +if "$VENV_PY" -c "import cv2" >/dev/null 2>&1; then + _cv2_ok=1 +fi +_running_ui=0 +for _arg in "$@"; do + case "$_arg" in + *tests/ui* | tests/) _running_ui=1 ;; + *) ;; + esac +done +if [[ $_running_ui -eq 1 && $_cv2_ok -eq 0 ]]; then + printf '\033[33m[pre-flight] tests/ui tier detected, but opencv-python-headless is not installed — deselecting.\033[0m\n' + printf ' install with: .venv/bin/pip install -e "mcp-server/.[ui]"\n' + echo + set -- "$@" --ignore=tests/ui +fi + +# Recovery tier needs `uhubctl` on PATH — it power-cycles devices via USB +# hub PPPS. The tier's conftest already skips cleanly, so this is just a +# friendly heads-up before the skip happens. `baked_single`'s auto- +# recovery hook also benefits from having uhubctl available across the +# whole suite. +if ! command -v uhubctl >/dev/null 2>&1; then + printf "\033[33m[pre-flight] uhubctl not found on PATH — recovery tier will skip, and\n" + printf " wedged-device auto-recovery is disabled.\033[0m\n" + printf " install with: brew install uhubctl (macOS) or apt install uhubctl (Debian/Ubuntu).\n" + echo +fi + # Always emit `tests/reportlog.jsonl` (unless the operator explicitly passed # their own `--report-log=...`). Consumers — notably the # `meshtastic-mcp-test-tui` TUI — tail the reportlog for live per-test state. diff --git a/mcp-server/src/meshtastic_mcp/admin.py b/mcp-server/src/meshtastic_mcp/admin.py index 6da92d860a4..33f3865dd68 100644 --- a/mcp-server/src/meshtastic_mcp/admin.py +++ b/mcp-server/src/meshtastic_mcp/admin.py @@ -356,6 +356,46 @@ def shutdown( return {"ok": True, "shutting_down_in_s": seconds} +def send_input_event( + event_code: int | str, + kb_char: int = 0, + touch_x: int = 0, + touch_y: int = 0, + port: str | None = None, +) -> dict[str, Any]: + """Inject an InputBroker event (button press / key / gesture) into the UI. + + Wraps `AdminMessage.send_input_event` (handled in firmware at + src/modules/AdminModule.cpp::handleSendInputEvent). Local-only — no PKI + warmup needed since the admin message is addressed to `my_node_num`. + + `event_code` accepts an int, a case-insensitive name + (`"RIGHT"` / `"input_broker_right"`), or an `InputEventCode`. The + firmware-side enum lives in src/input/InputBroker.h and is mirrored in + `meshtastic_mcp.input_events`. + """ + from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped] + + from .input_events import coerce_event_code + + code = coerce_event_code(event_code) + if not 0 <= kb_char <= 255: + raise ValueError(f"kb_char out of u8 range: {kb_char}") + if not 0 <= touch_x <= 65535: + raise ValueError(f"touch_x out of u16 range: {touch_x}") + if not 0 <= touch_y <= 65535: + raise ValueError(f"touch_y out of u16 range: {touch_y}") + + with connect(port=port) as iface: + msg = admin_pb2.AdminMessage() + msg.send_input_event.event_code = code + msg.send_input_event.kb_char = kb_char + msg.send_input_event.touch_x = touch_x + msg.send_input_event.touch_y = touch_y + iface.localNode._sendAdmin(msg) + return {"ok": True, "event_code": code, "kb_char": kb_char} + + def factory_reset( port: str | None = None, confirm: bool = False, full: bool = False ) -> dict[str, Any]: diff --git a/mcp-server/src/meshtastic_mcp/camera.py b/mcp-server/src/meshtastic_mcp/camera.py new file mode 100644 index 00000000000..5f1e5ede323 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/camera.py @@ -0,0 +1,286 @@ +"""Cross-platform USB-webcam capture for UI tests + the `capture_screen` tool. + +Backends: +- `opencv` — cv2.VideoCapture (AVFoundation on macOS, V4L2 on Linux). +- `ffmpeg` — subprocess shelling out to the system `ffmpeg` binary. Slower + per frame, but zero Python deps beyond stdlib. +- `null` — no-op stub returning a 1×1 black PNG. Used when no camera is + configured; keeps code paths alive without forcing every operator to + hook up hardware. + +Environment variables (read at `get_camera()` call time): +- `MESHTASTIC_UI_CAMERA_BACKEND` — one of `opencv` / `ffmpeg` / `null` / + `auto` (default). `auto` picks opencv if `cv2` imports, else ffmpeg if + `ffmpeg --version` resolves, else null. +- `MESHTASTIC_UI_CAMERA_DEVICE` — generic default (index or path). +- `MESHTASTIC_UI_CAMERA_DEVICE_` — per-role override, e.g. + `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3. + Role suffix is uppercased before lookup. + +Dependencies land in the optional `[ui]` extra; imports are lazy so clients +without `opencv-python-headless` installed can still import this module. +""" + +from __future__ import annotations + +import io +import os +import shutil +import subprocess +import sys +import time +import warnings +from pathlib import Path +from typing import Protocol + + +class CameraError(RuntimeError): + """Raised when a camera backend fails to initialize or capture.""" + + +class CameraBackend(Protocol): + name: str + + def capture(self) -> bytes: + """Return one PNG-encoded frame.""" + ... + + def close(self) -> None: ... + + +# ---------- OpenCV backend ------------------------------------------------- + + +class OpenCVBackend: + name = "opencv" + + def __init__(self, device: int | str, warmup_frames: int = 5) -> None: + try: + import cv2 # type: ignore[import-untyped] # noqa: PLC0415 + except ImportError as exc: + raise CameraError( + "opencv backend requested but `cv2` is not installed. " + "Install the mcp-server [ui] extra: pip install -e '.[ui]'" + ) from exc + + self._cv2 = cv2 + device_arg: int | str + if isinstance(device, str) and device.isdigit(): + device_arg = int(device) + else: + device_arg = device + self._cap = cv2.VideoCapture(device_arg) + if not self._cap.isOpened(): + raise CameraError( + f"cv2.VideoCapture({device_arg!r}) failed to open. " + "On macOS check TCC Camera permission; on Linux check /dev/video* and v4l2 access." + ) + + # Drop the first few frames — auto-exposure + white-balance settle. + for _ in range(warmup_frames): + self._cap.read() + # Detect a stuck black-frame camera early rather than silently + # producing all-black captures. + ok, frame = self._cap.read() + if not ok or frame is None: + self._cap.release() + raise CameraError(f"camera {device_arg!r} opened but returned no frames") + + def capture(self) -> bytes: + cv2 = self._cv2 + ok, frame = self._cap.read() + if not ok or frame is None: + raise CameraError("cv2.VideoCapture.read() returned no frame") + success, buf = cv2.imencode(".png", frame) + if not success: + raise CameraError("cv2.imencode('.png', ...) failed") + return bytes(buf) + + def close(self) -> None: + try: + self._cap.release() + except Exception: # noqa: BLE001 + pass + + +# ---------- ffmpeg subprocess backend -------------------------------------- + + +class FfmpegBackend: + name = "ffmpeg" + + def __init__(self, device: int | str) -> None: + if shutil.which("ffmpeg") is None: + raise CameraError("ffmpeg backend requested but `ffmpeg` is not on PATH") + + self._device = str(device) + # Platform-specific -f flag: + # macOS → avfoundation (index like "0") + # Linux → v4l2 (device like "/dev/video0" or "0") + if sys.platform == "darwin": + self._input_format = "avfoundation" + self._input_spec = self._device # bare index for avfoundation + else: + self._input_format = "v4l2" + self._input_spec = ( + self._device + if self._device.startswith("/dev/") + else f"/dev/video{self._device}" + ) + + def capture(self) -> bytes: + cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-f", + self._input_format, + "-i", + self._input_spec, + "-frames:v", + "1", + "-f", + "image2pipe", + "-vcodec", + "png", + "-", + ] + try: + out = subprocess.run( + cmd, capture_output=True, check=True, timeout=15 # noqa: S603 + ) + except subprocess.CalledProcessError as exc: + raise CameraError( + f"ffmpeg capture failed (rc={exc.returncode}): {exc.stderr.decode(errors='replace')[:200]}" + ) from exc + except subprocess.TimeoutExpired as exc: + raise CameraError("ffmpeg capture timed out after 15s") from exc + return out.stdout + + def close(self) -> None: + pass # stateless — each capture spawns a new process + + +# ---------- Null backend --------------------------------------------------- + + +# A tiny valid 1×1 transparent PNG so callers always get a decodable image. +_BLACK_1X1_PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" + "0000000d49444154789c6300010000000500010d0a2db40000000049454e44ae426082" +) + + +class NullBackend: + name = "null" + + def capture(self) -> bytes: + return _BLACK_1X1_PNG + + def close(self) -> None: + pass + + +# ---------- Factory -------------------------------------------------------- + + +def _resolve_device(role: str | None) -> str | None: + if role: + specific = os.environ.get(f"MESHTASTIC_UI_CAMERA_DEVICE_{role.upper()}") + if specific: + return specific + return os.environ.get("MESHTASTIC_UI_CAMERA_DEVICE") + + +def get_camera(role: str | None = None) -> CameraBackend: + """Return a CameraBackend for the given device role (e.g. `"esp32s3"`). + + Falls back to `NullBackend` if no camera is configured or the selected + backend fails to init — tests should treat captures as best-effort + evidence, not a blocker. + """ + backend = os.environ.get("MESHTASTIC_UI_CAMERA_BACKEND", "auto").lower() + device = _resolve_device(role) + + if backend in ("null", "none") or device is None: + return NullBackend() + + if backend == "auto": + # Prefer opencv if importable; fall back to ffmpeg; else null. + try: + import cv2 # type: ignore[import-untyped] # noqa: F401,PLC0415 + + backend = "opencv" + except ImportError: + backend = "ffmpeg" if shutil.which("ffmpeg") else "null" + + if backend == "opencv": + try: + return OpenCVBackend(device) + except CameraError as exc: + warnings.warn( + f"camera backend {backend!r} failed to initialize for device " + f"{device!r}: {exc}; falling back to null backend", + RuntimeWarning, + stacklevel=2, + ) + return NullBackend() + if backend == "ffmpeg": + try: + return FfmpegBackend(device) + except CameraError as exc: + warnings.warn( + f"camera backend {backend!r} failed to initialize for device " + f"{device!r}: {exc}; falling back to null backend", + RuntimeWarning, + stacklevel=2, + ) + return NullBackend() + if backend == "null": + return NullBackend() + + raise CameraError(f"unknown MESHTASTIC_UI_CAMERA_BACKEND: {backend!r}") + + +def save_capture(png_bytes: bytes, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(png_bytes) + + +def capture_to_file(role: str | None, path: Path) -> dict[str, object]: + """One-shot: open camera, capture, write PNG, close. Returns metadata.""" + started = time.monotonic() + cam = get_camera(role) + try: + data = cam.capture() + finally: + cam.close() + save_capture(data, path) + return { + "backend": cam.name, + "path": str(path), + "bytes": len(data), + "elapsed_s": round(time.monotonic() - started, 3), + } + + +def _is_png(data: bytes) -> bool: + return data.startswith(b"\x89PNG\r\n\x1a\n") + + +# Exposed so callers can sanity-check a capture without a full PIL import. +__all__ = [ + "CameraBackend", + "CameraError", + "FfmpegBackend", + "NullBackend", + "OpenCVBackend", + "capture_to_file", + "get_camera", + "save_capture", +] + +# Keep `io` import used (pyflakes is picky) via a small guard used at import +# time to normalize stdin/stdout if a subclass ever needs it. +_ = io.BytesIO # noqa: SLF001 diff --git a/mcp-server/src/meshtastic_mcp/cli/_uicap.py b/mcp-server/src/meshtastic_mcp/cli/_uicap.py new file mode 100644 index 00000000000..44845995480 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/_uicap.py @@ -0,0 +1,83 @@ +"""UI-capture transcript tailer for ``meshtastic-mcp-test-tui``. + +Watches ``tests/ui_captures//`` for new transcript lines +(one per ``frame_capture()`` call from the UI tier) and posts them to +the TUI. Enabled by ``MESHTASTIC_UI_TUI_CAMERA=1``. + +Design mirrors ``_flashlog.py``: +- Daemon thread, cooperative stop via ``threading.Event``. +- Tolerates the captures directory not existing yet (UI tier hasn't run). +- Per-file seek state so we only forward genuinely-new lines. +""" + +from __future__ import annotations + +import pathlib +import threading +import time +from typing import Callable + + +class UiCaptureTailer(threading.Thread): + """Recursively watch a captures root for new `transcript.md` lines. + + Invokes ``post(test_id, line)`` for each new line, where ``test_id`` + is derived from the path — the sanitized nodeid directory name. + """ + + def __init__( + self, + root: pathlib.Path, + post: Callable[[str, str], None], + stop: threading.Event, + *, + poll_interval: float = 0.5, + ) -> None: + super().__init__(daemon=True, name="uicap-tail") + self._root = root + self._post = post + self._stop = stop + self._poll_interval = poll_interval + # path → byte offset we've already read through + self._offsets: dict[pathlib.Path, int] = {} + + def run(self) -> None: + while not self._stop.is_set(): + try: + self._scan_once() + except Exception: + # Best-effort tailer — never bring down the TUI because a + # directory vanished mid-scan. + pass + time.sleep(self._poll_interval) + + def _scan_once(self) -> None: + if not self._root.is_dir(): + return + for transcript in self._root.rglob("transcript.md"): + test_id = transcript.parent.name + offset = self._offsets.get(transcript, 0) + try: + size = transcript.stat().st_size + except OSError: + continue + if size < offset: + # File truncated / rewritten — reset and re-emit. + offset = 0 + if size == offset: + continue + try: + with transcript.open("rb") as fh: + fh.seek(offset) + chunk = fh.read(size - offset).decode("utf-8", errors="replace") + except OSError: + continue + for line in chunk.splitlines(): + line = line.rstrip() + if not line or line.startswith("#"): + continue + try: + self._post(test_id, line) + except Exception: + return + self._offsets[transcript] = size diff --git a/mcp-server/src/meshtastic_mcp/cli/test_tui.py b/mcp-server/src/meshtastic_mcp/cli/test_tui.py index 33201101b1a..7f3a2da36e0 100644 --- a/mcp-server/src/meshtastic_mcp/cli/test_tui.py +++ b/mcp-server/src/meshtastic_mcp/cli/test_tui.py @@ -518,6 +518,7 @@ def _build_app( from . import _fwlog as _fwlog_mod from . import _history as _history_mod from . import _reproducer as _reproducer_mod + from . import _uicap as _uicap_mod # ---------------- Messages ---------------- @@ -548,6 +549,16 @@ def __init__(self, line: str) -> None: self.line = line super().__init__() + class UiCaptureLine(tx.Message): + """Live line from the UI-tier camera transcript — one per + `frame_capture()` call. Posted only when the camera panel is + enabled via `MESHTASTIC_UI_TUI_CAMERA=1`.""" + + def __init__(self, test_id: str, line: str) -> None: + self.test_id = test_id + self.line = line + super().__init__() + class DeviceSnapshot(tx.Message): def __init__(self, rows: list[DeviceRow]) -> None: self.rows = rows @@ -871,6 +882,10 @@ class TestTuiApp(tx.App): #pytest-pane { height: 50%; border-bottom: solid $primary-background; } #fwlog-header { height: 1; padding: 0 1; background: $panel; } #fwlog-pane { height: 1fr; } + #uicap-header { height: 1; padding: 0 1; background: $boost; } + #uicap-pane { height: 14; border-top: solid $primary-background; } + #uicap-image { width: 36; border-right: solid $primary-background; padding: 0 1; } + #uicap-log { width: 1fr; height: 14; } Tree { height: 100%; } RichLog { height: 100%; } #device-table { height: auto; max-height: 6; } @@ -912,6 +927,11 @@ def __init__(self) -> None: self._device_worker: DevicePollerWorker | None = None self._fwlog_worker: _fwlog_mod.FirmwareLogTailer | None = None self._flashlog_worker: _flashlog_mod.FlashLogTailer | None = None + self._uicap_worker: _uicap_mod.UiCaptureTailer | None = None + # Env-gated; only mounts the UI-capture panel when operator asks for it. + self._ui_camera_enabled = bool( + int(os.environ.get("MESHTASTIC_UI_TUI_CAMERA", "0") or "0") + ) self._tree_filter: str = "" self._sigint_count = 0 # Firmware-log port filter: None = all, else exact port match. @@ -959,6 +979,22 @@ def compose(self) -> Any: wrap=True, max_lines=5000, ) + if self._ui_camera_enabled: + yield tx.Static( + "UI camera — latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)", + id="uicap-header", + ) + with tx.Horizontal(id="uicap-pane"): + yield tx.Static( + "(waiting…)", id="uicap-image", markup=False + ) + yield tx.RichLog( + id="uicap-log", + highlight=False, + markup=False, + wrap=True, + max_lines=500, + ) yield tx.DataTable(id="device-table", show_cursor=False) yield tx.Footer() @@ -1023,6 +1059,21 @@ def on_mount(self) -> None: stop=self._stop, ) self._flashlog_worker.start() + # UI-capture transcript tailer — only runs when the camera panel + # is enabled. Watches tests/ui_captures/**/transcript.md for new + # lines as UI tests execute. + if self._ui_camera_enabled: + captures_root = self._root / "mcp-server" / "tests" / "ui_captures" + # When the TUI is launched from inside mcp-server (the usual + # case), `self._root` is already mcp-server/, so adjust: + if not captures_root.parent.name == "mcp-server": + captures_root = self._root / "tests" / "ui_captures" + self._uicap_worker = _uicap_mod.UiCaptureTailer( + root=captures_root, + post=lambda tid, line: self.post_message(UiCaptureLine(tid, line)), + stop=self._stop, + ) + self._uicap_worker.start() self._spawn_pytest(self._pytest_args) # Header tick (seed / runtime / sparkline re-renders at 1 Hz). # Also refreshes the device-status column so the per-test elapsed @@ -1217,6 +1268,84 @@ def on_flash_log_line(self, message: Any) -> None: log = self.query_one("#pytest-log", tx.RichLog) log.write(f"[flash] {message.line}") + def on_ui_capture_line(self, message: Any) -> None: + """Route a UI-capture transcript line into the camera panel. + + Each line is already formatted by frame_capture — e.g. + `1. **initial** — frame 2/8 name=home — OCR: ...`. We write + the text into the RichLog AND try to render the corresponding + PNG on the left side (requires rich-pixels, Pillow). + """ + if not self._ui_camera_enabled: + return + try: + log_panel = self.query_one("#uicap-log", tx.RichLog) + except Exception: + return + log_panel.write(f"[{message.test_id}] {message.line}") + self._render_latest_ui_capture(message.test_id, message.line) + + def _render_latest_ui_capture(self, test_id: str, line: str) -> None: + """Find the PNG that corresponds to `line` and render it on the + left of the uicap pane. Soft-fails if rich-pixels isn't + installed or the PNG isn't found — operator still has the text + transcript on the right. + """ + try: + from PIL import Image # type: ignore[import-untyped] + from rich_pixels import Pixels # type: ignore[import-untyped] + except ImportError: + return + + # Transcript lines look like `1. **label** — ...`. Pull the leading + # integer to locate the capture file. + import re as _re + + m = _re.match(r"\s*(\d+)\.\s", line) + if not m: + return + step = int(m.group(1)) + + # Captures directory is sibling of tests/ — mirror the path the + # tailer watches. Search both likely layouts (in-mcp-server vs. + # firmware-root invocation). + candidates = [ + self._root / "tests" / "ui_captures", + self._root / "mcp-server" / "tests" / "ui_captures", + ] + captures_root = next((p for p in candidates if p.is_dir()), None) + if captures_root is None: + return + + # Drill into // — test_id is the + # sanitized nodeid the tailer already passed through. + matches = list(captures_root.rglob(f"{test_id}/{step:03d}-*.png")) + if not matches: + return + png_path = matches[-1] + + try: + img = Image.open(png_path).convert("RGB") + # Resize to fit ~32 cells wide × ~12 rows tall (half-block + # renderer gives 2× vertical resolution, so 32×24 px input + # lands at ~32×12 cells). Keep aspect ratio. + target_w = 60 + w, h = img.size + target_h = max(1, int(h * (target_w / max(1, w)))) + # Clamp: the image panel is 14 rows; half-blocks give 2 rows + # per vertical cell, so cap pixel height at ~26. + target_h = min(target_h, 26) + img = img.resize((target_w, target_h)) + pixels = Pixels.from_image(img) + except Exception: + return + + try: + image_widget = self.query_one("#uicap-image", tx.Static) + image_widget.update(pixels) + except Exception: + pass + def on_firmware_log_line(self, message: Any) -> None: rec = message.record port = rec.get("port") diff --git a/mcp-server/src/meshtastic_mcp/config.py b/mcp-server/src/meshtastic_mcp/config.py index 7ece1003290..d3006a73ee3 100644 --- a/mcp-server/src/meshtastic_mcp/config.py +++ b/mcp-server/src/meshtastic_mcp/config.py @@ -135,3 +135,14 @@ def picotool_bin() -> Path: ("picotool",), "Install via `brew install picotool` or build from https://github.com/raspberrypi/picotool.", ) + + +def uhubctl_bin() -> Path: + return _hw_tool( + "MESHTASTIC_UHUBCTL_BIN", + ("uhubctl",), + "Install via `brew install uhubctl` (macOS) or `apt install uhubctl` " + "(Debian/Ubuntu). On Linux without the udev rules, or on older macOS " + "with certain hubs, you may need to run via `sudo`: " + "https://github.com/mvp/uhubctl#linux-usb-permissions", + ) diff --git a/mcp-server/src/meshtastic_mcp/input_events.py b/mcp-server/src/meshtastic_mcp/input_events.py new file mode 100644 index 00000000000..f6b287ba3e8 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/input_events.py @@ -0,0 +1,67 @@ +"""Python mirror of firmware `enum input_broker_event` (src/input/InputBroker.h). + +Used by `admin.send_input_event` + `tests/ui/` so callers can say +`InputEventCode.RIGHT` instead of hard-coding 20. Values MUST stay in sync +with the firmware enum — unit test `tests/unit/test_input_event_codes.py` +pins the mapping. +""" + +from __future__ import annotations + +from enum import IntEnum + + +class InputEventCode(IntEnum): + """Button / key / gesture events dispatched by the firmware InputBroker.""" + + NONE = 0 + SELECT = 10 + SELECT_LONG = 11 + UP_LONG = 12 + DOWN_LONG = 13 + UP = 17 + DOWN = 18 + LEFT = 19 + RIGHT = 20 + CANCEL = 24 + BACK = 27 + # Auto-incremented values in the C enum (27 + 1, +2, +3): + USER_PRESS = 28 + ALT_PRESS = 29 + ALT_LONG = 30 + SHUTDOWN = 0x9B + GPS_TOGGLE = 0x9E + SEND_PING = 0xAF + FN_F1 = 0xF1 + FN_F2 = 0xF2 + FN_F3 = 0xF3 + FN_F4 = 0xF4 + FN_F5 = 0xF5 + MATRIXKEY = 0xFE + ANYKEY = 0xFF + + +def coerce_event_code(value: int | str | InputEventCode) -> int: + """Accept an int, a case-insensitive name, or an `InputEventCode` and return + the u8 wire value. Raises ValueError on unknown names / out-of-range ints. + """ + if isinstance(value, InputEventCode): + return int(value) + if isinstance(value, int): + if not 0 <= value <= 255: + raise ValueError(f"event_code out of u8 range: {value}") + return value + if isinstance(value, str): + key = value.upper().replace("-", "_") + if key.startswith("INPUT_BROKER_"): + key = key[len("INPUT_BROKER_") :] + try: + return int(InputEventCode[key]) + except KeyError as exc: + known = ", ".join(m.name for m in InputEventCode) + raise ValueError( + f"unknown event code name {value!r}; known: {known}" + ) from exc + raise TypeError( + f"event_code must be int|str|InputEventCode, got {type(value).__name__}" + ) diff --git a/mcp-server/src/meshtastic_mcp/ocr.py b/mcp-server/src/meshtastic_mcp/ocr.py new file mode 100644 index 00000000000..f40bc3cbd9e --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/ocr.py @@ -0,0 +1,147 @@ +"""OCR wrapper for UI tests + the `capture_screen` tool. + +Auto-selects a reader in priority order: + 1. `easyocr` (deep-learning, high quality on OLED screens — but ~100 MB + model download on first use). + 2. `pytesseract` (requires system `tesseract` binary on PATH). + 3. `null` — returns `""` with a warning. Tests fall back to log + image + evidence when OCR is unavailable. + +Override via `MESHTASTIC_UI_OCR_BACKEND=easyocr|pytesseract|null|auto` +(default `auto`). + +`ocr_text(png_bytes) -> str` is the only public entry point. The reader is +constructed lazily on first call and cached, so the easyocr cold-start cost +only hits once per process. +""" + +from __future__ import annotations + +import functools +import logging +import os +import shutil +import sys +from typing import Callable + +log = logging.getLogger(__name__) + + +def _backend_choice() -> str: + return os.environ.get("MESHTASTIC_UI_OCR_BACKEND", "auto").lower() + + +@functools.lru_cache(maxsize=1) +def _reader() -> tuple[str, Callable[[bytes], str]]: + """Return `(backend_name, callable)` for whichever OCR is available.""" + choice = _backend_choice() + + def _easyocr() -> tuple[str, Callable[[bytes], str]]: + import easyocr # type: ignore[import-untyped] # noqa: PLC0415 + import numpy as np # type: ignore[import-untyped] # noqa: PLC0415 + + reader = easyocr.Reader(["en"], gpu=False, verbose=False) + + def _run(png: bytes) -> str: + try: + import cv2 # type: ignore[import-untyped] # noqa: PLC0415 + + arr = np.frombuffer(png, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + except ImportError: + # Fall back to PIL if cv2 isn't around. + from io import BytesIO # noqa: PLC0415 + + from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415 + + img = np.array(Image.open(BytesIO(png)).convert("RGB")) + try: + results = reader.readtext(img, detail=0, paragraph=True) + except Exception as exc: # noqa: BLE001 + log.warning("easyocr failed: %s", exc) + return "" + return "\n".join(str(r) for r in results) + + return "easyocr", _run + + def _pytesseract() -> tuple[str, Callable[[bytes], str]]: + from io import BytesIO # noqa: PLC0415 + + import pytesseract # type: ignore[import-untyped] # noqa: PLC0415 + from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415 + + if shutil.which("tesseract") is None: + raise ImportError("`tesseract` binary not on PATH") + + def _run(png: bytes) -> str: + try: + return str(pytesseract.image_to_string(Image.open(BytesIO(png)))) + except Exception as exc: # noqa: BLE001 + log.warning("pytesseract failed: %s", exc) + return "" + + return "pytesseract", _run + + def _null() -> tuple[str, Callable[[bytes], str]]: + log.warning( + "OCR backend is null; install easyocr or tesseract for text extraction" + ) + return "null", lambda _png: "" + + if choice == "easyocr": + return _easyocr() + if choice == "pytesseract": + return _pytesseract() + if choice == "null": + return _null() + if choice != "auto": + print( + f"[ocr] unknown MESHTASTIC_UI_OCR_BACKEND={choice!r}; falling back to auto", + file=sys.stderr, + ) + + # auto mode + try: + return _easyocr() + except ImportError: + pass + try: + return _pytesseract() + except ImportError: + pass + return _null() + + +def ocr_text(png_bytes: bytes) -> str: + """Run OCR on a PNG-encoded image and return the decoded text (possibly empty).""" + if not png_bytes: + return "" + _, run = _reader() + return run(png_bytes) + + +def backend_name() -> str: + """Return the currently-selected backend name, initializing if necessary.""" + name, _ = _reader() + return name + + +def warm() -> None: + """Run one dummy inference so the easyocr cold-start cost is paid upfront. + + Pytest session fixture calls this once so the first real capture doesn't + eat the model-load latency. + """ + # A 64×32 white PNG — decodes clean, no text to extract. + white_png = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000400000002008060000007ccac28e" + "0000001c49444154785eedc1010d000000c2a0f74f6d0d370000000000000080" + "0b010000ffff030000000000000049454e44ae426082" + ) + try: + ocr_text(white_png) + except Exception as exc: # noqa: BLE001 + log.warning("ocr.warm() failed: %s", exc) + + +__all__ = ["backend_name", "ocr_text", "warm"] diff --git a/mcp-server/src/meshtastic_mcp/server.py b/mcp-server/src/meshtastic_mcp/server.py index 7cb8db65e3d..83aa80c457f 100644 --- a/mcp-server/src/meshtastic_mcp/server.py +++ b/mcp-server/src/meshtastic_mcp/server.py @@ -1,4 +1,4 @@ -"""FastMCP server wiring — 38 tools across 7 categories. +"""FastMCP server wiring — 43 tools across 9 categories (adds uhubctl power control). Each tool handler is a thin delegation to a named module (pio.py, admin.py, etc.). Business logic does not live here. @@ -513,6 +513,152 @@ def factory_reset( return admin.factory_reset(port=port, confirm=confirm, full=full) +@app.tool() +def send_input_event( + event_code: int | str, + kb_char: int = 0, + touch_x: int = 0, + touch_y: int = 0, + port: str | None = None, +) -> dict[str, Any]: + """Inject an InputBroker event (button / key / gesture) into the device UI. + + Drives the same code path as a physical button press. Accepts a numeric + event code (0..255) or a name like `"RIGHT"`, `"SELECT"`, `"FN_F1"`. + + Common codes: SELECT=10, UP=17, DOWN=18, LEFT=19, RIGHT=20, CANCEL=24, + BACK=27, FN_F1..F5=241..245. + """ + return admin.send_input_event( + event_code=event_code, + kb_char=kb_char, + touch_x=touch_x, + touch_y=touch_y, + port=port, + ) + + +@app.tool() +def capture_screen(role: str | None = None, ocr: bool = True) -> dict[str, Any]: + """Grab a frame from the USB webcam pointed at the device screen. + + Returns PNG bytes (base64), optional OCR text, and backend metadata. + Requires the `[ui]` extras (opencv-python-headless) and a camera + configured via `MESHTASTIC_UI_CAMERA_DEVICE[_]`. Falls back to a + 1×1 black PNG from the null backend when no camera is configured. + """ + import base64 + + from . import camera as camera_mod + + cam = camera_mod.get_camera(role) + try: + png = cam.capture() + finally: + cam.close() + + result: dict[str, Any] = { + "backend": cam.name, + "bytes": len(png), + "image_base64": base64.b64encode(png).decode("ascii"), + } + if ocr: + from . import ocr as ocr_mod + + result["ocr_backend"] = ocr_mod.backend_name() + result["ocr_text"] = ocr_mod.ocr_text(png) + return result + + +# ---------- USB power control (uhubctl) ----------------------------------- + + +@app.tool() +def uhubctl_list() -> list[dict[str, Any]]: + """List every USB hub + per-port device attachment as seen by `uhubctl`. + + Read-only — no confirm required. Each hub entry includes its location + (`1-1.3`), descriptor, whether it supports Per-Port Power Switching, + and a list of populated ports with VID:PID of attached devices. + Useful for pre-flight checks before a destructive power-cycle call. + """ + from . import uhubctl as uhubctl_mod + + return uhubctl_mod.list_hubs() + + +@app.tool() +def uhubctl_power( + action: str, + location: str | None = None, + port: int | None = None, + role: str | None = None, + confirm: bool = False, +) -> dict[str, Any]: + """Power a USB hub port on or off via `uhubctl -a on|off`. + + Target the port by either (`location`, `port`) — raw uhubctl syntax, + e.g. `location="1-1.3", port=2` — OR by `role` ("nrf52", "esp32s3"). + Role lookup honors `MESHTASTIC_UHUBCTL_LOCATION_` + + `_PORT_` env vars first, falls back to VID auto-detection. + + `action="off"` requires `confirm=True` (destructive — the attached + device will immediately disappear from the OS). + """ + from . import uhubctl as uhubctl_mod + + action_lower = action.lower() + if action_lower not in {"on", "off"}: + raise ValueError(f"action must be 'on' or 'off', got {action!r}") + if action_lower == "off" and not confirm: + raise uhubctl_mod.UhubctlError( + "uhubctl_power action='off' requires confirm=True" + ) + loc, p = _resolve_uhubctl_target(location, port, role) + if action_lower == "on": + return uhubctl_mod.power_on(loc, p) + return uhubctl_mod.power_off(loc, p) + + +@app.tool() +def uhubctl_cycle( + location: str | None = None, + port: int | None = None, + role: str | None = None, + delay_s: int = 2, + confirm: bool = False, +) -> dict[str, Any]: + """Power a USB hub port off, wait `delay_s` seconds, then on. + + The typical hard-reset sequence — shorter than off+on as two RPCs + because uhubctl handles the timing in-process. Target by (location, + port) or by role (see `uhubctl_power`). Requires `confirm=True`. + """ + from . import uhubctl as uhubctl_mod + + if not confirm: + raise uhubctl_mod.UhubctlError("uhubctl_cycle requires confirm=True") + if delay_s < 0 or delay_s > 60: + raise ValueError(f"delay_s must be 0..60, got {delay_s}") + loc, p = _resolve_uhubctl_target(location, port, role) + return uhubctl_mod.cycle(loc, p, delay_s=delay_s) + + +def _resolve_uhubctl_target( + location: str | None, port: int | None, role: str | None +) -> tuple[str, int]: + """Shared arg-resolution for uhubctl_power + uhubctl_cycle.""" + from . import uhubctl as uhubctl_mod + + if role is not None: + if location is not None or port is not None: + raise ValueError("pass either `role` OR (`location` + `port`), not both") + return uhubctl_mod.resolve_target(role) + if location is None or port is None: + raise ValueError("must pass `role` or both `location` and `port`") + return (location, int(port)) + + # ---------- Direct hardware tools ----------------------------------------- diff --git a/mcp-server/src/meshtastic_mcp/uhubctl.py b/mcp-server/src/meshtastic_mcp/uhubctl.py new file mode 100644 index 00000000000..e0306386b0f --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/uhubctl.py @@ -0,0 +1,321 @@ +"""USB hub power control via `uhubctl` — hard-recovery for wedged devices + +deliberate offline-peer simulation for mesh tests. + +Why: when a Meshtastic device's serial port wedges (stuck in a boot loop, +frozen USB CDC, crashed firmware that didn't reboot), the only recovery is +a physical unplug. uhubctl toggles VBUS per-port on any hub with Per-Port +Power Switching (PPPS) support — which is most externally-powered hubs +from the last ~5 years — so the harness can power-cycle a device +programmatically. + +Architecture: +- `list_hubs()` parses `uhubctl` default output into structured records. +- `find_port_for_vid(vid)` walks the hubs to find which location+port + hosts a given USB VID. +- `resolve_target(role)` is the public entry for callers that know a role + (`nrf52`, `esp32s3`) but not a hub location: env-var pins win, VID + auto-detect falls back. +- `power_on`, `power_off`, `cycle` wrap the corresponding `uhubctl -a` + invocations, routed through `hw_tools._run` so they share tee-to-flash- + log + timeout handling with esptool / nrfutil / picotool. + +Sudo policy: **fail fast**. Modern macOS + most PPPS-capable hubs work +without root, but Linux without udev rules (or old macOS with specific +driver quirks) still needs it. We run uhubctl non-root; if stderr +matches the classic permission pattern we raise `UhubctlError` with an +install hint pointing at the uhubctl docs. Auto-wrapping with `sudo` +would prompt in the middle of test runs — bad for CI. +""" + +from __future__ import annotations + +import os +import re +from typing import Any, Sequence + +from . import config, hw_tools + +# ---------- Parser --------------------------------------------------------- + +# Hub descriptor line: +# Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps] +_HUB_RE = re.compile( + r"^Current status for hub (?P\S+)\s+\[(?P.+)\]\s*$" +) + +# Port line: +# " Port 2: 0103 power enable connect [239a:8029 RAKwireless ...]" +# The bracketed section is absent for empty ports. +_PORT_RE = re.compile( + r"^\s+Port\s+(?P\d+):\s+(?P\S+)\s+(?P.*?)" + r"(?:\s+\[(?P[0-9a-fA-F]{4}):(?P[0-9a-fA-F]{4})(?:\s+(?P.+))?\])?\s*$" +) + + +class UhubctlError(RuntimeError): + """Raised on uhubctl-specific failures: parse errors, permission denied, + hub-not-found, or PPPS not supported.""" + + +# ---------- Role → VID map ------------------------------------------------- + +# Mirrors the default hub_profile in `mcp-server/tests/conftest.py:335`. +# Note: esp32s3 and esp32s3_alt share a logical role — we search both. +ROLE_VIDS: dict[str, tuple[int, ...]] = { + "nrf52": (0x239A,), + "esp32s3": (0x303A, 0x10C4), +} + + +def _normalize_role(role: str) -> str: + """Collapse `esp32s3_alt` → `esp32s3` to match the tier conventions.""" + return role.split("_alt", 1)[0].lower() + + +# ---------- Core subprocess runner ----------------------------------------- + + +# If uhubctl hits a permission problem — most commonly Linux without the +# udev rules, or a macOS variant where the kernel holds the hub driver — +# it prints something like "Permission denied. Try running as root". +# Linux error text varies; we match a broad substring rather than exact. +_PERM_ERROR_PATTERNS = ( + "permission denied", + "operation not permitted", + "try running as root", + "need root", + "requires root", +) + + +def _run_uhubctl(args: Sequence[str], *, timeout: float = 30.0) -> dict[str, Any]: + """Invoke uhubctl with the given args. Returns `hw_tools._run`'s dict. + + Translates permission-denied failures into a `UhubctlError` with the + install hint, so callers don't have to match stderr themselves. Other + non-zero exits are returned as-is for the caller to interpret. + """ + binary = config.uhubctl_bin() + result = hw_tools._run(binary, args, timeout=timeout) # noqa: SLF001 + if result["exit_code"] != 0: + combined = (result.get("stderr") or "") + "\n" + (result.get("stdout") or "") + lower = combined.lower() + if any(pat in lower for pat in _PERM_ERROR_PATTERNS): + raise UhubctlError( + "uhubctl exited with a permission error. Install the udev " + "rules on Linux, or try `sudo` as a fallback: " + "https://github.com/mvp/uhubctl#linux-usb-permissions\n" + f"stderr: {result.get('stderr_tail')!r}" + ) + return result + + +# ---------- List / parse --------------------------------------------------- + + +def parse_list_output(output: str) -> list[dict[str, Any]]: + """Parse the default `uhubctl` stdout into structured hubs. + + Each hub: { + "location": "1-1.3", + "descriptor": "2109:2817 VIA Labs ...", + "vid": 0x2109, + "pid": 0x2817, + "ppps": bool, + "ports": [{"port": int, "status": str, "flags": str, + "device_vid": int | None, "device_pid": int | None, + "device_desc": str | None}, ...], + } + """ + hubs: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + + for line in output.splitlines(): + hm = _HUB_RE.match(line) + if hm: + descriptor = hm.group("descriptor") + hub_vid, hub_pid = None, None + vid_match = re.match(r"([0-9a-fA-F]{4}):([0-9a-fA-F]{4})", descriptor) + if vid_match: + hub_vid = int(vid_match.group(1), 16) + hub_pid = int(vid_match.group(2), 16) + current = { + "location": hm.group("location"), + "descriptor": descriptor, + "vid": hub_vid, + "pid": hub_pid, + "ppps": ", ppps" in descriptor or descriptor.endswith("ppps"), + "ports": [], + } + hubs.append(current) + continue + + pm = _PORT_RE.match(line) + if pm and current is not None: + device_vid = pm.group("device_vid") + device_pid = pm.group("device_pid") + current["ports"].append( + { + "port": int(pm.group("port")), + "status": pm.group("status"), + "flags": (pm.group("flags") or "").strip(), + "device_vid": int(device_vid, 16) if device_vid else None, + "device_pid": int(device_pid, 16) if device_pid else None, + "device_desc": (pm.group("device_desc") or "").strip() or None, + } + ) + return hubs + + +def list_hubs() -> list[dict[str, Any]]: + """Enumerate every hub uhubctl can see, with per-port device attachments. + + Pure read — no power state changes. Useful as a pre-flight check before + a destructive `power_off` call. + """ + result = _run_uhubctl([], timeout=15.0) + if result["exit_code"] != 0: + raise UhubctlError( + f"uhubctl list failed (exit {result['exit_code']}): {result.get('stderr_tail')!r}" + ) + return parse_list_output(result["stdout"]) + + +# ---------- Lookup / resolution ------------------------------------------- + + +def find_port_for_vid( + vid: int, pid: int | None = None, *, only_ppps: bool = True +) -> list[tuple[str, int]]: + """Return ALL (location, port) matches for a device VID (optionally +PID). + + `only_ppps=True` filters out hubs that don't advertise PPPS — we can't + control them anyway. Callers that want to diagnose a missing device can + pass `only_ppps=False` to see if the device is on a non-controllable + hub (and raise a clearer error). + """ + hubs = list_hubs() + matches: list[tuple[str, int]] = [] + for hub in hubs: + if only_ppps and not hub["ppps"]: + continue + for port in hub["ports"]: + if port["device_vid"] != vid: + continue + if pid is not None and port["device_pid"] != pid: + continue + matches.append((hub["location"], port["port"])) + return matches + + +def resolve_target(role: str) -> tuple[str, int]: + """Resolve a Meshtastic role to (hub_location, port_number). + + Priority: + 1. Env vars `MESHTASTIC_UHUBCTL_LOCATION_` + `_PORT_` + (e.g. `MESHTASTIC_UHUBCTL_LOCATION_NRF52=1-1.3`, `_PORT_NRF52=2`). + 2. VID auto-detect against `ROLE_VIDS[role]`, taking the first PPPS + match. + + Raises `UhubctlError` on ambiguity (multiple matches) or no-match. The + env-var path exists specifically to disambiguate when two devices share + a VID. + """ + role = _normalize_role(role) + env_key_loc = f"MESHTASTIC_UHUBCTL_LOCATION_{role.upper()}" + env_key_port = f"MESHTASTIC_UHUBCTL_PORT_{role.upper()}" + loc = os.environ.get(env_key_loc) + port_str = os.environ.get(env_key_port) + if loc and port_str: + try: + return (loc, int(port_str)) + except ValueError as exc: + raise UhubctlError( + f"{env_key_port}={port_str!r} is not a valid integer" + ) from exc + + if role not in ROLE_VIDS: + raise UhubctlError( + f"unknown role {role!r}; known roles: {sorted(ROLE_VIDS)}. " + f"Set {env_key_loc} + {env_key_port} to pin manually." + ) + + matches: list[tuple[str, int]] = [] + for vid in ROLE_VIDS[role]: + matches.extend(find_port_for_vid(vid)) + + if not matches: + vids = ", ".join(f"0x{v:04x}" for v in ROLE_VIDS[role]) + raise UhubctlError( + f"no controllable hub hosts a device with VID in {{{vids}}} " + f"for role={role!r}. Check the device is plugged into a " + f"PPPS-capable hub, or pin manually via {env_key_loc} + {env_key_port}." + ) + if len(matches) > 1: + shown = ", ".join(f"{loc}:port{p}" for loc, p in matches) + raise UhubctlError( + f"ambiguous: multiple devices match role={role!r} ({shown}). " + f"Pin the target via {env_key_loc} + {env_key_port}." + ) + return matches[0] + + +# ---------- Power actions -------------------------------------------------- + + +def _action( + action: str, + location: str, + port: int, + *, + delay_s: int | None = None, + timeout: float = 30.0, +) -> dict[str, Any]: + args: list[str] = ["-a", action, "-l", location, "-p", str(port)] + if delay_s is not None: + args.extend(["-d", str(delay_s)]) + # Suppress verbose "before" printout so our parser doesn't have to skip it. + args.append("-N") + result = _run_uhubctl(args, timeout=timeout) + if result["exit_code"] != 0: + raise UhubctlError( + f"uhubctl -a {action} -l {location} -p {port} failed " + f"(exit {result['exit_code']}): {result.get('stderr_tail')!r}" + ) + return { + "action": action, + "location": location, + "port": port, + "delay_s": delay_s, + "duration_s": result["duration_s"], + } + + +def power_on(location: str, port: int) -> dict[str, Any]: + """Drive the port VBUS high. Device re-enumerates in 1-3 s on healthy hubs.""" + return _action("on", location, port) + + +def power_off(location: str, port: int) -> dict[str, Any]: + """Drive the port VBUS low. Device disappears from `list_devices` immediately.""" + return _action("off", location, port) + + +def cycle(location: str, port: int, delay_s: int = 2) -> dict[str, Any]: + """Off → wait `delay_s` → on. The common hard-reset pattern.""" + # uhubctl's own `-a cycle` handles the delay internally; we use a + # slightly longer timeout to accommodate delay_s + enumeration. + return _action("cycle", location, port, delay_s=delay_s, timeout=30.0 + delay_s * 2) + + +__all__ = [ + "ROLE_VIDS", + "UhubctlError", + "cycle", + "find_port_for_vid", + "list_hubs", + "parse_list_output", + "power_off", + "power_on", + "resolve_target", +] diff --git a/mcp-server/src/meshtastic_mcp/userprefs.py b/mcp-server/src/meshtastic_mcp/userprefs.py index 59d7165f972..d5f8bab6948 100644 --- a/mcp-server/src/meshtastic_mcp/userprefs.py +++ b/mcp-server/src/meshtastic_mcp/userprefs.py @@ -393,6 +393,7 @@ def build_testing_profile( long_name: str | None = None, disable_mqtt: bool = True, disable_position: bool = False, + enable_ui_log: bool = False, ) -> dict[str, Any]: """Build a USERPREFS dict for an isolated test-mesh device. @@ -423,6 +424,10 @@ def build_testing_profile( traffic never leaks to a public broker. disable_position: if True, disables GPS + position broadcasts — useful when test devices sit on a bench without antennas. + enable_ui_log: if True, stamps `USERPREFS_UI_TEST_LOG=true` so the + firmware emits one `Screen: frame N/M name=... reason=...` log + line per frame transition. Test-only; off by default because the + log is chatty (multiple times per second during UI interaction). """ if region not in KNOWN_REGIONS: @@ -475,6 +480,9 @@ def build_testing_profile( prefs["USERPREFS_CONFIG_OWNER_LONG_NAME"] = long_name if short_name is not None: prefs["USERPREFS_CONFIG_OWNER_SHORT_NAME"] = short_name + if enable_ui_log: + # Consumed by `#ifdef USERPREFS_UI_TEST_LOG` in src/graphics/Screen.cpp. + prefs["USERPREFS_UI_TEST_LOG"] = True return prefs diff --git a/mcp-server/tests/_power.py b/mcp-server/tests/_power.py new file mode 100644 index 00000000000..0542bbed385 --- /dev/null +++ b/mcp-server/tests/_power.py @@ -0,0 +1,112 @@ +"""USB hub power control for tests — thin composition of the `uhubctl` +module + `_port_discovery.resolve_port_by_role`. + +Why separate from the production module: +- `meshtastic_mcp.uhubctl.cycle` returns as soon as uhubctl exits (VBUS is + back on, but the device hasn't finished enumerating as a CDC port yet). +- Tests that want to immediately issue a `connect(port=...)` need the NEW + `/dev/cu.*` path, which can differ from the pre-cycle path on nRF52 + boards (CDC re-enumeration assigns a fresh `cu.usbmodemNNNN`). +- `resolve_port_by_role` already handles that wait + path-resolution for + the `factory_reset` flow. Composing the two gives a one-call helper. + +Also exposes `is_uhubctl_available()` so fixtures can skip cleanly when +uhubctl isn't installed — we never want "no uhubctl" to look like a test +failure. +""" + +from __future__ import annotations + +import time +from typing import Any + +from meshtastic_mcp import config as config_mod +from meshtastic_mcp import uhubctl as uhubctl_mod + +from ._port_discovery import resolve_port_by_role + + +def is_uhubctl_available() -> bool: + """Return True iff `config.uhubctl_bin()` resolves AND the binary is callable. + + Soft-fails silently — fixtures use this to `pytest.skip` with an + actionable message when the operator hasn't installed uhubctl. + """ + try: + config_mod.uhubctl_bin() + except Exception: # noqa: BLE001 + return False + # Do NOT actually invoke uhubctl here — on macOS a non-sudo run would + # fail, which is a config issue, not a tool-missing issue. That gets + # surfaced to the user when they actually run a recovery action. + return True + + +def power_on(role: str) -> dict[str, Any]: + """Power on the hub port hosting `role`. Does NOT wait for re-enumeration. + Use `power_cycle` or follow with `resolve_port_by_role` to block on readiness. + """ + loc, port = uhubctl_mod.resolve_target(role) + return uhubctl_mod.power_on(loc, port) + + +def power_off(role: str) -> dict[str, Any]: + """Power off the hub port hosting `role`. The device disappears from + `list_devices` immediately. + """ + loc, port = uhubctl_mod.resolve_target(role) + return uhubctl_mod.power_off(loc, port) + + +def power_cycle( + role: str, + *, + delay_s: int = 2, + rediscover_timeout_s: float = 30.0, +) -> str: + """Cycle the port hosting `role`, wait for re-enumeration, return the + new port path. + + On nRF52 the post-cycle path typically matches the pre-cycle path, but + macOS may assign a different `/dev/cu.usbmodemNNNN` if the previous + CDC endpoint hasn't been fully released. `resolve_port_by_role` + handles that transparently. + """ + loc, port = uhubctl_mod.resolve_target(role) + uhubctl_mod.cycle(loc, port, delay_s=delay_s) + # After uhubctl exits, VBUS is on but the device may still be in + # bootloader init. Give it ~500 ms head-start before polling so we + # don't spam list_devices pointlessly. + time.sleep(0.5) + return resolve_port_by_role(role, timeout_s=rediscover_timeout_s) + + +def wait_for_absence(role: str, *, timeout_s: float = 10.0) -> None: + """Block until a device matching `role` is NOT in `list_devices`. + + Used by the recovery tier to assert power_off actually took effect. + Raises TimeoutError on failure. + """ + from meshtastic_mcp import devices as devices_mod + + from ._port_discovery import _ROLE_VIDS, _coerce_vid # type: ignore[attr-defined] + + if role not in _ROLE_VIDS: + raise ValueError(f"unknown role {role!r}") + wanted = _ROLE_VIDS[role] + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + found = devices_mod.list_devices(include_unknown=True) + if not any(_coerce_vid(d.get("vid")) in wanted for d in found): + return + time.sleep(0.3) + raise TimeoutError(f"role {role!r} still visible after {timeout_s}s of power_off") + + +__all__ = [ + "is_uhubctl_available", + "power_cycle", + "power_off", + "power_on", + "wait_for_absence", +] diff --git a/mcp-server/tests/conftest.py b/mcp-server/tests/conftest.py index 3d033b9b861..d4607b239ef 100644 --- a/mcp-server/tests/conftest.py +++ b/mcp-server/tests/conftest.py @@ -123,15 +123,24 @@ def sort_key(item: pytest.Item) -> tuple[int, str]: return (2, item.nodeid) if "/monitor/" in path or "tests/monitor" in path: return (3, item.nodeid) - if "/fleet/" in path or "tests/fleet" in path: + # Recovery tier: explicitly cycles device power via uhubctl. Slots + # between monitor (read-only) and ui (state-preserving) so any tier + # after it starts from a known re-enumerated + re-verified state. + if "/recovery/" in path or "tests/recovery" in path: return (4, item.nodeid) + # UI tier slots here — read-only w.r.t. mesh state, only mutates + # the on-screen UI (BACK×5 guard restores home before each test). + if "/ui/" in path or "tests/ui" in path: + return (5, item.nodeid) + if "/fleet/" in path or "tests/fleet" in path: + return (6, item.nodeid) # State-mutating tiers run last. if "/admin/" in path or "tests/admin" in path: - return (5, item.nodeid) + return (7, item.nodeid) if "/provisioning/" in path or "tests/provisioning" in path: - return (6, item.nodeid) + return (8, item.nodeid) # Top-level + anything else falls between. - return (7, item.nodeid) + return (9, item.nodeid) items.sort(key=sort_key) @@ -156,13 +165,20 @@ def session_seed(request: pytest.FixtureRequest) -> str: @pytest.fixture(scope="session") def test_profile(session_seed: str) -> dict[str, Any]: - """The canonical isolated-mesh test profile for this session.""" + """The canonical isolated-mesh test profile for this session. + + `enable_ui_log=True` stamps `USERPREFS_UI_TEST_LOG` so the firmware + emits `Screen: frame N/M name=... reason=...` log lines per UI + transition — consumed by the `tests/ui/` tier. Harmless on boards + without a screen (the `#ifdef` sits behind `HAS_SCREEN`). + """ return userprefs.build_testing_profile( psk_seed=session_seed, channel_name="McpTest", channel_num=88, region="US", modem_preset="LONG_FAST", + enable_ui_log=True, ) @@ -654,6 +670,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: def baked_single( baked_mesh: dict[str, Any], baked_single_role: str, + hub_devices: dict[str, str], ) -> dict[str, Any]: """Function-scoped: a single verified baked device. @@ -662,10 +679,75 @@ def baked_single( (e.g. `test_owner_survives_reboot[nrf52]` + `test_owner_survives_reboot[esp32s3]`). Tests never hardcode a role and never skip a device that happens to be connected. + + Auto-recovery: if the baked device fails a pre-test `device_info` probe + AND uhubctl is available, power-cycle the port once and retry. Without + uhubctl, surface the wedge as a clear skip. This catches "device got + stuck between tests" without masking persistent regressions (a second + wedge after cycling still skips). """ if baked_single_role not in baked_mesh: pytest.skip(f"role {baked_single_role!r} not present on the hub") - return {"role": baked_single_role, **baked_mesh[baked_single_role]} + + entry = baked_mesh[baked_single_role] + port = entry.get("port") + if port: + try: + _run_with_timeout(lambda: info.device_info(port=port, timeout_s=3.0), 5.0) + except Exception: + # Device didn't respond. Try a power-cycle recovery if uhubctl + # is installed; otherwise surface a skip that names the root + # cause clearly. + from tests import _power + + if not _power.is_uhubctl_available(): + pytest.skip( + f"device {baked_single_role!r} unresponsive on {port}; " + "install uhubctl (`brew install uhubctl` / `apt install " + "uhubctl`) for auto power-cycle recovery" + ) + try: + new_port = _power.power_cycle(baked_single_role, delay_s=2) + except Exception as exc: # noqa: BLE001 + pytest.skip( + f"device {baked_single_role!r} wedged and power-cycle " + f"failed: {exc}" + ) + # Mutate both the session-scoped `hub_devices` map AND the + # baked_mesh entry so downstream fixtures see the recovered port. + hub_devices[baked_single_role] = new_port + baked_mesh[baked_single_role]["port"] = new_port + entry = baked_mesh[baked_single_role] + return {"role": baked_single_role, **entry} + + +@pytest.fixture +def power_cycle( + hub_devices: dict[str, str], +) -> Callable[..., str]: + """Return a callable `(role, delay_s=2) -> new_port` that hard-resets the + hub port hosting `role`. Skips the test cleanly when uhubctl isn't + installed — never want "no uhubctl" to look like a test failure. + + The callable mutates `hub_devices[role]` in place so subsequent fixture + lookups pick up the post-cycle port (mirrors the pattern in + provisioning/test_userprefs_survive_factory_reset.py). + """ + from tests import _power + + if not _power.is_uhubctl_available(): + pytest.skip( + "uhubctl not installed; this test needs it for power control. " + "Install via `brew install uhubctl` (macOS) or `apt install " + "uhubctl` (Debian/Ubuntu)." + ) + + def _cycle(role: str, delay_s: int = 2) -> str: + new_port = _power.power_cycle(role, delay_s=delay_s) + hub_devices[role] = new_port + return new_port + + return _cycle _DEFAULT_ROLE_ENVS = { @@ -960,6 +1042,45 @@ def _run_with_timeout(fn: Callable[[], Any], timeout: float) -> Any: raise TimeoutError(f"operation did not complete within {timeout}s") from exc +def _attach_ui_captures(item: pytest.Item, report: Any) -> None: + """Embed per-step UI captures (PNG + OCR) into the pytest-html extras. + + Runs for every UI-tier test on BOTH pass and fail so the HTML report + always shows the image strip + OCR transcript. Silently no-ops if + pytest-html isn't installed or the test didn't use `frame_capture`. + """ + captures = getattr(item, "_ui_captures", None) + if not captures: + return + try: + from pytest_html import extras as html_extras # type: ignore[import-untyped] + except ImportError: + return + + existing = getattr(report, "extras", None) or [] + extras_list = list(existing) + for cap in captures: + png_path = cap.get("png_path") + label = f"{cap.get('step', '?')}: {cap.get('label', '')}" + frame = cap.get("frame") or {} + frame_str = ( + f" — frame {frame.get('idx')} {frame.get('name')!r}" if frame else "" + ) + if png_path: + try: + with open(png_path, "rb") as fh: + import base64 + + b64 = base64.b64encode(fh.read()).decode("ascii") + extras_list.append(html_extras.png(b64, name=f"{label}{frame_str}")) + except OSError: + pass + ocr = (cap.get("ocr_text") or "").strip() + if ocr: + extras_list.append(html_extras.text(ocr, name=f"OCR: {label}{frame_str}")) + report.extras = extras_list # type: ignore[attr-defined] + + @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Any: """On test failure, attach serial capture + device state as report artifacts. @@ -967,10 +1088,20 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Hard-bounded by `_run_with_timeout` — if the device is unreachable (stuck port, unbaked firmware, dead board), the dump is skipped rather than hanging the session. + + For UI-tier tests, also embeds per-step camera captures + OCR on every + test (pass or fail) so the HTML report shows visual evidence of what + the device did. """ outcome = yield report = outcome.get_result() + # Attach UI captures on any outcome (pass + fail) — these are the whole + # point of the UI tier. Do this before the failure-only branch below so + # passing tests still get their image strip. + if report.when == "call": + _attach_ui_captures(item, report) + if report.when != "call" or report.outcome != "failed": return diff --git a/mcp-server/tests/mesh/test_peer_offline_recovery.py b/mcp-server/tests/mesh/test_peer_offline_recovery.py new file mode 100644 index 00000000000..b8d0f7a63ed --- /dev/null +++ b/mcp-server/tests/mesh/test_peer_offline_recovery.py @@ -0,0 +1,155 @@ +"""Isolation test for peer-offline-then-back mid-conversation. + +Verifies the mesh stack's behavior when a peer is physically powered +off mid-send via uhubctl, then powered back on. + +Flow (parametrized over every directed mesh_pair): + 1. Bilateral PKI warmup (same pattern as test_direct_with_ack). + 2. TX sends a broadcast text "msg-1" — RX confirms receipt via pubsub. + 3. Power OFF RX via uhubctl. The RX device disappears from the OS. + 4. TX sends a directed text "msg-2" with wantAck=True. Firmware retries + internally for ~30s before giving up. Assertion: the packet object + was accepted by the TX stack (non-None) — we don't assert an ACK + since there's no peer to send one. + 5. Power ON RX. Wait for re-enumeration + boot. + 6. Bilateral PKI re-nudge — RX's in-RAM PKI cache was wiped on reboot, + so the first directed send may err=35 without a fresh NodeInfo ping. + 7. TX sends a directed "msg-3" — RX receives it via pubsub, confirming + the mesh recovered. + +Skips cleanly if uhubctl isn't installed (via the `power_cycle` fixture's +auto-skip). Skips for pair directions where RX isn't power-controllable +(e.g. a USB-IF hub that doesn't support PPPS for its port). +""" + +from __future__ import annotations + +import time +from typing import Any + +import pytest +from meshtastic_mcp.connection import connect +from tests import _power +from tests._port_discovery import resolve_port_by_role + +from ._receive import ReceiveCollector, nudge_nodeinfo + + +@pytest.mark.timeout(360) +def test_peer_offline_then_recovers( + mesh_pair: dict[str, Any], + power_cycle, # noqa: ARG001 — forces uhubctl-availability skip + hub_devices: dict[str, str], +) -> None: + tx_port = mesh_pair["tx"]["port"] + rx_node_num = mesh_pair["rx"]["my_node_num"] + tx_role = mesh_pair["tx_role"] + rx_role = mesh_pair["rx_role"] + + unique_pre = f"peer-offline-pre-{tx_role}-to-{rx_role}-{int(time.time())}" + unique_post = f"peer-offline-post-{tx_role}-to-{rx_role}-{int(time.time())}" + + # Step 1 + 2: warm up + confirm baseline delivery works before the test. + with ReceiveCollector( + mesh_pair["rx"]["port"], topic="meshtastic.receive.text" + ) as rx: + rx.broadcast_nodeinfo_ping() + with connect(port=tx_port) as tx_iface: + nudge_nodeinfo(tx_iface) + # Wait for bilateral PKI (RX pubkey in TX's nodesByNum). + deadline = time.monotonic() + 45.0 + last_nudge = time.monotonic() + while time.monotonic() < deadline: + rec = (tx_iface.nodesByNum or {}).get(rx_node_num, {}) + if rec.get("user", {}).get("publicKey"): + break + if time.monotonic() - last_nudge > 15.0: + rx.broadcast_nodeinfo_ping() + nudge_nodeinfo(tx_iface) + last_nudge = time.monotonic() + time.sleep(1.0) + else: + pytest.skip( + f"bilateral PKI never completed ({tx_role}→{rx_role}); " + "can't run the offline test without a warm baseline" + ) + + tx_iface.sendText(unique_pre, destinationId=rx_node_num, wantAck=True) + got = rx.wait_for( + lambda pkt: pkt.get("decoded", {}).get("text") == unique_pre, + timeout=30, + ) + assert got is not None, ( + f"baseline directed send ({tx_role}→{rx_role}) didn't land — " + "skipping offline test to avoid false positive" + ) + + # Step 3: power off RX. uhubctl skips the test with a clear message if + # the RX role isn't on a controllable hub. + try: + _power.power_off(rx_role) + except Exception as exc: # noqa: BLE001 + pytest.skip(f"can't power-control {rx_role!r}: {exc}") + + try: + _power.wait_for_absence(rx_role, timeout_s=10.0) + except TimeoutError: + _power.power_on(rx_role) # restore hub state before failing + resolve_port_by_role(rx_role, timeout_s=30.0) + pytest.fail(f"{rx_role!r} didn't disappear after power_off") + + # Step 4: send to a peer that isn't there. Firmware will retry + # internally. We don't wait for an ACK (there won't be one); we just + # confirm TX's stack accepts the packet without crashing. + try: + with connect(port=tx_port) as tx_iface: + packet = tx_iface.sendText( + f"while-offline-{rx_role}", + destinationId=rx_node_num, + wantAck=True, + ) + assert packet is not None + # Give firmware a moment to do a retry or two while RX is down. + time.sleep(5.0) + except Exception as exc: # noqa: BLE001 — TX should survive the peer being gone + # Restore RX before reraising so the bench state is sane. + _power.power_on(rx_role) + resolve_port_by_role(rx_role, timeout_s=30.0) + raise AssertionError(f"TX crashed when sending to offline peer: {exc}") from exc + + # Step 5: power RX back on + rediscover. + _power.power_on(rx_role) + time.sleep(0.5) + new_rx_port = resolve_port_by_role(rx_role, timeout_s=30.0) + hub_devices[rx_role] = new_rx_port + + # Step 6 + 7: bilateral re-warmup + directed send that should now work. + with ReceiveCollector(new_rx_port, topic="meshtastic.receive.text") as rx: + # RX rebooted → its PKI cache is gone. Re-warm. + rx.broadcast_nodeinfo_ping() + with connect(port=tx_port) as tx_iface: + nudge_nodeinfo(tx_iface) + time.sleep(3.0) + + got = None + for _attempt in range(3): + packet = tx_iface.sendText( + unique_post, + destinationId=rx_node_num, + wantAck=True, + ) + assert packet is not None + got = rx.wait_for( + lambda pkt: pkt.get("decoded", {}).get("text") == unique_post, + timeout=30, + ) + if got is not None: + break + rx.broadcast_nodeinfo_ping() + nudge_nodeinfo(tx_iface) + time.sleep(5.0) + + assert got is not None, ( + f"post-recovery directed send {unique_post!r} ({tx_role}→{rx_role}) " + "never landed — recovery path may be broken" + ) diff --git a/mcp-server/tests/recovery/__init__.py b/mcp-server/tests/recovery/__init__.py new file mode 100644 index 00000000000..cf8f3919e7f --- /dev/null +++ b/mcp-server/tests/recovery/__init__.py @@ -0,0 +1,6 @@ +"""Recovery tier — exercises `uhubctl` power control end-to-end. + +Requires `uhubctl` installed AND at least one connected device on a +PPPS-capable hub. The whole tier skips cleanly via +`tests/recovery/conftest.py::_recovery_tier_guard` when either is missing. +""" diff --git a/mcp-server/tests/recovery/conftest.py b/mcp-server/tests/recovery/conftest.py new file mode 100644 index 00000000000..21116d07a82 --- /dev/null +++ b/mcp-server/tests/recovery/conftest.py @@ -0,0 +1,44 @@ +"""Recovery-tier gating + shared helpers. + +Session-scoped guard skips the whole tier when uhubctl isn't installed. +Tests under this directory assume uhubctl is callable AND that at least +one hub role is detected on a PPPS-capable port. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def _recovery_tier_guard() -> None: + """Skip the tier when uhubctl is unavailable OR no device is on a + PPPS-capable hub. Prints the specific reason so operators know what + to fix.""" + from tests import _power + + if not _power.is_uhubctl_available(): + pytest.skip( + "uhubctl not installed; recovery tier needs it. " + "Install via `brew install uhubctl` or `apt install uhubctl`.", + allow_module_level=True, + ) + + # Probe: can we even list hubs? (A macOS user without sudo gets a + # permission error here — we'd rather find out once at tier-start than + # 6 tests later.) + from meshtastic_mcp import uhubctl + + try: + hubs = uhubctl.list_hubs() + except uhubctl.UhubctlError as exc: + pytest.skip( + f"uhubctl list failed: {exc}. Try the udev rules or `sudo` as a fallback.", + allow_module_level=True, + ) + + if not any(h["ppps"] for h in hubs): + pytest.skip( + "no PPPS-capable hubs detected — recovery tier has nothing to exercise.", + allow_module_level=True, + ) diff --git a/mcp-server/tests/recovery/test_list_hubs.py b/mcp-server/tests/recovery/test_list_hubs.py new file mode 100644 index 00000000000..0a09a672a55 --- /dev/null +++ b/mcp-server/tests/recovery/test_list_hubs.py @@ -0,0 +1,43 @@ +"""Smoke test: `uhubctl_list` returns a well-formed structure. + +No destructive action. Runs first in the tier as a sanity check that the +tier's dependencies (uhubctl binary + permissions) are actually satisfied. +""" + +from __future__ import annotations + +import pytest +from meshtastic_mcp import uhubctl + + +@pytest.mark.timeout(30) +def test_list_hubs_returns_at_least_one_ppps_hub() -> None: + hubs = uhubctl.list_hubs() + assert hubs, "uhubctl found no hubs at all — is a USB hub connected?" + assert any(h["ppps"] for h in hubs), ( + "no PPPS-capable hubs detected; power control won't work. " + "Check that the hub supports Per-Port Power Switching." + ) + + +@pytest.mark.timeout(30) +def test_list_hubs_structure(hub_devices: dict[str, str]) -> None: + hubs = uhubctl.list_hubs() + for hub in hubs: + assert "location" in hub and hub["location"] + assert "ports" in hub and isinstance(hub["ports"], list) + for port in hub["ports"]: + assert "port" in port and isinstance(port["port"], int) + assert "status" in port + + # At least one of the detected Meshtastic roles should show up in some + # port's device_vid — otherwise the recovery tier can't drive them. + seen_vids = { + p["device_vid"] for h in hubs for p in h["ports"] if p["device_vid"] is not None + } + expected_any = {0x239A, 0x303A, 0x10C4} & seen_vids + assert expected_any or not hub_devices, ( + f"hub_devices detected roles {list(hub_devices)} but uhubctl sees " + f"VIDs {sorted(hex(v) for v in seen_vids)} — the devices may be on " + "a hub that uhubctl can't see (e.g. built-in laptop ports)." + ) diff --git a/mcp-server/tests/recovery/test_power_cycle_preserves_userprefs.py b/mcp-server/tests/recovery/test_power_cycle_preserves_userprefs.py new file mode 100644 index 00000000000..9558a7fb424 --- /dev/null +++ b/mcp-server/tests/recovery/test_power_cycle_preserves_userprefs.py @@ -0,0 +1,60 @@ +"""Hard reset via uhubctl must NOT wipe NVS. Verify the test profile's +region + channel survive a power-cycle. + +Guards against a regression where a firmware change treats unexpected +power loss as a factory-reset trigger (e.g. bad EEPROM wear-leveling, +erase-on-boot-for-safety). Such a regression would be catastrophic for +field deployments. +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp import admin, info +from tests import _power +from tests._port_discovery import resolve_port_by_role + + +@pytest.mark.timeout(180) +def test_lora_config_survives_power_cycle( + baked_single: dict[str, object], + test_profile: dict[str, object], +) -> None: + role = baked_single["role"] + pre_port = baked_single["port"] + + pre_config = admin.get_config(section="lora", port=pre_port)["config"]["lora"] + pre_region = pre_config.get("region") + pre_preset = pre_config.get("modem_preset") + assert pre_region, f"lora.region not set pre-cycle on {role}" + + # Hard power-cycle. + _power.power_cycle(role, delay_s=2) + time.sleep(0.5) + new_port = resolve_port_by_role(role, timeout_s=30.0) + # Let the firmware complete boot before admin reads. + time.sleep(2.0) + # Quick readiness probe. + probe = info.device_info(port=new_port, timeout_s=10.0) + assert ( + probe.get("my_node_num") is not None + ), f"device {role!r} didn't respond after power-cycle" + + post_config = admin.get_config(section="lora", port=new_port)["config"]["lora"] + post_region = post_config.get("region") + post_preset = post_config.get("modem_preset") + + assert post_region == pre_region, ( + f"lora.region wiped by power-cycle on {role}: " + f"pre={pre_region!r} post={post_region!r}" + ) + assert post_preset == pre_preset, ( + f"lora.modem_preset wiped by power-cycle on {role}: " + f"pre={pre_preset!r} post={post_preset!r}" + ) + + # Channel-0 name should also match the test profile. + pri_ch = admin.get_channel_url(port=new_port) + assert pri_ch.get("url"), f"channel URL empty after power-cycle on {role}" diff --git a/mcp-server/tests/recovery/test_power_cycle_roundtrip.py b/mcp-server/tests/recovery/test_power_cycle_roundtrip.py new file mode 100644 index 00000000000..37bc7c8d25f --- /dev/null +++ b/mcp-server/tests/recovery/test_power_cycle_roundtrip.py @@ -0,0 +1,61 @@ +"""Full power-cycle round-trip: off → verify gone → on → verify identity +preserved. + +Parametrized over every connected role. Validates both the uhubctl +plumbing AND that the device survives a hard reset with the same +`my_node_num` (no firmware-level identity regeneration). +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp import info +from tests import _power +from tests._port_discovery import resolve_port_by_role + + +@pytest.mark.timeout(180) +def test_power_cycle_preserves_node_identity( + baked_single: dict[str, object], +) -> None: + role = baked_single["role"] + pre_port = baked_single["port"] + pre_node_num = baked_single["my_node_num"] + pre_fw = baked_single.get("firmware_version") + + # Record pre-cycle state. + pre_info = info.device_info(port=pre_port, timeout_s=5.0) + assert pre_info.get("my_node_num") == pre_node_num + + # Power off; confirm the device actually disappears from list_devices. + _power.power_off(role) + try: + _power.wait_for_absence(role, timeout_s=10.0) + except TimeoutError: + # If it didn't disappear, power it back on so we don't leave the + # hub in a weird state for the next test. + _power.power_on(role) + resolve_port_by_role(role, timeout_s=30.0) + pytest.fail(f"device {role!r} stayed visible after power_off") + + # Power back on + re-discover port. + _power.power_on(role) + time.sleep(0.5) # head-start before polling + new_port = resolve_port_by_role(role, timeout_s=30.0) + + # Give the firmware a moment to finish boot before we hit it with admin. + time.sleep(2.0) + + post_info = info.device_info(port=new_port, timeout_s=10.0) + assert post_info.get("my_node_num") == pre_node_num, ( + f"my_node_num changed across power-cycle: pre={pre_node_num:#x} " + f"post={post_info.get('my_node_num'):#x}" + ) + # Firmware version must match (same bake, not a re-flash). + if pre_fw: + assert post_info.get("firmware_version") == pre_fw, ( + f"firmware changed across cycle: pre={pre_fw} " + f"post={post_info.get('firmware_version')}" + ) diff --git a/mcp-server/tests/tool_coverage.py b/mcp-server/tests/tool_coverage.py index b91bd4039bc..edf974e03e9 100644 --- a/mcp-server/tests/tool_coverage.py +++ b/mcp-server/tests/tool_coverage.py @@ -73,6 +73,13 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: "reboot": ("meshtastic_mcp.admin", "reboot"), "shutdown": ("meshtastic_mcp.admin", "shutdown"), "factory_reset": ("meshtastic_mcp.admin", "factory_reset"), + "send_input_event": ("meshtastic_mcp.admin", "send_input_event"), + # `capture_screen` in server.py calls camera.get_camera — instrument that. + "capture_screen": ("meshtastic_mcp.camera", "get_camera"), + # USB power control via uhubctl. + "uhubctl_list": ("meshtastic_mcp.uhubctl", "list_hubs"), + "uhubctl_power": ("meshtastic_mcp.uhubctl", "power_on"), + "uhubctl_cycle": ("meshtastic_mcp.uhubctl", "cycle"), # USERPREFS "userprefs_manifest": ("meshtastic_mcp.userprefs", "build_manifest"), "userprefs_get": ("meshtastic_mcp.userprefs", "read_state"), diff --git a/mcp-server/tests/ui/__init__.py b/mcp-server/tests/ui/__init__.py new file mode 100644 index 00000000000..006fc3c8edb --- /dev/null +++ b/mcp-server/tests/ui/__init__.py @@ -0,0 +1,7 @@ +"""UI tier — input-broker-driven screen navigation tests. + +Only runs when a screen-bearing role (esp32s3/heltec-v3) is present on the +hub AND the firmware was baked with `enable_ui_log=True` (so the +`Screen: frame N/M name=... reason=...` log lines are emitted). The +`tests/ui/conftest.py` fixture forces that bake stamp. +""" diff --git a/mcp-server/tests/ui/_screen_log.py b/mcp-server/tests/ui/_screen_log.py new file mode 100644 index 00000000000..97954db48c6 --- /dev/null +++ b/mcp-server/tests/ui/_screen_log.py @@ -0,0 +1,176 @@ +"""Parse `Screen: frame N/M name=X reason=Y` log lines from `_debug_log_buffer`. + +The firmware emits one line per frame transition when +`USERPREFS_UI_TEST_LOG` is defined (see src/graphics/Screen.cpp). Tests use +these helpers to assert which frame is shown / to wait for a transition to +settle before taking a camera capture. +""" + +from __future__ import annotations + +import re +import time +from dataclasses import dataclass +from typing import Iterable, Iterator + +FRAME_RE = re.compile( + r"Screen: frame (?P\d+)/(?P\d+) name=(?P\S+) reason=(?P\S+)" +) + + +@dataclass(frozen=True) +class FrameEvent: + idx: int + count: int + name: str + reason: str + raw: str + + @classmethod + def parse(cls, line: str) -> "FrameEvent | None": + m = FRAME_RE.search(line) + if not m: + return None + return cls( + idx=int(m["idx"]), + count=int(m["count"]), + name=m["name"], + reason=m["reason"], + raw=line, + ) + + +def iter_frame_events(lines: Iterable[str]) -> Iterator[FrameEvent]: + for line in lines: + evt = FrameEvent.parse(line) + if evt is not None: + yield evt + + +def get_current_frame(lines: list[str]) -> FrameEvent | None: + """Return the most recent FrameEvent in `lines`, or None if none found.""" + for line in reversed(lines): + evt = FrameEvent.parse(line) + if evt is not None: + return evt + return None + + +def wait_for_frame( + lines: list[str], + expected_name: str, + *, + timeout_s: float = 5.0, + poll_interval_s: float = 0.1, + reason: str | None = None, +) -> FrameEvent: + """Poll `lines` (the `_debug_log_buffer`) until a FrameEvent with + `name=expected_name` appears after the call started. Raises TimeoutError + with context if it doesn't arrive in `timeout_s`. + + `reason` optionally filters to events matching a specific cause + (e.g. `"fn_f1"`, `"next"`, `"rebuild"`). + """ + start_idx = len(lines) + deadline = time.monotonic() + timeout_s + last: FrameEvent | None = None + while time.monotonic() < deadline: + # Scan only lines appended since we started waiting. + for line in lines[start_idx:]: + evt = FrameEvent.parse(line) + if evt is None: + continue + last = evt + if evt.name == expected_name and (reason is None or evt.reason == reason): + return evt + time.sleep(poll_interval_s) + + seen = [e.name for e in iter_frame_events(lines[start_idx:])] + raise TimeoutError( + f"frame name={expected_name!r} reason={reason!r} not seen in {timeout_s}s; " + f"saw {len(seen)} transition(s): {seen!r}; last={last!r}" + ) + + +def wait_for_any_frame( + lines: list[str], + *, + timeout_s: float = 5.0, + poll_interval_s: float = 0.1, +) -> FrameEvent: + """Wait for ANY frame transition to appear after call-start. Useful for + `no-op` tests that want to confirm a transition did NOT happen (via + TimeoutError) vs. one that did. + """ + start_idx = len(lines) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + for line in lines[start_idx:]: + evt = FrameEvent.parse(line) + if evt is not None: + return evt + time.sleep(poll_interval_s) + raise TimeoutError(f"no frame transition in {timeout_s}s") + + +def wait_for_reason( + lines: list[str], + reason: str, + *, + timeout_s: float = 5.0, + poll_interval_s: float = 0.1, +) -> FrameEvent: + """Wait for a frame event with `reason=` after call-start. + + Matches only on `reason` — useful when the caller knows *why* a + transition should happen (e.g. `fn_f1`, `rebuild`) but not which named + frame the firmware will land on for this particular board. + """ + start_idx = len(lines) + deadline = time.monotonic() + timeout_s + last: FrameEvent | None = None + while time.monotonic() < deadline: + for line in lines[start_idx:]: + evt = FrameEvent.parse(line) + if evt is None: + continue + last = evt + if evt.reason == reason: + return evt + time.sleep(poll_interval_s) + raise TimeoutError( + f"no frame with reason={reason!r} in {timeout_s}s; last={last!r}" + ) + + +def assert_no_frame_change( + lines: list[str], + *, + wait_s: float = 2.0, +) -> None: + """Assert that NO new FrameEvent lines arrive within `wait_s`. + + Used by idempotency / no-op tests (e.g. BACK on home frame). + """ + start_idx = len(lines) + time.sleep(wait_s) + new = [ + e for e in (FrameEvent.parse(ln) for ln in lines[start_idx:]) if e is not None + ] + if new: + raise AssertionError( + f"expected no frame change in {wait_s}s, but saw {len(new)} event(s): " + f"{[(e.reason, e.name) for e in new]!r}" + ) + + +__all__ = [ + "FRAME_RE", + "FrameEvent", + "assert_no_frame_change", + "get_current_frame", + "iter_frame_events", + "wait_for_any_frame", + "wait_for_frame", + "wait_for_reason", +] diff --git a/mcp-server/tests/ui/conftest.py b/mcp-server/tests/ui/conftest.py new file mode 100644 index 00000000000..aedfdbc8f2a --- /dev/null +++ b/mcp-server/tests/ui/conftest.py @@ -0,0 +1,381 @@ +"""UI-tier fixtures: camera lifecycle, OCR warmup, per-test frame capture, +and a `ui_home_state` autouse guard that resets to the home frame before +every test (prevents state bleed if a prior test exited inside a menu). + +The camera + OCR modules live in `meshtastic_mcp/{camera,ocr}.py` (production +code, so the `capture_screen` MCP tool can share them). These fixtures wire +them into pytest + write per-test captures to `tests/ui_captures/…`. +""" + +from __future__ import annotations + +import re +import shutil +import time +from pathlib import Path +from typing import Any, Iterator + +import pytest +from meshtastic_mcp import admin as admin_mod +from meshtastic_mcp import camera as camera_mod +from meshtastic_mcp import ocr as ocr_mod +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import FrameEvent, get_current_frame, wait_for_frame + +# Roles that carry a screen the UI tier can drive. Only esp32s3 (heltec-v3 +# SSD1306) qualifies today — nrf52 (rak4631) has no display. +UI_CAPABLE_ROLES = ("esp32s3",) + +# Where per-test captures land. One subdirectory per session seed, then per +# sanitized test nodeid — identical pattern to other pytest artifacts. +CAPTURES_ROOT = Path(__file__).resolve().parent.parent / "ui_captures" + + +def _sanitize_nodeid(nodeid: str) -> str: + return re.sub(r"[^a-zA-Z0-9_.-]+", "_", nodeid) + + +# ---------- Role gating ---------------------------------------------------- + + +@pytest.fixture +def ui_capable_role(request: pytest.FixtureRequest, hub_devices: dict[str, Any]) -> str: + """Resolve the single role the UI tier drives. + + Today that's `esp32s3`. Skips if the hub doesn't have one. A future + multi-screen hub could pick a role per parametrization. + """ + for role in UI_CAPABLE_ROLES: + if role in hub_devices: + return role + pytest.skip( + f"no UI-capable role on hub; need one of {UI_CAPABLE_ROLES} in {sorted(hub_devices)}" + ) + + +@pytest.fixture +def ui_port(ui_capable_role: str, hub_devices: dict[str, Any]) -> str: + port = ( + hub_devices[ui_capable_role].get("port") + if isinstance(hub_devices[ui_capable_role], dict) + else hub_devices[ui_capable_role] + ) + if not port: + pytest.skip(f"{ui_capable_role!r} has no usable port") + return port + + +# ---------- Camera + OCR session fixtures --------------------------------- + + +@pytest.fixture(scope="session") +def camera(ui_capable_role_session: str | None) -> Iterator[camera_mod.CameraBackend]: + """Session-scoped camera backend. Closed at teardown. + + Backend + device selected by env vars (see `meshtastic_mcp.camera`). + Falls through to `NullBackend` when no camera is configured, so the + tests run end-to-end on machines without hardware; they just won't + have useful images. + """ + role = ui_capable_role_session or "esp32s3" + cam = camera_mod.get_camera(role) + try: + yield cam + finally: + cam.close() + + +@pytest.fixture(scope="session") +def ui_capable_role_session(hub_devices: dict[str, Any]) -> str | None: + """Session-scoped lookup mirroring `ui_capable_role` but non-skipping. + + Used by the `camera` session fixture so it doesn't depend on a + test-scoped skip. + """ + for role in UI_CAPABLE_ROLES: + if role in hub_devices: + return role + return None + + +@pytest.fixture(scope="session", autouse=True) +def _ocr_warm() -> None: + """Pay easyocr's ~100 MB / cold-start cost ONCE per session. + + Subsequent `ocr_text()` calls hit the cached reader and return quickly. + Swallows errors — if OCR isn't installed, warm is a no-op. + """ + try: + ocr_mod.warm() + except Exception: # noqa: BLE001 — belt: never block the suite on OCR init + pass + + +@pytest.fixture(scope="session") +def _ui_screen_kept_on( + ui_capable_role_session: str | None, hub_devices: dict[str, Any] +) -> Iterator[None]: + """Keep the OLED on throughout the UI tier so input events aren't dropped. + + Why: `InputBroker::handleInputEvent` (src/input/InputBroker.cpp:118-122) + silently DROPS any event that arrives while the screen is off — it just + wakes the screen and returns. Every first event in each test would + disappear. We set `display.screen_on_secs = 86400` at session start + (effectively "always on" for the test window) and restore the prior + value at teardown. + """ + if ui_capable_role_session is None: + yield + return + + hub_entry = hub_devices[ui_capable_role_session] + port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry + if not port: + yield + return + + original: int | None = None + try: + current = admin_mod.get_config(section="display", port=port) + original = int( + current.get("config", {}).get("display", {}).get("screen_on_secs") or 0 + ) + except Exception: # noqa: BLE001 + pass + + try: + admin_mod.set_config("display.screen_on_secs", 86400, port=port) + # Send one wake event so the screen is actually ON going into the + # first test. The event itself gets dropped (screenWasOff), but the + # wake side-effect sticks. + try: + admin_mod.send_input_event(event_code=int(InputEventCode.FN_F1), port=port) + except Exception: # noqa: BLE001 + pass + time.sleep(1.5) # Let the screen finish its wake transition. + except ( + Exception + ): # noqa: BLE001 — best-effort; ui_home_state surfaces the real error + pass + + try: + yield + finally: + if original is not None: + try: + admin_mod.set_config("display.screen_on_secs", original, port=port) + except Exception: # noqa: BLE001 + pass + + +# ---------- Per-test capture + transcript ---------------------------------- + + +class FrameCapture: + """Per-test capture recorder. Created once per test via the + `frame_capture` fixture; call with a label to snapshot the screen. + """ + + def __init__( + self, + cam: camera_mod.CameraBackend, + dir_path: Path, + lines: list[str], + nodeid: str, + ) -> None: + self._cam = cam + self._dir = dir_path + self._lines = lines + self._nodeid = nodeid + self._step = 0 + self.captures: list[dict[str, Any]] = [] + self._transcript_path = dir_path / "transcript.md" + self._dir.mkdir(parents=True, exist_ok=True) + self._transcript_path.write_text( + f"# {nodeid} — {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n", + encoding="utf-8", + ) + + def __call__(self, label: str) -> dict[str, Any]: + self._step += 1 + stem = f"{self._step:03d}-{re.sub(r'[^a-zA-Z0-9_-]+', '-', label)}" + png_path = self._dir / f"{stem}.png" + ocr_path = self._dir / f"{stem}.ocr.txt" + + try: + png = self._cam.capture() + except Exception as exc: # noqa: BLE001 + png = b"" + ocr_str = f"[capture error: {exc}]" + else: + camera_mod.save_capture(png, png_path) + try: + ocr_str = ocr_mod.ocr_text(png) + except Exception as exc: # noqa: BLE001 + ocr_str = f"[ocr error: {exc}]" + ocr_path.write_text(ocr_str or "", encoding="utf-8") + + frame = get_current_frame(self._lines) + entry: dict[str, Any] = { + "step": self._step, + "label": label, + "png_path": str(png_path) if png else None, + "ocr_text": ocr_str, + "frame": ( + { + "idx": frame.idx, + "name": frame.name, + "reason": frame.reason, + } + if frame is not None + else None + ), + } + self.captures.append(entry) + + with self._transcript_path.open("a", encoding="utf-8") as fh: + frame_str = ( + f"frame {frame.idx}/{frame.count} name={frame.name} reason={frame.reason}" + if frame is not None + else "frame " + ) + ocr_summary = (ocr_str or "").replace("\n", " / ")[:80] + fh.write( + f"{self._step}. **{label}** — {frame_str} — OCR: `{ocr_summary}`\n" + ) + return entry + + +@pytest.fixture +def frame_capture( + request: pytest.FixtureRequest, + camera: camera_mod.CameraBackend, + session_seed: str, +) -> Iterator[FrameCapture]: + nodeid = _sanitize_nodeid(request.node.nodeid) + dir_path = CAPTURES_ROOT / session_seed / nodeid + # Fresh directory per test run so reruns don't mix old and new images. + if dir_path.exists(): + shutil.rmtree(dir_path) + + lines = getattr(request.node, "_debug_log_buffer", []) + fc = FrameCapture(camera, dir_path, lines, nodeid) + # Stash so pytest_runtest_makereport can embed captures in HTML extras. + request.node._ui_captures = fc.captures # type: ignore[attr-defined] + yield fc + + +# ---------- Pre-test home-state reset -------------------------------------- + + +def _send_event(port: str, event: InputEventCode) -> None: + try: + admin_mod.send_input_event(event_code=int(event), port=port) + except Exception: # noqa: BLE001 + # Treat a failed event as soft — the subsequent frame-log assertion + # surfaces the real problem with better context. + pass + + +@pytest.fixture(autouse=True) +def ui_home_state( + request: pytest.FixtureRequest, + hub_devices: dict[str, Any], + _ui_screen_kept_on: None, +) -> Iterator[None]: + """Before every UI test, jump to frame 0 (usually `home`) via FN_F1 and + confirm the device emitted the expected frame log. + + Why FN_F1 (not BACK): FN_F1 maps to `switchToFrame(0)` and ALWAYS + produces a `reason=fn_f1` log line, regardless of whatever frame the + prior test left us on. BACK is context-sensitive (dismisses overlays + on some frames, no-op on others) and can silently fail to transition. + + This fixture doubles as the macro-presence detector: if no `fn_f1` + log arrives within 5 s, the firmware almost certainly wasn't baked + with `USERPREFS_UI_TEST_LOG`. Skip the tier with an actionable hint + instead of letting every test body fail with a confusing assertion. + + Autouse scope is restricted to `tests/ui/` by virtue of this fixture + living in that directory's conftest.py — no explicit nodeid guard + needed (and earlier attempts at one were wrong, matching `/tests/ui/` + against a nodeid that has no leading slash). + """ + role = next((r for r in UI_CAPABLE_ROLES if r in hub_devices), None) + if role is None: + yield + return + + hub_entry = hub_devices[role] + port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry + lines: list[str] = getattr(request.node, "_debug_log_buffer", []) + start_len = len(lines) + + # First: a wake event. The screen should already be kept on by + # `_ui_screen_kept_on`, but belt + suspenders — if it somehow + # powered off (sleep after factory_reset, etc.), this first FN_F1 + # gets dropped by InputBroker's screenWasOff guard. That's fine; + # the second FN_F1 below lands cleanly. + _send_event(port, InputEventCode.FN_F1) + time.sleep(0.4) + _send_event(port, InputEventCode.FN_F1) + + # Wait for the fn_f1 transition log. Any new `reason=fn_f1` line + # after call-start counts — we don't care about the name (it should + # be `home` or `deviceFocused` depending on board-specific frame order). + from ._screen_log import wait_for_reason + + try: + wait_for_reason(lines, "fn_f1", timeout_s=5.0) + except TimeoutError: + # One more try — FreeRTOS queue may be draining slowly. + _send_event(port, InputEventCode.FN_F1) + try: + wait_for_reason(lines, "fn_f1", timeout_s=5.0) + except TimeoutError: + # Look at what the _debug_log_buffer actually contains to + # disambiguate "macro off" from "macro on but event lost". + frame_lines = [ln for ln in lines[start_len:] if "Screen: frame" in ln] + processing_lines = [ + ln for ln in lines[start_len:] if "Processing input event" in ln + ] + if frame_lines: + pytest.skip( + f"ui_home_state: events fire but none reach Screen " + f"(saw {len(frame_lines)} frame line(s), " + f"{len(processing_lines)} admin inject(s)). " + f"Device may be in an unusual state — try `--force-bake`." + ) + else: + pytest.skip( + "ui_home_state: no `Screen: frame` log after FN_F1. " + "Firmware not baked with USERPREFS_UI_TEST_LOG — " + "run with `--force-bake` to reflash, or verify the " + "macro is active in the bake." + ) + yield + + +# ---------- Small helpers reused by tests --------------------------------- + + +def send_event( + port: str, event: InputEventCode | int | str, **kwargs: Any +) -> dict[str, Any]: + """Thin wrapper so tests read `send_event(port, InputEventCode.RIGHT)`.""" + return admin_mod.send_input_event(event_code=event, port=port, **kwargs) + + +__all__ = [ + "FrameCapture", + "UI_CAPABLE_ROLES", + "send_event", + "wait_for_frame", + "FrameEvent", +] + + +# Make the helpers discoverable to test modules via `from .conftest import …`. +# pytest auto-loads conftest.py, but the symbols above are also re-exported +# for readability in the test files. diff --git a/mcp-server/tests/ui/test_input_fn_jump.py b/mcp-server/tests/ui/test_input_fn_jump.py new file mode 100644 index 00000000000..047aff7d2d7 --- /dev/null +++ b/mcp-server/tests/ui/test_input_fn_jump.py @@ -0,0 +1,61 @@ +"""FN_F1..F5 directly jumps to frame 0..4 via Screen::handleInputEvent. + +Parametrized over the 5 function keys. Each expects a +`Screen: frame / name=... reason=fn_f` log line, with +`idx == k-1`. We don't hardcode the frame *name* because the layout +depends on which modules are compiled in for this board. +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import get_current_frame, wait_for_reason +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(120) +@pytest.mark.parametrize( + "event,expected_idx,reason", + [ + (InputEventCode.FN_F1, 0, "fn_f1"), + (InputEventCode.FN_F2, 1, "fn_f2"), + (InputEventCode.FN_F3, 2, "fn_f3"), + (InputEventCode.FN_F4, 3, "fn_f4"), + (InputEventCode.FN_F5, 4, "fn_f5"), + ], + ids=["FN_F1", "FN_F2", "FN_F3", "FN_F4", "FN_F5"], +) +def test_fn_jump_direct_frame( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, + event: InputEventCode, + expected_idx: int, + reason: str, +) -> None: + lines: list[str] = request.node._debug_log_buffer + start = get_current_frame(lines) + assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?" + assert start.name in ( + "home", + "deviceFocused", + ), f"setup expected frame 0 landing, got {start.name!r}" + frame_capture("initial") + + if start.count <= expected_idx: + pytest.skip( + f"device has {start.count} frames; FN_F{expected_idx + 1} needs > {expected_idx}" + ) + + send_event(ui_port, event) + time.sleep(0.1) + evt = wait_for_reason(lines, reason, timeout_s=5.0) + assert evt.idx == expected_idx, ( + f"FN_F{expected_idx + 1} expected idx={expected_idx}, got {evt.idx} " + f"(name={evt.name}, count={evt.count})" + ) + frame_capture(f"after-{reason}") diff --git a/mcp-server/tests/ui/test_input_fn_oob.py b/mcp-server/tests/ui/test_input_fn_oob.py new file mode 100644 index 00000000000..a33ff1cc57e --- /dev/null +++ b/mcp-server/tests/ui/test_input_fn_oob.py @@ -0,0 +1,61 @@ +"""Out-of-bounds FN_F5 when the device has <5 frames: no crash, idx unchanged. + +`Screen::handleInputEvent` dispatches FN_F5 unconditionally to +`ui->switchToFrame(4)`. The OLEDDisplayUi library typically clamps or +silently ignores out-of-range indices, but firmware bugs have existed +here — this test protects against a regression that would wedge the UI. + +If this test fails, first check: did the device actually crash (Guru +Meditation in the log)? Or did switchToFrame accept an OOB index and +leave the UI blank? +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import get_current_frame, wait_for_reason +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(90) +def test_fn_f5_out_of_bounds( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + lines: list[str] = request.node._debug_log_buffer + start = get_current_frame(lines) + assert start is not None + + if start.count > 5: + pytest.skip( + f"device has {start.count} frames; FN_F5 is in-bounds — not testing OOB here" + ) + + frame_capture("initial-home") + send_event(ui_port, InputEventCode.FN_F5) + time.sleep(0.5) + + try: + wait_for_reason(lines, "fn_f5", timeout_s=3.0) + except TimeoutError: + # Firmware may have ignored the event entirely — acceptable. + pass + + # Capture whatever is on screen (OCR will tell us if something weird + # happened). Device must remain responsive — subsequent events should + # still land. + frame_capture("after-fn_f5-oob") + + # Send a RIGHT to confirm the UI is still alive. If this times out, + # the OOB switchToFrame wedged the UI. + send_event(ui_port, InputEventCode.RIGHT) + post = wait_for_reason(lines, "next", timeout_s=5.0) + assert ( + post is not None + ), "UI wedged after OOB FN_F5 — RIGHT no longer produces frame log" + frame_capture("after-recovery-right") diff --git a/mcp-server/tests/ui/test_input_menu.py b/mcp-server/tests/ui/test_input_menu.py new file mode 100644 index 00000000000..8799d7dfb9b --- /dev/null +++ b/mcp-server/tests/ui/test_input_menu.py @@ -0,0 +1,68 @@ +"""SELECT on the home frame opens the home menu; BACK closes it. + +The home menu is an overlay (menuHandler::homeBaseMenu), not a frame +transition — so we verify via OCR difference between before/after +captures rather than a `Screen: frame` log line. The underlying +mechanism is still InputBroker → Screen::handleInputEvent → menu +callback. +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import get_current_frame +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(120) +def test_select_opens_home_menu( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + lines: list[str] = request.node._debug_log_buffer + start = get_current_frame(lines) + assert start is not None + if start.name not in ("home", "deviceFocused"): + pytest.skip( + f"SELECT on {start.name!r} doesn't open homeBaseMenu; " + "test is only valid when the landing frame is home/deviceFocused" + ) + + initial = frame_capture("initial") + send_event(ui_port, InputEventCode.SELECT) + time.sleep(0.8) + opened = frame_capture("after-select") + + # The menu is an overlay (not a frame change). We cannot use log + # assertion — instead, OCR should differ because a menu list is now + # drawn on top. + initial_text = (initial.get("ocr_text") or "").strip() + opened_text = (opened.get("ocr_text") or "").strip() + if initial_text and opened_text: + # When OCR is available, require *some* difference between the two + # frames — even a single menu title changes the transcribed text. + assert initial_text != opened_text, ( + f"expected OCR diff after SELECT; both read {initial_text!r}. " + "If both are empty, check camera alignment + OCR backend." + ) + + # Back out — the menu dismisses on BACK. + send_event(ui_port, InputEventCode.BACK) + time.sleep(0.8) + closed = frame_capture("after-back") + + # Soft check: OCR after BACK should look different from the menu + # (either back to home or onto a previous frame — BACK's exact + # behavior when the menu is up vs. not-up varies). We don't assert + # equality because OLED rendering is pixel-stable but camera sampling + # introduces noise. + if opened_text and closed.get("ocr_text"): + close_text = (closed.get("ocr_text") or "").strip() + assert ( + close_text != opened_text + ), f"after BACK, OCR still looks like the menu: {close_text!r}" diff --git a/mcp-server/tests/ui/test_input_message_scroll.py b/mcp-server/tests/ui/test_input_message_scroll.py new file mode 100644 index 00000000000..85dc2d8e2b0 --- /dev/null +++ b/mcp-server/tests/ui/test_input_message_scroll.py @@ -0,0 +1,60 @@ +"""Once we navigate to the textMessage frame, UP/DOWN exercises the +message-scroll path (or opens CannedMessages on empty devices). + +Weaker than a "no frame change" assertion because on a fresh bench +device the message store is usually empty, and the firmware's UP +handler in that case launches CannedMessage — which DOES rebuild +frames. We just verify the path doesn't crash + produce captures for +visual inspection. +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import get_current_frame, wait_for_frame +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(180) +def test_up_down_on_textmessage_survives( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + lines: list[str] = request.node._debug_log_buffer + frame_capture("initial") + + # Walk RIGHT until we land on textMessage — up to 15 hops. + for _i in range(15): + send_event(ui_port, InputEventCode.RIGHT) + time.sleep(0.3) + current = get_current_frame(lines) + if current is not None and current.name == "textMessage": + break + else: + pytest.skip( + "couldn't reach textMessage frame within 15 RIGHTs — not present on this board" + ) + + wait_for_frame(lines, "textMessage", timeout_s=5.0) + frame_capture("on-textMessage") + + # UP and DOWN exercise the message-scroll / canned-message-launch path. + # Capture after each so the HTML report shows any visual effect. + send_event(ui_port, InputEventCode.UP) + time.sleep(0.3) + frame_capture("after-up") + + send_event(ui_port, InputEventCode.DOWN) + time.sleep(0.3) + frame_capture("after-down") + + # Soft check: we should still be in a reachable frame (not wedged). + # The next test's `ui_home_state` will error out if the device is + # unresponsive, so we don't need a stricter guarantee here. + final = get_current_frame(lines) + assert final is not None, "no frame log after UP/DOWN — event path broke" diff --git a/mcp-server/tests/ui/test_input_navigation.py b/mcp-server/tests/ui/test_input_navigation.py new file mode 100644 index 00000000000..1fe00b1349a --- /dev/null +++ b/mcp-server/tests/ui/test_input_navigation.py @@ -0,0 +1,93 @@ +"""INPUT_BROKER_RIGHT cycles forward through frames; INPUT_BROKER_LEFT backs. + +The simplest UI test: fire N RIGHT events and assert the frame index +moves forward by N (modulo frameCount). Each step captures an image + +OCR for the HTML report. +""" + +from __future__ import annotations + +import time +from typing import Any + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import get_current_frame, wait_for_frame +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(120) +def test_input_right_cycles_frames( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + lines: list[str] = request.node._debug_log_buffer + start = get_current_frame(lines) + assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?" + # FN_F1 in ui_home_state lands on frame 0. The name at frame 0 varies + # by board (home on heltec-v3, deviceFocused on others) — accept either. + assert start.name in ( + "home", + "deviceFocused", + ), f"setup expected home/deviceFocused at frame 0, got {start.name!r}" + + frame_capture("initial") + visited = [start.idx] + + for step in range(4): + send_event(ui_port, InputEventCode.RIGHT) + # Each RIGHT should bump the frame index by 1. The log fires with + # `reason=next` from showFrame(NEXT). + before_count = len(list(_frame_events(lines))) + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if len(list(_frame_events(lines))) > before_count: + break + time.sleep(0.1) + evt = get_current_frame(lines) + assert evt is not None + assert ( + evt.reason == "next" + ), f"step {step}: expected reason=next, got {evt.reason!r}" + visited.append(evt.idx) + frame_capture(f"after-right-{step + 1}") + + # Sanity: each index should differ from its predecessor. + diffs = [visited[i + 1] - visited[i] for i in range(len(visited) - 1)] + assert all( + d in (1, -(start.count - 1)) for d in diffs + ), f"expected monotonic +1 steps (or a wrap), got visited={visited} diffs={diffs}" + + +@pytest.mark.timeout(120) +def test_input_left_returns_to_home( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + """After RIGHT×3 + LEFT×3, we should end up back on the starting frame.""" + lines: list[str] = request.node._debug_log_buffer + start = get_current_frame(lines) + assert start is not None + start_name = start.name + frame_capture("initial") + for _ in range(3): + send_event(ui_port, InputEventCode.RIGHT) + time.sleep(0.3) + frame_capture("after-right-3") + + for _ in range(3): + send_event(ui_port, InputEventCode.LEFT) + time.sleep(0.3) + + # Back to whichever frame we started on (home or deviceFocused). + wait_for_frame(lines, start_name, timeout_s=5.0) + frame_capture(f"after-left-3-back-{start_name}") + + +def _frame_events(lines: list[str]) -> Any: + from ._screen_log import iter_frame_events + + return iter_frame_events(lines) diff --git a/mcp-server/tests/ui/test_input_node_scroll.py b/mcp-server/tests/ui/test_input_node_scroll.py new file mode 100644 index 00000000000..594b358a0fa --- /dev/null +++ b/mcp-server/tests/ui/test_input_node_scroll.py @@ -0,0 +1,51 @@ +"""On the nodelist_nodes frame, UP/DOWN scrolls the list via +`NodeListRenderer::scrollUp/scrollDown` (src/graphics/Screen.cpp:1779-1788). +The firmware returns 0 before notifying observers, so no frame-change +log fires. Verify the path doesn't crash and we stay on nodelist_nodes. +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import assert_no_frame_change, get_current_frame, wait_for_frame +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(180) +def test_up_down_on_nodelist_no_frame_change( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + lines: list[str] = request.node._debug_log_buffer + frame_capture("initial") + + # Walk RIGHT until we land on nodelist_nodes. + for _i in range(15): + send_event(ui_port, InputEventCode.RIGHT) + time.sleep(0.3) + current = get_current_frame(lines) + if current is not None and current.name == "nodelist_nodes": + break + else: + pytest.skip("couldn't reach nodelist_nodes within 15 RIGHTs") + + wait_for_frame(lines, "nodelist_nodes", timeout_s=5.0) + frame_capture("on-nodelist") + + # UP/DOWN on nodelist scroll internally + `return 0` before + # notifyObservers — no frame-change log. Verify. + send_event(ui_port, InputEventCode.UP) + assert_no_frame_change(lines, wait_s=1.5) + send_event(ui_port, InputEventCode.DOWN) + assert_no_frame_change(lines, wait_s=1.5) + + final = get_current_frame(lines) + assert ( + final is not None and final.name == "nodelist_nodes" + ), f"UP/DOWN moved us off nodelist_nodes; now on {final!r}" + frame_capture("after-up-down") diff --git a/mcp-server/tests/unit/test_input_event_codes.py b/mcp-server/tests/unit/test_input_event_codes.py new file mode 100644 index 00000000000..f92698edfe0 --- /dev/null +++ b/mcp-server/tests/unit/test_input_event_codes.py @@ -0,0 +1,90 @@ +"""Pin `InputEventCode` values to the firmware `input_broker_event` enum. + +If this test fails, someone changed the firmware enum (or this Python +mirror) and they must stay in sync — the admin RPC sends these as u8 +wire values directly. + +Also exercises `coerce_event_code` for the happy + error paths. +""" + +from __future__ import annotations + +import pytest +from meshtastic_mcp.input_events import InputEventCode, coerce_event_code + + +class TestInputEventCodeValues: + """These values MUST match src/input/InputBroker.h exactly.""" + + def test_navigation_keys(self) -> None: + assert int(InputEventCode.UP) == 17 + assert int(InputEventCode.DOWN) == 18 + assert int(InputEventCode.LEFT) == 19 + assert int(InputEventCode.RIGHT) == 20 + + def test_action_keys(self) -> None: + assert int(InputEventCode.SELECT) == 10 + assert int(InputEventCode.CANCEL) == 24 + assert int(InputEventCode.BACK) == 27 + + def test_long_press_variants(self) -> None: + assert int(InputEventCode.SELECT_LONG) == 11 + assert int(InputEventCode.UP_LONG) == 12 + assert int(InputEventCode.DOWN_LONG) == 13 + + def test_fn_keys(self) -> None: + assert int(InputEventCode.FN_F1) == 0xF1 + assert int(InputEventCode.FN_F2) == 0xF2 + assert int(InputEventCode.FN_F3) == 0xF3 + assert int(InputEventCode.FN_F4) == 0xF4 + assert int(InputEventCode.FN_F5) == 0xF5 + + def test_system_events(self) -> None: + assert int(InputEventCode.SHUTDOWN) == 0x9B + assert int(InputEventCode.GPS_TOGGLE) == 0x9E + assert int(InputEventCode.SEND_PING) == 0xAF + + def test_auto_increment_block(self) -> None: + # C enum: `BACK = 27, USER_PRESS, ALT_PRESS, ALT_LONG` → 28, 29, 30. + assert int(InputEventCode.USER_PRESS) == 28 + assert int(InputEventCode.ALT_PRESS) == 29 + assert int(InputEventCode.ALT_LONG) == 30 + + +class TestCoerceEventCode: + def test_int_passthrough(self) -> None: + assert coerce_event_code(20) == 20 + assert coerce_event_code(0) == 0 + assert coerce_event_code(255) == 255 + + def test_enum_passthrough(self) -> None: + assert coerce_event_code(InputEventCode.RIGHT) == 20 + assert coerce_event_code(InputEventCode.FN_F1) == 0xF1 + + def test_name_case_insensitive(self) -> None: + assert coerce_event_code("right") == 20 + assert coerce_event_code("RIGHT") == 20 + assert coerce_event_code("Right") == 20 + + def test_input_broker_prefix_stripped(self) -> None: + assert coerce_event_code("INPUT_BROKER_FN_F1") == 0xF1 + assert coerce_event_code("input_broker_select") == 10 + + def test_hyphen_and_underscore_equivalence(self) -> None: + assert coerce_event_code("fn-f1") == 0xF1 + + def test_int_out_of_range_raises(self) -> None: + with pytest.raises(ValueError, match="u8"): + coerce_event_code(256) + with pytest.raises(ValueError, match="u8"): + coerce_event_code(-1) + + def test_unknown_name_raises(self) -> None: + with pytest.raises(ValueError, match="unknown event code name"): + coerce_event_code("NOT_A_KEY") + + def test_wrong_type_raises(self) -> None: + with pytest.raises(TypeError): + coerce_event_code(1.5) # type: ignore[arg-type] + with pytest.raises(TypeError): + coerce_event_code(None) # type: ignore[arg-type] diff --git a/mcp-server/tests/unit/test_uhubctl_parser.py b/mcp-server/tests/unit/test_uhubctl_parser.py new file mode 100644 index 00000000000..37314795079 --- /dev/null +++ b/mcp-server/tests/unit/test_uhubctl_parser.py @@ -0,0 +1,148 @@ +"""Pin the `uhubctl` default-output parser against canned real-world samples. + +uhubctl's output format has been stable since v2.x but occasionally adds +new hub-descriptor fields (e.g. the `, ppps` marker). The parser uses loose +regexes to tolerate additions; this test keeps us honest. + +Samples captured from: +- v2.6.0 on macOS (Homebrew) — two USB2 hubs, one populated with an + nRF52 and a CP2102, plus chained USB3 hubs. +- v2.5.0 on Linux (hypothetical — reconstructed from the project README). +""" + +from __future__ import annotations + +import pytest +from meshtastic_mcp.uhubctl import ( + ROLE_VIDS, + UhubctlError, + parse_list_output, +) + +# Actual `uhubctl` stdout on the developer's macOS bench, Apr 2026. +_SAMPLE_MACOS_V26 = """\ +Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps] + Port 1: 0100 power + Port 2: 0103 power enable connect [239a:8029 RAKwireless WisCore RAK4631 Board 920456B1E6972262] + Port 3: 0103 power enable connect [10c4:ea60 Silicon Labs CP2102 USB to UART Bridge Controller 0001] + Port 4: 0100 power +Current status for hub 1-2.3 [2109:0817 VIA Labs, Inc. USB3.0 Hub, USB 3.10, 4 ports, ppps] + Port 1: 02a0 power 5gbps Rx.Detect + Port 2: 02a0 power 5gbps Rx.Detect + Port 3: 02a0 power 5gbps Rx.Detect + Port 4: 02a0 power 5gbps Rx.Detect +Current status for hub 1-1 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps] + Port 1: 0100 power + Port 2: 0100 power + Port 3: 0503 power highspeed enable connect [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps] + Port 4: 0100 power +""" + + +# Minimal Linux-style sample (fewer hubs, shows a non-PPPS hub). +_SAMPLE_LINUX_NONPPPS = """\ +Current status for hub 2-1.4 [05e3:0608 GenesysLogic USB2.1 Hub, USB 2.10, 4 ports] + Port 1: 0507 power highspeed suspend enable connect [239a:0029 Adafruit Feather Bootloader] + Port 2: 0100 power + Port 3: 0100 power + Port 4: 0100 power +""" + + +class TestParseListOutput: + def test_parses_macos_sample_hub_count(self) -> None: + hubs = parse_list_output(_SAMPLE_MACOS_V26) + assert len(hubs) == 3 + + def test_parses_hub_location_and_vid(self) -> None: + hubs = parse_list_output(_SAMPLE_MACOS_V26) + via_hub = hubs[0] + assert via_hub["location"] == "1-1.3" + assert via_hub["vid"] == 0x2109 + assert via_hub["pid"] == 0x2817 + assert via_hub["ppps"] is True + + def test_parses_port_with_device(self) -> None: + hubs = parse_list_output(_SAMPLE_MACOS_V26) + nrf52_hub = hubs[0] + port2 = next(p for p in nrf52_hub["ports"] if p["port"] == 2) + assert port2["device_vid"] == 0x239A + assert port2["device_pid"] == 0x8029 + assert "RAKwireless" in port2["device_desc"] + + def test_empty_port_has_no_device(self) -> None: + hubs = parse_list_output(_SAMPLE_MACOS_V26) + nrf52_hub = hubs[0] + port1 = next(p for p in nrf52_hub["ports"] if p["port"] == 1) + assert port1["device_vid"] is None + assert port1["device_pid"] is None + assert port1["device_desc"] is None + + def test_ports_count(self) -> None: + hubs = parse_list_output(_SAMPLE_MACOS_V26) + for hub in hubs: + assert len(hub["ports"]) == 4 # each sample hub has 4 ports + + def test_non_ppps_hub_flagged(self) -> None: + hubs = parse_list_output(_SAMPLE_LINUX_NONPPPS) + assert len(hubs) == 1 + assert hubs[0]["ppps"] is False + + def test_handles_empty_input(self) -> None: + assert parse_list_output("") == [] + + def test_handles_malformed_lines_gracefully(self) -> None: + # Lines that don't match HUB_RE or PORT_RE are ignored silently. + garbage = "uhubctl: warning: something weird\n" + _SAMPLE_LINUX_NONPPPS + hubs = parse_list_output(garbage) + assert len(hubs) == 1 + + +class TestRoleVids: + def test_nrf52_mapped(self) -> None: + assert 0x239A in ROLE_VIDS["nrf52"] + + def test_esp32s3_covers_both_vids(self) -> None: + # Espressif native USB + CP2102 USB-UART on heltec-v3 boards. + assert 0x303A in ROLE_VIDS["esp32s3"] + assert 0x10C4 in ROLE_VIDS["esp32s3"] + + +class TestResolveTargetErrorPaths: + def test_unknown_role_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + from meshtastic_mcp.uhubctl import resolve_target + + # Clear any env-var pinning that might make this pass accidentally. + for key in ( + "MESHTASTIC_UHUBCTL_LOCATION_FLUX", + "MESHTASTIC_UHUBCTL_PORT_FLUX", + ): + monkeypatch.delenv(key, raising=False) + with pytest.raises(UhubctlError, match="unknown role"): + resolve_target("flux") + + def test_invalid_env_port_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + from meshtastic_mcp.uhubctl import resolve_target + + monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "1-1.3") + monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "not-an-int") + with pytest.raises(UhubctlError, match="not a valid integer"): + resolve_target("nrf52") + + def test_env_var_pinning_wins(self, monkeypatch: pytest.MonkeyPatch) -> None: + from meshtastic_mcp.uhubctl import resolve_target + + # Env-var pinning should NOT require uhubctl to be running / installed. + monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "9-9.9") + monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "7") + assert resolve_target("nrf52") == ("9-9.9", 7) + + def test_normalize_role_strips_alt_suffix( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from meshtastic_mcp.uhubctl import resolve_target + + # esp32s3_alt collapses to esp32s3 for env-var lookup. + monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_ESP32S3", "2-2") + monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_ESP32S3", "3") + assert resolve_target("esp32s3_alt") == ("2-2", 3) diff --git a/mcp-server/tests/unit/test_ui_screen_log.py b/mcp-server/tests/unit/test_ui_screen_log.py new file mode 100644 index 00000000000..55029b38c8a --- /dev/null +++ b/mcp-server/tests/unit/test_ui_screen_log.py @@ -0,0 +1,80 @@ +"""Pin the `Screen: frame N/M name=X reason=Y` regex + FrameEvent dataclass. + +The firmware-side format lives in `src/graphics/Screen.cpp::logFrameChange`; +if the format string changes, this test — and the parser in +`tests/ui/_screen_log.py` — have to be updated together. +""" + +from __future__ import annotations + +from tests.ui._screen_log import FRAME_RE, FrameEvent, iter_frame_events + + +class TestFrameEventParse: + def test_exact_firmware_output(self) -> None: + raw = "Screen: frame 2/8 name=home reason=next" + evt = FrameEvent.parse(raw) + assert evt is not None + assert evt.idx == 2 + assert evt.count == 8 + assert evt.name == "home" + assert evt.reason == "next" + assert evt.raw == raw + + def test_with_log_prefix(self) -> None: + """Log lines may be preamble-wrapped by the firmware LOG_INFO macro + (timestamp, severity, etc.) — the regex uses .search() not .match() + so prefixes are fine.""" + raw = "[INFO] 00:12:34 567 Screen: frame 4/12 name=nodelist_nodes reason=fn_f3 " + evt = FrameEvent.parse(raw) + assert evt is not None + assert evt.idx == 4 + assert evt.count == 12 + assert evt.name == "nodelist_nodes" + assert evt.reason == "fn_f3" + + def test_rebuild_reason(self) -> None: + evt = FrameEvent.parse("Screen: frame 0/5 name=deviceFocused reason=rebuild") + assert evt is not None + assert evt.reason == "rebuild" + + def test_all_fn_reasons(self) -> None: + for k in range(1, 6): + evt = FrameEvent.parse( + f"Screen: frame {k - 1}/8 name=settings reason=fn_f{k}" + ) + assert evt is not None and evt.reason == f"fn_f{k}" + + def test_unknown_name_is_preserved(self) -> None: + """If the reverse-map returns 'unknown', that still parses cleanly.""" + evt = FrameEvent.parse("Screen: frame 99/100 name=unknown reason=prev") + assert evt is not None and evt.name == "unknown" + + def test_non_matching_line_returns_none(self) -> None: + assert FrameEvent.parse("BOOT Booting firmware 2.7.23") is None + assert FrameEvent.parse("") is None + assert FrameEvent.parse("Screen: without the right format") is None + + +class TestIterFrameEvents: + def test_filters_non_matching_lines(self) -> None: + lines = [ + "Booting...", + "Screen: frame 1/5 name=home reason=rebuild", + "Some other log line", + "Screen: frame 2/5 name=textMessage reason=next", + ] + evts = list(iter_frame_events(lines)) + assert len(evts) == 2 + assert evts[0].reason == "rebuild" + assert evts[1].reason == "next" + + +class TestRegexAnchoring: + def test_regex_is_compiled(self) -> None: + assert FRAME_RE.search("Screen: frame 0/0 name=home reason=next") is not None + + def test_regex_allows_unusual_names(self) -> None: + r"""Name is `\S+`, so compound names with underscores/digits match.""" + m = FRAME_RE.search("Screen: frame 5/10 name=nodelist_hopsignal reason=fn_f2") + assert m is not None and m["name"] == "nodelist_hopsignal" diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index b9745b29a69..3d13a68af12 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -96,7 +96,6 @@ class ScanI2C CW2015, SCD30, ADS1115, - CST3530, } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 55ec93db52a..60e1c43a622 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1265,6 +1265,10 @@ void Screen::setFrames(FrameFocus focus) // Store the info about this frameset, for future setFrames calls this->framesetInfo = fsi; +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("rebuild", ui->getUiState()->currentFrame); +#endif + setFastFramerate(); // Draw ASAP } @@ -1419,11 +1423,77 @@ void Screen::handleOnPress() } } +#ifdef USERPREFS_UI_TEST_LOG +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` + // without caring about how the positions were ordered this boot. + const auto &p = framesetInfo.positions; + const char *name = "unknown"; + if (targetIdx == p.home) + name = "home"; + else if (targetIdx == p.deviceFocused) + name = "deviceFocused"; + else if (targetIdx == p.textMessage) + name = "textMessage"; + else if (targetIdx == p.nodelist_nodes) + name = "nodelist_nodes"; + else if (targetIdx == p.nodelist_location) + name = "nodelist_location"; + else if (targetIdx == p.nodelist_lastheard) + name = "nodelist_lastheard"; + else if (targetIdx == p.nodelist_hopsignal) + name = "nodelist_hopsignal"; + else if (targetIdx == p.nodelist_distance) + name = "nodelist_distance"; + else if (targetIdx == p.nodelist_bearings) + name = "nodelist_bearings"; + else if (targetIdx == p.system) + name = "system"; + else if (targetIdx == p.gps) + name = "gps"; + else if (targetIdx == p.lora) + name = "lora"; + else if (targetIdx == p.clock) + name = "clock"; + else if (targetIdx == p.chirpy) + name = "chirpy"; + else if (targetIdx == p.fault) + name = "fault"; + else if (targetIdx == p.waypoint) + name = "waypoint"; + else if (targetIdx == p.focusedModule) + name = "focusedModule"; + else if (targetIdx == p.log) + name = "log"; + else if (targetIdx == p.settings) + name = "settings"; + else if (targetIdx == p.wifi) + name = "wifi"; + else if (p.firstFavorite != 255 && p.lastFavorite != 255 && targetIdx >= p.firstFavorite && targetIdx <= p.lastFavorite) + name = "favorite"; + LOG_INFO("Screen: frame %u/%u name=%s reason=%s", (unsigned)targetIdx, (unsigned)framesetInfo.frameCount, name, reason); +} +#endif + void Screen::showFrame(FrameDirection direction) { // Only advance frames when UI is stable if (ui->getUiState()->frameState == FIXED) { +#ifdef USERPREFS_UI_TEST_LOG + // Log the *intended* target before the (async) transition fires, so + // tests see a deterministic record of what was requested. + if (framesetInfo.frameCount > 0) { + uint8_t curr = ui->getUiState()->currentFrame; + uint8_t target = (direction == FrameDirection::NEXT) + ? (uint8_t)((curr + 1) % framesetInfo.frameCount) + : (uint8_t)((curr + framesetInfo.frameCount - 1) % framesetInfo.frameCount); + logFrameChange(direction == FrameDirection::NEXT ? "next" : "prev", target); + } +#endif + if (direction == FrameDirection::NEXT) { ui->nextFrame(); } else { @@ -1755,22 +1825,37 @@ int Screen::handleInputEvent(const InputEvent *event) showFrame(FrameDirection::NEXT); } else if (event->inputEvent == INPUT_BROKER_FN_F1) { this->ui->switchToFrame(0); +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("fn_f1", 0); +#endif lastScreenTransition = millis(); setFastFramerate(); } else if (event->inputEvent == INPUT_BROKER_FN_F2) { this->ui->switchToFrame(1); +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("fn_f2", 1); +#endif lastScreenTransition = millis(); setFastFramerate(); } else if (event->inputEvent == INPUT_BROKER_FN_F3) { this->ui->switchToFrame(2); +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("fn_f3", 2); +#endif lastScreenTransition = millis(); setFastFramerate(); } else if (event->inputEvent == INPUT_BROKER_FN_F4) { this->ui->switchToFrame(3); +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("fn_f4", 3); +#endif lastScreenTransition = millis(); setFastFramerate(); } else if (event->inputEvent == INPUT_BROKER_FN_F5) { this->ui->switchToFrame(4); +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("fn_f5", 4); +#endif lastScreenTransition = millis(); setFastFramerate(); } else if (event->inputEvent == INPUT_BROKER_UP_LONG) { diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index e259f7691e0..023f36f3877 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -673,6 +673,16 @@ class Screen : public concurrency::OSThread void handleOnPress(); void handleStartFirmwareUpdateScreen(); +#ifdef USERPREFS_UI_TEST_LOG + // 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. + // Member function (not free) because FramesetInfo is a private nested + // type — only methods of Screen can reach it. + void logFrameChange(const char *reason, uint8_t targetIdx); +#endif + // Info collected by setFrames method. // Index location of specific frames. // - Used to apply the FrameFocus parameter of setFrames diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 7492d736143..8a1843bcb2f 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1489,8 +1489,15 @@ void AdminModule::handleSendInputEvent(const meshtastic_AdminMessage_InputEvent LOG_DEBUG("Processing input event: event_code=%u, kb_char=%u, touch_x=%u, touch_y=%u", inputEvent.event_code, inputEvent.kb_char, inputEvent.touch_x, inputEvent.touch_y); - // Create InputEvent for injection - InputEvent event = {.inputEvent = (input_broker_event)inputEvent.event_code, + // Create InputEvent for injection. + // + // `.source` MUST be a non-null C string: the LOG_INFO below formats it + // with %s, and passing NULL to the esp-log formatter crashes with + // Guru Meditation LoadProhibited at strlen(NULL). Other InputBroker + // sources (buttons, rotary) always set this; the admin path was the + // only one leaving it default-null. + InputEvent event = {.source = "admin", + .inputEvent = (input_broker_event)inputEvent.event_code, .kbchar = (unsigned char)inputEvent.kb_char, .touchX = inputEvent.touch_x, .touchY = inputEvent.touch_y}; diff --git a/userPrefs.jsonc b/userPrefs.jsonc index b81f09362d9..a8201bab3f4 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -57,6 +57,7 @@ // "USERPREFS_MQTT_ROOT_TOPIC": "event/REPLACEME", // "USERPREFS_RINGTONE_NAG_SECS": "60", // "USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS": "43200", + // "USERPREFS_UI_TEST_LOG": "true", // Test-only: emits `Screen: frame N/M name=... reason=...` log per UI transition (for the mcp-server ui test tier); off in release builds. "USERPREFS_RINGTONE_RTTTL": "24:d=32,o=5,b=565:f6,p,f6,4p,p,f6,p,f6,2p,p,b6,p,b6,p,b6,p,b6,p,b,p,b,p,b,p,b,p,b,p,b,p,b,p,b,1p.,2p.,p", // "USERPREFS_NETWORK_IPV6_ENABLED": "1", "USERPREFS_TZ_STRING": "tzplaceholder " From f396200d3856bf6f6453a4815dd6c6da96dabac2 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:30:50 +0100 Subject: [PATCH 020/225] Add authoring guide for native unit tests in README.md (#10201) * Add authoring guide for native unit tests in README.md * Enhance documentation for agent tooling and native unit tests in README and related files --------- Co-authored-by: Ben Meadors --- .github/copilot-instructions.md | 19 ++ test/README.md | 322 ++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 test/README.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7c71a501485..89e1c5c119b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -296,6 +296,23 @@ Key defines in variant.h: ## Build System +## Agent Tooling Baseline + +Mirror counterpart: `AGENTS.md` under **Agent Tooling Baseline**. + +To reduce avoidable agent mistakes, assume these tools are available (or install them before significant repo work): + +- **Required CLI basics**: `bash`, `git`, `find`, `grep`, `sed`, `awk`, `xargs` +- **Strongly recommended**: `rg` (ripgrep) for fast file/text search, `jq` for JSON processing +- **Build/test tools**: `python3`, `pip`, virtualenv (`python3 -m venv`), `platformio` (`pio`) +- **Containerized native testing**: `docker` (especially important on macOS / non-Linux hosts) + +Fallback expectations for agents: + +- If `rg` is unavailable, use `find` + `grep` instead of failing. +- For native tests on hosts without Linux deps, prefer `./bin/test-native-docker.sh`. +- The simulator helper script is `./bin/test-simulator.sh`. + Uses **PlatformIO** with custom scripts: - `bin/platformio-pre.py` - Pre-build script @@ -448,6 +465,8 @@ Run with: `pio test -e native` Simulation testing: `bin/test-simulator.sh` +Quick entry point for new test modules: `test/README.md` (native unit-test authoring guide, skeleton, pitfalls, and setup checklist). + ### Hardware-in-the-loop tests (`mcp-server/tests/`) Separate pytest suite that exercises real USB-connected Meshtastic devices. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules. diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000000..55dbd4775a8 --- /dev/null +++ b/test/README.md @@ -0,0 +1,322 @@ +# Native Unit Tests — Authoring Guide + +This directory contains C++ unit tests that run on the host machine via PlatformIO's native environment. Tests use the [Unity](http://www.throwtheswitch.org/unity) framework. + +## Running Tests + +```bash +# All test suites +pio test -e native + +# Single suite +pio test -e native -f test_your_module + +# Verbose (shows build errors in detail) +pio test -e native -f test_your_module -vvv +``` + +### Helper Scripts (Useful Shortcuts) + +These wrappers are handy when local host dependencies are missing or when you want repeatable commands. + +```bash +# Run native tests in Docker (recommended on macOS / non-Linux hosts) +./bin/test-native-docker.sh + +# Pass normal PlatformIO test args through to Dockerized test run +./bin/test-native-docker.sh -f test_your_module + +# Force Docker image rebuild (after dependency changes) +./bin/test-native-docker.sh --rebuild + +# Run simulator integration check (build native first) +pio run -e native && ./bin/test-simulator.sh + +# Build and run meshtasticd natively +./bin/native-run.sh + +# Build and run under gdbserver on localhost:2345 +./bin/native-gdbserver.sh + +# Build native release artifact into ./release/ +./bin/build-native.sh native +``` + +Notes: + +- The repository script name is `./bin/test-simulator.sh` (there is no `test-native-simulator.sh`). +- `./bin/test-native-docker.sh` is the closest match to CI behavior for native tests and avoids host package setup. + +### System Dependencies (Ubuntu/Debian) + +The native build requires several system libraries. Install them all at once: + +```bash +sudo apt-get install -y \ + libbluetooth-dev libgpiod-dev libyaml-cpp-dev openssl libssl-dev \ + libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev +``` + +See `.github/actions/setup-native/action.yml` for the canonical list. + +## Creating a New Test Suite + +### 1. Directory Structure + +```text +test/test_your_module/test_main.cpp +``` + +One file per suite. No per-test `platformio.ini` is needed — tests build under the `[env:native]` environment defined in the root `platformio.ini`. + +### 2. File Skeleton + +```cpp +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, etc.) +#include "TestUtil.h" // initializeTestEnvironment(), testDelay() +#include + +#if YOUR_FEATURE_GUARD // Same #if guard as the module under test + +#include "FSCommon.h" +#include "gps/RTC.h" +#include "mesh/NodeDB.h" +#include "modules/YourModule.h" +#include +#include +#include + +// --- Test output helpers --- +// Unity swallows printf/stdout. Only TEST_MESSAGE() output appears in results. +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ +} while(0) + +// --- Tests --- + +void test_example() +{ + TEST_MESSAGE("=== Example test ==="); + TEST_ASSERT_TRUE(true); +} + +// --- Unity lifecycle --- + +void setUp(void) { /* runs before every test */ } +void tearDown(void) { /* runs after every test */ } + +void setup() +{ + initializeTestEnvironment(); // MUST call — sets up RTC, OSThread, console + UNITY_BEGIN(); + RUN_TEST(test_example); + exit(UNITY_END()); // exit() required — Unity runner expects it +} + +void loop() {} + +#else // !YOUR_FEATURE_GUARD + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +void loop() {} + +#endif +``` + +### 3. Feature Guard + +Wrap the entire test body in the same `#if` guard the module uses (e.g. `#if HAS_VARIABLE_HOPS`, `#if !MESHTASTIC_EXCLUDE_GPS`). When the feature is disabled, the `#else` branch produces an empty passing suite. + +## Common Patterns + +### MockNodeDB + +Most module tests need to inject nodes with controlled hop distances and ages: + +```cpp +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + numMeshNodes = 0; + } + + void addTestNode(NodeNum num, uint8_t hopsAway, bool hasHops, + uint32_t ageSecs, bool viaMqtt = false) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + node.has_hops_away = hasHops; + node.hops_away = hopsAway; + node.via_mqtt = viaMqtt; + node.last_heard = getTime() - ageSecs; + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + std::vector testNodes; +}; + +static MockNodeDB *mockNodeDB = nullptr; +``` + +Set `nodeDB = mockNodeDB;` in `setUp()`. + +### Test Shim (Exposing Protected/Private Members) + +Subclass the module under test to make protected methods callable and private members writable: + +```cpp +class YourModuleTestShim : public YourModule +{ + public: + // Expose protected methods + using YourModule::runOnce; + using YourModule::someProtectedMethod; + + // Access private members via friend (see below) + void setPrivateField(int x) { privateField = x; } +}; +``` + +In the module header, grant friend access under the `UNIT_TEST` define (set automatically by PlatformIO's test framework): + +```cpp +// In YourModule.h, inside the class body: +#ifdef UNIT_TEST + friend class YourModuleTestShim; +#endif +``` + +### Global Singleton Lifecycle + +Most modules use a global pointer (`extern YourModule *yourModule;`). Manage it carefully: + +```cpp +void setUp(void) { + // ... setup ... +} + +void tearDown(void) { + yourModule = nullptr; // prevent dangling pointer between tests +} + +void test_something() { + auto shim = std::unique_ptr(new YourModuleTestShim()); + yourModule = shim.get(); + // ... test ... + yourModule = nullptr; +} +``` + +## Pitfalls and How to Avoid Them + +### 1. Persisted Filesystem State Leaks Between Tests + +Modules that save state to `/prefs/*.bin` will have that state loaded by the next test's constructor via `loadState()`. This causes values from one test (e.g. rolling averages from a megamesh scenario) to bleed into unrelated tests. + +**Fix:** Delete state files at the start of `setUp()`: + +```cpp +void setUp(void) { + // ... +#ifdef FSCom + FSCom.remove("/prefs/your_module.bin"); +#endif +} +``` + +### 2. File-Scope Mutable Globals Persist Across Tests + +Variables like `static uint8_t someDenominator = 8;` in the module `.cpp` file retain mutations from previous tests. This is distinct from member variables — it affects all instances. + +**Fix:** Add a `static void resetGlobal()` method to the module and call it in `setUp()`. + +### 3. Randomness Breaks Determinism + +If the module uses `rand()` for jitter or similar, test results become non-reproducible. + +**Fix:** Add a static enable/disable flag: + +```cpp +// Module header: +static void setJitter(bool enabled) { s_jitterEnabled = enabled; } + +// Test setUp: +YourModule::setJitter(false); + +// Test tearDown: +YourModule::setJitter(true); +``` + +### 4. Time-Dependent Logic Produces Zeros + +Rolling averages weighted by `elapsedMs / ONE_HOUR_MS` collapse to zero when tests complete in microseconds. Sample windows, EMA alphas, and interval-based accumulators all suffer from this. + +**Fix:** Expose the timestamp via friend access and simulate realistic elapsed time: + +```cpp +// In test shim: +void setWindowStartMs(uint32_t ms) { windowStartMs = ms; } + +// In test: +shim.setWindowStartMs(millis() - 3600000UL); // pretend 1 hour elapsed +``` + +### 5. Capacity Limits Cause Cascading Failures + +Fixed-size data structures (hash sets, ring buffers) overflow when tests inject more data than fits. This triggers early flushes with near-zero time fractions, compounding the time-dependent-zeros problem. + +**Fix:** Simulate multiple realistic time windows rather than one massive burst. Let adaptive mechanisms (if any) self-tune over several rolls. + +## setUp/tearDown Checklist + +- [ ] Create and clear MockNodeDB (if needed) +- [ ] Zero global configs: `config`, `moduleConfig`, `myNodeInfo` +- [ ] Set `nodeDB = mockNodeDB` +- [ ] Delete persisted state files (`FSCom.remove(...)`) +- [ ] Reset file-scope mutable globals +- [ ] Disable randomness/jitter flags +- [ ] In `tearDown`: null the global singleton pointer, restore flags + +## Test Organization + +A well-structured test suite follows this pattern: + +1. **Topology/scenario builders** — static helper functions that set up specific test conditions +2. **Injection helpers** — simulate realistic traffic, time, or event patterns +3. **Scenario tests** — each builds a scenario, runs the module, asserts on outcomes +4. **Lifecycle tests** — state persistence, startup from blank, restart recovery +5. **Summary test** (optional) — emits a scenario table into the log for quick CI review + +## Existing Test Suites + +| Suite | Module Under Test | +| ---------------------------- | ----------------------------- | +| `test_crypto` | CryptoEngine | +| `test_mqtt` | MQTT integration | +| `test_radio` | Radio interface | +| `test_mesh_module` | Module framework | +| `test_meshpacket_serializer` | Packet serialization | +| `test_transmit_history` | Retransmission tracking | +| `test_atak` | ATAK integration | +| `test_default` | Default configuration helpers | +| `test_http_content_handler` | HTTP handling | +| `test_serial` | Serial communication | +| `test_hop_scaling` | Hop scaling algorithm | +| `test_traffic_management` | Traffic management | From d50caf231bd93ce45182bf20bcb4a070a15ee670 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 19 Apr 2026 16:05:28 -0500 Subject: [PATCH 021/225] Add encryption overview to agent instructions in AGENTS.md (#10207) * Add encryption overview to agent instructions in AGENTS.md * Update AGENTS.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update copilot-instructions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update copilot-instructions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Clarify nonce and wire overhead details in encryption section of copilot instructions * Enhance encryption documentation in copilot instructions and agents guide for clarity on key management and reset behaviors * Update .github/copilot-instructions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix botched merge conflict resolution --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 66 ++++++++++++++++++++++++++++++++- AGENTS.md | 9 +++++ src/detect/ScanI2C.h | 1 + 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 89e1c5c119b..2d74571021c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -70,6 +70,70 @@ PKI (Public Key Infrastructure) messages have special handling: - Accepted on a special "PKI" channel - Allow encrypted DMs between nodes that discovered each other on downlink-enabled channels +## Encryption & Key Management + +Meshtastic packets on the air are typically encrypted one of two ways: the **per-channel symmetric** layer (AES-CTR with a shared PSK) for broadcasts and channel traffic, and the **per-peer PKI** layer (X25519 ECDH → AES-256-CCM) for direct messages and remote admin. A channel with a 0-byte PSK (or Ham mode, which wipes PSKs) transmits cleartext — see the size table below. Both are implemented in `src/mesh/CryptoEngine.cpp`; the send/receive dispatch lives in `src/mesh/Router.cpp`; admin authorization lives in `src/modules/AdminModule.cpp`. + +### High-level model + +- **Channels** are symmetric rooms: anyone with the PSK can read any message on the channel. Channel 0 is the "primary" channel and ships with the short-form default PSK on factory devices, forming the public mesh most users join. (The LoRa modem preset `LONG_FAST` lives on `config.lora.modem_preset` and is an independent field — don't conflate "channel 0 default PSK" with the modem preset name.) +- **DMs** addressed to a single node require PKI so that other holders of the channel PSK can't read them. Outside Ham mode, Meshtastic does not fall back to channel-symmetric encryption when the destination public key is unknown. +- **Remote admin** is a DM carrying an `AdminMessage`. The receiver only acts on it if the sender's public key is on its allowlist (`config.security.admin_key[0..2]`). +- **Ham mode** (`owner.is_licensed=true`, where `owner` is the local `meshtastic_User` record) disables PKI entirely and sends cleartext — FCC Part 97 prohibits encryption on amateur bands. +- **No ratchet, no session.** Every packet is encrypted from scratch — a stateless design that matches the high-loss, store-and-forward nature of LoRa. + +### Symmetric channel encryption (AES-CTR) + +`CryptoEngine::encryptPacket` / `decrypt` / `encryptAESCtr` in `src/mesh/CryptoEngine.cpp`. + +- **Cipher**: AES-CTR, AES-128 or AES-256 depending on key length. Same routine in both directions (CTR is a stream cipher, so encrypt == decrypt). +- **Key**: `ChannelSettings.psk` bytes. Size semantics: + - **0 bytes** → no encryption, cleartext on the air + - **1 byte** → short-form index into the well-known `defaultpsk[]` in `src/mesh/Channels.h`. Index 0 = cleartext; 1 = defaultpsk unchanged; 2..255 = defaultpsk with its last byte incremented by (index − 1). This is what the CLI's `--ch-set psk default` produces. + - **16 bytes** → raw AES-128 key + - **32 bytes** → raw AES-256 key + - **2..15 bytes** → zero-padded to 16 and used as AES-128 (with a warn log); **17..31 bytes** → zero-padded to 32 and used as AES-256 (with a warn log). Defensive fallback for malformed PSK input, not something to rely on. +- **Nonce (128 bit)**: `packet_id` (u64 LE) ‖ `from_node` (u32 LE) ‖ `block_counter` (u32, starts at 0). Built in `CryptoEngine::initNonce`. +- **No AEAD**: channel packets carry no MAC, so the channel-hash byte is not an integrity or authenticity check. `Channels::getHash` is a 1-byte XOR-derived hint over the channel name bytes and PSK bytes that helps receivers pick a candidate channel/PSK for decryption. Because it is only a small hint and collisions are easy to find, it should be described purely as a PSK-selection aid, not as a security filter an attacker cannot bypass. +- **Channel 0 is special in one way only**: it's the channel the Router attempts PKI decryption on before falling through to AES-CTR. Non-zero channels always go straight to AES-CTR. + +### PKI encryption for DMs (X25519 ECDH + AES-256-CCM) + +`CryptoEngine::encryptCurve25519` / `decryptCurve25519` in `src/mesh/CryptoEngine.cpp`. + +- **Keypair**: Curve25519 (aka X25519), 32-byte public + 32-byte private. Stored in `config.security.public_key` / `private_key`; the public half is mirrored into `owner.public_key` so it rides along in NodeInfo broadcasts and propagates through the mesh like any other identity field. +- **Key generation** (`generateKeyPair`): stirs `HardwareRNG::fill()` (64 B from platform TRNG when available), the 16-byte `myNodeInfo.device_id`, and a call to `random()` into the rweather/Crypto library's software RNG, then `Curve25519::dh1`. `regeneratePublicKey` recomputes the public half from a known private (used when restoring from backup). +- **Keygen entry points**: at boot, `NodeDB` calls `generateKeyPair` (or `regeneratePublicKey` when a stored private key is present and passes a low-entropy check) **directly** when `!owner.is_licensed` and `config.lora.region != UNSET`. `ensurePkiKeys` wraps the same logic for runtime/admin flows — it's the path `AdminModule::handleSetConfig` runs when first assigning a valid region or when security config is written; **do not assume it's the universal boot-time gate**, because the NodeDB path bypasses it. +- **Handshake**: `Curve25519::dh2(local_private, remote_public) → 32-byte shared secret → SHA-256 → 32-byte AES-256 key`. Recomputed per packet. The SHA-256 step is effectively a KDF over the raw ECDH output. +- **Cipher**: AES-256-CCM via `aes_ccm_ae` / `aes_ccm_ad` (`src/mesh/aes-ccm.cpp`). MAC length (the `M` parameter) is **8 bytes**. No AAD — the MAC covers ciphertext only. +- **Nonce (13 bytes / 104 bit)**: `aes_ccm_ae`/`aes_ccm_ad` use a 13-byte CCM nonce (`L = 2` is hardcoded in `src/mesh/aes-ccm.cpp`), not a 16-byte nonce. For PKI packets, `CryptoEngine::initNonce(fromNode, packetNum, extraNonce)` starts from the usual packet-derived nonce material, then overwrites nonce bytes `4..7` with a fresh 32-bit `extraNonce = random()`. The effective nonce bytes are therefore: bytes `0..3` = `packet_id`, bytes `4..7` = transmitted `extraNonce`, bytes `8..11` = `from_node`, byte `12` = `0x00`. The receiver reconstructs the same 13-byte nonce from the packet metadata plus the appended `extraNonce`. +- **Wire overhead**: 12 bytes appended to the ciphertext = 8-byte MAC ‖ 4-byte extraNonce. Defined as `MESHTASTIC_PKC_OVERHEAD = 12` in `src/mesh/RadioInterface.h`. Only the 4-byte `extraNonce` is sent; the rest of the 13-byte CCM nonce is reconstructed from packet fields as described above. The Router's send path checks this overhead against `MAX_LORA_PAYLOAD_LEN` before committing to PKI. +- **Send selection** (`Router::send`): the sender enters the PKI path when **all** hold — we're the originator AND not Ham mode AND not Portduino simradio AND not on the `serial`/`gpio` channels (unless the packet is already marked `pki_encrypted`) AND `config.security.private_key.size == 32` AND destination is a single node (not broadcast) AND the portnum isn't infrastructure. `TRACEROUTE_APP`, `NODEINFO_APP`, `ROUTING_APP`, and `POSITION_APP` are routed through channel encryption even when DMed (these need to be readable by relaying peers). Once on the PKI path, if the destination's public key isn't in our NodeDB the send **fails** with `PKI_SEND_FAIL_PUBLIC_KEY` — it does not silently fall back to channel encryption. If the client explicitly set `pki_encrypted=true` and any condition blocks PKI, the send fails with `PKI_FAILED`. +- **Receive selection** (`Router::perhapsDecode`): try PKI decrypt first when `channel == 0` AND `isToUs(p)` AND not broadcast AND both peers have public keys in NodeDB AND `rawSize > MESHTASTIC_PKC_OVERHEAD`. On success the packet gets `pki_encrypted=true` stamped and the sender's public key copied into `p->public_key` for downstream authorization. + +### Remote admin authorization + +Implemented in `src/modules/AdminModule.cpp` → `handleReceivedProtobuf`. The authorization check runs in this order: + +1. **Response messages** — if `messageIsResponse(r)` is true (the payload is a response to one of our earlier admin requests), it's accepted without any further check. The in-file comment flags this as a known-untightened gap: a stricter implementation would remember which `public_key` we last queried and reject responses that don't match. +2. **Local admin** — `mp.from == 0` (phone app over BLE, serial CLI, internal module); never travels over the air. **Rejected** if `config.security.is_managed` is true, because managed devices expect admin to arrive over the air through an authorized remote path. +3. **Legacy admin channel (deprecated)** — the packet arrived on a channel named literally `"admin"`. Gated by `config.security.admin_channel_enabled`; returns `NOT_AUTHORIZED` if the flag is false. Kept for backward compatibility; new deployments should use PKI admin. +4. **PKI admin (preferred for remote)** — `mp.pki_encrypted == true` AND `mp.public_key` matches one of `config.security.admin_key[0..2]` (up to three authorized 32-byte Curve25519 public keys, typically copied from the admin node's own `user.public_key`). +5. **Fallthrough** → `NOT_AUTHORIZED`. + +On top of authorization, any remote admin message that **mutates** state (not a request, not a response) also has to pass a session-key check (`checkPassKey`): the client must first pull a fresh 8-byte `session_passkey` via `get_admin_session_key_request`, then echo that passkey back in the mutating message. The device rotates the passkey after 150 s and rejects values older than 300 s — a narrow anti-replay window on top of the PKI layer. + +`config.security.is_managed = true` disables **local** admin writes (`mp.from == 0` is rejected). It does not by itself force every admin action through PKI — the legacy `"admin"` channel still authorizes remote admin when `config.security.admin_channel_enabled == true`. The AdminModule refuses to persist `is_managed=true` unless at least one `admin_key` is populated — a deliberate guard against operators locking themselves out. + +### Key-rotation hazards (actions that invalidate peers) + +- **`factory_reset_device`** (the "full" variant, calls `NodeDB::factoryReset(eraseBleBonds=true)`) → **wipes** the X25519 private key; a fresh keypair is generated on the next region-set. Every existing peer holds the old public key, so DMs to this node silently fail PKI decrypt until every peer re-exchanges NodeInfo. +- **`factory_reset_config`** (the "partial" variant, calls `NodeDB::factoryReset()` with `eraseBleBonds=false`) → **preserves** the X25519 private key in `installDefaultConfig(preserveKey=true)`; the public key is zeroed and gets rebuilt from the preserved private key on the next boot via the NodeDB path's `regeneratePublicKey` call. Identity is preserved and the mesh does not need to re-exchange keys. +- **`region=UNSET → valid region`** → `ensurePkiKeys` runs inside the same `handleSetConfig` path; missing keys get generated at that moment. +- **Ham mode transitions** — entering Ham mode (`user.is_licensed=true`) runs `Channels::ensureLicensedOperation`, which **wipes every channel PSK** (all traffic becomes cleartext) and disables the legacy admin channel. The X25519 private key is preserved on the device but not used because `Router::send` skips PKI when `owner.is_licensed` is true. Leaving Ham mode re-enables PKI with the preserved keypair but does not restore the wiped channel PSKs — the operator has to re-set them. +- **Channel 0 PSK change** → every peer must re-learn the channel hash; cached NodeInfo becomes temporarily unreachable until the next broadcast. +- **`security.private_key` blanked via admin** → regenerates both halves (unless in Ham mode) and propagates the new public key via NodeInfo. + ## Project Structure ``` @@ -80,7 +144,7 @@ firmware/ │ │ ├── NodeDB.* # Node database management │ │ ├── Router.* # Packet routing │ │ ├── Channels.* # Channel management -│ │ ├── CryptoEngine.* # AES-CCM encryption +│ │ ├── CryptoEngine.* # AES-CTR (channels) + X25519 ECDH→AES-256-CCM (PKI for DMs/admin) │ │ ├── *Interface.* # Radio interface implementations │ │ ├── api/ # WiFi/Ethernet server APIs (ServerAPI, PacketAPI) │ │ ├── http/ # HTTP server (WebServer, ContentHandler) diff --git a/AGENTS.md b/AGENTS.md index b3fa1970c88..8f34746403f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,15 @@ Three test-and-diagnose workflows exist as slash commands: Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude/commands/README.md` is the index. +## Encryption at a glance + +Two layers, both in `src/mesh/CryptoEngine.cpp`: + +- **Channel (symmetric)** — **AES-CTR** with a channel-wide PSK (AES-128 or AES-256). Nonce = packet_id ‖ from_node ‖ block_counter. No AEAD; integrity is soft (channel-hash filter). The well-known default PSK lives in `src/mesh/Channels.h`; a 1-byte PSK is a short-form index into it. +- **Per-peer PKI** — **X25519 ECDH** (Curve25519, 32-byte keys) → SHA-256 → **AES-256-CCM** with an 8-byte MAC. Fresh 32-bit `extraNonce` per packet, sent in the clear alongside the MAC. 12-byte wire overhead (`MESHTASTIC_PKC_OVERHEAD`). Used for DMs. Also used for remote admin (`src/modules/AdminModule.cpp`), where AdminMessage authorization is gated by `config.security.admin_key[0..2]`. Disabled entirely in Ham mode (`user.is_licensed=true`). + +Key rotation to never trigger casually: only the **full** factory reset (`factory_reset_device`, `eraseBleBonds=true`) wipes `security.private_key` and regenerates the keypair — every peer holds the old public key, so DMs silently fail PKI decrypt until NodeInfo re-exchanges. The **partial** config reset (`factory_reset_config`) preserves the private key and doesn't invalidate peer relationships. Explicitly blanking `security.private_key` via admin also triggers regen. See the **Encryption & Key Management** section of `.github/copilot-instructions.md` for the full spec (nonce layout, send/receive selection logic including infrastructure-portnum exceptions, admin-key + session-passkey authorization, `is_managed` scope, key-rotation hazards). + ## House rules - **No destructive device operations without operator approval.** `factory_reset`, `erase_and_flash`, `reboot`, `shutdown`, history-rewriting git ops — describe the action and stop. Operator authorizes. diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 3d13a68af12..054c7854baf 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -34,6 +34,7 @@ class ScanI2C SHT31, SHT4X, SHTC3, + SHTXX, LPS22HB, QMC6310U, QMC6310N, From 8627bce1a1fc175c402cfe352523300b84e2e75f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 04:55:10 -0500 Subject: [PATCH 022/225] Upgrade trunk (#10125) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index d0cbaa8bc57..7a8ca0203d7 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -8,15 +8,15 @@ plugins: uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.517 - - renovate@43.110.9 - - prettier@3.8.1 + - checkov@3.2.524 + - renovate@43.132.1 + - prettier@3.8.3 - trufflehog@3.94.3 - yamllint@1.38.0 - bandit@1.9.4 - - trivy@0.69.3 + - trivy@0.70.0 - taplo@0.10.0 - - ruff@0.15.9 + - ruff@0.15.11 - isort@8.0.1 - markdownlint@0.48.0 - oxipng@10.1.0 From eba74fa6e26ba15626afe0e0a873dbc1a9a67875 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 06:08:28 -0500 Subject: [PATCH 023/225] Update GxEPD2 to v1.6.9 (#10212) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32/m5stack_coreink/platformio.ini | 2 +- variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini | 2 +- variants/esp32s3/esp32-s3-pico/platformio.ini | 2 +- variants/esp32s3/t-deck-pro-v1_1/platformio.ini | 2 +- variants/esp32s3/t-deck-pro/platformio.ini | 2 +- variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini | 2 +- variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini | 2 +- variants/nrf52840/MakePython_nRF52840_eink/platformio.ini | 2 +- variants/nrf52840/TWC_mesh_v4/platformio.ini | 2 +- variants/nrf52840/rak4631_epaper/platformio.ini | 2 +- variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/variants/esp32/m5stack_coreink/platformio.ini b/variants/esp32/m5stack_coreink/platformio.ini index e107bd893c2..70ada7bf329 100644 --- a/variants/esp32/m5stack_coreink/platformio.ini +++ b/variants/esp32/m5stack_coreink/platformio.ini @@ -19,7 +19,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 lib_ignore = diff --git a/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini b/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini index 90e4910f473..71116279c5e 100644 --- a/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini +++ b/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini @@ -11,7 +11,7 @@ upload_speed = 921600 lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 build_unflags = ${esp32s3_base.build_unflags} -DARDUINO_USB_MODE=1 diff --git a/variants/esp32s3/esp32-s3-pico/platformio.ini b/variants/esp32s3/esp32-s3-pico/platformio.ini index 64f50f80e88..b5ff66b8595 100644 --- a/variants/esp32s3/esp32-s3-pico/platformio.ini +++ b/variants/esp32s3/esp32-s3-pico/platformio.ini @@ -23,4 +23,4 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 diff --git a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini index 1a9b20f760d..22432d769e2 100644 --- a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini +++ b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini @@ -30,7 +30,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 # renovate: datasource=git-refs depName=CSE_Touch packageName=https://github.com/CIRCUITSTATE/CSE_Touch gitBranch=main https://github.com/CIRCUITSTATE/CSE_Touch/archive/b44f23b6f870b848f1fbe453c190879bc6cfaafa.zip # renovate: datasource=github-tags depName=CSE_CST328 packageName=CIRCUITSTATE/CSE_CST328 diff --git a/variants/esp32s3/t-deck-pro/platformio.ini b/variants/esp32s3/t-deck-pro/platformio.ini index 93ef8babf4d..d1a2398a4a2 100644 --- a/variants/esp32s3/t-deck-pro/platformio.ini +++ b/variants/esp32s3/t-deck-pro/platformio.ini @@ -34,7 +34,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 # renovate: datasource=git-refs depName=CSE_Touch packageName=https://github.com/CIRCUITSTATE/CSE_Touch gitBranch=main https://github.com/CIRCUITSTATE/CSE_Touch/archive/b44f23b6f870b848f1fbe453c190879bc6cfaafa.zip # renovate: datasource=github-tags depName=CSE_CST328 packageName=CIRCUITSTATE/CSE_CST328 diff --git a/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini b/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini index fd159a6d23f..e0cdd73c503 100644 --- a/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini +++ b/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini @@ -12,5 +12,5 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/Dongle_ lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 debug_tool = jlink diff --git a/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini b/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini index 39b5dfbd4b7..217e0dd3a27 100644 --- a/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini +++ b/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini @@ -16,7 +16,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/ME25LS0 lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm) upload_protocol = nrfutil ;upload_port = /dev/ttyACM1 diff --git a/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini b/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini index ebea1ce9728..d89ef348def 100644 --- a/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini +++ b/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini @@ -15,6 +15,6 @@ lib_deps = # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 debug_tool = jlink ;upload_port = /dev/ttyACM4 \ No newline at end of file diff --git a/variants/nrf52840/TWC_mesh_v4/platformio.ini b/variants/nrf52840/TWC_mesh_v4/platformio.ini index c529caa0baa..8f7479f741c 100644 --- a/variants/nrf52840/TWC_mesh_v4/platformio.ini +++ b/variants/nrf52840/TWC_mesh_v4/platformio.ini @@ -9,5 +9,5 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/TWC_mes lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 debug_tool = jlink diff --git a/variants/nrf52840/rak4631_epaper/platformio.ini b/variants/nrf52840/rak4631_epaper/platformio.ini index caa6ea32829..728581e357e 100644 --- a/variants/nrf52840/rak4631_epaper/platformio.ini +++ b/variants/nrf52840/rak4631_epaper/platformio.ini @@ -15,7 +15,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631 lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library diff --git a/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini b/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini index 84a582fd93e..994b54a4044 100644 --- a/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini +++ b/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini @@ -17,7 +17,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631 lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 - zinggjm/GxEPD2@1.6.8 + zinggjm/GxEPD2@1.6.9 # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library From 4090d9f2b39d98f7b28081fa74a8a5a89dddb056 Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:50:01 -0500 Subject: [PATCH 024/225] SX126x: re-apply 0x8B5 register in resetAGC() to preserve RX sensitivity (#10219) The CALIBRATE_ALL (0x7F) command inside resetAGC() clears bit 0 of the undocumented 0x8B5 register. That bit is set once in init() by #9571 and #9777 to improve SX1262 RX sensitivity, and the AGC-reset path was not re-applying it. Result: every SX1262 node silently loses the RX sensitivity patch ~60s after boot and never recovers until reboot. Empirically confirmed on Heltec Mesh Node T114 (nRF52840 + SX1262): - Post-calibration read of 0x8B5 = 0x04 (bit 0 cleared) - After re-apply: 0x05 (bit 0 set) Reproducible every AGC_RESET_INTERVAL_MS tick. Fix re-applies the register bit alongside the existing post-calibration re-applies (setDio2AsRfSwitch, setRxBoostedGainMode). --- src/mesh/SX126xInterface.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index bcb08f2c59a..44c4a805ac3 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -455,6 +455,15 @@ template void SX126xInterface::resetAGC() // RX boosted gain mode lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + // Re-apply the undocumented 0x8B5 RX sensitivity patch that was set in init(). + // The CALIBRATE_ALL (0x7F) command above clears bit 0 of register 0x8B5, which + // silently removes the RX sensitivity improvement introduced in #9571 / #9777. + // Without this re-apply, every SX1262 node loses its RX boost ~60s after boot + // and never recovers until reboot. See empirical evidence in the PR description. + if (module.SPIsetRegValue(0x8B5, 0x01, 0, 0) != RADIOLIB_ERR_NONE) { + LOG_WARN("SX126x resetAGC: failed to re-apply 0x8B5 RX sensitivity patch"); + } + // 6. Resume receiving startReceive(); } From 84ce1ea14768a073f4bf0ee5f09cbb9c31e70930 Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:50:01 -0500 Subject: [PATCH 025/225] SX126x: re-apply 0x8B5 register in resetAGC() to preserve RX sensitivity (#10219) The CALIBRATE_ALL (0x7F) command inside resetAGC() clears bit 0 of the undocumented 0x8B5 register. That bit is set once in init() by #9571 and #9777 to improve SX1262 RX sensitivity, and the AGC-reset path was not re-applying it. Result: every SX1262 node silently loses the RX sensitivity patch ~60s after boot and never recovers until reboot. Empirically confirmed on Heltec Mesh Node T114 (nRF52840 + SX1262): - Post-calibration read of 0x8B5 = 0x04 (bit 0 cleared) - After re-apply: 0x05 (bit 0 set) Reproducible every AGC_RESET_INTERVAL_MS tick. Fix re-applies the register bit alongside the existing post-calibration re-applies (setDio2AsRfSwitch, setRxBoostedGainMode). --- src/mesh/SX126xInterface.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 2e9a3250d28..e777f204dfc 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -448,6 +448,15 @@ template void SX126xInterface::resetAGC() // RX boosted gain mode lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + // Re-apply the undocumented 0x8B5 RX sensitivity patch that was set in init(). + // The CALIBRATE_ALL (0x7F) command above clears bit 0 of register 0x8B5, which + // silently removes the RX sensitivity improvement introduced in #9571 / #9777. + // Without this re-apply, every SX1262 node loses its RX boost ~60s after boot + // and never recovers until reboot. See empirical evidence in the PR description. + if (module.SPIsetRegValue(0x8B5, 0x01, 0, 0) != RADIOLIB_ERR_NONE) { + LOG_WARN("SX126x resetAGC: failed to re-apply 0x8B5 RX sensitivity patch"); + } + // 6. Resume receiving startReceive(); } From 63bce1f01ae7d3efb154543b0b78057f21481b23 Mon Sep 17 00:00:00 2001 From: Jaime Roldan Date: Tue, 21 Apr 2026 09:52:19 -0500 Subject: [PATCH 026/225] fix(nodedb): force null-terminate name fields in UserLite/User conversions (#8174) (#10218) --- src/mesh/TypeConversions.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index 75195bd425f..201a703e210 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -81,7 +81,9 @@ meshtastic_UserLite TypeConversions::ConvertToUserLite(meshtastic_User user) meshtastic_UserLite lite = meshtastic_UserLite_init_default; strncpy(lite.long_name, user.long_name, sizeof(lite.long_name)); + lite.long_name[sizeof(lite.long_name) - 1] = '\0'; strncpy(lite.short_name, user.short_name, sizeof(lite.short_name)); + lite.short_name[sizeof(lite.short_name) - 1] = '\0'; lite.hw_model = user.hw_model; lite.role = user.role; lite.is_licensed = user.is_licensed; @@ -99,7 +101,9 @@ meshtastic_User TypeConversions::ConvertToUser(uint32_t nodeNum, meshtastic_User snprintf(user.id, sizeof(user.id), "!%08x", nodeNum); strncpy(user.long_name, lite.long_name, sizeof(user.long_name)); + user.long_name[sizeof(user.long_name) - 1] = '\0'; strncpy(user.short_name, lite.short_name, sizeof(user.short_name)); + user.short_name[sizeof(user.short_name) - 1] = '\0'; user.hw_model = lite.hw_model; user.role = lite.role; user.is_licensed = lite.is_licensed; From 5d9a2564e489f8216ba649f6c65a5a0eb708acff Mon Sep 17 00:00:00 2001 From: Jaime Roldan Date: Tue, 21 Apr 2026 09:52:19 -0500 Subject: [PATCH 027/225] fix(nodedb): force null-terminate name fields in UserLite/User conversions (#8174) (#10218) --- src/mesh/TypeConversions.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index 75195bd425f..201a703e210 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -81,7 +81,9 @@ meshtastic_UserLite TypeConversions::ConvertToUserLite(meshtastic_User user) meshtastic_UserLite lite = meshtastic_UserLite_init_default; strncpy(lite.long_name, user.long_name, sizeof(lite.long_name)); + lite.long_name[sizeof(lite.long_name) - 1] = '\0'; strncpy(lite.short_name, user.short_name, sizeof(lite.short_name)); + lite.short_name[sizeof(lite.short_name) - 1] = '\0'; lite.hw_model = user.hw_model; lite.role = user.role; lite.is_licensed = user.is_licensed; @@ -99,7 +101,9 @@ meshtastic_User TypeConversions::ConvertToUser(uint32_t nodeNum, meshtastic_User snprintf(user.id, sizeof(user.id), "!%08x", nodeNum); strncpy(user.long_name, lite.long_name, sizeof(user.long_name)); + user.long_name[sizeof(user.long_name) - 1] = '\0'; strncpy(user.short_name, lite.short_name, sizeof(user.short_name)); + user.short_name[sizeof(user.short_name) - 1] = '\0'; user.hw_model = lite.hw_model; user.role = lite.role; user.is_licensed = lite.is_licensed; From e1f50434890afa6263388781de4a5e4040cc3c11 Mon Sep 17 00:00:00 2001 From: Catalin Patulea Date: Fri, 10 Apr 2026 14:20:25 -0700 Subject: [PATCH 028/225] Delete PointerQueue::dequeuePtrFromISR, unused since commit db766f1 (#99). (#10090) --- src/mesh/PointerQueue.h | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/mesh/PointerQueue.h b/src/mesh/PointerQueue.h index b45245eb867..972eb65fd47 100644 --- a/src/mesh/PointerQueue.h +++ b/src/mesh/PointerQueue.h @@ -17,14 +17,4 @@ template class PointerQueue : public TypedQueue return this->dequeue(&p, maxWait) ? p : nullptr; } - -#ifdef HAS_FREE_RTOS - // returns a ptr or null if the queue was empty - T *dequeuePtrFromISR(BaseType_t *higherPriWoken) - { - T *p; - - return this->dequeueFromISR(&p, higherPriWoken) ? p : nullptr; - } -#endif }; From 23321c45882784aa0cdbf5f230709fe725ed7958 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Mon, 13 Apr 2026 21:05:23 +0400 Subject: [PATCH 029/225] Reduce key duplication by enabling hardware RNG (#8803) * Reduce key duplication by enabling hardware RNG * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Use micros() for worst case random seed for nrf52 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Minor cleanup, remove dead code and clarify comment * trunk * Add useRadioEntropy bool, default false. --------- Co-authored-by: Ben Meadors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Bennett --- src/mesh/CryptoEngine.cpp | 10 ++ src/mesh/HardwareRNG.cpp | 159 +++++++++++++++++++++++ src/mesh/HardwareRNG.h | 28 ++++ src/mesh/RadioLibInterface.cpp | 20 ++- src/mesh/RadioLibInterface.h | 6 + src/platform/nrf52/main-nrf52.cpp | 18 +-- src/platform/portduino/PortduinoGlue.cpp | 9 +- src/platform/rp2xx0/main-rp2xx0.cpp | 11 +- 8 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 src/mesh/HardwareRNG.cpp create mode 100644 src/mesh/HardwareRNG.h diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 72216a63c8f..1073cd2e4d4 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -4,6 +4,7 @@ #include #if !(MESHTASTIC_EXCLUDE_PKI) +#include "HardwareRNG.h" #include "NodeDB.h" #include "aes-ccm.h" #include "meshUtils.h" @@ -26,6 +27,15 @@ void CryptoEngine::generateKeyPair(uint8_t *pubKey, uint8_t *privKey) { // Mix in any randomness we can, to make key generation stronger. CryptRNG.begin(optstr(APP_VERSION)); + + uint8_t hardwareEntropy[64] = {0}; + if (HardwareRNG::fill(hardwareEntropy, sizeof(hardwareEntropy), true)) { + CryptRNG.stir(hardwareEntropy, sizeof(hardwareEntropy)); + } else { + LOG_WARN("Hardware entropy unavailable, falling back to software RNG"); + } + memset(hardwareEntropy, 0, sizeof(hardwareEntropy)); + if (myNodeInfo.device_id.size == 16) { CryptRNG.stir(myNodeInfo.device_id.bytes, myNodeInfo.device_id.size); } diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp new file mode 100644 index 00000000000..b455128ac1f --- /dev/null +++ b/src/mesh/HardwareRNG.cpp @@ -0,0 +1,159 @@ +#include "HardwareRNG.h" + +#include +#include +#include + +#include "configuration.h" + +#if HAS_RADIO +#include "RadioLibInterface.h" +#endif + +#if defined(ARCH_NRF52) +#include +extern Adafruit_nRFCrypto nRFCrypto; +#elif defined(ARCH_ESP32) +#include +#elif defined(ARCH_RP2040) +#include +#elif defined(ARCH_PORTDUINO) +#include +#include +#include +#endif + +namespace HardwareRNG +{ + +namespace +{ +void fillWithRandomDevice(uint8_t *buffer, size_t length) +{ + std::random_device rd; + size_t offset = 0; + while (offset < length) { + uint32_t value = rd(); + size_t toCopy = std::min(length - offset, sizeof(value)); + memcpy(buffer + offset, &value, toCopy); + offset += toCopy; + } +} + +#if HAS_RADIO +bool mixWithLoRaEntropy(uint8_t *buffer, size_t length) +{ + // Only attempt to pull entropy from the modem if it is initialized and exposes the helper. + // When the radio stack is disabled or has not yet been configured, we simply skip this step + // and return false so callers know no extra mixing occurred. + RadioLibInterface *radio = RadioLibInterface::instance; + if (!radio) { + LOG_ERROR("No radio instance available to provide entropy"); + return false; + } + + constexpr size_t chunkSize = 16; + uint8_t scratch[chunkSize]; + size_t offset = 0; + bool mixed = false; + + while (offset < length) { + size_t toCopy = std::min(length - offset, chunkSize); + + // randomBytes() returns false if the modem does not support it or is not ready + // (for instance, when the radio is powered down). We break immediately to avoid + // blocking or returning partially-filled entropy and simply report failure. + if (!radio->randomBytes(scratch, toCopy)) { + break; + } + + for (size_t i = 0; i < toCopy; ++i) { + buffer[offset + i] ^= scratch[i]; + } + + mixed = true; + offset += toCopy; + } + + // Avoid leaving the modem-sourced bytes sitting on the stack longer than needed. + if (mixed) { + memset(scratch, 0, sizeof(scratch)); + } + + return mixed; +} +#endif +} // namespace + +bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) +{ + if (!buffer || length == 0) { + return false; + } + + bool filled = false; + +#if defined(ARCH_NRF52) + // The Nordic SDK RNG provides cryptographic-quality randomness backed by hardware. + nRFCrypto.begin(); + auto result = nRFCrypto.Random.generate(buffer, length); + nRFCrypto.end(); + filled = result; +#elif defined(ARCH_ESP32) + // ESP32 exposes a true RNG via esp_fill_random(). + esp_fill_random(buffer, length); + filled = true; +#elif defined(ARCH_RP2040) + // RP2040 has a hardware random number generator accessible through the Arduino core. + size_t offset = 0; + while (offset < length) { + uint32_t value = rp2040.hwrand32(); + size_t toCopy = std::min(length - offset, sizeof(value)); + memcpy(buffer + offset, &value, toCopy); + offset += toCopy; + } + filled = true; +#elif defined(ARCH_PORTDUINO) + // Prefer the host OS RNG first when running under Portduino. + ssize_t generated = ::getrandom(buffer, length, 0); + if (generated == static_cast(length)) { + filled = true; + } + + if (!filled) { + fillWithRandomDevice(buffer, length); + filled = true; + } +#endif + + if (!filled) { + // As a last resort, fall back to std::random_device. This should only be reached + // if a platform-specific source was unavailable. + fillWithRandomDevice(buffer, length); + filled = true; + } + +#if HAS_RADIO + if (useRadioEntropy) { + // Best-effort: if the radio is active and can provide modem entropy, XOR it over the + // buffer to improve overall quality. We consider the filling a success if either a + // good platform RNG or the modem RNG provided data, so we return true as long as at + // least one of those steps succeeded. + filled = mixWithLoRaEntropy(buffer, length) || filled; + } +#endif + + return filled; +} + +bool seed(uint32_t &seedOut) +{ + uint32_t candidate = 0; + if (!fill(reinterpret_cast(&candidate), sizeof(candidate), true)) { + return false; + } + seedOut = candidate; + return true; +} + +} // namespace HardwareRNG diff --git a/src/mesh/HardwareRNG.h b/src/mesh/HardwareRNG.h new file mode 100644 index 00000000000..2dacb6c2398 --- /dev/null +++ b/src/mesh/HardwareRNG.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace HardwareRNG +{ + +/** + * Fill the provided buffer with random bytes sourced from the most + * appropriate hardware-backed RNG available on the current platform. + * + * @param buffer Destination buffer for random bytes + * @param length Number of bytes to write + * @param useRadioEntropy If true, attempt to mix radio entropy into the output as well. + * @return true if the buffer was fully populated with entropy, false on failure + */ +bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy = false); + +/** + * Populate a 32-bit seed value with hardware-backed randomness where possible. + * + * @param seedOut Destination for the generated seed value + * @return true if a seed was produced from a reliable entropy source + */ +bool seed(uint32_t &seedOut); + +} // namespace HardwareRNG diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 30cd587da23..7ef707e0db4 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -246,6 +246,24 @@ bool RadioLibInterface::findInTxQueue(NodeNum from, PacketId id) return txQueue.find(from, id); } +bool RadioLibInterface::randomBytes(uint8_t *buffer, size_t length) +{ + if (!buffer || length == 0 || !iface) { + return false; + } + + // Older RadioLib versions only expose random(min, max), so fill the buffer byte-by-byte. + for (size_t i = 0; i < length; ++i) { + int32_t value = iface->random(0, 255); + if (value < 0) { + return false; + } + buffer[i] = static_cast(value & 0xFF); + } + + return true; +} + /** radio helper thread callback. We never immediately transmit after any operation (either Rx or Tx). Instead we should wait a random multiple of 'slotTimes' (see definition in RadioInterface.h) taken from a contention window (CW) to lower the chance of collision. @@ -587,4 +605,4 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) return res == RADIOLIB_ERR_NONE; } -} \ No newline at end of file +} diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index ca3d78503ed..310ca76bb24 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -172,6 +172,12 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified /** Attempt to find a packet in the TxQueue. Returns true if the packet was found. */ virtual bool findInTxQueue(NodeNum from, PacketId id) override; + /** + * Request randomness sourced from the LoRa modem, if supported by the active RadioLib interface. + * @return true if len bytes were produced, false otherwise. + */ + bool randomBytes(uint8_t *buffer, size_t length); + private: /** if we have something waiting to send, start a short (random) timer so we can come check for collision before actually * doing the transmit */ diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index 73780b6eb23..5cf3a4465b0 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -17,6 +17,7 @@ #include #include // #include +#include "HardwareRNG.h" #include "NodeDB.h" #include "PowerMon.h" #include "error.h" @@ -398,15 +399,14 @@ void nrf52Setup() #endif // Init random seed - union seedParts { - uint32_t seed32; - uint8_t seed8[4]; - } seed; - nRFCrypto.begin(); - nRFCrypto.Random.generate(seed.seed8, sizeof(seed.seed8)); - LOG_DEBUG("Set random seed %u", seed.seed32); - randomSeed(seed.seed32); - nRFCrypto.end(); + uint32_t seed = 0; + if (!HardwareRNG::seed(seed)) { + LOG_WARN("Hardware RNG seed unavailable, using PRNG fallback"); + // Use a hardware timer value as a fallback seed for better entropy + seed = micros(); + } + LOG_DEBUG("Set random seed %u", seed); + randomSeed(seed); // Set up nrfx watchdog. Do not enable the watchdog yet (we do that // the first time through the main loop), so that other threads can diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 4bbdbee7a2d..9e0a1b2a57e 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -1,4 +1,5 @@ #include "CryptoEngine.h" +#include "HardwareRNG.h" #include "PortduinoGPIO.h" #include "SPIChip.h" #include "mesh/RF95Interface.h" @@ -233,7 +234,9 @@ void portduinoSetup() std::cout << "Running in simulated mode." << std::endl; portduino_config.MaxNodes = 200; // Default to 200 nodes // Set the random seed equal to TCPPort to have a different seed per instance - randomSeed(TCPPort); + uint32_t seed = TCPPort; + HardwareRNG::seed(seed); + randomSeed(seed); return; } @@ -512,7 +515,9 @@ void portduinoSetup() #endif printf("MAC ADDRESS: %02X:%02X:%02X:%02X:%02X:%02X\n", dmac[0], dmac[1], dmac[2], dmac[3], dmac[4], dmac[5]); // Rather important to set this, if not running simulated. - randomSeed(time(NULL)); + uint32_t seed = static_cast(time(NULL)); + HardwareRNG::seed(seed); + randomSeed(seed); std::string defaultGpioChipName = gpioChipName + std::to_string(portduino_config.lora_default_gpiochip); diff --git a/src/platform/rp2xx0/main-rp2xx0.cpp b/src/platform/rp2xx0/main-rp2xx0.cpp index 6c73e385acc..e59b0a9cda2 100644 --- a/src/platform/rp2xx0/main-rp2xx0.cpp +++ b/src/platform/rp2xx0/main-rp2xx0.cpp @@ -1,3 +1,4 @@ +#include "HardwareRNG.h" #include "configuration.h" #include "hardware/xosc.h" #include @@ -98,10 +99,12 @@ void getMacAddr(uint8_t *dmac) void rp2040Setup() { - /* Sets a random seed to make sure we get different random numbers on each boot. - Taken from CPU cycle counter and ROSC oscillator, so should be pretty random. - */ - randomSeed(rp2040.hwrand32()); + /* Sets a random seed to make sure we get different random numbers on each boot. */ + uint32_t seed = 0; + if (!HardwareRNG::seed(seed)) { + seed = rp2040.hwrand32(); + } + randomSeed(seed); #ifdef RP2040_SLOW_CLOCK uint f_pll_sys = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_PLL_SYS_CLKSRC_PRIMARY); From f5be09c123f886aabe0547082049ea053b1f5f1c Mon Sep 17 00:00:00 2001 From: Ruledo Date: Thu, 16 Apr 2026 08:41:06 -0700 Subject: [PATCH 030/225] Add Luckfox Pico Max Waveshare Pico LoRa config (#10175) Add a meshtasticd config for the Luckfox Pico Max with the Waveshare Pico LoRa SX1262 TCXO HAT. Tested on hardware with successful SX1262 init, broadcast, and direct messaging. --- ...fox-pico-max-ws-raspberry-pi-pico-hat.yaml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml diff --git a/bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml b/bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml new file mode 100644 index 00000000000..e0cc6197b5e --- /dev/null +++ b/bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml @@ -0,0 +1,31 @@ +# For use with Armbian luckfox-pico-max +# Waveshare LoRa HAT for Raspberry Pi Pico +# https://www.waveshare.com/wiki/Pico-LoRa-SX1262 + +Meta: + name: luckfox-pico-max-ws-raspberry-pi-pico-hat + support: community + compatible: + - luckfox-pico-max # Armbian + +Lora: + Module: sx1262 + DIO2_AS_RF_SWITCH: true + DIO3_TCXO_VOLTAGE: true + spidev: spidev0.0 + Busy: # GPIO1_C7 / GP2 + pin: 55 + gpiochip: 1 + line: 23 + CS: # GPIO1_C6 / GP3 + pin: 54 + gpiochip: 1 + line: 22 + Reset: # GPIO1_D1 / GP15 + pin: 57 + gpiochip: 1 + line: 25 + IRQ: # GPIO2_A2 / GP20 + pin: 66 + gpiochip: 2 + line: 2 From 25febfdeee26f1d3e53337c54d853ecb158de31e Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 16 Apr 2026 13:12:31 -0500 Subject: [PATCH 031/225] More cleanly remove LED_BUILTIN (#10179) * Test PR to remove LED_BUILTIN Comment out the LED_BUILTIN definition in platformio.ini * Add LED_BUILTIN definition to nrf52840.ini --- platformio.ini | 2 +- variants/nrf52840/nrf52840.ini | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/platformio.ini b/platformio.ini index 4d66cf53875..529dbacabe4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -58,7 +58,7 @@ build_flags = -Wno-missing-field-initializers -DMESHTASTIC_EXCLUDE_POWERMON=1 -DMESHTASTIC_EXCLUDE_STATUS=1 -D MAX_THREADS=40 ; As we've split modules, we have more threads to manage - -DLED_BUILTIN=-1 + #-DLED_BUILTIN=-1 #-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now #-D OLED_PL=1 #-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs diff --git a/variants/nrf52840/nrf52840.ini b/variants/nrf52840/nrf52840.ini index 09b2ef97d7b..c5590cbc3f6 100644 --- a/variants/nrf52840/nrf52840.ini +++ b/variants/nrf52840/nrf52840.ini @@ -4,6 +4,7 @@ extends = nrf52_base build_flags = ${nrf52_base.build_flags} -DSERIAL_BUFFER_SIZE=4096 + -DLED_BUILTIN=-1 lib_deps = ${nrf52_base.lib_deps} @@ -79,4 +80,4 @@ debug_speed = 4000 ; The following is not needed because it automatically tries do this ;debug_server_ready_pattern = -.*GDB server started on port \d+.* -;debug_port = localhost:3333 \ No newline at end of file +;debug_port = localhost:3333 From d8e4389da2bbfc5df6bc3c355d48adbe7209c95d Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 16 Apr 2026 21:34:28 -0500 Subject: [PATCH 032/225] No longer need undefines, thanks to #10179 (#10180) --- platformio.ini | 1 - variants/esp32/chatter2/platformio.ini | 1 - variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini | 1 - variants/esp32/diy/hydra/platformio.ini | 1 - variants/esp32/diy/v1/platformio.ini | 1 - variants/esp32/heltec_v2.1/platformio.ini | 1 - variants/esp32/heltec_v2/platformio.ini | 1 - variants/esp32/nano-g1-explorer/platformio.ini | 1 - variants/esp32/nano-g1/platformio.ini | 1 - variants/esp32/radiomaster_900_bandit/platformio.ini | 1 - variants/esp32/radiomaster_900_bandit_micro/platformio.ini | 1 - variants/esp32/radiomaster_900_bandit_nano/platformio.ini | 1 - variants/esp32/station-g1/platformio.ini | 1 - variants/esp32/tbeam/platformio.ini | 1 - variants/esp32/tlora_v1/platformio.ini | 1 - variants/esp32/tlora_v2_1_16/platformio.ini | 2 +- variants/esp32/tlora_v2_1_16_tcxo/platformio.ini | 1 - variants/esp32/tlora_v3_3_0_tcxo/platformio.ini | 1 - variants/esp32c6/tlora_c6/platformio.ini | 1 - variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini | 1 - variants/esp32s3/heltec_sensor_hub/platformio.ini | 1 - variants/esp32s3/heltec_v3/platformio.ini | 1 - variants/esp32s3/heltec_v4/platformio.ini | 1 - variants/esp32s3/heltec_wsl_v3/platformio.ini | 1 - variants/rp2040/rpipicow/platformio.ini | 1 - 25 files changed, 1 insertion(+), 25 deletions(-) diff --git a/platformio.ini b/platformio.ini index 529dbacabe4..cd22fab6e81 100644 --- a/platformio.ini +++ b/platformio.ini @@ -58,7 +58,6 @@ build_flags = -Wno-missing-field-initializers -DMESHTASTIC_EXCLUDE_POWERMON=1 -DMESHTASTIC_EXCLUDE_STATUS=1 -D MAX_THREADS=40 ; As we've split modules, we have more threads to manage - #-DLED_BUILTIN=-1 #-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now #-D OLED_PL=1 #-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index 62d23b1e6e0..a14e407a10c 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -8,7 +8,6 @@ build_flags = -I variants/esp32/chatter2 -DMESHTASTIC_EXCLUDE_WEBSERVER=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 - -ULED_BUILTIN lib_deps = ${esp32_base.lib_deps} diff --git a/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini index 3fdb738fc07..2ddc5a2dbf7 100644 --- a/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini +++ b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini @@ -10,7 +10,6 @@ build_flags = -D EBYTE_E22 -D EBYTE_E22_900M30S ; Assume Tx power curve is identical to 900M30S as there is no documentation -I variants/esp32/diy/9m2ibr_aprs_lora_tracker - -ULED_BUILTIN build_src_filter = ${esp32_base.build_src_filter} +<../variants/esp32/diy/9m2ibr_aprs_lora_tracker> \ No newline at end of file diff --git a/variants/esp32/diy/hydra/platformio.ini b/variants/esp32/diy/hydra/platformio.ini index f23224f0bfe..3afd17e0106 100644 --- a/variants/esp32/diy/hydra/platformio.ini +++ b/variants/esp32/diy/hydra/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D DIY_V1 -I variants/esp32/diy/hydra - -ULED_BUILTIN diff --git a/variants/esp32/diy/v1/platformio.ini b/variants/esp32/diy/v1/platformio.ini index 6be2bfd09fb..3d31fc24a01 100644 --- a/variants/esp32/diy/v1/platformio.ini +++ b/variants/esp32/diy/v1/platformio.ini @@ -17,4 +17,3 @@ build_flags = -D DIY_V1 -D EBYTE_E22 -I variants/esp32/diy/v1 - -ULED_BUILTIN diff --git a/variants/esp32/heltec_v2.1/platformio.ini b/variants/esp32/heltec_v2.1/platformio.ini index 9fcb2388a68..1f7caa16f8b 100644 --- a/variants/esp32/heltec_v2.1/platformio.ini +++ b/variants/esp32/heltec_v2.1/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D HELTEC_V2_1 -I variants/esp32/heltec_v2.1 - -ULED_BUILTIN diff --git a/variants/esp32/heltec_v2/platformio.ini b/variants/esp32/heltec_v2/platformio.ini index fc9e05115ed..5f15fb321d7 100644 --- a/variants/esp32/heltec_v2/platformio.ini +++ b/variants/esp32/heltec_v2/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D HELTEC_V2_0 -I variants/esp32/heltec_v2 - -ULED_BUILTIN diff --git a/variants/esp32/nano-g1-explorer/platformio.ini b/variants/esp32/nano-g1-explorer/platformio.ini index b27ebf28e80..6f57897a8c8 100644 --- a/variants/esp32/nano-g1-explorer/platformio.ini +++ b/variants/esp32/nano-g1-explorer/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D NANO_G1_EXPLORER -I variants/esp32/nano-g1-explorer - -ULED_BUILTIN diff --git a/variants/esp32/nano-g1/platformio.ini b/variants/esp32/nano-g1/platformio.ini index b2e392dbdcd..82d0f5e7335 100644 --- a/variants/esp32/nano-g1/platformio.ini +++ b/variants/esp32/nano-g1/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D NANO_G1 -I variants/esp32/nano-g1 - -ULED_BUILTIN diff --git a/variants/esp32/radiomaster_900_bandit/platformio.ini b/variants/esp32/radiomaster_900_bandit/platformio.ini index 0012f49d3eb..6729235ed30 100644 --- a/variants/esp32/radiomaster_900_bandit/platformio.ini +++ b/variants/esp32/radiomaster_900_bandit/platformio.ini @@ -9,7 +9,6 @@ build_flags = -DHAS_STK8XXX=1 -O2 -I variants/esp32/radiomaster_900_bandit - -ULED_BUILTIN board_build.f_cpu = 240000000L upload_protocol = esptool lib_deps = diff --git a/variants/esp32/radiomaster_900_bandit_micro/platformio.ini b/variants/esp32/radiomaster_900_bandit_micro/platformio.ini index e58d06f1eec..32e9280e1bb 100644 --- a/variants/esp32/radiomaster_900_bandit_micro/platformio.ini +++ b/variants/esp32/radiomaster_900_bandit_micro/platformio.ini @@ -13,6 +13,5 @@ build_flags = -DCONFIG_DISABLE_HAL_LOCKS=1 -O2 -I variants/esp32/radiomaster_900_bandit_nano - -ULED_BUILTIN board_build.f_cpu = 240000000L upload_protocol = esptool diff --git a/variants/esp32/radiomaster_900_bandit_nano/platformio.ini b/variants/esp32/radiomaster_900_bandit_nano/platformio.ini index 7b3d187bf08..924447ee4f9 100644 --- a/variants/esp32/radiomaster_900_bandit_nano/platformio.ini +++ b/variants/esp32/radiomaster_900_bandit_nano/platformio.ini @@ -16,6 +16,5 @@ build_flags = -DCONFIG_DISABLE_HAL_LOCKS=1 -O2 -I variants/esp32/radiomaster_900_bandit_nano - -ULED_BUILTIN board_build.f_cpu = 240000000L upload_protocol = esptool diff --git a/variants/esp32/station-g1/platformio.ini b/variants/esp32/station-g1/platformio.ini index 5a7f33485a6..20e29764c6c 100644 --- a/variants/esp32/station-g1/platformio.ini +++ b/variants/esp32/station-g1/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D STATION_G1 -I variants/esp32/station-g1 - -ULED_BUILTIN diff --git a/variants/esp32/tbeam/platformio.ini b/variants/esp32/tbeam/platformio.ini index c9e6cce1f54..96e9879ce0f 100644 --- a/variants/esp32/tbeam/platformio.ini +++ b/variants/esp32/tbeam/platformio.ini @@ -16,7 +16,6 @@ board_check = true build_flags = ${esp32_base.build_flags} -D TBEAM_V10 -I variants/esp32/tbeam - -ULED_BUILTIN upload_speed = 921600 [env:tbeam-displayshield] diff --git a/variants/esp32/tlora_v1/platformio.ini b/variants/esp32/tlora_v1/platformio.ini index 5f72d634e67..c45cc2ce93e 100644 --- a/variants/esp32/tlora_v1/platformio.ini +++ b/variants/esp32/tlora_v1/platformio.ini @@ -13,5 +13,4 @@ build_flags = ${esp32_base.build_flags} -D TLORA_V1 -I variants/esp32/tlora_v1 - -ULED_BUILTIN upload_speed = 115200 diff --git a/variants/esp32/tlora_v2_1_16/platformio.ini b/variants/esp32/tlora_v2_1_16/platformio.ini index 2ea9bbb50f1..a41c5016e80 100644 --- a/variants/esp32/tlora_v2_1_16/platformio.ini +++ b/variants/esp32/tlora_v2_1_16/platformio.ini @@ -12,7 +12,7 @@ extends = esp32_base board = ttgo-lora32-v21 board_check = true build_flags = - ${esp32_base.build_flags} -D TLORA_V2_1_16 -I variants/esp32/tlora_v2_1_16 -ULED_BUILTIN + ${esp32_base.build_flags} -D TLORA_V2_1_16 -I variants/esp32/tlora_v2_1_16 upload_speed = 115200 [env:sugarcube] diff --git a/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini b/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini index 235ac7007aa..3cb64c976ba 100644 --- a/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini +++ b/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini @@ -7,5 +7,4 @@ build_flags = -D TLORA_V2_1_16 -I variants/esp32/tlora_v2_1_16 -D LORA_TCXO_GPIO=33 - -ULED_BUILTIN upload_speed = 115200 diff --git a/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini b/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini index 38f14ffc522..d3669ce5513 100644 --- a/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini +++ b/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini @@ -7,4 +7,3 @@ build_flags = -I variants/esp32/tlora_v2_1_16 -D LORA_TCXO_GPIO=12 -D BUTTON_PIN=0 - -ULED_BUILTIN \ No newline at end of file diff --git a/variants/esp32c6/tlora_c6/platformio.ini b/variants/esp32c6/tlora_c6/platformio.ini index 174e5e2977c..6b402d7c549 100644 --- a/variants/esp32c6/tlora_c6/platformio.ini +++ b/variants/esp32c6/tlora_c6/platformio.ini @@ -8,4 +8,3 @@ build_flags = -I variants/esp32c6/tlora_c6 -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 - -ULED_BUILTIN diff --git a/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini b/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini index 6dd8284337f..0bb21581ab6 100644 --- a/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini +++ b/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini @@ -6,5 +6,4 @@ board_build.partitions = default_8MB.csv build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/heltec_capsule_sensor_v3 -D HELTEC_CAPSULE_SENSOR_V3 - -ULED_BUILTIN ;-D DEBUG_DISABLED ; uncomment this line to disable DEBUG output diff --git a/variants/esp32s3/heltec_sensor_hub/platformio.ini b/variants/esp32s3/heltec_sensor_hub/platformio.ini index 9a5384ccd27..ab99e51ed01 100644 --- a/variants/esp32s3/heltec_sensor_hub/platformio.ini +++ b/variants/esp32s3/heltec_sensor_hub/platformio.ini @@ -7,4 +7,3 @@ build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/heltec_sensor_hub -D HELTEC_SENSOR_HUB - -ULED_BUILTIN diff --git a/variants/esp32s3/heltec_v3/platformio.ini b/variants/esp32s3/heltec_v3/platformio.ini index fe31df0949b..2f53c87563f 100644 --- a/variants/esp32s3/heltec_v3/platformio.ini +++ b/variants/esp32s3/heltec_v3/platformio.ini @@ -18,4 +18,3 @@ build_flags = ${esp32s3_base.build_flags} -D HELTEC_V3 -I variants/esp32s3/heltec_v3 - -ULED_BUILTIN diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 0336bf9839f..5a5004a456a 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -8,7 +8,6 @@ build_flags = -D HELTEC_V4 -D HAS_LORA_FEM=1 -I variants/esp32s3/heltec_v4 - -ULED_BUILTIN [env:heltec-v4] diff --git a/variants/esp32s3/heltec_wsl_v3/platformio.ini b/variants/esp32s3/heltec_wsl_v3/platformio.ini index 873300c3ca3..0903a6bc7d1 100644 --- a/variants/esp32s3/heltec_wsl_v3/platformio.ini +++ b/variants/esp32s3/heltec_wsl_v3/platformio.ini @@ -17,4 +17,3 @@ build_flags = ${esp32s3_base.build_flags} -D HELTEC_WSL_V3 -I variants/esp32s3/heltec_wsl_v3 - -ULED_BUILTIN diff --git a/variants/rp2040/rpipicow/platformio.ini b/variants/rp2040/rpipicow/platformio.ini index 9b4b29a5b15..99e02a1aaf8 100644 --- a/variants/rp2040/rpipicow/platformio.ini +++ b/variants/rp2040/rpipicow/platformio.ini @@ -22,7 +22,6 @@ build_flags = -D HW_SPI1_DEVICE -D HAS_UDP_MULTICAST=1 -fexceptions # for exception handling in MQTT - -ULED_BUILTIN build_src_filter = ${rp2040_base.build_src_filter} + lib_deps = ${rp2040_base.lib_deps} From 0e38a15d4602a1165b7eb37b2863143792850a2d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:13:55 +0200 Subject: [PATCH 033/225] Update protobufs (#10223) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/config.pb.h | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/protobufs b/protobufs index 4d5b500df5a..d004f503bbf 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 4d5b500df5af68a4f57d3e19705cc3bb1136358c +Subproject commit d004f503bbf3498fd689013a794e2a0e384b3f19 diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index c82dd5ff5f0..7e71f3f7a97 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -285,7 +285,11 @@ typedef enum _meshtastic_Config_LoRaConfig_RegionCode { /* Nepal 865MHz */ meshtastic_Config_LoRaConfig_RegionCode_NP_865 = 25, /* Brazil 902MHz */ - meshtastic_Config_LoRaConfig_RegionCode_BR_902 = 26 + meshtastic_Config_LoRaConfig_RegionCode_BR_902 = 26, + /* ITU Region 1 Amateur Radio 2m band (144-146 MHz) */ + meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M = 27, + /* ITU Region 2 / 3 Amateur Radio 2m band (144-148 MHz) */ + meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M = 28 } meshtastic_Config_LoRaConfig_RegionCode; /* Standard predefined channel settings @@ -702,8 +706,8 @@ extern "C" { #define _meshtastic_Config_DisplayConfig_CompassOrientation_ARRAYSIZE ((meshtastic_Config_DisplayConfig_CompassOrientation)(meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED+1)) #define _meshtastic_Config_LoRaConfig_RegionCode_MIN meshtastic_Config_LoRaConfig_RegionCode_UNSET -#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_BR_902 -#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_BR_902+1)) +#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M +#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M+1)) #define _meshtastic_Config_LoRaConfig_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST #define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO From a8a531546034d95c652b6f6558f4fe38208b5802 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:14:12 +0200 Subject: [PATCH 034/225] Upgrade trunk (#10221) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 7a8ca0203d7..91f3c06fc70 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,7 +9,7 @@ plugins: lint: enabled: - checkov@3.2.524 - - renovate@43.132.1 + - renovate@43.136.3 - prettier@3.8.3 - trufflehog@3.94.3 - yamllint@1.38.0 From 945f4780ea51c24af924450fcf569068edcb81ce Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:31:29 -0400 Subject: [PATCH 035/225] BaseUI: Nodelist screen/favorite screen cleanup (#10197) * nodelist screen cleanup * Update UIRenderer.cpp * Update src/graphics/draw/UIRenderer.cpp * removed brackets from hop and made signal mutually exclusive --- src/graphics/draw/NodeListRenderer.cpp | 46 ++++++---- src/graphics/draw/UIRenderer.cpp | 120 +++++++++---------------- 2 files changed, 72 insertions(+), 94 deletions(-) diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index e0c5df1249f..201d267e310 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -275,9 +275,12 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25); int barsOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19); - int hopOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17); + constexpr int kBarCount = 4; + constexpr int kBarWidth = 2; + constexpr int kBarGap = 1; int barsXOffset = columnWidth - barsOffset; + int barsRightEdge = x + barsXOffset + ((kBarCount - 1) * (kBarWidth + kBarGap)) + kBarWidth; const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3); char nodeName[96]; @@ -304,28 +307,35 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int } } - // Draw signal strength bars - int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0; - int barWidth = 2; - int barStartX = x + barsXOffset; - int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2; + const bool isZeroHop = node->has_hops_away && node->hops_away == 0; - for (int b = 0; b < 4; b++) { - if (b < bars) { - int height = (b * 2); - display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height); + // Show signal only for direct neighbors (0 hops) + if (isZeroHop) { + int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0; + int barStartX = x + barsXOffset; + int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2; + + for (int b = 0; b < kBarCount; b++) { + if (b < bars) { + int height = (b * 2); + display->fillRect(barStartX + (b * (kBarWidth + kBarGap)), barStartY - height, kBarWidth, height); + } } } - // Draw hop count - char hopStr[6] = ""; - if (node->has_hops_away && node->hops_away > 0) - snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away); + // Draw hop count + hop icon + if (node->has_hops_away && node->hops_away > 0) { + char hopCount[6]; + snprintf(hopCount, sizeof(hopCount), "%d", node->hops_away); + + const int hopCountWidth = display->getStringWidth(hopCount); + const int gap = 1; + const int totalWidth = hopCountWidth + gap + hop_width; + const int hopX = barsRightEdge - totalWidth; + const int iconY = y + (FONT_HEIGHT_SMALL - hop_height) / 2; - if (hopStr[0] != '\0') { - int rightEdge = x + columnWidth - hopOffset; - int textWidth = display->getStringWidth(hopStr); - display->drawString(rightEdge - textWidth, y, hopStr); + display->drawString(hopX, y, hopCount); + display->drawXbm(hopX + hopCountWidth + gap, iconY, hop_width, hop_height, hop); } } diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index b94c25a277e..4bf4df4bf16 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -405,10 +405,10 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat } #endif - // === 2. Signal and Hops (combined on one line, if available) === - char signalHopsStr[32] = ""; + // === 2. Signal/Hops line (if available) === bool haveSignal = false; int bars = 0; + const char *qualityLabel = nullptr; // Helper to get SNR limit based on modem preset auto getSnrLimit = [](meshtastic_Config_LoRaConfig_ModemPreset preset) -> float { @@ -429,81 +429,52 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat } }; - // Calculate signal grade using modem preset and SNR only - float snrLimit = getSnrLimit(config.lora.modem_preset); - float snr = node->snr; - - // Determine signal quality label and bars using SNR-only grading - const char *qualityLabel = nullptr; - - if (snr > snrLimit + 10) { - qualityLabel = "Good"; - bars = 4; - } else if (snr > snrLimit + 6) { - qualityLabel = "Good"; - bars = 3; - } else if (snr > snrLimit + 2) { - qualityLabel = "Good"; - bars = 2; - } else if (snr > snrLimit - 4) { - qualityLabel = "Fair"; - bars = 1; - } else { - qualityLabel = "Bad"; - bars = 1; - } - // Add extra spacing on the left if we have an API connection to account for the common footer icons const char *leftSideSpacing = graphics::isAPIConnected(service->api_state) ? (currentResolution == ScreenResolution::High ? " " : " ") : " "; + const bool isZeroHop = node->has_hops_away && node->hops_away == 0; + + // Signal text/bars are only for direct (zero-hop) nodes with valid SNR. + if (isZeroHop) { + float snr = node->snr; + if (snr > -100 && snr != 0) { + float snrLimit = getSnrLimit(config.lora.modem_preset); + // Determine signal quality label and bars using SNR-only grading. + if (snr > snrLimit + 10) { + qualityLabel = "Good"; + bars = 4; + } else if (snr > snrLimit + 6) { + qualityLabel = "Good"; + bars = 3; + } else if (snr > snrLimit + 2) { + qualityLabel = "Good"; + bars = 2; + } else if (snr > snrLimit - 4) { + qualityLabel = "Fair"; + bars = 1; + } else { + qualityLabel = "Bad"; + bars = 1; + } - // --- Build the Signal/Hops line --- - // Only show signal if we have valid SNR - if (snr > -100 && snr != 0) { - snprintf(signalHopsStr, sizeof(signalHopsStr), "%sSig:%s", leftSideSpacing, qualityLabel); - haveSignal = true; - } - - if (node->hops_away > 0) { - size_t len = strlen(signalHopsStr); - if (haveSignal) { - snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [#]"); - } else { - snprintf(signalHopsStr, sizeof(signalHopsStr), "[#]"); + haveSignal = true; } } - if (signalHopsStr[0]) { - int yPos = getTextPositions(display)[line++]; - int curX = x; - - // Split combined string into signal text and hop suffix - char sigPart[20] = ""; - const char *hopPart = nullptr; - - char *bracket = strchr(signalHopsStr, '['); - if (bracket) { - size_t n = (size_t)(bracket - signalHopsStr); - if (n >= sizeof(sigPart)) - n = sizeof(sigPart) - 1; - memcpy(sigPart, signalHopsStr, n); - sigPart[n] = '\0'; - - // Trim trailing spaces - while (strlen(sigPart) && sigPart[strlen(sigPart) - 1] == ' ') { - sigPart[strlen(sigPart) - 1] = '\0'; - } + const bool showHops = node->has_hops_away && node->hops_away > 0; - hopPart = bracket; // "[n Hop(s)]" - } else { - strncpy(sigPart, signalHopsStr, sizeof(sigPart) - 1); - sigPart[sizeof(sigPart) - 1] = '\0'; + if (haveSignal || showHops) { + int yPos = getTextPositions(display)[line++]; + int curX = x + display->getStringWidth(leftSideSpacing); + + // Draw signal quality text for zero-hop nodes when present. + if (haveSignal && qualityLabel) { + char signalLabel[20]; + snprintf(signalLabel, sizeof(signalLabel), "Sig:%s", qualityLabel); + display->drawString(curX, yPos, signalLabel); + curX += display->getStringWidth(signalLabel) + 4; } - // Draw signal quality text - display->drawString(curX, yPos, sigPart); - curX += display->getStringWidth(sigPart) + 4; - // Draw signal bars (skip on UltraLow, text only) if (currentResolution != ScreenResolution::UltraLow && haveSignal && bars > 0) { const int kMaxBars = 4; @@ -541,12 +512,12 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat curX += (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap) + 2; } - // Draw hops AFTER the bars as: [ number + hop icon ] - if (hopPart && node->hops_away > 0) { - - // open bracket - display->drawString(curX, yPos, "["); - curX += display->getStringWidth("[") + 1; + // Draw hops for non-zero-hop nodes as: number + hop icon. + // This path is mutually exclusive with the zero-hop signal-bars path above. + if (showHops) { + // hop label + display->drawString(curX, yPos, "Hop:"); + curX += display->getStringWidth("Hop:") + 2; // hop count char hopCount[6]; @@ -558,9 +529,6 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat const int iconY = yPos + (FONT_HEIGHT_SMALL - hop_height) / 2; display->drawXbm(curX, iconY, hop_width, hop_height, hop); curX += hop_width + 1; - - // closing bracket - display->drawString(curX, yPos, "]"); } } From 5f836cdf3bf28ce08560ea1f1d495ccbac77e060 Mon Sep 17 00:00:00 2001 From: Jennifer Sanchez <67692052+derpyspike@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:11:36 +0200 Subject: [PATCH 036/225] Added support for Spreading Factors 5 and 6 on compatible radios (#10160) --- src/mesh/MeshRadio.h | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 07d95687827..646ca86eb5b 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -4,6 +4,7 @@ #include "MeshTypes.h" #include "PointerQueue.h" #include "configuration.h" +#include "detect/LoRaRadioType.h" // Map from old region names to new region enums struct RegionInfo { @@ -25,7 +26,7 @@ extern const RegionInfo *myRegion; extern void initRegion(); // Valid LoRa spread factor range and defaults -constexpr uint8_t LORA_SF_MIN = 7; +constexpr uint8_t LORA_SF_MIN = 5; constexpr uint8_t LORA_SF_MAX = 12; constexpr uint8_t LORA_SF_DEFAULT = 11; // LONG_FAST default @@ -37,10 +38,14 @@ constexpr uint8_t LORA_CR_DEFAULT = 5; // LONG_FAST default // Default bandwidth in kHz (LONG_FAST) constexpr float LORA_BW_DEFAULT_KHZ = 250.0f; -/// Clamp spread factor to the valid LoRa range [7, 12]. +/// Clamp spread factor to the valid LoRa range [5, 12]. /// Out-of-range values (including 0 from unset preset mode) return LORA_SF_DEFAULT. static inline uint8_t clampSpreadFactor(uint8_t sf) { + // We check for RF95 radios that are incompatible with Spreading Factors 5 and 6. + if (radioType == RF95_RADIO && (sf == 5 || sf == 6)) + return LORA_SF_DEFAULT; + if (sf < LORA_SF_MIN || sf > LORA_SF_MAX) return LORA_SF_DEFAULT; return sf; From d7ba178bf11c5fa1632ee066b7b3e1f64982df31 Mon Sep 17 00:00:00 2001 From: Clive Blackledge Date: Sat, 18 Apr 2026 06:29:30 -0700 Subject: [PATCH 037/225] Fix: prompt markdownlint md040 fix for new prompts. (#10199) * Add ESP32 Power Management lessons learned document Documents our experimentation with ESP-IDF DFS and why it doesn't work well for Meshtastic (RTOS locks, BLE locks, USB issues). Proposes simpler alternative: manual setCpuFrequencyMhz() control with explicit triggers for when to go fast vs slow. * docs(prompts): fix markdown fence language tags * docs: remove ESP32 power management notes --- .github/prompts/new-module.prompt.md | 2 +- .github/prompts/new-variant.prompt.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/prompts/new-module.prompt.md b/.github/prompts/new-module.prompt.md index 8569a622c55..08b2395970a 100644 --- a/.github/prompts/new-module.prompt.md +++ b/.github/prompts/new-module.prompt.md @@ -118,7 +118,7 @@ CallbackObserver statusObserver = Add test suite in `test/test_mymodule/`: -``` +```text test/ └── test_mymodule/ └── test_main.cpp diff --git a/.github/prompts/new-variant.prompt.md b/.github/prompts/new-variant.prompt.md index 1a324cea95d..666e264e0bd 100644 --- a/.github/prompts/new-variant.prompt.md +++ b/.github/prompts/new-variant.prompt.md @@ -6,7 +6,7 @@ Guide for adding a new Meshtastic hardware variant to the firmware. Create under `variants///`: -``` +```text variants/ ├── esp32/ # ESP32 ├── esp32s3/ # ESP32-S3 From 76dea7792913b1bb2a46ee05e4561ffa4fe90290 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:30:50 +0100 Subject: [PATCH 038/225] Add authoring guide for native unit tests in README.md (#10201) * Add authoring guide for native unit tests in README.md * Enhance documentation for agent tooling and native unit tests in README and related files --------- Co-authored-by: Ben Meadors --- .github/copilot-instructions.md | 19 ++ test/README.md | 322 ++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 test/README.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7c71a501485..89e1c5c119b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -296,6 +296,23 @@ Key defines in variant.h: ## Build System +## Agent Tooling Baseline + +Mirror counterpart: `AGENTS.md` under **Agent Tooling Baseline**. + +To reduce avoidable agent mistakes, assume these tools are available (or install them before significant repo work): + +- **Required CLI basics**: `bash`, `git`, `find`, `grep`, `sed`, `awk`, `xargs` +- **Strongly recommended**: `rg` (ripgrep) for fast file/text search, `jq` for JSON processing +- **Build/test tools**: `python3`, `pip`, virtualenv (`python3 -m venv`), `platformio` (`pio`) +- **Containerized native testing**: `docker` (especially important on macOS / non-Linux hosts) + +Fallback expectations for agents: + +- If `rg` is unavailable, use `find` + `grep` instead of failing. +- For native tests on hosts without Linux deps, prefer `./bin/test-native-docker.sh`. +- The simulator helper script is `./bin/test-simulator.sh`. + Uses **PlatformIO** with custom scripts: - `bin/platformio-pre.py` - Pre-build script @@ -448,6 +465,8 @@ Run with: `pio test -e native` Simulation testing: `bin/test-simulator.sh` +Quick entry point for new test modules: `test/README.md` (native unit-test authoring guide, skeleton, pitfalls, and setup checklist). + ### Hardware-in-the-loop tests (`mcp-server/tests/`) Separate pytest suite that exercises real USB-connected Meshtastic devices. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules. diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000000..55dbd4775a8 --- /dev/null +++ b/test/README.md @@ -0,0 +1,322 @@ +# Native Unit Tests — Authoring Guide + +This directory contains C++ unit tests that run on the host machine via PlatformIO's native environment. Tests use the [Unity](http://www.throwtheswitch.org/unity) framework. + +## Running Tests + +```bash +# All test suites +pio test -e native + +# Single suite +pio test -e native -f test_your_module + +# Verbose (shows build errors in detail) +pio test -e native -f test_your_module -vvv +``` + +### Helper Scripts (Useful Shortcuts) + +These wrappers are handy when local host dependencies are missing or when you want repeatable commands. + +```bash +# Run native tests in Docker (recommended on macOS / non-Linux hosts) +./bin/test-native-docker.sh + +# Pass normal PlatformIO test args through to Dockerized test run +./bin/test-native-docker.sh -f test_your_module + +# Force Docker image rebuild (after dependency changes) +./bin/test-native-docker.sh --rebuild + +# Run simulator integration check (build native first) +pio run -e native && ./bin/test-simulator.sh + +# Build and run meshtasticd natively +./bin/native-run.sh + +# Build and run under gdbserver on localhost:2345 +./bin/native-gdbserver.sh + +# Build native release artifact into ./release/ +./bin/build-native.sh native +``` + +Notes: + +- The repository script name is `./bin/test-simulator.sh` (there is no `test-native-simulator.sh`). +- `./bin/test-native-docker.sh` is the closest match to CI behavior for native tests and avoids host package setup. + +### System Dependencies (Ubuntu/Debian) + +The native build requires several system libraries. Install them all at once: + +```bash +sudo apt-get install -y \ + libbluetooth-dev libgpiod-dev libyaml-cpp-dev openssl libssl-dev \ + libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev +``` + +See `.github/actions/setup-native/action.yml` for the canonical list. + +## Creating a New Test Suite + +### 1. Directory Structure + +```text +test/test_your_module/test_main.cpp +``` + +One file per suite. No per-test `platformio.ini` is needed — tests build under the `[env:native]` environment defined in the root `platformio.ini`. + +### 2. File Skeleton + +```cpp +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, etc.) +#include "TestUtil.h" // initializeTestEnvironment(), testDelay() +#include + +#if YOUR_FEATURE_GUARD // Same #if guard as the module under test + +#include "FSCommon.h" +#include "gps/RTC.h" +#include "mesh/NodeDB.h" +#include "modules/YourModule.h" +#include +#include +#include + +// --- Test output helpers --- +// Unity swallows printf/stdout. Only TEST_MESSAGE() output appears in results. +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ +} while(0) + +// --- Tests --- + +void test_example() +{ + TEST_MESSAGE("=== Example test ==="); + TEST_ASSERT_TRUE(true); +} + +// --- Unity lifecycle --- + +void setUp(void) { /* runs before every test */ } +void tearDown(void) { /* runs after every test */ } + +void setup() +{ + initializeTestEnvironment(); // MUST call — sets up RTC, OSThread, console + UNITY_BEGIN(); + RUN_TEST(test_example); + exit(UNITY_END()); // exit() required — Unity runner expects it +} + +void loop() {} + +#else // !YOUR_FEATURE_GUARD + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +void loop() {} + +#endif +``` + +### 3. Feature Guard + +Wrap the entire test body in the same `#if` guard the module uses (e.g. `#if HAS_VARIABLE_HOPS`, `#if !MESHTASTIC_EXCLUDE_GPS`). When the feature is disabled, the `#else` branch produces an empty passing suite. + +## Common Patterns + +### MockNodeDB + +Most module tests need to inject nodes with controlled hop distances and ages: + +```cpp +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + numMeshNodes = 0; + } + + void addTestNode(NodeNum num, uint8_t hopsAway, bool hasHops, + uint32_t ageSecs, bool viaMqtt = false) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + node.has_hops_away = hasHops; + node.hops_away = hopsAway; + node.via_mqtt = viaMqtt; + node.last_heard = getTime() - ageSecs; + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + std::vector testNodes; +}; + +static MockNodeDB *mockNodeDB = nullptr; +``` + +Set `nodeDB = mockNodeDB;` in `setUp()`. + +### Test Shim (Exposing Protected/Private Members) + +Subclass the module under test to make protected methods callable and private members writable: + +```cpp +class YourModuleTestShim : public YourModule +{ + public: + // Expose protected methods + using YourModule::runOnce; + using YourModule::someProtectedMethod; + + // Access private members via friend (see below) + void setPrivateField(int x) { privateField = x; } +}; +``` + +In the module header, grant friend access under the `UNIT_TEST` define (set automatically by PlatformIO's test framework): + +```cpp +// In YourModule.h, inside the class body: +#ifdef UNIT_TEST + friend class YourModuleTestShim; +#endif +``` + +### Global Singleton Lifecycle + +Most modules use a global pointer (`extern YourModule *yourModule;`). Manage it carefully: + +```cpp +void setUp(void) { + // ... setup ... +} + +void tearDown(void) { + yourModule = nullptr; // prevent dangling pointer between tests +} + +void test_something() { + auto shim = std::unique_ptr(new YourModuleTestShim()); + yourModule = shim.get(); + // ... test ... + yourModule = nullptr; +} +``` + +## Pitfalls and How to Avoid Them + +### 1. Persisted Filesystem State Leaks Between Tests + +Modules that save state to `/prefs/*.bin` will have that state loaded by the next test's constructor via `loadState()`. This causes values from one test (e.g. rolling averages from a megamesh scenario) to bleed into unrelated tests. + +**Fix:** Delete state files at the start of `setUp()`: + +```cpp +void setUp(void) { + // ... +#ifdef FSCom + FSCom.remove("/prefs/your_module.bin"); +#endif +} +``` + +### 2. File-Scope Mutable Globals Persist Across Tests + +Variables like `static uint8_t someDenominator = 8;` in the module `.cpp` file retain mutations from previous tests. This is distinct from member variables — it affects all instances. + +**Fix:** Add a `static void resetGlobal()` method to the module and call it in `setUp()`. + +### 3. Randomness Breaks Determinism + +If the module uses `rand()` for jitter or similar, test results become non-reproducible. + +**Fix:** Add a static enable/disable flag: + +```cpp +// Module header: +static void setJitter(bool enabled) { s_jitterEnabled = enabled; } + +// Test setUp: +YourModule::setJitter(false); + +// Test tearDown: +YourModule::setJitter(true); +``` + +### 4. Time-Dependent Logic Produces Zeros + +Rolling averages weighted by `elapsedMs / ONE_HOUR_MS` collapse to zero when tests complete in microseconds. Sample windows, EMA alphas, and interval-based accumulators all suffer from this. + +**Fix:** Expose the timestamp via friend access and simulate realistic elapsed time: + +```cpp +// In test shim: +void setWindowStartMs(uint32_t ms) { windowStartMs = ms; } + +// In test: +shim.setWindowStartMs(millis() - 3600000UL); // pretend 1 hour elapsed +``` + +### 5. Capacity Limits Cause Cascading Failures + +Fixed-size data structures (hash sets, ring buffers) overflow when tests inject more data than fits. This triggers early flushes with near-zero time fractions, compounding the time-dependent-zeros problem. + +**Fix:** Simulate multiple realistic time windows rather than one massive burst. Let adaptive mechanisms (if any) self-tune over several rolls. + +## setUp/tearDown Checklist + +- [ ] Create and clear MockNodeDB (if needed) +- [ ] Zero global configs: `config`, `moduleConfig`, `myNodeInfo` +- [ ] Set `nodeDB = mockNodeDB` +- [ ] Delete persisted state files (`FSCom.remove(...)`) +- [ ] Reset file-scope mutable globals +- [ ] Disable randomness/jitter flags +- [ ] In `tearDown`: null the global singleton pointer, restore flags + +## Test Organization + +A well-structured test suite follows this pattern: + +1. **Topology/scenario builders** — static helper functions that set up specific test conditions +2. **Injection helpers** — simulate realistic traffic, time, or event patterns +3. **Scenario tests** — each builds a scenario, runs the module, asserts on outcomes +4. **Lifecycle tests** — state persistence, startup from blank, restart recovery +5. **Summary test** (optional) — emits a scenario table into the log for quick CI review + +## Existing Test Suites + +| Suite | Module Under Test | +| ---------------------------- | ----------------------------- | +| `test_crypto` | CryptoEngine | +| `test_mqtt` | MQTT integration | +| `test_radio` | Radio interface | +| `test_mesh_module` | Module framework | +| `test_meshpacket_serializer` | Packet serialization | +| `test_transmit_history` | Retransmission tracking | +| `test_atak` | ATAK integration | +| `test_default` | Default configuration helpers | +| `test_http_content_handler` | HTTP handling | +| `test_serial` | Serial communication | +| `test_hop_scaling` | Hop scaling algorithm | +| `test_traffic_management` | Traffic management | From 68383c8bd5ae90946e337bd23bde183ddcdce9d1 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 19 Apr 2026 16:05:28 -0500 Subject: [PATCH 039/225] Add encryption overview to agent instructions in AGENTS.md (#10207) * Add encryption overview to agent instructions in AGENTS.md * Update AGENTS.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update copilot-instructions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update copilot-instructions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Clarify nonce and wire overhead details in encryption section of copilot instructions * Enhance encryption documentation in copilot instructions and agents guide for clarity on key management and reset behaviors * Update .github/copilot-instructions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix botched merge conflict resolution --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 66 ++++++++++++++++++++++++++++++++- AGENTS.md | 9 +++++ src/detect/ScanI2C.h | 1 + 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 89e1c5c119b..2d74571021c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -70,6 +70,70 @@ PKI (Public Key Infrastructure) messages have special handling: - Accepted on a special "PKI" channel - Allow encrypted DMs between nodes that discovered each other on downlink-enabled channels +## Encryption & Key Management + +Meshtastic packets on the air are typically encrypted one of two ways: the **per-channel symmetric** layer (AES-CTR with a shared PSK) for broadcasts and channel traffic, and the **per-peer PKI** layer (X25519 ECDH → AES-256-CCM) for direct messages and remote admin. A channel with a 0-byte PSK (or Ham mode, which wipes PSKs) transmits cleartext — see the size table below. Both are implemented in `src/mesh/CryptoEngine.cpp`; the send/receive dispatch lives in `src/mesh/Router.cpp`; admin authorization lives in `src/modules/AdminModule.cpp`. + +### High-level model + +- **Channels** are symmetric rooms: anyone with the PSK can read any message on the channel. Channel 0 is the "primary" channel and ships with the short-form default PSK on factory devices, forming the public mesh most users join. (The LoRa modem preset `LONG_FAST` lives on `config.lora.modem_preset` and is an independent field — don't conflate "channel 0 default PSK" with the modem preset name.) +- **DMs** addressed to a single node require PKI so that other holders of the channel PSK can't read them. Outside Ham mode, Meshtastic does not fall back to channel-symmetric encryption when the destination public key is unknown. +- **Remote admin** is a DM carrying an `AdminMessage`. The receiver only acts on it if the sender's public key is on its allowlist (`config.security.admin_key[0..2]`). +- **Ham mode** (`owner.is_licensed=true`, where `owner` is the local `meshtastic_User` record) disables PKI entirely and sends cleartext — FCC Part 97 prohibits encryption on amateur bands. +- **No ratchet, no session.** Every packet is encrypted from scratch — a stateless design that matches the high-loss, store-and-forward nature of LoRa. + +### Symmetric channel encryption (AES-CTR) + +`CryptoEngine::encryptPacket` / `decrypt` / `encryptAESCtr` in `src/mesh/CryptoEngine.cpp`. + +- **Cipher**: AES-CTR, AES-128 or AES-256 depending on key length. Same routine in both directions (CTR is a stream cipher, so encrypt == decrypt). +- **Key**: `ChannelSettings.psk` bytes. Size semantics: + - **0 bytes** → no encryption, cleartext on the air + - **1 byte** → short-form index into the well-known `defaultpsk[]` in `src/mesh/Channels.h`. Index 0 = cleartext; 1 = defaultpsk unchanged; 2..255 = defaultpsk with its last byte incremented by (index − 1). This is what the CLI's `--ch-set psk default` produces. + - **16 bytes** → raw AES-128 key + - **32 bytes** → raw AES-256 key + - **2..15 bytes** → zero-padded to 16 and used as AES-128 (with a warn log); **17..31 bytes** → zero-padded to 32 and used as AES-256 (with a warn log). Defensive fallback for malformed PSK input, not something to rely on. +- **Nonce (128 bit)**: `packet_id` (u64 LE) ‖ `from_node` (u32 LE) ‖ `block_counter` (u32, starts at 0). Built in `CryptoEngine::initNonce`. +- **No AEAD**: channel packets carry no MAC, so the channel-hash byte is not an integrity or authenticity check. `Channels::getHash` is a 1-byte XOR-derived hint over the channel name bytes and PSK bytes that helps receivers pick a candidate channel/PSK for decryption. Because it is only a small hint and collisions are easy to find, it should be described purely as a PSK-selection aid, not as a security filter an attacker cannot bypass. +- **Channel 0 is special in one way only**: it's the channel the Router attempts PKI decryption on before falling through to AES-CTR. Non-zero channels always go straight to AES-CTR. + +### PKI encryption for DMs (X25519 ECDH + AES-256-CCM) + +`CryptoEngine::encryptCurve25519` / `decryptCurve25519` in `src/mesh/CryptoEngine.cpp`. + +- **Keypair**: Curve25519 (aka X25519), 32-byte public + 32-byte private. Stored in `config.security.public_key` / `private_key`; the public half is mirrored into `owner.public_key` so it rides along in NodeInfo broadcasts and propagates through the mesh like any other identity field. +- **Key generation** (`generateKeyPair`): stirs `HardwareRNG::fill()` (64 B from platform TRNG when available), the 16-byte `myNodeInfo.device_id`, and a call to `random()` into the rweather/Crypto library's software RNG, then `Curve25519::dh1`. `regeneratePublicKey` recomputes the public half from a known private (used when restoring from backup). +- **Keygen entry points**: at boot, `NodeDB` calls `generateKeyPair` (or `regeneratePublicKey` when a stored private key is present and passes a low-entropy check) **directly** when `!owner.is_licensed` and `config.lora.region != UNSET`. `ensurePkiKeys` wraps the same logic for runtime/admin flows — it's the path `AdminModule::handleSetConfig` runs when first assigning a valid region or when security config is written; **do not assume it's the universal boot-time gate**, because the NodeDB path bypasses it. +- **Handshake**: `Curve25519::dh2(local_private, remote_public) → 32-byte shared secret → SHA-256 → 32-byte AES-256 key`. Recomputed per packet. The SHA-256 step is effectively a KDF over the raw ECDH output. +- **Cipher**: AES-256-CCM via `aes_ccm_ae` / `aes_ccm_ad` (`src/mesh/aes-ccm.cpp`). MAC length (the `M` parameter) is **8 bytes**. No AAD — the MAC covers ciphertext only. +- **Nonce (13 bytes / 104 bit)**: `aes_ccm_ae`/`aes_ccm_ad` use a 13-byte CCM nonce (`L = 2` is hardcoded in `src/mesh/aes-ccm.cpp`), not a 16-byte nonce. For PKI packets, `CryptoEngine::initNonce(fromNode, packetNum, extraNonce)` starts from the usual packet-derived nonce material, then overwrites nonce bytes `4..7` with a fresh 32-bit `extraNonce = random()`. The effective nonce bytes are therefore: bytes `0..3` = `packet_id`, bytes `4..7` = transmitted `extraNonce`, bytes `8..11` = `from_node`, byte `12` = `0x00`. The receiver reconstructs the same 13-byte nonce from the packet metadata plus the appended `extraNonce`. +- **Wire overhead**: 12 bytes appended to the ciphertext = 8-byte MAC ‖ 4-byte extraNonce. Defined as `MESHTASTIC_PKC_OVERHEAD = 12` in `src/mesh/RadioInterface.h`. Only the 4-byte `extraNonce` is sent; the rest of the 13-byte CCM nonce is reconstructed from packet fields as described above. The Router's send path checks this overhead against `MAX_LORA_PAYLOAD_LEN` before committing to PKI. +- **Send selection** (`Router::send`): the sender enters the PKI path when **all** hold — we're the originator AND not Ham mode AND not Portduino simradio AND not on the `serial`/`gpio` channels (unless the packet is already marked `pki_encrypted`) AND `config.security.private_key.size == 32` AND destination is a single node (not broadcast) AND the portnum isn't infrastructure. `TRACEROUTE_APP`, `NODEINFO_APP`, `ROUTING_APP`, and `POSITION_APP` are routed through channel encryption even when DMed (these need to be readable by relaying peers). Once on the PKI path, if the destination's public key isn't in our NodeDB the send **fails** with `PKI_SEND_FAIL_PUBLIC_KEY` — it does not silently fall back to channel encryption. If the client explicitly set `pki_encrypted=true` and any condition blocks PKI, the send fails with `PKI_FAILED`. +- **Receive selection** (`Router::perhapsDecode`): try PKI decrypt first when `channel == 0` AND `isToUs(p)` AND not broadcast AND both peers have public keys in NodeDB AND `rawSize > MESHTASTIC_PKC_OVERHEAD`. On success the packet gets `pki_encrypted=true` stamped and the sender's public key copied into `p->public_key` for downstream authorization. + +### Remote admin authorization + +Implemented in `src/modules/AdminModule.cpp` → `handleReceivedProtobuf`. The authorization check runs in this order: + +1. **Response messages** — if `messageIsResponse(r)` is true (the payload is a response to one of our earlier admin requests), it's accepted without any further check. The in-file comment flags this as a known-untightened gap: a stricter implementation would remember which `public_key` we last queried and reject responses that don't match. +2. **Local admin** — `mp.from == 0` (phone app over BLE, serial CLI, internal module); never travels over the air. **Rejected** if `config.security.is_managed` is true, because managed devices expect admin to arrive over the air through an authorized remote path. +3. **Legacy admin channel (deprecated)** — the packet arrived on a channel named literally `"admin"`. Gated by `config.security.admin_channel_enabled`; returns `NOT_AUTHORIZED` if the flag is false. Kept for backward compatibility; new deployments should use PKI admin. +4. **PKI admin (preferred for remote)** — `mp.pki_encrypted == true` AND `mp.public_key` matches one of `config.security.admin_key[0..2]` (up to three authorized 32-byte Curve25519 public keys, typically copied from the admin node's own `user.public_key`). +5. **Fallthrough** → `NOT_AUTHORIZED`. + +On top of authorization, any remote admin message that **mutates** state (not a request, not a response) also has to pass a session-key check (`checkPassKey`): the client must first pull a fresh 8-byte `session_passkey` via `get_admin_session_key_request`, then echo that passkey back in the mutating message. The device rotates the passkey after 150 s and rejects values older than 300 s — a narrow anti-replay window on top of the PKI layer. + +`config.security.is_managed = true` disables **local** admin writes (`mp.from == 0` is rejected). It does not by itself force every admin action through PKI — the legacy `"admin"` channel still authorizes remote admin when `config.security.admin_channel_enabled == true`. The AdminModule refuses to persist `is_managed=true` unless at least one `admin_key` is populated — a deliberate guard against operators locking themselves out. + +### Key-rotation hazards (actions that invalidate peers) + +- **`factory_reset_device`** (the "full" variant, calls `NodeDB::factoryReset(eraseBleBonds=true)`) → **wipes** the X25519 private key; a fresh keypair is generated on the next region-set. Every existing peer holds the old public key, so DMs to this node silently fail PKI decrypt until every peer re-exchanges NodeInfo. +- **`factory_reset_config`** (the "partial" variant, calls `NodeDB::factoryReset()` with `eraseBleBonds=false`) → **preserves** the X25519 private key in `installDefaultConfig(preserveKey=true)`; the public key is zeroed and gets rebuilt from the preserved private key on the next boot via the NodeDB path's `regeneratePublicKey` call. Identity is preserved and the mesh does not need to re-exchange keys. +- **`region=UNSET → valid region`** → `ensurePkiKeys` runs inside the same `handleSetConfig` path; missing keys get generated at that moment. +- **Ham mode transitions** — entering Ham mode (`user.is_licensed=true`) runs `Channels::ensureLicensedOperation`, which **wipes every channel PSK** (all traffic becomes cleartext) and disables the legacy admin channel. The X25519 private key is preserved on the device but not used because `Router::send` skips PKI when `owner.is_licensed` is true. Leaving Ham mode re-enables PKI with the preserved keypair but does not restore the wiped channel PSKs — the operator has to re-set them. +- **Channel 0 PSK change** → every peer must re-learn the channel hash; cached NodeInfo becomes temporarily unreachable until the next broadcast. +- **`security.private_key` blanked via admin** → regenerates both halves (unless in Ham mode) and propagates the new public key via NodeInfo. + ## Project Structure ``` @@ -80,7 +144,7 @@ firmware/ │ │ ├── NodeDB.* # Node database management │ │ ├── Router.* # Packet routing │ │ ├── Channels.* # Channel management -│ │ ├── CryptoEngine.* # AES-CCM encryption +│ │ ├── CryptoEngine.* # AES-CTR (channels) + X25519 ECDH→AES-256-CCM (PKI for DMs/admin) │ │ ├── *Interface.* # Radio interface implementations │ │ ├── api/ # WiFi/Ethernet server APIs (ServerAPI, PacketAPI) │ │ ├── http/ # HTTP server (WebServer, ContentHandler) diff --git a/AGENTS.md b/AGENTS.md index b3fa1970c88..8f34746403f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,15 @@ Three test-and-diagnose workflows exist as slash commands: Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude/commands/README.md` is the index. +## Encryption at a glance + +Two layers, both in `src/mesh/CryptoEngine.cpp`: + +- **Channel (symmetric)** — **AES-CTR** with a channel-wide PSK (AES-128 or AES-256). Nonce = packet_id ‖ from_node ‖ block_counter. No AEAD; integrity is soft (channel-hash filter). The well-known default PSK lives in `src/mesh/Channels.h`; a 1-byte PSK is a short-form index into it. +- **Per-peer PKI** — **X25519 ECDH** (Curve25519, 32-byte keys) → SHA-256 → **AES-256-CCM** with an 8-byte MAC. Fresh 32-bit `extraNonce` per packet, sent in the clear alongside the MAC. 12-byte wire overhead (`MESHTASTIC_PKC_OVERHEAD`). Used for DMs. Also used for remote admin (`src/modules/AdminModule.cpp`), where AdminMessage authorization is gated by `config.security.admin_key[0..2]`. Disabled entirely in Ham mode (`user.is_licensed=true`). + +Key rotation to never trigger casually: only the **full** factory reset (`factory_reset_device`, `eraseBleBonds=true`) wipes `security.private_key` and regenerates the keypair — every peer holds the old public key, so DMs silently fail PKI decrypt until NodeInfo re-exchanges. The **partial** config reset (`factory_reset_config`) preserves the private key and doesn't invalidate peer relationships. Explicitly blanking `security.private_key` via admin also triggers regen. See the **Encryption & Key Management** section of `.github/copilot-instructions.md` for the full spec (nonce layout, send/receive selection logic including infrastructure-portnum exceptions, admin-key + session-passkey authorization, `is_managed` scope, key-rotation hazards). + ## House rules - **No destructive device operations without operator approval.** `factory_reset`, `erase_and_flash`, `reboot`, `shutdown`, history-rewriting git ops — describe the action and stop. Operator authorizes. diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 3d13a68af12..054c7854baf 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -34,6 +34,7 @@ class ScanI2C SHT31, SHT4X, SHTC3, + SHTXX, LPS22HB, QMC6310U, QMC6310N, From 3b4c66439d427c60ced97e562d77ef121bf497db Mon Sep 17 00:00:00 2001 From: George <509474+giannoug@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:35:02 +0300 Subject: [PATCH 040/225] feat(t5s3-epaper): add InkHUD port for LilyGo T5 E-Paper S3 Pro (#10211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * niche: add InkHUD port for LilyGo T5-E-Paper-S3-Pro (ED047TC1) Add a NicheGraphics EInk driver adapter for the 4.7" ED047TC1 parallel e-paper display used on the T5-E-Paper-S3-Pro (H752-01). The driver wraps FastEPD and handles the polarity difference between InkHUD's buffer format (0xFF = white) and FastEPD's (0x00 = white). Rewrite variants/esp32s3/t5s3_epaper/nicheGraphics.h which was an incomplete copy of the Heltec VM-E290 setup referencing undefined SPI pin macros and a non-existent BUTTON_PIN_SECONDARY. The board uses a parallel display, not the small SPI DEPG0290BNS800 that was referenced. * fix: guard inputBroker null dereference in TouchScreenImpl1::init() When MESHTASTIC_EXCLUDE_INPUTBROKER is defined (e.g. InkHUD builds), inputBroker is nullptr. Calling inputBroker->registerSource() in that state caused a LoadProhibited panic on any board that has both HAS_TOUCHSCREEN=1 and the InputBroker excluded. Add a null check before registerSource() to prevent the crash. * niche: fix display rotation for T5-E-Paper-S3-Pro InkHUD port Set rotation=3 (270° CW) in nicheGraphics.h to correct for FastEPD scanning the ED047TC1 panel in portrait orientation, resulting in correct landscape display output. * fix: update buffer format descriptions and remove polarity inversion for InkHUD and FastEPD * fix: update ED047TC1 driver to handle inactive pixel borders and adjust safe-area dimensions * fix: comment out ruler diagnostic for E-Ink driver * feat: implement TouchInkHUDBridge for direct touch event handling in InkHUD * niche: add FreeSans 18pt/24pt Win1253 (Greek) fonts for larger InkHUD displays Add Win1253-encoded FreeSans 18pt and 24pt font headers to support Greek script on larger InkHUD screens (e.g., the 4.7" ED047TC1 at ~234 DPI). Register FREESANS_24PT_WIN1253 and FREESANS_18PT_WIN1253 macros in AppletFont.h. Set fontLarge=24pt, fontMedium=18pt, fontSmall=12pt in nicheGraphics.h for the T5-E-Paper-S3-Pro. * feat(ed047tc1): use true partial update for FAST refresh Replace fullUpdate(CLEAR_FAST) with partialUpdate() for FAST display updates. FastEPD's partialUpdate() diffs pCurrent against pPrevious and only applies the update waveform to rows that have changed, leaving unchanged rows with a neutral signal. This reduces visible flicker on routine updates (new messages, position changes) — only the affected region of the screen refreshes. Full-screen CLEAR_SLOW updates are preserved for periodic ghosting cleanup, driven by InkHUD's setDisplayResilience() ratio. * feat(t5s3-epaper): enable frontlight via LatchingBacklight Wire up BOARD_BL_EN (GPIO11) to InkHUD's LatchingBacklight driver. Enable the backlight menu item so users can toggle "Keep Backlight On" via Settings. The backlight turns on automatically when the menu opens and off when it closes. * Fix RTC chip (PCF8563 not PCF85063) and GT911 I2C address collision - variant.h used PCF85063_RTC but the board has a PCF8563. The difference is the RAM register: PCF85063 has 1 byte of RAM; PCF8563 does not. The PCF85063 driver was trying to write this register on init, failing every time, and setDateTime writes were silently discarded — RTC time was never persisted across reboots. Switch to PCF8563_RTC/PCF8563_INT. Before: [E][SensorPCF85063.hpp:375] initImpl(): Failed to write to RAM memory register. Maybe this chip is pcf8563. Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:23 PCF85063 setDateTime 2026-04-05 18:40:59 Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:19 ← lost After: PCF8563 found at address 0x51 Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:37 ← persisted PCF8563 setDateTime 2026-04-05 18:58:44 Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:44 ← round-trips - GT911 touch was initialized with GT911_SLAVE_ADDRESS_L (0x5D), which collides with the SFA30 air quality sensor also at 0x5D on the same I2C bus. Switch to GT911_SLAVE_ADDRESS_H (0x14): the library drives INT high during reset to program the GT911 to address 0x14, eliminating the address conflict. Before: SFA30 found at address 0x5d [I][TouchDrvGT911.hpp:568] initImpl(): Try using 0x5D as the device address After: SFA30 found at address 0x5d [I][TouchDrvGT911.hpp:544] initImpl(): Try using 0x14 as the device address * t5s3_epaper: fix GT911 ghost-SFA30 via early I2C address latch Investigation findings ---------------------- Boot logs showed "SFA30 found at address 0x5d" on every cold power-on, and AirQualityTelemetry was registering an SFA30 sensor. However, every readMeasuredValues() call returned error 268 (0x010C = Sensirion WriteError | I2cAddressNack), meaning the I2C write to 0x5D was being NACK'd — inconsistent with a real SFA30. Root cause: the GT911 touch controller latches its I2C address from the INT pin level at reset time (GT911 datasheet §4.3). GPIO3 (INT) defaults LOW on ESP32-S3 cold boot → GT911 always powers up at 0x5D (SLAVE_ADDRESS_L). The I2C scanner runs before lateInitVariant() had a chance to reprogram the chip. The scanner's SFA30 detection (ScanI2CTwoWire.cpp) sends the 2-byte command 0xD060 to 0x5D and requests 48 bytes back. GT911 ACKs the write (treating it as a register address) and returns 48 bytes of register data, passing the length check — a false-positive SFA30 detection. Confirmed via second cold-boot log: after the previous commit moved GT911 to 0x14 in lateInitVariant(), address 0x5D *still* appeared in the scan because the scanner runs first. The board has no physical SFA30 fitted. Fix --- Add the GT911 address-latch reset sequence to earlyInitVariant(), before Wire is initialised and before the I2C scan runs. Per the datasheet: drive RST LOW, drive INT HIGH (selects address 0x14 / SLAVE_ADDRESS_H), hold >100 µs, release RST, wait >5 ms startup. GPIO-only, no Wire dependency. lateInitVariant() then repeats this sequence internally via touch.begin(); the double-reset is harmless. Verified in boot log: Before: "SFA30 found at address 0x5d", 5 I2C devices, NACK errors After: no SFA30 entry, 4 I2C devices (TCA9535/PCF8563/BQ27220/BQ25896), GT911 found at 0x14 and touch initialised successfully, AirQualityTelemetry registers no sensors (correct — no SFA30 present) * t5s3_epaper: add variant_shutdown() for touch sleep and backlight off Put GT911 into low-power standby (command 0x05) and drive BOARD_BL_EN LOW before deep sleep to avoid unnecessary current draw. * t5s3_epaper: fix touch gesture routing and coordinate mapping readTouch() now transforms raw GT911 axes to visual-frame coordinates based on the current display rotation (rotation=3 is the hardware identity). This ensures TouchScreenBase detects swipe direction correctly regardless of which rotation the user has selected. TouchInkHUDBridge dynamically sets joystick.alignment = (4-rotation)%4 on each touch event so that (rotation+alignment)%4==0 always, keeping nav calls pass-through without remapping. nicheGraphics.h now calls loadSettings() first so that rotation is persisted across reboots. rotation=3 and other first-boot defaults are only applied when tips.firstBoot is set. alignment is recomputed from the loaded rotation on every boot. Co-Authored-By: Claude Sonnet 4.6 * t5s3_epaper: fix GT911 sleep timing via notifyDeepSleep observer touch.sleep() was called from variant_shutdown(), which runs inside cpuDeepSleep() — after Wire.end() had already torn down the I2C bus in doDeepSleep(). This caused Wire NULL TX buffer errors and left the GT911 awake during deep sleep. Register a CallbackObserver on notifyDeepSleep, which fires before Wire.end(), so the I2C command reaches the chip while the bus is live. Pattern matches LatchingBacklight and other NicheGraphics components. Co-Authored-By: Claude Sonnet 4.6 * t5s3_epaper: fix touch nav and applet defaults in nicheGraphics Enable joystick mode post-begin so menu scroll and swipe-up/down gestures are not silently dropped by the joystick.enabled gate in Events.cpp. Activate DMs and Channel 0/1 applets with correct autoshow defaults matching the mini-epaper-s3 reference pattern. Co-Authored-By: Claude Sonnet 4.6 * Update nicheGraphics.h * t5s3_epaper: fix ED047TC1 driver docs and remove spurious beginPolling Addressing PR review comments: Remove beginPolling(1, 0) after the blocking FastEPD update — it incorrectly set updateRunning=true for one loop cycle after the hardware was already done, causing busy() to briefly return true. Since isUpdateDone() always returns true, no polling is needed. Also fix stale comments: safe-area buffer size was 944×532, now 944×523; V_OFFSET_ROWS didn't exist, replaced with the actual V_OFFSET_TOP=9 / V_OFFSET_BOTTOM=8 constant names. * t5s3_epaper: clean up applet addition formatting in setupNicheGraphics * t5s3_epaper: guard ED047TC1.cpp against non-T5S3 InkHUD builds The InkHUD base config pulls in all of src/graphics/niche/ so every InkHUD device compiled ED047TC1.cpp, triggering the #error on line 48 for boards that define neither T5_S3_EPAPER_PRO_V1 nor V2. Wrap the file body with #ifdef T5_S3_EPAPER_PRO so it is only compiled for T5S3 targets. The #error is preserved inside the guard to catch future hardware revisions that forget to update the driver. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> --- src/graphics/niche/Drivers/EInk/ED047TC1.cpp | 122 + src/graphics/niche/Drivers/EInk/ED047TC1.h | 90 + .../niche/Fonts/FreeSans18pt_Win1253.h | 1467 ++++++++++ .../niche/Fonts/FreeSans24pt_Win1253.h | 2429 +++++++++++++++++ src/graphics/niche/InkHUD/AppletFont.h | 4 + src/input/TouchScreenImpl1.cpp | 3 +- variants/esp32s3/t5s3_epaper/nicheGraphics.h | 89 +- variants/esp32s3/t5s3_epaper/variant.cpp | 141 +- variants/esp32s3/t5s3_epaper/variant.h | 4 +- 9 files changed, 4295 insertions(+), 54 deletions(-) create mode 100644 src/graphics/niche/Drivers/EInk/ED047TC1.cpp create mode 100644 src/graphics/niche/Drivers/EInk/ED047TC1.h create mode 100644 src/graphics/niche/Fonts/FreeSans18pt_Win1253.h create mode 100644 src/graphics/niche/Fonts/FreeSans24pt_Win1253.h diff --git a/src/graphics/niche/Drivers/EInk/ED047TC1.cpp b/src/graphics/niche/Drivers/EInk/ED047TC1.cpp new file mode 100644 index 00000000000..f1189045b0a --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/ED047TC1.cpp @@ -0,0 +1,122 @@ +/* + + NicheGraphics parallel E-Ink driver for the LilyGo T5-S3-ePaper-Pro (ED047TC1). + + InkHUD buffer format : 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white + FastEPD buffer format: 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white + + Both formats share the same pixel layout and polarity (1 = white, 0 = black). + The InkHUD safe-area buffer (944×523) is copied into the centre of the physical + 960×540 FastEPD buffer so content clears the panel's inactive edge border. + See ED047TC1.h for the H_OFFSET_BYTES / V_OFFSET_TOP / V_OFFSET_BOTTOM constants. + +*/ + +// Ruler diagnostic — uncomment to draw calibration lines at each physical edge. +// #define EINK_EDGE_LINES + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +#ifdef T5_S3_EPAPER_PRO + +#include "./ED047TC1.h" + +#include "FastEPD.h" +#include "configuration.h" + +using namespace NicheGraphics::Drivers; + +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 + (void)spi; + (void)pin_dc; + (void)pin_cs; + (void)pin_busy; + (void)pin_rst; + + epaper = new FASTEPD; + +#if defined(T5_S3_EPAPER_PRO_V1) + epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000); +#elif defined(T5_S3_EPAPER_PRO_V2) + epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000); + // Initialize all PCA9535 port-0 pins as outputs / HIGH + for (int i = 0; i < 8; i++) { + epaper->ioPinMode(i, OUTPUT); + epaper->ioWrite(i, HIGH); + } +#else +#error "ED047TC1 driver: unsupported variant — define T5_S3_EPAPER_PRO_V1 or T5_S3_EPAPER_PRO_V2" +#endif + + epaper->setMode(BB_MODE_1BPP); + epaper->clearWhite(); + epaper->fullUpdate(true); // Blocking initial clear +} + +void ED047TC1::update(uint8_t *imageData, UpdateTypes type) +{ + if (!epaper) + return; + + // InkHUD renders into a DISPLAY_WIDTH × DISPLAY_HEIGHT safe-area buffer. + // We need to place that into the centre of the physical 960×540 FastEPD buffer, + // leaving blank margins at every edge to avoid the panel's inactive border. + const uint32_t srcRowBytes = (DISPLAY_WIDTH + 7) / 8; // bytes per row in InkHUD buffer (118) + const uint32_t dstRowBytes = (960 + 7) / 8; // bytes per row in physical buffer (120) + const uint32_t dstTotalRows = 540; + + uint8_t *cur = epaper->currentBuffer(); + + // Fill physical buffer with white (0xFF = white in FastEPD 1bpp) + memset(cur, 0xFF, dstRowBytes * dstTotalRows); + + // Copy each InkHUD row into the physical buffer with horizontal + vertical offsets + for (uint32_t row = 0; row < DISPLAY_HEIGHT; row++) { + const uint8_t *srcRow = imageData + row * srcRowBytes; + uint8_t *dstRow = cur + (row + V_OFFSET_TOP) * dstRowBytes + H_OFFSET_BYTES; + memcpy(dstRow, srcRow, srcRowBytes); + } + +#ifdef EINK_EDGE_LINES + // Draw a 1px black box at the exact boundary of the safe area within the + // physical buffer. If the margins are correct, all 4 lines should be + // fully visible and right at the edge of the usable display area. + + auto setPixelBlack = [&](uint32_t col, uint32_t row) { cur[row * dstRowBytes + col / 8] &= ~(0x80 >> (col % 8)); }; + + const uint32_t safeX = H_OFFSET_BYTES * 8; + const uint32_t safeY = V_OFFSET_TOP; + const uint32_t safeW = DISPLAY_WIDTH; + const uint32_t safeH = DISPLAY_HEIGHT; + + // Top edge: horizontal line at safeY + for (uint32_t col = safeX; col < safeX + safeW; col++) + setPixelBlack(col, safeY); + + // Bottom edge: horizontal line at safeY + safeH - 1 + for (uint32_t col = safeX; col < safeX + safeW; col++) + setPixelBlack(col, safeY + safeH - 1); + + // Left edge: vertical line at safeX + for (uint32_t row = safeY; row < safeY + safeH; row++) + setPixelBlack(safeX, row); + + // Right edge: vertical line at safeX + safeW - 1 + for (uint32_t row = safeY; row < safeY + safeH; row++) + setPixelBlack(safeX + safeW - 1, row); +#endif + + if (type == FULL) { + epaper->fullUpdate(CLEAR_SLOW, false); + epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline + } else { + // FAST: true partial update — compares pCurrent vs pPrevious and only applies + // the update waveform to rows that actually changed. Unchanged rows get a neutral + // signal (no visible effect). partialUpdate() updates pPrevious internally. + epaper->partialUpdate(false, 0, dstTotalRows - 1); + } +} + +#endif // T5_S3_EPAPER_PRO +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/Drivers/EInk/ED047TC1.h b/src/graphics/niche/Drivers/EInk/ED047TC1.h new file mode 100644 index 00000000000..3540481e73f --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/ED047TC1.h @@ -0,0 +1,90 @@ +/* + + E-Ink display driver adapter + - ED047TC1 (via FastEPD library) + - Manufacturer: E Ink / used in LilyGo T5-E-Paper-S3-Pro + - Size: 4.7 inch + - Physical resolution: 960px x 540px + - Interface: 8-bit parallel (NOT SPI) + + 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 + 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 + H_OFFSET_BYTES (horizontal, whole bytes = 8 pixels per byte), + 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 = 1 → 8px left margin, 8px right margin (960 – 8 – 8 = 944) + V_OFFSET_TOP = 9 → 9px top margin (asymmetric: top ≠ bottom) + V_OFFSET_BOTTOM = 8 → 8px bottom margin (540 – 9 – 8 = 523) + +*/ + +#pragma once + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "configuration.h" + +#include "./EInk.h" + +// Forward declare to avoid pulling FastEPD into all translation units +class FASTEPD; + +namespace NicheGraphics::Drivers +{ + +class ED047TC1 : public EInk +{ + // Safe-area dimensions exposed to InkHUD (physical panel is 960×540). + // + // The ED047TC1 has an inactive pixel border on all physical edges. + // The physical buffer coordinates do NOT directly match the visual orientation + // due to FastEPD's portrait scan direction and InkHUD's rotation=3 (270° CW): + // + // Physical buffer Visual on device (rotation=3) + // ───────────────── ────────────────────────────── + // Physical LEFT cols → Visual TOP edge + // Physical RIGHT cols → Visual BOTTOM edge + // Physical TOP rows → Visual RIGHT edge + // Physical BOTTOM rows → Visual LEFT edge + // + // Offset constants shift the InkHUD safe-area away from each physical dead zone: + // H_OFFSET_BYTES : whole bytes from physical left (8px per byte, affects visual TOP) + // Physical right margin = 960 − H_OFFSET_BYTES×8 − DISPLAY_WIDTH (affects visual BOTTOM) + // V_OFFSET_TOP : pixel rows from physical top (affects visual RIGHT) + // V_OFFSET_BOTTOM: pixel rows from physical bottom (affects visual LEFT) + // + // Calibrated by flashing a 1px border box and adjusting until all 4 sides are visible. + + static constexpr uint16_t DISPLAY_WIDTH = 944; // 960 − H_OFFSET_BYTES×8 − right_margin (8+8 = 16px) + static constexpr uint16_t DISPLAY_HEIGHT = 523; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (9+8 = 17px) + + static constexpr uint8_t H_OFFSET_BYTES = 1; // visual TOP : 8px physical left margin + // visual BOTTOM: 960−8−944=8px physical right margin + static constexpr uint8_t V_OFFSET_TOP = 9; // visual RIGHT : CONFIRMED OK + static constexpr uint8_t V_OFFSET_BOTTOM = 8; // visual LEFT : 8px physical bottom margin + + static constexpr UpdateTypes supported = static_cast(FULL | FAST); + + public: + ED047TC1() : EInk(DISPLAY_WIDTH, DISPLAY_HEIGHT, supported) {} + + // 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; + + protected: + bool isUpdateDone() override { return true; } // FastEPD updates are blocking + + private: + FASTEPD *epaper = nullptr; +}; + +} // namespace NicheGraphics::Drivers + +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/Fonts/FreeSans18pt_Win1253.h b/src/graphics/niche/Fonts/FreeSans18pt_Win1253.h new file mode 100644 index 00000000000..9b29f32b5cd --- /dev/null +++ b/src/graphics/niche/Fonts/FreeSans18pt_Win1253.h @@ -0,0 +1,1467 @@ +// trunk-ignore-all(clang-format) +#pragma once +/* PROPERTIES + +FONT_NAME FreeSans18pt_Win1253 +*/ +const uint8_t FreeSans18pt_Win1253Bitmaps[] PROGMEM = { + 0x00, 0x00, 0x70, 0x00, 0x00, 0x00, 0x6C, 0x00, 0x00, 0x00, 0x23, 0x00, + 0x00, 0x00, 0x10, 0x80, 0x00, 0x00, 0x08, 0x40, 0x00, 0x00, 0x04, 0x30, + 0x00, 0x00, 0x02, 0x18, 0x00, 0x00, 0x01, 0x08, 0x00, 0x00, 0x01, 0x84, + 0x00, 0x00, 0x01, 0x82, 0x00, 0x00, 0x01, 0x83, 0x00, 0x00, 0x01, 0x81, + 0x80, 0x00, 0x03, 0x80, 0xBF, 0xC0, 0x03, 0x00, 0x7C, 0x30, 0x03, 0x00, + 0x60, 0x18, 0x01, 0x00, 0x20, 0x04, 0x01, 0x80, 0x10, 0x06, 0x7F, 0xC0, + 0x0C, 0x06, 0x30, 0x00, 0x07, 0xFF, 0xD8, 0x00, 0x03, 0xE0, 0x2C, 0x00, + 0x01, 0x80, 0x1E, 0x00, 0x01, 0xC0, 0x0F, 0x00, 0x01, 0xA0, 0x05, 0x80, + 0x03, 0x9C, 0x06, 0xC0, 0x03, 0x07, 0xFE, 0x60, 0x00, 0x06, 0x01, 0xB0, + 0x00, 0x03, 0x00, 0x58, 0x00, 0x01, 0x80, 0x2C, 0x00, 0x00, 0xC0, 0x16, + 0x00, 0x00, 0x3E, 0x1B, 0xF8, 0x00, 0x3F, 0xF9, 0xFF, 0x80, 0x10, 0x10, + 0x00, 0x60, 0x08, 0x08, 0x00, 0x1E, 0x06, 0x04, 0x00, 0x03, 0xFF, 0xFE, + 0x00, 0x00, 0x06, 0x1E, 0x00, 0x00, 0x00, 0x79, 0xF0, 0x00, 0x07, 0xFF, + 0xEC, 0x00, 0x0E, 0x03, 0x02, 0x00, 0x0C, 0x01, 0x01, 0x0F, 0xFC, 0x00, + 0x80, 0x87, 0xE0, 0x00, 0x7F, 0xF3, 0x00, 0x00, 0x1E, 0x0D, 0x80, 0x00, + 0x18, 0x02, 0xC0, 0x00, 0x0C, 0x01, 0x60, 0x00, 0x06, 0x00, 0xB0, 0x00, + 0x03, 0x80, 0xD8, 0x00, 0x60, 0xFF, 0xCC, 0x00, 0x1C, 0xC0, 0x36, 0x00, + 0x03, 0x40, 0x0B, 0x00, 0x00, 0xE0, 0x07, 0x80, 0x00, 0x38, 0x03, 0xC0, + 0x00, 0x1F, 0x81, 0x60, 0x00, 0x07, 0xFF, 0xBF, 0xE0, 0x06, 0x01, 0x00, + 0x30, 0x02, 0x00, 0xC0, 0x08, 0x01, 0x00, 0x20, 0x06, 0x00, 0xC0, 0x30, + 0x01, 0x80, 0x3F, 0x18, 0x00, 0x70, 0x13, 0xF8, 0x00, 0x0C, 0x0C, 0x00, + 0x00, 0x03, 0x06, 0x00, 0x00, 0x00, 0xC1, 0x00, 0x00, 0x00, 0x30, 0x80, + 0x00, 0x00, 0x08, 0x40, 0x00, 0x00, 0x04, 0x30, 0x00, 0x00, 0x02, 0x18, + 0x00, 0x00, 0x01, 0x08, 0x00, 0x00, 0x00, 0x84, 0x00, 0x00, 0x00, 0x46, + 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0x07, + 0xFC, 0x00, 0x00, 0x07, 0x80, 0xE0, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, + 0xC0, 0x00, 0x18, 0x00, 0x20, 0x00, 0x01, 0x80, 0x08, 0x00, 0x00, 0x08, + 0x02, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x18, 0x10, 0x00, 0x00, + 0x01, 0x04, 0x00, 0x00, 0x00, 0x10, 0x80, 0x00, 0x00, 0x02, 0x20, 0x00, + 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x04, 0x80, 0x70, 0x07, 0x00, 0xA0, + 0x31, 0x01, 0x18, 0x0C, 0x04, 0x30, 0x61, 0x01, 0x80, 0x00, 0x00, 0x00, + 0x30, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xA8, 0x00, + 0x54, 0x18, 0x2A, 0x00, 0x12, 0x83, 0x05, 0x40, 0x02, 0xA0, 0x50, 0x00, + 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x02, 0x40, 0x10, 0x01, 0x00, 0x44, + 0x03, 0x80, 0xE0, 0x10, 0x80, 0x0F, 0xE0, 0x02, 0x08, 0x00, 0x00, 0x00, + 0x81, 0x00, 0x00, 0x00, 0x30, 0x10, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, + 0x01, 0x00, 0x10, 0x00, 0x00, 0x40, 0x01, 0x80, 0x00, 0x30, 0x00, 0x0C, + 0x00, 0x18, 0x00, 0x00, 0xF0, 0x1E, 0x00, 0x00, 0x03, 0xFE, 0x00, 0x00, + 0x00, 0x00, 0x7F, 0xC0, 0x00, 0x00, 0x00, 0x70, 0x1C, 0x00, 0x00, 0x00, + 0x60, 0x00, 0xC0, 0x00, 0x00, 0x60, 0x00, 0x0C, 0x00, 0x00, 0x30, 0x00, + 0x01, 0x80, 0x00, 0x10, 0x00, 0x00, 0x10, 0x00, 0x08, 0x00, 0x00, 0x02, + 0x00, 0x06, 0x00, 0x00, 0x00, 0xC0, 0x01, 0x0C, 0x00, 0x06, 0x10, 0x00, + 0x86, 0x00, 0x00, 0xC2, 0x00, 0x22, 0x00, 0x00, 0x08, 0x80, 0x11, 0x00, + 0x00, 0x01, 0x10, 0x04, 0x40, 0x00, 0x00, 0x44, 0x01, 0x03, 0xE0, 0x0F, + 0x81, 0x00, 0x81, 0x8C, 0x06, 0x30, 0x20, 0x20, 0x41, 0x01, 0x04, 0x08, + 0x08, 0x00, 0x00, 0x00, 0x02, 0x03, 0xE0, 0x00, 0x00, 0x0F, 0x83, 0x98, + 0x00, 0x00, 0x02, 0x31, 0x86, 0x00, 0x00, 0x00, 0xC2, 0xC1, 0x00, 0x00, + 0x00, 0x10, 0xE0, 0x4F, 0x80, 0x03, 0xE4, 0x18, 0x32, 0x1F, 0xFF, 0x09, + 0x06, 0x08, 0xE0, 0x00, 0x0E, 0x61, 0x4E, 0x1F, 0xFF, 0xFF, 0x0C, 0x8F, + 0x87, 0xFF, 0xFF, 0xC3, 0xC0, 0x20, 0xFF, 0xFF, 0xE0, 0x80, 0x0C, 0x0F, + 0x83, 0xE0, 0x60, 0x01, 0x81, 0xC0, 0x70, 0x30, 0x00, 0x20, 0x0F, 0xE0, + 0x18, 0x00, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0xC0, 0x00, 0x06, 0x00, + 0x00, 0x18, 0x00, 0x03, 0x00, 0x00, 0x03, 0x80, 0x03, 0x00, 0x00, 0x00, + 0x3C, 0x07, 0x80, 0x00, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x0C, + 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, + 0x08, 0x40, 0x00, 0x00, 0x71, 0xC0, 0x00, 0x03, 0x9F, 0x0C, 0x00, 0x00, + 0xFA, 0x30, 0x07, 0x00, 0x11, 0xC3, 0x01, 0xB0, 0x02, 0x18, 0x30, 0x62, + 0x00, 0x61, 0x83, 0x08, 0xC0, 0x06, 0x18, 0x31, 0x18, 0x01, 0xE1, 0x83, + 0x23, 0x00, 0x66, 0x18, 0x34, 0x20, 0x08, 0x61, 0x83, 0x84, 0x01, 0x86, + 0x18, 0x38, 0xC0, 0x18, 0x61, 0x83, 0x08, 0x03, 0x86, 0x10, 0x41, 0x00, + 0xF8, 0x60, 0x18, 0x30, 0x31, 0x86, 0x02, 0x02, 0x06, 0x18, 0x40, 0x40, + 0x60, 0xC1, 0x80, 0x08, 0x04, 0x0C, 0x18, 0x01, 0x00, 0x80, 0xC1, 0x00, + 0x20, 0x18, 0x0C, 0x00, 0x06, 0x01, 0x10, 0xC0, 0x00, 0xC0, 0x23, 0x8C, + 0x00, 0x0C, 0x06, 0x10, 0xC0, 0x00, 0x01, 0xE0, 0x0C, 0x00, 0x00, 0x37, + 0x00, 0xC0, 0x00, 0x04, 0x60, 0x0C, 0x00, 0x01, 0x80, 0x00, 0xC0, 0x00, + 0x20, 0x00, 0x0C, 0x00, 0x0C, 0x00, 0x00, 0xE0, 0x03, 0x00, 0x00, 0x07, + 0x00, 0xC0, 0x00, 0x00, 0x7F, 0xF0, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, + 0x00, 0x0C, 0x00, 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0x00, 0xD0, 0x00, + 0x00, 0xE0, 0x12, 0x00, 0x00, 0x16, 0x04, 0x60, 0x00, 0x02, 0x71, 0x8C, + 0x00, 0x00, 0x63, 0x20, 0x80, 0x00, 0x0C, 0x1F, 0xD0, 0x78, 0x00, 0x8E, + 0x0F, 0xFD, 0x00, 0x12, 0x00, 0x30, 0x60, 0x02, 0x80, 0x02, 0x08, 0x00, + 0x60, 0x00, 0x22, 0x00, 0x7C, 0x00, 0x06, 0xC0, 0xFD, 0x00, 0x00, 0x50, + 0x30, 0x20, 0x00, 0x0C, 0x06, 0x08, 0x00, 0x00, 0x80, 0x71, 0x00, 0x00, + 0x18, 0x03, 0x20, 0x00, 0x02, 0xC0, 0x3C, 0x00, 0x00, 0x4C, 0x01, 0x80, + 0x00, 0x08, 0x60, 0x10, 0x00, 0x01, 0x06, 0x07, 0x00, 0x00, 0x40, 0xC0, + 0xA0, 0x00, 0x0B, 0xF0, 0x36, 0x00, 0x03, 0xE0, 0x04, 0x40, 0x00, 0x60, + 0x01, 0x04, 0x00, 0x14, 0x00, 0x60, 0xC0, 0x04, 0x80, 0x0B, 0xFF, 0x07, + 0x10, 0x01, 0xF0, 0xBF, 0xC3, 0x00, 0x00, 0x10, 0x4C, 0x60, 0x00, 0x03, + 0x18, 0xE4, 0x00, 0x00, 0x62, 0x06, 0x80, 0x00, 0x0C, 0xC0, 0x70, 0x00, + 0x00, 0xB0, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x03, 0x80, 0x00, + 0x00, 0x00, 0x00, 0x1F, 0xC0, 0x00, 0x00, 0x03, 0x83, 0x80, 0x00, 0x00, + 0x30, 0x06, 0x00, 0x00, 0x03, 0x00, 0x18, 0x00, 0x00, 0x30, 0x00, 0x60, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x18, 0x00, 0x0C, 0x00, 0x07, 0xC0, + 0x00, 0x60, 0x00, 0xC3, 0x80, 0x01, 0x00, 0x0C, 0x04, 0x00, 0x08, 0x00, + 0xC0, 0x30, 0x00, 0x40, 0x04, 0x00, 0x80, 0x02, 0x00, 0x20, 0x04, 0x00, + 0x18, 0x0F, 0x00, 0x00, 0x01, 0xF1, 0xC0, 0x00, 0x00, 0x00, 0xC8, 0x00, + 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, + 0x60, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x26, 0x00, 0x00, + 0x00, 0x03, 0x0F, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x30, 0x03, 0x00, 0x38, 0x03, 0x80, + 0x38, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x1E, 0x01, 0xE0, 0x1E, 0x00, 0xE0, + 0x0E, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x80, 0x30, 0x00, 0x00, 0x1C, 0x03, 0x80, 0x00, 0x01, 0xE0, + 0x3C, 0x00, 0x00, 0x0F, 0x01, 0xC0, 0x00, 0x00, 0x70, 0x0E, 0x00, 0x00, + 0x00, 0x00, 0x07, 0xF0, 0x00, 0x00, 0x00, 0x1C, 0x0C, 0x00, 0x00, 0x00, + 0x70, 0x02, 0x00, 0x00, 0x00, 0xC0, 0x01, 0x00, 0x00, 0x01, 0x80, 0x00, + 0x80, 0x00, 0x01, 0x80, 0x00, 0x40, 0x00, 0x03, 0x00, 0x00, 0x40, 0x00, + 0x3F, 0x00, 0x00, 0x20, 0x00, 0xE1, 0xC0, 0x00, 0x20, 0x01, 0x80, 0x60, + 0x00, 0x20, 0x01, 0x00, 0x20, 0x00, 0x20, 0x02, 0x00, 0x10, 0x00, 0x20, + 0x02, 0x00, 0x10, 0x00, 0x20, 0x06, 0x00, 0x00, 0x00, 0x38, 0x1E, 0x00, + 0x00, 0x00, 0x0C, 0x70, 0x00, 0x00, 0x00, 0x02, 0x40, 0x00, 0x00, 0x00, + 0x03, 0x80, 0x00, 0x00, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x01, 0x80, + 0x00, 0x00, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0x00, + 0x00, 0x02, 0x60, 0x00, 0x00, 0x00, 0x02, 0x18, 0x20, 0x00, 0x00, 0x0C, + 0x0F, 0xF0, 0x00, 0x00, 0x70, 0x00, 0x0F, 0xFC, 0x00, 0x80, 0x00, 0x03, + 0xE7, 0x03, 0x00, 0x00, 0x00, 0x01, 0xFC, 0x00, 0x01, 0xF0, 0x00, 0x1F, + 0x00, 0x0F, 0xFC, 0x01, 0xFF, 0x80, 0x39, 0xDC, 0x07, 0x3B, 0x80, 0xF2, + 0xDC, 0x1E, 0x5B, 0x83, 0xB4, 0xEC, 0x6E, 0x9D, 0x8D, 0x38, 0xCD, 0x93, + 0x19, 0x9E, 0x30, 0xCB, 0xA3, 0x19, 0x64, 0x31, 0x69, 0xC7, 0x3B, 0x8C, + 0x52, 0x71, 0x8B, 0x4F, 0x96, 0x94, 0x61, 0x93, 0x8F, 0xA7, 0x18, 0x63, + 0xA3, 0x0D, 0xC6, 0x18, 0xE4, 0xC3, 0x19, 0x86, 0x39, 0x68, 0x87, 0x31, + 0x8E, 0x5A, 0x71, 0xCB, 0x53, 0x96, 0x9C, 0x62, 0xD1, 0x25, 0xA7, 0x18, + 0x64, 0xE2, 0x69, 0xC6, 0x18, 0xE8, 0xC4, 0x70, 0x86, 0x39, 0x70, 0xD0, + 0xE1, 0x8E, 0x5A, 0x21, 0xE0, 0xE2, 0xD2, 0x9C, 0x62, 0x81, 0xA4, 0xE3, + 0x08, 0xB7, 0x01, 0x38, 0xC3, 0x19, 0x34, 0x01, 0x30, 0xC7, 0x2E, 0x30, + 0x01, 0x31, 0xCB, 0x4C, 0x40, 0x01, 0x72, 0xD3, 0x8D, 0x00, 0x03, 0xB4, + 0xE3, 0x1C, 0x00, 0x03, 0x38, 0xC3, 0x38, 0x00, 0x03, 0x30, 0xC7, 0x60, + 0x00, 0x01, 0x31, 0xCB, 0x00, 0x00, 0x01, 0x72, 0x54, 0x00, 0x00, 0x01, + 0xB4, 0x70, 0x00, 0x00, 0x01, 0x18, 0x40, 0x00, 0x00, 0x01, 0x93, 0x00, + 0x00, 0x00, 0x01, 0xBC, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x38, 0x00, + 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 0x07, 0xE0, 0x00, 0x00, 0x01, 0xFE, + 0x00, 0x00, 0x00, 0x3F, 0xE0, 0x00, 0x00, 0x0F, 0xFF, 0xC0, 0x00, 0x01, + 0xFF, 0xFE, 0x00, 0x00, 0x3F, 0xFF, 0xC0, 0x00, 0x07, 0xFF, 0xFC, 0x00, + 0x03, 0xFF, 0xFF, 0x80, 0x00, 0xFF, 0xFF, 0xF0, 0x00, 0x3F, 0xFF, 0xFE, + 0x00, 0x07, 0xFF, 0xFF, 0xE0, 0x01, 0xFF, 0xFF, 0xFE, 0x00, 0x3F, 0xFF, + 0xFF, 0xE0, 0x07, 0xE1, 0xF8, 0xFC, 0x00, 0xF8, 0x1E, 0x0F, 0x80, 0x3E, + 0x71, 0x98, 0xF8, 0x1F, 0xCF, 0x37, 0x9F, 0xC7, 0xF9, 0xC6, 0x63, 0xFC, + 0xFF, 0x01, 0xE0, 0x7F, 0xBF, 0xF0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, + 0x03, 0xFF, 0xBF, 0xF0, 0x00, 0x7F, 0xE7, 0xFE, 0x00, 0x1F, 0xFC, 0x7F, + 0xE0, 0x03, 0xFF, 0x0F, 0xFE, 0x00, 0xFF, 0xC0, 0xFF, 0xF0, 0x7F, 0xF0, + 0x07, 0xFF, 0xFF, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0x00, 0x03, 0xFF, 0xFF, + 0x00, 0x00, 0x07, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x22, 0x00, 0x00, 0x00, 0x0C, 0x20, 0x00, 0x00, 0x01, 0x04, 0x00, 0x00, + 0x00, 0x20, 0x80, 0x00, 0x00, 0x06, 0x30, 0x00, 0x00, 0x00, 0x7C, 0x00, + 0x00, 0x00, 0x3F, 0xE0, 0x00, 0x00, 0x1C, 0x07, 0x00, 0x00, 0x06, 0x00, + 0x30, 0x00, 0x01, 0x00, 0x03, 0x00, 0x00, 0x43, 0x00, 0x30, 0x00, 0x18, + 0xC0, 0x03, 0x00, 0x02, 0x30, 0x00, 0x20, 0x00, 0x44, 0x00, 0x04, 0x00, + 0x11, 0x80, 0x00, 0xC0, 0x02, 0x20, 0x00, 0x08, 0x00, 0x40, 0x00, 0x01, + 0x00, 0x08, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x04, 0x00, 0x20, 0x00, + 0x00, 0x80, 0x0C, 0x00, 0x00, 0x18, 0x01, 0x00, 0x00, 0x03, 0x00, 0x20, + 0x00, 0x00, 0x20, 0x0D, 0xFF, 0xFE, 0x04, 0x01, 0xE0, 0x00, 0x00, 0x40, + 0x40, 0x00, 0x00, 0x04, 0x10, 0x00, 0x00, 0x00, 0x44, 0x07, 0xFF, 0xFC, + 0x04, 0xFF, 0xF8, 0x0F, 0xFE, 0xBF, 0xFF, 0x01, 0xFF, 0xFF, 0xFF, 0xE0, + 0x3F, 0xFF, 0xFF, 0xFE, 0x0F, 0xFF, 0xC7, 0xFF, 0xC1, 0xFF, 0xF0, 0x3F, + 0xFC, 0x7F, 0xF8, 0x00, 0x3F, 0xFF, 0xE0, 0x00, 0x00, 0x07, 0xFC, 0x00, + 0x00, 0x07, 0x00, 0xF0, 0x00, 0x03, 0x80, 0x03, 0x00, 0x00, 0xC0, 0x00, + 0x18, 0x00, 0x20, 0x00, 0x00, 0x80, 0x08, 0x00, 0x00, 0x08, 0x02, 0x00, + 0x00, 0x00, 0x80, 0x82, 0x00, 0x02, 0x08, 0x11, 0xC0, 0x00, 0x71, 0x04, + 0xE0, 0x00, 0x03, 0x11, 0x90, 0x00, 0x00, 0x33, 0x26, 0x00, 0x00, 0x03, + 0x24, 0x0E, 0x00, 0x0F, 0x05, 0x87, 0xF0, 0x07, 0xF0, 0x61, 0xCF, 0x01, + 0xE7, 0x0C, 0x09, 0x80, 0x0C, 0x81, 0x81, 0x30, 0x01, 0x90, 0x30, 0x26, + 0x00, 0x32, 0x06, 0x04, 0xC0, 0x06, 0x40, 0xC0, 0x98, 0xF8, 0xC8, 0x18, + 0x13, 0xFF, 0xF9, 0x03, 0x02, 0x70, 0x07, 0x20, 0xD0, 0x4C, 0x00, 0x64, + 0x12, 0x09, 0x80, 0x0C, 0x82, 0x61, 0x3F, 0xFF, 0x90, 0xC4, 0x27, 0xFF, + 0xF2, 0x10, 0xC4, 0xFF, 0xFE, 0x46, 0x08, 0x9F, 0xDF, 0xC8, 0x80, 0x93, + 0x00, 0x19, 0x30, 0x1A, 0x60, 0x03, 0x2C, 0x01, 0xCC, 0x00, 0x67, 0x80, + 0xC1, 0x80, 0x1C, 0x18, 0x10, 0x18, 0x03, 0x01, 0x03, 0x03, 0xC1, 0xE0, + 0x60, 0x20, 0x18, 0x30, 0x08, 0x06, 0x03, 0xFF, 0x02, 0x00, 0x3F, 0x80, + 0x3F, 0x80, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x00, + 0x24, 0x80, 0x00, 0x00, 0x1F, 0xE0, 0x00, 0x00, 0x06, 0xC8, 0x00, 0x00, + 0x01, 0x93, 0x00, 0x00, 0x00, 0x55, 0xC0, 0x00, 0x00, 0x1F, 0xF0, 0x00, + 0x00, 0x04, 0x44, 0x00, 0x00, 0x01, 0x11, 0x00, 0x00, 0x00, 0xC4, 0x40, + 0x00, 0x00, 0x31, 0x10, 0x00, 0x00, 0x0C, 0x46, 0x00, 0x00, 0x02, 0x11, + 0x80, 0x00, 0x00, 0x84, 0x20, 0x00, 0x00, 0x61, 0x08, 0x00, 0x00, 0x18, + 0x43, 0x00, 0x00, 0x04, 0x10, 0xC0, 0x00, 0x01, 0x04, 0x10, 0x00, 0x00, + 0x41, 0x04, 0x00, 0x00, 0x30, 0x41, 0x00, 0x00, 0x3C, 0x10, 0x78, 0x00, + 0x7E, 0x04, 0x0F, 0x80, 0x7A, 0x01, 0x01, 0xBC, 0x70, 0xC0, 0x40, 0x43, + 0xD0, 0x10, 0x10, 0x30, 0x10, 0x06, 0x0C, 0x0C, 0x00, 0x00, 0xC7, 0xC6, + 0x00, 0x00, 0x13, 0x1B, 0x00, 0x00, 0x07, 0x83, 0x80, 0x00, 0x00, 0xF1, + 0xE0, 0x00, 0x00, 0x1C, 0x70, 0x00, 0x00, 0x03, 0x18, 0x00, 0x00, 0x03, + 0x83, 0x80, 0x00, 0x01, 0x80, 0x30, 0x00, 0x00, 0x40, 0x04, 0x00, 0x00, + 0x07, 0xFC, 0x00, 0x00, 0x07, 0x80, 0xF0, 0x00, 0x01, 0x80, 0x03, 0x80, + 0x00, 0xC0, 0x00, 0x18, 0x00, 0x20, 0x00, 0x00, 0x80, 0x08, 0x00, 0x00, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x80, 0x83, 0x00, 0x00, 0x08, 0x10, 0xE0, + 0x00, 0x01, 0x84, 0x30, 0x00, 0x00, 0x10, 0x8C, 0x00, 0x00, 0x03, 0x21, + 0x00, 0x00, 0x10, 0x24, 0x03, 0x00, 0x0E, 0x04, 0x80, 0xF0, 0x03, 0x00, + 0xE0, 0x1E, 0x01, 0xC0, 0x0C, 0x03, 0xC0, 0x3E, 0x01, 0x80, 0x30, 0x00, + 0xF0, 0x30, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, + 0x00, 0x1E, 0x18, 0x00, 0x07, 0x07, 0xE1, 0x00, 0x00, 0x30, 0xFF, 0x90, + 0x00, 0x02, 0x1F, 0xFA, 0x00, 0x00, 0x43, 0xFF, 0x40, 0x00, 0x30, 0x7F, + 0xE4, 0x00, 0x01, 0x0F, 0xFC, 0x80, 0x00, 0x20, 0xFF, 0x88, 0x00, 0x0C, + 0x3F, 0xE1, 0x80, 0x06, 0x07, 0xF0, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0xE0, 0x01, 0x80, 0x00, 0x30, 0x00, + 0x0C, 0x00, 0x1C, 0x00, 0x00, 0x70, 0x1E, 0x00, 0x00, 0x01, 0xFE, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0xC0, 0x30, 0x00, 0xC0, 0xC0, + 0x00, 0x30, 0x30, 0x40, 0x30, 0x04, 0x04, 0x30, 0x04, 0x01, 0x00, 0x08, + 0x00, 0x00, 0x24, 0x02, 0x3C, 0x00, 0x09, 0x80, 0x99, 0x80, 0x02, 0x00, + 0x2C, 0x20, 0x00, 0x80, 0x06, 0x08, 0xF0, 0x20, 0x01, 0x86, 0x47, 0x18, + 0x1C, 0x3F, 0x10, 0x7C, 0x01, 0x0C, 0x04, 0x00, 0x00, 0x0F, 0x80, 0x80, + 0x00, 0x0C, 0x78, 0x21, 0x80, 0x02, 0x13, 0x08, 0x20, 0x01, 0x04, 0x7E, + 0x00, 0x00, 0x41, 0x1E, 0x00, 0x00, 0x30, 0x8D, 0x00, 0x0C, 0x0C, 0x66, + 0x23, 0x04, 0x07, 0x11, 0x08, 0x42, 0x01, 0x40, 0x41, 0x01, 0x80, 0x48, + 0x00, 0x40, 0x60, 0x32, 0x00, 0x7C, 0x10, 0x0C, 0x43, 0xE7, 0x8C, 0x07, + 0x88, 0x01, 0x3F, 0x01, 0x21, 0x00, 0x47, 0x80, 0xCC, 0x30, 0x20, 0x00, + 0x31, 0x83, 0xF0, 0x00, 0xCC, 0x38, 0xF0, 0x00, 0x05, 0x83, 0xF0, 0x04, + 0x01, 0x30, 0xE0, 0x01, 0x80, 0xC7, 0xE0, 0x00, 0x60, 0xA1, 0xE0, 0x00, + 0x00, 0x69, 0xC0, 0x00, 0x00, 0x0F, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x07, + 0xFC, 0x00, 0x00, 0x07, 0x00, 0x70, 0x00, 0x03, 0x80, 0x03, 0x80, 0x00, + 0xC0, 0x00, 0x18, 0x00, 0x20, 0x00, 0x00, 0x80, 0x08, 0x00, 0x00, 0x08, + 0x02, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x08, 0x10, 0x00, 0x00, + 0x01, 0x04, 0x00, 0x00, 0x00, 0x11, 0x80, 0x00, 0x00, 0x02, 0x20, 0x00, + 0x00, 0x00, 0x24, 0x01, 0x80, 0x18, 0x05, 0x80, 0x78, 0x07, 0x80, 0xA0, + 0x0F, 0x00, 0xF0, 0x0C, 0x01, 0xE0, 0x1E, 0x01, 0x80, 0x18, 0x01, 0x80, + 0x30, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, + 0x00, 0x18, 0x00, 0x00, 0x00, 0x03, 0x07, 0xC0, 0x01, 0xF0, 0x70, 0x87, + 0xFF, 0xC2, 0x12, 0x1C, 0x00, 0x01, 0xC2, 0x41, 0xFF, 0xFF, 0xF0, 0x4C, + 0x3F, 0xFF, 0xFE, 0x10, 0x83, 0xFF, 0xFF, 0x82, 0x08, 0x1F, 0x03, 0xC0, + 0x81, 0x01, 0xC0, 0x30, 0x10, 0x10, 0x07, 0xF8, 0x04, 0x01, 0x00, 0x00, + 0x01, 0x00, 0x10, 0x00, 0x00, 0x40, 0x01, 0x80, 0x00, 0x30, 0x00, 0x1C, + 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x0E, 0x00, 0x00, 0x03, 0xFE, 0x00, 0x00, + 0x00, 0x07, 0xFC, 0x00, 0x00, 0x07, 0x80, 0xE0, 0x00, 0x01, 0x80, 0x03, + 0x00, 0x00, 0xC0, 0x00, 0x18, 0x00, 0x20, 0x00, 0x01, 0x80, 0x08, 0x00, + 0x00, 0x08, 0x02, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x18, 0x10, + 0x10, 0x01, 0x81, 0x04, 0x1C, 0x00, 0x1E, 0x10, 0x8E, 0x00, 0x00, 0xF2, + 0x20, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x04, 0x80, 0xF0, 0x07, + 0xC0, 0xA0, 0x67, 0x81, 0x9C, 0x0C, 0x08, 0x70, 0x61, 0xC1, 0x83, 0x0F, + 0x18, 0x3C, 0x30, 0x63, 0xE3, 0x0F, 0x86, 0x0C, 0xFC, 0x73, 0xF0, 0xC1, + 0xFB, 0x8F, 0xEE, 0x18, 0x1F, 0xE0, 0xFF, 0x83, 0x03, 0xFC, 0x0F, 0xE0, + 0x50, 0x1E, 0x00, 0xF8, 0x12, 0x00, 0x00, 0x00, 0x02, 0x40, 0x00, 0x00, + 0x00, 0x44, 0x00, 0x00, 0x00, 0x10, 0x80, 0x00, 0x00, 0x02, 0x08, 0x00, + 0x00, 0x00, 0x81, 0x00, 0x3F, 0x80, 0x30, 0x10, 0x04, 0x10, 0x04, 0x01, + 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x00, 0x40, 0x01, 0x80, 0x00, 0x30, + 0x00, 0x0C, 0x00, 0x18, 0x00, 0x00, 0xF0, 0x1E, 0x00, 0x00, 0x03, 0xFE, + 0x00, 0x00, 0x00, 0x03, 0xFC, 0x00, 0x00, 0x00, 0x70, 0x0E, 0x02, 0x00, + 0x06, 0x00, 0x0E, 0x0C, 0x00, 0x60, 0x00, 0x0E, 0x58, 0x02, 0x00, 0x00, + 0x0F, 0x20, 0x10, 0x00, 0x00, 0x18, 0x40, 0x80, 0x00, 0x00, 0x41, 0x86, + 0x00, 0x00, 0x01, 0x02, 0x10, 0x00, 0x00, 0x08, 0x04, 0x80, 0x00, 0x00, + 0x20, 0x12, 0x00, 0x00, 0x00, 0x80, 0x50, 0x00, 0x00, 0x02, 0x01, 0x40, + 0x00, 0x00, 0x0C, 0x0D, 0x01, 0xF0, 0x1F, 0x18, 0x68, 0x0C, 0x60, 0xC6, + 0x3E, 0x20, 0x00, 0x82, 0x08, 0x08, 0x80, 0x00, 0x00, 0x00, 0x22, 0x00, + 0x00, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00, 0x02, 0x20, 0x00, 0x00, 0x00, + 0x08, 0x80, 0x00, 0x00, 0x00, 0x22, 0x0F, 0xC0, 0x03, 0xE0, 0x84, 0x21, + 0xFF, 0xF0, 0x84, 0x10, 0xE0, 0x00, 0x0E, 0x10, 0x41, 0xFF, 0xFF, 0xF8, + 0x40, 0x87, 0xFF, 0xFF, 0xC2, 0x02, 0x0F, 0xFF, 0xFE, 0x08, 0x04, 0x0F, + 0x83, 0xF0, 0x40, 0x10, 0x1C, 0x07, 0x03, 0x00, 0x20, 0x0F, 0xE0, 0x08, + 0x00, 0x40, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x06, 0x00, 0x01, 0x80, + 0x00, 0x30, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, 0x03, 0xC0, 0x70, 0x00, + 0x00, 0x01, 0xFF, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x01, 0xF8, 0x00, + 0x00, 0x0F, 0xC0, 0x00, 0x00, 0xFC, 0x00, 0x00, 0x0F, 0xE0, 0x00, 0x00, + 0xFF, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x0F, 0xF0, + 0x00, 0x01, 0xFF, 0x80, 0x00, 0x3F, 0xF8, 0x00, 0x87, 0xFF, 0x80, 0x18, + 0x7F, 0x78, 0x03, 0x8F, 0xCF, 0x00, 0x38, 0xF8, 0xF0, 0x07, 0x9F, 0x0F, + 0x00, 0x79, 0xE0, 0xF0, 0x0F, 0xDE, 0x0F, 0x04, 0xFF, 0xC0, 0xF9, 0xCF, + 0xFC, 0x0F, 0xFE, 0xFF, 0xC0, 0x7F, 0xEF, 0xFC, 0x07, 0xFF, 0xFF, 0xC0, + 0x3F, 0xFF, 0xF6, 0x01, 0xFF, 0xFF, 0x60, 0x0F, 0xFF, 0xE0, 0x00, 0xFF, + 0x7E, 0x00, 0x07, 0xE7, 0xE0, 0x00, 0x7E, 0x7E, 0x00, 0x07, 0xE3, 0xE0, + 0x00, 0x7C, 0x1E, 0x00, 0x0F, 0xC1, 0xF0, 0x00, 0xF8, 0x0F, 0x80, 0x1F, + 0x00, 0x3E, 0x07, 0xC0, 0x01, 0xFF, 0xF8, 0x00, 0x03, 0xFC, 0x00, 0x00, + 0x0F, 0xFC, 0x00, 0x00, 0x01, 0xC0, 0x38, 0x00, 0x00, 0x38, 0x00, 0x60, + 0x00, 0x03, 0x00, 0x00, 0x80, 0x00, 0x10, 0x00, 0x02, 0x00, 0x01, 0x00, + 0x00, 0x18, 0x00, 0x18, 0x1F, 0x80, 0x60, 0x01, 0x81, 0x86, 0x01, 0x00, + 0x08, 0x08, 0x10, 0x0C, 0x00, 0xC0, 0xC0, 0xF8, 0x30, 0x04, 0x16, 0x03, + 0x60, 0x80, 0x60, 0xF0, 0x13, 0x06, 0x02, 0x01, 0x80, 0x10, 0x18, 0x30, + 0x04, 0x00, 0x80, 0x61, 0x00, 0x30, 0x0C, 0x01, 0x18, 0x00, 0xC0, 0x20, + 0x0C, 0xC0, 0x03, 0x01, 0x00, 0x34, 0x01, 0xF8, 0x04, 0x00, 0xA0, 0x00, + 0x60, 0x30, 0x05, 0x00, 0x01, 0x00, 0xC0, 0x38, 0x00, 0x0C, 0x01, 0x80, + 0xC0, 0x00, 0x20, 0x04, 0x06, 0x00, 0x01, 0x80, 0x00, 0x38, 0x00, 0x06, + 0x00, 0x01, 0xC0, 0x00, 0x18, 0x00, 0x1B, 0x00, 0x00, 0x60, 0x00, 0xCC, + 0x00, 0x01, 0x80, 0x0C, 0x38, 0x00, 0x06, 0x00, 0x40, 0x7C, 0x00, 0x7E, + 0x0E, 0x00, 0xFF, 0xFF, 0x3F, 0xC0, 0x00, 0x00, 0x7F, 0xC0, 0x00, 0x00, + 0x00, 0x70, 0x1C, 0x00, 0x00, 0x00, 0x30, 0x01, 0x80, 0x00, 0x00, 0x10, + 0x00, 0x30, 0x00, 0x00, 0x08, 0x00, 0x06, 0x00, 0x00, 0x06, 0x00, 0x00, + 0xC0, 0x00, 0x03, 0x00, 0x00, 0x18, 0x01, 0xC0, 0x80, 0x00, 0x02, 0x0F, + 0xC8, 0x40, 0x00, 0x00, 0xC6, 0x71, 0x30, 0x70, 0x18, 0x19, 0x1C, 0x78, + 0x1C, 0x0E, 0x03, 0x85, 0x03, 0x03, 0x01, 0x81, 0x83, 0x60, 0x40, 0x00, + 0x00, 0xC0, 0x8C, 0x18, 0x00, 0x00, 0x20, 0x61, 0x83, 0x00, 0xE0, 0x18, + 0x30, 0x20, 0x40, 0x38, 0x04, 0x18, 0x0C, 0x18, 0x00, 0x03, 0x04, 0x03, + 0x82, 0x00, 0x00, 0x83, 0x80, 0xA0, 0x80, 0x00, 0x60, 0xA0, 0x6C, 0x30, + 0x00, 0x18, 0x68, 0x13, 0x0C, 0x00, 0x04, 0x13, 0x04, 0x41, 0x00, 0x01, + 0x04, 0x41, 0x10, 0x40, 0x00, 0x41, 0x10, 0x40, 0x10, 0x00, 0x10, 0x04, + 0x10, 0x04, 0x00, 0x04, 0x01, 0x04, 0x01, 0x00, 0x01, 0x00, 0x41, 0x80, + 0xC0, 0x00, 0x40, 0x30, 0x20, 0x3F, 0xFF, 0xF8, 0x08, 0x0E, 0x18, 0xFF, + 0xC3, 0x0C, 0x00, 0xFC, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x07, 0xFC, 0x00, + 0x00, 0x07, 0x80, 0xE0, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, 0xC0, 0x00, + 0x18, 0x00, 0x20, 0x00, 0x01, 0x80, 0x08, 0x00, 0x00, 0x08, 0x02, 0x00, + 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x18, 0x10, 0x00, 0x00, 0x01, 0x04, + 0x00, 0x00, 0x00, 0x10, 0x80, 0x00, 0x00, 0x02, 0x20, 0x7E, 0x01, 0xF8, + 0x24, 0x1F, 0xE0, 0x7F, 0x84, 0x82, 0xF4, 0x0B, 0xD0, 0xA0, 0x9E, 0x42, + 0x79, 0x0C, 0x10, 0x88, 0x42, 0x21, 0x82, 0x01, 0x08, 0x04, 0x30, 0x40, + 0x21, 0x00, 0x86, 0x04, 0x08, 0x10, 0x20, 0xC0, 0xC3, 0x03, 0x0C, 0x18, + 0x07, 0x80, 0x1E, 0x03, 0x00, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, + 0x12, 0x00, 0x00, 0x00, 0x02, 0x40, 0x00, 0x00, 0x00, 0x44, 0x00, 0x00, + 0x00, 0x10, 0x80, 0x00, 0x00, 0x02, 0x08, 0x00, 0x00, 0x00, 0x81, 0x00, + 0x7F, 0xE0, 0x30, 0x10, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, 0x01, 0x00, + 0x10, 0x00, 0x00, 0x40, 0x01, 0x80, 0x00, 0x30, 0x00, 0x0C, 0x00, 0x18, + 0x00, 0x00, 0xF0, 0x1E, 0x00, 0x00, 0x03, 0xFE, 0x00, 0x00, 0x00, 0x00, + 0x7F, 0x80, 0x00, 0x00, 0x00, 0xE0, 0x1C, 0x00, 0x00, 0x00, 0xC0, 0x00, + 0xC0, 0x00, 0x00, 0xC0, 0x00, 0x1C, 0x00, 0x00, 0x60, 0x00, 0x01, 0x80, + 0x00, 0x30, 0x00, 0x00, 0x30, 0x00, 0x18, 0x00, 0x00, 0x06, 0x00, 0x0C, + 0x00, 0x00, 0x00, 0xC0, 0x03, 0x00, 0x00, 0xF0, 0x30, 0x01, 0x80, 0x00, + 0x66, 0x06, 0x00, 0x60, 0x78, 0x10, 0x81, 0x80, 0x30, 0x13, 0x04, 0x00, + 0x30, 0x0C, 0x08, 0x00, 0x00, 0x0C, 0x07, 0x02, 0x00, 0x00, 0x43, 0x01, + 0x80, 0x00, 0x00, 0x10, 0x60, 0x60, 0x00, 0x00, 0x08, 0x18, 0x18, 0x00, + 0x00, 0x04, 0x06, 0x06, 0x00, 0x00, 0x06, 0x01, 0x81, 0x81, 0xC0, 0x07, + 0x00, 0x60, 0x60, 0x3E, 0x0F, 0x00, 0x18, 0x18, 0x01, 0xFF, 0x00, 0x06, + 0x07, 0xE0, 0x00, 0x00, 0x0F, 0x87, 0xCD, 0xC0, 0x00, 0x66, 0x79, 0x31, + 0x50, 0x00, 0x27, 0x12, 0x46, 0x32, 0x00, 0x19, 0x88, 0x98, 0x8C, 0x80, + 0x04, 0x46, 0x7B, 0x11, 0x20, 0x01, 0x11, 0x36, 0x22, 0x08, 0x00, 0x40, + 0x99, 0xC4, 0x01, 0x00, 0x10, 0x0C, 0xF8, 0x80, 0x40, 0x08, 0x06, 0x79, + 0x80, 0x10, 0x02, 0x00, 0x26, 0x30, 0x04, 0x00, 0x80, 0x11, 0x40, 0x01, + 0x00, 0x20, 0x00, 0xD8, 0x00, 0xC0, 0x0C, 0x00, 0x61, 0x80, 0x38, 0x07, + 0x00, 0x60, 0x38, 0x37, 0xFF, 0xB0, 0x70, 0x01, 0xF8, 0x00, 0x07, 0xE0, + 0x00, 0x00, 0x07, 0xFC, 0x00, 0x00, 0x07, 0x80, 0xE0, 0x00, 0x01, 0x80, + 0x03, 0x00, 0x00, 0xC0, 0x00, 0x18, 0x00, 0x20, 0x00, 0x01, 0x80, 0x08, + 0x00, 0x00, 0x08, 0x02, 0x00, 0x00, 0x00, 0x80, 0x81, 0xC0, 0x00, 0x18, + 0x10, 0x70, 0x00, 0x01, 0x04, 0x18, 0x00, 0x00, 0x10, 0x86, 0x00, 0x00, + 0x02, 0x20, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x02, 0x04, 0x80, 0x30, + 0x01, 0xC0, 0xA0, 0x0F, 0x00, 0x60, 0x0C, 0x01, 0xE0, 0x18, 0x01, 0x80, + 0x3C, 0x07, 0xC0, 0x30, 0x03, 0x00, 0x1E, 0x06, 0x00, 0x00, 0x00, 0x00, + 0xC0, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x50, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x04, 0x02, 0x40, 0x38, + 0x01, 0x80, 0x44, 0x01, 0xC0, 0xE0, 0x10, 0x80, 0x07, 0xE0, 0x02, 0x08, + 0x00, 0x00, 0x00, 0x81, 0x00, 0x00, 0x00, 0x30, 0x10, 0x00, 0x00, 0x04, + 0x01, 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x00, 0x40, 0x01, 0x80, 0x00, + 0x30, 0x00, 0x0C, 0x00, 0x18, 0x00, 0x00, 0xF0, 0x1E, 0x00, 0x00, 0x03, + 0xFE, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0x00, 0x03, 0xB8, 0x1C, 0x00, + 0x00, 0x60, 0xC0, 0x30, 0x00, 0x18, 0x00, 0x00, 0x80, 0x03, 0x00, 0x07, + 0xE4, 0x00, 0x61, 0xC0, 0x03, 0x20, 0x0C, 0x1E, 0x00, 0x01, 0x00, 0x81, + 0xE0, 0x00, 0x08, 0x10, 0x1E, 0x01, 0x80, 0xC3, 0x00, 0xC0, 0x3C, 0x04, + 0x20, 0x00, 0x03, 0xC0, 0x26, 0x00, 0x00, 0x3C, 0x02, 0x60, 0x00, 0x01, + 0x80, 0x24, 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0x00, 0x00, 0x14, 0x00, + 0x7F, 0x00, 0x01, 0x40, 0x1C, 0x1C, 0x00, 0x14, 0x00, 0x00, 0x60, 0x01, + 0x40, 0x00, 0x02, 0x00, 0x14, 0x00, 0x00, 0x00, 0x01, 0xF8, 0x00, 0x00, + 0x00, 0x18, 0x80, 0xFC, 0x00, 0x03, 0x8C, 0xF0, 0x20, 0x00, 0x28, 0x78, + 0x06, 0x00, 0x02, 0x40, 0x07, 0x80, 0x00, 0x64, 0x01, 0xC0, 0x00, 0x04, + 0x40, 0x3C, 0x00, 0x00, 0xC4, 0x00, 0x40, 0x00, 0x18, 0x40, 0x04, 0x00, + 0x01, 0x04, 0x01, 0xC0, 0x00, 0x20, 0x40, 0x04, 0x00, 0x0C, 0x04, 0x00, + 0xC0, 0x01, 0x80, 0x60, 0x04, 0x00, 0x70, 0x03, 0x00, 0x60, 0x3C, 0x00, + 0x18, 0x1F, 0xFF, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x00, 0x1D, 0xFC, + 0x00, 0x00, 0x0C, 0xC4, 0x80, 0x00, 0x06, 0x19, 0x9C, 0x00, 0x07, 0x06, + 0x66, 0xE0, 0x01, 0x03, 0x33, 0x43, 0x00, 0xC0, 0x8C, 0xD8, 0x30, 0x10, + 0x06, 0x66, 0x01, 0x04, 0x00, 0x31, 0x00, 0x10, 0x80, 0x00, 0xC0, 0x01, + 0x10, 0x00, 0x68, 0x00, 0x32, 0x07, 0xF3, 0x00, 0x02, 0x41, 0x84, 0xC0, + 0x00, 0x64, 0x00, 0x70, 0x00, 0x04, 0xC0, 0x18, 0x03, 0x80, 0x8C, 0x1F, + 0x00, 0x78, 0x1B, 0xFF, 0xE0, 0x0F, 0x01, 0x60, 0x3C, 0x01, 0xE0, 0x2C, + 0x03, 0x00, 0x38, 0x05, 0x80, 0x00, 0x00, 0x00, 0xB0, 0x00, 0x00, 0x00, + 0x16, 0x00, 0x00, 0x00, 0x02, 0xC0, 0x00, 0x00, 0x00, 0x48, 0x00, 0x00, + 0x00, 0x09, 0x00, 0x00, 0x00, 0x02, 0x20, 0x18, 0x00, 0x80, 0x46, 0x03, + 0xFF, 0xF0, 0x08, 0x40, 0x00, 0x00, 0x02, 0x0C, 0x00, 0x00, 0x00, 0x40, + 0x80, 0x00, 0x00, 0x10, 0x08, 0x00, 0x00, 0x06, 0x01, 0x80, 0x00, 0x01, + 0x80, 0x18, 0x00, 0x00, 0x60, 0x00, 0xC0, 0x00, 0x10, 0x00, 0x0C, 0x00, + 0x0C, 0x00, 0x00, 0x70, 0x0E, 0x00, 0x00, 0x01, 0xFE, 0x00, 0x00, 0x01, + 0xC0, 0x00, 0x01, 0xF8, 0x00, 0x00, 0x86, 0x00, 0x00, 0x41, 0xC0, 0x00, + 0x30, 0x30, 0x00, 0x1C, 0x0C, 0x00, 0x1B, 0x83, 0x80, 0x08, 0x60, 0x60, + 0x04, 0x18, 0x18, 0x03, 0x07, 0x04, 0x00, 0xC1, 0x83, 0x00, 0x30, 0x60, + 0x80, 0x1F, 0xF0, 0x60, 0x3C, 0x3E, 0x10, 0x30, 0x03, 0x8C, 0x70, 0x00, + 0x62, 0x60, 0x10, 0x11, 0x20, 0x7E, 0x0C, 0xF0, 0x61, 0x82, 0x6C, 0xE0, + 0x61, 0xB3, 0xC0, 0x10, 0x0B, 0x80, 0x08, 0x07, 0x60, 0x04, 0x03, 0x18, + 0x02, 0x03, 0x86, 0x03, 0x01, 0xC1, 0x01, 0x80, 0xE0, 0x61, 0x80, 0x58, + 0x1F, 0x80, 0x24, 0x00, 0x00, 0x33, 0x00, 0x00, 0x10, 0xC0, 0x00, 0x18, + 0x30, 0x00, 0x18, 0x0C, 0x00, 0x18, 0x03, 0x80, 0x18, 0x00, 0xFF, 0xF8, + 0x00, 0x0F, 0xE0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, + 0x00, 0x3F, 0xFC, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, + 0x87, 0xE1, 0xC0, 0x00, 0x38, 0x30, 0x00, 0x30, 0x70, 0x00, 0x70, 0x70, + 0x00, 0x70, 0x70, 0x00, 0x70, 0xE0, 0x00, 0x60, 0xE0, 0x00, 0xE0, 0xE0, + 0x3F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0x01, 0xC1, 0xC0, + 0x01, 0xC1, 0xC0, 0x01, 0xC1, 0x80, 0x03, 0x83, 0x80, 0x03, 0x83, 0x80, + 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFC, 0x07, 0x07, 0x00, + 0x07, 0x07, 0x00, 0x07, 0x06, 0x00, 0x0E, 0x0E, 0x00, 0x0E, 0x0E, 0x00, + 0x0E, 0x0E, 0x00, 0x0E, 0x0C, 0x00, 0x01, 0x80, 0x00, 0xC0, 0x00, 0x60, + 0x00, 0x30, 0x00, 0xFF, 0x81, 0xFF, 0xF1, 0xFF, 0xF8, 0xF3, 0x0C, 0xF1, + 0x80, 0x70, 0xC0, 0x38, 0x60, 0x1C, 0x30, 0x0F, 0x18, 0x03, 0xFC, 0x00, + 0xFF, 0xC0, 0x1F, 0xF8, 0x01, 0xFF, 0x00, 0xC7, 0x80, 0x61, 0xE0, 0x30, + 0x70, 0x18, 0x38, 0x0C, 0x1E, 0x06, 0x1F, 0xC3, 0x3E, 0xFF, 0xFE, 0x3F, + 0xFE, 0x03, 0xFC, 0x00, 0x30, 0x00, 0x18, 0x00, 0x0C, 0x00, 0x06, 0x00, + 0x03, 0x00, 0x1F, 0x80, 0x06, 0x01, 0xFE, 0x00, 0x70, 0x1E, 0x78, 0x03, + 0x00, 0xE1, 0xC0, 0x30, 0x0E, 0x07, 0x03, 0x80, 0x70, 0x38, 0x18, 0x03, + 0x81, 0xC1, 0xC0, 0x1C, 0x0E, 0x0C, 0x00, 0xE0, 0x70, 0xC0, 0x07, 0x03, + 0x8E, 0x00, 0x1C, 0x38, 0x60, 0x00, 0xF3, 0xC7, 0x00, 0x03, 0xFC, 0x30, + 0xFC, 0x0F, 0xC3, 0x0F, 0xF0, 0x00, 0x38, 0xF3, 0xC0, 0x01, 0x87, 0x0E, + 0x00, 0x1C, 0x70, 0x38, 0x00, 0xC3, 0x81, 0xC0, 0x0C, 0x1C, 0x0E, 0x00, + 0xE0, 0xE0, 0x70, 0x06, 0x07, 0x03, 0x80, 0x70, 0x38, 0x1C, 0x03, 0x00, + 0xE1, 0xC0, 0x30, 0x07, 0x9E, 0x03, 0x80, 0x1F, 0xE0, 0x18, 0x00, 0x7E, + 0x00, 0x01, 0xFC, 0x00, 0x07, 0xFF, 0x00, 0x0F, 0xFF, 0x00, 0x1F, 0x03, + 0x00, 0x1E, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x1C, 0x00, + 0x00, 0x0E, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x1F, 0xC0, + 0x00, 0x3D, 0xE0, 0x0E, 0x78, 0xF0, 0x0E, 0x70, 0x78, 0x1E, 0xF0, 0x3C, + 0x1C, 0xE0, 0x1F, 0x1C, 0xE0, 0x0F, 0xB8, 0xE0, 0x07, 0xF8, 0xE0, 0x03, + 0xF0, 0xF0, 0x01, 0xF0, 0x78, 0x03, 0xF0, 0x3E, 0x0F, 0xF8, 0x3F, 0xFF, + 0x7C, 0x0F, 0xFE, 0x3E, 0x03, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, + 0x0E, 0x1E, 0x1C, 0x18, 0x38, 0x38, 0x70, 0x70, 0x70, 0x70, 0xE0, 0xE0, + 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0x60, 0x70, 0x70, 0x70, 0x38, + 0x38, 0x1C, 0x1C, 0x1C, 0x0E, 0x07, 0xE0, 0x70, 0x70, 0x38, 0x38, 0x1C, + 0x1C, 0x0E, 0x0E, 0x0E, 0x0E, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, + 0x07, 0x07, 0x0E, 0x0E, 0x0E, 0x0E, 0x1C, 0x1C, 0x38, 0x38, 0x78, 0x70, + 0xE0, 0x01, 0x80, 0x01, 0x80, 0x01, 0x80, 0x41, 0x82, 0xF1, 0x8F, 0x39, + 0x9C, 0x1F, 0xF8, 0x07, 0xE0, 0x07, 0xE0, 0x1F, 0xF0, 0x39, 0x9C, 0xF1, + 0x8F, 0x41, 0x82, 0x01, 0x80, 0x01, 0x80, 0x01, 0x80, 0x00, 0x38, 0x00, + 0x00, 0x70, 0x00, 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, 0x03, 0x80, 0x00, + 0x07, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x38, 0x00, 0x00, + 0x70, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, + 0x00, 0x00, 0x0E, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x38, 0x00, 0x00, 0x70, + 0x00, 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, 0x03, 0x80, 0x00, 0x07, 0x00, + 0x00, 0x0E, 0x00, 0x00, 0x77, 0x77, 0x6E, 0xEC, 0xFF, 0xFF, 0xFF, 0xE0, + 0xFF, 0xF0, 0x00, 0x70, 0x0F, 0x00, 0xE0, 0x0E, 0x01, 0xE0, 0x1C, 0x01, + 0xC0, 0x1C, 0x03, 0x80, 0x38, 0x03, 0x80, 0x78, 0x07, 0x00, 0x70, 0x0F, + 0x00, 0xE0, 0x0E, 0x00, 0xE0, 0x1C, 0x01, 0xC0, 0x1C, 0x03, 0x80, 0x38, + 0x03, 0x80, 0x78, 0x07, 0x00, 0x70, 0x0F, 0x00, 0xE0, 0x00, 0x03, 0xF0, + 0x03, 0xFF, 0x01, 0xFF, 0xE0, 0xF8, 0x7C, 0x38, 0x07, 0x1E, 0x01, 0xE7, + 0x00, 0x39, 0xC0, 0x0E, 0xE0, 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, + 0x07, 0xE0, 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x01, + 0xF8, 0x00, 0x77, 0x00, 0x39, 0xC0, 0x0E, 0x78, 0x07, 0x8E, 0x01, 0xC3, + 0xE1, 0xF0, 0x7F, 0xF8, 0x0F, 0xFC, 0x00, 0xFC, 0x00, 0x1F, 0x81, 0xFF, + 0x03, 0xFE, 0x07, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, + 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, + 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, + 0x00, 0x70, 0x00, 0xE0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xE0, + 0xFF, 0xF8, 0xFF, 0xFC, 0xF0, 0x3E, 0x80, 0x0E, 0x00, 0x0F, 0x00, 0x07, + 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x0E, 0x00, 0x1E, 0x00, 0x1C, + 0x00, 0x38, 0x00, 0x78, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x07, 0x80, + 0x0F, 0x00, 0x1E, 0x00, 0x3C, 0x00, 0xF8, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x1F, 0xE0, 0x3F, 0xFC, 0x1F, 0xFF, 0x0C, 0x07, 0xC0, 0x00, + 0xF0, 0x00, 0x38, 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x07, 0x00, + 0x07, 0x81, 0xFF, 0x80, 0xFF, 0x00, 0x7F, 0xE0, 0x00, 0x7C, 0x00, 0x0E, + 0x00, 0x07, 0x80, 0x01, 0xC0, 0x00, 0xE0, 0x00, 0x70, 0x00, 0x78, 0x00, + 0x3B, 0x00, 0x7D, 0xFF, 0xFC, 0xFF, 0xFC, 0x1F, 0xF0, 0x00, 0x00, 0x3E, + 0x00, 0x07, 0xC0, 0x01, 0xF8, 0x00, 0x77, 0x00, 0x0E, 0xE0, 0x03, 0x9C, + 0x00, 0xE3, 0x80, 0x1C, 0x70, 0x07, 0x0E, 0x00, 0xE1, 0xC0, 0x38, 0x38, + 0x0E, 0x07, 0x01, 0xC0, 0xE0, 0x70, 0x1C, 0x1C, 0x03, 0x83, 0x80, 0x70, + 0xE0, 0x0E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0xE0, + 0x00, 0x1C, 0x00, 0x03, 0x80, 0x00, 0x70, 0x00, 0x0E, 0x00, 0x01, 0xC0, + 0x7F, 0xFE, 0x3F, 0xFF, 0x1F, 0xFF, 0x8E, 0x00, 0x07, 0x00, 0x03, 0x80, + 0x01, 0xC0, 0x00, 0xE0, 0x00, 0x70, 0x00, 0x3F, 0xF0, 0x1F, 0xFE, 0x0F, + 0xFF, 0xC6, 0x03, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, 0x07, 0x00, 0x03, + 0x80, 0x01, 0xC0, 0x00, 0xE0, 0x00, 0x70, 0x00, 0x78, 0x00, 0x7B, 0x00, + 0xFD, 0xFF, 0xFC, 0xFF, 0xF8, 0x1F, 0xF0, 0x00, 0x01, 0xFC, 0x01, 0xFF, + 0xC0, 0xFF, 0xF0, 0x7C, 0x0C, 0x3C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x01, + 0xC0, 0x00, 0xF0, 0x00, 0x38, 0xFE, 0x0E, 0x7F, 0xE3, 0xBF, 0xFC, 0xFE, + 0x0F, 0xBE, 0x00, 0xEF, 0x80, 0x3F, 0xC0, 0x07, 0xF0, 0x01, 0xFC, 0x00, + 0x77, 0x00, 0x1D, 0xC0, 0x07, 0x78, 0x03, 0xCE, 0x00, 0xE3, 0xE0, 0xF8, + 0x7F, 0xFC, 0x0F, 0xFE, 0x00, 0xFE, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0x00, 0x0E, 0x00, 0x0E, 0x00, 0x1E, 0x00, 0x1C, 0x00, 0x1C, 0x00, + 0x38, 0x00, 0x38, 0x00, 0x38, 0x00, 0x70, 0x00, 0x70, 0x00, 0xF0, 0x00, + 0xE0, 0x00, 0xE0, 0x01, 0xE0, 0x01, 0xC0, 0x01, 0xC0, 0x03, 0xC0, 0x03, + 0x80, 0x03, 0x80, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x0E, 0x00, 0x03, + 0xF8, 0x03, 0xFF, 0x03, 0xFF, 0xF0, 0xF0, 0x3C, 0x78, 0x07, 0x9C, 0x00, + 0xE7, 0x00, 0x39, 0xC0, 0x0E, 0x70, 0x03, 0x8E, 0x01, 0xC3, 0xC0, 0xF0, + 0x7F, 0xF8, 0x07, 0xF8, 0x07, 0xFF, 0x87, 0xC0, 0xF9, 0xC0, 0x0E, 0xE0, + 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x01, 0xFC, 0x00, + 0xF7, 0xC0, 0xF8, 0xFF, 0xFC, 0x1F, 0xFE, 0x01, 0xFE, 0x00, 0x07, 0xF0, + 0x07, 0xFF, 0x03, 0xFF, 0xE1, 0xF0, 0x7C, 0x70, 0x07, 0x3C, 0x01, 0xEE, + 0x00, 0x3B, 0x80, 0x0E, 0xE0, 0x03, 0xF8, 0x00, 0xFE, 0x00, 0x3F, 0xC0, + 0x1F, 0x70, 0x07, 0xDF, 0x07, 0xF3, 0xFF, 0xDC, 0x7F, 0xE7, 0x07, 0xF1, + 0xC0, 0x00, 0xF0, 0x00, 0x38, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x03, 0xC3, + 0x03, 0xE0, 0xFF, 0xF0, 0x3F, 0xF8, 0x03, 0xF8, 0x00, 0xFF, 0xF0, 0x00, + 0x00, 0x00, 0x3F, 0xFC, 0x77, 0x77, 0x00, 0x00, 0x00, 0x00, 0x00, 0x77, + 0x77, 0x6E, 0xEC, 0x00, 0x00, 0x04, 0x00, 0x00, 0xF0, 0x00, 0x1F, 0xC0, + 0x03, 0xFE, 0x00, 0x3F, 0xC0, 0x07, 0xFC, 0x00, 0xFF, 0x80, 0x0F, 0xF0, + 0x00, 0xFF, 0x00, 0x03, 0xE0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF8, 0x00, 0x07, 0xFC, 0x00, 0x03, 0xFC, 0x00, 0x03, 0xFE, 0x00, + 0x01, 0xFC, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x40, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, + 0x80, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0xE0, 0x00, 0x1F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x07, 0xFC, 0x00, 0x03, 0xFC, 0x00, 0x03, + 0xFC, 0x00, 0x01, 0xF0, 0x00, 0x3F, 0xC0, 0x03, 0xFC, 0x00, 0x7F, 0xC0, + 0x0F, 0xF8, 0x00, 0xFF, 0x00, 0x1F, 0xF0, 0x00, 0xFE, 0x00, 0x03, 0xC0, + 0x00, 0x08, 0x00, 0x00, 0x00, 0x1F, 0xC1, 0xFF, 0xCF, 0xFF, 0xBC, 0x1E, + 0x80, 0x3C, 0x00, 0x70, 0x01, 0xC0, 0x07, 0x00, 0x78, 0x03, 0xE0, 0x1F, + 0x00, 0xF8, 0x07, 0xC0, 0x3C, 0x00, 0xE0, 0x03, 0x80, 0x0E, 0x00, 0x38, + 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x03, 0x80, 0x0E, + 0x00, 0x38, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0xFF, 0xFC, 0x00, 0x07, + 0xFF, 0xFE, 0x00, 0x1F, 0x80, 0x7E, 0x00, 0x7C, 0x00, 0x3E, 0x01, 0xE0, + 0x00, 0x1E, 0x07, 0x80, 0x00, 0x1E, 0x1E, 0x00, 0x00, 0x1E, 0x38, 0x0F, + 0x8E, 0x1C, 0xF0, 0x7F, 0xDC, 0x39, 0xC1, 0xFF, 0xF8, 0x3F, 0x83, 0xC1, + 0xF0, 0x7E, 0x0F, 0x01, 0xE0, 0xFC, 0x1C, 0x01, 0xC1, 0xF8, 0x38, 0x03, + 0x83, 0xF0, 0x70, 0x07, 0x07, 0xE0, 0xE0, 0x0E, 0x1F, 0xC1, 0xC0, 0x1C, + 0x3B, 0x83, 0xC0, 0x78, 0xF7, 0x83, 0xC1, 0xF3, 0xC7, 0x07, 0xFF, 0xFF, + 0x0E, 0x07, 0xFD, 0xF8, 0x0E, 0x03, 0xE3, 0xC0, 0x1E, 0x00, 0x00, 0x00, + 0x1E, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x02, 0x00, 0x3F, 0x00, 0x0E, 0x00, + 0x1F, 0x80, 0xFC, 0x00, 0x1F, 0xFF, 0xF0, 0x00, 0x0F, 0xFF, 0x80, 0x00, + 0x07, 0xF8, 0x00, 0x00, 0x00, 0x7C, 0x00, 0x00, 0xF8, 0x00, 0x01, 0xF0, + 0x00, 0x07, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x1D, 0xC0, 0x00, 0x71, 0xC0, + 0x00, 0xE3, 0x80, 0x03, 0xC7, 0x80, 0x07, 0x07, 0x00, 0x0E, 0x0E, 0x00, + 0x3C, 0x1E, 0x00, 0x70, 0x1C, 0x00, 0xE0, 0x38, 0x03, 0x80, 0x38, 0x07, + 0x00, 0x70, 0x1E, 0x00, 0xF0, 0x3F, 0xFF, 0xE0, 0x7F, 0xFF, 0xC1, 0xFF, + 0xFF, 0xC3, 0x80, 0x03, 0x87, 0x00, 0x07, 0x1C, 0x00, 0x07, 0x38, 0x00, + 0x0E, 0x70, 0x00, 0x1D, 0xC0, 0x00, 0x1C, 0xFF, 0xF8, 0x3F, 0xFF, 0x8F, + 0xFF, 0xF3, 0x80, 0x3C, 0xE0, 0x07, 0xB8, 0x00, 0xEE, 0x00, 0x3B, 0x80, + 0x0E, 0xE0, 0x03, 0xB8, 0x01, 0xEE, 0x00, 0xF3, 0xFF, 0xF8, 0xFF, 0xFC, + 0x3F, 0xFF, 0xCE, 0x00, 0xFB, 0x80, 0x0E, 0xE0, 0x01, 0xF8, 0x00, 0x7E, + 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x01, 0xF8, 0x00, 0xFE, 0x00, 0xFB, 0xFF, + 0xFC, 0xFF, 0xFE, 0x3F, 0xFE, 0x00, 0x00, 0x7F, 0x80, 0x1F, 0xFF, 0x83, + 0xFF, 0xFE, 0x3F, 0x01, 0xF3, 0xE0, 0x01, 0x9E, 0x00, 0x05, 0xE0, 0x00, + 0x0E, 0x00, 0x00, 0x70, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, + 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, + 0xE0, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x9F, 0x00, 0x0C, 0x7E, 0x03, 0xE1, 0xFF, 0xFF, 0x03, 0xFF, 0xF0, + 0x07, 0xFC, 0x00, 0xFF, 0xF0, 0x07, 0xFF, 0xF0, 0x3F, 0xFF, 0xE1, 0xC0, + 0x1F, 0x8E, 0x00, 0x3E, 0x70, 0x00, 0x73, 0x80, 0x03, 0xDC, 0x00, 0x0E, + 0xE0, 0x00, 0x7F, 0x00, 0x01, 0xF8, 0x00, 0x0F, 0xC0, 0x00, 0x7E, 0x00, + 0x03, 0xF0, 0x00, 0x1F, 0x80, 0x00, 0xFC, 0x00, 0x07, 0xE0, 0x00, 0x3F, + 0x00, 0x03, 0xF8, 0x00, 0x1D, 0xC0, 0x01, 0xEE, 0x00, 0x0E, 0x70, 0x01, + 0xF3, 0x80, 0x3F, 0x1F, 0xFF, 0xF0, 0xFF, 0xFE, 0x07, 0xFF, 0x80, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, + 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xFF, 0xFE, + 0xFF, 0xFE, 0xFF, 0xFE, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, + 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x0E, + 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, + 0x07, 0xFF, 0xEF, 0xFF, 0xDF, 0xFF, 0xB8, 0x00, 0x70, 0x00, 0xE0, 0x01, + 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, + 0x00, 0xE0, 0x01, 0xC0, 0x00, 0x00, 0xFF, 0x80, 0x0F, 0xFF, 0xC0, 0xFF, + 0xFF, 0x87, 0xE0, 0x3E, 0x3E, 0x00, 0x18, 0xE0, 0x00, 0x27, 0x80, 0x00, + 0x1C, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x0E, 0x00, 0x00, 0x38, + 0x00, 0x00, 0xE0, 0x07, 0xFF, 0x80, 0x1F, 0xFE, 0x00, 0x7F, 0xF8, 0x00, + 0x07, 0xE0, 0x00, 0x1D, 0xC0, 0x00, 0x77, 0x00, 0x01, 0xDE, 0x00, 0x07, + 0x38, 0x00, 0x1C, 0xF8, 0x00, 0x71, 0xF8, 0x07, 0xC3, 0xFF, 0xFE, 0x03, + 0xFF, 0xE0, 0x03, 0xFE, 0x00, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, + 0xF0, 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, 0x00, 0x07, + 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1F, + 0x80, 0x03, 0xF0, 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, + 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1C, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, + 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, + 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x0E, 0x1E, 0xFE, + 0xFC, 0xF0, 0xE0, 0x03, 0xCE, 0x00, 0x78, 0xE0, 0x0F, 0x0E, 0x01, 0xE0, + 0xE0, 0x3C, 0x0E, 0x07, 0x80, 0xE0, 0xF0, 0x0E, 0x1E, 0x00, 0xE3, 0xC0, + 0x0E, 0x78, 0x00, 0xEF, 0x00, 0x0F, 0xE0, 0x00, 0xFE, 0x00, 0x0F, 0xF0, + 0x00, 0xEF, 0x80, 0x0E, 0x7C, 0x00, 0xE3, 0xE0, 0x0E, 0x1F, 0x00, 0xE0, + 0xF8, 0x0E, 0x07, 0xC0, 0xE0, 0x3E, 0x0E, 0x01, 0xF0, 0xE0, 0x0F, 0x8E, + 0x00, 0x7C, 0xE0, 0x03, 0xEE, 0x00, 0x1F, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, + 0x00, 0x3F, 0xF8, 0x00, 0xFF, 0xF0, 0x01, 0xFF, 0xE0, 0x03, 0xFF, 0xE0, + 0x0F, 0xFD, 0xC0, 0x1D, 0xFB, 0x80, 0x3B, 0xF3, 0x80, 0xE7, 0xE7, 0x01, + 0xCF, 0xCF, 0x07, 0x9F, 0x8E, 0x0E, 0x3F, 0x1C, 0x1C, 0x7E, 0x1C, 0x70, + 0xFC, 0x38, 0xE1, 0xF8, 0x71, 0xC3, 0xF0, 0x77, 0x07, 0xE0, 0xEE, 0x0F, + 0xC1, 0xFC, 0x1F, 0x81, 0xF0, 0x3F, 0x03, 0xE0, 0x7E, 0x03, 0x80, 0xFC, + 0x00, 0x01, 0xF8, 0x00, 0x03, 0xF0, 0x00, 0x07, 0xE0, 0x00, 0x0F, 0xC0, + 0x00, 0x1C, 0xF8, 0x00, 0xFF, 0x00, 0x1F, 0xF0, 0x03, 0xFE, 0x00, 0x7F, + 0xE0, 0x0F, 0xDC, 0x01, 0xFB, 0xC0, 0x3F, 0x38, 0x07, 0xE7, 0x80, 0xFC, + 0x70, 0x1F, 0x8F, 0x03, 0xF0, 0xE0, 0x7E, 0x0E, 0x0F, 0xC1, 0xC1, 0xF8, + 0x1C, 0x3F, 0x03, 0xC7, 0xE0, 0x38, 0xFC, 0x07, 0x9F, 0x80, 0x73, 0xF0, + 0x0F, 0x7E, 0x00, 0xEF, 0xC0, 0x1F, 0xF8, 0x01, 0xFF, 0x00, 0x3F, 0xE0, + 0x03, 0xFC, 0x00, 0x7C, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, + 0xF0, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x3C, 0x00, 0x3C, 0x78, 0x00, + 0x1E, 0x70, 0x00, 0x0E, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0x70, 0x00, + 0x0E, 0x78, 0x00, 0x1E, 0x3C, 0x00, 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x81, + 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0x00, 0xFF, 0xE0, + 0xFF, 0xF8, 0xFF, 0xFC, 0xE0, 0x3E, 0xE0, 0x0F, 0xE0, 0x07, 0xE0, 0x07, + 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x3E, 0xFF, 0xFC, + 0xFF, 0xF8, 0xFF, 0xE0, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, + 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, + 0xE0, 0x00, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, + 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x3C, 0x00, 0x3C, 0x78, 0x00, 0x1E, 0x70, + 0x00, 0x0E, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, + 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, + 0x00, 0x07, 0xE0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0x70, 0x00, 0x0E, 0x78, + 0x00, 0x1E, 0x3C, 0x00, 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x81, 0xF8, 0x0F, + 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0xC0, 0x00, 0x01, 0xE0, 0x00, + 0x01, 0xE0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x3C, 0xFF, + 0xE0, 0x1F, 0xFF, 0x03, 0xFF, 0xF8, 0x70, 0x1F, 0x0E, 0x00, 0xF1, 0xC0, + 0x0E, 0x38, 0x01, 0xC7, 0x00, 0x38, 0xE0, 0x07, 0x1C, 0x00, 0xE3, 0x80, + 0x3C, 0x70, 0x0F, 0x0F, 0xFF, 0xC1, 0xFF, 0xF0, 0x3F, 0xFE, 0x07, 0x01, + 0xE0, 0xE0, 0x1E, 0x1C, 0x01, 0xC3, 0x80, 0x3C, 0x70, 0x03, 0x8E, 0x00, + 0x79, 0xC0, 0x07, 0x38, 0x00, 0xE7, 0x00, 0x0E, 0xE0, 0x01, 0xDC, 0x00, + 0x1C, 0x07, 0xFC, 0x07, 0xFF, 0xC3, 0xFF, 0xF1, 0xF0, 0x0C, 0xF0, 0x00, + 0x38, 0x00, 0x0E, 0x00, 0x03, 0x80, 0x00, 0xE0, 0x00, 0x3C, 0x00, 0x07, + 0xC0, 0x00, 0xFF, 0x80, 0x1F, 0xFC, 0x00, 0xFF, 0xC0, 0x01, 0xF8, 0x00, + 0x1E, 0x00, 0x03, 0xC0, 0x00, 0x70, 0x00, 0x1C, 0x00, 0x07, 0x00, 0x01, + 0xE0, 0x00, 0xEF, 0x00, 0xFB, 0xFF, 0xFC, 0xFF, 0xFE, 0x0F, 0xFE, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0xE0, 0x00, 0x07, + 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, + 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, + 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, + 0x1C, 0x00, 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, + 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0xE0, 0x00, 0xFC, + 0x00, 0x1F, 0x80, 0x03, 0xF0, 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, + 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xF0, + 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, 0x00, 0x07, 0xE0, + 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xF8, 0x00, 0xF7, 0x00, 0x1C, 0xF0, + 0x07, 0x8F, 0x01, 0xE1, 0xFF, 0xFC, 0x0F, 0xFE, 0x00, 0x7F, 0x00, 0xE0, + 0x00, 0x0E, 0xE0, 0x00, 0x39, 0xC0, 0x00, 0x73, 0x80, 0x01, 0xE3, 0x80, + 0x03, 0x87, 0x00, 0x07, 0x0F, 0x00, 0x1E, 0x0E, 0x00, 0x38, 0x1C, 0x00, + 0x70, 0x3C, 0x01, 0xE0, 0x38, 0x03, 0x80, 0x70, 0x07, 0x00, 0x70, 0x1C, + 0x00, 0xE0, 0x38, 0x01, 0xE0, 0xF0, 0x01, 0xC1, 0xC0, 0x03, 0x83, 0x80, + 0x07, 0x8F, 0x00, 0x07, 0x1C, 0x00, 0x0E, 0x38, 0x00, 0x0E, 0xE0, 0x00, + 0x1D, 0xC0, 0x00, 0x3F, 0x80, 0x00, 0x3E, 0x00, 0x00, 0x7C, 0x00, 0x00, + 0xF8, 0x00, 0xE0, 0x03, 0xC0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0x70, 0x07, + 0xE0, 0x0E, 0x70, 0x07, 0xE0, 0x0E, 0x70, 0x07, 0xE0, 0x0E, 0x70, 0x0F, + 0xF0, 0x0E, 0x38, 0x0E, 0x70, 0x1C, 0x38, 0x0E, 0x70, 0x1C, 0x38, 0x0E, + 0x70, 0x1C, 0x38, 0x1E, 0x78, 0x1C, 0x1C, 0x1C, 0x38, 0x38, 0x1C, 0x1C, + 0x38, 0x38, 0x1C, 0x1C, 0x38, 0x38, 0x1C, 0x1C, 0x38, 0x38, 0x0E, 0x38, + 0x1C, 0x70, 0x0E, 0x38, 0x1C, 0x70, 0x0E, 0x38, 0x1C, 0x70, 0x0E, 0x38, + 0x1C, 0x70, 0x0F, 0x70, 0x0E, 0xE0, 0x07, 0x70, 0x0E, 0xE0, 0x07, 0x70, + 0x0E, 0xE0, 0x07, 0x70, 0x0E, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x03, 0xE0, + 0x07, 0xC0, 0x03, 0xE0, 0x07, 0xC0, 0x03, 0xE0, 0x07, 0xC0, 0x78, 0x00, + 0x78, 0xF0, 0x03, 0xC1, 0xC0, 0x0E, 0x07, 0x80, 0x78, 0x0F, 0x03, 0xC0, + 0x1C, 0x0E, 0x00, 0x78, 0x78, 0x00, 0xF3, 0xC0, 0x01, 0xCE, 0x00, 0x07, + 0xF8, 0x00, 0x0F, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x03, 0xF0, + 0x00, 0x0F, 0xC0, 0x00, 0x7F, 0x80, 0x03, 0xCF, 0x00, 0x0E, 0x1C, 0x00, + 0x78, 0x78, 0x03, 0xC0, 0xF0, 0x0E, 0x01, 0xC0, 0x78, 0x07, 0x83, 0xC0, + 0x0F, 0x0E, 0x00, 0x1C, 0x78, 0x00, 0x7B, 0xC0, 0x00, 0xF0, 0xF0, 0x00, + 0x7B, 0xC0, 0x07, 0x8E, 0x00, 0x38, 0x78, 0x03, 0xC1, 0xE0, 0x3C, 0x07, + 0x01, 0xC0, 0x3C, 0x1E, 0x00, 0xF1, 0xE0, 0x03, 0x8E, 0x00, 0x1E, 0xF0, + 0x00, 0x7F, 0x00, 0x01, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x38, 0x00, 0x01, + 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, + 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, + 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, + 0xBF, 0xFF, 0xFC, 0x00, 0x01, 0xC0, 0x00, 0x1E, 0x00, 0x01, 0xE0, 0x00, + 0x1E, 0x00, 0x01, 0xE0, 0x00, 0x0E, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x07, 0x80, 0x00, 0x78, + 0x00, 0x07, 0x80, 0x00, 0x38, 0x00, 0x03, 0xC0, 0x00, 0x3C, 0x00, 0x03, + 0xC0, 0x00, 0x3C, 0x00, 0x01, 0xC0, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0x0E, 0x1C, 0x38, 0x70, 0xE1, + 0xC3, 0x87, 0x0E, 0x1C, 0x38, 0x70, 0xE1, 0xC3, 0x87, 0x0E, 0x1C, 0x38, + 0x70, 0xE1, 0xC3, 0x87, 0x0F, 0xFF, 0xFF, 0x80, 0xE0, 0x0F, 0x00, 0x70, + 0x07, 0x00, 0x78, 0x03, 0x80, 0x38, 0x03, 0x80, 0x1C, 0x01, 0xC0, 0x1C, + 0x00, 0xE0, 0x0E, 0x00, 0xE0, 0x0F, 0x00, 0x70, 0x07, 0x00, 0x70, 0x03, + 0x80, 0x38, 0x03, 0x80, 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xE0, 0x0E, 0x00, + 0xE0, 0x0F, 0x00, 0x70, 0xFF, 0xFF, 0xF8, 0x70, 0xE1, 0xC3, 0x87, 0x0E, + 0x1C, 0x38, 0x70, 0xE1, 0xC3, 0x87, 0x0E, 0x1C, 0x38, 0x70, 0xE1, 0xC3, + 0x87, 0x0E, 0x1C, 0x38, 0x7F, 0xFF, 0xFF, 0x80, 0x00, 0x78, 0x00, 0x03, + 0xF0, 0x00, 0x1F, 0xE0, 0x00, 0xF3, 0xC0, 0x07, 0x87, 0x80, 0x3C, 0x0F, + 0x01, 0xE0, 0x1E, 0x0F, 0x00, 0x3C, 0x78, 0x00, 0x7B, 0xC0, 0x00, 0xF0, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xF0, 0x70, 0x38, 0x1C, 0x0E, + 0x07, 0x0F, 0xE0, 0x3F, 0xF8, 0x7F, 0xFC, 0x70, 0x1E, 0x40, 0x0E, 0x00, + 0x07, 0x00, 0x07, 0x0F, 0xFF, 0x3F, 0xFF, 0x7F, 0xFF, 0x78, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x1F, 0xF8, 0x3F, 0x7F, 0xFF, 0x3F, + 0xF7, 0x0F, 0xC7, 0xE0, 0x00, 0x70, 0x00, 0x38, 0x00, 0x1C, 0x00, 0x0E, + 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE3, 0xF0, 0x77, 0xFE, + 0x3F, 0xFF, 0x9F, 0x83, 0xCF, 0x80, 0xF7, 0x80, 0x3B, 0x80, 0x0F, 0xC0, + 0x07, 0xE0, 0x03, 0xF0, 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0x3F, + 0x80, 0x3B, 0xE0, 0x3D, 0xF8, 0x3C, 0xFF, 0xFE, 0x77, 0xFE, 0x38, 0xFC, + 0x00, 0x03, 0xF8, 0x1F, 0xFC, 0x7F, 0xF9, 0xF0, 0x37, 0x80, 0x0E, 0x00, + 0x3C, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0F, + 0x00, 0x0E, 0x00, 0x1E, 0x00, 0x1F, 0x03, 0x1F, 0xFE, 0x1F, 0xFC, 0x0F, + 0xE0, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE0, 0x00, 0x70, 0x00, 0x38, + 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x07, 0xE3, 0x8F, 0xFD, 0xCF, 0xFF, + 0xE7, 0x83, 0xF7, 0x80, 0xFB, 0x80, 0x3F, 0x80, 0x0F, 0xC0, 0x07, 0xE0, + 0x03, 0xF0, 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0x3B, 0x80, 0x3D, + 0xE0, 0x3E, 0x78, 0x3F, 0x3F, 0xFF, 0x8F, 0xFD, 0xC1, 0xF8, 0xE0, 0x03, + 0xF8, 0x03, 0xFF, 0x81, 0xFF, 0xF0, 0xF8, 0x3E, 0x78, 0x03, 0x9C, 0x00, + 0xEE, 0x00, 0x1F, 0x80, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0x80, 0x00, 0xE0, 0x00, 0x1C, 0x00, 0x07, 0x80, 0x08, 0xF8, 0x0E, 0x1F, + 0xFF, 0x83, 0xFF, 0xC0, 0x3F, 0xC0, 0x03, 0xF0, 0x7F, 0x0F, 0xF1, 0xE0, + 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, 0xFF, 0xEF, 0xFE, 0xFF, 0xE1, 0xC0, + 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, + 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, 0x1C, 0x00, 0x07, + 0xE3, 0x8F, 0xFD, 0xCF, 0xFF, 0xEF, 0x83, 0xF7, 0x80, 0xFB, 0x80, 0x3F, + 0x80, 0x0F, 0xC0, 0x07, 0xE0, 0x03, 0xF0, 0x01, 0xF8, 0x00, 0xFC, 0x00, + 0x7E, 0x00, 0x3B, 0x80, 0x3D, 0xE0, 0x3E, 0xF8, 0x3F, 0x3F, 0xFF, 0x8F, + 0xFD, 0xC1, 0xF8, 0xE0, 0x00, 0x70, 0x00, 0x70, 0x00, 0x78, 0xC0, 0x7C, + 0x7F, 0xFC, 0x3F, 0xFC, 0x07, 0xF8, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE3, + 0xF0, 0xEF, 0xFC, 0xFF, 0xFE, 0xFC, 0x1E, 0xF0, 0x0F, 0xF0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xFF, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, + 0x0E, 0x1C, 0x38, 0x70, 0x00, 0x00, 0x00, 0x0E, 0x1C, 0x38, 0x70, 0xE1, + 0xC3, 0x87, 0x0E, 0x1C, 0x38, 0x70, 0xE1, 0xC3, 0x87, 0x0E, 0x1C, 0x38, + 0x70, 0xE1, 0xC7, 0xFE, 0xF9, 0xE0, 0xE0, 0x00, 0x70, 0x00, 0x38, 0x00, + 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE0, + 0x0F, 0x70, 0x0F, 0x38, 0x1F, 0x1C, 0x1F, 0x0E, 0x1F, 0x07, 0x1E, 0x03, + 0x9E, 0x01, 0xFE, 0x00, 0xFE, 0x00, 0x7F, 0x00, 0x3B, 0xC0, 0x1C, 0xF0, + 0x0E, 0x3C, 0x07, 0x0F, 0x03, 0x83, 0xC1, 0xC0, 0xF0, 0xE0, 0x3C, 0x70, + 0x0F, 0x38, 0x03, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x80, 0xE3, 0xF0, 0x1F, 0x87, 0x7F, 0xE3, 0xFF, 0x3F, 0xFF, + 0xBF, 0xFD, 0xF8, 0x3D, 0xC1, 0xEF, 0x00, 0xF8, 0x07, 0xF8, 0x03, 0xC0, + 0x1F, 0x80, 0x1C, 0x00, 0xFC, 0x00, 0xE0, 0x07, 0xE0, 0x07, 0x00, 0x3F, + 0x00, 0x38, 0x01, 0xF8, 0x01, 0xC0, 0x0F, 0xC0, 0x0E, 0x00, 0x7E, 0x00, + 0x70, 0x03, 0xF0, 0x03, 0x80, 0x1F, 0x80, 0x1C, 0x00, 0xFC, 0x00, 0xE0, + 0x07, 0xE0, 0x07, 0x00, 0x3F, 0x00, 0x38, 0x01, 0xF8, 0x01, 0xC0, 0x0E, + 0xE3, 0xF0, 0xEF, 0xFC, 0xFF, 0xFE, 0xFC, 0x1E, 0xF0, 0x0F, 0xF0, 0x07, + 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, + 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, + 0xE0, 0x07, 0x03, 0xF0, 0x03, 0xFF, 0x03, 0xFF, 0xF0, 0xF0, 0x3C, 0x78, + 0x07, 0x9C, 0x00, 0xEE, 0x00, 0x3F, 0x80, 0x07, 0xE0, 0x01, 0xF8, 0x00, + 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xF0, 0x03, 0xDC, 0x00, 0xE7, 0x80, 0x78, + 0xF0, 0x3C, 0x3F, 0xFF, 0x03, 0xFF, 0x00, 0x3F, 0x00, 0xE3, 0xF0, 0x77, + 0xFE, 0x3F, 0xFF, 0x9F, 0x83, 0xCF, 0x80, 0xF7, 0x80, 0x3B, 0x80, 0x0F, + 0xC0, 0x07, 0xE0, 0x03, 0xF0, 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, + 0x3F, 0x80, 0x3B, 0xE0, 0x3D, 0xF8, 0x3C, 0xFF, 0xFE, 0x77, 0xFE, 0x38, + 0xFC, 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, + 0xE0, 0x00, 0x70, 0x00, 0x00, 0x07, 0xE3, 0x8F, 0xFD, 0xCF, 0xFF, 0xE7, + 0x83, 0xF7, 0x80, 0xFB, 0x80, 0x3F, 0x80, 0x0F, 0xC0, 0x07, 0xE0, 0x03, + 0xF0, 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0x3B, 0x80, 0x3D, 0xE0, + 0x3E, 0x78, 0x3F, 0x3F, 0xFF, 0x8F, 0xFD, 0xC1, 0xF8, 0xE0, 0x00, 0x70, + 0x00, 0x38, 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, + 0xC0, 0xE3, 0xFD, 0xFF, 0xFF, 0xFE, 0x0F, 0x01, 0xE0, 0x38, 0x07, 0x00, + 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, 0xC0, 0x38, 0x07, 0x00, 0xE0, + 0x1C, 0x03, 0x80, 0x00, 0x0F, 0xF0, 0x7F, 0xF9, 0xFF, 0xF7, 0xC0, 0x6E, + 0x00, 0x1C, 0x00, 0x3C, 0x00, 0x3F, 0xC0, 0x3F, 0xF0, 0x1F, 0xF0, 0x03, + 0xF0, 0x00, 0xF0, 0x00, 0xE0, 0x01, 0xE0, 0x03, 0xF8, 0x1F, 0xFF, 0xFC, + 0xFF, 0xF0, 0x3F, 0x80, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x81, 0xFF, + 0xFF, 0xFF, 0xFF, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, + 0x01, 0xC0, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0xC0, 0x3F, 0xC7, 0xF8, + 0x3F, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xF0, 0x0F, 0x78, 0x3F, 0x7F, 0xFF, 0x3F, + 0xF7, 0x0F, 0xC7, 0xE0, 0x00, 0xEE, 0x00, 0x39, 0xC0, 0x07, 0x3C, 0x01, + 0xE3, 0x80, 0x38, 0x70, 0x07, 0x0F, 0x01, 0xE0, 0xE0, 0x38, 0x1C, 0x0F, + 0x01, 0xC1, 0xC0, 0x38, 0x38, 0x07, 0x8F, 0x00, 0x71, 0xC0, 0x0E, 0x38, + 0x00, 0xEE, 0x00, 0x1D, 0xC0, 0x03, 0xF8, 0x00, 0x3E, 0x00, 0x07, 0xC0, + 0x00, 0xE0, 0x1E, 0x01, 0xFC, 0x0F, 0xC0, 0xF7, 0x03, 0xF0, 0x39, 0xC0, + 0xFC, 0x0E, 0x70, 0x3F, 0x03, 0x9E, 0x1C, 0xE1, 0xE3, 0x87, 0x38, 0x70, + 0xE1, 0xCE, 0x1C, 0x38, 0x73, 0x87, 0x07, 0x38, 0x73, 0x81, 0xCE, 0x1C, + 0xE0, 0x73, 0x87, 0x38, 0x1D, 0xE1, 0xEE, 0x03, 0xF0, 0x3F, 0x00, 0xFC, + 0x0F, 0xC0, 0x3F, 0x03, 0xF0, 0x0F, 0xC0, 0xFC, 0x01, 0xE0, 0x1E, 0x00, + 0x78, 0x07, 0x80, 0x78, 0x01, 0xE7, 0x80, 0x78, 0x78, 0x1E, 0x07, 0x03, + 0x80, 0xF0, 0xF0, 0x0F, 0x3C, 0x00, 0xFF, 0x00, 0x0F, 0xC0, 0x00, 0xF0, + 0x00, 0x1E, 0x00, 0x07, 0xE0, 0x01, 0xFE, 0x00, 0x79, 0xC0, 0x1E, 0x3C, + 0x03, 0x83, 0xC0, 0xF0, 0x38, 0x3C, 0x07, 0x8F, 0x00, 0x7B, 0xC0, 0x07, + 0x80, 0xE0, 0x00, 0xEE, 0x00, 0x39, 0xC0, 0x07, 0x1C, 0x01, 0xE3, 0x80, + 0x38, 0x78, 0x0F, 0x07, 0x01, 0xC0, 0xF0, 0x38, 0x0E, 0x0E, 0x01, 0xC1, + 0xC0, 0x1C, 0x78, 0x03, 0x8E, 0x00, 0x79, 0xC0, 0x07, 0x70, 0x00, 0xFE, + 0x00, 0x0F, 0xC0, 0x01, 0xF0, 0x00, 0x1E, 0x00, 0x03, 0x80, 0x00, 0x70, + 0x00, 0x1C, 0x00, 0x03, 0x80, 0x00, 0xF0, 0x01, 0xFC, 0x00, 0x3F, 0x00, + 0x07, 0xC0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x01, 0xE0, 0x03, + 0xC0, 0x0F, 0x00, 0x3C, 0x00, 0xF0, 0x03, 0xC0, 0x07, 0x80, 0x1E, 0x00, + 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x3C, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x00, 0xF8, 0x1F, 0xC0, 0xFE, 0x0F, 0x00, 0x70, 0x03, + 0x80, 0x1C, 0x00, 0xE0, 0x07, 0x00, 0x38, 0x01, 0xC0, 0x0E, 0x00, 0x70, + 0x07, 0x03, 0xF8, 0x1F, 0x00, 0xFE, 0x00, 0xF0, 0x03, 0xC0, 0x0E, 0x00, + 0x70, 0x03, 0x80, 0x1C, 0x00, 0xE0, 0x07, 0x00, 0x38, 0x01, 0xC0, 0x0E, + 0x00, 0x78, 0x01, 0xFC, 0x0F, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0xF8, 0x07, 0xF0, + 0x3F, 0x80, 0x1E, 0x00, 0x70, 0x03, 0x80, 0x1C, 0x00, 0xE0, 0x07, 0x00, + 0x38, 0x01, 0xC0, 0x0E, 0x00, 0x70, 0x03, 0xC0, 0x0F, 0xE0, 0x1F, 0x03, + 0xF8, 0x1E, 0x01, 0xE0, 0x0E, 0x00, 0x70, 0x03, 0x80, 0x1C, 0x00, 0xE0, + 0x07, 0x00, 0x38, 0x01, 0xC0, 0x0E, 0x00, 0xF0, 0x7F, 0x03, 0xF8, 0x1F, + 0x00, 0x0F, 0xC0, 0x05, 0xFF, 0xE0, 0x7F, 0xFF, 0xFF, 0xFC, 0x1F, 0xFE, + 0xC0, 0x0F, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x3F, 0x80, 0x0F, 0xFE, 0x01, + 0xFF, 0xF0, 0x3E, 0x0F, 0x07, 0x80, 0x30, 0xF0, 0x01, 0x0E, 0x00, 0x00, + 0xE0, 0x00, 0x1C, 0x00, 0x07, 0xFF, 0xF0, 0xFF, 0xFE, 0x01, 0xC0, 0x00, + 0x1C, 0x00, 0x01, 0xC0, 0x00, 0x1C, 0x00, 0x07, 0xFF, 0xC0, 0xFF, 0xF8, + 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0xE0, 0x00, 0x0F, 0x00, 0x10, 0x78, + 0x03, 0x03, 0xE0, 0xF0, 0x1F, 0xFF, 0x00, 0xFF, 0xE0, 0x03, 0xF8, 0x77, + 0x77, 0x6E, 0xEC, 0x00, 0x7E, 0x01, 0xFC, 0x07, 0xF8, 0x1E, 0x00, 0x38, + 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x1F, 0xFC, 0x3F, 0xF8, 0x7F, 0xF0, + 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, + 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, + 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x01, + 0xE0, 0x7F, 0x80, 0xFE, 0x01, 0xF8, 0x00, 0x71, 0xDC, 0x77, 0x1D, 0xC7, + 0x61, 0xB8, 0xEE, 0x3B, 0x0C, 0xE0, 0x0E, 0x00, 0xFC, 0x01, 0xC0, 0x1F, + 0x80, 0x38, 0x03, 0xF0, 0x07, 0x00, 0x70, 0x03, 0x80, 0x07, 0x00, 0x0E, + 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x7F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFC, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, + 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, + 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, + 0x38, 0x00, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, + 0x70, 0x00, 0xE0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x0E, 0x00, 0x1C, + 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, + 0x0E, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xE0, 0x01, 0xC0, 0x03, + 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x1F, 0x80, 0x06, + 0x00, 0x00, 0x07, 0xF8, 0x01, 0xC0, 0x00, 0x01, 0xE7, 0x80, 0x30, 0x00, + 0x00, 0x38, 0x70, 0x0C, 0x00, 0x00, 0x0E, 0x07, 0x03, 0x80, 0x00, 0x01, + 0xC0, 0xE0, 0x60, 0x00, 0x00, 0x38, 0x1C, 0x1C, 0x00, 0x00, 0x07, 0x03, + 0x83, 0x00, 0x00, 0x00, 0xE0, 0x70, 0xC0, 0x00, 0x00, 0x1C, 0x0E, 0x38, + 0x00, 0x00, 0x01, 0xC3, 0x86, 0x00, 0x00, 0x00, 0x3C, 0xF1, 0xC0, 0x00, + 0x00, 0x03, 0xFC, 0x30, 0xFC, 0x03, 0xF0, 0x3F, 0x0C, 0x3F, 0xC0, 0xFF, + 0x00, 0x03, 0x8F, 0x3C, 0x3C, 0xF0, 0x00, 0x61, 0xC3, 0x87, 0x0E, 0x00, + 0x1C, 0x70, 0x39, 0xC0, 0xE0, 0x03, 0x0E, 0x07, 0x38, 0x1C, 0x00, 0xC1, + 0xC0, 0xE7, 0x03, 0x80, 0x38, 0x38, 0x1C, 0xE0, 0x70, 0x06, 0x07, 0x03, + 0x9C, 0x0E, 0x01, 0xC0, 0xE0, 0x73, 0x81, 0xC0, 0x30, 0x0E, 0x1C, 0x38, + 0x70, 0x0C, 0x01, 0xE7, 0x87, 0x9E, 0x03, 0x80, 0x1F, 0xE0, 0x7F, 0x80, + 0x60, 0x01, 0xF8, 0x07, 0xE0, 0x01, 0x03, 0x07, 0x0E, 0x1C, 0x38, 0x70, + 0xE0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x01, 0x37, 0x76, 0xEE, + 0xEE, 0x77, 0x77, 0x6E, 0xEC, 0x30, 0xDC, 0x77, 0x1D, 0x86, 0xE3, 0xB8, + 0xEE, 0x3B, 0x8E, 0x71, 0xDC, 0x77, 0x1D, 0xC7, 0x61, 0xB8, 0xEE, 0x3B, + 0x0C, 0x1E, 0x1F, 0xE7, 0xFB, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD, 0xFE, 0x7F, + 0x87, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x38, 0x1F, 0xFC, + 0xF0, 0xF1, 0x83, 0xE7, 0xC6, 0x0D, 0x9B, 0x18, 0x33, 0xCC, 0x60, 0xCF, + 0x31, 0x83, 0x18, 0xC6, 0x0C, 0x03, 0x18, 0x30, 0x0C, 0x60, 0xC0, 0x30, + 0x80, 0xC0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x07, 0x0E, 0x1C, 0x38, + 0x70, 0xE0, 0xC0, 0x80, 0x00, 0x01, 0xE0, 0x78, 0x0E, 0x03, 0x80, 0xE0, + 0x38, 0x00, 0x07, 0x8F, 0xF1, 0xFE, 0x3F, 0xC7, 0x80, 0x03, 0xC0, 0x00, + 0x0F, 0x00, 0x00, 0x1D, 0xF0, 0x00, 0x73, 0xE0, 0x01, 0xC7, 0xC0, 0x07, + 0x1D, 0xC0, 0x00, 0x3B, 0x80, 0x00, 0x77, 0x00, 0x01, 0xC7, 0x00, 0x03, + 0x8E, 0x00, 0x0F, 0x1E, 0x00, 0x1C, 0x1C, 0x00, 0x38, 0x38, 0x00, 0xF0, + 0x78, 0x01, 0xC0, 0x70, 0x03, 0x80, 0xE0, 0x0E, 0x00, 0xE0, 0x1C, 0x01, + 0xC0, 0x78, 0x03, 0xC0, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x07, 0xFF, 0xFF, + 0x0E, 0x00, 0x0E, 0x1C, 0x00, 0x1C, 0x70, 0x00, 0x1C, 0xE0, 0x00, 0x39, + 0xC0, 0x00, 0x77, 0x00, 0x00, 0x70, 0x00, 0xFE, 0x00, 0xFF, 0xC0, 0xFF, + 0xE0, 0xF0, 0x30, 0x70, 0x00, 0x70, 0x00, 0x38, 0x00, 0x1C, 0x00, 0x0E, + 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE0, 0x03, 0xFF, 0xE1, + 0xFF, 0xF0, 0xFF, 0xF8, 0x0E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, + 0x00, 0xE0, 0x00, 0x70, 0x00, 0x38, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xC0, 0x40, 0x00, 0x5C, 0x00, 0x1D, 0xE7, 0xCF, 0x1F, 0xFF, 0xC1, + 0xFF, 0xF0, 0x3C, 0x1E, 0x07, 0x01, 0xC1, 0xC0, 0x1C, 0x38, 0x03, 0x87, + 0x00, 0x70, 0xE0, 0x0E, 0x1C, 0x01, 0xC1, 0xC0, 0x70, 0x3C, 0x1E, 0x0F, + 0xFF, 0xC1, 0xFF, 0xFC, 0x79, 0xF1, 0xDC, 0x00, 0x1D, 0x00, 0x01, 0x00, + 0xF0, 0x01, 0xEE, 0x00, 0x39, 0xE0, 0x0F, 0x1C, 0x01, 0xC3, 0xC0, 0x78, + 0x38, 0x0E, 0x07, 0x83, 0xC0, 0x70, 0x70, 0x0F, 0x1E, 0x00, 0xE3, 0x80, + 0x1E, 0xF0, 0x3F, 0xDF, 0xE7, 0xFF, 0xFC, 0x03, 0xE0, 0x00, 0x7C, 0x00, + 0x07, 0x00, 0x00, 0xE0, 0x0F, 0xFF, 0xF9, 0xFF, 0xFF, 0x00, 0x70, 0x00, + 0x0E, 0x00, 0x01, 0xC0, 0x00, 0x38, 0x00, 0x07, 0x00, 0x00, 0xE0, 0x00, + 0x1C, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x03, 0xFF, 0xFF, 0xFF, + 0xFF, 0xF8, 0x0F, 0xE0, 0xFF, 0xC3, 0x87, 0x1C, 0x04, 0x70, 0x01, 0xC0, + 0x07, 0x80, 0x0F, 0x00, 0x1F, 0x00, 0xFE, 0x07, 0x3E, 0x38, 0x7C, 0xE0, + 0x7B, 0x80, 0xFE, 0x01, 0xFC, 0x07, 0x7C, 0x1C, 0xF8, 0xE0, 0xFB, 0x81, + 0xF8, 0x01, 0xF0, 0x03, 0xC0, 0x07, 0x80, 0x0E, 0x00, 0x39, 0x00, 0xE7, + 0x07, 0x1F, 0xF8, 0x1F, 0xC0, 0xF1, 0xFE, 0x3F, 0xC7, 0xF8, 0xF0, 0x00, + 0x7F, 0x00, 0x00, 0xFF, 0xE0, 0x01, 0xE0, 0x3C, 0x01, 0xC0, 0x07, 0x01, + 0xC0, 0x01, 0xC1, 0xC3, 0xF8, 0x71, 0xC3, 0xFE, 0x18, 0xC3, 0xC1, 0x06, + 0x63, 0x80, 0x03, 0x63, 0xC0, 0x00, 0xF1, 0xC0, 0x00, 0x78, 0xE0, 0x00, + 0x3C, 0x70, 0x00, 0x1E, 0x38, 0x00, 0x0F, 0x1C, 0x00, 0x07, 0x8F, 0x00, + 0x03, 0x63, 0x80, 0x03, 0x30, 0xF0, 0x41, 0x9C, 0x3F, 0xE1, 0xC7, 0x0F, + 0xE1, 0xC1, 0xC0, 0x01, 0xC0, 0x70, 0x01, 0xC0, 0x1E, 0x03, 0xC0, 0x03, + 0xFF, 0x80, 0x00, 0x7F, 0x00, 0x00, 0x01, 0x02, 0x06, 0x0C, 0x1C, 0x38, + 0x70, 0xE1, 0xC3, 0x87, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xE1, 0xC0, 0xE1, + 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xC1, 0x80, 0x81, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x07, 0x00, + 0x00, 0x1C, 0x00, 0x00, 0x70, 0x00, 0x01, 0xC0, 0x00, 0x07, 0x00, 0x00, + 0x1C, 0x00, 0x00, 0x70, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x7F, 0x00, 0x00, + 0xFF, 0xE0, 0x01, 0xE0, 0x3C, 0x01, 0xC0, 0x07, 0x01, 0xC0, 0x01, 0xC1, + 0xC7, 0xF8, 0x71, 0xC3, 0xFE, 0x18, 0xC1, 0xC7, 0x86, 0x60, 0xE1, 0xC3, + 0x60, 0x70, 0xE0, 0xF0, 0x38, 0xF0, 0x78, 0x1F, 0xF0, 0x3C, 0x0F, 0xE0, + 0x1E, 0x07, 0x38, 0x0F, 0x03, 0x8C, 0x07, 0x81, 0xC7, 0x03, 0x60, 0xE3, + 0x83, 0x30, 0x70, 0xE1, 0x9C, 0x38, 0x71, 0xC7, 0x1C, 0x1D, 0xC1, 0xC0, + 0x01, 0xC0, 0x70, 0x01, 0xC0, 0x1E, 0x03, 0xC0, 0x03, 0xFF, 0x80, 0x00, + 0x7F, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0x07, 0xF1, 0xC7, 0x70, 0x7C, 0x07, + 0x80, 0xF0, 0x1F, 0x07, 0x71, 0xC7, 0xF0, 0x7C, 0x00, 0x00, 0x38, 0x00, + 0x00, 0x70, 0x00, 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, 0x03, 0x80, 0x00, + 0x07, 0x00, 0x00, 0x0E, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFC, 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, 0x03, 0x80, 0x00, 0x07, + 0x00, 0x00, 0x0E, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xC0, 0x7E, 0x3F, 0xEC, 0x1C, 0x03, 0x00, 0xC0, 0x70, 0x18, 0x0C, 0x0E, + 0x07, 0x03, 0x81, 0xC0, 0xFF, 0xFF, 0xF0, 0x1F, 0x8F, 0xF9, 0x83, 0x80, + 0x30, 0x0E, 0x3F, 0x87, 0xF0, 0x07, 0x00, 0x60, 0x0C, 0x01, 0xC0, 0xFF, + 0xFC, 0xFE, 0x00, 0x0F, 0x1E, 0x1C, 0x38, 0x70, 0xE0, 0x01, 0xE0, 0x78, + 0x0E, 0x03, 0x80, 0xE0, 0x38, 0x00, 0x07, 0x8F, 0xF1, 0xFE, 0x3F, 0xC7, + 0x80, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x1D, 0xF0, 0x00, 0x73, 0xE0, + 0x01, 0xC7, 0xC0, 0x07, 0x1D, 0xC0, 0x00, 0x3B, 0x80, 0x00, 0x77, 0x00, + 0x01, 0xC7, 0x00, 0x03, 0x8E, 0x00, 0x0F, 0x1E, 0x00, 0x1C, 0x1C, 0x00, + 0x38, 0x38, 0x00, 0xF0, 0x78, 0x01, 0xC0, 0x70, 0x03, 0x80, 0xE0, 0x0E, + 0x00, 0xE0, 0x1C, 0x01, 0xC0, 0x78, 0x03, 0xC0, 0xFF, 0xFF, 0x81, 0xFF, + 0xFF, 0x07, 0xFF, 0xFF, 0x0E, 0x00, 0x0E, 0x1C, 0x00, 0x1C, 0x70, 0x00, + 0x1C, 0xE0, 0x00, 0x39, 0xC0, 0x00, 0x77, 0x00, 0x00, 0x70, 0xFF, 0xF0, + 0x07, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x77, 0xFF, 0xF9, 0xCF, 0xFF, 0xF7, + 0x1F, 0xFF, 0xEC, 0x38, 0x00, 0x00, 0x70, 0x00, 0x00, 0xE0, 0x00, 0x01, + 0xC0, 0x00, 0x03, 0x80, 0x00, 0x07, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x1C, + 0x00, 0x00, 0x3F, 0xFF, 0x80, 0x7F, 0xFF, 0x00, 0xFF, 0xFE, 0x01, 0xC0, + 0x00, 0x03, 0x80, 0x00, 0x07, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x1C, 0x00, + 0x00, 0x38, 0x00, 0x00, 0x70, 0x00, 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, + 0x03, 0xFF, 0xFC, 0x07, 0xFF, 0xF8, 0x0F, 0xFF, 0xF0, 0x07, 0x00, 0x00, + 0x03, 0x80, 0x00, 0x01, 0xDC, 0x00, 0x1C, 0xE7, 0x00, 0x07, 0x31, 0xC0, + 0x01, 0xD8, 0x70, 0x00, 0x70, 0x1C, 0x00, 0x1C, 0x07, 0x00, 0x07, 0x01, + 0xC0, 0x01, 0xC0, 0x70, 0x00, 0x70, 0x1C, 0x00, 0x1C, 0x07, 0x00, 0x07, + 0x01, 0xC0, 0x01, 0xC0, 0x7F, 0xFF, 0xF0, 0x1F, 0xFF, 0xFC, 0x07, 0xFF, + 0xFF, 0x01, 0xC0, 0x01, 0xC0, 0x70, 0x00, 0x70, 0x1C, 0x00, 0x1C, 0x07, + 0x00, 0x07, 0x01, 0xC0, 0x01, 0xC0, 0x70, 0x00, 0x70, 0x1C, 0x00, 0x1C, + 0x07, 0x00, 0x07, 0x01, 0xC0, 0x01, 0xC0, 0x70, 0x00, 0x70, 0x1C, 0x00, + 0x1C, 0x07, 0x00, 0x07, 0x07, 0x03, 0x81, 0xDC, 0xE7, 0x71, 0xD8, 0x70, + 0x1C, 0x07, 0x01, 0xC0, 0x70, 0x1C, 0x07, 0x01, 0xC0, 0x70, 0x1C, 0x07, + 0x01, 0xC0, 0x70, 0x1C, 0x07, 0x01, 0xC0, 0x70, 0x1C, 0x07, 0x01, 0xC0, + 0x70, 0x1C, 0x07, 0x81, 0x01, 0x83, 0x03, 0x87, 0x03, 0x87, 0x03, 0x87, + 0x03, 0x87, 0x03, 0x87, 0x03, 0x87, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE1, + 0xC3, 0x87, 0x0E, 0x1C, 0x38, 0x30, 0x60, 0x40, 0x80, 0x07, 0x00, 0x00, + 0x01, 0xC0, 0x00, 0x00, 0x70, 0x7F, 0x80, 0x1C, 0x3F, 0xFC, 0x03, 0x1F, + 0xFF, 0xC0, 0xC7, 0xE0, 0x7C, 0x01, 0xE0, 0x03, 0xC0, 0x78, 0x00, 0x3C, + 0x0E, 0x00, 0x03, 0x81, 0xC0, 0x00, 0x78, 0x70, 0x00, 0x07, 0x0E, 0x00, + 0x00, 0xE1, 0xC0, 0x00, 0x1E, 0x38, 0x00, 0x01, 0xC7, 0x00, 0x00, 0x38, + 0xE0, 0x00, 0x07, 0x1C, 0x00, 0x00, 0xE3, 0x80, 0x00, 0x3C, 0x70, 0x00, + 0x07, 0x0E, 0x00, 0x00, 0xE0, 0xE0, 0x00, 0x3C, 0x1C, 0x00, 0x07, 0x03, + 0xC0, 0x01, 0xE0, 0x3C, 0x00, 0x78, 0x03, 0xF0, 0x3E, 0x00, 0x3F, 0xFF, + 0x80, 0x03, 0xFF, 0xE0, 0x00, 0x0F, 0xE0, 0x00, 0x3C, 0x00, 0x07, 0x07, + 0xE0, 0x00, 0x30, 0x33, 0x00, 0x03, 0x00, 0x18, 0x00, 0x38, 0x00, 0xC0, + 0x01, 0x80, 0x06, 0x00, 0x1C, 0x00, 0x30, 0x01, 0xC0, 0x01, 0x80, 0x0C, + 0x00, 0x0C, 0x00, 0xE0, 0x00, 0x60, 0x06, 0x00, 0x03, 0x00, 0x60, 0x00, + 0x18, 0x07, 0x1F, 0x8F, 0xFC, 0x31, 0xFF, 0x7F, 0xE3, 0x08, 0x1C, 0x00, + 0x38, 0x00, 0x60, 0x01, 0x80, 0x03, 0x00, 0x18, 0x00, 0x30, 0x01, 0xC0, + 0x03, 0x80, 0x0C, 0x00, 0x38, 0x00, 0xE0, 0x03, 0x80, 0x06, 0x00, 0x38, + 0x00, 0x60, 0x03, 0x80, 0x07, 0x00, 0x38, 0x00, 0x30, 0x03, 0xFF, 0x03, + 0x00, 0x1F, 0xF8, 0x38, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xE0, + 0x00, 0x00, 0x1C, 0xE0, 0x00, 0xF3, 0x8F, 0x00, 0x0E, 0x70, 0x78, 0x01, + 0xE6, 0x03, 0x80, 0x3C, 0x00, 0x3C, 0x03, 0x80, 0x01, 0xE0, 0x78, 0x00, + 0x0E, 0x0F, 0x00, 0x00, 0xF0, 0xE0, 0x00, 0x07, 0x1E, 0x00, 0x00, 0x3B, + 0xC0, 0x00, 0x03, 0xF8, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0E, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, + 0xE0, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x0E, 0x00, + 0x00, 0x00, 0xE0, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, + 0x0E, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x0E, 0x00, 0x0F, 0x00, 0x00, + 0x01, 0xC0, 0x00, 0x00, 0x70, 0x7F, 0x80, 0x1C, 0x3F, 0xFC, 0x07, 0x1F, + 0xFF, 0xE1, 0xC7, 0xE0, 0x7E, 0x01, 0xF0, 0x03, 0xE0, 0x38, 0x00, 0x3C, + 0x0F, 0x00, 0x03, 0xC1, 0xC0, 0x00, 0x38, 0x78, 0x00, 0x07, 0x0E, 0x00, + 0x00, 0x71, 0xC0, 0x00, 0x0E, 0x38, 0x00, 0x01, 0xC7, 0x00, 0x00, 0x38, + 0xE0, 0x00, 0x07, 0x1C, 0x00, 0x00, 0xE3, 0x80, 0x00, 0x3C, 0x38, 0x00, + 0x07, 0x07, 0x00, 0x00, 0xE0, 0xF0, 0x00, 0x38, 0x0E, 0x00, 0x07, 0x00, + 0xE0, 0x01, 0xC0, 0x0E, 0x00, 0x70, 0x00, 0xE0, 0x1C, 0x03, 0xFE, 0x07, + 0xFC, 0x7F, 0xC0, 0xFF, 0x8F, 0xF8, 0x1F, 0xF0, 0x00, 0xE0, 0x38, 0x0E, + 0x03, 0x80, 0x60, 0x18, 0x00, 0x07, 0x8F, 0xF1, 0xFE, 0x3F, 0xC7, 0x80, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, + 0xC0, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, 0xC0, + 0x3C, 0x03, 0x80, 0x7F, 0x07, 0xE0, 0x7C, 0x00, 0x7C, 0x00, 0x00, 0xF8, + 0x00, 0x01, 0xF0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x1D, 0xC0, + 0x00, 0x71, 0xC0, 0x00, 0xE3, 0x80, 0x03, 0xC7, 0x80, 0x07, 0x07, 0x00, + 0x0E, 0x0E, 0x00, 0x3C, 0x1E, 0x00, 0x70, 0x1C, 0x00, 0xE0, 0x38, 0x03, + 0x80, 0x38, 0x07, 0x00, 0x70, 0x1E, 0x00, 0xF0, 0x3F, 0xFF, 0xE0, 0x7F, + 0xFF, 0xC1, 0xFF, 0xFF, 0xC3, 0x80, 0x03, 0x87, 0x00, 0x07, 0x1C, 0x00, + 0x07, 0x38, 0x00, 0x0E, 0x70, 0x00, 0x1D, 0xC0, 0x00, 0x1C, 0xFF, 0xF8, + 0x3F, 0xFF, 0x8F, 0xFF, 0xF3, 0x80, 0x3C, 0xE0, 0x07, 0xB8, 0x00, 0xEE, + 0x00, 0x3B, 0x80, 0x0E, 0xE0, 0x03, 0xB8, 0x01, 0xEE, 0x00, 0xF3, 0xFF, + 0xF8, 0xFF, 0xFC, 0x3F, 0xFF, 0xCE, 0x00, 0xFB, 0x80, 0x0E, 0xE0, 0x01, + 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x01, 0xF8, 0x00, 0xFE, + 0x00, 0xFB, 0xFF, 0xFC, 0xFF, 0xFE, 0x3F, 0xFE, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, + 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, + 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, + 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x00, 0x00, 0x7C, + 0x00, 0x00, 0xF8, 0x00, 0x01, 0xF0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0xE0, + 0x00, 0x1D, 0xC0, 0x00, 0x71, 0xC0, 0x00, 0xE3, 0x80, 0x03, 0xC7, 0x80, + 0x07, 0x07, 0x00, 0x0E, 0x0E, 0x00, 0x3C, 0x1E, 0x00, 0x70, 0x1C, 0x00, + 0xE0, 0x38, 0x03, 0x80, 0x38, 0x07, 0x00, 0x70, 0x1E, 0x00, 0xF0, 0x38, + 0x00, 0xE0, 0x70, 0x01, 0xC1, 0xE0, 0x03, 0xC3, 0x80, 0x03, 0x87, 0x00, + 0x07, 0x1C, 0x00, 0x07, 0x3F, 0xFF, 0xFE, 0x7F, 0xFF, 0xFD, 0xFF, 0xFF, + 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xFF, + 0xFE, 0xFF, 0xFE, 0xFF, 0xFE, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xBF, 0xFF, + 0xFC, 0x00, 0x01, 0xC0, 0x00, 0x1E, 0x00, 0x01, 0xE0, 0x00, 0x1E, 0x00, + 0x01, 0xE0, 0x00, 0x0E, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x07, 0x80, 0x00, 0x78, 0x00, 0x07, + 0x80, 0x00, 0x38, 0x00, 0x03, 0xC0, 0x00, 0x3C, 0x00, 0x03, 0xC0, 0x00, + 0x3C, 0x00, 0x01, 0xC0, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xC0, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xF0, 0x00, 0x7E, + 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, + 0x00, 0x1F, 0x80, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, + 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xF0, + 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, 0x00, 0x07, 0xE0, + 0x00, 0xFC, 0x00, 0x1C, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, + 0xF0, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x3C, 0x00, 0x3C, 0x78, 0x00, + 0x1E, 0x70, 0x00, 0x0E, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE3, 0xFF, 0xC7, 0xE3, 0xFF, 0xC7, 0xE3, 0xFF, 0xC7, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0x70, 0x00, + 0x0E, 0x78, 0x00, 0x1E, 0x3C, 0x00, 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x81, + 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xE0, 0x03, 0xCE, 0x00, + 0x78, 0xE0, 0x0F, 0x0E, 0x01, 0xE0, 0xE0, 0x3C, 0x0E, 0x07, 0x80, 0xE0, + 0xF0, 0x0E, 0x1E, 0x00, 0xE3, 0xC0, 0x0E, 0x78, 0x00, 0xEF, 0x00, 0x0F, + 0xE0, 0x00, 0xFE, 0x00, 0x0F, 0xF0, 0x00, 0xEF, 0x80, 0x0E, 0x7C, 0x00, + 0xE3, 0xE0, 0x0E, 0x1F, 0x00, 0xE0, 0xF8, 0x0E, 0x07, 0xC0, 0xE0, 0x3E, + 0x0E, 0x01, 0xF0, 0xE0, 0x0F, 0x8E, 0x00, 0x7C, 0xE0, 0x03, 0xEE, 0x00, + 0x1F, 0x00, 0x7C, 0x00, 0x00, 0xF8, 0x00, 0x01, 0xF0, 0x00, 0x07, 0x70, + 0x00, 0x0E, 0xE0, 0x00, 0x1D, 0xC0, 0x00, 0x71, 0xC0, 0x00, 0xE3, 0x80, + 0x03, 0xC7, 0x80, 0x07, 0x07, 0x00, 0x0E, 0x0E, 0x00, 0x3C, 0x1E, 0x00, + 0x70, 0x1C, 0x00, 0xE0, 0x38, 0x03, 0x80, 0x38, 0x07, 0x00, 0x70, 0x1E, + 0x00, 0xF0, 0x38, 0x00, 0xE0, 0x70, 0x01, 0xC1, 0xE0, 0x03, 0xC3, 0x80, + 0x03, 0x87, 0x00, 0x07, 0x1C, 0x00, 0x07, 0x38, 0x00, 0x0E, 0x70, 0x00, + 0x1D, 0xC0, 0x00, 0x1C, 0xF8, 0x00, 0x3F, 0xF8, 0x00, 0xFF, 0xF0, 0x01, + 0xFF, 0xE0, 0x03, 0xFF, 0xE0, 0x0F, 0xFD, 0xC0, 0x1D, 0xFB, 0x80, 0x3B, + 0xF3, 0x80, 0xE7, 0xE7, 0x01, 0xCF, 0xCF, 0x07, 0x9F, 0x8E, 0x0E, 0x3F, + 0x1C, 0x1C, 0x7E, 0x1C, 0x70, 0xFC, 0x38, 0xE1, 0xF8, 0x71, 0xC3, 0xF0, + 0x77, 0x07, 0xE0, 0xEE, 0x0F, 0xC1, 0xFC, 0x1F, 0x81, 0xF0, 0x3F, 0x03, + 0xE0, 0x7E, 0x03, 0x80, 0xFC, 0x00, 0x01, 0xF8, 0x00, 0x03, 0xF0, 0x00, + 0x07, 0xE0, 0x00, 0x0F, 0xC0, 0x00, 0x1C, 0xF8, 0x00, 0xFF, 0x00, 0x1F, + 0xF0, 0x03, 0xFE, 0x00, 0x7F, 0xE0, 0x0F, 0xDC, 0x01, 0xFB, 0xC0, 0x3F, + 0x38, 0x07, 0xE7, 0x80, 0xFC, 0x70, 0x1F, 0x8F, 0x03, 0xF0, 0xE0, 0x7E, + 0x0E, 0x0F, 0xC1, 0xC1, 0xF8, 0x1C, 0x3F, 0x03, 0xC7, 0xE0, 0x38, 0xFC, + 0x07, 0x9F, 0x80, 0x73, 0xF0, 0x0F, 0x7E, 0x00, 0xEF, 0xC0, 0x1F, 0xF8, + 0x01, 0xFF, 0x00, 0x3F, 0xE0, 0x03, 0xFC, 0x00, 0x7C, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFC, 0x3F, 0xFC, 0x3F, + 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, 0x81, + 0xF8, 0x3E, 0x00, 0x7C, 0x3C, 0x00, 0x3C, 0x78, 0x00, 0x1E, 0x70, 0x00, + 0x0E, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0x70, 0x00, 0x0E, 0x78, 0x00, + 0x1E, 0x3C, 0x00, 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x81, 0xF8, 0x0F, 0xFF, + 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, + 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xF0, 0x00, 0x7E, + 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, + 0x00, 0x1F, 0x80, 0x03, 0xF0, 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, + 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1C, 0xFF, 0xE0, 0xFF, + 0xF8, 0xFF, 0xFC, 0xE0, 0x3E, 0xE0, 0x0F, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x3E, 0xFF, 0xFC, 0xFF, + 0xF8, 0xFF, 0xE0, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x78, 0x00, 0x3C, + 0x00, 0x1E, 0x00, 0x0F, 0x00, 0x07, 0x80, 0x03, 0xC0, 0x01, 0xE0, 0x00, + 0xF0, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x03, 0x80, 0x07, 0x80, 0x0F, + 0x00, 0x1E, 0x00, 0x1C, 0x00, 0x3C, 0x00, 0x78, 0x00, 0xF0, 0x00, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFE, 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, + 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, + 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, + 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x00, 0x07, 0x00, + 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, + 0x80, 0x00, 0xF0, 0x00, 0x7B, 0xC0, 0x07, 0x8E, 0x00, 0x38, 0x78, 0x03, + 0xC1, 0xE0, 0x3C, 0x07, 0x01, 0xC0, 0x3C, 0x1E, 0x00, 0xF1, 0xE0, 0x03, + 0x8E, 0x00, 0x1E, 0xF0, 0x00, 0x7F, 0x00, 0x01, 0xF0, 0x00, 0x0F, 0x80, + 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, + 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, + 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x00, + 0x1C, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x07, 0x00, 0x00, 0x1F, 0xF0, 0x00, + 0x7F, 0xFF, 0x00, 0x7F, 0xFF, 0xC0, 0x7E, 0x73, 0xF0, 0x78, 0x38, 0x3C, + 0x78, 0x1C, 0x0F, 0x38, 0x0E, 0x03, 0xB8, 0x07, 0x00, 0xFC, 0x03, 0x80, + 0x7E, 0x01, 0xC0, 0x3F, 0x00, 0xE0, 0x1F, 0x80, 0x70, 0x0F, 0xC0, 0x38, + 0x07, 0x70, 0x1C, 0x07, 0x3C, 0x0E, 0x07, 0x8F, 0x07, 0x07, 0x83, 0xF3, + 0x9F, 0x80, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0x80, 0x03, 0xFE, 0x00, 0x00, + 0x38, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x78, 0x00, 0x78, + 0xF0, 0x03, 0xC1, 0xC0, 0x0E, 0x07, 0x80, 0x78, 0x0F, 0x03, 0xC0, 0x1C, + 0x0E, 0x00, 0x78, 0x78, 0x00, 0xF3, 0xC0, 0x01, 0xCE, 0x00, 0x07, 0xF8, + 0x00, 0x0F, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x03, 0xF0, 0x00, + 0x0F, 0xC0, 0x00, 0x7F, 0x80, 0x03, 0xCF, 0x00, 0x0E, 0x1C, 0x00, 0x78, + 0x78, 0x03, 0xC0, 0xF0, 0x0E, 0x01, 0xC0, 0x78, 0x07, 0x83, 0xC0, 0x0F, + 0x0E, 0x00, 0x1C, 0x78, 0x00, 0x7B, 0xC0, 0x00, 0xF0, 0xE0, 0x1C, 0x03, + 0xF0, 0x0E, 0x01, 0xF8, 0x07, 0x00, 0xFC, 0x03, 0x80, 0x7E, 0x01, 0xC0, + 0x3F, 0x00, 0xE0, 0x1F, 0x80, 0x70, 0x0F, 0xC0, 0x38, 0x07, 0xE0, 0x1C, + 0x03, 0xF0, 0x0E, 0x01, 0xF8, 0x07, 0x00, 0xFE, 0x03, 0x80, 0xE7, 0x01, + 0xC0, 0x73, 0x80, 0xE0, 0x38, 0xE0, 0x70, 0x38, 0x78, 0x38, 0x3C, 0x1E, + 0x1C, 0x3C, 0x07, 0xCE, 0x7C, 0x01, 0xFF, 0xFC, 0x00, 0x7F, 0xFC, 0x00, + 0x0F, 0xF8, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x70, 0x00, 0x00, 0x38, 0x00, + 0x00, 0x1C, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x03, 0xFF, + 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x3C, 0x00, + 0x1C, 0x78, 0x00, 0x1E, 0x70, 0x00, 0x0E, 0xF0, 0x00, 0x0F, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0x70, 0x00, + 0x0E, 0x78, 0x00, 0x1E, 0x38, 0x00, 0x1C, 0x1C, 0x00, 0x38, 0x0E, 0x00, + 0x70, 0x07, 0x00, 0xE0, 0xFF, 0x81, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x81, + 0xFF, 0xF1, 0xFE, 0x3F, 0xC7, 0xF8, 0xF0, 0x00, 0x00, 0x03, 0x80, 0x70, + 0x0E, 0x01, 0xC0, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, + 0x01, 0xC0, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, + 0xC0, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x07, 0x8F, 0x00, + 0x3C, 0x78, 0x01, 0xE3, 0xC0, 0x0F, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x03, 0xC0, 0x01, 0xEF, 0x00, 0x1E, 0x38, 0x00, 0xE1, 0xE0, 0x0F, 0x07, + 0x80, 0xF0, 0x1C, 0x07, 0x00, 0xF0, 0x78, 0x03, 0xC7, 0x80, 0x0E, 0x38, + 0x00, 0x7B, 0xC0, 0x01, 0xFC, 0x00, 0x07, 0xC0, 0x00, 0x3E, 0x00, 0x00, + 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, + 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x00, 0x07, + 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x1C, 0x00, + 0x07, 0x80, 0x00, 0xE0, 0x00, 0x38, 0x00, 0x0E, 0x00, 0x03, 0x80, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x38, 0x7F, 0xCF, 0x1F, + 0xFD, 0xC7, 0x87, 0xB8, 0xE0, 0x7F, 0x3C, 0x07, 0xC7, 0x00, 0xF8, 0xE0, + 0x1F, 0x1C, 0x03, 0xC3, 0x80, 0x38, 0x70, 0x07, 0x0E, 0x01, 0xE1, 0xC0, + 0x3C, 0x3C, 0x0F, 0xC3, 0x81, 0xF8, 0x78, 0x7F, 0x87, 0xFF, 0xFC, 0x7F, + 0xCF, 0x83, 0xF0, 0xF0, 0x00, 0x1C, 0x00, 0x70, 0x01, 0xC0, 0x07, 0x00, + 0x0C, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xE0, 0x7F, + 0xF1, 0xFF, 0xE7, 0x80, 0xCE, 0x00, 0x1C, 0x00, 0x3C, 0x00, 0x3F, 0xE0, + 0x1F, 0xC0, 0x7F, 0x83, 0xC0, 0x0F, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x78, + 0x00, 0xF8, 0x06, 0xFF, 0xFC, 0xFF, 0xF8, 0x3F, 0xC0, 0x00, 0x0E, 0x00, + 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0x60, 0x00, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xE3, 0xF0, 0xEF, 0xFC, 0xFF, 0xFE, 0xFC, 0x1E, 0xF8, + 0x0F, 0xF0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, + 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x07, 0x07, 0x03, 0x83, 0x83, + 0x83, 0x80, 0x00, 0x00, 0x00, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, + 0xC0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xC0, 0xF0, 0x3F, + 0x8F, 0xC3, 0xE0, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x03, + 0x00, 0x06, 0x00, 0x00, 0x00, 0x78, 0xF0, 0x78, 0xF0, 0x78, 0xF0, 0x78, + 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, + 0x1C, 0xE0, 0x1C, 0xE0, 0x0E, 0xE0, 0x0E, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x0F, 0xE0, + 0x0E, 0xE0, 0x1E, 0xF0, 0x3C, 0x78, 0x7C, 0x7F, 0xF8, 0x3F, 0xF0, 0x0F, + 0xC0, 0x07, 0xE1, 0xC3, 0xFE, 0x78, 0xFF, 0xEE, 0x3C, 0x3D, 0xC7, 0x03, + 0xF9, 0xE0, 0x3E, 0x38, 0x07, 0xC7, 0x00, 0xF8, 0xE0, 0x1E, 0x1C, 0x01, + 0xC3, 0x80, 0x38, 0x70, 0x0F, 0x0E, 0x01, 0xE1, 0xE0, 0x7E, 0x1C, 0x0F, + 0xC3, 0xC3, 0xFC, 0x3F, 0xFF, 0xE3, 0xFE, 0x7C, 0x1F, 0x87, 0x80, 0x0F, + 0xE0, 0x1F, 0xFC, 0x1F, 0xFF, 0x0F, 0x07, 0x8F, 0x01, 0xE7, 0x00, 0x73, + 0x80, 0x39, 0xC0, 0x1C, 0xE0, 0x0E, 0x70, 0x0F, 0x38, 0x0F, 0x1C, 0x0F, + 0x8E, 0x7F, 0x87, 0x3F, 0xC3, 0x9F, 0xF9, 0xC0, 0x3E, 0xE0, 0x07, 0x70, + 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0x3F, 0x00, 0x1F, 0xC0, 0x1D, + 0xF8, 0x3E, 0xFF, 0xFE, 0x7F, 0xFE, 0x39, 0xFC, 0x1C, 0x00, 0x0E, 0x00, + 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE0, 0x00, 0x70, 0x00, 0x00, + 0xE0, 0x00, 0xFF, 0x00, 0x3B, 0xE0, 0x07, 0x1E, 0x01, 0xE1, 0xC0, 0x38, + 0x3C, 0x0F, 0x03, 0x81, 0xC0, 0x70, 0x38, 0x0F, 0x0E, 0x00, 0xE1, 0xC0, + 0x1C, 0x78, 0x03, 0xCE, 0x00, 0x3B, 0xC0, 0x07, 0x70, 0x00, 0xFE, 0x00, + 0x0F, 0x80, 0x01, 0xF0, 0x00, 0x3E, 0x00, 0x03, 0x80, 0x00, 0x70, 0x00, + 0x0E, 0x00, 0x01, 0xC0, 0x00, 0x38, 0x00, 0x07, 0x00, 0x00, 0xE0, 0x00, + 0x1C, 0x00, 0x03, 0xFE, 0x03, 0xFF, 0xC1, 0xFF, 0xF0, 0xF0, 0x04, 0x38, + 0x00, 0x0E, 0x00, 0x03, 0xE0, 0x00, 0x7F, 0xC0, 0x0F, 0xFC, 0x07, 0xFF, + 0x83, 0x81, 0xF1, 0xC0, 0x1E, 0x70, 0x03, 0xB8, 0x00, 0xFE, 0x00, 0x1F, + 0x80, 0x07, 0xE0, 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0xC0, 0x0F, 0x70, + 0x03, 0x9E, 0x01, 0xE3, 0xC0, 0xF0, 0xFF, 0xFC, 0x0F, 0xFC, 0x00, 0xFC, + 0x00, 0x07, 0xF0, 0x3F, 0xF8, 0xFF, 0xF3, 0xC0, 0x67, 0x00, 0x0E, 0x00, + 0x1E, 0x00, 0x1F, 0xF0, 0x0F, 0xE0, 0x3F, 0xC1, 0xE0, 0x07, 0x80, 0x0E, + 0x00, 0x1C, 0x00, 0x3C, 0x00, 0x7C, 0x03, 0x7F, 0xFE, 0x7F, 0xFC, 0x1F, + 0xE0, 0x7F, 0xFF, 0x7F, 0xFF, 0x7F, 0xFF, 0x00, 0x7E, 0x00, 0xF8, 0x03, + 0xE0, 0x07, 0xC0, 0x0F, 0x80, 0x1E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x78, + 0x00, 0x70, 0x00, 0x70, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xF0, 0x00, 0x70, 0x00, 0x78, 0x00, 0x3E, + 0x00, 0x1F, 0xF8, 0x0F, 0xFE, 0x01, 0xFE, 0x00, 0x0F, 0x00, 0x07, 0x00, + 0x07, 0x00, 0x0F, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1C, 0xE3, 0xF0, 0xEF, + 0xFC, 0xFF, 0xFE, 0xFC, 0x1E, 0xF8, 0x0F, 0xF0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0x00, + 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, + 0x07, 0x03, 0xF0, 0x03, 0xFF, 0x01, 0xFF, 0xE0, 0xF8, 0x7C, 0x38, 0x07, + 0x1E, 0x01, 0xE7, 0x00, 0x39, 0xC0, 0x0E, 0xF0, 0x03, 0xF8, 0x00, 0x7E, + 0x00, 0x1F, 0x80, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, + 0x07, 0xE0, 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1D, 0xC0, 0x0E, 0x70, 0x03, + 0x9E, 0x01, 0xE3, 0x80, 0x70, 0xF8, 0x7C, 0x1F, 0xFE, 0x03, 0xFF, 0x00, + 0x3F, 0x00, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, + 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xF0, 0x7F, 0x3F, 0x1F, 0xE0, 0x3C, 0x70, + 0x3C, 0x38, 0x3C, 0x1C, 0x3C, 0x0E, 0x3C, 0x07, 0x3C, 0x03, 0xBC, 0x01, + 0xFE, 0x00, 0xFF, 0x80, 0x7F, 0xC0, 0x3C, 0xF0, 0x1C, 0x3C, 0x0E, 0x0F, + 0x07, 0x03, 0xC3, 0x80, 0xE1, 0xC0, 0x78, 0xE0, 0x1E, 0x70, 0x07, 0xB8, + 0x01, 0xE0, 0x1F, 0x00, 0x03, 0xF0, 0x00, 0x7F, 0x00, 0x01, 0xE0, 0x00, + 0x1E, 0x00, 0x01, 0xC0, 0x00, 0x38, 0x00, 0x07, 0x80, 0x00, 0x70, 0x00, + 0x1E, 0x00, 0x03, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0xC0, 0x07, 0xB8, 0x00, + 0xE7, 0x00, 0x3C, 0xF0, 0x07, 0x0E, 0x00, 0xE1, 0xC0, 0x38, 0x1C, 0x07, + 0x03, 0x81, 0xC0, 0x78, 0x38, 0x07, 0x0E, 0x00, 0xE1, 0xC0, 0x1E, 0x78, + 0x01, 0xCE, 0x00, 0x3B, 0xC0, 0x03, 0x80, 0xE0, 0x07, 0x38, 0x01, 0xCE, + 0x00, 0x73, 0x80, 0x1C, 0xE0, 0x07, 0x38, 0x01, 0xCE, 0x00, 0x73, 0x80, + 0x1C, 0xE0, 0x07, 0x38, 0x01, 0xCE, 0x00, 0x73, 0x80, 0x1C, 0xE0, 0x07, + 0x38, 0x01, 0xCF, 0x00, 0xF3, 0xE0, 0xFC, 0xFF, 0xFF, 0xFB, 0xFC, 0xFE, + 0x7E, 0x3B, 0x80, 0x00, 0xE0, 0x00, 0x38, 0x00, 0x0E, 0x00, 0x03, 0x80, + 0x00, 0xE0, 0x00, 0x38, 0x00, 0x00, 0xE0, 0x0E, 0x78, 0x07, 0x1C, 0x01, + 0xCE, 0x00, 0xE7, 0x80, 0x39, 0xC0, 0x1C, 0xE0, 0x0E, 0x78, 0x07, 0x1C, + 0x03, 0x8E, 0x03, 0xC7, 0x01, 0xC1, 0xC1, 0xE0, 0xE0, 0xE0, 0x70, 0xE0, + 0x1C, 0xF0, 0x0E, 0xF0, 0x07, 0xF0, 0x01, 0xF0, 0x00, 0xF0, 0x00, 0x7F, + 0xFC, 0xFF, 0xF9, 0xFF, 0xF0, 0xFC, 0x03, 0xC0, 0x0F, 0x00, 0x1C, 0x00, + 0x38, 0x00, 0x70, 0x00, 0xF0, 0x00, 0xF8, 0x00, 0xFF, 0x80, 0x3F, 0x03, + 0xFE, 0x0F, 0x80, 0x3C, 0x00, 0xF0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, + 0x0E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x3F, 0x00, 0x3F, 0xF0, 0x3F, 0xF8, + 0x1F, 0xF0, 0x00, 0xF0, 0x00, 0xE0, 0x01, 0xC0, 0x07, 0x80, 0x1E, 0x00, + 0x3C, 0x00, 0x70, 0x03, 0xF0, 0x03, 0xFF, 0x03, 0xFF, 0xF0, 0xF0, 0x3C, + 0x78, 0x07, 0x9C, 0x00, 0xEE, 0x00, 0x3F, 0x80, 0x07, 0xE0, 0x01, 0xF8, + 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xF0, 0x03, 0xDC, 0x00, 0xE7, 0x80, + 0x78, 0xF0, 0x3C, 0x3F, 0xFF, 0x03, 0xFF, 0x00, 0x3F, 0x00, 0xFF, 0xFF, + 0xBF, 0xFF, 0xEF, 0xFF, 0xF8, 0xE0, 0x38, 0x38, 0x0E, 0x0E, 0x03, 0x83, + 0x80, 0xE0, 0xE0, 0x38, 0x38, 0x0E, 0x0E, 0x03, 0x83, 0x80, 0xE0, 0xE0, + 0x38, 0x38, 0x0E, 0x0E, 0x03, 0x83, 0x80, 0xE0, 0xE0, 0x38, 0x38, 0x0F, + 0xCE, 0x01, 0xF3, 0x80, 0x7C, 0x03, 0xF8, 0x03, 0xFF, 0x81, 0xFF, 0xF0, + 0xF0, 0x3C, 0x78, 0x07, 0x9C, 0x00, 0xEE, 0x00, 0x3F, 0x80, 0x07, 0xE0, + 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x03, 0xFC, 0x00, + 0xEF, 0x80, 0x7B, 0xF0, 0x3C, 0xEF, 0xFF, 0x39, 0xFF, 0x0E, 0x3F, 0x03, + 0x80, 0x00, 0xE0, 0x00, 0x38, 0x00, 0x0E, 0x00, 0x03, 0x80, 0x00, 0xE0, + 0x00, 0x38, 0x00, 0x00, 0x03, 0xF8, 0x1F, 0xFC, 0x7F, 0xF9, 0xF0, 0x37, + 0x80, 0x0E, 0x00, 0x3C, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, + 0x07, 0x00, 0x0F, 0x00, 0x0E, 0x00, 0x1E, 0x00, 0x1F, 0x00, 0x1F, 0xF0, + 0x1F, 0xF8, 0x0F, 0xF0, 0x00, 0xF0, 0x00, 0xE0, 0x01, 0xC0, 0x07, 0x80, + 0x1E, 0x00, 0x3C, 0x00, 0x70, 0x07, 0xFF, 0xF1, 0xFF, 0xFF, 0x3F, 0xFF, + 0xF7, 0xC0, 0xF0, 0x78, 0x07, 0x87, 0x00, 0x38, 0xE0, 0x03, 0xCE, 0x00, + 0x1C, 0xE0, 0x01, 0xCE, 0x00, 0x1C, 0xE0, 0x01, 0xCE, 0x00, 0x1C, 0xF0, + 0x03, 0xC7, 0x00, 0x38, 0x78, 0x07, 0x83, 0xC0, 0xF0, 0x3F, 0xFE, 0x00, + 0xFF, 0xC0, 0x03, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, + 0x38, 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, + 0x00, 0xE0, 0x00, 0x70, 0x00, 0x38, 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x07, + 0x00, 0x03, 0xC0, 0x00, 0xFE, 0x00, 0x3F, 0x00, 0x0F, 0x80, 0xE0, 0x1C, + 0xE0, 0x1C, 0xE0, 0x0E, 0xE0, 0x0E, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, + 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x0F, 0xE0, 0x0E, + 0xE0, 0x1E, 0xF0, 0x3C, 0x78, 0x7C, 0x7F, 0xF8, 0x3F, 0xF0, 0x0F, 0xC0, + 0x06, 0x3C, 0x03, 0xCF, 0xE0, 0xFB, 0xFE, 0x3E, 0x71, 0xE7, 0x8E, 0x1D, + 0xE1, 0xC3, 0xFC, 0x38, 0x3F, 0x07, 0x07, 0xE0, 0xE0, 0xFC, 0x1C, 0x1F, + 0x83, 0x83, 0xF0, 0x70, 0x7E, 0x0E, 0x0E, 0xE1, 0xC3, 0x9E, 0x38, 0xF1, + 0xE7, 0x3C, 0x3F, 0xFF, 0x83, 0xFF, 0xE0, 0x1F, 0xF0, 0x00, 0x70, 0x00, + 0x0E, 0x00, 0x01, 0xC0, 0x00, 0x38, 0x00, 0x07, 0x00, 0x00, 0xE0, 0x00, + 0x1C, 0x00, 0xF0, 0x03, 0xFF, 0x00, 0xEF, 0xC0, 0x78, 0x78, 0x1C, 0x0E, + 0x0E, 0x03, 0xC7, 0x80, 0x71, 0xC0, 0x1C, 0xF0, 0x03, 0xB8, 0x00, 0xFE, + 0x00, 0x3F, 0x00, 0x07, 0xC0, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x3E, 0x00, + 0x0F, 0xC0, 0x07, 0xF0, 0x01, 0xDC, 0x00, 0xF3, 0x80, 0x38, 0xE0, 0x1E, + 0x3C, 0x07, 0x07, 0x03, 0x81, 0xE1, 0xE0, 0x3F, 0x70, 0x0F, 0xFC, 0x00, + 0xF0, 0xE0, 0xE0, 0xFC, 0x1C, 0x1F, 0x83, 0x83, 0xF0, 0x70, 0x7E, 0x0E, + 0x0F, 0xC1, 0xC1, 0xF8, 0x38, 0x3F, 0x07, 0x07, 0xE0, 0xE0, 0xFC, 0x1C, + 0x1F, 0x83, 0x83, 0xF0, 0x70, 0x7E, 0x0E, 0x0F, 0xE1, 0xC3, 0xDC, 0x38, + 0x73, 0xE7, 0x3E, 0x3F, 0xFF, 0x83, 0xFF, 0xE0, 0x0F, 0xE0, 0x00, 0x70, + 0x00, 0x0E, 0x00, 0x01, 0xC0, 0x00, 0x38, 0x00, 0x07, 0x00, 0x00, 0xE0, + 0x00, 0x1C, 0x00, 0x1C, 0x00, 0x1C, 0x1C, 0x00, 0x07, 0x0E, 0x00, 0x03, + 0x8E, 0x00, 0x00, 0xE7, 0x00, 0x00, 0x73, 0x80, 0x00, 0x3B, 0x80, 0x00, + 0x0F, 0xC0, 0x00, 0x07, 0xE0, 0x1C, 0x03, 0xF0, 0x0E, 0x01, 0xF8, 0x07, + 0x00, 0xFC, 0x03, 0x80, 0x7E, 0x01, 0xC0, 0x3F, 0x81, 0xF0, 0x3D, 0xC0, + 0xD8, 0x1C, 0xF0, 0xEE, 0x1E, 0x3F, 0xF7, 0xFE, 0x0F, 0xF1, 0xFE, 0x03, + 0xF0, 0x7E, 0x00, 0xF1, 0xFE, 0x3F, 0xC7, 0xF8, 0xF0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, 0xC0, 0x38, 0x07, 0x00, + 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, 0xC0, 0x38, 0x07, 0x80, 0x70, + 0x0F, 0xE0, 0xFC, 0x0F, 0x80, 0x7C, 0xF8, 0x7C, 0xF8, 0x7C, 0xF8, 0x7C, + 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, + 0x1C, 0xE0, 0x1C, 0xE0, 0x0E, 0xE0, 0x0E, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x0F, 0xE0, + 0x0E, 0xE0, 0x1E, 0xF0, 0x3C, 0x78, 0x7C, 0x7F, 0xF8, 0x3F, 0xF0, 0x0F, + 0xC0, 0x00, 0x1E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE0, + 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00, + 0xFF, 0xC0, 0xFF, 0xFC, 0x3C, 0x0F, 0x1E, 0x01, 0xE7, 0x00, 0x3B, 0x80, + 0x0F, 0xE0, 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x01, + 0xFC, 0x00, 0xF7, 0x00, 0x39, 0xE0, 0x1E, 0x3C, 0x0F, 0x0F, 0xFF, 0xC0, + 0xFF, 0xC0, 0x0F, 0xC0, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x01, 0x80, + 0x03, 0x80, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xE0, 0x1C, 0xE0, 0x1C, 0xE0, 0x0E, 0xE0, 0x0E, 0xE0, 0x07, 0xE0, 0x07, + 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x0F, + 0xE0, 0x0E, 0xE0, 0x1E, 0xF0, 0x3C, 0x78, 0x7C, 0x7F, 0xF8, 0x3F, 0xF0, + 0x0F, 0xC0, 0x00, 0x01, 0xC0, 0x00, 0x01, 0xE0, 0x00, 0x00, 0xE0, 0x00, + 0x00, 0xE0, 0x00, 0x00, 0xE0, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x07, + 0x07, 0x00, 0x01, 0xC3, 0x80, 0x00, 0xE3, 0x80, 0x00, 0x39, 0xC0, 0x00, + 0x1C, 0xE0, 0x00, 0x0E, 0xE0, 0x00, 0x03, 0xF0, 0x00, 0x01, 0xF8, 0x07, + 0x00, 0xFC, 0x03, 0x80, 0x7E, 0x01, 0xC0, 0x3F, 0x00, 0xE0, 0x1F, 0x80, + 0x70, 0x0F, 0xE0, 0x7C, 0x0F, 0x70, 0x36, 0x07, 0x3C, 0x3B, 0x87, 0x8F, + 0xFD, 0xFF, 0x83, 0xFC, 0x7F, 0x80, 0xFC, 0x1F, 0x80, +}; + +const GFXglyph FreeSans18pt_Win1253Glyphs[] PROGMEM = { +/* 0x01 */ { 0, 33, 36, 45, 6, -29 }, +/* 0x02 */ { 149, 33, 36, 45, 6, -29 }, +/* 0x03 */ { 298, 35, 36, 45, 5, -29 }, +/* 0x04 */ { 456, 42, 36, 45, 1, -29 }, +/* 0x05 */ { 645, 35, 36, 45, 5, -29 }, +/* 0x06 */ { 803, 35, 36, 45, 5, -29 }, +/* 0x07 */ { 961, 0, 0, 0, 0, 0 }, +/* 0x08 */ { 961, 37, 36, 45, 4, -29 }, +/* 0x09 */ { 1128, 40, 28, 45, 2, -25 }, +/* 0x0A */ { 1268, 0, 0, 0, 0, 0 }, +/* 0x0B */ { 1268, 39, 36, 45, 3, -29 }, +/* 0x0C */ { 1444, 35, 36, 45, 5, -29 }, +/* 0x0D */ { 1602, 0, 0, 0, 0, 0 }, +/* 0x0E */ { 1602, 35, 36, 45, 5, -29 }, +/* 0x0F */ { 1760, 35, 37, 45, 5, -29 }, +/* 0x10 */ { 1922, 34, 36, 45, 5, -29 }, +/* 0x11 */ { 2075, 35, 36, 45, 5, -29 }, +/* 0x12 */ { 2233, 34, 36, 45, 5, -29 }, +/* 0x13 */ { 2386, 35, 36, 45, 5, -29 }, +/* 0x14 */ { 2544, 35, 36, 45, 5, -29 }, +/* 0x15 */ { 2702, 38, 36, 45, 4, -29 }, +/* 0x16 */ { 2873, 28, 36, 45, 8, -29 }, +/* 0x17 */ { 2999, 37, 30, 45, 4, -26 }, +/* 0x18 */ { 3138, 42, 30, 45, 1, -26 }, +/* 0x19 */ { 3296, 35, 36, 45, 5, -29 }, +/* 0x1A */ { 3454, 0, 0, 0, 0, 0 }, +/* 0x1B */ { 3454, 42, 37, 45, 1, -29 }, +/* 0x1C */ { 3649, 35, 36, 45, 5, -29 }, +/* 0x1D */ { 3807, 36, 36, 45, 4, -28 }, +/* 0x1E */ { 3969, 35, 36, 45, 4, -29 }, +/* 0x1F */ { 4127, 25, 36, 45, 10, -29 }, +/* 0x20 */ { 4240, 1, 1, 11, 0, 0 }, +/* 0x21 */ { 4241, 3, 26, 14, 5, -25 }, +/* 0x22 */ { 4251, 10, 9, 16, 3, -25 }, +/* 0x23 */ { 4263, 24, 25, 29, 3, -24 }, +/* 0x24 */ { 4338, 17, 32, 22, 3, -26 }, +/* 0x25 */ { 4406, 29, 26, 33, 2, -25 }, +/* 0x26 */ { 4501, 24, 26, 27, 2, -25 }, +/* 0x27 */ { 4579, 3, 9, 10, 3, -25 }, +/* 0x28 */ { 4583, 8, 31, 14, 3, -26 }, +/* 0x29 */ { 4614, 8, 31, 14, 3, -26 }, +/* 0x2A */ { 4645, 16, 16, 18, 1, -25 }, +/* 0x2B */ { 4677, 23, 23, 29, 4, -22 }, +/* 0x2C */ { 4744, 4, 8, 11, 3, -3 }, +/* 0x2D */ { 4748, 9, 3, 13, 2, -10 }, +/* 0x2E */ { 4752, 3, 4, 11, 4, -3 }, +/* 0x2F */ { 4754, 12, 29, 12, 0, -25 }, +/* 0x30 */ { 4798, 18, 26, 22, 2, -25 }, +/* 0x31 */ { 4857, 15, 26, 22, 4, -25 }, +/* 0x32 */ { 4906, 16, 26, 22, 3, -25 }, +/* 0x33 */ { 4958, 17, 26, 22, 3, -25 }, +/* 0x34 */ { 5014, 19, 26, 22, 2, -25 }, +/* 0x35 */ { 5076, 17, 26, 22, 3, -25 }, +/* 0x36 */ { 5132, 18, 26, 22, 2, -25 }, +/* 0x37 */ { 5191, 16, 26, 22, 3, -25 }, +/* 0x38 */ { 5243, 18, 26, 22, 2, -25 }, +/* 0x39 */ { 5302, 18, 26, 22, 2, -25 }, +/* 0x3A */ { 5361, 3, 18, 12, 4, -17 }, +/* 0x3B */ { 5368, 4, 22, 12, 3, -17 }, +/* 0x3C */ { 5379, 22, 19, 29, 4, -19 }, +/* 0x3D */ { 5432, 22, 10, 29, 4, -15 }, +/* 0x3E */ { 5460, 22, 19, 29, 4, -19 }, +/* 0x3F */ { 5513, 14, 26, 19, 3, -25 }, +/* 0x40 */ { 5559, 31, 31, 35, 2, -24 }, +/* 0x41 */ { 5680, 23, 26, 24, 0, -25 }, +/* 0x42 */ { 5755, 18, 26, 24, 3, -25 }, +/* 0x43 */ { 5814, 21, 26, 24, 2, -25 }, +/* 0x44 */ { 5883, 21, 26, 27, 3, -25 }, +/* 0x45 */ { 5952, 16, 26, 22, 3, -25 }, +/* 0x46 */ { 6004, 15, 26, 20, 3, -25 }, +/* 0x47 */ { 6053, 22, 26, 27, 2, -25 }, +/* 0x48 */ { 6125, 19, 26, 26, 3, -25 }, +/* 0x49 */ { 6187, 3, 26, 10, 3, -25 }, +/* 0x4A */ { 6197, 8, 33, 10, -2, -25 }, +/* 0x4B */ { 6230, 20, 26, 23, 3, -25 }, +/* 0x4C */ { 6295, 16, 26, 20, 3, -25 }, +/* 0x4D */ { 6347, 23, 26, 30, 3, -25 }, +/* 0x4E */ { 6422, 19, 26, 26, 3, -25 }, +/* 0x4F */ { 6484, 24, 26, 28, 2, -25 }, +/* 0x50 */ { 6562, 16, 26, 21, 3, -25 }, +/* 0x51 */ { 6614, 24, 31, 28, 2, -25 }, +/* 0x52 */ { 6707, 19, 26, 24, 3, -25 }, +/* 0x53 */ { 6769, 18, 26, 22, 2, -25 }, +/* 0x54 */ { 6828, 21, 26, 21, 0, -25 }, +/* 0x55 */ { 6897, 19, 26, 26, 3, -25 }, +/* 0x56 */ { 6959, 23, 26, 24, 0, -25 }, +/* 0x57 */ { 7034, 32, 26, 35, 1, -25 }, +/* 0x58 */ { 7138, 22, 26, 24, 1, -25 }, +/* 0x59 */ { 7210, 21, 26, 21, 0, -25 }, +/* 0x5A */ { 7279, 21, 26, 24, 2, -25 }, +/* 0x5B */ { 7348, 7, 31, 14, 3, -26 }, +/* 0x5C */ { 7376, 12, 29, 12, 0, -25 }, +/* 0x5D */ { 7420, 7, 31, 14, 3, -26 }, +/* 0x5E */ { 7448, 22, 10, 29, 4, -25 }, +/* 0x5F */ { 7476, 18, 3, 18, 0, 6 }, +/* 0x60 */ { 7483, 8, 6, 18, 3, -27 }, +/* 0x61 */ { 7489, 16, 19, 21, 2, -18 }, +/* 0x62 */ { 7527, 17, 27, 22, 3, -26 }, +/* 0x63 */ { 7585, 15, 19, 19, 2, -18 }, +/* 0x64 */ { 7621, 17, 27, 22, 2, -26 }, +/* 0x65 */ { 7679, 18, 19, 22, 2, -18 }, +/* 0x66 */ { 7722, 12, 27, 12, 1, -26 }, +/* 0x67 */ { 7763, 17, 26, 22, 2, -18 }, +/* 0x68 */ { 7819, 16, 27, 22, 3, -26 }, +/* 0x69 */ { 7873, 3, 27, 10, 3, -26 }, +/* 0x6A */ { 7884, 7, 34, 10, -1, -26 }, +/* 0x6B */ { 7914, 17, 27, 20, 3, -26 }, +/* 0x6C */ { 7972, 3, 27, 10, 3, -26 }, +/* 0x6D */ { 7983, 29, 19, 34, 3, -18 }, +/* 0x6E */ { 8052, 16, 19, 22, 3, -18 }, +/* 0x6F */ { 8090, 18, 19, 21, 2, -18 }, +/* 0x70 */ { 8133, 17, 26, 22, 3, -18 }, +/* 0x71 */ { 8189, 17, 26, 22, 2, -18 }, +/* 0x72 */ { 8245, 11, 19, 14, 3, -18 }, +/* 0x73 */ { 8272, 15, 19, 18, 2, -18 }, +/* 0x74 */ { 8308, 11, 24, 14, 1, -23 }, +/* 0x75 */ { 8341, 16, 19, 22, 3, -18 }, +/* 0x76 */ { 8379, 19, 19, 21, 1, -18 }, +/* 0x77 */ { 8425, 26, 19, 29, 1, -18 }, +/* 0x78 */ { 8487, 19, 19, 21, 1, -18 }, +/* 0x79 */ { 8533, 19, 26, 21, 1, -18 }, +/* 0x7A */ { 8595, 15, 19, 18, 2, -18 }, +/* 0x7B */ { 8631, 13, 32, 22, 5, -26 }, +/* 0x7C */ { 8683, 3, 35, 12, 4, -26 }, +/* 0x7D */ { 8697, 13, 32, 22, 4, -26 }, +/* 0x7E */ { 8749, 22, 6, 29, 4, -13 }, +/* 0x7F */ { 8766, 0, 0, 0, 0, 0 }, +/* 0x80 */ { 8766, 20, 26, 22, 0, -25 }, +/* 0x81 */ { 8831, 0, 0, 0, 0, 0 }, +/* 0x82 */ { 8831, 4, 8, 11, 3, -3 }, +/* 0x83 */ { 8835, 15, 34, 12, -2, -26 }, +/* 0x84 */ { 8899, 10, 8, 18, 3, -3 }, +/* 0x85 */ { 8909, 27, 4, 35, 4, -3 }, +/* 0x86 */ { 8923, 15, 29, 18, 1, -25 }, +/* 0x87 */ { 8978, 15, 29, 18, 1, -25 }, +/* 0x88 */ { 9033, 0, 0, 0, 0, 0 }, +/* 0x89 */ { 9033, 43, 26, 47, 2, -25 }, +/* 0x8A */ { 9173, 0, 0, 0, 0, 0 }, +/* 0x8B */ { 9173, 8, 16, 14, 3, -17 }, +/* 0x8C */ { 9189, 0, 0, 0, 0, 0 }, +/* 0x8D */ { 9189, 0, 0, 0, 0, 0 }, +/* 0x8E */ { 9189, 0, 0, 0, 0, 0 }, +/* 0x8F */ { 9189, 0, 0, 0, 0, 0 }, +/* 0x90 */ { 9189, 0, 0, 0, 0, 0 }, +/* 0x91 */ { 9189, 4, 8, 11, 3, -25 }, +/* 0x92 */ { 9193, 4, 8, 11, 3, -25 }, +/* 0x93 */ { 9197, 10, 8, 18, 3, -25 }, +/* 0x94 */ { 9207, 10, 8, 18, 3, -25 }, +/* 0x95 */ { 9217, 10, 10, 21, 5, -17 }, +/* 0x96 */ { 9230, 14, 3, 18, 2, -10 }, +/* 0x97 */ { 9236, 32, 3, 35, 2, -10 }, +/* 0x98 */ { 9248, 0, 0, 0, 0, 0 }, +/* 0x99 */ { 9248, 22, 10, 35, 5, -25 }, +/* 0x9A */ { 9276, 0, 0, 0, 0, 0 }, +/* 0x9B */ { 9276, 8, 16, 14, 3, -17 }, +/* 0x9C */ { 9292, 0, 0, 0, 0, 0 }, +/* 0x9D */ { 9292, 0, 0, 0, 0, 0 }, +/* 0x9E */ { 9292, 0, 0, 0, 0, 0 }, +/* 0x9F */ { 9292, 0, 0, 0, 0, 0 }, +/* 0xA0 */ { 9292, 1, 1, 11, 0, 0 }, +/* 0xA1 */ { 9293, 11, 11, 18, 4, -33 }, +/* 0xA2 */ { 9309, 23, 28, 24, 0, -27 }, +/* 0xA3 */ { 9390, 17, 26, 22, 2, -25 }, +/* 0xA4 */ { 9446, 19, 19, 22, 2, -19 }, +/* 0xA5 */ { 9492, 19, 26, 22, 1, -25 }, +/* 0xA6 */ { 9554, 3, 31, 12, 4, -24 }, +/* 0xA7 */ { 9566, 14, 29, 18, 2, -25 }, +/* 0xA8 */ { 9617, 11, 4, 18, 4, -26 }, +/* 0xA9 */ { 9623, 25, 25, 35, 5, -24 }, +/* 0xAA */ { 9702, 0, 0, 0, 0, 0 }, +/* 0xAB */ { 9702, 15, 16, 21, 3, -17 }, +/* 0xAC */ { 9732, 22, 10, 29, 4, -14 }, +/* 0xAD */ { 9760, 9, 3, 13, 2, -10 }, +/* 0xAE */ { 9764, 25, 25, 35, 5, -24 }, +/* 0xAF */ { 9843, 35, 3, 35, 0, -10 }, +/* 0xB0 */ { 9857, 11, 11, 18, 3, -25 }, +/* 0xB1 */ { 9873, 23, 22, 29, 3, -21 }, +/* 0xB2 */ { 9937, 10, 14, 14, 2, -25 }, +/* 0xB3 */ { 9955, 11, 14, 14, 2, -25 }, +/* 0xB4 */ { 9975, 8, 6, 18, 7, -27 }, +/* 0xB5 */ { 9981, 11, 11, 18, 4, -33 }, +/* 0xB6 */ { 9997, 23, 28, 24, 0, -27 }, +/* 0xB7 */ { 10078, 3, 4, 11, 4, -13 }, +/* 0xB8 */ { 10080, 23, 28, 26, 0, -27 }, +/* 0xB9 */ { 10161, 26, 28, 30, 0, -27 }, +/* 0xBA */ { 10252, 10, 28, 14, 0, -27 }, +/* 0xBB */ { 10287, 15, 16, 21, 3, -17 }, +/* 0xBC */ { 10317, 27, 28, 28, 0, -27 }, +/* 0xBD */ { 10412, 29, 26, 34, 3, -25 }, +/* 0xBE */ { 10507, 28, 28, 29, 0, -27 }, +/* 0xBF */ { 10605, 27, 28, 29, 0, -27 }, +/* 0xC0 */ { 10700, 11, 34, 12, 0, -33 }, +/* 0xC1 */ { 10747, 23, 26, 24, 0, -25 }, +/* 0xC2 */ { 10822, 18, 26, 24, 3, -25 }, +/* 0xC3 */ { 10881, 15, 26, 19, 3, -25 }, +/* 0xC4 */ { 10930, 23, 26, 24, 0, -25 }, +/* 0xC5 */ { 11005, 16, 26, 22, 3, -25 }, +/* 0xC6 */ { 11057, 21, 26, 24, 2, -25 }, +/* 0xC7 */ { 11126, 19, 26, 26, 3, -25 }, +/* 0xC8 */ { 11188, 24, 26, 28, 2, -25 }, +/* 0xC9 */ { 11266, 3, 26, 10, 3, -25 }, +/* 0xCA */ { 11276, 20, 26, 23, 3, -25 }, +/* 0xCB */ { 11341, 23, 26, 24, 0, -25 }, +/* 0xCC */ { 11416, 23, 26, 30, 3, -25 }, +/* 0xCD */ { 11491, 19, 26, 26, 3, -25 }, +/* 0xCE */ { 11553, 16, 26, 22, 3, -25 }, +/* 0xCF */ { 11605, 24, 26, 28, 2, -25 }, +/* 0xD0 */ { 11683, 19, 26, 25, 3, -25 }, +/* 0xD1 */ { 11745, 16, 26, 21, 3, -25 }, +/* 0xD2 */ { 11797, 0, 0, 0, 0, 0 }, +/* 0xD3 */ { 11797, 16, 26, 22, 3, -25 }, +/* 0xD4 */ { 11849, 21, 26, 21, 0, -25 }, +/* 0xD5 */ { 11918, 21, 26, 21, 0, -25 }, +/* 0xD6 */ { 11987, 25, 26, 29, 2, -25 }, +/* 0xD7 */ { 12069, 22, 26, 24, 1, -25 }, +/* 0xD8 */ { 12141, 25, 26, 29, 2, -25 }, +/* 0xD9 */ { 12223, 24, 26, 28, 2, -25 }, +/* 0xDA */ { 12301, 11, 32, 10, -1, -31 }, +/* 0xDB */ { 12345, 21, 32, 21, 0, -31 }, +/* 0xDC */ { 12429, 19, 28, 23, 2, -27 }, +/* 0xDD */ { 12496, 15, 28, 19, 2, -27 }, +/* 0xDE */ { 12549, 16, 35, 22, 3, -27 }, +/* 0xDF */ { 12619, 9, 28, 12, 3, -27 }, +/* 0xE0 */ { 12651, 16, 35, 21, 3, -33 }, +/* 0xE1 */ { 12721, 19, 19, 23, 2, -18 }, +/* 0xE2 */ { 12767, 17, 34, 22, 3, -26 }, +/* 0xE3 */ { 12840, 19, 26, 21, 1, -18 }, +/* 0xE4 */ { 12902, 18, 26, 22, 2, -25 }, +/* 0xE5 */ { 12961, 15, 19, 19, 2, -18 }, +/* 0xE6 */ { 12997, 16, 34, 20, 2, -26 }, +/* 0xE7 */ { 13065, 16, 26, 22, 3, -18 }, +/* 0xE8 */ { 13117, 18, 27, 22, 2, -26 }, +/* 0xE9 */ { 13178, 8, 19, 12, 3, -18 }, +/* 0xEA */ { 13197, 17, 19, 21, 3, -18 }, +/* 0xEB */ { 13238, 19, 27, 21, 1, -26 }, +/* 0xEC */ { 13303, 18, 26, 22, 3, -18 }, +/* 0xED */ { 13362, 17, 19, 20, 1, -18 }, +/* 0xEE */ { 13403, 15, 34, 19, 2, -26 }, +/* 0xEF */ { 13467, 18, 19, 21, 2, -18 }, +/* 0xF0 */ { 13510, 18, 19, 21, 2, -18 }, +/* 0xF1 */ { 13553, 18, 26, 23, 3, -18 }, +/* 0xF2 */ { 13612, 15, 26, 20, 2, -18 }, +/* 0xF3 */ { 13661, 20, 19, 23, 2, -18 }, +/* 0xF4 */ { 13709, 17, 19, 21, 2, -18 }, +/* 0xF5 */ { 13750, 16, 19, 21, 3, -17 }, +/* 0xF6 */ { 13788, 19, 26, 23, 2, -18 }, +/* 0xF7 */ { 13850, 18, 26, 20, 1, -18 }, +/* 0xF8 */ { 13909, 19, 26, 23, 2, -18 }, +/* 0xF9 */ { 13971, 25, 19, 29, 2, -17 }, +/* 0xFA */ { 14031, 11, 27, 12, 0, -26 }, +/* 0xFB */ { 14069, 16, 28, 21, 3, -26 }, +/* 0xFC */ { 14125, 18, 28, 21, 2, -27 }, +/* 0xFD */ { 14188, 16, 29, 21, 3, -27 }, +/* 0xFE */ { 14246, 25, 29, 29, 2, -27 }, +/* 0xFF */ { 14337, 0, 0, 0, 0, 0 }, +}; + +const GFXfont FreeSans18pt_Win1253 PROGMEM = { +(uint8_t*)FreeSans18pt_Win1253Bitmaps, +(GFXglyph*)FreeSans18pt_Win1253Glyphs, +0x01, 0xFF, 41 +}; diff --git a/src/graphics/niche/Fonts/FreeSans24pt_Win1253.h b/src/graphics/niche/Fonts/FreeSans24pt_Win1253.h new file mode 100644 index 00000000000..7efefd443aa --- /dev/null +++ b/src/graphics/niche/Fonts/FreeSans24pt_Win1253.h @@ -0,0 +1,2429 @@ +// trunk-ignore-all(clang-format) +#pragma once +/* PROPERTIES + +FONT_NAME FreeSans24pt_Win1253 +*/ +const uint8_t FreeSans24pt_Win1253Bitmaps[] PROGMEM = { + 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x00, + 0x00, 0x0C, 0xE0, 0x00, 0x00, 0x00, 0x03, 0x0C, 0x00, 0x00, 0x00, 0x00, + 0x60, 0xC0, 0x00, 0x00, 0x00, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x01, 0x83, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x60, 0x00, 0x00, 0x00, 0x06, 0x0C, 0x00, + 0x00, 0x00, 0x00, 0xC1, 0x80, 0x00, 0x00, 0x00, 0x18, 0x30, 0x00, 0x00, + 0x00, 0x06, 0x06, 0x00, 0x00, 0x00, 0x01, 0xC0, 0xC0, 0x00, 0x00, 0x00, + 0x70, 0x30, 0x00, 0x00, 0x00, 0x1C, 0x06, 0x00, 0x00, 0x00, 0x07, 0x00, + 0xC0, 0x00, 0x00, 0x03, 0x80, 0x13, 0xFF, 0x00, 0x00, 0xE0, 0x07, 0xFF, + 0xF0, 0x00, 0x70, 0x00, 0xF0, 0x06, 0x00, 0x0C, 0x00, 0x38, 0x00, 0xC0, + 0x03, 0x00, 0x06, 0x00, 0x18, 0x00, 0x60, 0x00, 0xC0, 0x03, 0x01, 0xFC, + 0x00, 0x18, 0x00, 0x67, 0xFF, 0x00, 0x03, 0x80, 0x38, 0xC0, 0x00, 0x00, + 0x3F, 0xFF, 0xD8, 0x00, 0x00, 0x07, 0xFE, 0x1F, 0x00, 0x00, 0x01, 0xE0, + 0x01, 0xE0, 0x00, 0x00, 0x3C, 0x00, 0x1C, 0x00, 0x00, 0x0F, 0x00, 0x03, + 0x80, 0x00, 0x03, 0xA0, 0x00, 0xF0, 0x00, 0x00, 0xE6, 0x00, 0x1E, 0x00, + 0x00, 0x78, 0xF0, 0x1F, 0xC0, 0x00, 0x1C, 0x0F, 0xFF, 0xD8, 0x00, 0x02, + 0x01, 0xC0, 0x7B, 0x00, 0x00, 0x00, 0x60, 0x03, 0xE0, 0x00, 0x00, 0x0C, + 0x00, 0x3C, 0x00, 0x00, 0x01, 0x80, 0x07, 0x80, 0x00, 0x00, 0x30, 0x00, + 0xF0, 0x00, 0x00, 0x07, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x7F, 0x86, 0xFF, + 0x80, 0x00, 0x1F, 0xFF, 0x9F, 0xFE, 0x00, 0x03, 0x00, 0xC0, 0x01, 0xF0, + 0x00, 0xC0, 0x18, 0x00, 0x07, 0x00, 0x18, 0x03, 0x00, 0x00, 0x3C, 0x01, + 0x80, 0x60, 0x00, 0x03, 0xFC, 0x7E, 0x1C, 0x00, 0x00, 0x0F, 0xFF, 0xFF, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x01, 0x80, 0xF8, 0x00, + 0x00, 0x0F, 0xFF, 0xFF, 0x80, 0x00, 0x07, 0xE0, 0x78, 0x38, 0x00, 0x03, + 0xC0, 0x0C, 0x03, 0x00, 0x00, 0xE0, 0x03, 0x00, 0x60, 0x00, 0xF8, 0x00, + 0x60, 0x0C, 0x3F, 0xFC, 0x00, 0x06, 0x03, 0xC7, 0xF8, 0x00, 0x00, 0xFF, + 0xFC, 0xC0, 0x00, 0x00, 0x0F, 0xE0, 0xD8, 0x00, 0x00, 0x03, 0x80, 0x0F, + 0x00, 0x00, 0x00, 0x60, 0x01, 0xE0, 0x00, 0x00, 0x0C, 0x00, 0x3C, 0x00, + 0x00, 0x01, 0x80, 0x07, 0x80, 0x00, 0x00, 0x30, 0x01, 0xB0, 0x00, 0x04, + 0x03, 0xFF, 0xF6, 0x00, 0x00, 0xE0, 0x7F, 0xFE, 0xC0, 0x00, 0x0F, 0x1C, + 0x01, 0xF8, 0x00, 0x00, 0x73, 0x00, 0x0F, 0x00, 0x00, 0x07, 0xC0, 0x01, + 0xE0, 0x00, 0x00, 0x78, 0x00, 0x1C, 0x00, 0x00, 0x07, 0x80, 0x07, 0x80, + 0x00, 0x00, 0xF8, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0xFF, 0x3F, 0xC0, 0x00, + 0x01, 0xFF, 0xFE, 0xFF, 0xE0, 0x00, 0x70, 0x03, 0x80, 0x7E, 0x00, 0x0C, + 0x00, 0x30, 0x00, 0xC0, 0x01, 0x80, 0x06, 0x00, 0x18, 0x00, 0x30, 0x00, + 0xC0, 0x01, 0x80, 0x07, 0x00, 0x18, 0x00, 0x18, 0x00, 0x78, 0x03, 0x00, + 0x01, 0xC0, 0x0F, 0xFF, 0xC0, 0x00, 0x1E, 0x00, 0x8F, 0xF0, 0x00, 0x00, + 0xE0, 0x18, 0x00, 0x00, 0x00, 0x0E, 0x03, 0x00, 0x00, 0x00, 0x00, 0x60, + 0x60, 0x00, 0x00, 0x00, 0x0E, 0x06, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xC0, + 0x00, 0x00, 0x00, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x01, 0x83, 0x00, 0x00, + 0x00, 0x00, 0x30, 0x60, 0x00, 0x00, 0x00, 0x06, 0x0C, 0x00, 0x00, 0x00, + 0x00, 0xC1, 0x80, 0x00, 0x00, 0x00, 0x18, 0x30, 0x00, 0x00, 0x00, 0x03, + 0x06, 0x00, 0x00, 0x00, 0x00, 0x61, 0x80, 0x00, 0x00, 0x00, 0x06, 0x70, + 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, + 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, + 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF8, 0x00, + 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, + 0x00, 0xE0, 0x00, 0x00, 0x07, 0x00, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, + 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, 0x00, 0x00, 0x00, 0x00, 0x60, + 0x0E, 0x00, 0x00, 0x00, 0x00, 0x70, 0x1C, 0x00, 0x00, 0x00, 0x00, 0x30, + 0x18, 0x00, 0x00, 0x00, 0x00, 0x18, 0x38, 0x00, 0x00, 0x00, 0x00, 0x1C, + 0x30, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x70, 0x00, 0x00, 0x00, 0x00, 0x0C, + 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x03, 0xC0, 0x03, 0xC0, 0x06, + 0x60, 0x07, 0xE0, 0x07, 0xE0, 0x06, 0xC0, 0x0C, 0x30, 0x0C, 0x30, 0x03, + 0xC0, 0x18, 0x30, 0x0C, 0x38, 0x03, 0xC0, 0x18, 0x18, 0x18, 0x18, 0x03, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x0D, 0x00, 0x00, 0x4A, 0x03, + 0xC0, 0x69, 0x00, 0x00, 0xD2, 0x03, 0xC0, 0x4B, 0x00, 0x00, 0x96, 0x03, + 0xC0, 0xD2, 0x00, 0x01, 0xA4, 0x03, 0x60, 0x10, 0x00, 0x00, 0x20, 0x06, + 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, + 0x70, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x30, 0x00, 0xC0, 0x03, 0x00, 0x0C, + 0x38, 0x01, 0xF0, 0x0F, 0x80, 0x1C, 0x18, 0x00, 0x7F, 0xFE, 0x00, 0x18, + 0x1C, 0x00, 0x0F, 0xF0, 0x00, 0x30, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x70, + 0x06, 0x00, 0x00, 0x00, 0x00, 0x60, 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, + 0x03, 0x80, 0x00, 0x00, 0x01, 0x80, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x00, + 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, + 0x00, 0x1F, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, + 0x00, 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFC, + 0x00, 0x00, 0x00, 0x00, 0x0F, 0xC0, 0x0F, 0xC0, 0x00, 0x00, 0x00, 0x1F, + 0x00, 0x00, 0xF8, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x00, 0x1C, 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x18, 0x00, + 0x00, 0x00, 0x06, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x01, 0x80, 0x00, + 0x1C, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x1C, 0x1C, 0x00, 0x00, 0x38, + 0x38, 0x00, 0x0C, 0x3C, 0x00, 0x00, 0x0F, 0x0C, 0x00, 0x0E, 0x38, 0x00, + 0x00, 0x01, 0xC7, 0x00, 0x06, 0x38, 0x00, 0x00, 0x00, 0x71, 0x80, 0x07, + 0x18, 0x00, 0x00, 0x00, 0x18, 0xE0, 0x03, 0x08, 0x00, 0x00, 0x00, 0x04, + 0x30, 0x01, 0x80, 0x3E, 0x00, 0x07, 0xC0, 0x18, 0x00, 0xC0, 0x71, 0xC0, + 0x0C, 0x38, 0x0C, 0x00, 0xC0, 0x60, 0x30, 0x0C, 0x06, 0x03, 0x00, 0x60, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x80, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xC0, 0x1F, 0xC0, 0x00, 0x00, 0x00, 0x0F, 0xE0, 0x1F, 0x60, 0x00, 0x00, + 0x00, 0x06, 0xF8, 0x3C, 0x30, 0x00, 0x00, 0x00, 0x03, 0x0E, 0x38, 0x10, + 0x00, 0x00, 0x00, 0x00, 0x83, 0x98, 0x18, 0x00, 0x00, 0x00, 0x00, 0x40, + 0xD8, 0x0C, 0x60, 0x00, 0x00, 0x06, 0x30, 0x3C, 0x04, 0x6F, 0xC0, 0x00, + 0xFF, 0x98, 0x1E, 0x02, 0x30, 0x1F, 0xFF, 0xE0, 0xC4, 0x0F, 0x03, 0x1C, + 0x00, 0x00, 0x00, 0xE2, 0x07, 0x81, 0x07, 0xE0, 0x00, 0x07, 0xE1, 0x83, + 0x61, 0x83, 0xFF, 0xFF, 0xFF, 0xF0, 0x63, 0x1F, 0xC0, 0xFF, 0xFF, 0xFF, + 0xF0, 0x3F, 0x86, 0x30, 0x3F, 0xFF, 0xFF, 0xF0, 0x32, 0x00, 0x18, 0x0F, + 0xFF, 0xFF, 0xF0, 0x18, 0x00, 0x06, 0x03, 0xFC, 0x0F, 0xF0, 0x18, 0x00, + 0x03, 0x80, 0x78, 0x01, 0xE0, 0x1C, 0x00, 0x00, 0xE0, 0x0E, 0x01, 0xC0, + 0x1C, 0x00, 0x00, 0x30, 0x00, 0xFF, 0x00, 0x0C, 0x00, 0x00, 0x0E, 0x00, + 0x00, 0x00, 0x1C, 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0x1C, 0x00, 0x00, + 0x00, 0xE0, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, + 0x00, 0x00, 0x00, 0x07, 0x80, 0x00, 0x78, 0x00, 0x00, 0x00, 0x01, 0xF8, + 0x01, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0xE0, 0x00, 0x00, 0x00, + 0x00, 0x01, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x02, 0x0C, 0x00, 0x00, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x00, 0x1F, 0x03, 0x80, 0x00, 0x00, + 0x1F, 0x3F, 0x03, 0x00, 0x00, 0x00, 0x7F, 0xC3, 0x00, 0x07, 0x80, 0x00, + 0xC7, 0x83, 0x00, 0x1F, 0x80, 0x03, 0x07, 0x03, 0x00, 0x63, 0x00, 0x06, + 0x07, 0x03, 0x01, 0xC6, 0x00, 0x06, 0x07, 0x03, 0x03, 0x0C, 0x00, 0x06, + 0x07, 0x03, 0x06, 0x18, 0x00, 0x0E, 0x07, 0x03, 0x0C, 0x30, 0x00, 0x7E, + 0x07, 0x03, 0x18, 0x60, 0x00, 0xC6, 0x07, 0x03, 0x30, 0xC0, 0x03, 0x06, + 0x07, 0x03, 0x61, 0x80, 0x06, 0x06, 0x07, 0x03, 0xC1, 0x80, 0x0E, 0x06, + 0x07, 0x03, 0x83, 0x00, 0x0E, 0x06, 0x07, 0x03, 0x06, 0x00, 0x0E, 0x06, + 0x06, 0x06, 0x06, 0x00, 0x3E, 0x06, 0x00, 0x18, 0x0C, 0x00, 0xFE, 0x06, + 0x00, 0x30, 0x18, 0x03, 0x8E, 0x06, 0x00, 0x40, 0x18, 0x06, 0x0E, 0x06, + 0x01, 0x80, 0x30, 0x0C, 0x0E, 0x00, 0x03, 0x00, 0x30, 0x1C, 0x0E, 0x00, + 0x06, 0x00, 0x60, 0x1C, 0x0E, 0x00, 0x0C, 0x00, 0x60, 0x1C, 0x0E, 0x00, + 0x18, 0x00, 0xC0, 0x1C, 0x08, 0x00, 0x30, 0x01, 0x80, 0x1C, 0x00, 0x00, + 0x30, 0x03, 0x0C, 0x1C, 0x00, 0x00, 0x60, 0x02, 0x0C, 0x1C, 0x00, 0x00, + 0x60, 0x0F, 0x0E, 0x1C, 0x00, 0x00, 0xC0, 0x1B, 0x0C, 0x1C, 0x00, 0x00, + 0x00, 0x36, 0x00, 0x1C, 0x00, 0x00, 0x00, 0x67, 0x00, 0x1C, 0x00, 0x00, + 0x00, 0xC7, 0x80, 0x1C, 0x00, 0x00, 0x03, 0x07, 0x00, 0x1C, 0x00, 0x00, + 0x06, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00, 0x00, 0x1C, 0x00, 0x00, + 0x30, 0x00, 0x00, 0x0C, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x0E, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x0F, 0x00, 0x1C, 0x00, 0x00, 0x00, 0x07, 0x80, 0xF0, + 0x00, 0x00, 0x00, 0x03, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00, + 0x00, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x00, 0x00, 0x01, 0xB0, 0x00, 0x00, 0x01, 0x80, 0x06, 0x60, 0x00, 0x00, + 0x07, 0x80, 0x1C, 0xC0, 0x00, 0x00, 0x0F, 0xC0, 0x31, 0x80, 0x00, 0x00, + 0x19, 0xC0, 0xC3, 0x00, 0x00, 0x00, 0x11, 0xE1, 0x82, 0x00, 0x00, 0x00, + 0x30, 0xE6, 0x06, 0x00, 0x00, 0x00, 0x60, 0xFF, 0xCC, 0x00, 0x00, 0x00, + 0xC1, 0xFF, 0xF8, 0x3F, 0x80, 0x01, 0x8F, 0x80, 0xFF, 0xFF, 0x00, 0x01, + 0xBC, 0x00, 0x7E, 0x06, 0x00, 0x03, 0xE0, 0x00, 0x38, 0x18, 0x00, 0x07, + 0x80, 0x00, 0x38, 0x70, 0x00, 0x0E, 0x00, 0x00, 0x39, 0xC0, 0x01, 0xF8, + 0x00, 0x00, 0x33, 0x00, 0xFF, 0xF0, 0x00, 0x00, 0x7C, 0x03, 0xF0, 0xC0, + 0x00, 0x00, 0x78, 0x06, 0x03, 0x80, 0x00, 0x00, 0xE0, 0x0E, 0x06, 0x00, + 0x00, 0x00, 0xC0, 0x0F, 0x0C, 0x00, 0x00, 0x01, 0xE0, 0x07, 0x18, 0x00, + 0x00, 0x03, 0xE0, 0x07, 0xB0, 0x00, 0x00, 0x06, 0xF0, 0x03, 0xE0, 0x00, + 0x00, 0x0C, 0x70, 0x01, 0xC0, 0x00, 0x00, 0x18, 0x78, 0x01, 0x80, 0x00, + 0x00, 0x30, 0x38, 0x03, 0x80, 0x00, 0x00, 0xC0, 0x30, 0x0F, 0x00, 0x00, + 0x01, 0x8F, 0xE0, 0x3F, 0x00, 0x00, 0x07, 0xFF, 0x00, 0x66, 0x00, 0x00, + 0x0F, 0xC0, 0x01, 0x8E, 0x00, 0x00, 0x38, 0x00, 0x07, 0x0E, 0x00, 0x00, + 0xF0, 0x00, 0x0C, 0x0E, 0x00, 0x03, 0xE0, 0x00, 0x30, 0x3F, 0x00, 0x1E, + 0xC0, 0x00, 0x6F, 0xFF, 0x80, 0xF8, 0x80, 0x00, 0xFE, 0x0F, 0xFF, 0xC1, + 0x80, 0x00, 0xC0, 0x19, 0xFF, 0x83, 0x00, 0x00, 0x00, 0x30, 0x33, 0x86, + 0x00, 0x00, 0x00, 0x60, 0xE3, 0xCC, 0x00, 0x00, 0x00, 0x41, 0x81, 0xCC, + 0x00, 0x00, 0x00, 0xC6, 0x01, 0xF8, 0x00, 0x00, 0x01, 0x9C, 0x00, 0xF0, + 0x00, 0x00, 0x03, 0x30, 0x00, 0x00, 0x00, 0x00, 0x06, 0xC0, 0x00, 0x00, + 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x7F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x1F, 0x00, + 0x00, 0x00, 0x00, 0xE0, 0x01, 0xC0, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x70, + 0x00, 0x00, 0x00, 0xE0, 0x00, 0x1C, 0x00, 0x00, 0x00, 0x60, 0x00, 0x06, + 0x00, 0x00, 0x00, 0x60, 0x00, 0x01, 0x80, 0x00, 0x00, 0x30, 0x00, 0x00, + 0xC0, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x30, 0x00, 0x07, 0xFE, 0x00, 0x00, + 0x18, 0x00, 0x07, 0x83, 0xC0, 0x00, 0x0C, 0x00, 0x07, 0x00, 0x60, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x18, 0x00, 0x01, 0x00, 0x03, 0x80, 0x06, 0x00, + 0x01, 0x80, 0x01, 0x80, 0x03, 0x00, 0x00, 0xC0, 0x00, 0xC0, 0x01, 0x80, + 0x00, 0x70, 0x07, 0xE0, 0x00, 0x00, 0x00, 0x3E, 0x0F, 0xF0, 0x00, 0x00, + 0x00, 0x01, 0xCF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x66, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, + 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x01, 0x9E, 0x00, 0x00, + 0x00, 0x00, 0x03, 0xC7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, + 0x00, 0x60, 0x00, 0x60, 0x00, 0x70, 0x00, 0x70, 0x00, 0x70, 0x00, 0x78, + 0x00, 0x78, 0x00, 0x78, 0x00, 0x7C, 0x00, 0x7C, 0x00, 0x7C, 0x00, 0x3E, + 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x07, + 0x00, 0x07, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x00, 0xC0, 0x00, 0x00, + 0x00, 0x70, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x78, 0x00, 0xF0, 0x00, 0x00, + 0x00, 0x7C, 0x00, 0xF8, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x7C, 0x00, 0x00, + 0x00, 0x1E, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x07, 0x00, 0x0E, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x07, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF8, + 0x00, 0x00, 0x00, 0x00, 0x0F, 0x80, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x78, + 0x00, 0x70, 0x00, 0x00, 0x00, 0x03, 0x80, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x1C, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x03, 0x80, 0x00, + 0x00, 0x07, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x0C, + 0x00, 0x00, 0x0F, 0xE0, 0x00, 0x00, 0x30, 0x00, 0x00, 0xFF, 0xE0, 0x00, + 0x00, 0xE0, 0x00, 0x07, 0x83, 0xC0, 0x00, 0x01, 0x80, 0x00, 0x38, 0x03, + 0x80, 0x00, 0x06, 0x00, 0x01, 0xC0, 0x07, 0x00, 0x00, 0x18, 0x00, 0x06, + 0x00, 0x0C, 0x00, 0x00, 0x60, 0x00, 0x38, 0x00, 0x38, 0x00, 0x01, 0x80, + 0x00, 0xC0, 0x00, 0x60, 0x00, 0x06, 0x00, 0x03, 0x00, 0x01, 0x80, 0x00, + 0x1C, 0x00, 0x7C, 0x00, 0x00, 0x00, 0x00, 0x7C, 0x03, 0xF0, 0x00, 0x00, + 0x00, 0x00, 0x38, 0x3C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x31, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xB8, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x60, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x19, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x63, 0x80, + 0x00, 0x00, 0x00, 0x00, 0x03, 0x07, 0x81, 0x80, 0x00, 0x00, 0x00, 0x38, + 0x0F, 0xFF, 0x00, 0x00, 0x00, 0x0F, 0xC0, 0x07, 0xF7, 0x00, 0x60, 0x00, + 0x7C, 0x00, 0x00, 0x0F, 0xFF, 0xC0, 0x07, 0x00, 0x00, 0x00, 0x07, 0xF3, + 0xC0, 0x78, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0x03, 0xF8, 0x00, 0x00, 0x00, 0x7F, 0x80, 0x00, 0x1F, 0xE0, 0x00, + 0x1F, 0xFE, 0x00, 0x07, 0xFF, 0x80, 0x07, 0xCF, 0xF8, 0x01, 0xF3, 0xBE, + 0x00, 0xFC, 0xD9, 0xC0, 0x38, 0x3C, 0xF0, 0x1F, 0xEC, 0xFE, 0x07, 0xE3, + 0x6F, 0x83, 0xB7, 0xC7, 0xB0, 0xDF, 0x33, 0xD8, 0x33, 0x1C, 0x39, 0x99, + 0xBB, 0x1C, 0xC7, 0xF0, 0xC1, 0xD9, 0xD9, 0xF0, 0xCE, 0x6F, 0x0E, 0x1E, + 0xFF, 0x8F, 0x0E, 0x66, 0x70, 0xF1, 0xB6, 0x78, 0x30, 0xF6, 0xC3, 0x8D, + 0x99, 0xE1, 0x83, 0x8D, 0xBC, 0x3C, 0xCD, 0x8E, 0x1C, 0x3C, 0xCF, 0xE3, + 0x6C, 0x78, 0x61, 0xE3, 0x6C, 0x7F, 0x33, 0xC3, 0x87, 0x1B, 0x33, 0xC3, + 0xFB, 0x1C, 0x1C, 0x79, 0x9B, 0x1C, 0x3D, 0xF0, 0xC1, 0xE6, 0xD8, 0xF0, + 0xE3, 0xC7, 0x0E, 0x1F, 0x67, 0x87, 0x0F, 0x3C, 0x30, 0xF1, 0xBE, 0x38, + 0x30, 0xFB, 0x63, 0x8D, 0x98, 0xE1, 0xC3, 0x8D, 0xE6, 0x3C, 0xCF, 0x86, + 0x1E, 0x3E, 0xC6, 0x63, 0x6C, 0x78, 0x71, 0xF3, 0x7C, 0x63, 0x33, 0xC3, + 0x87, 0x99, 0xB3, 0xCC, 0x3B, 0x1C, 0x1C, 0x6D, 0x8F, 0x1C, 0xC1, 0xF0, + 0xE1, 0xE6, 0x78, 0x70, 0xF8, 0x1F, 0x0F, 0x1B, 0x63, 0x83, 0x0F, 0x80, + 0xF8, 0xF9, 0x9E, 0x1C, 0x38, 0xF0, 0x0F, 0xCD, 0xD8, 0xE1, 0xE3, 0xCF, + 0x00, 0x7E, 0xCF, 0x86, 0x1F, 0x36, 0xE0, 0x03, 0x3C, 0x38, 0x71, 0xBB, + 0x3C, 0x00, 0x39, 0xC1, 0x87, 0x99, 0xF1, 0xC0, 0x01, 0xCC, 0x1C, 0x6D, + 0x87, 0x38, 0x00, 0x0C, 0xE1, 0xE6, 0x78, 0x33, 0x00, 0x00, 0x6F, 0x1B, + 0x63, 0x83, 0xE0, 0x00, 0x03, 0xD9, 0x9E, 0x1C, 0x3C, 0x00, 0x00, 0x1C, + 0xF8, 0xE1, 0xE3, 0x80, 0x00, 0x00, 0xC7, 0x87, 0x1F, 0x30, 0x00, 0x00, + 0x06, 0x38, 0x79, 0x9E, 0x00, 0x00, 0x00, 0x31, 0xC7, 0xD8, 0xC0, 0x00, + 0x00, 0x01, 0x9E, 0x6F, 0x98, 0x00, 0x00, 0x00, 0x0D, 0xB6, 0x3B, 0x00, + 0x00, 0x00, 0x00, 0x79, 0xE1, 0xE0, 0x00, 0x00, 0x00, 0x03, 0x8E, 0x1C, + 0x00, 0x00, 0x00, 0x00, 0x0C, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, + 0x60, 0x00, 0x00, 0x00, 0x00, 0x03, 0x7C, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x1F, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7C, 0x00, 0x00, 0x00, 0x00, + 0x03, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x00, 0x00, + 0x3F, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xE0, 0x00, 0x00, 0x00, 0x01, + 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, + 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x0F, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x1F, + 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0x80, 0x00, 0x01, 0xFF, + 0xFF, 0xFF, 0x00, 0x00, 0x0F, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x3F, 0xFF, + 0xFF, 0xFC, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, + 0xFF, 0xF8, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x0F, 0xFF, 0xFF, + 0xFF, 0xF8, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x3F, 0xE3, 0xFF, + 0x1F, 0xF0, 0x00, 0x7F, 0x03, 0xF8, 0x0F, 0xE0, 0x00, 0xFC, 0x03, 0xE0, + 0x0F, 0xE0, 0x07, 0xF0, 0xC3, 0x87, 0x1F, 0xC0, 0x1F, 0xE3, 0xC7, 0x1F, + 0x1F, 0xE0, 0xFF, 0xC7, 0x8E, 0x3E, 0x3F, 0xE1, 0xFF, 0x8F, 0x1C, 0x7C, + 0x7F, 0xE7, 0xFF, 0x0C, 0x38, 0x71, 0xFF, 0xDF, 0xFF, 0x00, 0xF8, 0x03, + 0xFF, 0xFF, 0xFF, 0x03, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0x9F, 0xFC, 0x7F, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFB, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x03, 0xFF, + 0xF7, 0xFF, 0xE0, 0x00, 0x07, 0xFF, 0xEF, 0xFF, 0xC0, 0x00, 0x1F, 0xFF, + 0x8F, 0xFF, 0xC0, 0x00, 0x3F, 0xFF, 0x1F, 0xFF, 0xC0, 0x00, 0xFF, 0xFC, + 0x1F, 0xFF, 0xE0, 0x07, 0xFF, 0xF0, 0x1F, 0xFF, 0xF0, 0x3F, 0xFF, 0xE0, + 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, + 0x0F, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0x00, 0x00, + 0x01, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x3F, 0xFE, 0x00, 0x00, 0x00, + 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x18, 0xE0, 0x00, 0x00, 0x00, + 0x00, 0x20, 0xC0, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xC0, 0x00, 0x00, 0x00, + 0x01, 0x81, 0x80, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, + 0x06, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x06, 0x38, 0x00, 0x00, 0x00, 0x00, + 0x07, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xF8, 0x00, 0x00, 0x00, 0x03, + 0xC0, 0x3C, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x60, + 0x00, 0x0E, 0x00, 0x00, 0x01, 0x80, 0x00, 0x0E, 0x00, 0x00, 0x06, 0x04, + 0x00, 0x0C, 0x00, 0x00, 0x08, 0x38, 0x00, 0x0C, 0x00, 0x00, 0x30, 0xE0, + 0x00, 0x08, 0x00, 0x00, 0x43, 0x00, 0x00, 0x18, 0x00, 0x01, 0x86, 0x00, + 0x00, 0x10, 0x00, 0x03, 0x18, 0x00, 0x00, 0x30, 0x00, 0x04, 0x30, 0x00, + 0x00, 0x60, 0x00, 0x18, 0x40, 0x00, 0x00, 0xC0, 0x00, 0x30, 0x00, 0x00, + 0x00, 0x80, 0x00, 0x60, 0x00, 0x00, 0x01, 0x00, 0x00, 0xC0, 0x00, 0x00, + 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x00, 0x02, 0x00, 0x00, 0x00, + 0x0C, 0x00, 0x04, 0x00, 0x00, 0x00, 0x18, 0x00, 0x18, 0x00, 0x00, 0x00, + 0x30, 0x00, 0x30, 0x00, 0x00, 0x00, 0x20, 0x00, 0x60, 0x00, 0x00, 0x00, + 0x60, 0x00, 0x80, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x3F, 0xFF, 0xFE, 0x00, + 0x80, 0x07, 0xE0, 0x00, 0x00, 0x01, 0x80, 0x18, 0x00, 0x00, 0x00, 0x01, + 0x80, 0x60, 0x00, 0x00, 0x00, 0x03, 0x81, 0x80, 0x00, 0x00, 0x00, 0x03, + 0x86, 0x00, 0x00, 0x00, 0x00, 0x03, 0x18, 0x00, 0xFF, 0xFF, 0xF0, 0x03, + 0x30, 0xFF, 0xFF, 0xFF, 0xFF, 0xC3, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFB, + 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0xFF, 0xFB, + 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xF3, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0x81, + 0xFF, 0xFF, 0x87, 0xFF, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, + 0x03, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x70, 0x00, 0x00, 0x0E, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x00, 0x03, + 0x80, 0x00, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, + 0x00, 0x00, 0x00, 0x00, 0x60, 0x0E, 0x08, 0x00, 0x00, 0x30, 0x70, 0x1C, + 0x3C, 0x00, 0x00, 0x3C, 0x38, 0x18, 0xE0, 0x00, 0x00, 0x0F, 0x18, 0x39, + 0xC0, 0x00, 0x00, 0x03, 0x9C, 0x33, 0x80, 0x00, 0x00, 0x01, 0xCC, 0x73, + 0x00, 0x00, 0x00, 0x00, 0xCC, 0x60, 0x1F, 0x00, 0x00, 0xF8, 0x06, 0x60, + 0x7F, 0xC0, 0x03, 0xFE, 0x06, 0x60, 0xF3, 0xE0, 0x07, 0x8F, 0x06, 0xC0, + 0xF3, 0xA0, 0x07, 0x8F, 0x03, 0xC0, 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, + 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, + 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, + 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, 0x33, 0xBF, 0xFB, 0x8C, 0x03, 0xC0, + 0x33, 0xFC, 0x7F, 0x8C, 0x03, 0xC0, 0x33, 0xC0, 0x07, 0x8C, 0x03, 0x60, + 0x33, 0x80, 0x03, 0x8C, 0x06, 0x60, 0x33, 0x80, 0x03, 0x8C, 0x06, 0x60, + 0x33, 0x80, 0x03, 0x8C, 0x06, 0x30, 0x33, 0xFF, 0xFF, 0x8C, 0x0C, 0x30, + 0x33, 0xFF, 0xFF, 0x8C, 0x0C, 0x38, 0x33, 0xFF, 0xFF, 0x8C, 0x18, 0x18, + 0x33, 0xFF, 0xFF, 0x8C, 0x18, 0x0C, 0x33, 0xFF, 0xFF, 0x8C, 0x30, 0x0E, + 0x33, 0xF0, 0x1F, 0x8C, 0x70, 0x06, 0x33, 0x80, 0x03, 0x8C, 0x60, 0x03, + 0x33, 0x80, 0x03, 0x8C, 0xC0, 0x01, 0xB3, 0x80, 0x03, 0x8D, 0x80, 0x07, + 0xF3, 0x80, 0x03, 0x8F, 0xC0, 0x0E, 0x03, 0x80, 0x03, 0x80, 0xF0, 0x0C, + 0x01, 0xE0, 0x0F, 0x00, 0x30, 0x07, 0x00, 0x78, 0x1C, 0x00, 0xE0, 0x03, + 0x00, 0x18, 0x18, 0x00, 0xC0, 0x03, 0x80, 0x3F, 0xFC, 0x03, 0xC0, 0x01, + 0xFF, 0xF7, 0xEF, 0xFF, 0x80, 0x00, 0x7F, 0xC0, 0x03, 0xFE, 0x00, 0x00, + 0x00, 0x1D, 0xC0, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00, + 0x03, 0x33, 0x00, 0x00, 0x00, 0x00, 0x0C, 0xCC, 0x00, 0x00, 0x00, 0x00, + 0x3F, 0xF0, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x06, + 0x33, 0x00, 0x00, 0x00, 0x00, 0x18, 0xC4, 0x00, 0x00, 0x00, 0x00, 0x63, + 0x18, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x07, 0x7B, + 0x80, 0x00, 0x00, 0x00, 0x18, 0xC6, 0x00, 0x00, 0x00, 0x00, 0x43, 0x18, + 0x00, 0x00, 0x00, 0x03, 0x0C, 0x60, 0x00, 0x00, 0x00, 0x0C, 0x30, 0x80, + 0x00, 0x00, 0x00, 0x30, 0xC3, 0x00, 0x00, 0x00, 0x00, 0xC3, 0x0C, 0x00, + 0x00, 0x00, 0x03, 0x0C, 0x30, 0x00, 0x00, 0x00, 0x0C, 0x30, 0xC0, 0x00, + 0x00, 0x00, 0x60, 0xC3, 0x00, 0x00, 0x00, 0x01, 0x83, 0x06, 0x00, 0x00, + 0x00, 0x06, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x30, 0x30, 0x60, 0x00, 0x00, + 0x00, 0xC0, 0xC0, 0x80, 0x00, 0x00, 0x03, 0x03, 0x03, 0x00, 0x00, 0x00, + 0x0C, 0x0C, 0x0C, 0x00, 0x00, 0x00, 0x30, 0x30, 0x30, 0x00, 0x00, 0x01, + 0x80, 0xC0, 0xC0, 0x00, 0x00, 0x36, 0x03, 0x01, 0xA0, 0x00, 0x03, 0xF0, + 0x0C, 0x07, 0xF0, 0x00, 0x3F, 0x80, 0x30, 0x07, 0xF0, 0x03, 0xCC, 0x00, + 0xC0, 0x18, 0xF0, 0x7C, 0x18, 0x03, 0x00, 0x60, 0xF3, 0xC0, 0x60, 0x0C, + 0x01, 0x80, 0xE0, 0x01, 0xC0, 0x30, 0x0C, 0x00, 0x80, 0x03, 0x01, 0xE0, + 0x70, 0x00, 0x00, 0x06, 0x1F, 0xC1, 0x80, 0x00, 0x00, 0x1C, 0x63, 0x8C, + 0x00, 0x00, 0x00, 0x3B, 0x03, 0x70, 0x00, 0x00, 0x00, 0x7C, 0x0F, 0x80, + 0x00, 0x00, 0x00, 0xF0, 0x7C, 0x00, 0x00, 0x00, 0x01, 0xE1, 0xE0, 0x00, + 0x00, 0x00, 0x03, 0x8F, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x1C, 0x00, 0x00, + 0x00, 0x00, 0xE0, 0x38, 0x00, 0x00, 0x00, 0x07, 0x00, 0x38, 0x00, 0x00, + 0x00, 0x38, 0x00, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, + 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, + 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, + 0xE0, 0x00, 0x00, 0x07, 0x00, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, 0x03, + 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, 0x03, 0x00, 0x00, 0x00, 0x60, 0x0E, + 0x0F, 0x00, 0x00, 0x00, 0x70, 0x1C, 0x1C, 0x00, 0x00, 0x00, 0x30, 0x18, + 0x30, 0x00, 0x00, 0x00, 0x18, 0x38, 0x60, 0x00, 0x00, 0x00, 0x1C, 0x30, + 0x40, 0x00, 0x00, 0x18, 0x0C, 0x70, 0x03, 0xC0, 0x00, 0x78, 0x0C, 0x60, + 0x03, 0xC0, 0x00, 0xE0, 0x06, 0x60, 0x07, 0xE0, 0x03, 0xC0, 0x06, 0x60, + 0x07, 0xE0, 0x07, 0x00, 0x06, 0xC0, 0x07, 0xE0, 0x0F, 0xC0, 0x03, 0xC0, + 0x03, 0xC0, 0x07, 0xF8, 0x03, 0xC0, 0x03, 0xC0, 0x00, 0x38, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x1F, 0x83, 0xC0, 0x00, 0x00, 0xE0, 0x3F, 0xC3, 0xC0, + 0x00, 0x00, 0xF0, 0x3F, 0xC0, 0x60, 0x00, 0x00, 0x38, 0x3F, 0xFC, 0x60, + 0x00, 0x00, 0x18, 0x3F, 0xFE, 0x60, 0x00, 0x00, 0x38, 0x3F, 0xFF, 0x70, + 0x00, 0x00, 0x70, 0x3F, 0xFF, 0x30, 0x00, 0x00, 0x70, 0x1F, 0xFF, 0x38, + 0x00, 0x00, 0x18, 0x1F, 0xFF, 0x18, 0x00, 0x00, 0x18, 0x1F, 0xFE, 0x1C, + 0x00, 0x00, 0x38, 0x3F, 0xFC, 0x0E, 0x00, 0x00, 0xF0, 0x3F, 0xF8, 0x07, + 0x00, 0x00, 0xE0, 0x0F, 0xE0, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, + 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, + 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x38, 0x01, 0x80, 0x00, 0x00, + 0x07, 0xE0, 0x06, 0x00, 0x00, 0xC0, 0x3C, 0x00, 0x00, 0x38, 0x03, 0x01, + 0xC0, 0x18, 0x00, 0x60, 0x1C, 0x06, 0x00, 0x60, 0x00, 0xC0, 0x00, 0x38, + 0x00, 0xC0, 0x03, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x0E, 0x00, 0x03, 0x0E, + 0x00, 0x00, 0x19, 0x80, 0x0C, 0xFE, 0x00, 0x00, 0x67, 0x00, 0x37, 0x18, + 0x00, 0x01, 0x84, 0x00, 0xF8, 0x30, 0xF0, 0x06, 0x00, 0x01, 0xC0, 0xC7, + 0xF0, 0x18, 0x00, 0x03, 0x02, 0x38, 0xF0, 0xE0, 0x1C, 0x0F, 0xF8, 0xC0, + 0xF7, 0x00, 0x78, 0x3F, 0xC3, 0x00, 0xF8, 0x00, 0x40, 0x40, 0x0C, 0x00, + 0x00, 0x00, 0x1F, 0x80, 0x18, 0x00, 0x00, 0x01, 0xFF, 0x80, 0x60, 0x60, + 0x00, 0x0F, 0x1F, 0x80, 0xC1, 0x80, 0x00, 0x30, 0x67, 0x03, 0x06, 0x00, + 0x01, 0xC1, 0x8E, 0x38, 0x00, 0x00, 0x06, 0x06, 0x0F, 0xE0, 0x00, 0x00, + 0x18, 0x18, 0x3E, 0x00, 0x07, 0x00, 0xE0, 0xE3, 0xF0, 0x00, 0x3C, 0x03, + 0x83, 0x0C, 0xC1, 0x81, 0xC0, 0x1E, 0x18, 0x71, 0x87, 0x0C, 0x00, 0x78, + 0x61, 0x86, 0x08, 0x30, 0x01, 0xF0, 0x06, 0x18, 0x00, 0x80, 0x0C, 0xC0, + 0x00, 0x30, 0x06, 0x00, 0x33, 0x00, 0x00, 0xC0, 0x18, 0x01, 0xC6, 0x00, + 0x1F, 0xC0, 0x60, 0x07, 0x1C, 0x0F, 0xEF, 0x81, 0x00, 0x1C, 0x38, 0x3E, + 0x37, 0x8C, 0x00, 0xD8, 0x70, 0x00, 0xCF, 0xE0, 0x03, 0x30, 0xE0, 0x06, + 0x0F, 0x00, 0x18, 0xE1, 0xF0, 0x38, 0x00, 0x00, 0x61, 0xC1, 0xFF, 0xC0, + 0x00, 0x73, 0xC3, 0x81, 0xFC, 0x00, 0x01, 0xCF, 0x07, 0x87, 0xC0, 0x00, + 0x00, 0x36, 0x07, 0xFC, 0x00, 0x20, 0x01, 0x9C, 0x0F, 0x80, 0x00, 0xC0, + 0x06, 0x3C, 0xF8, 0x00, 0x03, 0x00, 0x30, 0x3F, 0x00, 0x00, 0x04, 0x0C, + 0xC1, 0xF0, 0x00, 0x00, 0x00, 0x3B, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x6F, + 0xE0, 0x00, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x01, 0xE0, 0x07, 0x80, 0x00, 0x00, + 0x07, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x38, 0x00, 0x00, + 0x30, 0x00, 0x00, 0x0C, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x03, 0x00, 0x01, + 0x80, 0x00, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, + 0x00, 0x00, 0x00, 0x00, 0x60, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x30, 0x0C, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x18, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, + 0x00, 0x00, 0x00, 0x00, 0x18, 0x30, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x30, + 0x03, 0xC0, 0x03, 0xC0, 0x0C, 0x60, 0x03, 0xC0, 0x03, 0xC0, 0x06, 0x60, + 0x07, 0xE0, 0x07, 0xE0, 0x06, 0x60, 0x07, 0xE0, 0x07, 0xE0, 0x06, 0xC0, + 0x07, 0xE0, 0x07, 0xE0, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, + 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, + 0x60, 0x00, 0x00, 0x06, 0x03, 0xC0, 0xDF, 0x80, 0x01, 0xFF, 0x03, 0x60, + 0xC0, 0x7F, 0xFF, 0x83, 0x06, 0x60, 0xE0, 0x00, 0x00, 0x07, 0x06, 0x60, + 0xFE, 0x00, 0x00, 0x7F, 0x06, 0x30, 0x7F, 0xFF, 0xFF, 0xFE, 0x0C, 0x30, + 0x3F, 0xFF, 0xFF, 0xFC, 0x0C, 0x38, 0x1F, 0xFF, 0xFF, 0xF8, 0x18, 0x18, + 0x0F, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xF8, 0x1F, 0xE0, 0x30, 0x0C, + 0x01, 0xE0, 0x07, 0x80, 0x30, 0x06, 0x00, 0x70, 0x0E, 0x00, 0x60, 0x03, + 0x00, 0x0F, 0xF0, 0x00, 0xC0, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, 0x00, + 0xC0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, + 0x1C, 0x00, 0x00, 0x38, 0x00, 0x00, 0x07, 0x00, 0x00, 0xE0, 0x00, 0x00, + 0x01, 0xE0, 0x07, 0x80, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, + 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, + 0xE0, 0x00, 0x00, 0x07, 0x00, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, 0x03, + 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, 0x00, 0x00, 0x00, 0x00, 0x60, 0x0E, + 0x00, 0x00, 0x00, 0x00, 0x70, 0x1C, 0x03, 0x00, 0x01, 0x80, 0x30, 0x18, + 0x07, 0x00, 0x00, 0xE0, 0x18, 0x38, 0x3C, 0x00, 0x00, 0x7C, 0x1C, 0x30, + 0xF8, 0x00, 0x00, 0x1F, 0x0C, 0x70, 0x40, 0x00, 0x00, 0x02, 0x0C, 0x60, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, + 0x0F, 0xC0, 0x03, 0xF0, 0x06, 0xC0, 0x11, 0xE0, 0x06, 0x38, 0x03, 0xC0, + 0x60, 0xF8, 0x18, 0x1E, 0x03, 0xC0, 0x40, 0xF8, 0x10, 0x1E, 0x03, 0xC0, + 0xC0, 0xFC, 0x30, 0x1F, 0x03, 0xC0, 0xC1, 0xFC, 0x30, 0x3F, 0x03, 0xC0, + 0xE3, 0xFC, 0x38, 0xFF, 0x03, 0xC0, 0xFF, 0x3C, 0x3F, 0xCF, 0x03, 0xC0, + 0xFF, 0x3C, 0x3F, 0xCF, 0x03, 0xC0, 0x7F, 0xF8, 0x1F, 0xFE, 0x03, 0xC0, + 0x7F, 0xF8, 0x1F, 0xFE, 0x03, 0x60, 0x3F, 0xF0, 0x0F, 0xFC, 0x06, 0x60, + 0x0F, 0xC0, 0x03, 0xF0, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x70, + 0x00, 0x00, 0x00, 0x00, 0x0C, 0x30, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x38, + 0x00, 0x00, 0x00, 0x00, 0x1C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x18, 0x1C, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x0E, 0x00, 0x07, 0xE0, 0x00, 0x70, 0x06, + 0x00, 0x1F, 0xF0, 0x00, 0x60, 0x03, 0x00, 0x08, 0x10, 0x00, 0xC0, 0x03, + 0x80, 0x00, 0x00, 0x01, 0x80, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x00, 0x00, + 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, + 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xE0, 0x00, 0x00, + 0x00, 0x1F, 0x80, 0x1F, 0x00, 0x60, 0x00, 0x0F, 0x80, 0x00, 0x78, 0x0E, + 0x00, 0x03, 0xC0, 0x00, 0x03, 0xC3, 0xC0, 0x00, 0xE0, 0x00, 0x00, 0x1C, + 0xCC, 0x00, 0x38, 0x00, 0x00, 0x01, 0xF8, 0xC0, 0x1C, 0x00, 0x00, 0x00, + 0x1E, 0x1C, 0x03, 0x00, 0x00, 0x00, 0x01, 0x81, 0x80, 0xC0, 0x00, 0x00, + 0x00, 0x70, 0x38, 0x38, 0x00, 0x00, 0x00, 0x0C, 0x03, 0x0E, 0x00, 0x00, + 0x00, 0x03, 0x80, 0x71, 0x80, 0x00, 0x00, 0x00, 0x60, 0x06, 0x70, 0x00, + 0x00, 0x00, 0x0C, 0x00, 0xCC, 0x00, 0x00, 0x00, 0x01, 0x80, 0x1B, 0x80, + 0x00, 0x00, 0x00, 0x30, 0x03, 0x60, 0x00, 0x00, 0x00, 0x06, 0x00, 0x6C, + 0x00, 0x00, 0x00, 0x20, 0x60, 0x19, 0x80, 0x1F, 0xC0, 0x3F, 0x86, 0x06, + 0x60, 0x06, 0x1C, 0x0E, 0x38, 0x3F, 0x8C, 0x00, 0x81, 0x81, 0x01, 0x00, + 0x31, 0x80, 0x00, 0x00, 0x00, 0x00, 0x06, 0x30, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xC6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0x03, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x63, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0C, 0x60, 0x30, 0x00, 0x00, 0x07, 0x01, 0x8C, 0x0D, 0xF8, + 0x00, 0x1F, 0xE0, 0x70, 0xC1, 0x80, 0xFF, 0xFE, 0x06, 0x0C, 0x18, 0x38, + 0x00, 0x00, 0x01, 0x81, 0x83, 0x07, 0xF0, 0x00, 0x03, 0xF0, 0x30, 0x70, + 0x7F, 0xFF, 0xFF, 0xFC, 0x0C, 0x06, 0x07, 0xFF, 0xFF, 0xFF, 0x81, 0x80, + 0xE0, 0x7F, 0xFF, 0xFF, 0xE0, 0x70, 0x0C, 0x07, 0xFF, 0xFF, 0xF8, 0x0C, + 0x01, 0xC0, 0x7F, 0x81, 0xFE, 0x03, 0x00, 0x1C, 0x07, 0xC0, 0x0F, 0x00, + 0xE0, 0x01, 0x80, 0x1C, 0x03, 0x80, 0x38, 0x00, 0x18, 0x00, 0xFF, 0x80, + 0x0E, 0x00, 0x01, 0x80, 0x00, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, + 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x38, 0x00, 0x00, 0x1E, 0x00, + 0x00, 0x1E, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x0F, + 0xC0, 0x0F, 0xC0, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x00, 0x0F, + 0xE0, 0x00, 0x00, 0x00, 0x7F, 0x80, 0x00, 0x00, 0x01, 0xFE, 0x00, 0x00, + 0x00, 0x0F, 0xF8, 0x00, 0x00, 0x00, 0x7F, 0xE0, 0x00, 0x00, 0x03, 0xFF, + 0x00, 0x00, 0x00, 0x1F, 0xFC, 0x00, 0x00, 0x00, 0xFF, 0xE0, 0x00, 0x00, + 0x07, 0xFF, 0x00, 0x00, 0x00, 0x7F, 0xF8, 0x00, 0x00, 0x03, 0xFF, 0xE0, + 0x00, 0x00, 0x3F, 0xFF, 0x00, 0x00, 0x01, 0xFF, 0xF8, 0x00, 0x08, 0x1F, + 0xFF, 0xC0, 0x00, 0xC1, 0xFF, 0xBE, 0x00, 0x0C, 0x1F, 0xF3, 0xF0, 0x00, + 0xE0, 0xFF, 0x1F, 0x80, 0x0F, 0x0F, 0xF1, 0xF8, 0x00, 0x78, 0x7F, 0x0F, + 0xC0, 0x07, 0xE7, 0xF0, 0x7E, 0x00, 0x3F, 0x3F, 0x03, 0xF0, 0x01, 0xF9, + 0xF0, 0x1F, 0x81, 0x1F, 0xEF, 0x80, 0xFE, 0x08, 0xFF, 0xF8, 0x07, 0xFD, + 0xE7, 0xFF, 0xC0, 0x3F, 0xFF, 0xBF, 0xFE, 0x00, 0xFF, 0xFD, 0xFF, 0xF0, + 0x07, 0xFF, 0xEF, 0xFF, 0x80, 0x1F, 0xFF, 0x7F, 0xFC, 0x00, 0x7F, 0xFF, + 0xFF, 0xF0, 0x01, 0xFF, 0xFF, 0xFB, 0x80, 0x07, 0xFF, 0xFF, 0xCE, 0x00, + 0x1F, 0xFB, 0xFC, 0x20, 0x00, 0xFF, 0x9F, 0xE0, 0x00, 0x03, 0xFC, 0xFF, + 0x00, 0x00, 0x1F, 0xE3, 0xF8, 0x00, 0x00, 0xFF, 0x1F, 0xC0, 0x00, 0x07, + 0xF0, 0x7E, 0x00, 0x00, 0x3F, 0x83, 0xF0, 0x00, 0x01, 0xF8, 0x0F, 0xC0, + 0x00, 0x1F, 0x80, 0x3E, 0x00, 0x00, 0xF8, 0x00, 0xF8, 0x00, 0x0F, 0x80, + 0x03, 0xF0, 0x01, 0xF8, 0x00, 0x07, 0xE0, 0x3F, 0x00, 0x00, 0x0F, 0xFF, + 0xE0, 0x00, 0x00, 0x0F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF8, 0x00, + 0x00, 0x00, 0x00, 0xFC, 0x1F, 0xC0, 0x00, 0x00, 0x00, 0x70, 0x00, 0x38, + 0x00, 0x00, 0x00, 0x70, 0x00, 0x07, 0x00, 0x00, 0x00, 0x38, 0x00, 0x00, + 0x60, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x06, 0x00, 0x00, + 0x01, 0x80, 0x00, 0x03, 0x00, 0x10, 0x00, 0x30, 0x00, 0x01, 0x80, 0x3F, + 0x80, 0x0E, 0x00, 0x00, 0x60, 0x1C, 0x70, 0x01, 0x80, 0x00, 0x30, 0x0E, + 0x06, 0x00, 0x30, 0x00, 0x18, 0x03, 0x00, 0xC0, 0x0E, 0x00, 0x06, 0x00, + 0x80, 0x37, 0x81, 0x80, 0x03, 0x00, 0x60, 0x07, 0x60, 0x30, 0x00, 0xC1, + 0xF8, 0x01, 0x98, 0x0C, 0x00, 0x60, 0x1E, 0x00, 0x46, 0x01, 0x80, 0x18, + 0x00, 0x80, 0x01, 0x80, 0x30, 0x0C, 0x00, 0x30, 0x00, 0xC0, 0x0E, 0x03, + 0x00, 0x0C, 0x00, 0x30, 0x01, 0x81, 0x80, 0x01, 0x80, 0x0C, 0x00, 0x30, + 0x60, 0x00, 0x70, 0x03, 0x00, 0x06, 0x18, 0x00, 0x0E, 0x00, 0x60, 0x01, + 0x8C, 0x00, 0x0F, 0xC0, 0x18, 0x00, 0x33, 0x00, 0x0F, 0xF0, 0x03, 0x00, + 0x0C, 0xC0, 0x01, 0x06, 0x00, 0x60, 0x01, 0xB0, 0x00, 0x01, 0x80, 0x0C, + 0x00, 0x6C, 0x00, 0x00, 0x20, 0x01, 0xC0, 0x0B, 0x00, 0x00, 0x0C, 0x00, + 0x38, 0x03, 0xC0, 0x00, 0x01, 0x80, 0x06, 0x00, 0xF0, 0x00, 0x00, 0x60, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x0D, 0x80, 0x00, 0x03, + 0x80, 0x00, 0x03, 0x60, 0x00, 0x00, 0x70, 0x00, 0x00, 0x9C, 0x00, 0x00, + 0x0E, 0x00, 0x00, 0x63, 0x80, 0x00, 0x01, 0xC0, 0x00, 0x18, 0x70, 0x00, + 0x00, 0x38, 0x00, 0x0C, 0x0F, 0x00, 0x00, 0x07, 0x00, 0x07, 0x00, 0xF8, + 0x00, 0x00, 0x70, 0x03, 0x80, 0x0F, 0xFF, 0x7F, 0xFF, 0xFF, 0xC0, 0x00, + 0x7F, 0xFF, 0xF0, 0xFF, 0xC0, 0x00, 0x00, 0x03, 0xFF, 0xC0, 0x00, 0x00, + 0x00, 0x00, 0x0F, 0xC3, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x38, + 0x00, 0x00, 0x00, 0x00, 0x70, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0xE0, + 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0xC0, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x01, 0x80, 0x00, 0x01, 0x80, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0xC0, + 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x70, 0x0C, 0x00, 0x00, + 0x00, 0x60, 0x1C, 0xFC, 0x0C, 0x00, 0x00, 0x00, 0x30, 0x3E, 0xC6, 0x18, + 0x04, 0x00, 0x60, 0x18, 0x63, 0xC6, 0x38, 0x0E, 0x00, 0xF0, 0x18, 0xC3, + 0xC3, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x83, 0xC1, 0xF0, 0x0F, 0x00, 0xF0, + 0x0F, 0x83, 0x60, 0x30, 0x0E, 0x00, 0x60, 0x18, 0x06, 0x60, 0x18, 0x00, + 0x00, 0x00, 0x18, 0x0E, 0x30, 0x0C, 0x00, 0x00, 0x00, 0x30, 0x0C, 0x38, + 0x0C, 0x00, 0x38, 0x00, 0x60, 0x18, 0x1C, 0x06, 0x00, 0x3C, 0x00, 0x60, + 0x30, 0x0C, 0x06, 0x00, 0x7C, 0x00, 0xC0, 0x70, 0x06, 0x03, 0x00, 0x38, + 0x00, 0xC0, 0x60, 0x07, 0x03, 0x00, 0x00, 0x01, 0x80, 0xE0, 0x07, 0x01, + 0x80, 0x00, 0x01, 0x81, 0xE0, 0x0D, 0x81, 0x80, 0x00, 0x01, 0x81, 0xA0, + 0x0D, 0x81, 0x80, 0x00, 0x03, 0x03, 0x30, 0x08, 0xC0, 0xC0, 0x00, 0x03, + 0x03, 0x30, 0x18, 0xC0, 0xC0, 0x00, 0x03, 0x03, 0x10, 0x18, 0xC0, 0xC0, + 0x00, 0x03, 0x02, 0x10, 0x18, 0xC0, 0xC0, 0x00, 0x02, 0x02, 0x18, 0x18, + 0x00, 0xC0, 0x00, 0x02, 0x00, 0x18, 0x18, 0x00, 0xC0, 0x00, 0x06, 0x00, + 0x18, 0x18, 0x00, 0xC0, 0x00, 0x02, 0x00, 0x18, 0x18, 0x00, 0xC0, 0x00, + 0x02, 0x00, 0x18, 0x18, 0x00, 0xC0, 0x00, 0x03, 0x00, 0x10, 0x08, 0x00, + 0xC0, 0x00, 0x03, 0x00, 0x30, 0x0C, 0x01, 0xFF, 0xFF, 0xFF, 0x00, 0x70, + 0x06, 0x03, 0xFF, 0xFF, 0xFF, 0x80, 0xE0, 0x03, 0xFF, 0x00, 0x00, 0x00, + 0xFF, 0xC0, 0x01, 0xFE, 0x00, 0x00, 0x00, 0x7F, 0x80, 0x00, 0x00, 0x1F, + 0xF8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, 0xE0, + 0x07, 0xE0, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x3C, 0x00, + 0x00, 0x3C, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, 0xE0, 0x00, + 0x00, 0x07, 0x00, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, + 0x00, 0x00, 0xC0, 0x06, 0x00, 0x00, 0x00, 0x00, 0x60, 0x0E, 0x00, 0x00, + 0x00, 0x00, 0x70, 0x1C, 0x00, 0x00, 0x00, 0x00, 0x30, 0x18, 0x00, 0x00, + 0x00, 0x00, 0x18, 0x38, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x30, 0x00, 0x00, + 0x00, 0x00, 0x0C, 0x70, 0x0F, 0xC0, 0x03, 0xF0, 0x0C, 0x60, 0x3F, 0xF0, + 0x0F, 0xFC, 0x06, 0x60, 0x6F, 0xD8, 0x1B, 0xF6, 0x06, 0x60, 0x6F, 0xD8, + 0x1B, 0xF6, 0x06, 0xC0, 0xC7, 0x8C, 0x31, 0xE3, 0x03, 0xC0, 0xC0, 0x0C, + 0x30, 0x03, 0x03, 0xC0, 0xC0, 0x0C, 0x30, 0x03, 0x03, 0xC0, 0xC0, 0x0C, + 0x30, 0x03, 0x03, 0xC0, 0xE0, 0x1C, 0x38, 0x07, 0x03, 0xC0, 0x60, 0x18, + 0x18, 0x06, 0x03, 0xC0, 0x38, 0x70, 0x0E, 0x1C, 0x03, 0xC0, 0x1F, 0xE0, + 0x07, 0xF8, 0x03, 0xC0, 0x0F, 0xC0, 0x03, 0xF0, 0x03, 0xC0, 0x00, 0x00, + 0x00, 0x00, 0x03, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, + 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x70, 0x00, 0x00, + 0x00, 0x00, 0x0C, 0x30, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x38, 0x00, 0x00, + 0x00, 0x00, 0x1C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x18, 0x1C, 0x00, 0x00, + 0x00, 0x00, 0x30, 0x0E, 0x00, 0x3F, 0xFC, 0x00, 0x70, 0x06, 0x00, 0x3F, + 0xFC, 0x00, 0x60, 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x80, 0x00, + 0x00, 0x01, 0x80, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x00, 0x00, 0x70, 0x00, + 0x00, 0x0E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x1F, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x01, 0xFF, + 0xFF, 0x80, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x01, + 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xFF, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0x7E, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x0F, 0x80, + 0x00, 0x00, 0x03, 0xC0, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x07, 0x00, 0x00, + 0x00, 0xE0, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x38, + 0x00, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x0C, 0x00, + 0x00, 0x60, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x07, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x78, 0x03, 0x80, 0x01, 0x80, 0x00, + 0x01, 0x8C, 0x01, 0x80, 0x03, 0x80, 0x00, 0x01, 0x06, 0x01, 0xC0, 0x03, + 0x00, 0x7C, 0x03, 0x00, 0x00, 0xC0, 0x03, 0x00, 0xC7, 0x03, 0x00, 0x00, + 0xC0, 0x06, 0x01, 0x83, 0x00, 0x00, 0x00, 0x60, 0x06, 0x01, 0x80, 0x00, + 0x00, 0x00, 0x60, 0x06, 0x01, 0x80, 0x00, 0x00, 0x60, 0x60, 0x0C, 0x00, + 0x00, 0x00, 0x00, 0x60, 0x70, 0x0C, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x30, + 0x0C, 0x00, 0x00, 0x00, 0x01, 0x80, 0x30, 0x0C, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x30, 0x0C, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x30, 0x0C, 0x02, 0x00, + 0x00, 0x1C, 0x00, 0x30, 0x0C, 0x03, 0xC0, 0x00, 0x70, 0x00, 0x30, 0x0C, + 0x00, 0xF8, 0x03, 0xC0, 0x00, 0x30, 0x0C, 0x00, 0x1F, 0xFF, 0x00, 0x00, + 0x30, 0x0D, 0xC0, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x1E, 0x60, 0x00, 0x00, + 0x00, 0x04, 0xF8, 0x3E, 0x31, 0xC0, 0x00, 0x03, 0x88, 0x7C, 0x22, 0x1A, + 0x60, 0x00, 0x06, 0x58, 0xC4, 0x23, 0x0E, 0x30, 0x00, 0x0C, 0x70, 0xC4, + 0x31, 0x8C, 0x30, 0x00, 0x0C, 0x61, 0x8C, 0x70, 0x84, 0x30, 0x00, 0x0C, + 0x63, 0x0E, 0xC8, 0xC2, 0x30, 0x00, 0x0C, 0x42, 0x1B, 0xC4, 0x60, 0x18, + 0x00, 0x18, 0x04, 0x23, 0xE6, 0x20, 0x18, 0x00, 0x18, 0x04, 0x47, 0x63, + 0x00, 0x18, 0x00, 0x18, 0x00, 0x86, 0x71, 0x80, 0x0C, 0x00, 0x30, 0x01, + 0x0E, 0xCC, 0x00, 0x0C, 0x00, 0x30, 0x00, 0x3B, 0xC6, 0x00, 0x0C, 0x00, + 0x30, 0x00, 0x63, 0xC2, 0x00, 0x0C, 0x00, 0x30, 0x00, 0x43, 0x60, 0x00, + 0x0C, 0x00, 0x30, 0x00, 0x06, 0x30, 0x00, 0x18, 0x00, 0x18, 0x00, 0x0C, + 0x1C, 0x00, 0x18, 0x00, 0x18, 0x00, 0x38, 0x07, 0x00, 0x3F, 0xC3, 0xFC, + 0x00, 0xE0, 0x01, 0xC0, 0xE7, 0xFF, 0xE7, 0x03, 0x80, 0x00, 0x3F, 0x80, + 0x00, 0x01, 0xFC, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x1F, + 0x00, 0x00, 0xF8, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x70, + 0x00, 0x00, 0x0E, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x07, 0x00, 0x01, 0x80, + 0x00, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, 0x00, + 0x00, 0x00, 0x00, 0x60, 0x0E, 0x03, 0x80, 0x00, 0x00, 0x70, 0x1C, 0x0F, + 0x80, 0x00, 0x00, 0x30, 0x18, 0x1C, 0x00, 0x00, 0x00, 0x18, 0x38, 0x38, + 0x00, 0x00, 0x00, 0x1C, 0x30, 0x70, 0x00, 0x00, 0x00, 0x0C, 0x70, 0x60, + 0x00, 0x00, 0x00, 0x0C, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, + 0x00, 0x00, 0x18, 0x06, 0x60, 0x03, 0xC0, 0x00, 0x78, 0x06, 0xC0, 0x03, + 0xC0, 0x00, 0xE0, 0x03, 0xC0, 0x07, 0xE0, 0x03, 0xC0, 0x03, 0xC0, 0x07, + 0xE0, 0x07, 0x00, 0x03, 0xC0, 0x07, 0xE0, 0x0F, 0xC0, 0x03, 0xC0, 0x03, + 0xC0, 0x07, 0xF8, 0x03, 0xC0, 0x03, 0x80, 0x00, 0x38, 0x03, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0x60, 0x00, + 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x03, + 0x00, 0x00, 0x40, 0x06, 0x70, 0x03, 0xC0, 0x01, 0xC0, 0x0C, 0x30, 0x01, + 0xF0, 0x0F, 0x80, 0x0C, 0x38, 0x00, 0x7F, 0xFE, 0x00, 0x1C, 0x18, 0x00, + 0x0F, 0xF0, 0x00, 0x18, 0x1C, 0x00, 0x00, 0x00, 0x00, 0x30, 0x0E, 0x00, + 0x00, 0x00, 0x00, 0x70, 0x06, 0x00, 0x00, 0x00, 0x00, 0x60, 0x03, 0x00, + 0x00, 0x00, 0x00, 0xC0, 0x03, 0x80, 0x00, 0x00, 0x01, 0x80, 0x01, 0xE0, + 0x00, 0x00, 0x07, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x3C, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, + 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, + 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x7F, 0xEF, 0xF8, 0x00, 0x00, 0x00, + 0xFB, 0xC0, 0x1F, 0x00, 0x00, 0x00, 0xF0, 0x70, 0x03, 0xC0, 0x00, 0x01, + 0xE0, 0x08, 0x00, 0x78, 0x00, 0x01, 0xC0, 0x00, 0x3F, 0x8E, 0x00, 0x01, + 0xC1, 0xE0, 0x1F, 0xF3, 0x80, 0x01, 0xC0, 0xF0, 0x00, 0x3C, 0xE0, 0x01, + 0xC0, 0xFC, 0x00, 0x00, 0x38, 0x01, 0xC0, 0x7E, 0x00, 0x00, 0x0E, 0x00, + 0xC0, 0x3F, 0x00, 0x78, 0x03, 0x00, 0xE0, 0x1F, 0x00, 0x3C, 0x01, 0xC0, + 0x60, 0x07, 0x80, 0x3F, 0x00, 0x60, 0x60, 0x00, 0x00, 0x1F, 0x80, 0x18, + 0x30, 0x00, 0x00, 0x0F, 0xC0, 0x0C, 0x38, 0x00, 0x00, 0x03, 0xC0, 0x03, + 0x18, 0x00, 0x00, 0x01, 0xE0, 0x01, 0x8C, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xCE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x76, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x1B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0D, 0x80, 0x00, 0x7F, 0x00, 0x00, + 0x06, 0xC0, 0x01, 0xFF, 0xE0, 0x00, 0x03, 0x60, 0x00, 0xE0, 0x3C, 0x00, + 0x01, 0xB0, 0x00, 0x00, 0x07, 0x00, 0x00, 0xD8, 0x00, 0x00, 0x01, 0x80, + 0x00, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x37, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0x1E, 0x30, 0x01, 0xFC, 0x00, 0x00, 0x1B, 0x0C, 0x07, 0x03, 0x00, + 0x00, 0x0D, 0x86, 0x0C, 0x01, 0x80, 0x00, 0x06, 0x61, 0x98, 0x03, 0xC0, + 0x00, 0x03, 0x30, 0x70, 0x0F, 0xC0, 0x00, 0x03, 0x08, 0x00, 0x1F, 0x80, + 0x00, 0x01, 0x86, 0x00, 0x1E, 0x00, 0x00, 0x01, 0x83, 0x00, 0x3E, 0x00, + 0x00, 0x01, 0xC1, 0x80, 0x1B, 0x00, 0x00, 0x00, 0xC0, 0xC0, 0x08, 0x80, + 0x00, 0x00, 0xC0, 0x60, 0x00, 0x40, 0x00, 0x00, 0xE0, 0x30, 0x00, 0xF0, + 0x00, 0x00, 0xE0, 0x18, 0x00, 0xD8, 0x00, 0x00, 0xE0, 0x0C, 0x00, 0x0C, + 0x00, 0x00, 0xE0, 0x06, 0x00, 0x0C, 0x00, 0x01, 0xE0, 0x01, 0x00, 0x0F, + 0x00, 0x03, 0xC0, 0x00, 0xC0, 0x01, 0x80, 0x07, 0x80, 0x00, 0x30, 0x01, + 0xFE, 0xFF, 0x00, 0x00, 0x0E, 0x03, 0xBF, 0xFC, 0x00, 0x00, 0x01, 0xFE, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF7, 0xF0, 0x00, 0x00, 0x00, + 0x03, 0x9C, 0xC8, 0x00, 0x00, 0x00, 0x0E, 0x19, 0x8E, 0x00, 0x00, 0x00, + 0x78, 0x33, 0x9F, 0xC0, 0x00, 0x03, 0xC0, 0x67, 0x3D, 0xF0, 0x00, 0x0E, + 0x01, 0xCC, 0x6C, 0x3C, 0x00, 0x18, 0x07, 0x38, 0xC8, 0x0E, 0x00, 0x30, + 0x04, 0x63, 0x10, 0x03, 0x80, 0x60, 0x00, 0xC6, 0x60, 0x01, 0xC0, 0x60, + 0x00, 0x18, 0xC0, 0x00, 0xE0, 0xC0, 0x00, 0x13, 0x00, 0x00, 0x60, 0xC0, + 0x00, 0x0F, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x1B, 0x00, 0x00, 0x38, 0xC0, + 0x3F, 0x63, 0x00, 0x00, 0x18, 0xC0, 0x60, 0x8E, 0x00, 0x00, 0x0C, 0xC0, + 0x00, 0x1C, 0x00, 0x00, 0x0C, 0x60, 0x00, 0x78, 0x00, 0x00, 0x06, 0x70, + 0x00, 0xE0, 0x01, 0xC0, 0x06, 0x38, 0x03, 0xE0, 0x03, 0xE0, 0x06, 0x1C, + 0x1F, 0xF0, 0x03, 0xF0, 0x07, 0x1F, 0xFB, 0xF0, 0x03, 0xF0, 0x03, 0x30, + 0x83, 0xF0, 0x03, 0xF0, 0x03, 0x30, 0x01, 0xE0, 0x03, 0xE0, 0x03, 0x30, + 0x01, 0xE0, 0x01, 0xC0, 0x03, 0x30, 0x00, 0x00, 0x00, 0x00, 0x03, 0x30, + 0x00, 0x00, 0x00, 0x00, 0x03, 0x30, 0x00, 0x00, 0x00, 0x00, 0x03, 0x30, + 0x00, 0x00, 0x00, 0x00, 0x03, 0x10, 0x00, 0x00, 0x00, 0x00, 0x03, 0x18, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x18, 0x00, 0x00, 0x00, 0x00, 0x06, 0x18, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x0C, 0x00, 0xE0, 0x01, 0x80, 0x0E, 0x0C, + 0x00, 0xFF, 0xFF, 0xC0, 0x0C, 0x0C, 0x00, 0x1F, 0xFE, 0x00, 0x1C, 0x06, + 0x00, 0x00, 0x00, 0x00, 0x18, 0x03, 0x00, 0x00, 0x00, 0x00, 0x38, 0x03, + 0x00, 0x00, 0x00, 0x00, 0x70, 0x01, 0x80, 0x00, 0x00, 0x00, 0x60, 0x00, + 0xC0, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x60, 0x00, 0x00, 0x03, 0x80, 0x00, + 0x38, 0x00, 0x00, 0x07, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x0F, 0x80, 0x00, 0x7C, 0x00, 0x00, 0x03, 0xF0, 0x03, 0xF0, 0x00, 0x00, + 0x00, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x0F, 0xFC, 0x00, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x00, 0x1F, 0xC0, 0x00, 0x00, 0x06, 0x38, 0x00, 0x00, + 0x03, 0x07, 0x00, 0x00, 0x00, 0xC0, 0xF0, 0x00, 0x00, 0x18, 0x0E, 0x00, + 0x00, 0x07, 0x01, 0xC0, 0x00, 0x03, 0xE0, 0x3C, 0x00, 0x01, 0xDC, 0x03, + 0x80, 0x00, 0x61, 0xC0, 0x70, 0x00, 0x18, 0x38, 0x06, 0x00, 0x06, 0x07, + 0x00, 0xC0, 0x00, 0xC0, 0xF0, 0x30, 0x00, 0x38, 0x0C, 0x06, 0x00, 0x07, + 0x01, 0x81, 0xC0, 0x00, 0xE0, 0x60, 0x30, 0x00, 0x1F, 0xEC, 0x06, 0x00, + 0x3F, 0x7F, 0x01, 0x80, 0x1C, 0x01, 0xF8, 0x30, 0x1E, 0x00, 0x0F, 0x0C, + 0x0E, 0x00, 0x00, 0xE3, 0x0E, 0x00, 0x00, 0x18, 0x63, 0x00, 0x7C, 0x03, + 0x19, 0x80, 0x7F, 0xC0, 0xC6, 0x60, 0x78, 0x38, 0x19, 0x98, 0x38, 0x06, + 0x06, 0x23, 0xFC, 0x00, 0xC0, 0xCC, 0x7C, 0x00, 0x30, 0x03, 0x3C, 0x00, + 0x0C, 0x00, 0xDF, 0x80, 0x03, 0x00, 0x3E, 0x30, 0x00, 0xC0, 0x0F, 0x0E, + 0x00, 0x30, 0x03, 0xC1, 0x80, 0x0C, 0x00, 0xB0, 0x30, 0x06, 0x00, 0x6C, + 0x0E, 0x03, 0x80, 0x1B, 0x01, 0xC1, 0xC0, 0x06, 0x60, 0x3F, 0xE0, 0x03, + 0x18, 0x03, 0xE0, 0x00, 0xC7, 0x00, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, + 0x18, 0x18, 0x00, 0x00, 0x06, 0x07, 0x00, 0x00, 0x03, 0x00, 0xE0, 0x00, + 0x01, 0xC0, 0x1C, 0x00, 0x00, 0xE0, 0x03, 0xC0, 0x00, 0xF0, 0x00, 0x3E, + 0x01, 0xF0, 0x00, 0x03, 0xFF, 0xF0, 0x00, 0x00, 0x1F, 0xE0, 0x00, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xC0, 0xF0, 0x7F, + 0x83, 0xFC, 0x1F, 0xE0, 0xFF, 0x07, 0xF8, 0x3F, 0xC1, 0xFE, 0x0F, 0xF0, + 0x7F, 0x83, 0xFC, 0x1F, 0xE0, 0xFF, 0x07, 0x80, 0x00, 0x07, 0x81, 0xE0, + 0x00, 0x07, 0x81, 0xE0, 0x00, 0x07, 0x01, 0xE0, 0x00, 0x0F, 0x01, 0xC0, + 0x00, 0x0F, 0x03, 0xC0, 0x00, 0x0F, 0x03, 0xC0, 0x00, 0x0E, 0x03, 0xC0, + 0x00, 0x1E, 0x03, 0x80, 0x00, 0x1E, 0x03, 0x80, 0x00, 0x1E, 0x07, 0x80, + 0x1F, 0xFF, 0xFF, 0xFF, 0x1F, 0xFF, 0xFF, 0xFF, 0x1F, 0xFF, 0xFF, 0xFF, + 0x1F, 0xFF, 0xFF, 0xFF, 0x00, 0x3C, 0x0F, 0x00, 0x00, 0x38, 0x0F, 0x00, + 0x00, 0x78, 0x0E, 0x00, 0x00, 0x78, 0x1E, 0x00, 0x00, 0x70, 0x1E, 0x00, + 0x00, 0xF0, 0x1E, 0x00, 0x00, 0xF0, 0x1C, 0x00, 0xFF, 0xFF, 0xFF, 0xF8, + 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xFF, 0xF8, + 0x01, 0xE0, 0x78, 0x00, 0x01, 0xC0, 0x78, 0x00, 0x01, 0xC0, 0x78, 0x00, + 0x03, 0xC0, 0x70, 0x00, 0x03, 0xC0, 0xF0, 0x00, 0x03, 0xC0, 0xF0, 0x00, + 0x03, 0x80, 0xF0, 0x00, 0x07, 0x80, 0xE0, 0x00, 0x07, 0x80, 0xE0, 0x00, + 0x07, 0x81, 0xE0, 0x00, 0x00, 0x30, 0x00, 0x00, 0xC0, 0x00, 0x03, 0x00, + 0x00, 0x0C, 0x00, 0x00, 0x30, 0x00, 0x07, 0xFF, 0x00, 0xFF, 0xFF, 0x07, + 0xFF, 0xFC, 0x3F, 0xFF, 0xF1, 0xF8, 0xC1, 0xCF, 0x83, 0x00, 0x3C, 0x0C, + 0x00, 0xF0, 0x30, 0x03, 0xC0, 0xC0, 0x0F, 0x03, 0x00, 0x3E, 0x0C, 0x00, + 0x7E, 0x30, 0x01, 0xFF, 0xC0, 0x03, 0xFF, 0xE0, 0x03, 0xFF, 0xF0, 0x03, + 0xFF, 0xE0, 0x00, 0xFF, 0xC0, 0x03, 0x1F, 0x80, 0x0C, 0x1F, 0x00, 0x30, + 0x7C, 0x00, 0xC0, 0xF0, 0x03, 0x03, 0xC0, 0x0C, 0x0F, 0x00, 0x30, 0x7E, + 0x00, 0xC3, 0xEF, 0x83, 0x3F, 0xBF, 0xFF, 0xFC, 0xFF, 0xFF, 0xE1, 0xFF, + 0xFF, 0x00, 0x7F, 0xE0, 0x00, 0x0C, 0x00, 0x00, 0x30, 0x00, 0x00, 0xC0, + 0x00, 0x03, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x30, 0x00, 0x00, 0xC0, 0x00, + 0x07, 0xE0, 0x00, 0x0E, 0x00, 0x3F, 0xF0, 0x00, 0x3C, 0x00, 0xFF, 0xF0, + 0x00, 0x70, 0x03, 0xE1, 0xE0, 0x01, 0xE0, 0x07, 0x81, 0xE0, 0x03, 0x80, + 0x0F, 0x03, 0xC0, 0x0F, 0x00, 0x3C, 0x03, 0xC0, 0x3C, 0x00, 0x78, 0x07, + 0x80, 0x70, 0x00, 0xF0, 0x0F, 0x01, 0xE0, 0x01, 0xE0, 0x1E, 0x03, 0x80, + 0x03, 0xC0, 0x3C, 0x0F, 0x00, 0x07, 0x80, 0x78, 0x1C, 0x00, 0x0F, 0x00, + 0xF0, 0x70, 0x00, 0x0F, 0x03, 0xC1, 0xE0, 0x00, 0x1E, 0x07, 0x83, 0x80, + 0x00, 0x3E, 0x1F, 0x0F, 0x00, 0x00, 0x3F, 0xFC, 0x1C, 0x00, 0x00, 0x3F, + 0xF0, 0x78, 0x1F, 0x80, 0x1F, 0x81, 0xE0, 0xFF, 0xC0, 0x00, 0x03, 0x83, + 0xFF, 0xC0, 0x00, 0x0F, 0x0F, 0x87, 0xC0, 0x00, 0x1C, 0x1E, 0x07, 0x80, + 0x00, 0x78, 0x3C, 0x0F, 0x00, 0x00, 0xE0, 0xF0, 0x0F, 0x00, 0x03, 0x81, + 0xE0, 0x1E, 0x00, 0x0F, 0x03, 0xC0, 0x3C, 0x00, 0x1C, 0x07, 0x80, 0x78, + 0x00, 0x78, 0x0F, 0x00, 0xF0, 0x00, 0xE0, 0x1E, 0x01, 0xE0, 0x03, 0xC0, + 0x3C, 0x03, 0xC0, 0x07, 0x00, 0x3C, 0x0F, 0x00, 0x1C, 0x00, 0x78, 0x1E, + 0x00, 0x78, 0x00, 0xF8, 0x78, 0x00, 0xE0, 0x00, 0xFF, 0xF0, 0x03, 0xC0, + 0x00, 0xFF, 0xC0, 0x07, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x3F, 0x80, 0x00, + 0x00, 0xFF, 0xF0, 0x00, 0x03, 0xFF, 0xF8, 0x00, 0x07, 0xFF, 0xF8, 0x00, + 0x07, 0xE0, 0x78, 0x00, 0x0F, 0x80, 0x08, 0x00, 0x0F, 0x80, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, + 0x07, 0xE0, 0x00, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, + 0x1F, 0xFC, 0x00, 0x00, 0x1E, 0x7E, 0x00, 0x3C, 0x3E, 0x3F, 0x00, 0x3C, + 0x7C, 0x1F, 0x80, 0x7C, 0x78, 0x0F, 0xC0, 0x78, 0xF8, 0x07, 0xE0, 0x78, + 0xF0, 0x03, 0xF0, 0x78, 0xF0, 0x01, 0xF8, 0xF0, 0xF0, 0x00, 0xFC, 0xF0, + 0xF0, 0x00, 0x7E, 0xE0, 0xF0, 0x00, 0x3F, 0xE0, 0xF8, 0x00, 0x1F, 0xC0, + 0x78, 0x00, 0x0F, 0xC0, 0x7C, 0x00, 0x0F, 0xC0, 0x3E, 0x00, 0x3F, 0xE0, + 0x3F, 0xC0, 0xFF, 0xF0, 0x1F, 0xFF, 0xFC, 0xF8, 0x0F, 0xFF, 0xF8, 0x7E, + 0x03, 0xFF, 0xE0, 0x3F, 0x00, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x03, 0xE0, 0x78, 0x1E, 0x03, 0xC0, 0xF0, 0x1E, 0x07, + 0x80, 0xF0, 0x3C, 0x07, 0x80, 0xF0, 0x3C, 0x07, 0x80, 0xF0, 0x1E, 0x07, + 0x80, 0xF0, 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x3C, 0x07, 0x80, + 0xF0, 0x1E, 0x03, 0xC0, 0x3C, 0x07, 0x80, 0xF0, 0x1E, 0x01, 0xE0, 0x3C, + 0x07, 0x80, 0x78, 0x0F, 0x00, 0xF0, 0x1E, 0x01, 0xE0, 0x3C, 0x03, 0xC0, + 0x7C, 0xF8, 0x0F, 0x00, 0xF0, 0x1E, 0x01, 0xE0, 0x3C, 0x03, 0xC0, 0x78, + 0x07, 0x80, 0xF0, 0x1E, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x0F, 0x01, + 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, + 0x3C, 0x07, 0x81, 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x3C, 0x07, 0x80, 0xF0, + 0x3C, 0x07, 0x81, 0xE0, 0x3C, 0x0F, 0x01, 0xE0, 0x78, 0x1F, 0x00, 0x00, + 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x04, 0x07, 0x01, + 0x78, 0x38, 0x3F, 0xE1, 0xC3, 0xE7, 0xCE, 0x7C, 0x0F, 0x77, 0x80, 0x1F, + 0xF0, 0x00, 0x7F, 0x00, 0x03, 0xF8, 0x00, 0x3F, 0xE0, 0x07, 0xBB, 0xC0, + 0xF9, 0xCF, 0x9F, 0x0E, 0x1F, 0xF0, 0x70, 0x7A, 0x03, 0x80, 0x80, 0x1C, + 0x00, 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x00, 0x07, 0x80, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x01, 0xE0, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, + 0x78, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x03, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFC, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x01, 0xE0, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, + 0x78, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x3C, 0xF3, 0xCF, 0x3C, 0xE7, 0x9C, 0x73, 0xCE, 0x00, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x0F, 0x00, 0x1F, + 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x3C, 0x00, 0x3C, 0x00, 0x3C, + 0x00, 0x78, 0x00, 0x78, 0x00, 0x78, 0x00, 0xF8, 0x00, 0xF0, 0x00, 0xF0, + 0x01, 0xF0, 0x01, 0xE0, 0x01, 0xE0, 0x01, 0xE0, 0x03, 0xC0, 0x03, 0xC0, + 0x03, 0xC0, 0x07, 0x80, 0x07, 0x80, 0x07, 0x80, 0x0F, 0x80, 0x0F, 0x00, + 0x0F, 0x00, 0x1F, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x3C, 0x00, + 0x3C, 0x00, 0x3C, 0x00, 0x7C, 0x00, 0x78, 0x00, 0x78, 0x00, 0xF8, 0x00, + 0xF0, 0x00, 0x00, 0x7F, 0x00, 0x03, 0xFF, 0xC0, 0x07, 0xFF, 0xE0, 0x0F, + 0xFF, 0xF0, 0x1F, 0x81, 0xF8, 0x1F, 0x00, 0xF8, 0x3E, 0x00, 0x7C, 0x3C, + 0x00, 0x3C, 0x7C, 0x00, 0x3E, 0x78, 0x00, 0x1E, 0x78, 0x00, 0x1E, 0xF8, + 0x00, 0x1E, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF8, 0x00, 0x1E, 0x78, 0x00, 0x1E, 0x78, 0x00, 0x1E, 0x7C, + 0x00, 0x3E, 0x3C, 0x00, 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x00, 0xF8, 0x1F, + 0x81, 0xF8, 0x0F, 0xFF, 0xF0, 0x07, 0xFF, 0xE0, 0x03, 0xFF, 0xC0, 0x00, + 0xFE, 0x00, 0x03, 0xF0, 0x03, 0xFF, 0x00, 0xFF, 0xF0, 0x0F, 0xFF, 0x00, + 0xFC, 0xF0, 0x0C, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x07, 0xFC, 0x00, 0xFF, 0xFE, 0x0F, 0xFF, 0xFE, 0x3F, + 0xFF, 0xFC, 0xFE, 0x03, 0xF3, 0x80, 0x03, 0xE8, 0x00, 0x07, 0x80, 0x00, + 0x1F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, + 0x00, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x80, 0x00, 0x3C, 0x00, + 0x01, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x7C, 0x00, 0x03, 0xE0, 0x00, 0x1F, + 0x00, 0x00, 0xF8, 0x00, 0x07, 0xC0, 0x00, 0x3E, 0x00, 0x01, 0xF0, 0x00, + 0x0F, 0x80, 0x00, 0x7C, 0x00, 0x03, 0xE0, 0x00, 0x1F, 0x00, 0x00, 0xF8, + 0x00, 0x07, 0xC0, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xFE, 0x00, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, + 0x83, 0xFF, 0xFF, 0x87, 0x00, 0x3F, 0x80, 0x00, 0x1F, 0x00, 0x00, 0x1F, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, 0xF0, + 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1F, 0x00, 0x00, 0xFC, 0x01, + 0xFF, 0xF0, 0x03, 0xFF, 0x80, 0x07, 0xFF, 0x80, 0x0F, 0xFF, 0xC0, 0x00, + 0x1F, 0xC0, 0x00, 0x0F, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, 0x80, 0x00, + 0x0F, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, + 0xF0, 0x00, 0x03, 0xE0, 0x00, 0x0F, 0xA0, 0x00, 0x3F, 0x7C, 0x01, 0xFC, + 0xFF, 0xFF, 0xF1, 0xFF, 0xFF, 0xC1, 0xFF, 0xFE, 0x00, 0x3F, 0xE0, 0x00, + 0x00, 0x03, 0xF0, 0x00, 0x03, 0xF8, 0x00, 0x01, 0xFC, 0x00, 0x01, 0xFE, + 0x00, 0x01, 0xEF, 0x00, 0x00, 0xE7, 0x80, 0x00, 0xF3, 0xC0, 0x00, 0xF1, + 0xE0, 0x00, 0x78, 0xF0, 0x00, 0x78, 0x78, 0x00, 0x78, 0x3C, 0x00, 0x3C, + 0x1E, 0x00, 0x3C, 0x0F, 0x00, 0x3C, 0x07, 0x80, 0x1E, 0x03, 0xC0, 0x1E, + 0x01, 0xE0, 0x1E, 0x00, 0xF0, 0x0F, 0x00, 0x78, 0x0F, 0x00, 0x3C, 0x0F, + 0x80, 0x1E, 0x07, 0x80, 0x0F, 0x07, 0x80, 0x07, 0x83, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x3C, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x07, 0x80, 0x00, 0x03, + 0xC0, 0x00, 0x01, 0xE0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x78, 0x00, 0x7F, + 0xFF, 0xE1, 0xFF, 0xFF, 0x87, 0xFF, 0xFE, 0x1F, 0xFF, 0xF8, 0x78, 0x00, + 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x01, + 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0xFE, 0x00, 0x7F, 0xFF, 0x01, 0xFF, + 0xFF, 0x07, 0xFF, 0xFE, 0x1E, 0x03, 0xFC, 0x40, 0x01, 0xF0, 0x00, 0x03, + 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, + 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, + 0x07, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xFA, 0x00, 0x07, 0xCF, 0x00, 0x7F, + 0x3F, 0xFF, 0xF8, 0xFF, 0xFF, 0xC3, 0xFF, 0xFC, 0x01, 0xFF, 0xC0, 0x00, + 0x00, 0x1F, 0xF0, 0x00, 0xFF, 0xFC, 0x03, 0xFF, 0xFC, 0x07, 0xFF, 0xFC, + 0x0F, 0xE0, 0x0C, 0x1F, 0x80, 0x00, 0x1E, 0x00, 0x00, 0x3E, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, 0x78, 0x00, 0x00, 0x78, 0x00, 0x00, + 0xF8, 0x7F, 0x00, 0xF1, 0xFF, 0xE0, 0xF3, 0xFF, 0xF0, 0xF7, 0xFF, 0xF8, + 0xFF, 0xC1, 0xFC, 0xFF, 0x00, 0x7E, 0xFE, 0x00, 0x3E, 0xFC, 0x00, 0x1E, + 0xFC, 0x00, 0x1F, 0xF8, 0x00, 0x0F, 0xF8, 0x00, 0x0F, 0xF8, 0x00, 0x0F, + 0x78, 0x00, 0x0F, 0x78, 0x00, 0x0F, 0x78, 0x00, 0x0F, 0x7C, 0x00, 0x1F, + 0x3C, 0x00, 0x1E, 0x3E, 0x00, 0x3E, 0x1F, 0x00, 0x7C, 0x1F, 0xC1, 0xFC, + 0x0F, 0xFF, 0xF8, 0x07, 0xFF, 0xF0, 0x03, 0xFF, 0xE0, 0x00, 0x7F, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, + 0x00, 0x78, 0x00, 0x03, 0xE0, 0x00, 0x0F, 0x00, 0x00, 0x7C, 0x00, 0x01, + 0xE0, 0x00, 0x07, 0x80, 0x00, 0x3E, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x03, 0xE0, 0x00, 0x0F, 0x00, 0x00, + 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x3E, 0x00, 0x00, 0xF0, + 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x03, 0xE0, 0x00, + 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x3E, + 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0x00, 0xFF, + 0x00, 0x07, 0xFF, 0xE0, 0x0F, 0xFF, 0xF0, 0x1F, 0xFF, 0xF8, 0x3F, 0x81, + 0xFC, 0x3E, 0x00, 0x7C, 0x7C, 0x00, 0x3E, 0x78, 0x00, 0x1E, 0x78, 0x00, + 0x1E, 0x78, 0x00, 0x1E, 0x78, 0x00, 0x1E, 0x78, 0x00, 0x1E, 0x3C, 0x00, + 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x81, 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, + 0xC0, 0x07, 0xFF, 0xE0, 0x1F, 0xFF, 0xF8, 0x3F, 0x81, 0xFC, 0x7C, 0x00, + 0x3E, 0x78, 0x00, 0x1E, 0xF8, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF8, 0x00, + 0x1F, 0x78, 0x00, 0x1E, 0x7C, 0x00, 0x3E, 0x3F, 0x00, 0xFC, 0x3F, 0xFF, + 0xFC, 0x1F, 0xFF, 0xF8, 0x07, 0xFF, 0xE0, 0x00, 0xFF, 0x00, 0x00, 0xFE, + 0x00, 0x07, 0xFF, 0xC0, 0x0F, 0xFF, 0xE0, 0x1F, 0xFF, 0xF0, 0x3F, 0x83, + 0xF8, 0x7E, 0x00, 0xF8, 0x7C, 0x00, 0x7C, 0x78, 0x00, 0x3C, 0xF8, 0x00, + 0x3E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, + 0x1F, 0xF0, 0x00, 0x1F, 0xF0, 0x00, 0x1F, 0xF8, 0x00, 0x3F, 0x78, 0x00, + 0x3F, 0x7C, 0x00, 0x7F, 0x7E, 0x00, 0xFF, 0x3F, 0x83, 0xFF, 0x1F, 0xFF, + 0xEF, 0x0F, 0xFF, 0xCF, 0x07, 0xFF, 0x8F, 0x00, 0xFE, 0x1E, 0x00, 0x00, + 0x1E, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x7C, 0x00, 0x00, 0xF8, 0x00, 0x01, 0xF8, 0x30, 0x07, 0xF0, 0x3F, 0xFF, + 0xE0, 0x3F, 0xFF, 0xC0, 0x3F, 0xFF, 0x00, 0x0F, 0xF8, 0x00, 0xFF, 0xFF, + 0xFF, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xFF, + 0xFF, 0x3C, 0xF3, 0xCF, 0x3C, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x03, 0xCF, 0x3C, 0xF3, 0xCE, 0x79, 0xC7, 0x3C, 0xE0, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0xFE, 0x00, 0x00, + 0x3F, 0xF0, 0x00, 0x07, 0xFF, 0x00, 0x01, 0xFF, 0xE0, 0x00, 0x7F, 0xF8, + 0x00, 0x1F, 0xFE, 0x00, 0x03, 0xFF, 0x80, 0x00, 0xFF, 0xF0, 0x00, 0x3F, + 0xFC, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x0F, 0xE0, 0x00, 0x00, 0x7F, 0xC0, + 0x00, 0x03, 0xFF, 0xC0, 0x00, 0x03, 0xFF, 0xC0, 0x00, 0x03, 0xFF, 0x80, + 0x00, 0x07, 0xFF, 0x80, 0x00, 0x07, 0xFF, 0x80, 0x00, 0x07, 0xFF, 0x80, + 0x00, 0x07, 0xFF, 0x00, 0x00, 0x0F, 0xFC, 0x00, 0x00, 0x0F, 0xE0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x80, 0x00, 0x00, + 0x07, 0x80, 0x00, 0x00, 0x3F, 0x80, 0x00, 0x01, 0xFF, 0x80, 0x00, 0x07, + 0xFF, 0x00, 0x00, 0x0F, 0xFF, 0x00, 0x00, 0x0F, 0xFF, 0x00, 0x00, 0x0F, + 0xFF, 0x00, 0x00, 0x0F, 0xFE, 0x00, 0x00, 0x1F, 0xFE, 0x00, 0x00, 0x1F, + 0xFE, 0x00, 0x00, 0x1F, 0xF0, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x07, 0xFC, + 0x00, 0x01, 0xFF, 0xE0, 0x00, 0x7F, 0xF8, 0x00, 0x0F, 0xFE, 0x00, 0x03, + 0xFF, 0xC0, 0x00, 0xFF, 0xF0, 0x00, 0x3F, 0xFC, 0x00, 0x07, 0xFF, 0x00, + 0x00, 0x7F, 0xE0, 0x00, 0x03, 0xF8, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, + 0x80, 0x00, 0x00, 0x00, 0x07, 0xF0, 0x0F, 0xFF, 0x07, 0xFF, 0xF3, 0xFF, + 0xFE, 0xF8, 0x1F, 0xB8, 0x01, 0xF8, 0x00, 0x7C, 0x00, 0x0F, 0x00, 0x03, + 0xC0, 0x00, 0xF0, 0x00, 0x3C, 0x00, 0x1E, 0x00, 0x0F, 0x80, 0x07, 0xC0, + 0x03, 0xE0, 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0x1F, 0x00, 0x0F, + 0x80, 0x03, 0xC0, 0x00, 0xF0, 0x00, 0x3C, 0x00, 0x0F, 0x00, 0x03, 0xC0, + 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x00, + 0x3E, 0x00, 0x0F, 0x80, 0x03, 0xE0, 0x00, 0xF8, 0x00, 0x3E, 0x00, 0x00, + 0x00, 0x7F, 0xC0, 0x00, 0x00, 0x03, 0xFF, 0xFC, 0x00, 0x00, 0x07, 0xFF, + 0xFF, 0xC0, 0x00, 0x0F, 0xFF, 0xFF, 0xF0, 0x00, 0x0F, 0xF8, 0x03, 0xFE, + 0x00, 0x0F, 0xE0, 0x00, 0x3F, 0x80, 0x0F, 0xC0, 0x00, 0x07, 0xE0, 0x0F, + 0x80, 0x00, 0x01, 0xF8, 0x0F, 0x80, 0x00, 0x00, 0x7C, 0x0F, 0x80, 0x00, + 0x00, 0x1F, 0x07, 0x80, 0x00, 0x00, 0x07, 0x87, 0xC0, 0x1F, 0x87, 0x81, + 0xE3, 0xC0, 0x1F, 0xF3, 0xC0, 0xF3, 0xE0, 0x3F, 0xFD, 0xE0, 0x7D, 0xE0, + 0x1F, 0xFF, 0xF0, 0x1E, 0xF0, 0x1F, 0x83, 0xF8, 0x0F, 0xF0, 0x0F, 0x00, + 0x7C, 0x07, 0xF8, 0x0F, 0x80, 0x3E, 0x03, 0xFC, 0x07, 0x80, 0x0F, 0x01, + 0xFE, 0x03, 0xC0, 0x07, 0x80, 0xFF, 0x01, 0xE0, 0x03, 0xC0, 0x7F, 0x80, + 0xF0, 0x01, 0xE0, 0x7B, 0xC0, 0x78, 0x00, 0xF0, 0x3D, 0xE0, 0x3E, 0x00, + 0xF8, 0x3E, 0xF0, 0x0F, 0x00, 0x7C, 0x3E, 0x3C, 0x07, 0xE0, 0xFE, 0x7E, + 0x1E, 0x01, 0xFF, 0xFF, 0xFE, 0x0F, 0x00, 0xFF, 0xF7, 0xFE, 0x07, 0xC0, + 0x3F, 0xF3, 0xFC, 0x01, 0xF0, 0x07, 0xE1, 0xF0, 0x00, 0xF8, 0x00, 0x00, + 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x00, + 0x07, 0xE0, 0x00, 0x00, 0x80, 0x01, 0xFC, 0x00, 0x01, 0xE0, 0x00, 0x7F, + 0x80, 0x01, 0xF0, 0x00, 0x1F, 0xF8, 0x07, 0xF8, 0x00, 0x03, 0xFF, 0xFF, + 0xF8, 0x00, 0x00, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x0F, 0xFF, 0xE0, 0x00, + 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0x1F, + 0xC0, 0x00, 0x00, 0x3F, 0x80, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x01, 0xFF, + 0x00, 0x00, 0x03, 0xDE, 0x00, 0x00, 0x0F, 0xBE, 0x00, 0x00, 0x1E, 0x3C, + 0x00, 0x00, 0x3C, 0x78, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x01, 0xE0, 0xF0, + 0x00, 0x03, 0xC1, 0xE0, 0x00, 0x0F, 0x01, 0xE0, 0x00, 0x1E, 0x03, 0xC0, + 0x00, 0x7C, 0x07, 0xC0, 0x00, 0xF0, 0x07, 0x80, 0x01, 0xE0, 0x0F, 0x00, + 0x07, 0xC0, 0x1F, 0x00, 0x0F, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x3E, 0x00, + 0x78, 0x00, 0x3C, 0x00, 0xFF, 0xFF, 0xF8, 0x03, 0xFF, 0xFF, 0xF8, 0x07, + 0xFF, 0xFF, 0xF0, 0x0F, 0xFF, 0xFF, 0xE0, 0x3E, 0x00, 0x03, 0xE0, 0x78, + 0x00, 0x03, 0xC1, 0xF0, 0x00, 0x07, 0xC3, 0xC0, 0x00, 0x07, 0x87, 0x80, + 0x00, 0x0F, 0x1F, 0x00, 0x00, 0x1F, 0x3C, 0x00, 0x00, 0x1E, 0x78, 0x00, + 0x00, 0x3D, 0xE0, 0x00, 0x00, 0x3C, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xE0, + 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xF8, 0xF0, 0x01, 0xFC, 0xF0, 0x00, 0x7E, + 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, + 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0x7C, 0xF0, 0x01, 0xFC, + 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xE0, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xF8, + 0xF0, 0x00, 0xFC, 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x1F, 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0xFE, + 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x80, + 0x00, 0x0F, 0xFC, 0x00, 0x07, 0xFF, 0xF8, 0x01, 0xFF, 0xFF, 0xE0, 0x3F, + 0xFF, 0xFF, 0x07, 0xF0, 0x07, 0xF0, 0xFC, 0x00, 0x0F, 0x1F, 0x00, 0x00, + 0x31, 0xE0, 0x00, 0x01, 0x3E, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x78, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x78, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, + 0x78, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x7C, 0x00, 0x00, 0x03, 0xE0, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x11, 0xF0, 0x00, 0x03, 0x0F, 0xC0, 0x00, + 0xF0, 0x7F, 0x80, 0x7F, 0x03, 0xFF, 0xFF, 0xF0, 0x1F, 0xFF, 0xFE, 0x00, + 0x7F, 0xFF, 0x80, 0x00, 0xFF, 0xC0, 0xFF, 0xFF, 0x00, 0x07, 0xFF, 0xFF, + 0x80, 0x3F, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0xFC, 0x0F, 0x00, 0x1F, 0xF8, + 0x78, 0x00, 0x0F, 0xE3, 0xC0, 0x00, 0x1F, 0x1E, 0x00, 0x00, 0x7C, 0xF0, + 0x00, 0x01, 0xE7, 0x80, 0x00, 0x0F, 0xBC, 0x00, 0x00, 0x3D, 0xE0, 0x00, + 0x01, 0xEF, 0x00, 0x00, 0x0F, 0xF8, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x01, + 0xFE, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0x80, 0x00, 0x03, 0xFC, + 0x00, 0x00, 0x1F, 0xE0, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x07, 0xF8, 0x00, + 0x00, 0x7F, 0xC0, 0x00, 0x03, 0xDE, 0x00, 0x00, 0x1E, 0xF0, 0x00, 0x01, + 0xF7, 0x80, 0x00, 0x0F, 0x3C, 0x00, 0x00, 0xF9, 0xE0, 0x00, 0x0F, 0x8F, + 0x00, 0x01, 0xFC, 0x78, 0x00, 0x7F, 0xC3, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, + 0xFF, 0x80, 0xFF, 0xFF, 0xF0, 0x07, 0xFF, 0xF8, 0x00, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x03, + 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, + 0x0F, 0xFF, 0xFF, 0xBF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFB, 0xFF, 0xFF, 0xEF, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, + 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xFF, 0xFF, 0xEF, 0xFF, 0xFE, + 0xFF, 0xFF, 0xEF, 0xFF, 0xFE, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x0F, 0xFC, + 0x00, 0x01, 0xFF, 0xFF, 0x00, 0x1F, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFE, + 0x07, 0xF8, 0x07, 0xF8, 0x3F, 0x00, 0x01, 0xE1, 0xF0, 0x00, 0x01, 0x8F, + 0x80, 0x00, 0x02, 0x3E, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x07, 0x80, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x03, 0xC0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x03, 0xC0, 0x00, 0xFF, 0xFF, 0x00, 0x03, 0xFF, 0xFC, 0x00, 0x0F, 0xFF, + 0xF0, 0x00, 0x3F, 0xFF, 0xC0, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x03, 0xDE, + 0x00, 0x00, 0x0F, 0x78, 0x00, 0x00, 0x3D, 0xE0, 0x00, 0x00, 0xF7, 0xC0, + 0x00, 0x03, 0xCF, 0x80, 0x00, 0x0F, 0x3E, 0x00, 0x00, 0x3C, 0x7E, 0x00, + 0x00, 0xF0, 0xFC, 0x00, 0x07, 0xC1, 0xFE, 0x00, 0x7F, 0x03, 0xFF, 0xFF, + 0xF8, 0x07, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xF8, 0x00, 0x03, 0xFF, 0x00, + 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, + 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, + 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, + 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, + 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, + 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, + 0x00, 0x00, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xE0, 0x3C, 0x07, + 0x80, 0xF0, 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x3C, 0x07, 0x80, + 0xF0, 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xF0, + 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x1E, + 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x3C, 0x07, + 0x83, 0xF7, 0xFC, 0xFF, 0x9F, 0xC3, 0xE0, 0x00, 0xF0, 0x00, 0x1F, 0x9E, + 0x00, 0x07, 0xE3, 0xC0, 0x01, 0xF8, 0x78, 0x00, 0x7E, 0x0F, 0x00, 0x1F, + 0x81, 0xE0, 0x07, 0xE0, 0x3C, 0x01, 0xF8, 0x07, 0x80, 0x7E, 0x00, 0xF0, + 0x1F, 0x80, 0x1E, 0x07, 0xE0, 0x03, 0xC3, 0xF0, 0x00, 0x78, 0xFC, 0x00, + 0x0F, 0x3F, 0x00, 0x01, 0xEF, 0xC0, 0x00, 0x3F, 0xF0, 0x00, 0x07, 0xFC, + 0x00, 0x00, 0xFF, 0x80, 0x00, 0x1F, 0xF8, 0x00, 0x03, 0xDF, 0x80, 0x00, + 0x79, 0xF8, 0x00, 0x0F, 0x1F, 0x80, 0x01, 0xE0, 0xF8, 0x00, 0x3C, 0x0F, + 0x80, 0x07, 0x80, 0xF8, 0x00, 0xF0, 0x0F, 0x80, 0x1E, 0x00, 0xF8, 0x03, + 0xC0, 0x0F, 0x80, 0x78, 0x00, 0xF8, 0x0F, 0x00, 0x0F, 0x81, 0xE0, 0x00, + 0xF8, 0x3C, 0x00, 0x0F, 0x87, 0x80, 0x00, 0xF8, 0xF0, 0x00, 0x0F, 0x9E, + 0x00, 0x00, 0xFC, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, + 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, + 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x00, + 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xF0, 0x00, 0x07, + 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, + 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, + 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x03, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xFE, 0x00, 0x00, + 0xFF, 0xFC, 0x00, 0x01, 0xFF, 0xFC, 0x00, 0x07, 0xFF, 0xF8, 0x00, 0x0F, + 0xFF, 0xF0, 0x00, 0x1F, 0xFE, 0xF0, 0x00, 0x7B, 0xFD, 0xE0, 0x00, 0xF7, + 0xFB, 0xE0, 0x03, 0xEF, 0xF3, 0xC0, 0x07, 0x9F, 0xE7, 0x80, 0x0F, 0x3F, + 0xC7, 0x80, 0x3C, 0x7F, 0x8F, 0x00, 0x78, 0xFF, 0x1F, 0x01, 0xF1, 0xFE, + 0x1E, 0x03, 0xC3, 0xFC, 0x3C, 0x07, 0x87, 0xF8, 0x3C, 0x1E, 0x0F, 0xF0, + 0x78, 0x3C, 0x1F, 0xE0, 0xF8, 0xF8, 0x3F, 0xC0, 0xF1, 0xE0, 0x7F, 0x81, + 0xE3, 0xC0, 0xFF, 0x01, 0xEF, 0x01, 0xFE, 0x03, 0xDE, 0x03, 0xFC, 0x07, + 0xFC, 0x07, 0xF8, 0x07, 0xF0, 0x0F, 0xF0, 0x0F, 0xE0, 0x1F, 0xE0, 0x1F, + 0xC0, 0x3F, 0xC0, 0x1F, 0x00, 0x7F, 0x80, 0x00, 0x00, 0xFF, 0x00, 0x00, + 0x01, 0xFE, 0x00, 0x00, 0x03, 0xFC, 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, + 0x0F, 0xF0, 0x00, 0x00, 0x1F, 0xE0, 0x00, 0x00, 0x3C, 0xFC, 0x00, 0x03, + 0xFF, 0x80, 0x00, 0xFF, 0xE0, 0x00, 0x3F, 0xFC, 0x00, 0x0F, 0xFF, 0x00, + 0x03, 0xFF, 0xE0, 0x00, 0xFF, 0x78, 0x00, 0x3F, 0xDF, 0x00, 0x0F, 0xF3, + 0xE0, 0x03, 0xFC, 0x78, 0x00, 0xFF, 0x1F, 0x00, 0x3F, 0xC3, 0xC0, 0x0F, + 0xF0, 0xF8, 0x03, 0xFC, 0x1E, 0x00, 0xFF, 0x07, 0xC0, 0x3F, 0xC0, 0xF0, + 0x0F, 0xF0, 0x3E, 0x03, 0xFC, 0x07, 0xC0, 0xFF, 0x00, 0xF0, 0x3F, 0xC0, + 0x3E, 0x0F, 0xF0, 0x07, 0x83, 0xFC, 0x01, 0xF0, 0xFF, 0x00, 0x3C, 0x3F, + 0xC0, 0x0F, 0x8F, 0xF0, 0x01, 0xE3, 0xFC, 0x00, 0x7C, 0xFF, 0x00, 0x0F, + 0xBF, 0xC0, 0x01, 0xEF, 0xF0, 0x00, 0x7F, 0xFC, 0x00, 0x0F, 0xFF, 0x00, + 0x03, 0xFF, 0xC0, 0x00, 0x7F, 0xF0, 0x00, 0x1F, 0xFC, 0x00, 0x03, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0x80, + 0x03, 0xFF, 0xFF, 0xC0, 0x07, 0xF0, 0x0F, 0xE0, 0x0F, 0xC0, 0x03, 0xF0, + 0x1F, 0x80, 0x01, 0xF8, 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, + 0x7C, 0x00, 0x00, 0x3C, 0x78, 0x00, 0x00, 0x3E, 0x78, 0x00, 0x00, 0x1E, + 0x78, 0x00, 0x00, 0x1E, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0x78, 0x00, 0x00, 0x1E, + 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x7C, 0x00, 0x00, 0x3C, + 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, 0x1F, 0x80, 0x01, 0xF8, + 0x0F, 0xC0, 0x03, 0xF0, 0x07, 0xF0, 0x0F, 0xE0, 0x03, 0xFF, 0xFF, 0xC0, + 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x7F, 0xFE, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0xFF, 0xFE, 0x03, 0xFF, 0xFE, 0x0F, 0xFF, 0xFE, 0x3F, 0xFF, 0xFC, 0xF0, + 0x03, 0xFB, 0xC0, 0x03, 0xEF, 0x00, 0x07, 0xBC, 0x00, 0x1F, 0xF0, 0x00, + 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, + 0xC0, 0x01, 0xFF, 0x00, 0x07, 0xBC, 0x00, 0x3E, 0xF0, 0x03, 0xFB, 0xFF, + 0xFF, 0xCF, 0xFF, 0xFE, 0x3F, 0xFF, 0xE0, 0xFF, 0xFE, 0x03, 0xC0, 0x00, + 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, + 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0x80, 0x03, 0xFF, + 0xFF, 0xC0, 0x07, 0xF0, 0x0F, 0xE0, 0x0F, 0xC0, 0x03, 0xF0, 0x1F, 0x80, + 0x01, 0xF8, 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, 0x7C, 0x00, + 0x00, 0x3C, 0x78, 0x00, 0x00, 0x3E, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, + 0x00, 0x1E, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, + 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x7C, 0x00, 0x00, 0x3E, 0x3E, 0x00, + 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, 0x1F, 0x80, 0x01, 0xF8, 0x0F, 0xC0, + 0x03, 0xF0, 0x07, 0xF0, 0x0F, 0xE0, 0x03, 0xFF, 0xFF, 0xC0, 0x01, 0xFF, + 0xFF, 0x80, 0x00, 0x7F, 0xFE, 0x00, 0x00, 0x0F, 0xFF, 0x00, 0x00, 0x00, + 0x1F, 0x80, 0x00, 0x00, 0x0F, 0xC0, 0x00, 0x00, 0x07, 0xE0, 0x00, 0x00, + 0x03, 0xF0, 0x00, 0x00, 0x01, 0xF8, 0xFF, 0xFE, 0x00, 0x1F, 0xFF, 0xF8, + 0x03, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xF8, 0x0F, 0x00, 0x3F, 0x81, 0xE0, + 0x01, 0xF0, 0x3C, 0x00, 0x1F, 0x07, 0x80, 0x01, 0xE0, 0xF0, 0x00, 0x3C, + 0x1E, 0x00, 0x07, 0x83, 0xC0, 0x00, 0xF0, 0x78, 0x00, 0x1E, 0x0F, 0x00, + 0x03, 0xC1, 0xE0, 0x00, 0xF8, 0x3C, 0x00, 0x3E, 0x07, 0x80, 0x1F, 0xC0, + 0xFF, 0xFF, 0xF0, 0x1F, 0xFF, 0xFC, 0x03, 0xFF, 0xFE, 0x00, 0x7F, 0xFF, + 0xF0, 0x0F, 0x00, 0x7E, 0x01, 0xE0, 0x03, 0xE0, 0x3C, 0x00, 0x3E, 0x07, + 0x80, 0x07, 0xC0, 0xF0, 0x00, 0x7C, 0x1E, 0x00, 0x07, 0x83, 0xC0, 0x00, + 0xF8, 0x78, 0x00, 0x0F, 0x0F, 0x00, 0x01, 0xF1, 0xE0, 0x00, 0x1E, 0x3C, + 0x00, 0x03, 0xE7, 0x80, 0x00, 0x3E, 0xF0, 0x00, 0x03, 0xDE, 0x00, 0x00, + 0x7C, 0x00, 0xFF, 0x80, 0x07, 0xFF, 0xF8, 0x1F, 0xFF, 0xFC, 0x3F, 0xFF, + 0xFC, 0x3F, 0x00, 0xFC, 0x7C, 0x00, 0x1C, 0x78, 0x00, 0x04, 0xF0, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0xF8, 0x00, 0x00, 0x7C, 0x00, 0x00, 0x7F, 0xC0, 0x00, 0x3F, 0xFE, + 0x00, 0x3F, 0xFF, 0xC0, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xF8, 0x00, 0x7F, + 0xFC, 0x00, 0x03, 0xFE, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x3F, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x1E, 0xE0, 0x00, 0x3E, 0xFE, 0x01, + 0xFC, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xF8, 0x3F, 0xFF, 0xE0, 0x03, 0xFF, + 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, + 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, + 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, + 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, + 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, + 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFE, 0x00, 0x01, + 0xF7, 0x80, 0x00, 0x79, 0xE0, 0x00, 0x1E, 0x7C, 0x00, 0x0F, 0x8F, 0x80, + 0x07, 0xC3, 0xF8, 0x07, 0xF0, 0x7F, 0xFF, 0xF8, 0x0F, 0xFF, 0xFC, 0x00, + 0xFF, 0xFC, 0x00, 0x0F, 0xFC, 0x00, 0xF0, 0x00, 0x00, 0x1E, 0xF0, 0x00, + 0x00, 0x79, 0xE0, 0x00, 0x00, 0xF3, 0xE0, 0x00, 0x03, 0xE3, 0xC0, 0x00, + 0x07, 0x87, 0x80, 0x00, 0x0F, 0x0F, 0x80, 0x00, 0x3E, 0x0F, 0x00, 0x00, + 0x78, 0x1F, 0x00, 0x01, 0xF0, 0x1E, 0x00, 0x03, 0xC0, 0x3C, 0x00, 0x07, + 0x80, 0x7C, 0x00, 0x1F, 0x00, 0x78, 0x00, 0x3C, 0x00, 0xF0, 0x00, 0x78, + 0x00, 0xF0, 0x01, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x03, 0xE0, 0x0F, 0x80, + 0x03, 0xC0, 0x1E, 0x00, 0x07, 0x80, 0x3C, 0x00, 0x0F, 0x80, 0xF8, 0x00, + 0x0F, 0x01, 0xE0, 0x00, 0x1E, 0x03, 0xC0, 0x00, 0x1E, 0x0F, 0x00, 0x00, + 0x3C, 0x1E, 0x00, 0x00, 0x7C, 0x7C, 0x00, 0x00, 0x78, 0xF0, 0x00, 0x00, + 0xF1, 0xE0, 0x00, 0x01, 0xF7, 0xC0, 0x00, 0x01, 0xEF, 0x00, 0x00, 0x03, + 0xFE, 0x00, 0x00, 0x03, 0xF8, 0x00, 0x00, 0x07, 0xF0, 0x00, 0x00, 0x0F, + 0xE0, 0x00, 0x00, 0x0F, 0x80, 0x00, 0xF0, 0x00, 0x3F, 0x80, 0x01, 0xFF, + 0x00, 0x07, 0xF0, 0x00, 0x7D, 0xE0, 0x00, 0xFE, 0x00, 0x0F, 0x3C, 0x00, + 0x1F, 0xC0, 0x01, 0xE7, 0x80, 0x07, 0xFC, 0x00, 0x3C, 0xF8, 0x00, 0xF7, + 0x80, 0x0F, 0x8F, 0x00, 0x1E, 0xF0, 0x01, 0xE1, 0xE0, 0x03, 0xDE, 0x00, + 0x3C, 0x3C, 0x00, 0xFB, 0xE0, 0x07, 0x87, 0xC0, 0x1E, 0x3C, 0x01, 0xF0, + 0x78, 0x03, 0xC7, 0x80, 0x3C, 0x0F, 0x00, 0x78, 0xF0, 0x07, 0x81, 0xE0, + 0x1F, 0x1F, 0x00, 0xF0, 0x3E, 0x03, 0xC1, 0xE0, 0x3E, 0x03, 0xC0, 0x78, + 0x3C, 0x07, 0x80, 0x78, 0x0F, 0x07, 0x80, 0xF0, 0x0F, 0x03, 0xE0, 0xF8, + 0x1E, 0x01, 0xF0, 0x78, 0x0F, 0x07, 0xC0, 0x1E, 0x0F, 0x01, 0xE0, 0xF0, + 0x03, 0xC1, 0xE0, 0x3C, 0x1E, 0x00, 0x78, 0x7C, 0x07, 0xC3, 0xC0, 0x0F, + 0x8F, 0x00, 0x78, 0xF8, 0x00, 0xF1, 0xE0, 0x0F, 0x1E, 0x00, 0x1E, 0x3C, + 0x01, 0xE3, 0xC0, 0x03, 0xCF, 0x80, 0x3E, 0x78, 0x00, 0x7D, 0xE0, 0x03, + 0xDF, 0x00, 0x07, 0xBC, 0x00, 0x7B, 0xC0, 0x00, 0xF7, 0x80, 0x0F, 0x78, + 0x00, 0x1E, 0xF0, 0x01, 0xEF, 0x00, 0x03, 0xFC, 0x00, 0x1F, 0xE0, 0x00, + 0x3F, 0x80, 0x03, 0xF8, 0x00, 0x07, 0xF0, 0x00, 0x7F, 0x00, 0x00, 0xFE, + 0x00, 0x0F, 0xE0, 0x00, 0x1F, 0x80, 0x00, 0xFC, 0x00, 0x3E, 0x00, 0x01, + 0xF0, 0xF8, 0x00, 0x1F, 0x03, 0xC0, 0x01, 0xF0, 0x1F, 0x00, 0x0F, 0x80, + 0x7C, 0x00, 0xF8, 0x01, 0xE0, 0x0F, 0x80, 0x0F, 0x80, 0x7C, 0x00, 0x3E, + 0x07, 0xC0, 0x00, 0xF0, 0x7C, 0x00, 0x07, 0xC3, 0xE0, 0x00, 0x1F, 0x3E, + 0x00, 0x00, 0xFB, 0xE0, 0x00, 0x03, 0xFF, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0x00, 0x7F, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x0F, 0xC0, 0x00, 0x00, + 0xFE, 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, 0x7F, 0xE0, 0x00, 0x07, 0xDF, + 0x00, 0x00, 0x3C, 0x7C, 0x00, 0x03, 0xE1, 0xF0, 0x00, 0x3E, 0x0F, 0x80, + 0x03, 0xE0, 0x3E, 0x00, 0x1F, 0x00, 0xF0, 0x01, 0xF0, 0x07, 0xC0, 0x1F, + 0x00, 0x1F, 0x00, 0xF8, 0x00, 0x78, 0x0F, 0x80, 0x03, 0xE0, 0xF8, 0x00, + 0x0F, 0x87, 0xC0, 0x00, 0x3C, 0x7C, 0x00, 0x01, 0xF7, 0xC0, 0x00, 0x07, + 0xC0, 0xF8, 0x00, 0x01, 0xF7, 0xC0, 0x00, 0x3E, 0x3E, 0x00, 0x07, 0xC3, + 0xE0, 0x00, 0x7C, 0x1F, 0x00, 0x0F, 0x80, 0xF8, 0x01, 0xF0, 0x0F, 0x80, + 0x1F, 0x00, 0x7C, 0x03, 0xE0, 0x03, 0xE0, 0x7C, 0x00, 0x3E, 0x07, 0xC0, + 0x01, 0xF0, 0xF8, 0x00, 0x0F, 0x9F, 0x00, 0x00, 0xF9, 0xF0, 0x00, 0x07, + 0xFE, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0x1F, 0x80, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x7F, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xE7, 0xFF, + 0xFF, 0xFE, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, + 0x80, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, + 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x3E, + 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x00, 0x00, + 0x01, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, + 0x00, 0x00, 0x1F, 0x00, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, + 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x03, 0xF0, + 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, + 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, + 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, + 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF0, 0xF0, 0x00, 0xF8, 0x00, 0x78, 0x00, 0x78, 0x00, + 0x7C, 0x00, 0x3C, 0x00, 0x3C, 0x00, 0x3C, 0x00, 0x1E, 0x00, 0x1E, 0x00, + 0x1E, 0x00, 0x1F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x80, 0x07, 0x80, + 0x07, 0x80, 0x07, 0x80, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x01, 0xE0, + 0x01, 0xE0, 0x01, 0xE0, 0x01, 0xF0, 0x00, 0xF0, 0x00, 0xF0, 0x00, 0xF8, + 0x00, 0x78, 0x00, 0x78, 0x00, 0x78, 0x00, 0x3C, 0x00, 0x3C, 0x00, 0x3C, + 0x00, 0x3E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1F, 0x00, 0x0F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, + 0x78, 0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, + 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x07, 0x83, + 0xC1, 0xE0, 0xF0, 0x78, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x0F, + 0x80, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x0F, 0xF8, 0x00, 0x00, 0xFF, 0xE0, + 0x00, 0x0F, 0xDF, 0x80, 0x00, 0xFC, 0x7E, 0x00, 0x0F, 0xC1, 0xF8, 0x00, + 0xF8, 0x07, 0xE0, 0x0F, 0x80, 0x0F, 0x80, 0xF8, 0x00, 0x3E, 0x0F, 0x80, + 0x00, 0xF8, 0xF8, 0x00, 0x03, 0xEF, 0x80, 0x00, 0x0F, 0x80, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x0F, + 0x80, 0xF0, 0x0F, 0x00, 0xF0, 0x0E, 0x01, 0xE0, 0x1E, 0x01, 0xE0, 0x01, + 0xFE, 0x00, 0x7F, 0xFE, 0x03, 0xFF, 0xFE, 0x0F, 0xFF, 0xF8, 0x3C, 0x03, + 0xF0, 0x80, 0x03, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, 0x00, 0x3C, + 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x7F, 0xFF, 0x0F, 0xFF, 0xFC, 0x7F, + 0xFF, 0xF3, 0xFF, 0xFF, 0xDF, 0xC0, 0x0F, 0x78, 0x00, 0x3F, 0xE0, 0x00, + 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x1F, 0xF0, 0x00, 0x7F, 0xC0, 0x03, 0xFF, + 0x80, 0x1F, 0xDF, 0x81, 0xFF, 0x7F, 0xFF, 0xBC, 0xFF, 0xFC, 0xF1, 0xFF, + 0xE3, 0xC1, 0xFC, 0x00, 0xF0, 0x00, 0x01, 0xE0, 0x00, 0x03, 0xC0, 0x00, + 0x07, 0x80, 0x00, 0x0F, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x78, 0x00, 0x00, 0xF0, 0x00, 0x01, 0xE0, 0xFE, 0x03, 0xC7, 0xFF, 0x07, + 0x9F, 0xFF, 0x0F, 0x7F, 0xFF, 0x1F, 0xF0, 0x7E, 0x3F, 0x80, 0x3E, 0x7E, + 0x00, 0x3E, 0xF8, 0x00, 0x3D, 0xF0, 0x00, 0x7B, 0xE0, 0x00, 0xFF, 0x80, + 0x00, 0xFF, 0x00, 0x01, 0xFE, 0x00, 0x03, 0xFC, 0x00, 0x07, 0xF8, 0x00, + 0x0F, 0xF0, 0x00, 0x1F, 0xE0, 0x00, 0x3F, 0xC0, 0x00, 0x7F, 0xC0, 0x01, + 0xFF, 0x80, 0x03, 0xDF, 0x00, 0x07, 0xBF, 0x00, 0x1F, 0x7F, 0x00, 0x7C, + 0xFF, 0x83, 0xF1, 0xEF, 0xFF, 0xE3, 0xCF, 0xFF, 0x87, 0x8F, 0xFE, 0x00, + 0x07, 0xE0, 0x00, 0x00, 0x7F, 0x80, 0x3F, 0xFE, 0x07, 0xFF, 0xF0, 0xFF, + 0xFF, 0x1F, 0xC0, 0xF3, 0xF0, 0x01, 0x7C, 0x00, 0x07, 0xC0, 0x00, 0x78, + 0x00, 0x0F, 0x80, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF8, 0x00, 0x07, 0x80, 0x00, 0x7C, 0x00, 0x03, 0xC0, 0x00, 0x3F, 0x00, + 0x11, 0xFC, 0x0F, 0x0F, 0xFF, 0xF0, 0x7F, 0xFF, 0x03, 0xFF, 0xE0, 0x07, + 0xF0, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, + 0xF0, 0x00, 0x01, 0xE0, 0x00, 0x03, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, + 0x00, 0x00, 0x1E, 0x01, 0xF8, 0x3C, 0x1F, 0xFC, 0x78, 0x7F, 0xFC, 0xF1, + 0xFF, 0xFD, 0xE7, 0xF0, 0x7F, 0xCF, 0x80, 0x3F, 0xBE, 0x00, 0x3F, 0x78, + 0x00, 0x3E, 0xF0, 0x00, 0x7F, 0xE0, 0x00, 0xFF, 0x80, 0x00, 0xFF, 0x00, + 0x01, 0xFE, 0x00, 0x03, 0xFC, 0x00, 0x07, 0xF8, 0x00, 0x0F, 0xF0, 0x00, + 0x1F, 0xE0, 0x00, 0x3F, 0xC0, 0x00, 0x7F, 0xC0, 0x01, 0xF7, 0x80, 0x03, + 0xEF, 0x00, 0x07, 0xDF, 0x00, 0x1F, 0x9F, 0x00, 0x7F, 0x3F, 0x83, 0xFE, + 0x3F, 0xFF, 0xBC, 0x3F, 0xFE, 0x78, 0x3F, 0xF8, 0xF0, 0x1F, 0xC0, 0x00, + 0x00, 0x7F, 0x80, 0x03, 0xFF, 0xE0, 0x07, 0xFF, 0xF0, 0x0F, 0xFF, 0xF8, + 0x1F, 0x80, 0xFC, 0x3E, 0x00, 0x3E, 0x3C, 0x00, 0x1E, 0x78, 0x00, 0x1E, + 0x78, 0x00, 0x0F, 0x70, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x7C, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x3F, 0x00, 0x02, 0x1F, 0xC0, 0x3E, + 0x0F, 0xFF, 0xFE, 0x07, 0xFF, 0xFE, 0x01, 0xFF, 0xFC, 0x00, 0x7F, 0xC0, + 0x00, 0xFF, 0x03, 0xFF, 0x07, 0xFF, 0x07, 0xFF, 0x0F, 0x80, 0x0F, 0x00, + 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0xFF, 0xFE, 0xFF, 0xFE, + 0xFF, 0xFE, 0xFF, 0xFE, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, + 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, + 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, + 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, + 0x01, 0xFC, 0x00, 0x0F, 0xFE, 0x3C, 0x3F, 0xFE, 0x78, 0xFF, 0xFE, 0xF3, + 0xF8, 0x3F, 0xE7, 0xC0, 0x1F, 0xDF, 0x00, 0x1F, 0xBC, 0x00, 0x1F, 0x78, + 0x00, 0x3F, 0xF0, 0x00, 0x7F, 0xC0, 0x00, 0x7F, 0x80, 0x00, 0xFF, 0x00, + 0x01, 0xFE, 0x00, 0x03, 0xFC, 0x00, 0x07, 0xF8, 0x00, 0x0F, 0xF0, 0x00, + 0x1F, 0xE0, 0x00, 0x7D, 0xE0, 0x00, 0xFB, 0xC0, 0x01, 0xF7, 0xC0, 0x07, + 0xE7, 0xC0, 0x1F, 0xCF, 0xE0, 0xFF, 0x8F, 0xFF, 0xEF, 0x0F, 0xFF, 0x9E, + 0x0F, 0xFE, 0x3C, 0x07, 0xF0, 0x78, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x07, 0x80, 0x00, 0x1F, 0x08, 0x00, 0x7C, 0x1E, 0x03, 0xF8, 0x3F, + 0xFF, 0xE0, 0x7F, 0xFF, 0x80, 0x7F, 0xFE, 0x00, 0x1F, 0xE0, 0x00, 0xF0, + 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, + 0xC1, 0xFC, 0x0F, 0x1F, 0xFC, 0x3C, 0xFF, 0xF8, 0xF7, 0xFF, 0xF3, 0xFE, + 0x07, 0xEF, 0xE0, 0x0F, 0xBF, 0x00, 0x1E, 0xF8, 0x00, 0x7F, 0xE0, 0x00, + 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, + 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, + 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, + 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, + 0x00, 0x0F, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x83, 0xC1, 0xE0, + 0xF0, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xE0, 0xF0, 0x78, 0x3C, + 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x07, + 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, + 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x0F, 0x8F, 0xBF, 0xDF, 0xCF, 0xE7, 0xC0, + 0xF0, 0x00, 0x01, 0xE0, 0x00, 0x03, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, 0xF0, + 0x00, 0x01, 0xE0, 0x00, 0x03, 0xC0, 0x07, 0xE7, 0x80, 0x1F, 0x8F, 0x00, + 0x7E, 0x1E, 0x01, 0xF8, 0x3C, 0x07, 0xE0, 0x78, 0x1F, 0x80, 0xF0, 0x7E, + 0x01, 0xE1, 0xF8, 0x03, 0xCF, 0xC0, 0x07, 0xBF, 0x00, 0x0F, 0xFC, 0x00, + 0x1F, 0xF0, 0x00, 0x3F, 0xE0, 0x00, 0x7F, 0xE0, 0x00, 0xF7, 0xE0, 0x01, + 0xE7, 0xE0, 0x03, 0xC7, 0xE0, 0x07, 0x87, 0xE0, 0x0F, 0x07, 0xE0, 0x1E, + 0x07, 0xE0, 0x3C, 0x07, 0xE0, 0x78, 0x07, 0xE0, 0xF0, 0x07, 0xE1, 0xE0, + 0x03, 0xE3, 0xC0, 0x03, 0xE7, 0x80, 0x03, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x00, 0x7E, 0x00, 0x3F, 0x83, 0xC7, 0xFE, 0x03, 0xFF, 0x0F, + 0x3F, 0xFC, 0x1F, 0xFE, 0x3D, 0xFF, 0xF8, 0xFF, 0xFC, 0xFF, 0x83, 0xF7, + 0xC1, 0xFB, 0xF8, 0x07, 0xDC, 0x03, 0xEF, 0xC0, 0x0F, 0xE0, 0x07, 0xBE, + 0x00, 0x3F, 0x00, 0x1F, 0xF8, 0x00, 0x7C, 0x00, 0x3F, 0xC0, 0x01, 0xE0, + 0x00, 0xFF, 0x00, 0x07, 0x80, 0x03, 0xFC, 0x00, 0x1E, 0x00, 0x0F, 0xF0, + 0x00, 0x78, 0x00, 0x3F, 0xC0, 0x01, 0xE0, 0x00, 0xFF, 0x00, 0x07, 0x80, + 0x03, 0xFC, 0x00, 0x1E, 0x00, 0x0F, 0xF0, 0x00, 0x78, 0x00, 0x3F, 0xC0, + 0x01, 0xE0, 0x00, 0xFF, 0x00, 0x07, 0x80, 0x03, 0xFC, 0x00, 0x1E, 0x00, + 0x0F, 0xF0, 0x00, 0x78, 0x00, 0x3F, 0xC0, 0x01, 0xE0, 0x00, 0xFF, 0x00, + 0x07, 0x80, 0x03, 0xFC, 0x00, 0x1E, 0x00, 0x0F, 0xF0, 0x00, 0x78, 0x00, + 0x3F, 0xC0, 0x01, 0xE0, 0x00, 0xFF, 0x00, 0x07, 0x80, 0x03, 0xC0, 0x00, + 0x7F, 0x03, 0xC7, 0xFF, 0x0F, 0x3F, 0xFE, 0x3D, 0xFF, 0xFC, 0xFF, 0x81, + 0xFB, 0xF8, 0x03, 0xEF, 0xC0, 0x07, 0xBE, 0x00, 0x1F, 0xF8, 0x00, 0x3F, + 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, + 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, + 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, + 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, + 0x03, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, + 0xFF, 0xF8, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x7C, 0x00, 0x3E, 0x7C, + 0x00, 0x3E, 0x78, 0x00, 0x1E, 0xF8, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x1F, 0x78, + 0x00, 0x1E, 0x7C, 0x00, 0x3E, 0x7C, 0x00, 0x3E, 0x3E, 0x00, 0x7C, 0x1F, + 0x81, 0xF8, 0x1F, 0xFF, 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, + 0xFF, 0x00, 0x00, 0x7F, 0x01, 0xE3, 0xFF, 0x83, 0xCF, 0xFF, 0x87, 0xBF, + 0xFF, 0x8F, 0xF8, 0x3F, 0x1F, 0xC0, 0x1F, 0x3F, 0x00, 0x1F, 0x7C, 0x00, + 0x1E, 0xF8, 0x00, 0x3D, 0xF0, 0x00, 0x7F, 0xC0, 0x00, 0x7F, 0x80, 0x00, + 0xFF, 0x00, 0x01, 0xFE, 0x00, 0x03, 0xFC, 0x00, 0x07, 0xF8, 0x00, 0x0F, + 0xF0, 0x00, 0x1F, 0xE0, 0x00, 0x3F, 0xE0, 0x00, 0xFF, 0xC0, 0x01, 0xEF, + 0x80, 0x03, 0xDF, 0x80, 0x0F, 0xBF, 0x80, 0x3E, 0x7F, 0xC1, 0xF8, 0xF7, + 0xFF, 0xF1, 0xE7, 0xFF, 0xC3, 0xC7, 0xFF, 0x07, 0x83, 0xF0, 0x0F, 0x00, + 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, 0xF0, 0x00, + 0x01, 0xE0, 0x00, 0x03, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0x00, 0xFC, 0x00, 0x0F, 0xFE, 0x3C, 0x3F, 0xFE, 0x78, 0xFF, 0xFE, + 0xF3, 0xF8, 0x3F, 0xE7, 0xC0, 0x1F, 0xDF, 0x00, 0x1F, 0xBC, 0x00, 0x1F, + 0x78, 0x00, 0x3F, 0xF0, 0x00, 0x7F, 0xC0, 0x00, 0x7F, 0x80, 0x00, 0xFF, + 0x00, 0x01, 0xFE, 0x00, 0x03, 0xFC, 0x00, 0x07, 0xF8, 0x00, 0x0F, 0xF0, + 0x00, 0x1F, 0xE0, 0x00, 0x3F, 0xE0, 0x00, 0xFB, 0xC0, 0x01, 0xF7, 0x80, + 0x03, 0xEF, 0x80, 0x0F, 0xCF, 0x80, 0x3F, 0x9F, 0xC1, 0xFF, 0x1F, 0xFF, + 0xDE, 0x1F, 0xFF, 0x3C, 0x1F, 0xFC, 0x78, 0x0F, 0xE0, 0xF0, 0x00, 0x01, + 0xE0, 0x00, 0x03, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, 0xF0, 0x00, 0x01, 0xE0, + 0x00, 0x7F, 0xE3, 0xFF, 0xCF, 0xFF, 0xBF, 0xFF, 0xF8, 0x1F, 0xC0, 0x3F, + 0x00, 0x7C, 0x00, 0xF8, 0x01, 0xE0, 0x03, 0xC0, 0x07, 0x80, 0x0F, 0x00, + 0x1E, 0x00, 0x3C, 0x00, 0x78, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x07, + 0x80, 0x0F, 0x00, 0x1E, 0x00, 0x3C, 0x00, 0x78, 0x00, 0xF0, 0x01, 0xE0, + 0x03, 0xC0, 0x00, 0x03, 0xFC, 0x03, 0xFF, 0xF0, 0xFF, 0xFF, 0x3F, 0xFF, + 0xE7, 0xE0, 0x3D, 0xF0, 0x00, 0xBC, 0x00, 0x07, 0x80, 0x00, 0xF0, 0x00, + 0x1E, 0x00, 0x03, 0xE0, 0x00, 0x3F, 0x00, 0x07, 0xFE, 0x00, 0x7F, 0xFC, + 0x03, 0xFF, 0xC0, 0x0F, 0xFE, 0x00, 0x1F, 0xC0, 0x00, 0xFC, 0x00, 0x07, + 0x80, 0x00, 0xF0, 0x00, 0x1E, 0x00, 0x03, 0xE0, 0x00, 0xFF, 0xC0, 0x7E, + 0xFF, 0xFF, 0xDF, 0xFF, 0xF1, 0xFF, 0xF8, 0x07, 0xFC, 0x00, 0x1E, 0x00, + 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1E, 0x00, 0x1E, 0x00, + 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, + 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, + 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1F, 0x00, 0x0F, 0xFF, 0x0F, 0xFF, + 0x07, 0xFF, 0x01, 0xFF, 0x00, 0x00, 0x03, 0xC0, 0x00, 0xFF, 0x00, 0x03, + 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, + 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, + 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, + 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x1F, 0xF8, + 0x00, 0x7D, 0xE0, 0x03, 0xF7, 0xC0, 0x1F, 0xDF, 0x81, 0xFF, 0x3F, 0xFF, + 0xBC, 0x7F, 0xFC, 0xF0, 0xFF, 0xE3, 0xC0, 0xFE, 0x00, 0xF0, 0x00, 0x07, + 0xBC, 0x00, 0x07, 0x9E, 0x00, 0x03, 0xCF, 0x80, 0x03, 0xE3, 0xC0, 0x01, + 0xE1, 0xE0, 0x00, 0xF0, 0xF8, 0x00, 0xF8, 0x3C, 0x00, 0x78, 0x1E, 0x00, + 0x3C, 0x07, 0x80, 0x3C, 0x03, 0xC0, 0x1E, 0x01, 0xF0, 0x1F, 0x00, 0x78, + 0x0F, 0x00, 0x3C, 0x07, 0x80, 0x1F, 0x07, 0xC0, 0x07, 0x83, 0xC0, 0x03, + 0xC1, 0xE0, 0x01, 0xF1, 0xF0, 0x00, 0x78, 0xF0, 0x00, 0x3E, 0xF8, 0x00, + 0x0F, 0x78, 0x00, 0x07, 0xBC, 0x00, 0x03, 0xFE, 0x00, 0x00, 0xFE, 0x00, + 0x00, 0x7F, 0x00, 0x00, 0x3F, 0x80, 0x00, 0xF0, 0x03, 0xF8, 0x01, 0xFF, + 0x00, 0x7F, 0x00, 0x7D, 0xE0, 0x0F, 0xE0, 0x0F, 0x3C, 0x01, 0xFC, 0x01, + 0xE7, 0x80, 0x7F, 0xC0, 0x3C, 0xF8, 0x0F, 0x78, 0x0F, 0x8F, 0x01, 0xEF, + 0x01, 0xE1, 0xE0, 0x3D, 0xE0, 0x3C, 0x3C, 0x0F, 0x9E, 0x07, 0x83, 0xC1, + 0xE3, 0xC1, 0xF0, 0x78, 0x3C, 0x78, 0x3C, 0x0F, 0x07, 0x8F, 0x07, 0x81, + 0xE1, 0xE0, 0xF0, 0xF0, 0x1E, 0x3C, 0x1E, 0x3C, 0x03, 0xC7, 0x83, 0xC7, + 0x80, 0x78, 0xF0, 0x78, 0xF0, 0x0F, 0x3C, 0x07, 0x9E, 0x00, 0xF7, 0x80, + 0xF7, 0x80, 0x1E, 0xF0, 0x1E, 0xF0, 0x03, 0xFE, 0x03, 0xFE, 0x00, 0x7F, + 0x80, 0x3F, 0xC0, 0x07, 0xF0, 0x07, 0xF0, 0x00, 0xFE, 0x00, 0xFE, 0x00, + 0x1F, 0xC0, 0x1F, 0xC0, 0x03, 0xF0, 0x01, 0xF8, 0x00, 0x3E, 0x00, 0x3E, + 0x00, 0x7C, 0x00, 0x0F, 0x9F, 0x00, 0x0F, 0x87, 0xC0, 0x0F, 0x81, 0xF0, + 0x0F, 0x80, 0xF8, 0x07, 0xC0, 0x3E, 0x07, 0xC0, 0x0F, 0x87, 0xC0, 0x03, + 0xE7, 0xC0, 0x01, 0xF3, 0xE0, 0x00, 0x7F, 0xE0, 0x00, 0x1F, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x03, 0xF0, 0x00, 0x03, 0xF8, 0x00, 0x01, 0xFE, 0x00, + 0x01, 0xFF, 0x80, 0x01, 0xF7, 0xC0, 0x01, 0xF1, 0xF0, 0x00, 0xF8, 0x7C, + 0x00, 0xF8, 0x3E, 0x00, 0xF8, 0x0F, 0x80, 0xF8, 0x03, 0xE0, 0x7C, 0x00, + 0xF8, 0x7C, 0x00, 0x7C, 0x7C, 0x00, 0x1F, 0x7C, 0x00, 0x07, 0xC0, 0xF8, + 0x00, 0x0F, 0xBC, 0x00, 0x07, 0x9E, 0x00, 0x03, 0xCF, 0x80, 0x03, 0xE3, + 0xC0, 0x01, 0xE1, 0xF0, 0x01, 0xF0, 0x78, 0x00, 0xF0, 0x3C, 0x00, 0x78, + 0x1F, 0x00, 0x7C, 0x07, 0x80, 0x3C, 0x03, 0xE0, 0x3E, 0x00, 0xF0, 0x1E, + 0x00, 0x78, 0x0F, 0x00, 0x3E, 0x0F, 0x80, 0x0F, 0x07, 0x80, 0x07, 0xC7, + 0xC0, 0x01, 0xE3, 0xC0, 0x00, 0xF1, 0xE0, 0x00, 0x7D, 0xE0, 0x00, 0x1E, + 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x03, 0xF8, 0x00, 0x01, 0xFC, 0x00, 0x00, + 0xFC, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x0F, 0x80, 0x00, 0x07, 0x80, 0x00, 0x07, 0xC0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x3F, 0xF0, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x07, 0xE0, 0x00, 0x00, 0x7F, 0xFF, 0xFB, 0xFF, 0xFF, 0xDF, 0xFF, 0xFE, + 0xFF, 0xFF, 0xF0, 0x00, 0x1F, 0x00, 0x01, 0xF8, 0x00, 0x1F, 0x80, 0x00, + 0xF8, 0x00, 0x0F, 0x80, 0x00, 0xF8, 0x00, 0x0F, 0x80, 0x00, 0xF8, 0x00, + 0x0F, 0xC0, 0x00, 0xFC, 0x00, 0x07, 0xC0, 0x00, 0x7C, 0x00, 0x07, 0xC0, + 0x00, 0x7C, 0x00, 0x07, 0xE0, 0x00, 0x7E, 0x00, 0x07, 0xE0, 0x00, 0x3E, + 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xC0, 0x00, 0x0F, 0xC0, 0x1F, 0xF0, 0x0F, 0xFC, 0x03, 0xFF, 0x01, 0xF8, + 0x00, 0x7C, 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, + 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, 0x07, + 0x80, 0x01, 0xE0, 0x00, 0xF8, 0x00, 0x7C, 0x03, 0xFF, 0x00, 0xFF, 0x00, + 0x3F, 0xC0, 0x0F, 0xF8, 0x00, 0x3F, 0x00, 0x03, 0xE0, 0x00, 0xF8, 0x00, + 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, 0x07, + 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, + 0x00, 0x7C, 0x00, 0x1F, 0x80, 0x03, 0xFF, 0x00, 0xFF, 0xC0, 0x1F, 0xF0, + 0x01, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xF0, 0xFE, 0x00, 0x3F, 0xE0, 0x0F, 0xFC, 0x03, 0xFF, 0x00, 0x07, + 0xE0, 0x00, 0xF8, 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, + 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, + 0x07, 0x80, 0x01, 0xE0, 0x00, 0x7C, 0x00, 0x0F, 0x80, 0x03, 0xFF, 0x00, + 0x3F, 0xC0, 0x0F, 0xF0, 0x07, 0xFC, 0x03, 0xF0, 0x01, 0xF0, 0x00, 0x7C, + 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, + 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, + 0xE0, 0x00, 0xF8, 0x00, 0x7E, 0x03, 0xFF, 0x00, 0xFF, 0xC0, 0x3F, 0xE0, + 0x0F, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0x80, 0x00, 0x47, 0xFF, + 0x80, 0x0E, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, + 0xFB, 0x80, 0x0F, 0xFF, 0x18, 0x00, 0x0F, 0xE0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x03, 0xF8, 0x00, 0x07, 0xFF, 0xC0, 0x03, 0xFF, 0xF8, 0x03, 0xFF, + 0xFF, 0x00, 0xFC, 0x07, 0xC0, 0x7C, 0x00, 0x70, 0x3E, 0x00, 0x0C, 0x0F, + 0x00, 0x01, 0x07, 0xC0, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x3E, 0x00, 0x00, 0x7F, 0xFF, 0xFC, 0x1F, 0xFF, 0xFE, 0x0F, 0xFF, 0xFF, + 0x80, 0x3C, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x7F, 0xFF, 0xC0, 0x1F, 0xFF, 0xE0, 0x0F, + 0xFF, 0xF8, 0x00, 0x3E, 0x00, 0x00, 0x07, 0x80, 0x00, 0x01, 0xE0, 0x00, + 0x00, 0x78, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x03, 0xE0, 0x00, 0x40, 0xF8, + 0x00, 0x30, 0x1F, 0x80, 0x1C, 0x03, 0xF8, 0x1F, 0x00, 0x7F, 0xFF, 0xC0, + 0x0F, 0xFF, 0xE0, 0x01, 0xFF, 0xF0, 0x00, 0x0F, 0xE0, 0x3C, 0xF3, 0xCF, + 0x3C, 0xE7, 0x9C, 0x73, 0xCE, 0x00, 0x00, 0x0F, 0xF0, 0x03, 0xFF, 0x00, + 0x7F, 0xF0, 0x07, 0xFF, 0x00, 0xF8, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x0F, 0xFF, 0xE0, 0xFF, 0xFE, + 0x0F, 0xFF, 0xE0, 0xFF, 0xFE, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x01, 0xF0, + 0x00, 0x3E, 0x00, 0xFF, 0xE0, 0x0F, 0xFC, 0x00, 0xFF, 0x80, 0x0F, 0xF0, + 0x00, 0x3C, 0x1E, 0x78, 0x3C, 0xF0, 0x79, 0xE0, 0xF3, 0xC1, 0xE7, 0x03, + 0x9E, 0x0F, 0x38, 0x1C, 0x70, 0x39, 0xE0, 0xF3, 0x81, 0xC0, 0xF0, 0x00, + 0xF0, 0x00, 0xFF, 0x00, 0x0F, 0x00, 0x0F, 0xF0, 0x00, 0xF0, 0x00, 0xFF, + 0x00, 0x0F, 0x00, 0x0F, 0xF0, 0x00, 0xF0, 0x00, 0xFF, 0x00, 0x0F, 0x00, + 0x0F, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF0, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x07, 0xE0, 0x00, 0x0E, 0x00, 0x00, 0x00, + 0x07, 0xFE, 0x00, 0x07, 0x80, 0x00, 0x00, 0x03, 0xFF, 0xC0, 0x01, 0xC0, + 0x00, 0x00, 0x01, 0xF0, 0xF0, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x78, 0x1E, + 0x00, 0x38, 0x00, 0x00, 0x00, 0x1E, 0x07, 0x80, 0x1E, 0x00, 0x00, 0x00, + 0x0F, 0x00, 0xF0, 0x0F, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x3C, 0x03, 0x80, + 0x00, 0x00, 0x00, 0xF0, 0x0F, 0x01, 0xE0, 0x00, 0x00, 0x00, 0x3C, 0x03, + 0xC0, 0x70, 0x00, 0x00, 0x00, 0x0F, 0x00, 0xF0, 0x3C, 0x00, 0x00, 0x00, + 0x03, 0xC0, 0x3C, 0x0E, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x0F, 0x07, 0x00, + 0x00, 0x00, 0x00, 0x1E, 0x07, 0x83, 0xC0, 0x00, 0x00, 0x00, 0x07, 0x81, + 0xE0, 0xE0, 0x00, 0x00, 0x00, 0x01, 0xF0, 0xF8, 0x78, 0x00, 0x00, 0x00, + 0x00, 0x3F, 0xFC, 0x1C, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFE, 0x0F, 0x03, + 0xF0, 0x00, 0x7E, 0x00, 0x7E, 0x07, 0x83, 0xFF, 0x00, 0x7F, 0xE0, 0x00, + 0x01, 0xC1, 0xFF, 0xE0, 0x3F, 0xFC, 0x00, 0x00, 0xF0, 0xF8, 0x7C, 0x1F, + 0x0F, 0x80, 0x00, 0x38, 0x3C, 0x0F, 0x07, 0x81, 0xE0, 0x00, 0x1E, 0x0F, + 0x03, 0xC1, 0xE0, 0x78, 0x00, 0x07, 0x07, 0x80, 0x78, 0xF0, 0x0F, 0x00, + 0x03, 0x81, 0xE0, 0x1E, 0x3C, 0x03, 0xC0, 0x01, 0xE0, 0x78, 0x07, 0x8F, + 0x00, 0xF0, 0x00, 0x70, 0x1E, 0x01, 0xE3, 0xC0, 0x3C, 0x00, 0x3C, 0x07, + 0x80, 0x78, 0xF0, 0x0F, 0x00, 0x0E, 0x01, 0xE0, 0x1E, 0x3C, 0x03, 0xC0, + 0x07, 0x80, 0x78, 0x07, 0x8F, 0x00, 0xF0, 0x01, 0xC0, 0x0F, 0x03, 0xC1, + 0xE0, 0x78, 0x00, 0xE0, 0x03, 0xC0, 0xF0, 0x78, 0x1E, 0x00, 0x78, 0x00, + 0xF8, 0x78, 0x1F, 0x0F, 0x00, 0x1C, 0x00, 0x1F, 0xFE, 0x03, 0xFF, 0xC0, + 0x0F, 0x00, 0x03, 0xFF, 0x00, 0x7F, 0xE0, 0x03, 0x80, 0x00, 0x3F, 0x00, + 0x07, 0xE0, 0x00, 0x20, 0x0C, 0x03, 0x80, 0xF0, 0x3C, 0x0F, 0x03, 0xC1, + 0xF0, 0x7C, 0x1F, 0x03, 0xC0, 0x7C, 0x07, 0xC0, 0x7C, 0x03, 0xC0, 0x3C, + 0x03, 0xC0, 0x3C, 0x03, 0x80, 0x30, 0x02, 0x1C, 0xF3, 0x8E, 0x79, 0xCF, + 0x3C, 0xF3, 0xCF, 0x00, 0x3C, 0xF3, 0xCF, 0x3D, 0xE7, 0x9C, 0x73, 0xCE, + 0x00, 0x1C, 0x0E, 0x78, 0x3C, 0xE0, 0x71, 0xC0, 0xE7, 0x83, 0xCE, 0x07, + 0x3C, 0x1E, 0x78, 0x3C, 0xF0, 0x79, 0xE0, 0xF3, 0xC1, 0xE0, 0x3C, 0x1E, + 0x78, 0x3C, 0xF0, 0x79, 0xE0, 0xF3, 0xC1, 0xE7, 0x03, 0x9E, 0x0F, 0x38, + 0x1C, 0x70, 0x39, 0xE0, 0xF3, 0x81, 0xC0, 0x0F, 0xC0, 0x7F, 0x83, 0xFF, + 0x1F, 0xFE, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF7, 0xFF, 0x8F, 0xFC, 0x1F, 0xE0, 0x3F, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xE0, 0x1F, 0xFF, 0xF3, 0xE0, 0x7C, 0x1C, + 0x07, 0xE1, 0xF8, 0x38, 0x0E, 0xE3, 0x70, 0x70, 0x1C, 0xCC, 0xE0, 0xE0, + 0x39, 0xF9, 0xC1, 0xC0, 0x71, 0xE3, 0x83, 0x80, 0xE1, 0xC7, 0x07, 0x01, + 0xC3, 0x0E, 0x0E, 0x03, 0x80, 0x1C, 0x1C, 0x07, 0x00, 0x38, 0x38, 0x0E, + 0x00, 0x70, 0x70, 0x1C, 0x00, 0xE0, 0x80, 0x18, 0x03, 0x80, 0x78, 0x07, + 0x80, 0x78, 0x07, 0x80, 0x7C, 0x07, 0xC0, 0x7C, 0x07, 0x81, 0xF0, 0x7C, + 0x1F, 0x07, 0x81, 0xE0, 0x78, 0x1E, 0x03, 0x80, 0x60, 0x08, 0x00, 0x00, + 0x00, 0x3E, 0x00, 0xF8, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x00, + 0xF0, 0x03, 0xC0, 0x0F, 0x00, 0x00, 0x03, 0xE1, 0xF7, 0xC3, 0xEF, 0x87, + 0xDF, 0x0F, 0xBE, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x01, 0xE0, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x7C, 0x7C, 0x00, + 0x00, 0xF1, 0xFC, 0x00, 0x03, 0xC3, 0xF8, 0x00, 0x0F, 0x07, 0xF0, 0x00, + 0x1C, 0x1F, 0xF0, 0x00, 0x00, 0x3D, 0xE0, 0x00, 0x00, 0xFB, 0xE0, 0x00, + 0x01, 0xE3, 0xC0, 0x00, 0x03, 0xC7, 0x80, 0x00, 0x0F, 0x8F, 0x80, 0x00, + 0x1E, 0x0F, 0x00, 0x00, 0x3C, 0x1E, 0x00, 0x00, 0xF0, 0x1E, 0x00, 0x01, + 0xE0, 0x3C, 0x00, 0x07, 0xC0, 0x7C, 0x00, 0x0F, 0x00, 0x78, 0x00, 0x1E, + 0x00, 0xF0, 0x00, 0x7C, 0x01, 0xF0, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xE0, + 0x03, 0xE0, 0x07, 0x80, 0x03, 0xC0, 0x0F, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, + 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFE, 0x03, 0xE0, 0x00, + 0x3E, 0x07, 0x80, 0x00, 0x3C, 0x1F, 0x00, 0x00, 0x7C, 0x3C, 0x00, 0x00, + 0x78, 0x78, 0x00, 0x00, 0xF1, 0xF0, 0x00, 0x01, 0xF3, 0xC0, 0x00, 0x01, + 0xE7, 0x80, 0x00, 0x03, 0xDE, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x1F, 0xF0, + 0x01, 0xFF, 0xF0, 0x0F, 0xFF, 0xC0, 0x7F, 0xFF, 0x03, 0xF0, 0x0C, 0x0F, + 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, + 0x00, 0x78, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, 0x00, + 0x78, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x03, 0xFF, 0xFF, 0x0F, 0xFF, + 0xFC, 0x3F, 0xFF, 0xF0, 0xFF, 0xFF, 0xC0, 0x1E, 0x00, 0x00, 0x78, 0x00, + 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x01, + 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x3F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x20, 0x00, + 0x01, 0x1C, 0x00, 0x00, 0xEF, 0x80, 0x00, 0x7D, 0xF0, 0xFC, 0x3E, 0x3E, + 0xFF, 0xDF, 0x07, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xC0, 0x1F, 0x87, 0xE0, + 0x0F, 0x80, 0x7C, 0x03, 0xC0, 0x0F, 0x01, 0xF0, 0x03, 0xE0, 0x78, 0x00, + 0x78, 0x1E, 0x00, 0x1E, 0x07, 0x80, 0x07, 0x81, 0xE0, 0x01, 0xE0, 0x7C, + 0x00, 0xF8, 0x0F, 0x00, 0x3C, 0x03, 0xE0, 0x1F, 0x00, 0x7E, 0x1F, 0x80, + 0x3F, 0xFF, 0xF0, 0x1F, 0xFF, 0xFE, 0x0F, 0xBF, 0xF7, 0xC7, 0xC3, 0xF0, + 0xFB, 0xE0, 0x00, 0x1F, 0x70, 0x00, 0x03, 0x88, 0x00, 0x00, 0x40, 0xF8, + 0x00, 0x07, 0xDE, 0x00, 0x01, 0xE7, 0xC0, 0x00, 0xF8, 0xF8, 0x00, 0x7C, + 0x1E, 0x00, 0x1E, 0x07, 0xC0, 0x0F, 0x80, 0xF0, 0x03, 0xC0, 0x3E, 0x01, + 0xF0, 0x07, 0x80, 0x78, 0x01, 0xF0, 0x3E, 0x00, 0x3C, 0x0F, 0x00, 0x0F, + 0x87, 0x80, 0x01, 0xF3, 0xE0, 0x1F, 0xFC, 0xFF, 0xE7, 0xFF, 0xFF, 0xF9, + 0xFF, 0xFF, 0xFE, 0x00, 0x7F, 0x80, 0x00, 0x0F, 0xC0, 0x00, 0x03, 0xF0, + 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x1F, 0xFF, 0xFF, 0xE7, 0xFF, + 0xFF, 0xF9, 0xFF, 0xFF, 0xFE, 0x00, 0x1E, 0x00, 0x00, 0x07, 0x80, 0x00, + 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x07, 0x80, + 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x07, + 0x80, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, + 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xFE, + 0x00, 0xFF, 0xF0, 0x7F, 0xFE, 0x0F, 0x83, 0xC3, 0xE0, 0x08, 0x78, 0x00, + 0x0F, 0x00, 0x01, 0xE0, 0x00, 0x3E, 0x00, 0x03, 0xE0, 0x00, 0x7E, 0x00, + 0x07, 0xF0, 0x01, 0xFF, 0x00, 0x7B, 0xF8, 0x1E, 0x1F, 0x83, 0xC1, 0xFC, + 0xF0, 0x0F, 0xDE, 0x00, 0xFB, 0xC0, 0x0F, 0xFC, 0x00, 0xFF, 0x80, 0x1E, + 0xF8, 0x03, 0xCF, 0xC0, 0x78, 0xFC, 0x1E, 0x0F, 0xE7, 0xC0, 0x7F, 0xF0, + 0x03, 0xF8, 0x00, 0x3F, 0x00, 0x03, 0xF0, 0x00, 0x1E, 0x00, 0x03, 0xE0, + 0x00, 0x3C, 0x00, 0x07, 0x80, 0x00, 0xF0, 0x80, 0x3E, 0x1E, 0x0F, 0x83, + 0xFF, 0xE0, 0x7F, 0xF8, 0x03, 0xFC, 0x00, 0xF8, 0x7F, 0xE1, 0xFF, 0x87, + 0xFE, 0x1F, 0xF8, 0x7C, 0x00, 0x07, 0xF8, 0x00, 0x00, 0x0F, 0xFF, 0xC0, + 0x00, 0x0F, 0x80, 0x7C, 0x00, 0x07, 0x00, 0x03, 0x80, 0x03, 0x80, 0x00, + 0x70, 0x01, 0x80, 0x00, 0x06, 0x00, 0xC0, 0x3F, 0xC0, 0xC0, 0x60, 0x3F, + 0xFC, 0x18, 0x38, 0x3F, 0xFF, 0x07, 0x0C, 0x1F, 0x80, 0xC0, 0xC6, 0x07, + 0xC0, 0x00, 0x19, 0x83, 0xE0, 0x00, 0x06, 0x60, 0xF0, 0x00, 0x01, 0xB0, + 0x7C, 0x00, 0x00, 0x3C, 0x1E, 0x00, 0x00, 0x0F, 0x07, 0x80, 0x00, 0x03, + 0xC1, 0xE0, 0x00, 0x00, 0xF0, 0x78, 0x00, 0x00, 0x3C, 0x1E, 0x00, 0x00, + 0x0F, 0x07, 0x80, 0x00, 0x03, 0xC1, 0xF0, 0x00, 0x00, 0xD8, 0x3C, 0x00, + 0x00, 0x66, 0x0F, 0x80, 0x00, 0x19, 0x81, 0xF0, 0x00, 0x06, 0x30, 0x7E, + 0x03, 0x03, 0x0E, 0x0F, 0xFF, 0xC1, 0xC1, 0x80, 0xFF, 0xF0, 0x60, 0x30, + 0x0F, 0xF0, 0x30, 0x06, 0x00, 0x00, 0x18, 0x00, 0xE0, 0x00, 0x1C, 0x00, + 0x1C, 0x00, 0x0E, 0x00, 0x03, 0xE0, 0x1F, 0x00, 0x00, 0x3F, 0xFF, 0x00, + 0x00, 0x01, 0xFE, 0x00, 0x00, 0x00, 0x20, 0x08, 0x03, 0x00, 0xC0, 0x38, + 0x0E, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x07, + 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x3C, 0x0F, 0x01, 0xF0, 0x7C, + 0x07, 0xC1, 0xF0, 0x1F, 0x07, 0xC0, 0x3C, 0x0F, 0x00, 0xF0, 0x3C, 0x03, + 0xC0, 0xF0, 0x0F, 0x03, 0xC0, 0x38, 0x0E, 0x00, 0xC0, 0x30, 0x02, 0x00, + 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x3C, 0x00, + 0x00, 0x01, 0xE0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x03, 0xC0, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, + 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x07, 0xF8, 0x00, 0x00, + 0x0F, 0xFF, 0xC0, 0x00, 0x0F, 0x80, 0x7C, 0x00, 0x07, 0x00, 0x03, 0x80, + 0x03, 0x80, 0x00, 0x70, 0x01, 0x80, 0x00, 0x06, 0x00, 0xC3, 0xFF, 0x80, + 0xC0, 0x60, 0xFF, 0xF8, 0x18, 0x38, 0x3C, 0x0F, 0x07, 0x0C, 0x0F, 0x01, + 0xE0, 0xC6, 0x03, 0xC0, 0x78, 0x19, 0x80, 0xF0, 0x1E, 0x06, 0x60, 0x3C, + 0x07, 0x81, 0xB0, 0x0F, 0x01, 0xE0, 0x3C, 0x03, 0xC0, 0xF0, 0x0F, 0x00, + 0xFF, 0xF8, 0x03, 0xC0, 0x3F, 0xF0, 0x00, 0xF0, 0x0F, 0x1E, 0x00, 0x3C, + 0x03, 0xC3, 0xC0, 0x0F, 0x00, 0xF0, 0x78, 0x03, 0xC0, 0x3C, 0x1E, 0x00, + 0xD8, 0x0F, 0x07, 0xC0, 0x66, 0x03, 0xC0, 0xF0, 0x19, 0x80, 0xF0, 0x3E, + 0x06, 0x30, 0x3C, 0x07, 0x83, 0x0E, 0x0F, 0x01, 0xF1, 0xC1, 0x83, 0xC0, + 0x3C, 0x60, 0x30, 0xF0, 0x0F, 0xB0, 0x06, 0x00, 0x00, 0x18, 0x00, 0xE0, + 0x00, 0x1C, 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x03, 0xE0, 0x1F, 0x00, 0x00, + 0x3F, 0xFF, 0x00, 0x00, 0x01, 0xFE, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x07, 0xC0, 0x3F, 0xE0, + 0xFF, 0xE3, 0xC1, 0xE7, 0x01, 0xDC, 0x01, 0xF8, 0x03, 0xF0, 0x07, 0xE0, + 0x0F, 0xC0, 0x1D, 0xC0, 0x73, 0xC1, 0xE3, 0xFF, 0x83, 0xFE, 0x01, 0xF0, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, + 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x00, 0x78, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x03, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFC, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x01, 0xE0, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, + 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xC3, 0xFF, 0xCF, 0xFF, 0xB0, + 0x1F, 0x00, 0x3C, 0x00, 0x70, 0x01, 0xC0, 0x0E, 0x00, 0x78, 0x03, 0xC0, + 0x1E, 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x03, 0xE0, 0x1E, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xC0, 0x1F, 0xC1, 0xFF, 0xC7, 0xFF, 0x98, 0x0F, 0x00, + 0x1C, 0x00, 0x70, 0x03, 0x83, 0xFE, 0x0F, 0xE0, 0x3F, 0xE0, 0x07, 0x80, + 0x07, 0x00, 0x1C, 0x00, 0x70, 0x03, 0xF0, 0x1F, 0xFF, 0xFB, 0xFF, 0xC3, + 0xFC, 0x00, 0x03, 0xE0, 0xF8, 0x1E, 0x07, 0x81, 0xE0, 0x78, 0x0F, 0x03, + 0xC0, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0xF8, 0x01, 0xE0, 0x07, 0x80, 0x1E, + 0x00, 0x78, 0x00, 0xF0, 0x03, 0xC0, 0x0F, 0x00, 0x00, 0x03, 0xE1, 0xF7, + 0xC3, 0xEF, 0x87, 0xDF, 0x0F, 0xBE, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, + 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, + 0x7C, 0x7C, 0x00, 0x00, 0xF1, 0xFC, 0x00, 0x03, 0xC3, 0xF8, 0x00, 0x0F, + 0x07, 0xF0, 0x00, 0x1C, 0x1F, 0xF0, 0x00, 0x00, 0x3D, 0xE0, 0x00, 0x00, + 0xFB, 0xE0, 0x00, 0x01, 0xE3, 0xC0, 0x00, 0x03, 0xC7, 0x80, 0x00, 0x0F, + 0x8F, 0x80, 0x00, 0x1E, 0x0F, 0x00, 0x00, 0x3C, 0x1E, 0x00, 0x00, 0xF0, + 0x1E, 0x00, 0x01, 0xE0, 0x3C, 0x00, 0x07, 0xC0, 0x7C, 0x00, 0x0F, 0x00, + 0x78, 0x00, 0x1E, 0x00, 0xF0, 0x00, 0x7C, 0x01, 0xF0, 0x00, 0xF0, 0x01, + 0xE0, 0x03, 0xE0, 0x03, 0xE0, 0x07, 0x80, 0x03, 0xC0, 0x0F, 0xFF, 0xFF, + 0x80, 0x3F, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFE, + 0x03, 0xE0, 0x00, 0x3E, 0x07, 0x80, 0x00, 0x3C, 0x1F, 0x00, 0x00, 0x7C, + 0x3C, 0x00, 0x00, 0x78, 0x78, 0x00, 0x00, 0xF1, 0xF0, 0x00, 0x01, 0xF3, + 0xC0, 0x00, 0x01, 0xE7, 0x80, 0x00, 0x03, 0xDE, 0x00, 0x00, 0x03, 0xC0, + 0xFF, 0xFF, 0xFF, 0x03, 0xC0, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x01, 0xE7, 0xFF, 0xFF, 0xE7, 0x8F, + 0xFF, 0xFF, 0xCF, 0x1F, 0xFF, 0xFF, 0xBC, 0x3F, 0xFF, 0xFF, 0xF0, 0x78, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x1E, 0x00, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x01, 0xFF, 0xFF, 0xF0, 0x03, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, + 0xC0, 0x0F, 0xFF, 0xFF, 0x80, 0x1E, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x00, 0x78, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, + 0x03, 0xC0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0x1E, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x01, 0xFF, 0xFF, 0xF8, 0x03, 0xFF, 0xFF, 0xF0, 0x07, + 0xFF, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xC0, 0x03, 0xE0, 0x00, 0x00, 0x00, + 0x78, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, + 0x01, 0xE7, 0x80, 0x00, 0x1E, 0x3C, 0xF0, 0x00, 0x03, 0xCF, 0x1E, 0x00, + 0x00, 0x7B, 0xC3, 0xC0, 0x00, 0x0F, 0x70, 0x78, 0x00, 0x01, 0xE0, 0x0F, + 0x00, 0x00, 0x3C, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x3C, 0x00, 0x00, 0xF0, + 0x07, 0x80, 0x00, 0x1E, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x1E, 0x00, 0x00, + 0x78, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x78, 0x00, 0x01, 0xE0, 0x0F, 0x00, + 0x00, 0x3C, 0x01, 0xFF, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0xFF, 0xF0, 0x07, + 0xFF, 0xFF, 0xFE, 0x00, 0xFF, 0xFF, 0xFF, 0xC0, 0x1E, 0x00, 0x00, 0x78, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x78, 0x00, 0x01, 0xE0, 0x0F, 0x00, 0x00, + 0x3C, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x3C, 0x00, 0x00, 0xF0, 0x07, 0x80, + 0x00, 0x1E, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x1E, 0x00, 0x00, 0x78, 0x03, + 0xC0, 0x00, 0x0F, 0x00, 0x78, 0x00, 0x01, 0xE0, 0x0F, 0x00, 0x00, 0x3C, + 0x01, 0xE0, 0x00, 0x07, 0x80, 0x3C, 0x00, 0x00, 0xF0, 0x07, 0x80, 0x00, + 0x1E, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x03, 0xE0, 0x3E, 0x01, 0xE0, 0x1E, + 0x01, 0xE7, 0x8E, 0x3C, 0xF1, 0xEF, 0x0F, 0xF0, 0x78, 0x03, 0xC0, 0x1E, + 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x01, 0xE0, 0x0F, 0x00, 0x78, 0x03, 0xC0, + 0x1E, 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x01, 0xE0, 0x0F, 0x00, 0x78, 0x03, + 0xC0, 0x1E, 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x01, 0xE0, 0x0F, 0x00, 0x78, + 0x03, 0xC0, 0x1E, 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x80, 0x20, 0x06, 0x01, + 0x80, 0x38, 0x0E, 0x01, 0xE0, 0x78, 0x07, 0x81, 0xE0, 0x1E, 0x07, 0x80, + 0x78, 0x1E, 0x01, 0xF0, 0x7C, 0x07, 0xC1, 0xF0, 0x1F, 0x07, 0xC0, 0x78, + 0x1E, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x78, 0x1E, 0x07, + 0x81, 0xE0, 0x78, 0x1E, 0x07, 0x81, 0xE0, 0x38, 0x0E, 0x01, 0x80, 0x60, + 0x08, 0x02, 0x00, 0x00, 0x03, 0xE0, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x00, 0x00, 0xF0, 0x0F, 0xF0, 0x00, 0x1E, 0x07, + 0xFF, 0xE0, 0x01, 0xE1, 0xFF, 0xFF, 0x80, 0x3C, 0x3F, 0xFF, 0xFC, 0x07, + 0x87, 0xF0, 0x0F, 0xE0, 0x70, 0xFC, 0x00, 0x3F, 0x00, 0x1F, 0x00, 0x01, + 0xF8, 0x03, 0xE0, 0x00, 0x0F, 0x80, 0x3E, 0x00, 0x00, 0x7C, 0x07, 0xC0, + 0x00, 0x03, 0xC0, 0x78, 0x00, 0x00, 0x3E, 0x07, 0x80, 0x00, 0x01, 0xE0, + 0xF8, 0x00, 0x00, 0x1E, 0x0F, 0x00, 0x00, 0x01, 0xF0, 0xF0, 0x00, 0x00, + 0x0F, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0xF0, 0x00, 0x00, 0x0F, 0x0F, 0x00, + 0x00, 0x00, 0xF0, 0xF0, 0x00, 0x00, 0x0F, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0xF0, 0x00, 0x00, 0x0F, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0xF0, 0x00, 0x00, + 0x1F, 0x0F, 0x80, 0x00, 0x01, 0xE0, 0x78, 0x00, 0x00, 0x1E, 0x07, 0x80, + 0x00, 0x03, 0xE0, 0x7C, 0x00, 0x00, 0x3C, 0x03, 0xE0, 0x00, 0x07, 0xC0, + 0x3E, 0x00, 0x00, 0xF8, 0x01, 0xF0, 0x00, 0x1F, 0x80, 0x0F, 0xC0, 0x03, + 0xF0, 0x00, 0x7F, 0x00, 0xFE, 0x00, 0x03, 0xFF, 0xFF, 0xC0, 0x00, 0x1F, + 0xFF, 0xF8, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x00, 0x01, 0xFF, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0x1E, 0x01, 0xFE, 0x00, 0x00, 0x78, 0x03, 0xFC, 0x00, + 0x00, 0xE0, 0x07, 0x38, 0x00, 0x03, 0xC0, 0x00, 0x70, 0x00, 0x07, 0x00, + 0x00, 0xE0, 0x00, 0x1E, 0x00, 0x01, 0xC0, 0x00, 0x78, 0x00, 0x03, 0x80, + 0x00, 0xE0, 0x00, 0x07, 0x00, 0x03, 0xC0, 0x00, 0x0E, 0x00, 0x07, 0x00, + 0x00, 0x1C, 0x00, 0x1E, 0x00, 0x00, 0x38, 0x00, 0x78, 0x00, 0x00, 0x70, + 0x00, 0xE0, 0x00, 0x00, 0xE0, 0x03, 0xC0, 0x00, 0x01, 0xC0, 0x07, 0x00, + 0x00, 0x03, 0x80, 0x1E, 0x0F, 0xF0, 0xFF, 0xF8, 0x78, 0x7F, 0xF9, 0xFF, + 0xF0, 0xE0, 0xFF, 0xFB, 0xFF, 0xE3, 0xC1, 0x80, 0xF0, 0x00, 0x07, 0x00, + 0x00, 0xF0, 0x00, 0x1E, 0x00, 0x00, 0xE0, 0x00, 0x78, 0x00, 0x01, 0x80, + 0x00, 0xE0, 0x00, 0x07, 0x00, 0x03, 0xC0, 0x00, 0x1C, 0x00, 0x07, 0x00, + 0x00, 0x78, 0x00, 0x1E, 0x00, 0x01, 0xE0, 0x00, 0x38, 0x00, 0x07, 0x80, + 0x00, 0xE0, 0x00, 0x1E, 0x00, 0x03, 0xC0, 0x00, 0xF8, 0x00, 0x07, 0x00, + 0x03, 0xE0, 0x00, 0x1E, 0x00, 0x1F, 0x00, 0x00, 0x38, 0x00, 0x3F, 0xFF, + 0x00, 0xE0, 0x00, 0x7F, 0xFE, 0x03, 0xC0, 0x00, 0xFF, 0xFC, 0x07, 0x00, + 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, + 0x00, 0x1F, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x1E, 0x3E, 0x00, 0x00, 0x78, 0xF0, 0x7C, 0x00, 0x03, + 0xE3, 0x81, 0xF0, 0x00, 0x1F, 0x1E, 0x03, 0xE0, 0x00, 0x78, 0xF0, 0x07, + 0xC0, 0x03, 0xE0, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00, 0x3E, 0x00, 0x78, + 0x00, 0x00, 0x7C, 0x03, 0xE0, 0x00, 0x00, 0xF0, 0x1F, 0x00, 0x00, 0x03, + 0xE0, 0x78, 0x00, 0x00, 0x07, 0xC3, 0xE0, 0x00, 0x00, 0x0F, 0x1F, 0x00, + 0x00, 0x00, 0x3E, 0x78, 0x00, 0x00, 0x00, 0x7F, 0xE0, 0x00, 0x00, 0x00, + 0xFF, 0x00, 0x00, 0x00, 0x03, 0xF8, 0x00, 0x00, 0x00, 0x07, 0xE0, 0x00, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x03, + 0xC0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x03, 0xC0, 0x00, + 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x00, 0xF0, + 0x0F, 0xF8, 0x00, 0x1E, 0x07, 0xFF, 0xE0, 0x03, 0xC1, 0xFF, 0xFF, 0x80, + 0x78, 0x3F, 0xFF, 0xFE, 0x07, 0x07, 0xF8, 0x0F, 0xF0, 0xF0, 0xFC, 0x00, + 0x3F, 0x80, 0x1F, 0x80, 0x00, 0xF8, 0x03, 0xE0, 0x00, 0x07, 0xC0, 0x3E, + 0x00, 0x00, 0x3E, 0x07, 0xC0, 0x00, 0x01, 0xE0, 0x78, 0x00, 0x00, 0x1E, + 0x07, 0x80, 0x00, 0x01, 0xF0, 0x78, 0x00, 0x00, 0x0F, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0xF0, 0x00, 0x00, 0x0F, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0xF0, + 0x00, 0x00, 0x0F, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0xF0, 0x00, 0x00, 0x0F, + 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x78, 0x00, 0x00, 0x0F, 0x07, 0x80, 0x00, + 0x01, 0xF0, 0x78, 0x00, 0x00, 0x1E, 0x03, 0xC0, 0x00, 0x01, 0xE0, 0x3C, + 0x00, 0x00, 0x3E, 0x01, 0xE0, 0x00, 0x07, 0xC0, 0x1F, 0x00, 0x00, 0x78, + 0x00, 0xF8, 0x00, 0x0F, 0x80, 0x07, 0xC0, 0x01, 0xF0, 0x00, 0x3E, 0x00, + 0x3E, 0x00, 0x01, 0xF0, 0x0F, 0xC0, 0x0F, 0xFF, 0x81, 0xFF, 0xF0, 0xFF, + 0xF8, 0x1F, 0xFF, 0x0F, 0xFF, 0x81, 0xFF, 0xF0, 0xFF, 0xF8, 0x1F, 0xFF, + 0x00, 0x3E, 0x00, 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x3C, 0x00, + 0xF0, 0x03, 0xC0, 0x07, 0x00, 0x00, 0x03, 0xE1, 0xF7, 0xC3, 0xEF, 0x87, + 0xDF, 0x0F, 0xBE, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x07, 0x80, 0x0F, 0x00, 0x1E, + 0x00, 0x3C, 0x00, 0x78, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x07, 0x80, + 0x0F, 0x00, 0x1E, 0x00, 0x3C, 0x00, 0x78, 0x00, 0xF0, 0x01, 0xE0, 0x03, + 0xC0, 0x03, 0xC0, 0x07, 0x80, 0x0F, 0x80, 0x1F, 0xF0, 0x1F, 0xE0, 0x1F, + 0xC0, 0x0F, 0x80, 0x00, 0x07, 0xC0, 0x00, 0x00, 0x1F, 0xC0, 0x00, 0x00, + 0x3F, 0x80, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x03, + 0xDE, 0x00, 0x00, 0x0F, 0xBE, 0x00, 0x00, 0x1E, 0x3C, 0x00, 0x00, 0x3C, + 0x78, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x01, 0xE0, 0xF0, 0x00, 0x03, 0xC1, + 0xE0, 0x00, 0x0F, 0x01, 0xE0, 0x00, 0x1E, 0x03, 0xC0, 0x00, 0x7C, 0x07, + 0xC0, 0x00, 0xF0, 0x07, 0x80, 0x01, 0xE0, 0x0F, 0x00, 0x07, 0xC0, 0x1F, + 0x00, 0x0F, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x78, 0x00, 0x3C, + 0x00, 0xFF, 0xFF, 0xF8, 0x03, 0xFF, 0xFF, 0xF8, 0x07, 0xFF, 0xFF, 0xF0, + 0x0F, 0xFF, 0xFF, 0xE0, 0x3E, 0x00, 0x03, 0xE0, 0x78, 0x00, 0x03, 0xC1, + 0xF0, 0x00, 0x07, 0xC3, 0xC0, 0x00, 0x07, 0x87, 0x80, 0x00, 0x0F, 0x1F, + 0x00, 0x00, 0x1F, 0x3C, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x3D, 0xE0, + 0x00, 0x00, 0x3C, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xE0, 0xFF, 0xFF, 0xF8, + 0xFF, 0xFF, 0xF8, 0xF0, 0x01, 0xFC, 0xF0, 0x00, 0x7E, 0xF0, 0x00, 0x3E, + 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, + 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0x7C, 0xF0, 0x01, 0xFC, 0xFF, 0xFF, 0xF8, + 0xFF, 0xFF, 0xE0, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xF8, 0xF0, 0x00, 0xFC, + 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x1F, 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0xFE, 0xFF, 0xFF, 0xFC, + 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x78, 0x00, + 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, + 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, + 0x1E, 0x00, 0x00, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, + 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, + 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x00, + 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xF0, 0x00, 0x07, + 0x80, 0x00, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0x1F, 0xC0, 0x00, 0x00, + 0x3F, 0x80, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x01, 0xEF, 0x00, 0x00, 0x03, + 0xDE, 0x00, 0x00, 0x0F, 0xBE, 0x00, 0x00, 0x1E, 0x3C, 0x00, 0x00, 0x3C, + 0x78, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x01, 0xE0, 0xF0, 0x00, 0x03, 0xC1, + 0xE0, 0x00, 0x0F, 0x01, 0xE0, 0x00, 0x1E, 0x03, 0xC0, 0x00, 0x7C, 0x07, + 0xC0, 0x00, 0xF0, 0x07, 0x80, 0x01, 0xE0, 0x0F, 0x00, 0x07, 0xC0, 0x1F, + 0x00, 0x0F, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x78, 0x00, 0x3C, + 0x00, 0xF0, 0x00, 0x78, 0x03, 0xE0, 0x00, 0xF8, 0x07, 0x80, 0x00, 0xF0, + 0x0F, 0x00, 0x01, 0xE0, 0x3E, 0x00, 0x01, 0xE0, 0x78, 0x00, 0x03, 0xC1, + 0xF0, 0x00, 0x07, 0xC3, 0xC0, 0x00, 0x07, 0x87, 0x80, 0x00, 0x0F, 0x1F, + 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFD, 0xFF, + 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, + 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0xFF, 0xFF, 0xBF, 0xFF, 0xFE, 0xFF, + 0xFF, 0xFB, 0xFF, 0xFF, 0xEF, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, + 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF0, 0x7F, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xE7, + 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, + 0x1F, 0x80, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, + 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x01, 0xF0, 0x00, 0x00, + 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x00, + 0x00, 0x01, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, + 0xF8, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x3E, 0x00, + 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x03, + 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, + 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, + 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, + 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, + 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, + 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, + 0xFF, 0xFF, 0x80, 0x03, 0xFF, 0xFF, 0xC0, 0x07, 0xF0, 0x0F, 0xE0, 0x0F, + 0xC0, 0x03, 0xF0, 0x1F, 0x80, 0x01, 0xF8, 0x3F, 0x00, 0x00, 0x7C, 0x3E, + 0x00, 0x00, 0x7C, 0x7C, 0x00, 0x00, 0x3C, 0x78, 0x00, 0x00, 0x1E, 0x78, + 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0xF0, 0x00, 0x00, 0x0F, 0xF0, + 0x00, 0x00, 0x0F, 0xF0, 0xFF, 0xFF, 0x0F, 0xF0, 0xFF, 0xFF, 0x0F, 0xF0, + 0xFF, 0xFF, 0x0F, 0xF0, 0xFF, 0xFF, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, + 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0x78, + 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x7C, + 0x00, 0x00, 0x3C, 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, 0x1F, + 0x80, 0x01, 0xF8, 0x0F, 0xC0, 0x03, 0xF0, 0x07, 0xF0, 0x0F, 0xE0, 0x03, + 0xFF, 0xFF, 0xC0, 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x7F, 0xFE, 0x00, 0x00, + 0x0F, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x1F, 0x9E, + 0x00, 0x07, 0xE3, 0xC0, 0x01, 0xF8, 0x78, 0x00, 0x7E, 0x0F, 0x00, 0x1F, + 0x81, 0xE0, 0x07, 0xE0, 0x3C, 0x01, 0xF8, 0x07, 0x80, 0x7E, 0x00, 0xF0, + 0x1F, 0x80, 0x1E, 0x07, 0xE0, 0x03, 0xC3, 0xF0, 0x00, 0x78, 0xFC, 0x00, + 0x0F, 0x3F, 0x00, 0x01, 0xEF, 0xC0, 0x00, 0x3F, 0xF0, 0x00, 0x07, 0xFC, + 0x00, 0x00, 0xFF, 0x80, 0x00, 0x1F, 0xF8, 0x00, 0x03, 0xDF, 0x80, 0x00, + 0x79, 0xF8, 0x00, 0x0F, 0x1F, 0x80, 0x01, 0xE0, 0xF8, 0x00, 0x3C, 0x0F, + 0x80, 0x07, 0x80, 0xF8, 0x00, 0xF0, 0x0F, 0x80, 0x1E, 0x00, 0xF8, 0x03, + 0xC0, 0x0F, 0x80, 0x78, 0x00, 0xF8, 0x0F, 0x00, 0x0F, 0x81, 0xE0, 0x00, + 0xF8, 0x3C, 0x00, 0x0F, 0x87, 0x80, 0x00, 0xF8, 0xF0, 0x00, 0x0F, 0x9E, + 0x00, 0x00, 0xFC, 0x00, 0x07, 0xC0, 0x00, 0x00, 0x1F, 0xC0, 0x00, 0x00, + 0x3F, 0x80, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x01, 0xEF, 0x00, 0x00, 0x03, + 0xDE, 0x00, 0x00, 0x0F, 0xBE, 0x00, 0x00, 0x1E, 0x3C, 0x00, 0x00, 0x3C, + 0x78, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x01, 0xE0, 0xF0, 0x00, 0x03, 0xC1, + 0xE0, 0x00, 0x0F, 0x01, 0xE0, 0x00, 0x1E, 0x03, 0xC0, 0x00, 0x7C, 0x07, + 0xC0, 0x00, 0xF0, 0x07, 0x80, 0x01, 0xE0, 0x0F, 0x00, 0x07, 0xC0, 0x1F, + 0x00, 0x0F, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x78, 0x00, 0x3C, + 0x00, 0xF0, 0x00, 0x78, 0x03, 0xE0, 0x00, 0xF8, 0x07, 0x80, 0x00, 0xF0, + 0x0F, 0x00, 0x01, 0xE0, 0x3E, 0x00, 0x03, 0xE0, 0x78, 0x00, 0x03, 0xC1, + 0xF0, 0x00, 0x07, 0xC3, 0xC0, 0x00, 0x07, 0x87, 0x80, 0x00, 0x0F, 0x1F, + 0x00, 0x00, 0x1F, 0x3C, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x3D, 0xE0, + 0x00, 0x00, 0x3C, 0xFE, 0x00, 0x00, 0xFF, 0xFC, 0x00, 0x01, 0xFF, 0xFC, + 0x00, 0x07, 0xFF, 0xF8, 0x00, 0x0F, 0xFF, 0xF0, 0x00, 0x1F, 0xFE, 0xF0, + 0x00, 0x7B, 0xFD, 0xE0, 0x00, 0xF7, 0xFB, 0xE0, 0x03, 0xEF, 0xF3, 0xC0, + 0x07, 0x9F, 0xE7, 0x80, 0x0F, 0x3F, 0xC7, 0x80, 0x3C, 0x7F, 0x8F, 0x00, + 0x78, 0xFF, 0x1F, 0x01, 0xF1, 0xFE, 0x1E, 0x03, 0xC3, 0xFC, 0x3C, 0x07, + 0x87, 0xF8, 0x3C, 0x1E, 0x0F, 0xF0, 0x78, 0x3C, 0x1F, 0xE0, 0xF8, 0xF8, + 0x3F, 0xC0, 0xF1, 0xE0, 0x7F, 0x81, 0xE3, 0xC0, 0xFF, 0x01, 0xEF, 0x01, + 0xFE, 0x03, 0xDE, 0x03, 0xFC, 0x07, 0xFC, 0x07, 0xF8, 0x07, 0xF0, 0x0F, + 0xF0, 0x0F, 0xE0, 0x1F, 0xE0, 0x1F, 0xC0, 0x3F, 0xC0, 0x1F, 0x00, 0x7F, + 0x80, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x01, 0xFE, 0x00, 0x00, 0x03, 0xFC, + 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x1F, 0xE0, + 0x00, 0x00, 0x3C, 0xFC, 0x00, 0x03, 0xFF, 0x80, 0x00, 0xFF, 0xE0, 0x00, + 0x3F, 0xFC, 0x00, 0x0F, 0xFF, 0x00, 0x03, 0xFF, 0xE0, 0x00, 0xFF, 0x78, + 0x00, 0x3F, 0xDF, 0x00, 0x0F, 0xF3, 0xE0, 0x03, 0xFC, 0x78, 0x00, 0xFF, + 0x1F, 0x00, 0x3F, 0xC3, 0xC0, 0x0F, 0xF0, 0xF8, 0x03, 0xFC, 0x1E, 0x00, + 0xFF, 0x07, 0xC0, 0x3F, 0xC0, 0xF0, 0x0F, 0xF0, 0x3E, 0x03, 0xFC, 0x07, + 0xC0, 0xFF, 0x00, 0xF0, 0x3F, 0xC0, 0x3E, 0x0F, 0xF0, 0x07, 0x83, 0xFC, + 0x01, 0xF0, 0xFF, 0x00, 0x3C, 0x3F, 0xC0, 0x0F, 0x8F, 0xF0, 0x01, 0xE3, + 0xFC, 0x00, 0x7C, 0xFF, 0x00, 0x0F, 0xBF, 0xC0, 0x01, 0xEF, 0xF0, 0x00, + 0x7F, 0xFC, 0x00, 0x0F, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x00, 0x7F, 0xF0, + 0x00, 0x1F, 0xFC, 0x00, 0x03, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x87, 0xFF, 0xFC, + 0x3F, 0xFF, 0xE1, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0x80, + 0x03, 0xFF, 0xFF, 0xC0, 0x07, 0xF0, 0x0F, 0xE0, 0x0F, 0xC0, 0x03, 0xF0, + 0x1F, 0x80, 0x01, 0xF8, 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, + 0x7C, 0x00, 0x00, 0x3C, 0x78, 0x00, 0x00, 0x3E, 0x78, 0x00, 0x00, 0x1E, + 0x78, 0x00, 0x00, 0x1E, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0x78, 0x00, 0x00, 0x1E, + 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x7C, 0x00, 0x00, 0x3C, + 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, 0x1F, 0x80, 0x01, 0xF8, + 0x0F, 0xC0, 0x03, 0xF0, 0x07, 0xF0, 0x0F, 0xE0, 0x03, 0xFF, 0xFF, 0xC0, + 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x7F, 0xFE, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, + 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, + 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, + 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, + 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, + 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, + 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, + 0x00, 0x00, 0xF0, 0xFF, 0xFE, 0x03, 0xFF, 0xFE, 0x0F, 0xFF, 0xFE, 0x3F, + 0xFF, 0xFC, 0xF0, 0x03, 0xFB, 0xC0, 0x03, 0xEF, 0x00, 0x07, 0xBC, 0x00, + 0x1F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, + 0xF0, 0x00, 0x3F, 0xC0, 0x01, 0xFF, 0x00, 0x07, 0xBC, 0x00, 0x3E, 0xF0, + 0x03, 0xFB, 0xFF, 0xFF, 0xCF, 0xFF, 0xFE, 0x3F, 0xFF, 0xE0, 0xFF, 0xFE, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, + 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, + 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFC, 0x00, 0x01, 0xF8, 0x00, 0x03, 0xF0, 0x00, 0x07, 0xE0, 0x00, 0x0F, + 0xC0, 0x00, 0x1F, 0x80, 0x00, 0x3E, 0x00, 0x00, 0x7C, 0x00, 0x00, 0xF8, + 0x00, 0x01, 0xF0, 0x00, 0x03, 0xE0, 0x00, 0x07, 0xC0, 0x00, 0x1F, 0x00, + 0x00, 0xF8, 0x00, 0x07, 0xC0, 0x00, 0x3E, 0x00, 0x01, 0xF8, 0x00, 0x07, + 0xC0, 0x00, 0x3E, 0x00, 0x01, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x3E, 0x00, + 0x01, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x7C, 0x00, 0x03, 0xE0, 0x00, 0x0F, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0xF8, 0x00, + 0x01, 0xF7, 0xC0, 0x00, 0x3E, 0x3E, 0x00, 0x07, 0xC3, 0xE0, 0x00, 0x7C, + 0x1F, 0x00, 0x0F, 0x80, 0xF8, 0x01, 0xF0, 0x0F, 0x80, 0x1F, 0x00, 0x7C, + 0x03, 0xE0, 0x03, 0xE0, 0x7C, 0x00, 0x3E, 0x07, 0xC0, 0x01, 0xF0, 0xF8, + 0x00, 0x0F, 0x9F, 0x00, 0x00, 0xF9, 0xF0, 0x00, 0x07, 0xFE, 0x00, 0x00, + 0x3F, 0xC0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x03, 0xFF, 0xFF, + 0xC0, 0x0F, 0xFF, 0xFF, 0xF0, 0x1F, 0xE3, 0xC7, 0xF8, 0x3F, 0x83, 0xC1, + 0xFC, 0x3E, 0x03, 0xC0, 0x7C, 0x7C, 0x03, 0xC0, 0x3E, 0x78, 0x03, 0xC0, + 0x1E, 0xF8, 0x03, 0xC0, 0x1F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF8, 0x03, 0xC0, 0x1F, 0x78, 0x03, 0xC0, + 0x1E, 0x7C, 0x03, 0xC0, 0x3E, 0x3E, 0x03, 0xC0, 0x7C, 0x3F, 0x83, 0xC1, + 0xFC, 0x1F, 0xE3, 0xC7, 0xF8, 0x0F, 0xFF, 0xFF, 0xF0, 0x03, 0xFF, 0xFF, + 0xC0, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x3E, 0x00, 0x01, 0xF0, 0xF8, 0x00, 0x1F, 0x03, 0xC0, 0x01, 0xF0, + 0x1F, 0x00, 0x0F, 0x80, 0x7C, 0x00, 0xF8, 0x01, 0xE0, 0x0F, 0x80, 0x0F, + 0x80, 0x7C, 0x00, 0x3E, 0x07, 0xC0, 0x00, 0xF0, 0x7C, 0x00, 0x07, 0xC3, + 0xE0, 0x00, 0x1F, 0x3E, 0x00, 0x00, 0xFB, 0xE0, 0x00, 0x03, 0xFF, 0x00, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, + 0x0F, 0xC0, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, 0x7F, + 0xE0, 0x00, 0x07, 0xDF, 0x00, 0x00, 0x3C, 0x7C, 0x00, 0x03, 0xE1, 0xF0, + 0x00, 0x3E, 0x0F, 0x80, 0x03, 0xE0, 0x3E, 0x00, 0x1F, 0x00, 0xF0, 0x01, + 0xF0, 0x07, 0xC0, 0x1F, 0x00, 0x1F, 0x00, 0xF8, 0x00, 0x78, 0x0F, 0x80, + 0x03, 0xE0, 0xF8, 0x00, 0x0F, 0x87, 0xC0, 0x00, 0x3C, 0x7C, 0x00, 0x01, + 0xF7, 0xC0, 0x00, 0x07, 0xC0, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0x78, 0x03, 0xC0, 0x1E, 0x78, 0x03, 0xC0, 0x1E, 0x78, 0x03, 0xC0, + 0x1E, 0x7C, 0x03, 0xC0, 0x3C, 0x3E, 0x03, 0xC0, 0x7C, 0x3E, 0x03, 0xC0, + 0x7C, 0x1F, 0x03, 0xC0, 0xF8, 0x0F, 0xC3, 0xC3, 0xF0, 0x07, 0xF3, 0xCF, + 0xE0, 0x03, 0xFF, 0xFF, 0xC0, 0x01, 0xFF, 0xFF, 0x80, 0x00, 0xFF, 0xFF, + 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x0F, 0xF0, + 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0x80, 0x03, 0xFF, 0xFF, + 0xC0, 0x07, 0xF0, 0x0F, 0xE0, 0x0F, 0xC0, 0x03, 0xF0, 0x1F, 0x00, 0x00, + 0xF8, 0x3E, 0x00, 0x00, 0x7C, 0x3C, 0x00, 0x00, 0x3C, 0x7C, 0x00, 0x00, + 0x3E, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0xF0, 0x00, 0x00, + 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, + 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, + 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x1F, 0x78, 0x00, 0x00, + 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x7C, 0x00, 0x00, 0x3E, 0x3C, 0x00, 0x00, + 0x3C, 0x3E, 0x00, 0x00, 0x7C, 0x1E, 0x00, 0x00, 0xF8, 0x0F, 0x00, 0x00, + 0xF0, 0x0F, 0x80, 0x01, 0xF0, 0x07, 0xE0, 0x07, 0xE0, 0x03, 0xF0, 0x0F, + 0xC0, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xF8, 0x1F, + 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xF8, 0x7F, 0xE1, 0xFF, 0x87, 0xFE, 0x1F, + 0xF8, 0x7C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, + 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, + 0x00, 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x01, 0xE0, 0x07, + 0x80, 0x1E, 0x00, 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x01, + 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, + 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x01, 0xE0, 0x01, 0xF0, + 0xF8, 0x00, 0x1F, 0x0F, 0x80, 0x01, 0xF0, 0xF8, 0x00, 0x1F, 0x0F, 0x80, + 0x01, 0xF0, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x00, 0x01, + 0xF7, 0xC0, 0x00, 0x3E, 0x3E, 0x00, 0x07, 0xC3, 0xE0, 0x00, 0x7C, 0x1F, + 0x00, 0x0F, 0x80, 0xF8, 0x01, 0xF0, 0x0F, 0x80, 0x1F, 0x00, 0x7C, 0x03, + 0xE0, 0x03, 0xE0, 0x7C, 0x00, 0x3E, 0x07, 0xC0, 0x01, 0xF0, 0xF8, 0x00, + 0x0F, 0x9F, 0x00, 0x00, 0xF9, 0xF0, 0x00, 0x07, 0xFE, 0x00, 0x00, 0x3F, + 0xC0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x01, 0xE0, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x03, 0xC0, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x70, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xF0, 0x00, 0x07, + 0xFF, 0x07, 0x83, 0xFF, 0xE3, 0xE1, 0xFF, 0xFC, 0xF0, 0xFC, 0x3F, 0x3C, + 0x3C, 0x03, 0xDF, 0x1F, 0x00, 0xFF, 0x87, 0x80, 0x1F, 0xE1, 0xE0, 0x07, + 0xF8, 0xF8, 0x01, 0xFC, 0x3C, 0x00, 0x7F, 0x0F, 0x00, 0x0F, 0x83, 0xC0, + 0x03, 0xE0, 0xF0, 0x00, 0xF8, 0x3C, 0x00, 0x3C, 0x0F, 0x00, 0x0F, 0x03, + 0xC0, 0x03, 0xE0, 0xF0, 0x01, 0xF8, 0x3C, 0x00, 0x7E, 0x07, 0x80, 0x1F, + 0x81, 0xE0, 0x0F, 0xE0, 0x7C, 0x03, 0xFC, 0x0F, 0x81, 0xFF, 0x83, 0xF0, + 0xFB, 0xFC, 0x7F, 0xFE, 0xFF, 0x0F, 0xFF, 0x1F, 0xC1, 0xFF, 0x81, 0xF0, + 0x1F, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, 0xF8, 0x00, 0x3E, 0x00, 0x07, + 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x0E, 0x00, 0x03, 0xC0, 0x00, 0xF0, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0x80, 0xFF, 0xFC, 0x3F, 0xFF, + 0x8F, 0xFF, 0xF3, 0xF8, 0x06, 0x7C, 0x00, 0x0F, 0x00, 0x01, 0xE0, 0x00, + 0x3C, 0x00, 0x07, 0x80, 0x00, 0x78, 0x00, 0x07, 0xC0, 0x00, 0x3F, 0xE0, + 0x0F, 0xFC, 0x03, 0xFF, 0x81, 0xFF, 0xF0, 0x3F, 0x00, 0x0F, 0x80, 0x01, + 0xE0, 0x00, 0x3C, 0x00, 0x07, 0x80, 0x00, 0xF0, 0x00, 0x1F, 0x00, 0x01, + 0xF8, 0x01, 0x9F, 0xFF, 0xF3, 0xFF, 0xFE, 0x1F, 0xFF, 0xC0, 0x7F, 0xE0, + 0x00, 0x00, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xF0, 0x00, + 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xC0, 0xF1, 0xFF, 0xC3, + 0xCF, 0xFF, 0x8F, 0x7F, 0xFF, 0x3F, 0xE0, 0x7E, 0xFE, 0x00, 0xFB, 0xF0, + 0x01, 0xEF, 0x80, 0x07, 0xFE, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, + 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, + 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, + 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, + 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, + 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, + 0x01, 0xE0, 0x3C, 0x07, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x1C, 0x03, 0xC0, + 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, + 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, + 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, + 0xF8, 0x07, 0xC0, 0x7F, 0xC3, 0xFC, 0x1F, 0xC0, 0xFC, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x78, 0x00, 0x07, 0x80, 0x00, 0x78, + 0x00, 0x07, 0x80, 0x00, 0x38, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, + 0x87, 0xC0, 0x7C, 0x3E, 0x03, 0xE1, 0xF0, 0x1F, 0x0F, 0x80, 0xF8, 0x7C, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x78, 0x01, 0xE3, 0xC0, 0x07, 0x9E, 0x00, 0x3C, + 0xF0, 0x00, 0xF7, 0x80, 0x07, 0xBC, 0x00, 0x3D, 0xE0, 0x00, 0xFF, 0x00, + 0x07, 0xF8, 0x00, 0x3F, 0xC0, 0x01, 0xFE, 0x00, 0x0F, 0xF0, 0x00, 0x7F, + 0x80, 0x03, 0xFC, 0x00, 0x1F, 0xE0, 0x01, 0xFF, 0x00, 0x0F, 0x78, 0x00, + 0x7B, 0xC0, 0x07, 0xDE, 0x00, 0x3C, 0xF8, 0x03, 0xE3, 0xC0, 0x3E, 0x1F, + 0x87, 0xF0, 0x7F, 0xFF, 0x03, 0xFF, 0xF0, 0x07, 0xFE, 0x00, 0x1F, 0xC0, + 0x00, 0x01, 0xFC, 0x00, 0x01, 0xFF, 0xC1, 0xE0, 0xFF, 0xF8, 0xF8, 0x7F, + 0xFF, 0x3C, 0x3F, 0x0F, 0xCF, 0x0F, 0x00, 0xF7, 0xC7, 0xC0, 0x3F, 0xE1, + 0xE0, 0x07, 0xF8, 0x78, 0x01, 0xFE, 0x3E, 0x00, 0x7F, 0x0F, 0x00, 0x1F, + 0xC3, 0xC0, 0x03, 0xE0, 0xF0, 0x00, 0xF8, 0x3C, 0x00, 0x3E, 0x0F, 0x00, + 0x0F, 0x03, 0xC0, 0x03, 0xC0, 0xF0, 0x00, 0xF8, 0x3C, 0x00, 0x7E, 0x0F, + 0x00, 0x1F, 0x81, 0xE0, 0x07, 0xE0, 0x78, 0x03, 0xF8, 0x1F, 0x00, 0xFF, + 0x03, 0xE0, 0x7F, 0xE0, 0xFC, 0x3E, 0xFF, 0x1F, 0xFF, 0xBF, 0xC3, 0xFF, + 0xC7, 0xF0, 0x7F, 0xE0, 0x7C, 0x07, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x3F, + 0xFC, 0x01, 0xFF, 0xFC, 0x0F, 0xFF, 0xF8, 0x7E, 0x07, 0xE1, 0xF0, 0x07, + 0xCF, 0x80, 0x0F, 0x3E, 0x00, 0x3C, 0xF0, 0x00, 0xF3, 0xC0, 0x03, 0xCF, + 0x00, 0x0F, 0x3C, 0x00, 0x3C, 0xF0, 0x01, 0xF3, 0xC0, 0x0F, 0x8F, 0x00, + 0x7E, 0x3C, 0x07, 0xF0, 0xF1, 0xFF, 0xC3, 0xC7, 0xFC, 0x0F, 0x1F, 0xFC, + 0x3C, 0x7F, 0xF8, 0xF0, 0x07, 0xF3, 0xC0, 0x03, 0xEF, 0x00, 0x07, 0xBC, + 0x00, 0x1F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, + 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x01, 0xFF, 0x80, 0x07, 0xBF, 0x00, 0x3E, + 0xFF, 0x03, 0xFB, 0xFF, 0xFF, 0xCF, 0xFF, 0xFE, 0x3D, 0xFF, 0xF0, 0xF1, + 0xFE, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, + 0xC0, 0x00, 0x00, 0xF0, 0x00, 0x07, 0xFF, 0x00, 0x01, 0xEF, 0xE0, 0x00, + 0x7B, 0xF8, 0x00, 0x3E, 0x1F, 0x00, 0x0F, 0x03, 0xC0, 0x07, 0xC0, 0xF0, + 0x01, 0xE0, 0x1E, 0x00, 0xF8, 0x07, 0x80, 0x3C, 0x01, 0xF0, 0x0F, 0x00, + 0x3C, 0x07, 0xC0, 0x0F, 0x01, 0xE0, 0x03, 0xE0, 0xF8, 0x00, 0x78, 0x3C, + 0x00, 0x1E, 0x0F, 0x00, 0x07, 0xC7, 0x80, 0x00, 0xF1, 0xE0, 0x00, 0x3C, + 0xF8, 0x00, 0x0F, 0xBC, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x7F, 0x80, 0x00, + 0x1F, 0xE0, 0x00, 0x03, 0xF0, 0x00, 0x00, 0xFC, 0x00, 0x00, 0x3F, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x1E, 0x00, 0x00, 0x07, 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, + 0x00, 0xFF, 0xC0, 0x03, 0xFF, 0xF0, 0x0F, 0xFF, 0xF8, 0x0F, 0xFF, 0xF8, + 0x1F, 0x80, 0x38, 0x1E, 0x00, 0x08, 0x1E, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x1F, 0x80, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xFF, 0x00, 0x07, 0xFF, 0xC0, + 0x07, 0xFF, 0xF0, 0x1F, 0x1F, 0xF8, 0x1E, 0x01, 0xFC, 0x3C, 0x00, 0x7C, + 0x78, 0x00, 0x3E, 0x78, 0x00, 0x1E, 0x70, 0x00, 0x1F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x1F, 0x78, 0x00, 0x1E, + 0x78, 0x00, 0x3E, 0x7C, 0x00, 0x3E, 0x3E, 0x00, 0x7C, 0x3F, 0x81, 0xFC, + 0x1F, 0xFF, 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0x00, + 0x03, 0xFF, 0x01, 0xFF, 0xF8, 0x7F, 0xFF, 0x1F, 0xFF, 0xE7, 0xF0, 0x0C, + 0xF8, 0x00, 0x1E, 0x00, 0x03, 0xC0, 0x00, 0x78, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x7F, 0xC0, 0x1F, 0xF8, 0x07, 0xFF, 0x03, + 0xFF, 0xE0, 0x7E, 0x00, 0x1F, 0x00, 0x03, 0xC0, 0x00, 0x78, 0x00, 0x0F, + 0x00, 0x01, 0xE0, 0x00, 0x3E, 0x00, 0x03, 0xF0, 0x03, 0x3F, 0xFF, 0xE7, + 0xFF, 0xFC, 0x3F, 0xFF, 0x80, 0xFF, 0xC0, 0x7F, 0xFF, 0xFB, 0xFF, 0xFF, + 0xDF, 0xFF, 0xFE, 0xFF, 0xFF, 0xF0, 0x00, 0xFF, 0x00, 0x0F, 0xE0, 0x00, + 0xFC, 0x00, 0x1F, 0xC0, 0x01, 0xF8, 0x00, 0x1F, 0x80, 0x01, 0xF8, 0x00, + 0x1F, 0x80, 0x00, 0xF8, 0x00, 0x0F, 0x80, 0x00, 0xF8, 0x00, 0x07, 0x80, + 0x00, 0x7C, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x01, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xF0, 0x00, + 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3E, + 0x00, 0x01, 0xF0, 0x00, 0x07, 0xE0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0xFC, + 0x01, 0xFF, 0xF8, 0x07, 0xFF, 0xE0, 0x07, 0xFF, 0x00, 0x00, 0x7C, 0x00, + 0x01, 0xE0, 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x3E, + 0x00, 0x07, 0xE0, 0x00, 0x3F, 0x00, 0x01, 0xF0, 0x00, 0x0E, 0x00, 0x00, + 0x7F, 0x03, 0xC7, 0xFF, 0x0F, 0x3F, 0xFE, 0x3D, 0xFF, 0xFC, 0xFF, 0x81, + 0xFB, 0xF8, 0x03, 0xEF, 0xC0, 0x07, 0xBE, 0x00, 0x1F, 0xF8, 0x00, 0x3F, + 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, + 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, + 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, + 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, + 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x7E, 0x00, 0x01, 0xFF, 0x80, 0x07, + 0xFF, 0xE0, 0x0F, 0xFF, 0xF0, 0x1F, 0x81, 0xF8, 0x1F, 0x00, 0xF8, 0x3E, + 0x00, 0x7C, 0x3C, 0x00, 0x3C, 0x7C, 0x00, 0x3E, 0x78, 0x00, 0x1E, 0x78, + 0x00, 0x1E, 0x78, 0x00, 0x1E, 0xF0, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0x78, 0x00, 0x1E, 0x78, + 0x00, 0x1E, 0x78, 0x00, 0x1E, 0x7C, 0x00, 0x3E, 0x3C, 0x00, 0x3C, 0x3E, + 0x00, 0x7C, 0x1F, 0x00, 0xF8, 0x1F, 0x81, 0xF0, 0x0F, 0xFF, 0xF0, 0x07, + 0xFF, 0xE0, 0x01, 0xFF, 0x80, 0x00, 0x7E, 0x00, 0xF0, 0x3C, 0x0F, 0x03, + 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, + 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF8, 0x1F, 0x07, + 0xFC, 0xFF, 0x1F, 0xC3, 0xF0, 0xF0, 0x01, 0xF3, 0xC0, 0x1F, 0x8F, 0x00, + 0xFC, 0x3C, 0x07, 0xE0, 0xF0, 0x3F, 0x03, 0xC1, 0xF8, 0x0F, 0x0F, 0xC0, + 0x3C, 0x7E, 0x00, 0xF3, 0xF0, 0x03, 0xDF, 0x80, 0x0F, 0xFE, 0x00, 0x3F, + 0xFC, 0x00, 0xFE, 0xF8, 0x03, 0xF1, 0xE0, 0x0F, 0x87, 0xC0, 0x3C, 0x0F, + 0x80, 0xF0, 0x1E, 0x03, 0xC0, 0x7C, 0x0F, 0x00, 0xF8, 0x3C, 0x01, 0xF0, + 0xF0, 0x07, 0xC3, 0xC0, 0x0F, 0x8F, 0x00, 0x1F, 0x3C, 0x00, 0x7C, 0xF0, + 0x00, 0xFB, 0xC0, 0x01, 0xF0, 0x0F, 0xC0, 0x00, 0x07, 0xF0, 0x00, 0x03, + 0xFC, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x03, 0xE0, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x7C, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x07, 0xC0, 0x00, 0x01, 0xE0, 0x00, 0x01, 0xF0, 0x00, 0x00, 0xFC, + 0x00, 0x00, 0xFE, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x7F, 0xC0, 0x00, 0x3D, + 0xE0, 0x00, 0x3E, 0xF8, 0x00, 0x1E, 0x3C, 0x00, 0x1F, 0x1E, 0x00, 0x0F, + 0x0F, 0x80, 0x0F, 0x83, 0xC0, 0x07, 0x81, 0xE0, 0x07, 0xC0, 0xF8, 0x03, + 0xC0, 0x3C, 0x03, 0xE0, 0x1F, 0x01, 0xE0, 0x07, 0x80, 0xF0, 0x03, 0xC0, + 0xF8, 0x01, 0xF0, 0x78, 0x00, 0x78, 0x7C, 0x00, 0x3C, 0x3C, 0x00, 0x1F, + 0x3E, 0x00, 0x07, 0x9E, 0x00, 0x03, 0xDF, 0x00, 0x00, 0xF0, 0xF0, 0x00, + 0x3C, 0x78, 0x00, 0x1E, 0x3C, 0x00, 0x0F, 0x1E, 0x00, 0x07, 0x8F, 0x00, + 0x03, 0xC7, 0x80, 0x01, 0xE3, 0xC0, 0x00, 0xF1, 0xE0, 0x00, 0x78, 0xF0, + 0x00, 0x3C, 0x78, 0x00, 0x1E, 0x3C, 0x00, 0x0F, 0x1E, 0x00, 0x07, 0x8F, + 0x00, 0x03, 0xC7, 0x80, 0x01, 0xE3, 0xC0, 0x00, 0xF1, 0xE0, 0x00, 0x78, + 0xF0, 0x00, 0x3C, 0x78, 0x00, 0x1E, 0x3C, 0x00, 0x0F, 0x1F, 0x00, 0x0F, + 0x8F, 0x80, 0x07, 0xC7, 0xE0, 0x07, 0xE3, 0xFC, 0x0F, 0xFB, 0xFF, 0xFF, + 0xFF, 0xF7, 0xFF, 0x9F, 0xF9, 0xFF, 0x8F, 0xFC, 0x3F, 0x03, 0xDE, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x07, 0x80, 0x00, 0x03, 0xC0, 0x00, 0x01, 0xE0, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x00, 0xF0, 0x01, 0xE3, 0xE0, 0x03, 0xC7, 0x80, 0x0F, 0x1E, + 0x00, 0x1E, 0x7C, 0x00, 0x78, 0xF0, 0x01, 0xE3, 0xC0, 0x03, 0xCF, 0x80, + 0x0F, 0x1E, 0x00, 0x3C, 0x78, 0x00, 0xF1, 0xE0, 0x03, 0xC3, 0xC0, 0x0F, + 0x0F, 0x00, 0x7C, 0x3C, 0x01, 0xE0, 0xF8, 0x0F, 0x81, 0xE0, 0x3E, 0x07, + 0x81, 0xF0, 0x1F, 0x0F, 0x80, 0x3C, 0x3E, 0x00, 0xF1, 0xF0, 0x03, 0xEF, + 0x80, 0x07, 0xFC, 0x00, 0x1F, 0xE0, 0x00, 0x7F, 0x80, 0x00, 0xFC, 0x00, + 0x03, 0xE0, 0x00, 0x7F, 0xFF, 0xE7, 0xFF, 0xFE, 0x7F, 0xFF, 0xE7, 0xFF, + 0xFE, 0x07, 0xF0, 0x01, 0xF8, 0x00, 0x3E, 0x00, 0x03, 0xC0, 0x00, 0x78, + 0x00, 0x07, 0x80, 0x00, 0x78, 0x00, 0x07, 0x80, 0x00, 0x7C, 0x00, 0x03, + 0xE0, 0x00, 0x3F, 0xE0, 0x01, 0xFF, 0xF8, 0x07, 0xFF, 0x80, 0x3F, 0xF8, + 0x0F, 0xFF, 0x81, 0xFC, 0x00, 0x3E, 0x00, 0x07, 0xC0, 0x00, 0x78, 0x00, + 0x0F, 0x80, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x7C, 0x00, 0x07, 0xE0, 0x00, 0x3F, + 0x80, 0x03, 0xFF, 0xF0, 0x0F, 0xFF, 0xC0, 0x7F, 0xFE, 0x00, 0xFF, 0xE0, + 0x00, 0x1F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x01, 0xF0, 0x00, 0x7E, 0x00, 0x07, 0xE0, 0x00, 0x7C, 0x00, 0x07, + 0x00, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, 0xFF, + 0xF8, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x7C, 0x00, 0x3E, 0x7C, 0x00, + 0x3E, 0x78, 0x00, 0x1E, 0xF8, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x1F, 0x78, 0x00, + 0x1E, 0x7C, 0x00, 0x3E, 0x7C, 0x00, 0x3E, 0x3E, 0x00, 0x7C, 0x1F, 0x81, + 0xF8, 0x1F, 0xFF, 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, + 0x00, 0xFF, 0xFF, 0xFF, 0x7F, 0xFF, 0xFF, 0xBF, 0xFF, 0xFF, 0xDF, 0xFF, + 0xFF, 0xE1, 0xE0, 0x07, 0x80, 0xF0, 0x03, 0xC0, 0x78, 0x01, 0xE0, 0x3C, + 0x00, 0xF0, 0x1E, 0x00, 0x78, 0x0F, 0x00, 0x3C, 0x07, 0x80, 0x1E, 0x03, + 0xC0, 0x0F, 0x01, 0xE0, 0x07, 0x80, 0xF0, 0x03, 0xC0, 0x78, 0x01, 0xE0, + 0x3C, 0x00, 0xF0, 0x1E, 0x00, 0x78, 0x0F, 0x00, 0x3C, 0x07, 0x80, 0x1E, + 0x03, 0xC0, 0x0F, 0x01, 0xE0, 0x07, 0x80, 0xF0, 0x03, 0xC0, 0x78, 0x01, + 0xF0, 0x3C, 0x00, 0xFF, 0x1E, 0x00, 0x3F, 0x8F, 0x00, 0x1F, 0xC0, 0x00, + 0x07, 0xE0, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x07, 0xFF, 0xF0, 0x0F, + 0xFF, 0xF8, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x7C, 0x00, 0x3E, 0x7C, + 0x00, 0x3E, 0x78, 0x00, 0x1E, 0xF8, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x1F, 0xF8, + 0x00, 0x1E, 0xFC, 0x00, 0x3E, 0xFC, 0x00, 0x3E, 0xFE, 0x00, 0x7C, 0xFF, + 0x81, 0xF8, 0xF7, 0xFF, 0xF8, 0xF3, 0xFF, 0xF0, 0xF1, 0xFF, 0xC0, 0xF0, + 0x7F, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x7F, 0x80, 0x3F, 0xFE, 0x07, 0xFF, + 0xF0, 0xFF, 0xFF, 0x1F, 0xC0, 0xF3, 0xF0, 0x01, 0x7C, 0x00, 0x07, 0xC0, + 0x00, 0x78, 0x00, 0x0F, 0x80, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF8, 0x00, 0x07, 0x80, 0x00, 0x7C, 0x00, 0x07, 0xC0, 0x00, + 0x3E, 0x00, 0x01, 0xF8, 0x00, 0x1F, 0xFE, 0x00, 0x7F, 0xF8, 0x03, 0xFF, + 0xC0, 0x0F, 0xFC, 0x00, 0x03, 0xE0, 0x00, 0x1E, 0x00, 0x01, 0xE0, 0x00, + 0x1E, 0x00, 0x01, 0xE0, 0x00, 0x3E, 0x00, 0x0F, 0xC0, 0x00, 0xFC, 0x00, + 0x0F, 0x80, 0x00, 0xE0, 0x00, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xF0, 0xFF, + 0xFF, 0xFC, 0x7F, 0xFF, 0xFF, 0x3F, 0x81, 0xFC, 0x0F, 0x80, 0x1F, 0x07, + 0xC0, 0x03, 0xE1, 0xE0, 0x00, 0x78, 0xF8, 0x00, 0x1E, 0x3E, 0x00, 0x07, + 0xCF, 0x00, 0x00, 0xF3, 0xC0, 0x00, 0x3C, 0xF0, 0x00, 0x0F, 0x3C, 0x00, + 0x03, 0xCF, 0x00, 0x00, 0xF3, 0xC0, 0x00, 0x3C, 0xF0, 0x00, 0x0F, 0x3E, + 0x00, 0x07, 0xC7, 0x80, 0x01, 0xE1, 0xE0, 0x00, 0xF8, 0x7C, 0x00, 0x3E, + 0x0F, 0x80, 0x1F, 0x01, 0xF8, 0x1F, 0x80, 0x7F, 0xFF, 0xE0, 0x0F, 0xFF, + 0xF0, 0x00, 0xFF, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x3C, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3E, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x07, 0xF0, 0x00, + 0x03, 0xF0, 0xF0, 0x03, 0xC7, 0x80, 0x0F, 0x3C, 0x00, 0x79, 0xE0, 0x01, + 0xEF, 0x00, 0x0F, 0x78, 0x00, 0x7B, 0xC0, 0x01, 0xFE, 0x00, 0x0F, 0xF0, + 0x00, 0x7F, 0x80, 0x03, 0xFC, 0x00, 0x1F, 0xE0, 0x00, 0xFF, 0x00, 0x07, + 0xF8, 0x00, 0x3F, 0xC0, 0x03, 0xFE, 0x00, 0x1E, 0xF0, 0x00, 0xF7, 0x80, + 0x0F, 0xBC, 0x00, 0x79, 0xF0, 0x07, 0xC7, 0x80, 0x7C, 0x3F, 0x0F, 0xE0, + 0xFF, 0xFE, 0x07, 0xFF, 0xE0, 0x0F, 0xFC, 0x00, 0x3F, 0x80, 0x00, 0x00, + 0xC3, 0xE0, 0x00, 0xF1, 0xFE, 0x00, 0xFC, 0xFF, 0xC0, 0x7F, 0x3F, 0xF8, + 0x3F, 0x9F, 0x3F, 0x0F, 0x87, 0x87, 0xC7, 0xC1, 0xE0, 0xF9, 0xF0, 0x78, + 0x1E, 0x78, 0x1E, 0x07, 0xBE, 0x07, 0x81, 0xFF, 0x01, 0xE0, 0x3F, 0xC0, + 0x78, 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, 0xE0, 0x3F, + 0xC0, 0x78, 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x81, 0xE0, + 0x7D, 0xE0, 0x78, 0x1E, 0x7C, 0x1E, 0x07, 0x9F, 0x07, 0x83, 0xE3, 0xE1, + 0xE1, 0xF0, 0x7E, 0x79, 0xF8, 0x1F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0x00, + 0x3F, 0xFF, 0x00, 0x03, 0xFF, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x07, 0x80, + 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x07, + 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0xF8, 0x00, 0x1F, 0xFE, 0x00, 0x3E, 0xFF, 0x00, 0x3E, 0xFF, 0x80, 0x7C, + 0x0F, 0x80, 0x7C, 0x07, 0x80, 0xF8, 0x07, 0xC0, 0xF8, 0x03, 0xC1, 0xF0, + 0x03, 0xE1, 0xF0, 0x01, 0xE3, 0xE0, 0x01, 0xE3, 0xC0, 0x01, 0xF7, 0xC0, + 0x00, 0xFF, 0x80, 0x00, 0xFF, 0x80, 0x00, 0x7F, 0x00, 0x00, 0x7F, 0x00, + 0x00, 0x7E, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x7C, 0x00, 0x00, 0x7E, 0x00, + 0x00, 0xFE, 0x00, 0x00, 0xFE, 0x00, 0x01, 0xFF, 0x00, 0x01, 0xFF, 0x00, + 0x03, 0xEF, 0x80, 0x03, 0xC7, 0x80, 0x07, 0xC7, 0x80, 0x07, 0x83, 0xC0, + 0x0F, 0x83, 0xC0, 0x1F, 0x03, 0xC0, 0x1F, 0x01, 0xE0, 0x3E, 0x01, 0xF0, + 0x3E, 0x01, 0xFF, 0x7C, 0x00, 0xFF, 0x7C, 0x00, 0x7F, 0xF8, 0x00, 0x1F, + 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, 0xE0, 0x3F, 0xC0, 0x78, + 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, 0xE0, 0x3F, 0xC0, + 0x78, 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, 0xE0, 0x3F, + 0xC0, 0x78, 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, 0xE0, + 0x3F, 0xC0, 0x78, 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, + 0xE0, 0x7F, 0xE0, 0x78, 0x1E, 0x7C, 0x1E, 0x0F, 0x9F, 0x87, 0x87, 0xE3, + 0xF9, 0xE7, 0xF0, 0x7F, 0xFF, 0xF8, 0x0F, 0xFF, 0xFC, 0x00, 0xFF, 0xFC, + 0x00, 0x0F, 0xF8, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x07, + 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x07, 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x1E, 0x00, 0x00, 0x78, 0x1E, 0x00, 0x00, 0x78, 0x3C, 0x00, 0x00, + 0x3C, 0x3C, 0x00, 0x00, 0x3C, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, + 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x70, 0x00, 0x00, 0x0E, 0xF0, 0x00, 0x00, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x07, 0xC0, 0x0F, 0xF8, 0x07, 0xE0, 0x1F, 0x78, 0x07, 0xE0, + 0x1E, 0x78, 0x07, 0xE0, 0x1E, 0x7C, 0x0F, 0xF0, 0x3E, 0x3E, 0x1E, 0xF8, + 0x7C, 0x3F, 0xFE, 0x7F, 0xFC, 0x1F, 0xFC, 0x7F, 0xF8, 0x0F, 0xFC, 0x3F, + 0xF0, 0x03, 0xF0, 0x0F, 0xC0, 0xF8, 0x7F, 0xE1, 0xFF, 0x87, 0xFE, 0x1F, + 0xF8, 0x7C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, + 0x03, 0xC0, 0x0F, 0x00, 0x3C, 0x00, 0xF0, 0x03, 0xC0, 0x0F, 0x00, 0x3C, + 0x00, 0xF0, 0x03, 0xC0, 0x0F, 0x00, 0x3C, 0x00, 0xF0, 0x03, 0xC0, 0x0F, + 0x00, 0x3C, 0x00, 0xF0, 0x03, 0xC0, 0x0F, 0x00, 0x1E, 0x00, 0x78, 0x01, + 0xF0, 0x07, 0xFC, 0x0F, 0xF0, 0x1F, 0xC0, 0x1F, 0x3E, 0x1F, 0x01, 0xF0, + 0xF8, 0x0F, 0x87, 0xC0, 0x7C, 0x3E, 0x03, 0xE1, 0xF0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0xE0, 0x07, 0x8F, 0x00, 0x1E, 0x78, 0x00, 0xF3, 0xC0, 0x03, 0xDE, + 0x00, 0x1E, 0xF0, 0x00, 0xF7, 0x80, 0x03, 0xFC, 0x00, 0x1F, 0xE0, 0x00, + 0xFF, 0x00, 0x07, 0xF8, 0x00, 0x3F, 0xC0, 0x01, 0xFE, 0x00, 0x0F, 0xF0, + 0x00, 0x7F, 0x80, 0x07, 0xFC, 0x00, 0x3D, 0xE0, 0x01, 0xEF, 0x00, 0x1F, + 0x78, 0x00, 0xF3, 0xE0, 0x0F, 0x8F, 0x00, 0xF8, 0x7E, 0x1F, 0xC1, 0xFF, + 0xFC, 0x0F, 0xFF, 0xC0, 0x1F, 0xF8, 0x00, 0x7F, 0x00, 0x00, 0x00, 0x01, + 0xE0, 0x00, 0x03, 0xE0, 0x00, 0x03, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x03, 0xFF, + 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, 0xFF, 0xF8, 0x1F, 0x81, 0xF8, 0x3E, 0x00, + 0x7C, 0x7C, 0x00, 0x3E, 0x7C, 0x00, 0x3E, 0x78, 0x00, 0x1E, 0xF8, 0x00, + 0x1F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF8, 0x00, 0x1F, 0x78, 0x00, 0x1E, 0x7C, 0x00, 0x3E, 0x7C, 0x00, + 0x3E, 0x3E, 0x00, 0x7C, 0x1F, 0x81, 0xF8, 0x1F, 0xFF, 0xF8, 0x0F, 0xFF, + 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0x00, 0x00, 0x0F, 0x00, 0x00, 0xF8, + 0x00, 0x0F, 0x80, 0x00, 0x78, 0x00, 0x07, 0x80, 0x00, 0x78, 0x00, 0x03, + 0x80, 0x00, 0x3C, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x01, 0xE3, 0xC0, 0x07, 0x9E, 0x00, + 0x3C, 0xF0, 0x00, 0xF7, 0x80, 0x07, 0xBC, 0x00, 0x3D, 0xE0, 0x00, 0xFF, + 0x00, 0x07, 0xF8, 0x00, 0x3F, 0xC0, 0x01, 0xFE, 0x00, 0x0F, 0xF0, 0x00, + 0x7F, 0x80, 0x03, 0xFC, 0x00, 0x1F, 0xE0, 0x01, 0xFF, 0x00, 0x0F, 0x78, + 0x00, 0x7B, 0xC0, 0x07, 0xDE, 0x00, 0x3C, 0xF8, 0x03, 0xE3, 0xC0, 0x3E, + 0x1F, 0x87, 0xF0, 0x7F, 0xFF, 0x03, 0xFF, 0xF0, 0x07, 0xFE, 0x00, 0x1F, + 0xC0, 0x00, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, + 0x1E, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x03, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x1E, 0x00, + 0x00, 0x78, 0x1E, 0x00, 0x00, 0x78, 0x3C, 0x00, 0x00, 0x3C, 0x3C, 0x00, + 0x00, 0x3C, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, + 0x00, 0x1E, 0x70, 0x00, 0x00, 0x0E, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x03, + 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, + 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x07, + 0xC0, 0x0F, 0xF8, 0x07, 0xE0, 0x1F, 0x78, 0x07, 0xE0, 0x1E, 0x78, 0x07, + 0xE0, 0x1E, 0x7C, 0x0F, 0xF0, 0x3E, 0x3E, 0x1E, 0xF8, 0x7C, 0x3F, 0xFE, + 0x7F, 0xFC, 0x1F, 0xFC, 0x7F, 0xF8, 0x0F, 0xFC, 0x3F, 0xF0, 0x03, 0xF0, + 0x0F, 0xC0, +}; + +const GFXglyph FreeSans24pt_Win1253Glyphs[] PROGMEM = { +/* 0x01 */ { 0, 43, 48, 60, 8, -39 }, +/* 0x02 */ { 258, 43, 48, 60, 8, -39 }, +/* 0x03 */ { 516, 48, 48, 60, 6, -39 }, +/* 0x04 */ { 804, 57, 48, 60, 1, -39 }, +/* 0x05 */ { 1146, 47, 48, 60, 6, -39 }, +/* 0x06 */ { 1428, 47, 48, 60, 6, -39 }, +/* 0x07 */ { 1710, 0, 0, 0, 0, 0 }, +/* 0x08 */ { 1710, 49, 48, 60, 5, -39 }, +/* 0x09 */ { 2004, 54, 38, 60, 3, -34 }, +/* 0x0A */ { 2261, 0, 0, 0, 0, 0 }, +/* 0x0B */ { 2261, 52, 48, 60, 4, -39 }, +/* 0x0C */ { 2573, 47, 48, 60, 6, -39 }, +/* 0x0D */ { 2855, 0, 0, 0, 0, 0 }, +/* 0x0E */ { 2855, 47, 48, 60, 6, -39 }, +/* 0x0F */ { 3137, 48, 49, 60, 6, -39 }, +/* 0x10 */ { 3431, 46, 48, 60, 7, -39 }, +/* 0x11 */ { 3707, 48, 48, 60, 6, -39 }, +/* 0x12 */ { 3995, 46, 48, 60, 7, -39 }, +/* 0x13 */ { 4271, 48, 46, 60, 6, -38 }, +/* 0x14 */ { 4547, 48, 48, 60, 6, -39 }, +/* 0x15 */ { 4835, 51, 48, 60, 4, -39 }, +/* 0x16 */ { 5141, 37, 47, 60, 11, -38 }, +/* 0x17 */ { 5359, 50, 40, 60, 5, -35 }, +/* 0x18 */ { 5609, 56, 40, 60, 2, -35 }, +/* 0x19 */ { 5889, 48, 48, 60, 6, -39 }, +/* 0x1A */ { 6177, 0, 0, 0, 0, 0 }, +/* 0x1B */ { 6177, 56, 49, 60, 2, -39 }, +/* 0x1C */ { 6520, 48, 48, 60, 6, -39 }, +/* 0x1D */ { 6808, 49, 49, 60, 5, -39 }, +/* 0x1E */ { 7109, 48, 47, 60, 5, -38 }, +/* 0x1F */ { 7391, 34, 48, 60, 13, -39 }, +/* 0x20 */ { 7595, 1, 1, 15, 0, 0 }, +/* 0x21 */ { 7596, 5, 34, 19, 7, -33 }, +/* 0x22 */ { 7618, 13, 13, 22, 4, -33 }, +/* 0x23 */ { 7640, 32, 35, 39, 4, -34 }, +/* 0x24 */ { 7780, 22, 42, 30, 4, -34 }, +/* 0x25 */ { 7896, 39, 36, 45, 3, -34 }, +/* 0x26 */ { 8072, 32, 36, 37, 3, -34 }, +/* 0x27 */ { 8216, 4, 13, 13, 4, -33 }, +/* 0x28 */ { 8223, 11, 42, 18, 4, -35 }, +/* 0x29 */ { 8281, 11, 42, 18, 4, -35 }, +/* 0x2A */ { 8339, 21, 22, 24, 1, -34 }, +/* 0x2B */ { 8397, 30, 30, 39, 5, -29 }, +/* 0x2C */ { 8510, 6, 11, 15, 4, -5 }, +/* 0x2D */ { 8519, 12, 4, 17, 2, -14 }, +/* 0x2E */ { 8525, 4, 6, 15, 6, -5 }, +/* 0x2F */ { 8528, 16, 39, 16, 0, -33 }, +/* 0x30 */ { 8606, 24, 36, 30, 3, -34 }, +/* 0x31 */ { 8714, 20, 34, 30, 5, -33 }, +/* 0x32 */ { 8799, 22, 35, 30, 4, -34 }, +/* 0x33 */ { 8896, 23, 36, 30, 4, -34 }, +/* 0x34 */ { 9000, 25, 34, 30, 2, -33 }, +/* 0x35 */ { 9107, 22, 35, 30, 4, -33 }, +/* 0x36 */ { 9204, 24, 36, 30, 3, -34 }, +/* 0x37 */ { 9312, 22, 34, 30, 4, -33 }, +/* 0x38 */ { 9406, 24, 36, 30, 3, -34 }, +/* 0x39 */ { 9514, 24, 36, 30, 3, -34 }, +/* 0x3A */ { 9622, 5, 24, 16, 6, -23 }, +/* 0x3B */ { 9637, 6, 29, 16, 4, -23 }, +/* 0x3C */ { 9659, 29, 25, 39, 5, -26 }, +/* 0x3D */ { 9750, 29, 14, 39, 5, -21 }, +/* 0x3E */ { 9801, 29, 25, 39, 5, -26 }, +/* 0x3F */ { 9892, 18, 35, 25, 3, -34 }, +/* 0x40 */ { 9971, 41, 41, 47, 3, -32 }, +/* 0x41 */ { 10182, 31, 34, 32, 0, -33 }, +/* 0x42 */ { 10314, 24, 34, 32, 4, -33 }, +/* 0x43 */ { 10416, 28, 36, 33, 3, -34 }, +/* 0x44 */ { 10542, 29, 34, 36, 4, -33 }, +/* 0x45 */ { 10666, 22, 34, 30, 4, -33 }, +/* 0x46 */ { 10760, 20, 34, 27, 4, -33 }, +/* 0x47 */ { 10845, 30, 36, 36, 3, -34 }, +/* 0x48 */ { 10980, 26, 34, 35, 4, -33 }, +/* 0x49 */ { 11091, 4, 34, 14, 4, -33 }, +/* 0x4A */ { 11108, 11, 43, 14, -3, -33 }, +/* 0x4B */ { 11168, 27, 34, 31, 4, -33 }, +/* 0x4C */ { 11283, 21, 34, 26, 4, -33 }, +/* 0x4D */ { 11373, 31, 34, 41, 4, -33 }, +/* 0x4E */ { 11505, 26, 34, 35, 4, -33 }, +/* 0x4F */ { 11616, 32, 36, 37, 3, -34 }, +/* 0x50 */ { 11760, 22, 34, 28, 4, -33 }, +/* 0x51 */ { 11854, 32, 41, 37, 3, -34 }, +/* 0x52 */ { 12018, 27, 34, 33, 4, -33 }, +/* 0x53 */ { 12133, 24, 36, 30, 3, -34 }, +/* 0x54 */ { 12241, 28, 34, 29, 0, -33 }, +/* 0x55 */ { 12360, 26, 35, 34, 5, -33 }, +/* 0x56 */ { 12474, 31, 34, 32, 0, -33 }, +/* 0x57 */ { 12606, 43, 34, 46, 2, -33 }, +/* 0x58 */ { 12789, 29, 34, 31, 1, -33 }, +/* 0x59 */ { 12913, 28, 34, 29, 0, -33 }, +/* 0x5A */ { 13032, 28, 34, 32, 2, -33 }, +/* 0x5B */ { 13151, 10, 42, 18, 4, -35 }, +/* 0x5C */ { 13204, 16, 39, 16, 0, -33 }, +/* 0x5D */ { 13282, 9, 42, 18, 4, -35 }, +/* 0x5E */ { 13330, 29, 13, 39, 5, -33 }, +/* 0x5F */ { 13378, 24, 4, 24, 0, 8 }, +/* 0x60 */ { 13390, 11, 9, 24, 4, -37 }, +/* 0x61 */ { 13403, 22, 28, 29, 3, -26 }, +/* 0x62 */ { 13480, 23, 37, 30, 4, -35 }, +/* 0x63 */ { 13587, 20, 28, 26, 3, -26 }, +/* 0x64 */ { 13657, 23, 37, 30, 3, -35 }, +/* 0x65 */ { 13764, 24, 28, 29, 3, -26 }, +/* 0x66 */ { 13848, 16, 36, 17, 1, -35 }, +/* 0x67 */ { 13920, 23, 37, 30, 3, -26 }, +/* 0x68 */ { 14027, 22, 36, 30, 4, -35 }, +/* 0x69 */ { 14126, 4, 36, 13, 4, -35 }, +/* 0x6A */ { 14144, 9, 46, 13, -1, -35 }, +/* 0x6B */ { 14196, 23, 36, 27, 4, -35 }, +/* 0x6C */ { 14300, 4, 36, 13, 4, -35 }, +/* 0x6D */ { 14318, 38, 27, 46, 4, -26 }, +/* 0x6E */ { 14447, 22, 27, 30, 4, -26 }, +/* 0x6F */ { 14522, 24, 28, 29, 3, -26 }, +/* 0x70 */ { 14606, 23, 37, 30, 4, -26 }, +/* 0x71 */ { 14713, 23, 37, 30, 3, -26 }, +/* 0x72 */ { 14820, 15, 27, 19, 4, -26 }, +/* 0x73 */ { 14871, 19, 28, 24, 3, -26 }, +/* 0x74 */ { 14938, 16, 33, 18, 1, -32 }, +/* 0x75 */ { 15004, 22, 28, 30, 4, -26 }, +/* 0x76 */ { 15081, 25, 26, 28, 1, -25 }, +/* 0x77 */ { 15163, 35, 26, 38, 2, -25 }, +/* 0x78 */ { 15277, 25, 26, 28, 1, -25 }, +/* 0x79 */ { 15359, 25, 36, 28, 1, -25 }, +/* 0x7A */ { 15472, 21, 26, 25, 2, -25 }, +/* 0x7B */ { 15541, 18, 43, 30, 6, -35 }, +/* 0x7C */ { 15638, 4, 47, 16, 6, -35 }, +/* 0x7D */ { 15662, 18, 43, 30, 6, -35 }, +/* 0x7E */ { 15759, 29, 9, 39, 5, -18 }, +/* 0x7F */ { 15792, 0, 0, 0, 0, 0 }, +/* 0x80 */ { 15792, 26, 36, 30, 0, -34 }, +/* 0x81 */ { 15909, 0, 0, 0, 0, 0 }, +/* 0x82 */ { 15909, 6, 11, 15, 4, -5 }, +/* 0x83 */ { 15918, 20, 46, 17, -3, -35 }, +/* 0x84 */ { 16033, 15, 11, 24, 4, -5 }, +/* 0x85 */ { 16054, 36, 6, 47, 5, -5 }, +/* 0x86 */ { 16081, 20, 39, 24, 2, -33 }, +/* 0x87 */ { 16179, 20, 39, 24, 2, -33 }, +/* 0x88 */ { 16277, 0, 0, 0, 0, 0 }, +/* 0x89 */ { 16277, 58, 36, 63, 3, -34 }, +/* 0x8A */ { 16538, 0, 0, 0, 0, 0 }, +/* 0x8B */ { 16538, 11, 21, 19, 4, -23 }, +/* 0x8C */ { 16567, 0, 0, 0, 0, 0 }, +/* 0x8D */ { 16567, 0, 0, 0, 0, 0 }, +/* 0x8E */ { 16567, 0, 0, 0, 0, 0 }, +/* 0x8F */ { 16567, 0, 0, 0, 0, 0 }, +/* 0x90 */ { 16567, 0, 0, 0, 0, 0 }, +/* 0x91 */ { 16567, 6, 11, 15, 4, -33 }, +/* 0x92 */ { 16576, 6, 11, 15, 4, -33 }, +/* 0x93 */ { 16585, 15, 11, 24, 4, -33 }, +/* 0x94 */ { 16606, 15, 11, 24, 4, -33 }, +/* 0x95 */ { 16627, 14, 14, 28, 7, -23 }, +/* 0x96 */ { 16652, 19, 4, 24, 2, -14 }, +/* 0x97 */ { 16662, 42, 4, 47, 2, -14 }, +/* 0x98 */ { 16683, 0, 0, 0, 0, 0 }, +/* 0x99 */ { 16683, 31, 13, 47, 6, -33 }, +/* 0x9A */ { 16734, 0, 0, 0, 0, 0 }, +/* 0x9B */ { 16734, 11, 21, 19, 4, -23 }, +/* 0x9C */ { 16763, 0, 0, 0, 0, 0 }, +/* 0x9D */ { 16763, 0, 0, 0, 0, 0 }, +/* 0x9E */ { 16763, 0, 0, 0, 0, 0 }, +/* 0x9F */ { 16763, 0, 0, 0, 0, 0 }, +/* 0xA0 */ { 16763, 1, 1, 15, 0, 0 }, +/* 0xA1 */ { 16764, 15, 15, 24, 5, -45 }, +/* 0xA2 */ { 16793, 31, 38, 33, 0, -37 }, +/* 0xA3 */ { 16941, 22, 35, 30, 3, -34 }, +/* 0xA4 */ { 17038, 26, 26, 30, 2, -26 }, +/* 0xA5 */ { 17123, 26, 34, 30, 2, -33 }, +/* 0xA6 */ { 17234, 4, 40, 16, 6, -31 }, +/* 0xA7 */ { 17254, 19, 39, 24, 2, -34 }, +/* 0xA8 */ { 17347, 14, 5, 24, 5, -35 }, +/* 0xA9 */ { 17356, 34, 34, 47, 7, -33 }, +/* 0xAA */ { 17501, 0, 0, 0, 0, 0 }, +/* 0xAB */ { 17501, 21, 21, 29, 4, -23 }, +/* 0xAC */ { 17557, 29, 13, 39, 5, -19 }, +/* 0xAD */ { 17605, 12, 4, 17, 2, -14 }, +/* 0xAE */ { 17611, 34, 34, 47, 7, -33 }, +/* 0xAF */ { 17756, 47, 4, 47, 0, -14 }, +/* 0xB0 */ { 17780, 15, 15, 24, 4, -34 }, +/* 0xB1 */ { 17809, 30, 30, 39, 5, -29 }, +/* 0xB2 */ { 17922, 14, 19, 19, 2, -34 }, +/* 0xB3 */ { 17956, 14, 19, 19, 2, -34 }, +/* 0xB4 */ { 17990, 11, 9, 24, 9, -37 }, +/* 0xB5 */ { 18003, 15, 15, 24, 5, -45 }, +/* 0xB6 */ { 18032, 31, 38, 33, 0, -37 }, +/* 0xB7 */ { 18180, 4, 6, 15, 5, -18 }, +/* 0xB8 */ { 18183, 31, 38, 35, 0, -37 }, +/* 0xB9 */ { 18331, 35, 38, 41, 0, -37 }, +/* 0xBA */ { 18498, 13, 38, 19, 0, -37 }, +/* 0xBB */ { 18560, 21, 21, 29, 4, -23 }, +/* 0xBC */ { 18616, 36, 39, 38, 0, -37 }, +/* 0xBD */ { 18792, 39, 36, 46, 4, -34 }, +/* 0xBE */ { 18968, 38, 38, 39, 0, -37 }, +/* 0xBF */ { 19149, 36, 38, 39, 0, -37 }, +/* 0xC0 */ { 19320, 15, 46, 16, 0, -45 }, +/* 0xC1 */ { 19407, 31, 34, 32, 0, -33 }, +/* 0xC2 */ { 19539, 24, 34, 32, 4, -33 }, +/* 0xC3 */ { 19641, 21, 34, 25, 4, -33 }, +/* 0xC4 */ { 19731, 31, 34, 32, 0, -33 }, +/* 0xC5 */ { 19863, 22, 34, 30, 4, -33 }, +/* 0xC6 */ { 19957, 28, 34, 32, 2, -33 }, +/* 0xC7 */ { 20076, 26, 34, 35, 4, -33 }, +/* 0xC8 */ { 20187, 32, 36, 38, 3, -34 }, +/* 0xC9 */ { 20331, 4, 34, 14, 4, -33 }, +/* 0xCA */ { 20348, 27, 34, 31, 4, -33 }, +/* 0xCB */ { 20463, 31, 34, 32, 0, -33 }, +/* 0xCC */ { 20595, 31, 34, 41, 4, -33 }, +/* 0xCD */ { 20727, 26, 34, 35, 4, -33 }, +/* 0xCE */ { 20838, 21, 34, 29, 4, -33 }, +/* 0xCF */ { 20928, 32, 36, 37, 3, -34 }, +/* 0xD0 */ { 21072, 26, 34, 34, 4, -33 }, +/* 0xD1 */ { 21183, 22, 34, 28, 4, -33 }, +/* 0xD2 */ { 21277, 0, 0, 0, 0, 0 }, +/* 0xD3 */ { 21277, 22, 34, 29, 4, -33 }, +/* 0xD4 */ { 21371, 28, 34, 29, 0, -33 }, +/* 0xD5 */ { 21490, 28, 34, 29, 0, -33 }, +/* 0xD6 */ { 21609, 32, 34, 38, 3, -33 }, +/* 0xD7 */ { 21745, 29, 34, 31, 1, -33 }, +/* 0xD8 */ { 21869, 32, 34, 38, 3, -33 }, +/* 0xD9 */ { 22005, 32, 35, 38, 3, -34 }, +/* 0xDA */ { 22145, 14, 44, 14, -1, -43 }, +/* 0xDB */ { 22222, 28, 44, 29, 0, -43 }, +/* 0xDC */ { 22376, 26, 39, 31, 3, -37 }, +/* 0xDD */ { 22503, 19, 39, 25, 3, -37 }, +/* 0xDE */ { 22596, 22, 48, 30, 4, -37 }, +/* 0xDF */ { 22728, 12, 38, 16, 4, -37 }, +/* 0xE0 */ { 22785, 21, 47, 28, 4, -45 }, +/* 0xE1 */ { 22909, 26, 28, 31, 3, -26 }, +/* 0xE2 */ { 23000, 22, 46, 29, 4, -35 }, +/* 0xE3 */ { 23127, 26, 36, 28, 1, -25 }, +/* 0xE4 */ { 23244, 24, 36, 30, 3, -34 }, +/* 0xE5 */ { 23352, 19, 28, 25, 3, -26 }, +/* 0xE6 */ { 23419, 21, 47, 25, 2, -35 }, +/* 0xE7 */ { 23543, 22, 37, 30, 4, -26 }, +/* 0xE8 */ { 23645, 24, 37, 30, 3, -35 }, +/* 0xE9 */ { 23756, 10, 26, 16, 4, -25 }, +/* 0xEA */ { 23789, 22, 26, 27, 4, -25 }, +/* 0xEB */ { 23861, 25, 36, 27, 1, -35 }, +/* 0xEC */ { 23974, 25, 36, 30, 4, -25 }, +/* 0xED */ { 24087, 22, 26, 26, 2, -25 }, +/* 0xEE */ { 24159, 20, 47, 25, 2, -35 }, +/* 0xEF */ { 24277, 24, 28, 29, 3, -26 }, +/* 0xF0 */ { 24361, 25, 27, 28, 2, -25 }, +/* 0xF1 */ { 24446, 24, 37, 31, 4, -26 }, +/* 0xF2 */ { 24557, 20, 38, 28, 3, -26 }, +/* 0xF3 */ { 24652, 26, 27, 30, 3, -25 }, +/* 0xF4 */ { 24740, 24, 26, 28, 2, -25 }, +/* 0xF5 */ { 24818, 21, 26, 28, 4, -24 }, +/* 0xF6 */ { 24887, 26, 37, 32, 3, -26 }, +/* 0xF7 */ { 25008, 24, 36, 26, 1, -25 }, +/* 0xF8 */ { 25116, 26, 36, 32, 3, -25 }, +/* 0xF9 */ { 25233, 32, 26, 38, 3, -24 }, +/* 0xFA */ { 25337, 14, 36, 16, 0, -35 }, +/* 0xFB */ { 25400, 21, 37, 28, 4, -35 }, +/* 0xFC */ { 25498, 24, 39, 29, 3, -37 }, +/* 0xFD */ { 25615, 21, 39, 28, 4, -37 }, +/* 0xFE */ { 25718, 32, 39, 38, 3, -37 }, +/* 0xFF */ { 25874, 0, 0, 0, 0, 0 }, +}; + +const GFXfont FreeSans24pt_Win1253 PROGMEM = { +(uint8_t*)FreeSans24pt_Win1253Bitmaps, +(GFXglyph*)FreeSans24pt_Win1253Glyphs, +0x01, 0xFF, 55 +}; diff --git a/src/graphics/niche/InkHUD/AppletFont.h b/src/graphics/niche/InkHUD/AppletFont.h index 8374c7f617f..c97c12c36fe 100644 --- a/src/graphics/niche/InkHUD/AppletFont.h +++ b/src/graphics/niche/InkHUD/AppletFont.h @@ -88,8 +88,12 @@ class AppletFont // Greek #include "graphics/niche/Fonts/FreeSans12pt_Win1253.h" +#include "graphics/niche/Fonts/FreeSans18pt_Win1253.h" +#include "graphics/niche/Fonts/FreeSans24pt_Win1253.h" #include "graphics/niche/Fonts/FreeSans6pt_Win1253.h" #include "graphics/niche/Fonts/FreeSans9pt_Win1253.h" +#define FREESANS_24PT_WIN1253 InkHUD::AppletFont(FreeSans24pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -5, 3) +#define FREESANS_18PT_WIN1253 InkHUD::AppletFont(FreeSans18pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -4, 2) #define FREESANS_12PT_WIN1253 InkHUD::AppletFont(FreeSans12pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -3, 1) #define FREESANS_9PT_WIN1253 InkHUD::AppletFont(FreeSans9pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -2, -1) #define FREESANS_6PT_WIN1253 InkHUD::AppletFont(FreeSans6pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -1, -2) diff --git a/src/input/TouchScreenImpl1.cpp b/src/input/TouchScreenImpl1.cpp index 69dcab04e9c..14f95b73a52 100644 --- a/src/input/TouchScreenImpl1.cpp +++ b/src/input/TouchScreenImpl1.cpp @@ -29,7 +29,8 @@ void TouchScreenImpl1::init() return; #else TouchScreenBase::init(true); - inputBroker->registerSource(this); + if (inputBroker) + inputBroker->registerSource(this); #endif } diff --git a/variants/esp32s3/t5s3_epaper/nicheGraphics.h b/variants/esp32s3/t5s3_epaper/nicheGraphics.h index 699a82de0f9..18217800bb6 100644 --- a/variants/esp32s3/t5s3_epaper/nicheGraphics.h +++ b/variants/esp32s3/t5s3_epaper/nicheGraphics.h @@ -6,26 +6,27 @@ NicheGraphics attempts a different approach: Per-device config takes place in this setupNicheGraphics() method (And a small amount in platformio.ini) -This file sets up InkHUD for Heltec VM-E290. -Different NicheGraphics UIs and different hardware variants will each have their own setup procedure. +This file sets up InkHUD for the LilyGo T5-E-Paper-S3-Pro. + +The board uses a 4.7" ED047TC1 parallel e-paper display (960×540, 8-bit parallel interface). +This is driven via the FastEPD library through the NicheGraphics ED047TC1 driver adapter. */ #pragma once #include "configuration.h" -#include "mesh/MeshModule.h" #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS // InkHUD-specific components // --------------------------- -// #include "graphics/niche/InkHUD/InkHUD.h" -#include "graphics/niche/InkHUD/WindowManager.h" +#include "graphics/niche/InkHUD/InkHUD.h" // Applets #include "graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h" #include "graphics/niche/InkHUD/Applets/User/DM/DMApplet.h" +#include "graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.h" #include "graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h" #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" @@ -34,26 +35,20 @@ Different NicheGraphics UIs and different hardware variants will each have their // Shared NicheGraphics components // -------------------------------- #include "graphics/niche/Drivers/Backlight/LatchingBacklight.h" -#include "graphics/niche/Drivers/EInk/DEPG0290BNS800.h" +#include "graphics/niche/Drivers/EInk/ED047TC1.h" #include "graphics/niche/Inputs/TwoButton.h" void setupNicheGraphics() { using namespace NicheGraphics; - // SPI - // ----------------------------- - - // Display is connected to HSPI - SPIClass *hspi = new SPIClass(HSPI); - hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); - // E-Ink Driver // ----------------------------- + // The ED047TC1 is a parallel display — no SPI bus setup needed. + // begin() args are part of the EInk interface but are ignored for parallel displays. - // Use E-Ink driver - Drivers::EInk *driver = new Drivers::DEPG0290BNS800; - driver->begin(hspi, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY); + Drivers::EInk *driver = new Drivers::ED047TC1; + driver->begin(nullptr, 0, 0, 0); // InkHUD // ---------------------------- @@ -67,57 +62,57 @@ void setupNicheGraphics() // Set how unhealthy additional FAST updates beyond this number are inkhud->setDisplayResilience(7, 1.5); - // Prepare fonts - InkHUD::Applet::fontLarge = FREESANS_9PT_WIN1252; - InkHUD::Applet::fontSmall = FREESANS_6PT_WIN1252; + // Prepare fonts — use larger sizes to suit the 4.7" screen at ~234 DPI + InkHUD::Applet::fontLarge = FREESANS_24PT_WIN1253; + InkHUD::Applet::fontMedium = FREESANS_18PT_WIN1253; + InkHUD::Applet::fontSmall = FREESANS_12PT_WIN1253; - // Init settings, and customize defaults + // Customize default settings inkhud->persistence->settings.userTiles.maxCount = 2; // How many tiles can the display handle? - inkhud->persistence->settings.rotation = 1; // 90 degrees clockwise + inkhud->persistence->settings.rotation = 3; // 270 degrees clockwise inkhud->persistence->settings.userTiles.count = 1; // One tile only by default, keep things simple for new users - inkhud->persistence->settings.optionalMenuItems.nextTile = false; // Behavior handled by aux button instead - inkhud->persistence->settings.optionalFeatures.batteryIcon = true; // Device definitely has a battery + inkhud->persistence->settings.optionalFeatures.batteryIcon = true; + inkhud->persistence->settings.optionalMenuItems.backlight = true; - // Setup backlight - // Note: AUX button behavior configured further down - Drivers::LatchingBacklight *backlight = Drivers::LatchingBacklight::getInstance(); - backlight->setPin(PIN_EINK_EN); + // Alignment must cancel rotation for visual-frame touch input: (rotation + alignment) % 4 == 0. + inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; // Pick applets // Note: order of applets determines priority of "auto-show" feature - // Optional arguments for defaults: - // - is activated? - // - is autoshown? - // - is foreground on a specific tile (index)? - inkhud->addApplet("All Messages", new InkHUD::AllMessageApplet, true, true); // Activated, autoshown - inkhud->addApplet("DMs", new InkHUD::DMApplet); - inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); + inkhud->addApplet("All Messages", new InkHUD::AllMessageApplet, false, false); // Not Active, not autoshown + inkhud->addApplet("DMs", new InkHUD::DMApplet, true, true); // Activated, Autoshown + inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, true); // Activated, Autoshown + inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1), false, false); // Not Active, not autoshown + inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false); // Activated, not autoshown + inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false); // Activated, not autoshown inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 - // inkhud->addApplet("Basic", new InkHUD::BasicExampleApplet); - // inkhud->addApplet("NewMsg", new InkHUD::NewMsgExampleApplet); + inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // Not Active, not autoshown + + // Backlight + // ---------------------------- + Drivers::LatchingBacklight *backlight = Drivers::LatchingBacklight::getInstance(); + backlight->setPin(BOARD_BL_EN); // GPIO11 on V2 // Start running InkHUD inkhud->begin(); + // Touch navigation requires joystick mode — enforce post-begin so flash cannot override. + inkhud->persistence->settings.joystick.enabled = true; + inkhud->persistence->settings.joystick.aligned = true; + // Buttons // -------------------------- Inputs::TwoButton *buttons = Inputs::TwoButton::getInstance(); // A shared NicheGraphics component - // Setup the main user button (0) + // Setup the main user button (boot button, GPIO 0) buttons->setWiring(0, BUTTON_PIN); - buttons->setHandlerShortPress(0, []() { InkHUD::InkHUD::getInstance()->shortpress(); }); - buttons->setHandlerLongPress(0, []() { InkHUD::InkHUD::getInstance()->longpress(); }); + buttons->setHandlerShortPress(0, [inkhud]() { inkhud->shortpress(); }); + buttons->setHandlerLongPress(0, [inkhud]() { inkhud->longpress(); }); - // Setup the aux button (1) - // Bonus feature of VME290 - buttons->setWiring(1, BUTTON_PIN_SECONDARY); - buttons->setHandlerShortPress(1, []() { InkHUD::InkHUD::getInstance()->nextTile(); }); + // No dedicated aux button on this board buttons->start(); } -#endif \ No newline at end of file +#endif diff --git a/variants/esp32s3/t5s3_epaper/variant.cpp b/variants/esp32s3/t5s3_epaper/variant.cpp index e10d7c34757..3bc010ce061 100644 --- a/variants/esp32s3/t5s3_epaper/variant.cpp +++ b/variants/esp32s3/t5s3_epaper/variant.cpp @@ -2,21 +2,123 @@ #ifdef T5_S3_EPAPER_PRO +#include "Observer.h" #include "TouchDrvGT911.hpp" #include "Wire.h" +#include "input/InputBroker.h" #include "input/TouchScreenImpl1.h" +#include "sleep.h" + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +#include "graphics/niche/InkHUD/InkHUD.h" +#include "graphics/niche/InkHUD/SystemApplet.h" + +// Bridges touch events from TouchScreenImpl1 directly into InkHUD, +// bypassing the InputBroker (which is excluded in InkHUD builds). +// Routing mirrors the mini-epaper-s3 two-way rocker pattern: +// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu) +// - Nav up/down: navUp/navDown always (menu scroll) +// - Tap: shortpress (cycle applets / confirm in menu) +// - Long press: longpress (open menu / back) +class TouchInkHUDBridge : public Observer +{ + int onNotify(const InputEvent *e) override + { + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + + // Keep alignment in sync with the current rotation so that visual-frame gestures + // always pass through nav functions without remapping: (rotation + alignment) % 4 == 0. + inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; + + // Check whether a system applet (e.g. menu) is currently handling input + bool systemHandlingInput = false; + for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + systemHandlingInput = true; + break; + } + } + + switch (e->inputEvent) { + case INPUT_BROKER_USER_PRESS: + inkhud->shortpress(); + break; + case INPUT_BROKER_SELECT: + inkhud->longpress(); + break; + case INPUT_BROKER_LEFT: + if (systemHandlingInput) + inkhud->navUp(); + else + inkhud->prevApplet(); + break; + case INPUT_BROKER_RIGHT: + if (systemHandlingInput) + inkhud->navDown(); + else + inkhud->nextApplet(); + break; + case INPUT_BROKER_UP: + inkhud->navUp(); + break; + case INPUT_BROKER_DOWN: + inkhud->navDown(); + break; + default: + break; + } + return 0; + } +}; + +static TouchInkHUDBridge touchBridge; +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS TouchDrvGT911 touch; +// Commands the GT911 into standby before the Wire bus is torn down. +// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here. +struct TouchDeepSleepObserver { + int onDeepSleep(void *) + { + touch.sleep(); + return 0; + } + CallbackObserver observer{this, &TouchDeepSleepObserver::onDeepSleep}; +} static touchDeepSleepObserver; + bool readTouch(int16_t *x, int16_t *y) { if (!digitalRead(GT911_PIN_INT)) { int16_t raw_x; int16_t raw_y; if (touch.getPoint(&raw_x, &raw_y)) { - // rotate 90° for landscape - *x = raw_y; - *y = EPD_WIDTH - 1 - raw_x; +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. + // rotation=3 is the physical identity (device's default orientation). + switch (NicheGraphics::InkHUD::InkHUD::getInstance()->persistence->settings.rotation) { + default: + case 3: + *x = raw_x; + *y = raw_y; + break; // identity + case 2: + *x = (EPD_WIDTH - 1) - raw_y; + *y = raw_x; + break; // 90° CW tilt + case 1: + *x = (EPD_HEIGHT - 1) - raw_x; + *y = (EPD_WIDTH - 1) - raw_y; + break; // 180° flip + case 0: + *x = raw_y; + *y = (EPD_HEIGHT - 1) - raw_x; + break; // 90° CCW tilt + } +#else + *x = raw_x; + *y = raw_y; +#endif LOG_DEBUG("touched(%d/%d)", *x, *y); return true; } @@ -31,15 +133,46 @@ void earlyInitVariant() pinMode(SDCARD_CS, OUTPUT); digitalWrite(SDCARD_CS, HIGH); pinMode(BOARD_BL_EN, OUTPUT); + + // Program GT911 touch controller to I2C address 0x14 (GT911_SLAVE_ADDRESS_H) before + // the I2C bus scan runs. GPIO3 (INT) defaults LOW on ESP32-S3 cold boot, which would + // leave the GT911 at 0x5D (GT911_SLAVE_ADDRESS_L) — the same address as the SFA30 + // air quality sensor — causing a false-positive SFA30 detection during the I2C scan. + // + // GT911 datasheet §4.3 "Address Selection": + // Pull INT HIGH before releasing RST → device latches address 0x14 (SLAVE_ADDRESS_H) + // Pull INT LOW before releasing RST → device latches address 0x5D (SLAVE_ADDRESS_L) + // Minimum RST assert time: 100 µs; minimum startup time after RST deassert: 5 ms. + // + // lateInitVariant() calls touch.begin() which repeats this sequence internally while + // also performing full I2C initialisation; the double-reset is harmless. + pinMode(GT911_PIN_RST, OUTPUT); + digitalWrite(GT911_PIN_RST, LOW); + pinMode(GT911_PIN_INT, OUTPUT); + digitalWrite(GT911_PIN_INT, HIGH); // HIGH → latch address 0x14 + delay(1); // > 100 µs + digitalWrite(GT911_PIN_RST, HIGH); + delay(10); // > 5 ms startup + pinMode(GT911_PIN_INT, INPUT); // release INT for interrupt use +} + +void variant_shutdown() +{ + // Ensure frontlight is off during deep sleep + digitalWrite(BOARD_BL_EN, LOW); } // T5-S3-ePaper Pro specific (late-) init void lateInitVariant(void) { touch.setPins(GT911_PIN_RST, GT911_PIN_INT); - if (touch.begin(Wire, GT911_SLAVE_ADDRESS_L, GT911_PIN_SDA, GT911_PIN_SCL)) { + if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) { + touchDeepSleepObserver.observer.observe(¬ifyDeepSleep); touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch); touchScreenImpl1->init(); +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + touchBridge.observe(touchScreenImpl1); +#endif } else { LOG_ERROR("Failed to find touch controller!"); } diff --git a/variants/esp32s3/t5s3_epaper/variant.h b/variants/esp32s3/t5s3_epaper/variant.h index c2c0013732f..803b582af03 100644 --- a/variants/esp32s3/t5s3_epaper/variant.h +++ b/variants/esp32s3/t5s3_epaper/variant.h @@ -26,9 +26,9 @@ #define GT911_PIN_RST 9 #endif -#define PCF85063_RTC 0x51 +#define PCF8563_RTC 0x51 #define HAS_RTC 1 -#define PCF85063_INT 2 +#define PCF8563_INT 2 #define USE_POWERSAVE #define SLEEP_TIME 120 From 9361b85f47aab6c0c6f9205ac25beac134e90535 Mon Sep 17 00:00:00 2001 From: George <509474+giannoug@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:35:02 +0300 Subject: [PATCH 041/225] feat(t5s3-epaper): add InkHUD port for LilyGo T5 E-Paper S3 Pro (#10211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * niche: add InkHUD port for LilyGo T5-E-Paper-S3-Pro (ED047TC1) Add a NicheGraphics EInk driver adapter for the 4.7" ED047TC1 parallel e-paper display used on the T5-E-Paper-S3-Pro (H752-01). The driver wraps FastEPD and handles the polarity difference between InkHUD's buffer format (0xFF = white) and FastEPD's (0x00 = white). Rewrite variants/esp32s3/t5s3_epaper/nicheGraphics.h which was an incomplete copy of the Heltec VM-E290 setup referencing undefined SPI pin macros and a non-existent BUTTON_PIN_SECONDARY. The board uses a parallel display, not the small SPI DEPG0290BNS800 that was referenced. * fix: guard inputBroker null dereference in TouchScreenImpl1::init() When MESHTASTIC_EXCLUDE_INPUTBROKER is defined (e.g. InkHUD builds), inputBroker is nullptr. Calling inputBroker->registerSource() in that state caused a LoadProhibited panic on any board that has both HAS_TOUCHSCREEN=1 and the InputBroker excluded. Add a null check before registerSource() to prevent the crash. * niche: fix display rotation for T5-E-Paper-S3-Pro InkHUD port Set rotation=3 (270° CW) in nicheGraphics.h to correct for FastEPD scanning the ED047TC1 panel in portrait orientation, resulting in correct landscape display output. * fix: update buffer format descriptions and remove polarity inversion for InkHUD and FastEPD * fix: update ED047TC1 driver to handle inactive pixel borders and adjust safe-area dimensions * fix: comment out ruler diagnostic for E-Ink driver * feat: implement TouchInkHUDBridge for direct touch event handling in InkHUD * niche: add FreeSans 18pt/24pt Win1253 (Greek) fonts for larger InkHUD displays Add Win1253-encoded FreeSans 18pt and 24pt font headers to support Greek script on larger InkHUD screens (e.g., the 4.7" ED047TC1 at ~234 DPI). Register FREESANS_24PT_WIN1253 and FREESANS_18PT_WIN1253 macros in AppletFont.h. Set fontLarge=24pt, fontMedium=18pt, fontSmall=12pt in nicheGraphics.h for the T5-E-Paper-S3-Pro. * feat(ed047tc1): use true partial update for FAST refresh Replace fullUpdate(CLEAR_FAST) with partialUpdate() for FAST display updates. FastEPD's partialUpdate() diffs pCurrent against pPrevious and only applies the update waveform to rows that have changed, leaving unchanged rows with a neutral signal. This reduces visible flicker on routine updates (new messages, position changes) — only the affected region of the screen refreshes. Full-screen CLEAR_SLOW updates are preserved for periodic ghosting cleanup, driven by InkHUD's setDisplayResilience() ratio. * feat(t5s3-epaper): enable frontlight via LatchingBacklight Wire up BOARD_BL_EN (GPIO11) to InkHUD's LatchingBacklight driver. Enable the backlight menu item so users can toggle "Keep Backlight On" via Settings. The backlight turns on automatically when the menu opens and off when it closes. * Fix RTC chip (PCF8563 not PCF85063) and GT911 I2C address collision - variant.h used PCF85063_RTC but the board has a PCF8563. The difference is the RAM register: PCF85063 has 1 byte of RAM; PCF8563 does not. The PCF85063 driver was trying to write this register on init, failing every time, and setDateTime writes were silently discarded — RTC time was never persisted across reboots. Switch to PCF8563_RTC/PCF8563_INT. Before: [E][SensorPCF85063.hpp:375] initImpl(): Failed to write to RAM memory register. Maybe this chip is pcf8563. Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:23 PCF85063 setDateTime 2026-04-05 18:40:59 Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:19 ← lost After: PCF8563 found at address 0x51 Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:37 ← persisted PCF8563 setDateTime 2026-04-05 18:58:44 Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:44 ← round-trips - GT911 touch was initialized with GT911_SLAVE_ADDRESS_L (0x5D), which collides with the SFA30 air quality sensor also at 0x5D on the same I2C bus. Switch to GT911_SLAVE_ADDRESS_H (0x14): the library drives INT high during reset to program the GT911 to address 0x14, eliminating the address conflict. Before: SFA30 found at address 0x5d [I][TouchDrvGT911.hpp:568] initImpl(): Try using 0x5D as the device address After: SFA30 found at address 0x5d [I][TouchDrvGT911.hpp:544] initImpl(): Try using 0x14 as the device address * t5s3_epaper: fix GT911 ghost-SFA30 via early I2C address latch Investigation findings ---------------------- Boot logs showed "SFA30 found at address 0x5d" on every cold power-on, and AirQualityTelemetry was registering an SFA30 sensor. However, every readMeasuredValues() call returned error 268 (0x010C = Sensirion WriteError | I2cAddressNack), meaning the I2C write to 0x5D was being NACK'd — inconsistent with a real SFA30. Root cause: the GT911 touch controller latches its I2C address from the INT pin level at reset time (GT911 datasheet §4.3). GPIO3 (INT) defaults LOW on ESP32-S3 cold boot → GT911 always powers up at 0x5D (SLAVE_ADDRESS_L). The I2C scanner runs before lateInitVariant() had a chance to reprogram the chip. The scanner's SFA30 detection (ScanI2CTwoWire.cpp) sends the 2-byte command 0xD060 to 0x5D and requests 48 bytes back. GT911 ACKs the write (treating it as a register address) and returns 48 bytes of register data, passing the length check — a false-positive SFA30 detection. Confirmed via second cold-boot log: after the previous commit moved GT911 to 0x14 in lateInitVariant(), address 0x5D *still* appeared in the scan because the scanner runs first. The board has no physical SFA30 fitted. Fix --- Add the GT911 address-latch reset sequence to earlyInitVariant(), before Wire is initialised and before the I2C scan runs. Per the datasheet: drive RST LOW, drive INT HIGH (selects address 0x14 / SLAVE_ADDRESS_H), hold >100 µs, release RST, wait >5 ms startup. GPIO-only, no Wire dependency. lateInitVariant() then repeats this sequence internally via touch.begin(); the double-reset is harmless. Verified in boot log: Before: "SFA30 found at address 0x5d", 5 I2C devices, NACK errors After: no SFA30 entry, 4 I2C devices (TCA9535/PCF8563/BQ27220/BQ25896), GT911 found at 0x14 and touch initialised successfully, AirQualityTelemetry registers no sensors (correct — no SFA30 present) * t5s3_epaper: add variant_shutdown() for touch sleep and backlight off Put GT911 into low-power standby (command 0x05) and drive BOARD_BL_EN LOW before deep sleep to avoid unnecessary current draw. * t5s3_epaper: fix touch gesture routing and coordinate mapping readTouch() now transforms raw GT911 axes to visual-frame coordinates based on the current display rotation (rotation=3 is the hardware identity). This ensures TouchScreenBase detects swipe direction correctly regardless of which rotation the user has selected. TouchInkHUDBridge dynamically sets joystick.alignment = (4-rotation)%4 on each touch event so that (rotation+alignment)%4==0 always, keeping nav calls pass-through without remapping. nicheGraphics.h now calls loadSettings() first so that rotation is persisted across reboots. rotation=3 and other first-boot defaults are only applied when tips.firstBoot is set. alignment is recomputed from the loaded rotation on every boot. Co-Authored-By: Claude Sonnet 4.6 * t5s3_epaper: fix GT911 sleep timing via notifyDeepSleep observer touch.sleep() was called from variant_shutdown(), which runs inside cpuDeepSleep() — after Wire.end() had already torn down the I2C bus in doDeepSleep(). This caused Wire NULL TX buffer errors and left the GT911 awake during deep sleep. Register a CallbackObserver on notifyDeepSleep, which fires before Wire.end(), so the I2C command reaches the chip while the bus is live. Pattern matches LatchingBacklight and other NicheGraphics components. Co-Authored-By: Claude Sonnet 4.6 * t5s3_epaper: fix touch nav and applet defaults in nicheGraphics Enable joystick mode post-begin so menu scroll and swipe-up/down gestures are not silently dropped by the joystick.enabled gate in Events.cpp. Activate DMs and Channel 0/1 applets with correct autoshow defaults matching the mini-epaper-s3 reference pattern. Co-Authored-By: Claude Sonnet 4.6 * Update nicheGraphics.h * t5s3_epaper: fix ED047TC1 driver docs and remove spurious beginPolling Addressing PR review comments: Remove beginPolling(1, 0) after the blocking FastEPD update — it incorrectly set updateRunning=true for one loop cycle after the hardware was already done, causing busy() to briefly return true. Since isUpdateDone() always returns true, no polling is needed. Also fix stale comments: safe-area buffer size was 944×532, now 944×523; V_OFFSET_ROWS didn't exist, replaced with the actual V_OFFSET_TOP=9 / V_OFFSET_BOTTOM=8 constant names. * t5s3_epaper: clean up applet addition formatting in setupNicheGraphics * t5s3_epaper: guard ED047TC1.cpp against non-T5S3 InkHUD builds The InkHUD base config pulls in all of src/graphics/niche/ so every InkHUD device compiled ED047TC1.cpp, triggering the #error on line 48 for boards that define neither T5_S3_EPAPER_PRO_V1 nor V2. Wrap the file body with #ifdef T5_S3_EPAPER_PRO so it is only compiled for T5S3 targets. The #error is preserved inside the guard to catch future hardware revisions that forget to update the driver. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> --- src/graphics/niche/Drivers/EInk/ED047TC1.cpp | 122 + src/graphics/niche/Drivers/EInk/ED047TC1.h | 90 + .../niche/Fonts/FreeSans18pt_Win1253.h | 1467 ++++++++++ .../niche/Fonts/FreeSans24pt_Win1253.h | 2429 +++++++++++++++++ src/graphics/niche/InkHUD/AppletFont.h | 4 + src/input/TouchScreenImpl1.cpp | 3 +- variants/esp32s3/t5s3_epaper/nicheGraphics.h | 89 +- variants/esp32s3/t5s3_epaper/variant.cpp | 141 +- variants/esp32s3/t5s3_epaper/variant.h | 4 +- 9 files changed, 4295 insertions(+), 54 deletions(-) create mode 100644 src/graphics/niche/Drivers/EInk/ED047TC1.cpp create mode 100644 src/graphics/niche/Drivers/EInk/ED047TC1.h create mode 100644 src/graphics/niche/Fonts/FreeSans18pt_Win1253.h create mode 100644 src/graphics/niche/Fonts/FreeSans24pt_Win1253.h diff --git a/src/graphics/niche/Drivers/EInk/ED047TC1.cpp b/src/graphics/niche/Drivers/EInk/ED047TC1.cpp new file mode 100644 index 00000000000..f1189045b0a --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/ED047TC1.cpp @@ -0,0 +1,122 @@ +/* + + NicheGraphics parallel E-Ink driver for the LilyGo T5-S3-ePaper-Pro (ED047TC1). + + InkHUD buffer format : 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white + FastEPD buffer format: 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white + + Both formats share the same pixel layout and polarity (1 = white, 0 = black). + The InkHUD safe-area buffer (944×523) is copied into the centre of the physical + 960×540 FastEPD buffer so content clears the panel's inactive edge border. + See ED047TC1.h for the H_OFFSET_BYTES / V_OFFSET_TOP / V_OFFSET_BOTTOM constants. + +*/ + +// Ruler diagnostic — uncomment to draw calibration lines at each physical edge. +// #define EINK_EDGE_LINES + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +#ifdef T5_S3_EPAPER_PRO + +#include "./ED047TC1.h" + +#include "FastEPD.h" +#include "configuration.h" + +using namespace NicheGraphics::Drivers; + +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 + (void)spi; + (void)pin_dc; + (void)pin_cs; + (void)pin_busy; + (void)pin_rst; + + epaper = new FASTEPD; + +#if defined(T5_S3_EPAPER_PRO_V1) + epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000); +#elif defined(T5_S3_EPAPER_PRO_V2) + epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000); + // Initialize all PCA9535 port-0 pins as outputs / HIGH + for (int i = 0; i < 8; i++) { + epaper->ioPinMode(i, OUTPUT); + epaper->ioWrite(i, HIGH); + } +#else +#error "ED047TC1 driver: unsupported variant — define T5_S3_EPAPER_PRO_V1 or T5_S3_EPAPER_PRO_V2" +#endif + + epaper->setMode(BB_MODE_1BPP); + epaper->clearWhite(); + epaper->fullUpdate(true); // Blocking initial clear +} + +void ED047TC1::update(uint8_t *imageData, UpdateTypes type) +{ + if (!epaper) + return; + + // InkHUD renders into a DISPLAY_WIDTH × DISPLAY_HEIGHT safe-area buffer. + // We need to place that into the centre of the physical 960×540 FastEPD buffer, + // leaving blank margins at every edge to avoid the panel's inactive border. + const uint32_t srcRowBytes = (DISPLAY_WIDTH + 7) / 8; // bytes per row in InkHUD buffer (118) + const uint32_t dstRowBytes = (960 + 7) / 8; // bytes per row in physical buffer (120) + const uint32_t dstTotalRows = 540; + + uint8_t *cur = epaper->currentBuffer(); + + // Fill physical buffer with white (0xFF = white in FastEPD 1bpp) + memset(cur, 0xFF, dstRowBytes * dstTotalRows); + + // Copy each InkHUD row into the physical buffer with horizontal + vertical offsets + for (uint32_t row = 0; row < DISPLAY_HEIGHT; row++) { + const uint8_t *srcRow = imageData + row * srcRowBytes; + uint8_t *dstRow = cur + (row + V_OFFSET_TOP) * dstRowBytes + H_OFFSET_BYTES; + memcpy(dstRow, srcRow, srcRowBytes); + } + +#ifdef EINK_EDGE_LINES + // Draw a 1px black box at the exact boundary of the safe area within the + // physical buffer. If the margins are correct, all 4 lines should be + // fully visible and right at the edge of the usable display area. + + auto setPixelBlack = [&](uint32_t col, uint32_t row) { cur[row * dstRowBytes + col / 8] &= ~(0x80 >> (col % 8)); }; + + const uint32_t safeX = H_OFFSET_BYTES * 8; + const uint32_t safeY = V_OFFSET_TOP; + const uint32_t safeW = DISPLAY_WIDTH; + const uint32_t safeH = DISPLAY_HEIGHT; + + // Top edge: horizontal line at safeY + for (uint32_t col = safeX; col < safeX + safeW; col++) + setPixelBlack(col, safeY); + + // Bottom edge: horizontal line at safeY + safeH - 1 + for (uint32_t col = safeX; col < safeX + safeW; col++) + setPixelBlack(col, safeY + safeH - 1); + + // Left edge: vertical line at safeX + for (uint32_t row = safeY; row < safeY + safeH; row++) + setPixelBlack(safeX, row); + + // Right edge: vertical line at safeX + safeW - 1 + for (uint32_t row = safeY; row < safeY + safeH; row++) + setPixelBlack(safeX + safeW - 1, row); +#endif + + if (type == FULL) { + epaper->fullUpdate(CLEAR_SLOW, false); + epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline + } else { + // FAST: true partial update — compares pCurrent vs pPrevious and only applies + // the update waveform to rows that actually changed. Unchanged rows get a neutral + // signal (no visible effect). partialUpdate() updates pPrevious internally. + epaper->partialUpdate(false, 0, dstTotalRows - 1); + } +} + +#endif // T5_S3_EPAPER_PRO +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/Drivers/EInk/ED047TC1.h b/src/graphics/niche/Drivers/EInk/ED047TC1.h new file mode 100644 index 00000000000..3540481e73f --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/ED047TC1.h @@ -0,0 +1,90 @@ +/* + + E-Ink display driver adapter + - ED047TC1 (via FastEPD library) + - Manufacturer: E Ink / used in LilyGo T5-E-Paper-S3-Pro + - Size: 4.7 inch + - Physical resolution: 960px x 540px + - Interface: 8-bit parallel (NOT SPI) + + 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 + 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 + H_OFFSET_BYTES (horizontal, whole bytes = 8 pixels per byte), + 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 = 1 → 8px left margin, 8px right margin (960 – 8 – 8 = 944) + V_OFFSET_TOP = 9 → 9px top margin (asymmetric: top ≠ bottom) + V_OFFSET_BOTTOM = 8 → 8px bottom margin (540 – 9 – 8 = 523) + +*/ + +#pragma once + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "configuration.h" + +#include "./EInk.h" + +// Forward declare to avoid pulling FastEPD into all translation units +class FASTEPD; + +namespace NicheGraphics::Drivers +{ + +class ED047TC1 : public EInk +{ + // Safe-area dimensions exposed to InkHUD (physical panel is 960×540). + // + // The ED047TC1 has an inactive pixel border on all physical edges. + // The physical buffer coordinates do NOT directly match the visual orientation + // due to FastEPD's portrait scan direction and InkHUD's rotation=3 (270° CW): + // + // Physical buffer Visual on device (rotation=3) + // ───────────────── ────────────────────────────── + // Physical LEFT cols → Visual TOP edge + // Physical RIGHT cols → Visual BOTTOM edge + // Physical TOP rows → Visual RIGHT edge + // Physical BOTTOM rows → Visual LEFT edge + // + // Offset constants shift the InkHUD safe-area away from each physical dead zone: + // H_OFFSET_BYTES : whole bytes from physical left (8px per byte, affects visual TOP) + // Physical right margin = 960 − H_OFFSET_BYTES×8 − DISPLAY_WIDTH (affects visual BOTTOM) + // V_OFFSET_TOP : pixel rows from physical top (affects visual RIGHT) + // V_OFFSET_BOTTOM: pixel rows from physical bottom (affects visual LEFT) + // + // Calibrated by flashing a 1px border box and adjusting until all 4 sides are visible. + + static constexpr uint16_t DISPLAY_WIDTH = 944; // 960 − H_OFFSET_BYTES×8 − right_margin (8+8 = 16px) + static constexpr uint16_t DISPLAY_HEIGHT = 523; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (9+8 = 17px) + + static constexpr uint8_t H_OFFSET_BYTES = 1; // visual TOP : 8px physical left margin + // visual BOTTOM: 960−8−944=8px physical right margin + static constexpr uint8_t V_OFFSET_TOP = 9; // visual RIGHT : CONFIRMED OK + static constexpr uint8_t V_OFFSET_BOTTOM = 8; // visual LEFT : 8px physical bottom margin + + static constexpr UpdateTypes supported = static_cast(FULL | FAST); + + public: + ED047TC1() : EInk(DISPLAY_WIDTH, DISPLAY_HEIGHT, supported) {} + + // 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; + + protected: + bool isUpdateDone() override { return true; } // FastEPD updates are blocking + + private: + FASTEPD *epaper = nullptr; +}; + +} // namespace NicheGraphics::Drivers + +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/Fonts/FreeSans18pt_Win1253.h b/src/graphics/niche/Fonts/FreeSans18pt_Win1253.h new file mode 100644 index 00000000000..9b29f32b5cd --- /dev/null +++ b/src/graphics/niche/Fonts/FreeSans18pt_Win1253.h @@ -0,0 +1,1467 @@ +// trunk-ignore-all(clang-format) +#pragma once +/* PROPERTIES + +FONT_NAME FreeSans18pt_Win1253 +*/ +const uint8_t FreeSans18pt_Win1253Bitmaps[] PROGMEM = { + 0x00, 0x00, 0x70, 0x00, 0x00, 0x00, 0x6C, 0x00, 0x00, 0x00, 0x23, 0x00, + 0x00, 0x00, 0x10, 0x80, 0x00, 0x00, 0x08, 0x40, 0x00, 0x00, 0x04, 0x30, + 0x00, 0x00, 0x02, 0x18, 0x00, 0x00, 0x01, 0x08, 0x00, 0x00, 0x01, 0x84, + 0x00, 0x00, 0x01, 0x82, 0x00, 0x00, 0x01, 0x83, 0x00, 0x00, 0x01, 0x81, + 0x80, 0x00, 0x03, 0x80, 0xBF, 0xC0, 0x03, 0x00, 0x7C, 0x30, 0x03, 0x00, + 0x60, 0x18, 0x01, 0x00, 0x20, 0x04, 0x01, 0x80, 0x10, 0x06, 0x7F, 0xC0, + 0x0C, 0x06, 0x30, 0x00, 0x07, 0xFF, 0xD8, 0x00, 0x03, 0xE0, 0x2C, 0x00, + 0x01, 0x80, 0x1E, 0x00, 0x01, 0xC0, 0x0F, 0x00, 0x01, 0xA0, 0x05, 0x80, + 0x03, 0x9C, 0x06, 0xC0, 0x03, 0x07, 0xFE, 0x60, 0x00, 0x06, 0x01, 0xB0, + 0x00, 0x03, 0x00, 0x58, 0x00, 0x01, 0x80, 0x2C, 0x00, 0x00, 0xC0, 0x16, + 0x00, 0x00, 0x3E, 0x1B, 0xF8, 0x00, 0x3F, 0xF9, 0xFF, 0x80, 0x10, 0x10, + 0x00, 0x60, 0x08, 0x08, 0x00, 0x1E, 0x06, 0x04, 0x00, 0x03, 0xFF, 0xFE, + 0x00, 0x00, 0x06, 0x1E, 0x00, 0x00, 0x00, 0x79, 0xF0, 0x00, 0x07, 0xFF, + 0xEC, 0x00, 0x0E, 0x03, 0x02, 0x00, 0x0C, 0x01, 0x01, 0x0F, 0xFC, 0x00, + 0x80, 0x87, 0xE0, 0x00, 0x7F, 0xF3, 0x00, 0x00, 0x1E, 0x0D, 0x80, 0x00, + 0x18, 0x02, 0xC0, 0x00, 0x0C, 0x01, 0x60, 0x00, 0x06, 0x00, 0xB0, 0x00, + 0x03, 0x80, 0xD8, 0x00, 0x60, 0xFF, 0xCC, 0x00, 0x1C, 0xC0, 0x36, 0x00, + 0x03, 0x40, 0x0B, 0x00, 0x00, 0xE0, 0x07, 0x80, 0x00, 0x38, 0x03, 0xC0, + 0x00, 0x1F, 0x81, 0x60, 0x00, 0x07, 0xFF, 0xBF, 0xE0, 0x06, 0x01, 0x00, + 0x30, 0x02, 0x00, 0xC0, 0x08, 0x01, 0x00, 0x20, 0x06, 0x00, 0xC0, 0x30, + 0x01, 0x80, 0x3F, 0x18, 0x00, 0x70, 0x13, 0xF8, 0x00, 0x0C, 0x0C, 0x00, + 0x00, 0x03, 0x06, 0x00, 0x00, 0x00, 0xC1, 0x00, 0x00, 0x00, 0x30, 0x80, + 0x00, 0x00, 0x08, 0x40, 0x00, 0x00, 0x04, 0x30, 0x00, 0x00, 0x02, 0x18, + 0x00, 0x00, 0x01, 0x08, 0x00, 0x00, 0x00, 0x84, 0x00, 0x00, 0x00, 0x46, + 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0x07, + 0xFC, 0x00, 0x00, 0x07, 0x80, 0xE0, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, + 0xC0, 0x00, 0x18, 0x00, 0x20, 0x00, 0x01, 0x80, 0x08, 0x00, 0x00, 0x08, + 0x02, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x18, 0x10, 0x00, 0x00, + 0x01, 0x04, 0x00, 0x00, 0x00, 0x10, 0x80, 0x00, 0x00, 0x02, 0x20, 0x00, + 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x04, 0x80, 0x70, 0x07, 0x00, 0xA0, + 0x31, 0x01, 0x18, 0x0C, 0x04, 0x30, 0x61, 0x01, 0x80, 0x00, 0x00, 0x00, + 0x30, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xA8, 0x00, + 0x54, 0x18, 0x2A, 0x00, 0x12, 0x83, 0x05, 0x40, 0x02, 0xA0, 0x50, 0x00, + 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x02, 0x40, 0x10, 0x01, 0x00, 0x44, + 0x03, 0x80, 0xE0, 0x10, 0x80, 0x0F, 0xE0, 0x02, 0x08, 0x00, 0x00, 0x00, + 0x81, 0x00, 0x00, 0x00, 0x30, 0x10, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, + 0x01, 0x00, 0x10, 0x00, 0x00, 0x40, 0x01, 0x80, 0x00, 0x30, 0x00, 0x0C, + 0x00, 0x18, 0x00, 0x00, 0xF0, 0x1E, 0x00, 0x00, 0x03, 0xFE, 0x00, 0x00, + 0x00, 0x00, 0x7F, 0xC0, 0x00, 0x00, 0x00, 0x70, 0x1C, 0x00, 0x00, 0x00, + 0x60, 0x00, 0xC0, 0x00, 0x00, 0x60, 0x00, 0x0C, 0x00, 0x00, 0x30, 0x00, + 0x01, 0x80, 0x00, 0x10, 0x00, 0x00, 0x10, 0x00, 0x08, 0x00, 0x00, 0x02, + 0x00, 0x06, 0x00, 0x00, 0x00, 0xC0, 0x01, 0x0C, 0x00, 0x06, 0x10, 0x00, + 0x86, 0x00, 0x00, 0xC2, 0x00, 0x22, 0x00, 0x00, 0x08, 0x80, 0x11, 0x00, + 0x00, 0x01, 0x10, 0x04, 0x40, 0x00, 0x00, 0x44, 0x01, 0x03, 0xE0, 0x0F, + 0x81, 0x00, 0x81, 0x8C, 0x06, 0x30, 0x20, 0x20, 0x41, 0x01, 0x04, 0x08, + 0x08, 0x00, 0x00, 0x00, 0x02, 0x03, 0xE0, 0x00, 0x00, 0x0F, 0x83, 0x98, + 0x00, 0x00, 0x02, 0x31, 0x86, 0x00, 0x00, 0x00, 0xC2, 0xC1, 0x00, 0x00, + 0x00, 0x10, 0xE0, 0x4F, 0x80, 0x03, 0xE4, 0x18, 0x32, 0x1F, 0xFF, 0x09, + 0x06, 0x08, 0xE0, 0x00, 0x0E, 0x61, 0x4E, 0x1F, 0xFF, 0xFF, 0x0C, 0x8F, + 0x87, 0xFF, 0xFF, 0xC3, 0xC0, 0x20, 0xFF, 0xFF, 0xE0, 0x80, 0x0C, 0x0F, + 0x83, 0xE0, 0x60, 0x01, 0x81, 0xC0, 0x70, 0x30, 0x00, 0x20, 0x0F, 0xE0, + 0x18, 0x00, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0xC0, 0x00, 0x06, 0x00, + 0x00, 0x18, 0x00, 0x03, 0x00, 0x00, 0x03, 0x80, 0x03, 0x00, 0x00, 0x00, + 0x3C, 0x07, 0x80, 0x00, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x0C, + 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, + 0x08, 0x40, 0x00, 0x00, 0x71, 0xC0, 0x00, 0x03, 0x9F, 0x0C, 0x00, 0x00, + 0xFA, 0x30, 0x07, 0x00, 0x11, 0xC3, 0x01, 0xB0, 0x02, 0x18, 0x30, 0x62, + 0x00, 0x61, 0x83, 0x08, 0xC0, 0x06, 0x18, 0x31, 0x18, 0x01, 0xE1, 0x83, + 0x23, 0x00, 0x66, 0x18, 0x34, 0x20, 0x08, 0x61, 0x83, 0x84, 0x01, 0x86, + 0x18, 0x38, 0xC0, 0x18, 0x61, 0x83, 0x08, 0x03, 0x86, 0x10, 0x41, 0x00, + 0xF8, 0x60, 0x18, 0x30, 0x31, 0x86, 0x02, 0x02, 0x06, 0x18, 0x40, 0x40, + 0x60, 0xC1, 0x80, 0x08, 0x04, 0x0C, 0x18, 0x01, 0x00, 0x80, 0xC1, 0x00, + 0x20, 0x18, 0x0C, 0x00, 0x06, 0x01, 0x10, 0xC0, 0x00, 0xC0, 0x23, 0x8C, + 0x00, 0x0C, 0x06, 0x10, 0xC0, 0x00, 0x01, 0xE0, 0x0C, 0x00, 0x00, 0x37, + 0x00, 0xC0, 0x00, 0x04, 0x60, 0x0C, 0x00, 0x01, 0x80, 0x00, 0xC0, 0x00, + 0x20, 0x00, 0x0C, 0x00, 0x0C, 0x00, 0x00, 0xE0, 0x03, 0x00, 0x00, 0x07, + 0x00, 0xC0, 0x00, 0x00, 0x7F, 0xF0, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, + 0x00, 0x0C, 0x00, 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0x00, 0xD0, 0x00, + 0x00, 0xE0, 0x12, 0x00, 0x00, 0x16, 0x04, 0x60, 0x00, 0x02, 0x71, 0x8C, + 0x00, 0x00, 0x63, 0x20, 0x80, 0x00, 0x0C, 0x1F, 0xD0, 0x78, 0x00, 0x8E, + 0x0F, 0xFD, 0x00, 0x12, 0x00, 0x30, 0x60, 0x02, 0x80, 0x02, 0x08, 0x00, + 0x60, 0x00, 0x22, 0x00, 0x7C, 0x00, 0x06, 0xC0, 0xFD, 0x00, 0x00, 0x50, + 0x30, 0x20, 0x00, 0x0C, 0x06, 0x08, 0x00, 0x00, 0x80, 0x71, 0x00, 0x00, + 0x18, 0x03, 0x20, 0x00, 0x02, 0xC0, 0x3C, 0x00, 0x00, 0x4C, 0x01, 0x80, + 0x00, 0x08, 0x60, 0x10, 0x00, 0x01, 0x06, 0x07, 0x00, 0x00, 0x40, 0xC0, + 0xA0, 0x00, 0x0B, 0xF0, 0x36, 0x00, 0x03, 0xE0, 0x04, 0x40, 0x00, 0x60, + 0x01, 0x04, 0x00, 0x14, 0x00, 0x60, 0xC0, 0x04, 0x80, 0x0B, 0xFF, 0x07, + 0x10, 0x01, 0xF0, 0xBF, 0xC3, 0x00, 0x00, 0x10, 0x4C, 0x60, 0x00, 0x03, + 0x18, 0xE4, 0x00, 0x00, 0x62, 0x06, 0x80, 0x00, 0x0C, 0xC0, 0x70, 0x00, + 0x00, 0xB0, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x03, 0x80, 0x00, + 0x00, 0x00, 0x00, 0x1F, 0xC0, 0x00, 0x00, 0x03, 0x83, 0x80, 0x00, 0x00, + 0x30, 0x06, 0x00, 0x00, 0x03, 0x00, 0x18, 0x00, 0x00, 0x30, 0x00, 0x60, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x18, 0x00, 0x0C, 0x00, 0x07, 0xC0, + 0x00, 0x60, 0x00, 0xC3, 0x80, 0x01, 0x00, 0x0C, 0x04, 0x00, 0x08, 0x00, + 0xC0, 0x30, 0x00, 0x40, 0x04, 0x00, 0x80, 0x02, 0x00, 0x20, 0x04, 0x00, + 0x18, 0x0F, 0x00, 0x00, 0x01, 0xF1, 0xC0, 0x00, 0x00, 0x00, 0xC8, 0x00, + 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, + 0x60, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x26, 0x00, 0x00, + 0x00, 0x03, 0x0F, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x30, 0x03, 0x00, 0x38, 0x03, 0x80, + 0x38, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x1E, 0x01, 0xE0, 0x1E, 0x00, 0xE0, + 0x0E, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x80, 0x30, 0x00, 0x00, 0x1C, 0x03, 0x80, 0x00, 0x01, 0xE0, + 0x3C, 0x00, 0x00, 0x0F, 0x01, 0xC0, 0x00, 0x00, 0x70, 0x0E, 0x00, 0x00, + 0x00, 0x00, 0x07, 0xF0, 0x00, 0x00, 0x00, 0x1C, 0x0C, 0x00, 0x00, 0x00, + 0x70, 0x02, 0x00, 0x00, 0x00, 0xC0, 0x01, 0x00, 0x00, 0x01, 0x80, 0x00, + 0x80, 0x00, 0x01, 0x80, 0x00, 0x40, 0x00, 0x03, 0x00, 0x00, 0x40, 0x00, + 0x3F, 0x00, 0x00, 0x20, 0x00, 0xE1, 0xC0, 0x00, 0x20, 0x01, 0x80, 0x60, + 0x00, 0x20, 0x01, 0x00, 0x20, 0x00, 0x20, 0x02, 0x00, 0x10, 0x00, 0x20, + 0x02, 0x00, 0x10, 0x00, 0x20, 0x06, 0x00, 0x00, 0x00, 0x38, 0x1E, 0x00, + 0x00, 0x00, 0x0C, 0x70, 0x00, 0x00, 0x00, 0x02, 0x40, 0x00, 0x00, 0x00, + 0x03, 0x80, 0x00, 0x00, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x01, 0x80, + 0x00, 0x00, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0x00, + 0x00, 0x02, 0x60, 0x00, 0x00, 0x00, 0x02, 0x18, 0x20, 0x00, 0x00, 0x0C, + 0x0F, 0xF0, 0x00, 0x00, 0x70, 0x00, 0x0F, 0xFC, 0x00, 0x80, 0x00, 0x03, + 0xE7, 0x03, 0x00, 0x00, 0x00, 0x01, 0xFC, 0x00, 0x01, 0xF0, 0x00, 0x1F, + 0x00, 0x0F, 0xFC, 0x01, 0xFF, 0x80, 0x39, 0xDC, 0x07, 0x3B, 0x80, 0xF2, + 0xDC, 0x1E, 0x5B, 0x83, 0xB4, 0xEC, 0x6E, 0x9D, 0x8D, 0x38, 0xCD, 0x93, + 0x19, 0x9E, 0x30, 0xCB, 0xA3, 0x19, 0x64, 0x31, 0x69, 0xC7, 0x3B, 0x8C, + 0x52, 0x71, 0x8B, 0x4F, 0x96, 0x94, 0x61, 0x93, 0x8F, 0xA7, 0x18, 0x63, + 0xA3, 0x0D, 0xC6, 0x18, 0xE4, 0xC3, 0x19, 0x86, 0x39, 0x68, 0x87, 0x31, + 0x8E, 0x5A, 0x71, 0xCB, 0x53, 0x96, 0x9C, 0x62, 0xD1, 0x25, 0xA7, 0x18, + 0x64, 0xE2, 0x69, 0xC6, 0x18, 0xE8, 0xC4, 0x70, 0x86, 0x39, 0x70, 0xD0, + 0xE1, 0x8E, 0x5A, 0x21, 0xE0, 0xE2, 0xD2, 0x9C, 0x62, 0x81, 0xA4, 0xE3, + 0x08, 0xB7, 0x01, 0x38, 0xC3, 0x19, 0x34, 0x01, 0x30, 0xC7, 0x2E, 0x30, + 0x01, 0x31, 0xCB, 0x4C, 0x40, 0x01, 0x72, 0xD3, 0x8D, 0x00, 0x03, 0xB4, + 0xE3, 0x1C, 0x00, 0x03, 0x38, 0xC3, 0x38, 0x00, 0x03, 0x30, 0xC7, 0x60, + 0x00, 0x01, 0x31, 0xCB, 0x00, 0x00, 0x01, 0x72, 0x54, 0x00, 0x00, 0x01, + 0xB4, 0x70, 0x00, 0x00, 0x01, 0x18, 0x40, 0x00, 0x00, 0x01, 0x93, 0x00, + 0x00, 0x00, 0x01, 0xBC, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x38, 0x00, + 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 0x07, 0xE0, 0x00, 0x00, 0x01, 0xFE, + 0x00, 0x00, 0x00, 0x3F, 0xE0, 0x00, 0x00, 0x0F, 0xFF, 0xC0, 0x00, 0x01, + 0xFF, 0xFE, 0x00, 0x00, 0x3F, 0xFF, 0xC0, 0x00, 0x07, 0xFF, 0xFC, 0x00, + 0x03, 0xFF, 0xFF, 0x80, 0x00, 0xFF, 0xFF, 0xF0, 0x00, 0x3F, 0xFF, 0xFE, + 0x00, 0x07, 0xFF, 0xFF, 0xE0, 0x01, 0xFF, 0xFF, 0xFE, 0x00, 0x3F, 0xFF, + 0xFF, 0xE0, 0x07, 0xE1, 0xF8, 0xFC, 0x00, 0xF8, 0x1E, 0x0F, 0x80, 0x3E, + 0x71, 0x98, 0xF8, 0x1F, 0xCF, 0x37, 0x9F, 0xC7, 0xF9, 0xC6, 0x63, 0xFC, + 0xFF, 0x01, 0xE0, 0x7F, 0xBF, 0xF0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, + 0x03, 0xFF, 0xBF, 0xF0, 0x00, 0x7F, 0xE7, 0xFE, 0x00, 0x1F, 0xFC, 0x7F, + 0xE0, 0x03, 0xFF, 0x0F, 0xFE, 0x00, 0xFF, 0xC0, 0xFF, 0xF0, 0x7F, 0xF0, + 0x07, 0xFF, 0xFF, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0x00, 0x03, 0xFF, 0xFF, + 0x00, 0x00, 0x07, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x22, 0x00, 0x00, 0x00, 0x0C, 0x20, 0x00, 0x00, 0x01, 0x04, 0x00, 0x00, + 0x00, 0x20, 0x80, 0x00, 0x00, 0x06, 0x30, 0x00, 0x00, 0x00, 0x7C, 0x00, + 0x00, 0x00, 0x3F, 0xE0, 0x00, 0x00, 0x1C, 0x07, 0x00, 0x00, 0x06, 0x00, + 0x30, 0x00, 0x01, 0x00, 0x03, 0x00, 0x00, 0x43, 0x00, 0x30, 0x00, 0x18, + 0xC0, 0x03, 0x00, 0x02, 0x30, 0x00, 0x20, 0x00, 0x44, 0x00, 0x04, 0x00, + 0x11, 0x80, 0x00, 0xC0, 0x02, 0x20, 0x00, 0x08, 0x00, 0x40, 0x00, 0x01, + 0x00, 0x08, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x04, 0x00, 0x20, 0x00, + 0x00, 0x80, 0x0C, 0x00, 0x00, 0x18, 0x01, 0x00, 0x00, 0x03, 0x00, 0x20, + 0x00, 0x00, 0x20, 0x0D, 0xFF, 0xFE, 0x04, 0x01, 0xE0, 0x00, 0x00, 0x40, + 0x40, 0x00, 0x00, 0x04, 0x10, 0x00, 0x00, 0x00, 0x44, 0x07, 0xFF, 0xFC, + 0x04, 0xFF, 0xF8, 0x0F, 0xFE, 0xBF, 0xFF, 0x01, 0xFF, 0xFF, 0xFF, 0xE0, + 0x3F, 0xFF, 0xFF, 0xFE, 0x0F, 0xFF, 0xC7, 0xFF, 0xC1, 0xFF, 0xF0, 0x3F, + 0xFC, 0x7F, 0xF8, 0x00, 0x3F, 0xFF, 0xE0, 0x00, 0x00, 0x07, 0xFC, 0x00, + 0x00, 0x07, 0x00, 0xF0, 0x00, 0x03, 0x80, 0x03, 0x00, 0x00, 0xC0, 0x00, + 0x18, 0x00, 0x20, 0x00, 0x00, 0x80, 0x08, 0x00, 0x00, 0x08, 0x02, 0x00, + 0x00, 0x00, 0x80, 0x82, 0x00, 0x02, 0x08, 0x11, 0xC0, 0x00, 0x71, 0x04, + 0xE0, 0x00, 0x03, 0x11, 0x90, 0x00, 0x00, 0x33, 0x26, 0x00, 0x00, 0x03, + 0x24, 0x0E, 0x00, 0x0F, 0x05, 0x87, 0xF0, 0x07, 0xF0, 0x61, 0xCF, 0x01, + 0xE7, 0x0C, 0x09, 0x80, 0x0C, 0x81, 0x81, 0x30, 0x01, 0x90, 0x30, 0x26, + 0x00, 0x32, 0x06, 0x04, 0xC0, 0x06, 0x40, 0xC0, 0x98, 0xF8, 0xC8, 0x18, + 0x13, 0xFF, 0xF9, 0x03, 0x02, 0x70, 0x07, 0x20, 0xD0, 0x4C, 0x00, 0x64, + 0x12, 0x09, 0x80, 0x0C, 0x82, 0x61, 0x3F, 0xFF, 0x90, 0xC4, 0x27, 0xFF, + 0xF2, 0x10, 0xC4, 0xFF, 0xFE, 0x46, 0x08, 0x9F, 0xDF, 0xC8, 0x80, 0x93, + 0x00, 0x19, 0x30, 0x1A, 0x60, 0x03, 0x2C, 0x01, 0xCC, 0x00, 0x67, 0x80, + 0xC1, 0x80, 0x1C, 0x18, 0x10, 0x18, 0x03, 0x01, 0x03, 0x03, 0xC1, 0xE0, + 0x60, 0x20, 0x18, 0x30, 0x08, 0x06, 0x03, 0xFF, 0x02, 0x00, 0x3F, 0x80, + 0x3F, 0x80, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x00, + 0x24, 0x80, 0x00, 0x00, 0x1F, 0xE0, 0x00, 0x00, 0x06, 0xC8, 0x00, 0x00, + 0x01, 0x93, 0x00, 0x00, 0x00, 0x55, 0xC0, 0x00, 0x00, 0x1F, 0xF0, 0x00, + 0x00, 0x04, 0x44, 0x00, 0x00, 0x01, 0x11, 0x00, 0x00, 0x00, 0xC4, 0x40, + 0x00, 0x00, 0x31, 0x10, 0x00, 0x00, 0x0C, 0x46, 0x00, 0x00, 0x02, 0x11, + 0x80, 0x00, 0x00, 0x84, 0x20, 0x00, 0x00, 0x61, 0x08, 0x00, 0x00, 0x18, + 0x43, 0x00, 0x00, 0x04, 0x10, 0xC0, 0x00, 0x01, 0x04, 0x10, 0x00, 0x00, + 0x41, 0x04, 0x00, 0x00, 0x30, 0x41, 0x00, 0x00, 0x3C, 0x10, 0x78, 0x00, + 0x7E, 0x04, 0x0F, 0x80, 0x7A, 0x01, 0x01, 0xBC, 0x70, 0xC0, 0x40, 0x43, + 0xD0, 0x10, 0x10, 0x30, 0x10, 0x06, 0x0C, 0x0C, 0x00, 0x00, 0xC7, 0xC6, + 0x00, 0x00, 0x13, 0x1B, 0x00, 0x00, 0x07, 0x83, 0x80, 0x00, 0x00, 0xF1, + 0xE0, 0x00, 0x00, 0x1C, 0x70, 0x00, 0x00, 0x03, 0x18, 0x00, 0x00, 0x03, + 0x83, 0x80, 0x00, 0x01, 0x80, 0x30, 0x00, 0x00, 0x40, 0x04, 0x00, 0x00, + 0x07, 0xFC, 0x00, 0x00, 0x07, 0x80, 0xF0, 0x00, 0x01, 0x80, 0x03, 0x80, + 0x00, 0xC0, 0x00, 0x18, 0x00, 0x20, 0x00, 0x00, 0x80, 0x08, 0x00, 0x00, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x80, 0x83, 0x00, 0x00, 0x08, 0x10, 0xE0, + 0x00, 0x01, 0x84, 0x30, 0x00, 0x00, 0x10, 0x8C, 0x00, 0x00, 0x03, 0x21, + 0x00, 0x00, 0x10, 0x24, 0x03, 0x00, 0x0E, 0x04, 0x80, 0xF0, 0x03, 0x00, + 0xE0, 0x1E, 0x01, 0xC0, 0x0C, 0x03, 0xC0, 0x3E, 0x01, 0x80, 0x30, 0x00, + 0xF0, 0x30, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, + 0x00, 0x1E, 0x18, 0x00, 0x07, 0x07, 0xE1, 0x00, 0x00, 0x30, 0xFF, 0x90, + 0x00, 0x02, 0x1F, 0xFA, 0x00, 0x00, 0x43, 0xFF, 0x40, 0x00, 0x30, 0x7F, + 0xE4, 0x00, 0x01, 0x0F, 0xFC, 0x80, 0x00, 0x20, 0xFF, 0x88, 0x00, 0x0C, + 0x3F, 0xE1, 0x80, 0x06, 0x07, 0xF0, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0xE0, 0x01, 0x80, 0x00, 0x30, 0x00, + 0x0C, 0x00, 0x1C, 0x00, 0x00, 0x70, 0x1E, 0x00, 0x00, 0x01, 0xFE, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0xC0, 0x30, 0x00, 0xC0, 0xC0, + 0x00, 0x30, 0x30, 0x40, 0x30, 0x04, 0x04, 0x30, 0x04, 0x01, 0x00, 0x08, + 0x00, 0x00, 0x24, 0x02, 0x3C, 0x00, 0x09, 0x80, 0x99, 0x80, 0x02, 0x00, + 0x2C, 0x20, 0x00, 0x80, 0x06, 0x08, 0xF0, 0x20, 0x01, 0x86, 0x47, 0x18, + 0x1C, 0x3F, 0x10, 0x7C, 0x01, 0x0C, 0x04, 0x00, 0x00, 0x0F, 0x80, 0x80, + 0x00, 0x0C, 0x78, 0x21, 0x80, 0x02, 0x13, 0x08, 0x20, 0x01, 0x04, 0x7E, + 0x00, 0x00, 0x41, 0x1E, 0x00, 0x00, 0x30, 0x8D, 0x00, 0x0C, 0x0C, 0x66, + 0x23, 0x04, 0x07, 0x11, 0x08, 0x42, 0x01, 0x40, 0x41, 0x01, 0x80, 0x48, + 0x00, 0x40, 0x60, 0x32, 0x00, 0x7C, 0x10, 0x0C, 0x43, 0xE7, 0x8C, 0x07, + 0x88, 0x01, 0x3F, 0x01, 0x21, 0x00, 0x47, 0x80, 0xCC, 0x30, 0x20, 0x00, + 0x31, 0x83, 0xF0, 0x00, 0xCC, 0x38, 0xF0, 0x00, 0x05, 0x83, 0xF0, 0x04, + 0x01, 0x30, 0xE0, 0x01, 0x80, 0xC7, 0xE0, 0x00, 0x60, 0xA1, 0xE0, 0x00, + 0x00, 0x69, 0xC0, 0x00, 0x00, 0x0F, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x07, + 0xFC, 0x00, 0x00, 0x07, 0x00, 0x70, 0x00, 0x03, 0x80, 0x03, 0x80, 0x00, + 0xC0, 0x00, 0x18, 0x00, 0x20, 0x00, 0x00, 0x80, 0x08, 0x00, 0x00, 0x08, + 0x02, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x08, 0x10, 0x00, 0x00, + 0x01, 0x04, 0x00, 0x00, 0x00, 0x11, 0x80, 0x00, 0x00, 0x02, 0x20, 0x00, + 0x00, 0x00, 0x24, 0x01, 0x80, 0x18, 0x05, 0x80, 0x78, 0x07, 0x80, 0xA0, + 0x0F, 0x00, 0xF0, 0x0C, 0x01, 0xE0, 0x1E, 0x01, 0x80, 0x18, 0x01, 0x80, + 0x30, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, + 0x00, 0x18, 0x00, 0x00, 0x00, 0x03, 0x07, 0xC0, 0x01, 0xF0, 0x70, 0x87, + 0xFF, 0xC2, 0x12, 0x1C, 0x00, 0x01, 0xC2, 0x41, 0xFF, 0xFF, 0xF0, 0x4C, + 0x3F, 0xFF, 0xFE, 0x10, 0x83, 0xFF, 0xFF, 0x82, 0x08, 0x1F, 0x03, 0xC0, + 0x81, 0x01, 0xC0, 0x30, 0x10, 0x10, 0x07, 0xF8, 0x04, 0x01, 0x00, 0x00, + 0x01, 0x00, 0x10, 0x00, 0x00, 0x40, 0x01, 0x80, 0x00, 0x30, 0x00, 0x1C, + 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x0E, 0x00, 0x00, 0x03, 0xFE, 0x00, 0x00, + 0x00, 0x07, 0xFC, 0x00, 0x00, 0x07, 0x80, 0xE0, 0x00, 0x01, 0x80, 0x03, + 0x00, 0x00, 0xC0, 0x00, 0x18, 0x00, 0x20, 0x00, 0x01, 0x80, 0x08, 0x00, + 0x00, 0x08, 0x02, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x18, 0x10, + 0x10, 0x01, 0x81, 0x04, 0x1C, 0x00, 0x1E, 0x10, 0x8E, 0x00, 0x00, 0xF2, + 0x20, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x04, 0x80, 0xF0, 0x07, + 0xC0, 0xA0, 0x67, 0x81, 0x9C, 0x0C, 0x08, 0x70, 0x61, 0xC1, 0x83, 0x0F, + 0x18, 0x3C, 0x30, 0x63, 0xE3, 0x0F, 0x86, 0x0C, 0xFC, 0x73, 0xF0, 0xC1, + 0xFB, 0x8F, 0xEE, 0x18, 0x1F, 0xE0, 0xFF, 0x83, 0x03, 0xFC, 0x0F, 0xE0, + 0x50, 0x1E, 0x00, 0xF8, 0x12, 0x00, 0x00, 0x00, 0x02, 0x40, 0x00, 0x00, + 0x00, 0x44, 0x00, 0x00, 0x00, 0x10, 0x80, 0x00, 0x00, 0x02, 0x08, 0x00, + 0x00, 0x00, 0x81, 0x00, 0x3F, 0x80, 0x30, 0x10, 0x04, 0x10, 0x04, 0x01, + 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x00, 0x40, 0x01, 0x80, 0x00, 0x30, + 0x00, 0x0C, 0x00, 0x18, 0x00, 0x00, 0xF0, 0x1E, 0x00, 0x00, 0x03, 0xFE, + 0x00, 0x00, 0x00, 0x03, 0xFC, 0x00, 0x00, 0x00, 0x70, 0x0E, 0x02, 0x00, + 0x06, 0x00, 0x0E, 0x0C, 0x00, 0x60, 0x00, 0x0E, 0x58, 0x02, 0x00, 0x00, + 0x0F, 0x20, 0x10, 0x00, 0x00, 0x18, 0x40, 0x80, 0x00, 0x00, 0x41, 0x86, + 0x00, 0x00, 0x01, 0x02, 0x10, 0x00, 0x00, 0x08, 0x04, 0x80, 0x00, 0x00, + 0x20, 0x12, 0x00, 0x00, 0x00, 0x80, 0x50, 0x00, 0x00, 0x02, 0x01, 0x40, + 0x00, 0x00, 0x0C, 0x0D, 0x01, 0xF0, 0x1F, 0x18, 0x68, 0x0C, 0x60, 0xC6, + 0x3E, 0x20, 0x00, 0x82, 0x08, 0x08, 0x80, 0x00, 0x00, 0x00, 0x22, 0x00, + 0x00, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00, 0x02, 0x20, 0x00, 0x00, 0x00, + 0x08, 0x80, 0x00, 0x00, 0x00, 0x22, 0x0F, 0xC0, 0x03, 0xE0, 0x84, 0x21, + 0xFF, 0xF0, 0x84, 0x10, 0xE0, 0x00, 0x0E, 0x10, 0x41, 0xFF, 0xFF, 0xF8, + 0x40, 0x87, 0xFF, 0xFF, 0xC2, 0x02, 0x0F, 0xFF, 0xFE, 0x08, 0x04, 0x0F, + 0x83, 0xF0, 0x40, 0x10, 0x1C, 0x07, 0x03, 0x00, 0x20, 0x0F, 0xE0, 0x08, + 0x00, 0x40, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x06, 0x00, 0x01, 0x80, + 0x00, 0x30, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, 0x03, 0xC0, 0x70, 0x00, + 0x00, 0x01, 0xFF, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x01, 0xF8, 0x00, + 0x00, 0x0F, 0xC0, 0x00, 0x00, 0xFC, 0x00, 0x00, 0x0F, 0xE0, 0x00, 0x00, + 0xFF, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x0F, 0xF0, + 0x00, 0x01, 0xFF, 0x80, 0x00, 0x3F, 0xF8, 0x00, 0x87, 0xFF, 0x80, 0x18, + 0x7F, 0x78, 0x03, 0x8F, 0xCF, 0x00, 0x38, 0xF8, 0xF0, 0x07, 0x9F, 0x0F, + 0x00, 0x79, 0xE0, 0xF0, 0x0F, 0xDE, 0x0F, 0x04, 0xFF, 0xC0, 0xF9, 0xCF, + 0xFC, 0x0F, 0xFE, 0xFF, 0xC0, 0x7F, 0xEF, 0xFC, 0x07, 0xFF, 0xFF, 0xC0, + 0x3F, 0xFF, 0xF6, 0x01, 0xFF, 0xFF, 0x60, 0x0F, 0xFF, 0xE0, 0x00, 0xFF, + 0x7E, 0x00, 0x07, 0xE7, 0xE0, 0x00, 0x7E, 0x7E, 0x00, 0x07, 0xE3, 0xE0, + 0x00, 0x7C, 0x1E, 0x00, 0x0F, 0xC1, 0xF0, 0x00, 0xF8, 0x0F, 0x80, 0x1F, + 0x00, 0x3E, 0x07, 0xC0, 0x01, 0xFF, 0xF8, 0x00, 0x03, 0xFC, 0x00, 0x00, + 0x0F, 0xFC, 0x00, 0x00, 0x01, 0xC0, 0x38, 0x00, 0x00, 0x38, 0x00, 0x60, + 0x00, 0x03, 0x00, 0x00, 0x80, 0x00, 0x10, 0x00, 0x02, 0x00, 0x01, 0x00, + 0x00, 0x18, 0x00, 0x18, 0x1F, 0x80, 0x60, 0x01, 0x81, 0x86, 0x01, 0x00, + 0x08, 0x08, 0x10, 0x0C, 0x00, 0xC0, 0xC0, 0xF8, 0x30, 0x04, 0x16, 0x03, + 0x60, 0x80, 0x60, 0xF0, 0x13, 0x06, 0x02, 0x01, 0x80, 0x10, 0x18, 0x30, + 0x04, 0x00, 0x80, 0x61, 0x00, 0x30, 0x0C, 0x01, 0x18, 0x00, 0xC0, 0x20, + 0x0C, 0xC0, 0x03, 0x01, 0x00, 0x34, 0x01, 0xF8, 0x04, 0x00, 0xA0, 0x00, + 0x60, 0x30, 0x05, 0x00, 0x01, 0x00, 0xC0, 0x38, 0x00, 0x0C, 0x01, 0x80, + 0xC0, 0x00, 0x20, 0x04, 0x06, 0x00, 0x01, 0x80, 0x00, 0x38, 0x00, 0x06, + 0x00, 0x01, 0xC0, 0x00, 0x18, 0x00, 0x1B, 0x00, 0x00, 0x60, 0x00, 0xCC, + 0x00, 0x01, 0x80, 0x0C, 0x38, 0x00, 0x06, 0x00, 0x40, 0x7C, 0x00, 0x7E, + 0x0E, 0x00, 0xFF, 0xFF, 0x3F, 0xC0, 0x00, 0x00, 0x7F, 0xC0, 0x00, 0x00, + 0x00, 0x70, 0x1C, 0x00, 0x00, 0x00, 0x30, 0x01, 0x80, 0x00, 0x00, 0x10, + 0x00, 0x30, 0x00, 0x00, 0x08, 0x00, 0x06, 0x00, 0x00, 0x06, 0x00, 0x00, + 0xC0, 0x00, 0x03, 0x00, 0x00, 0x18, 0x01, 0xC0, 0x80, 0x00, 0x02, 0x0F, + 0xC8, 0x40, 0x00, 0x00, 0xC6, 0x71, 0x30, 0x70, 0x18, 0x19, 0x1C, 0x78, + 0x1C, 0x0E, 0x03, 0x85, 0x03, 0x03, 0x01, 0x81, 0x83, 0x60, 0x40, 0x00, + 0x00, 0xC0, 0x8C, 0x18, 0x00, 0x00, 0x20, 0x61, 0x83, 0x00, 0xE0, 0x18, + 0x30, 0x20, 0x40, 0x38, 0x04, 0x18, 0x0C, 0x18, 0x00, 0x03, 0x04, 0x03, + 0x82, 0x00, 0x00, 0x83, 0x80, 0xA0, 0x80, 0x00, 0x60, 0xA0, 0x6C, 0x30, + 0x00, 0x18, 0x68, 0x13, 0x0C, 0x00, 0x04, 0x13, 0x04, 0x41, 0x00, 0x01, + 0x04, 0x41, 0x10, 0x40, 0x00, 0x41, 0x10, 0x40, 0x10, 0x00, 0x10, 0x04, + 0x10, 0x04, 0x00, 0x04, 0x01, 0x04, 0x01, 0x00, 0x01, 0x00, 0x41, 0x80, + 0xC0, 0x00, 0x40, 0x30, 0x20, 0x3F, 0xFF, 0xF8, 0x08, 0x0E, 0x18, 0xFF, + 0xC3, 0x0C, 0x00, 0xFC, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x07, 0xFC, 0x00, + 0x00, 0x07, 0x80, 0xE0, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, 0xC0, 0x00, + 0x18, 0x00, 0x20, 0x00, 0x01, 0x80, 0x08, 0x00, 0x00, 0x08, 0x02, 0x00, + 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x18, 0x10, 0x00, 0x00, 0x01, 0x04, + 0x00, 0x00, 0x00, 0x10, 0x80, 0x00, 0x00, 0x02, 0x20, 0x7E, 0x01, 0xF8, + 0x24, 0x1F, 0xE0, 0x7F, 0x84, 0x82, 0xF4, 0x0B, 0xD0, 0xA0, 0x9E, 0x42, + 0x79, 0x0C, 0x10, 0x88, 0x42, 0x21, 0x82, 0x01, 0x08, 0x04, 0x30, 0x40, + 0x21, 0x00, 0x86, 0x04, 0x08, 0x10, 0x20, 0xC0, 0xC3, 0x03, 0x0C, 0x18, + 0x07, 0x80, 0x1E, 0x03, 0x00, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, + 0x12, 0x00, 0x00, 0x00, 0x02, 0x40, 0x00, 0x00, 0x00, 0x44, 0x00, 0x00, + 0x00, 0x10, 0x80, 0x00, 0x00, 0x02, 0x08, 0x00, 0x00, 0x00, 0x81, 0x00, + 0x7F, 0xE0, 0x30, 0x10, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, 0x01, 0x00, + 0x10, 0x00, 0x00, 0x40, 0x01, 0x80, 0x00, 0x30, 0x00, 0x0C, 0x00, 0x18, + 0x00, 0x00, 0xF0, 0x1E, 0x00, 0x00, 0x03, 0xFE, 0x00, 0x00, 0x00, 0x00, + 0x7F, 0x80, 0x00, 0x00, 0x00, 0xE0, 0x1C, 0x00, 0x00, 0x00, 0xC0, 0x00, + 0xC0, 0x00, 0x00, 0xC0, 0x00, 0x1C, 0x00, 0x00, 0x60, 0x00, 0x01, 0x80, + 0x00, 0x30, 0x00, 0x00, 0x30, 0x00, 0x18, 0x00, 0x00, 0x06, 0x00, 0x0C, + 0x00, 0x00, 0x00, 0xC0, 0x03, 0x00, 0x00, 0xF0, 0x30, 0x01, 0x80, 0x00, + 0x66, 0x06, 0x00, 0x60, 0x78, 0x10, 0x81, 0x80, 0x30, 0x13, 0x04, 0x00, + 0x30, 0x0C, 0x08, 0x00, 0x00, 0x0C, 0x07, 0x02, 0x00, 0x00, 0x43, 0x01, + 0x80, 0x00, 0x00, 0x10, 0x60, 0x60, 0x00, 0x00, 0x08, 0x18, 0x18, 0x00, + 0x00, 0x04, 0x06, 0x06, 0x00, 0x00, 0x06, 0x01, 0x81, 0x81, 0xC0, 0x07, + 0x00, 0x60, 0x60, 0x3E, 0x0F, 0x00, 0x18, 0x18, 0x01, 0xFF, 0x00, 0x06, + 0x07, 0xE0, 0x00, 0x00, 0x0F, 0x87, 0xCD, 0xC0, 0x00, 0x66, 0x79, 0x31, + 0x50, 0x00, 0x27, 0x12, 0x46, 0x32, 0x00, 0x19, 0x88, 0x98, 0x8C, 0x80, + 0x04, 0x46, 0x7B, 0x11, 0x20, 0x01, 0x11, 0x36, 0x22, 0x08, 0x00, 0x40, + 0x99, 0xC4, 0x01, 0x00, 0x10, 0x0C, 0xF8, 0x80, 0x40, 0x08, 0x06, 0x79, + 0x80, 0x10, 0x02, 0x00, 0x26, 0x30, 0x04, 0x00, 0x80, 0x11, 0x40, 0x01, + 0x00, 0x20, 0x00, 0xD8, 0x00, 0xC0, 0x0C, 0x00, 0x61, 0x80, 0x38, 0x07, + 0x00, 0x60, 0x38, 0x37, 0xFF, 0xB0, 0x70, 0x01, 0xF8, 0x00, 0x07, 0xE0, + 0x00, 0x00, 0x07, 0xFC, 0x00, 0x00, 0x07, 0x80, 0xE0, 0x00, 0x01, 0x80, + 0x03, 0x00, 0x00, 0xC0, 0x00, 0x18, 0x00, 0x20, 0x00, 0x01, 0x80, 0x08, + 0x00, 0x00, 0x08, 0x02, 0x00, 0x00, 0x00, 0x80, 0x81, 0xC0, 0x00, 0x18, + 0x10, 0x70, 0x00, 0x01, 0x04, 0x18, 0x00, 0x00, 0x10, 0x86, 0x00, 0x00, + 0x02, 0x20, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x02, 0x04, 0x80, 0x30, + 0x01, 0xC0, 0xA0, 0x0F, 0x00, 0x60, 0x0C, 0x01, 0xE0, 0x18, 0x01, 0x80, + 0x3C, 0x07, 0xC0, 0x30, 0x03, 0x00, 0x1E, 0x06, 0x00, 0x00, 0x00, 0x00, + 0xC0, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x50, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x04, 0x02, 0x40, 0x38, + 0x01, 0x80, 0x44, 0x01, 0xC0, 0xE0, 0x10, 0x80, 0x07, 0xE0, 0x02, 0x08, + 0x00, 0x00, 0x00, 0x81, 0x00, 0x00, 0x00, 0x30, 0x10, 0x00, 0x00, 0x04, + 0x01, 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x00, 0x40, 0x01, 0x80, 0x00, + 0x30, 0x00, 0x0C, 0x00, 0x18, 0x00, 0x00, 0xF0, 0x1E, 0x00, 0x00, 0x03, + 0xFE, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0x00, 0x03, 0xB8, 0x1C, 0x00, + 0x00, 0x60, 0xC0, 0x30, 0x00, 0x18, 0x00, 0x00, 0x80, 0x03, 0x00, 0x07, + 0xE4, 0x00, 0x61, 0xC0, 0x03, 0x20, 0x0C, 0x1E, 0x00, 0x01, 0x00, 0x81, + 0xE0, 0x00, 0x08, 0x10, 0x1E, 0x01, 0x80, 0xC3, 0x00, 0xC0, 0x3C, 0x04, + 0x20, 0x00, 0x03, 0xC0, 0x26, 0x00, 0x00, 0x3C, 0x02, 0x60, 0x00, 0x01, + 0x80, 0x24, 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0x00, 0x00, 0x14, 0x00, + 0x7F, 0x00, 0x01, 0x40, 0x1C, 0x1C, 0x00, 0x14, 0x00, 0x00, 0x60, 0x01, + 0x40, 0x00, 0x02, 0x00, 0x14, 0x00, 0x00, 0x00, 0x01, 0xF8, 0x00, 0x00, + 0x00, 0x18, 0x80, 0xFC, 0x00, 0x03, 0x8C, 0xF0, 0x20, 0x00, 0x28, 0x78, + 0x06, 0x00, 0x02, 0x40, 0x07, 0x80, 0x00, 0x64, 0x01, 0xC0, 0x00, 0x04, + 0x40, 0x3C, 0x00, 0x00, 0xC4, 0x00, 0x40, 0x00, 0x18, 0x40, 0x04, 0x00, + 0x01, 0x04, 0x01, 0xC0, 0x00, 0x20, 0x40, 0x04, 0x00, 0x0C, 0x04, 0x00, + 0xC0, 0x01, 0x80, 0x60, 0x04, 0x00, 0x70, 0x03, 0x00, 0x60, 0x3C, 0x00, + 0x18, 0x1F, 0xFF, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x00, 0x1D, 0xFC, + 0x00, 0x00, 0x0C, 0xC4, 0x80, 0x00, 0x06, 0x19, 0x9C, 0x00, 0x07, 0x06, + 0x66, 0xE0, 0x01, 0x03, 0x33, 0x43, 0x00, 0xC0, 0x8C, 0xD8, 0x30, 0x10, + 0x06, 0x66, 0x01, 0x04, 0x00, 0x31, 0x00, 0x10, 0x80, 0x00, 0xC0, 0x01, + 0x10, 0x00, 0x68, 0x00, 0x32, 0x07, 0xF3, 0x00, 0x02, 0x41, 0x84, 0xC0, + 0x00, 0x64, 0x00, 0x70, 0x00, 0x04, 0xC0, 0x18, 0x03, 0x80, 0x8C, 0x1F, + 0x00, 0x78, 0x1B, 0xFF, 0xE0, 0x0F, 0x01, 0x60, 0x3C, 0x01, 0xE0, 0x2C, + 0x03, 0x00, 0x38, 0x05, 0x80, 0x00, 0x00, 0x00, 0xB0, 0x00, 0x00, 0x00, + 0x16, 0x00, 0x00, 0x00, 0x02, 0xC0, 0x00, 0x00, 0x00, 0x48, 0x00, 0x00, + 0x00, 0x09, 0x00, 0x00, 0x00, 0x02, 0x20, 0x18, 0x00, 0x80, 0x46, 0x03, + 0xFF, 0xF0, 0x08, 0x40, 0x00, 0x00, 0x02, 0x0C, 0x00, 0x00, 0x00, 0x40, + 0x80, 0x00, 0x00, 0x10, 0x08, 0x00, 0x00, 0x06, 0x01, 0x80, 0x00, 0x01, + 0x80, 0x18, 0x00, 0x00, 0x60, 0x00, 0xC0, 0x00, 0x10, 0x00, 0x0C, 0x00, + 0x0C, 0x00, 0x00, 0x70, 0x0E, 0x00, 0x00, 0x01, 0xFE, 0x00, 0x00, 0x01, + 0xC0, 0x00, 0x01, 0xF8, 0x00, 0x00, 0x86, 0x00, 0x00, 0x41, 0xC0, 0x00, + 0x30, 0x30, 0x00, 0x1C, 0x0C, 0x00, 0x1B, 0x83, 0x80, 0x08, 0x60, 0x60, + 0x04, 0x18, 0x18, 0x03, 0x07, 0x04, 0x00, 0xC1, 0x83, 0x00, 0x30, 0x60, + 0x80, 0x1F, 0xF0, 0x60, 0x3C, 0x3E, 0x10, 0x30, 0x03, 0x8C, 0x70, 0x00, + 0x62, 0x60, 0x10, 0x11, 0x20, 0x7E, 0x0C, 0xF0, 0x61, 0x82, 0x6C, 0xE0, + 0x61, 0xB3, 0xC0, 0x10, 0x0B, 0x80, 0x08, 0x07, 0x60, 0x04, 0x03, 0x18, + 0x02, 0x03, 0x86, 0x03, 0x01, 0xC1, 0x01, 0x80, 0xE0, 0x61, 0x80, 0x58, + 0x1F, 0x80, 0x24, 0x00, 0x00, 0x33, 0x00, 0x00, 0x10, 0xC0, 0x00, 0x18, + 0x30, 0x00, 0x18, 0x0C, 0x00, 0x18, 0x03, 0x80, 0x18, 0x00, 0xFF, 0xF8, + 0x00, 0x0F, 0xE0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, + 0x00, 0x3F, 0xFC, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, + 0x87, 0xE1, 0xC0, 0x00, 0x38, 0x30, 0x00, 0x30, 0x70, 0x00, 0x70, 0x70, + 0x00, 0x70, 0x70, 0x00, 0x70, 0xE0, 0x00, 0x60, 0xE0, 0x00, 0xE0, 0xE0, + 0x3F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0x01, 0xC1, 0xC0, + 0x01, 0xC1, 0xC0, 0x01, 0xC1, 0x80, 0x03, 0x83, 0x80, 0x03, 0x83, 0x80, + 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFC, 0x07, 0x07, 0x00, + 0x07, 0x07, 0x00, 0x07, 0x06, 0x00, 0x0E, 0x0E, 0x00, 0x0E, 0x0E, 0x00, + 0x0E, 0x0E, 0x00, 0x0E, 0x0C, 0x00, 0x01, 0x80, 0x00, 0xC0, 0x00, 0x60, + 0x00, 0x30, 0x00, 0xFF, 0x81, 0xFF, 0xF1, 0xFF, 0xF8, 0xF3, 0x0C, 0xF1, + 0x80, 0x70, 0xC0, 0x38, 0x60, 0x1C, 0x30, 0x0F, 0x18, 0x03, 0xFC, 0x00, + 0xFF, 0xC0, 0x1F, 0xF8, 0x01, 0xFF, 0x00, 0xC7, 0x80, 0x61, 0xE0, 0x30, + 0x70, 0x18, 0x38, 0x0C, 0x1E, 0x06, 0x1F, 0xC3, 0x3E, 0xFF, 0xFE, 0x3F, + 0xFE, 0x03, 0xFC, 0x00, 0x30, 0x00, 0x18, 0x00, 0x0C, 0x00, 0x06, 0x00, + 0x03, 0x00, 0x1F, 0x80, 0x06, 0x01, 0xFE, 0x00, 0x70, 0x1E, 0x78, 0x03, + 0x00, 0xE1, 0xC0, 0x30, 0x0E, 0x07, 0x03, 0x80, 0x70, 0x38, 0x18, 0x03, + 0x81, 0xC1, 0xC0, 0x1C, 0x0E, 0x0C, 0x00, 0xE0, 0x70, 0xC0, 0x07, 0x03, + 0x8E, 0x00, 0x1C, 0x38, 0x60, 0x00, 0xF3, 0xC7, 0x00, 0x03, 0xFC, 0x30, + 0xFC, 0x0F, 0xC3, 0x0F, 0xF0, 0x00, 0x38, 0xF3, 0xC0, 0x01, 0x87, 0x0E, + 0x00, 0x1C, 0x70, 0x38, 0x00, 0xC3, 0x81, 0xC0, 0x0C, 0x1C, 0x0E, 0x00, + 0xE0, 0xE0, 0x70, 0x06, 0x07, 0x03, 0x80, 0x70, 0x38, 0x1C, 0x03, 0x00, + 0xE1, 0xC0, 0x30, 0x07, 0x9E, 0x03, 0x80, 0x1F, 0xE0, 0x18, 0x00, 0x7E, + 0x00, 0x01, 0xFC, 0x00, 0x07, 0xFF, 0x00, 0x0F, 0xFF, 0x00, 0x1F, 0x03, + 0x00, 0x1E, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x1C, 0x00, + 0x00, 0x0E, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x1F, 0xC0, + 0x00, 0x3D, 0xE0, 0x0E, 0x78, 0xF0, 0x0E, 0x70, 0x78, 0x1E, 0xF0, 0x3C, + 0x1C, 0xE0, 0x1F, 0x1C, 0xE0, 0x0F, 0xB8, 0xE0, 0x07, 0xF8, 0xE0, 0x03, + 0xF0, 0xF0, 0x01, 0xF0, 0x78, 0x03, 0xF0, 0x3E, 0x0F, 0xF8, 0x3F, 0xFF, + 0x7C, 0x0F, 0xFE, 0x3E, 0x03, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, + 0x0E, 0x1E, 0x1C, 0x18, 0x38, 0x38, 0x70, 0x70, 0x70, 0x70, 0xE0, 0xE0, + 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0x60, 0x70, 0x70, 0x70, 0x38, + 0x38, 0x1C, 0x1C, 0x1C, 0x0E, 0x07, 0xE0, 0x70, 0x70, 0x38, 0x38, 0x1C, + 0x1C, 0x0E, 0x0E, 0x0E, 0x0E, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, + 0x07, 0x07, 0x0E, 0x0E, 0x0E, 0x0E, 0x1C, 0x1C, 0x38, 0x38, 0x78, 0x70, + 0xE0, 0x01, 0x80, 0x01, 0x80, 0x01, 0x80, 0x41, 0x82, 0xF1, 0x8F, 0x39, + 0x9C, 0x1F, 0xF8, 0x07, 0xE0, 0x07, 0xE0, 0x1F, 0xF0, 0x39, 0x9C, 0xF1, + 0x8F, 0x41, 0x82, 0x01, 0x80, 0x01, 0x80, 0x01, 0x80, 0x00, 0x38, 0x00, + 0x00, 0x70, 0x00, 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, 0x03, 0x80, 0x00, + 0x07, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x38, 0x00, 0x00, + 0x70, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, + 0x00, 0x00, 0x0E, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x38, 0x00, 0x00, 0x70, + 0x00, 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, 0x03, 0x80, 0x00, 0x07, 0x00, + 0x00, 0x0E, 0x00, 0x00, 0x77, 0x77, 0x6E, 0xEC, 0xFF, 0xFF, 0xFF, 0xE0, + 0xFF, 0xF0, 0x00, 0x70, 0x0F, 0x00, 0xE0, 0x0E, 0x01, 0xE0, 0x1C, 0x01, + 0xC0, 0x1C, 0x03, 0x80, 0x38, 0x03, 0x80, 0x78, 0x07, 0x00, 0x70, 0x0F, + 0x00, 0xE0, 0x0E, 0x00, 0xE0, 0x1C, 0x01, 0xC0, 0x1C, 0x03, 0x80, 0x38, + 0x03, 0x80, 0x78, 0x07, 0x00, 0x70, 0x0F, 0x00, 0xE0, 0x00, 0x03, 0xF0, + 0x03, 0xFF, 0x01, 0xFF, 0xE0, 0xF8, 0x7C, 0x38, 0x07, 0x1E, 0x01, 0xE7, + 0x00, 0x39, 0xC0, 0x0E, 0xE0, 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, + 0x07, 0xE0, 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x01, + 0xF8, 0x00, 0x77, 0x00, 0x39, 0xC0, 0x0E, 0x78, 0x07, 0x8E, 0x01, 0xC3, + 0xE1, 0xF0, 0x7F, 0xF8, 0x0F, 0xFC, 0x00, 0xFC, 0x00, 0x1F, 0x81, 0xFF, + 0x03, 0xFE, 0x07, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, + 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, + 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, + 0x00, 0x70, 0x00, 0xE0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xE0, + 0xFF, 0xF8, 0xFF, 0xFC, 0xF0, 0x3E, 0x80, 0x0E, 0x00, 0x0F, 0x00, 0x07, + 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x0E, 0x00, 0x1E, 0x00, 0x1C, + 0x00, 0x38, 0x00, 0x78, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x07, 0x80, + 0x0F, 0x00, 0x1E, 0x00, 0x3C, 0x00, 0xF8, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x1F, 0xE0, 0x3F, 0xFC, 0x1F, 0xFF, 0x0C, 0x07, 0xC0, 0x00, + 0xF0, 0x00, 0x38, 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x07, 0x00, + 0x07, 0x81, 0xFF, 0x80, 0xFF, 0x00, 0x7F, 0xE0, 0x00, 0x7C, 0x00, 0x0E, + 0x00, 0x07, 0x80, 0x01, 0xC0, 0x00, 0xE0, 0x00, 0x70, 0x00, 0x78, 0x00, + 0x3B, 0x00, 0x7D, 0xFF, 0xFC, 0xFF, 0xFC, 0x1F, 0xF0, 0x00, 0x00, 0x3E, + 0x00, 0x07, 0xC0, 0x01, 0xF8, 0x00, 0x77, 0x00, 0x0E, 0xE0, 0x03, 0x9C, + 0x00, 0xE3, 0x80, 0x1C, 0x70, 0x07, 0x0E, 0x00, 0xE1, 0xC0, 0x38, 0x38, + 0x0E, 0x07, 0x01, 0xC0, 0xE0, 0x70, 0x1C, 0x1C, 0x03, 0x83, 0x80, 0x70, + 0xE0, 0x0E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0xE0, + 0x00, 0x1C, 0x00, 0x03, 0x80, 0x00, 0x70, 0x00, 0x0E, 0x00, 0x01, 0xC0, + 0x7F, 0xFE, 0x3F, 0xFF, 0x1F, 0xFF, 0x8E, 0x00, 0x07, 0x00, 0x03, 0x80, + 0x01, 0xC0, 0x00, 0xE0, 0x00, 0x70, 0x00, 0x3F, 0xF0, 0x1F, 0xFE, 0x0F, + 0xFF, 0xC6, 0x03, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, 0x07, 0x00, 0x03, + 0x80, 0x01, 0xC0, 0x00, 0xE0, 0x00, 0x70, 0x00, 0x78, 0x00, 0x7B, 0x00, + 0xFD, 0xFF, 0xFC, 0xFF, 0xF8, 0x1F, 0xF0, 0x00, 0x01, 0xFC, 0x01, 0xFF, + 0xC0, 0xFF, 0xF0, 0x7C, 0x0C, 0x3C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x01, + 0xC0, 0x00, 0xF0, 0x00, 0x38, 0xFE, 0x0E, 0x7F, 0xE3, 0xBF, 0xFC, 0xFE, + 0x0F, 0xBE, 0x00, 0xEF, 0x80, 0x3F, 0xC0, 0x07, 0xF0, 0x01, 0xFC, 0x00, + 0x77, 0x00, 0x1D, 0xC0, 0x07, 0x78, 0x03, 0xCE, 0x00, 0xE3, 0xE0, 0xF8, + 0x7F, 0xFC, 0x0F, 0xFE, 0x00, 0xFE, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0x00, 0x0E, 0x00, 0x0E, 0x00, 0x1E, 0x00, 0x1C, 0x00, 0x1C, 0x00, + 0x38, 0x00, 0x38, 0x00, 0x38, 0x00, 0x70, 0x00, 0x70, 0x00, 0xF0, 0x00, + 0xE0, 0x00, 0xE0, 0x01, 0xE0, 0x01, 0xC0, 0x01, 0xC0, 0x03, 0xC0, 0x03, + 0x80, 0x03, 0x80, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x0E, 0x00, 0x03, + 0xF8, 0x03, 0xFF, 0x03, 0xFF, 0xF0, 0xF0, 0x3C, 0x78, 0x07, 0x9C, 0x00, + 0xE7, 0x00, 0x39, 0xC0, 0x0E, 0x70, 0x03, 0x8E, 0x01, 0xC3, 0xC0, 0xF0, + 0x7F, 0xF8, 0x07, 0xF8, 0x07, 0xFF, 0x87, 0xC0, 0xF9, 0xC0, 0x0E, 0xE0, + 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x01, 0xFC, 0x00, + 0xF7, 0xC0, 0xF8, 0xFF, 0xFC, 0x1F, 0xFE, 0x01, 0xFE, 0x00, 0x07, 0xF0, + 0x07, 0xFF, 0x03, 0xFF, 0xE1, 0xF0, 0x7C, 0x70, 0x07, 0x3C, 0x01, 0xEE, + 0x00, 0x3B, 0x80, 0x0E, 0xE0, 0x03, 0xF8, 0x00, 0xFE, 0x00, 0x3F, 0xC0, + 0x1F, 0x70, 0x07, 0xDF, 0x07, 0xF3, 0xFF, 0xDC, 0x7F, 0xE7, 0x07, 0xF1, + 0xC0, 0x00, 0xF0, 0x00, 0x38, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x03, 0xC3, + 0x03, 0xE0, 0xFF, 0xF0, 0x3F, 0xF8, 0x03, 0xF8, 0x00, 0xFF, 0xF0, 0x00, + 0x00, 0x00, 0x3F, 0xFC, 0x77, 0x77, 0x00, 0x00, 0x00, 0x00, 0x00, 0x77, + 0x77, 0x6E, 0xEC, 0x00, 0x00, 0x04, 0x00, 0x00, 0xF0, 0x00, 0x1F, 0xC0, + 0x03, 0xFE, 0x00, 0x3F, 0xC0, 0x07, 0xFC, 0x00, 0xFF, 0x80, 0x0F, 0xF0, + 0x00, 0xFF, 0x00, 0x03, 0xE0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF8, 0x00, 0x07, 0xFC, 0x00, 0x03, 0xFC, 0x00, 0x03, 0xFE, 0x00, + 0x01, 0xFC, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x40, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, + 0x80, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0xE0, 0x00, 0x1F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x07, 0xFC, 0x00, 0x03, 0xFC, 0x00, 0x03, + 0xFC, 0x00, 0x01, 0xF0, 0x00, 0x3F, 0xC0, 0x03, 0xFC, 0x00, 0x7F, 0xC0, + 0x0F, 0xF8, 0x00, 0xFF, 0x00, 0x1F, 0xF0, 0x00, 0xFE, 0x00, 0x03, 0xC0, + 0x00, 0x08, 0x00, 0x00, 0x00, 0x1F, 0xC1, 0xFF, 0xCF, 0xFF, 0xBC, 0x1E, + 0x80, 0x3C, 0x00, 0x70, 0x01, 0xC0, 0x07, 0x00, 0x78, 0x03, 0xE0, 0x1F, + 0x00, 0xF8, 0x07, 0xC0, 0x3C, 0x00, 0xE0, 0x03, 0x80, 0x0E, 0x00, 0x38, + 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x03, 0x80, 0x0E, + 0x00, 0x38, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0xFF, 0xFC, 0x00, 0x07, + 0xFF, 0xFE, 0x00, 0x1F, 0x80, 0x7E, 0x00, 0x7C, 0x00, 0x3E, 0x01, 0xE0, + 0x00, 0x1E, 0x07, 0x80, 0x00, 0x1E, 0x1E, 0x00, 0x00, 0x1E, 0x38, 0x0F, + 0x8E, 0x1C, 0xF0, 0x7F, 0xDC, 0x39, 0xC1, 0xFF, 0xF8, 0x3F, 0x83, 0xC1, + 0xF0, 0x7E, 0x0F, 0x01, 0xE0, 0xFC, 0x1C, 0x01, 0xC1, 0xF8, 0x38, 0x03, + 0x83, 0xF0, 0x70, 0x07, 0x07, 0xE0, 0xE0, 0x0E, 0x1F, 0xC1, 0xC0, 0x1C, + 0x3B, 0x83, 0xC0, 0x78, 0xF7, 0x83, 0xC1, 0xF3, 0xC7, 0x07, 0xFF, 0xFF, + 0x0E, 0x07, 0xFD, 0xF8, 0x0E, 0x03, 0xE3, 0xC0, 0x1E, 0x00, 0x00, 0x00, + 0x1E, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x02, 0x00, 0x3F, 0x00, 0x0E, 0x00, + 0x1F, 0x80, 0xFC, 0x00, 0x1F, 0xFF, 0xF0, 0x00, 0x0F, 0xFF, 0x80, 0x00, + 0x07, 0xF8, 0x00, 0x00, 0x00, 0x7C, 0x00, 0x00, 0xF8, 0x00, 0x01, 0xF0, + 0x00, 0x07, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x1D, 0xC0, 0x00, 0x71, 0xC0, + 0x00, 0xE3, 0x80, 0x03, 0xC7, 0x80, 0x07, 0x07, 0x00, 0x0E, 0x0E, 0x00, + 0x3C, 0x1E, 0x00, 0x70, 0x1C, 0x00, 0xE0, 0x38, 0x03, 0x80, 0x38, 0x07, + 0x00, 0x70, 0x1E, 0x00, 0xF0, 0x3F, 0xFF, 0xE0, 0x7F, 0xFF, 0xC1, 0xFF, + 0xFF, 0xC3, 0x80, 0x03, 0x87, 0x00, 0x07, 0x1C, 0x00, 0x07, 0x38, 0x00, + 0x0E, 0x70, 0x00, 0x1D, 0xC0, 0x00, 0x1C, 0xFF, 0xF8, 0x3F, 0xFF, 0x8F, + 0xFF, 0xF3, 0x80, 0x3C, 0xE0, 0x07, 0xB8, 0x00, 0xEE, 0x00, 0x3B, 0x80, + 0x0E, 0xE0, 0x03, 0xB8, 0x01, 0xEE, 0x00, 0xF3, 0xFF, 0xF8, 0xFF, 0xFC, + 0x3F, 0xFF, 0xCE, 0x00, 0xFB, 0x80, 0x0E, 0xE0, 0x01, 0xF8, 0x00, 0x7E, + 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x01, 0xF8, 0x00, 0xFE, 0x00, 0xFB, 0xFF, + 0xFC, 0xFF, 0xFE, 0x3F, 0xFE, 0x00, 0x00, 0x7F, 0x80, 0x1F, 0xFF, 0x83, + 0xFF, 0xFE, 0x3F, 0x01, 0xF3, 0xE0, 0x01, 0x9E, 0x00, 0x05, 0xE0, 0x00, + 0x0E, 0x00, 0x00, 0x70, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, + 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, + 0xE0, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x9F, 0x00, 0x0C, 0x7E, 0x03, 0xE1, 0xFF, 0xFF, 0x03, 0xFF, 0xF0, + 0x07, 0xFC, 0x00, 0xFF, 0xF0, 0x07, 0xFF, 0xF0, 0x3F, 0xFF, 0xE1, 0xC0, + 0x1F, 0x8E, 0x00, 0x3E, 0x70, 0x00, 0x73, 0x80, 0x03, 0xDC, 0x00, 0x0E, + 0xE0, 0x00, 0x7F, 0x00, 0x01, 0xF8, 0x00, 0x0F, 0xC0, 0x00, 0x7E, 0x00, + 0x03, 0xF0, 0x00, 0x1F, 0x80, 0x00, 0xFC, 0x00, 0x07, 0xE0, 0x00, 0x3F, + 0x00, 0x03, 0xF8, 0x00, 0x1D, 0xC0, 0x01, 0xEE, 0x00, 0x0E, 0x70, 0x01, + 0xF3, 0x80, 0x3F, 0x1F, 0xFF, 0xF0, 0xFF, 0xFE, 0x07, 0xFF, 0x80, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, + 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xFF, 0xFE, + 0xFF, 0xFE, 0xFF, 0xFE, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, + 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x0E, + 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, + 0x07, 0xFF, 0xEF, 0xFF, 0xDF, 0xFF, 0xB8, 0x00, 0x70, 0x00, 0xE0, 0x01, + 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, + 0x00, 0xE0, 0x01, 0xC0, 0x00, 0x00, 0xFF, 0x80, 0x0F, 0xFF, 0xC0, 0xFF, + 0xFF, 0x87, 0xE0, 0x3E, 0x3E, 0x00, 0x18, 0xE0, 0x00, 0x27, 0x80, 0x00, + 0x1C, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x0E, 0x00, 0x00, 0x38, + 0x00, 0x00, 0xE0, 0x07, 0xFF, 0x80, 0x1F, 0xFE, 0x00, 0x7F, 0xF8, 0x00, + 0x07, 0xE0, 0x00, 0x1D, 0xC0, 0x00, 0x77, 0x00, 0x01, 0xDE, 0x00, 0x07, + 0x38, 0x00, 0x1C, 0xF8, 0x00, 0x71, 0xF8, 0x07, 0xC3, 0xFF, 0xFE, 0x03, + 0xFF, 0xE0, 0x03, 0xFE, 0x00, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, + 0xF0, 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, 0x00, 0x07, + 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1F, + 0x80, 0x03, 0xF0, 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, + 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1C, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, + 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, + 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x0E, 0x1E, 0xFE, + 0xFC, 0xF0, 0xE0, 0x03, 0xCE, 0x00, 0x78, 0xE0, 0x0F, 0x0E, 0x01, 0xE0, + 0xE0, 0x3C, 0x0E, 0x07, 0x80, 0xE0, 0xF0, 0x0E, 0x1E, 0x00, 0xE3, 0xC0, + 0x0E, 0x78, 0x00, 0xEF, 0x00, 0x0F, 0xE0, 0x00, 0xFE, 0x00, 0x0F, 0xF0, + 0x00, 0xEF, 0x80, 0x0E, 0x7C, 0x00, 0xE3, 0xE0, 0x0E, 0x1F, 0x00, 0xE0, + 0xF8, 0x0E, 0x07, 0xC0, 0xE0, 0x3E, 0x0E, 0x01, 0xF0, 0xE0, 0x0F, 0x8E, + 0x00, 0x7C, 0xE0, 0x03, 0xEE, 0x00, 0x1F, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, + 0x00, 0x3F, 0xF8, 0x00, 0xFF, 0xF0, 0x01, 0xFF, 0xE0, 0x03, 0xFF, 0xE0, + 0x0F, 0xFD, 0xC0, 0x1D, 0xFB, 0x80, 0x3B, 0xF3, 0x80, 0xE7, 0xE7, 0x01, + 0xCF, 0xCF, 0x07, 0x9F, 0x8E, 0x0E, 0x3F, 0x1C, 0x1C, 0x7E, 0x1C, 0x70, + 0xFC, 0x38, 0xE1, 0xF8, 0x71, 0xC3, 0xF0, 0x77, 0x07, 0xE0, 0xEE, 0x0F, + 0xC1, 0xFC, 0x1F, 0x81, 0xF0, 0x3F, 0x03, 0xE0, 0x7E, 0x03, 0x80, 0xFC, + 0x00, 0x01, 0xF8, 0x00, 0x03, 0xF0, 0x00, 0x07, 0xE0, 0x00, 0x0F, 0xC0, + 0x00, 0x1C, 0xF8, 0x00, 0xFF, 0x00, 0x1F, 0xF0, 0x03, 0xFE, 0x00, 0x7F, + 0xE0, 0x0F, 0xDC, 0x01, 0xFB, 0xC0, 0x3F, 0x38, 0x07, 0xE7, 0x80, 0xFC, + 0x70, 0x1F, 0x8F, 0x03, 0xF0, 0xE0, 0x7E, 0x0E, 0x0F, 0xC1, 0xC1, 0xF8, + 0x1C, 0x3F, 0x03, 0xC7, 0xE0, 0x38, 0xFC, 0x07, 0x9F, 0x80, 0x73, 0xF0, + 0x0F, 0x7E, 0x00, 0xEF, 0xC0, 0x1F, 0xF8, 0x01, 0xFF, 0x00, 0x3F, 0xE0, + 0x03, 0xFC, 0x00, 0x7C, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, + 0xF0, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x3C, 0x00, 0x3C, 0x78, 0x00, + 0x1E, 0x70, 0x00, 0x0E, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0x70, 0x00, + 0x0E, 0x78, 0x00, 0x1E, 0x3C, 0x00, 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x81, + 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0x00, 0xFF, 0xE0, + 0xFF, 0xF8, 0xFF, 0xFC, 0xE0, 0x3E, 0xE0, 0x0F, 0xE0, 0x07, 0xE0, 0x07, + 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x3E, 0xFF, 0xFC, + 0xFF, 0xF8, 0xFF, 0xE0, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, + 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, + 0xE0, 0x00, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, + 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x3C, 0x00, 0x3C, 0x78, 0x00, 0x1E, 0x70, + 0x00, 0x0E, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, + 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, + 0x00, 0x07, 0xE0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0x70, 0x00, 0x0E, 0x78, + 0x00, 0x1E, 0x3C, 0x00, 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x81, 0xF8, 0x0F, + 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0xC0, 0x00, 0x01, 0xE0, 0x00, + 0x01, 0xE0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x3C, 0xFF, + 0xE0, 0x1F, 0xFF, 0x03, 0xFF, 0xF8, 0x70, 0x1F, 0x0E, 0x00, 0xF1, 0xC0, + 0x0E, 0x38, 0x01, 0xC7, 0x00, 0x38, 0xE0, 0x07, 0x1C, 0x00, 0xE3, 0x80, + 0x3C, 0x70, 0x0F, 0x0F, 0xFF, 0xC1, 0xFF, 0xF0, 0x3F, 0xFE, 0x07, 0x01, + 0xE0, 0xE0, 0x1E, 0x1C, 0x01, 0xC3, 0x80, 0x3C, 0x70, 0x03, 0x8E, 0x00, + 0x79, 0xC0, 0x07, 0x38, 0x00, 0xE7, 0x00, 0x0E, 0xE0, 0x01, 0xDC, 0x00, + 0x1C, 0x07, 0xFC, 0x07, 0xFF, 0xC3, 0xFF, 0xF1, 0xF0, 0x0C, 0xF0, 0x00, + 0x38, 0x00, 0x0E, 0x00, 0x03, 0x80, 0x00, 0xE0, 0x00, 0x3C, 0x00, 0x07, + 0xC0, 0x00, 0xFF, 0x80, 0x1F, 0xFC, 0x00, 0xFF, 0xC0, 0x01, 0xF8, 0x00, + 0x1E, 0x00, 0x03, 0xC0, 0x00, 0x70, 0x00, 0x1C, 0x00, 0x07, 0x00, 0x01, + 0xE0, 0x00, 0xEF, 0x00, 0xFB, 0xFF, 0xFC, 0xFF, 0xFE, 0x0F, 0xFE, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0xE0, 0x00, 0x07, + 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, + 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, + 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, + 0x1C, 0x00, 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, + 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0xE0, 0x00, 0xFC, + 0x00, 0x1F, 0x80, 0x03, 0xF0, 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, + 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xF0, + 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, 0x00, 0x07, 0xE0, + 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xF8, 0x00, 0xF7, 0x00, 0x1C, 0xF0, + 0x07, 0x8F, 0x01, 0xE1, 0xFF, 0xFC, 0x0F, 0xFE, 0x00, 0x7F, 0x00, 0xE0, + 0x00, 0x0E, 0xE0, 0x00, 0x39, 0xC0, 0x00, 0x73, 0x80, 0x01, 0xE3, 0x80, + 0x03, 0x87, 0x00, 0x07, 0x0F, 0x00, 0x1E, 0x0E, 0x00, 0x38, 0x1C, 0x00, + 0x70, 0x3C, 0x01, 0xE0, 0x38, 0x03, 0x80, 0x70, 0x07, 0x00, 0x70, 0x1C, + 0x00, 0xE0, 0x38, 0x01, 0xE0, 0xF0, 0x01, 0xC1, 0xC0, 0x03, 0x83, 0x80, + 0x07, 0x8F, 0x00, 0x07, 0x1C, 0x00, 0x0E, 0x38, 0x00, 0x0E, 0xE0, 0x00, + 0x1D, 0xC0, 0x00, 0x3F, 0x80, 0x00, 0x3E, 0x00, 0x00, 0x7C, 0x00, 0x00, + 0xF8, 0x00, 0xE0, 0x03, 0xC0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0x70, 0x07, + 0xE0, 0x0E, 0x70, 0x07, 0xE0, 0x0E, 0x70, 0x07, 0xE0, 0x0E, 0x70, 0x0F, + 0xF0, 0x0E, 0x38, 0x0E, 0x70, 0x1C, 0x38, 0x0E, 0x70, 0x1C, 0x38, 0x0E, + 0x70, 0x1C, 0x38, 0x1E, 0x78, 0x1C, 0x1C, 0x1C, 0x38, 0x38, 0x1C, 0x1C, + 0x38, 0x38, 0x1C, 0x1C, 0x38, 0x38, 0x1C, 0x1C, 0x38, 0x38, 0x0E, 0x38, + 0x1C, 0x70, 0x0E, 0x38, 0x1C, 0x70, 0x0E, 0x38, 0x1C, 0x70, 0x0E, 0x38, + 0x1C, 0x70, 0x0F, 0x70, 0x0E, 0xE0, 0x07, 0x70, 0x0E, 0xE0, 0x07, 0x70, + 0x0E, 0xE0, 0x07, 0x70, 0x0E, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x03, 0xE0, + 0x07, 0xC0, 0x03, 0xE0, 0x07, 0xC0, 0x03, 0xE0, 0x07, 0xC0, 0x78, 0x00, + 0x78, 0xF0, 0x03, 0xC1, 0xC0, 0x0E, 0x07, 0x80, 0x78, 0x0F, 0x03, 0xC0, + 0x1C, 0x0E, 0x00, 0x78, 0x78, 0x00, 0xF3, 0xC0, 0x01, 0xCE, 0x00, 0x07, + 0xF8, 0x00, 0x0F, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x03, 0xF0, + 0x00, 0x0F, 0xC0, 0x00, 0x7F, 0x80, 0x03, 0xCF, 0x00, 0x0E, 0x1C, 0x00, + 0x78, 0x78, 0x03, 0xC0, 0xF0, 0x0E, 0x01, 0xC0, 0x78, 0x07, 0x83, 0xC0, + 0x0F, 0x0E, 0x00, 0x1C, 0x78, 0x00, 0x7B, 0xC0, 0x00, 0xF0, 0xF0, 0x00, + 0x7B, 0xC0, 0x07, 0x8E, 0x00, 0x38, 0x78, 0x03, 0xC1, 0xE0, 0x3C, 0x07, + 0x01, 0xC0, 0x3C, 0x1E, 0x00, 0xF1, 0xE0, 0x03, 0x8E, 0x00, 0x1E, 0xF0, + 0x00, 0x7F, 0x00, 0x01, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x38, 0x00, 0x01, + 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, + 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, + 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, + 0xBF, 0xFF, 0xFC, 0x00, 0x01, 0xC0, 0x00, 0x1E, 0x00, 0x01, 0xE0, 0x00, + 0x1E, 0x00, 0x01, 0xE0, 0x00, 0x0E, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x07, 0x80, 0x00, 0x78, + 0x00, 0x07, 0x80, 0x00, 0x38, 0x00, 0x03, 0xC0, 0x00, 0x3C, 0x00, 0x03, + 0xC0, 0x00, 0x3C, 0x00, 0x01, 0xC0, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0x0E, 0x1C, 0x38, 0x70, 0xE1, + 0xC3, 0x87, 0x0E, 0x1C, 0x38, 0x70, 0xE1, 0xC3, 0x87, 0x0E, 0x1C, 0x38, + 0x70, 0xE1, 0xC3, 0x87, 0x0F, 0xFF, 0xFF, 0x80, 0xE0, 0x0F, 0x00, 0x70, + 0x07, 0x00, 0x78, 0x03, 0x80, 0x38, 0x03, 0x80, 0x1C, 0x01, 0xC0, 0x1C, + 0x00, 0xE0, 0x0E, 0x00, 0xE0, 0x0F, 0x00, 0x70, 0x07, 0x00, 0x70, 0x03, + 0x80, 0x38, 0x03, 0x80, 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xE0, 0x0E, 0x00, + 0xE0, 0x0F, 0x00, 0x70, 0xFF, 0xFF, 0xF8, 0x70, 0xE1, 0xC3, 0x87, 0x0E, + 0x1C, 0x38, 0x70, 0xE1, 0xC3, 0x87, 0x0E, 0x1C, 0x38, 0x70, 0xE1, 0xC3, + 0x87, 0x0E, 0x1C, 0x38, 0x7F, 0xFF, 0xFF, 0x80, 0x00, 0x78, 0x00, 0x03, + 0xF0, 0x00, 0x1F, 0xE0, 0x00, 0xF3, 0xC0, 0x07, 0x87, 0x80, 0x3C, 0x0F, + 0x01, 0xE0, 0x1E, 0x0F, 0x00, 0x3C, 0x78, 0x00, 0x7B, 0xC0, 0x00, 0xF0, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xF0, 0x70, 0x38, 0x1C, 0x0E, + 0x07, 0x0F, 0xE0, 0x3F, 0xF8, 0x7F, 0xFC, 0x70, 0x1E, 0x40, 0x0E, 0x00, + 0x07, 0x00, 0x07, 0x0F, 0xFF, 0x3F, 0xFF, 0x7F, 0xFF, 0x78, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x1F, 0xF8, 0x3F, 0x7F, 0xFF, 0x3F, + 0xF7, 0x0F, 0xC7, 0xE0, 0x00, 0x70, 0x00, 0x38, 0x00, 0x1C, 0x00, 0x0E, + 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE3, 0xF0, 0x77, 0xFE, + 0x3F, 0xFF, 0x9F, 0x83, 0xCF, 0x80, 0xF7, 0x80, 0x3B, 0x80, 0x0F, 0xC0, + 0x07, 0xE0, 0x03, 0xF0, 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0x3F, + 0x80, 0x3B, 0xE0, 0x3D, 0xF8, 0x3C, 0xFF, 0xFE, 0x77, 0xFE, 0x38, 0xFC, + 0x00, 0x03, 0xF8, 0x1F, 0xFC, 0x7F, 0xF9, 0xF0, 0x37, 0x80, 0x0E, 0x00, + 0x3C, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0F, + 0x00, 0x0E, 0x00, 0x1E, 0x00, 0x1F, 0x03, 0x1F, 0xFE, 0x1F, 0xFC, 0x0F, + 0xE0, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE0, 0x00, 0x70, 0x00, 0x38, + 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x07, 0xE3, 0x8F, 0xFD, 0xCF, 0xFF, + 0xE7, 0x83, 0xF7, 0x80, 0xFB, 0x80, 0x3F, 0x80, 0x0F, 0xC0, 0x07, 0xE0, + 0x03, 0xF0, 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0x3B, 0x80, 0x3D, + 0xE0, 0x3E, 0x78, 0x3F, 0x3F, 0xFF, 0x8F, 0xFD, 0xC1, 0xF8, 0xE0, 0x03, + 0xF8, 0x03, 0xFF, 0x81, 0xFF, 0xF0, 0xF8, 0x3E, 0x78, 0x03, 0x9C, 0x00, + 0xEE, 0x00, 0x1F, 0x80, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0x80, 0x00, 0xE0, 0x00, 0x1C, 0x00, 0x07, 0x80, 0x08, 0xF8, 0x0E, 0x1F, + 0xFF, 0x83, 0xFF, 0xC0, 0x3F, 0xC0, 0x03, 0xF0, 0x7F, 0x0F, 0xF1, 0xE0, + 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, 0xFF, 0xEF, 0xFE, 0xFF, 0xE1, 0xC0, + 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, + 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xC0, 0x1C, 0x00, 0x07, + 0xE3, 0x8F, 0xFD, 0xCF, 0xFF, 0xEF, 0x83, 0xF7, 0x80, 0xFB, 0x80, 0x3F, + 0x80, 0x0F, 0xC0, 0x07, 0xE0, 0x03, 0xF0, 0x01, 0xF8, 0x00, 0xFC, 0x00, + 0x7E, 0x00, 0x3B, 0x80, 0x3D, 0xE0, 0x3E, 0xF8, 0x3F, 0x3F, 0xFF, 0x8F, + 0xFD, 0xC1, 0xF8, 0xE0, 0x00, 0x70, 0x00, 0x70, 0x00, 0x78, 0xC0, 0x7C, + 0x7F, 0xFC, 0x3F, 0xFC, 0x07, 0xF8, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE3, + 0xF0, 0xEF, 0xFC, 0xFF, 0xFE, 0xFC, 0x1E, 0xF0, 0x0F, 0xF0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xFF, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, + 0x0E, 0x1C, 0x38, 0x70, 0x00, 0x00, 0x00, 0x0E, 0x1C, 0x38, 0x70, 0xE1, + 0xC3, 0x87, 0x0E, 0x1C, 0x38, 0x70, 0xE1, 0xC3, 0x87, 0x0E, 0x1C, 0x38, + 0x70, 0xE1, 0xC7, 0xFE, 0xF9, 0xE0, 0xE0, 0x00, 0x70, 0x00, 0x38, 0x00, + 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE0, + 0x0F, 0x70, 0x0F, 0x38, 0x1F, 0x1C, 0x1F, 0x0E, 0x1F, 0x07, 0x1E, 0x03, + 0x9E, 0x01, 0xFE, 0x00, 0xFE, 0x00, 0x7F, 0x00, 0x3B, 0xC0, 0x1C, 0xF0, + 0x0E, 0x3C, 0x07, 0x0F, 0x03, 0x83, 0xC1, 0xC0, 0xF0, 0xE0, 0x3C, 0x70, + 0x0F, 0x38, 0x03, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x80, 0xE3, 0xF0, 0x1F, 0x87, 0x7F, 0xE3, 0xFF, 0x3F, 0xFF, + 0xBF, 0xFD, 0xF8, 0x3D, 0xC1, 0xEF, 0x00, 0xF8, 0x07, 0xF8, 0x03, 0xC0, + 0x1F, 0x80, 0x1C, 0x00, 0xFC, 0x00, 0xE0, 0x07, 0xE0, 0x07, 0x00, 0x3F, + 0x00, 0x38, 0x01, 0xF8, 0x01, 0xC0, 0x0F, 0xC0, 0x0E, 0x00, 0x7E, 0x00, + 0x70, 0x03, 0xF0, 0x03, 0x80, 0x1F, 0x80, 0x1C, 0x00, 0xFC, 0x00, 0xE0, + 0x07, 0xE0, 0x07, 0x00, 0x3F, 0x00, 0x38, 0x01, 0xF8, 0x01, 0xC0, 0x0E, + 0xE3, 0xF0, 0xEF, 0xFC, 0xFF, 0xFE, 0xFC, 0x1E, 0xF0, 0x0F, 0xF0, 0x07, + 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, + 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, + 0xE0, 0x07, 0x03, 0xF0, 0x03, 0xFF, 0x03, 0xFF, 0xF0, 0xF0, 0x3C, 0x78, + 0x07, 0x9C, 0x00, 0xEE, 0x00, 0x3F, 0x80, 0x07, 0xE0, 0x01, 0xF8, 0x00, + 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xF0, 0x03, 0xDC, 0x00, 0xE7, 0x80, 0x78, + 0xF0, 0x3C, 0x3F, 0xFF, 0x03, 0xFF, 0x00, 0x3F, 0x00, 0xE3, 0xF0, 0x77, + 0xFE, 0x3F, 0xFF, 0x9F, 0x83, 0xCF, 0x80, 0xF7, 0x80, 0x3B, 0x80, 0x0F, + 0xC0, 0x07, 0xE0, 0x03, 0xF0, 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, + 0x3F, 0x80, 0x3B, 0xE0, 0x3D, 0xF8, 0x3C, 0xFF, 0xFE, 0x77, 0xFE, 0x38, + 0xFC, 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, + 0xE0, 0x00, 0x70, 0x00, 0x00, 0x07, 0xE3, 0x8F, 0xFD, 0xCF, 0xFF, 0xE7, + 0x83, 0xF7, 0x80, 0xFB, 0x80, 0x3F, 0x80, 0x0F, 0xC0, 0x07, 0xE0, 0x03, + 0xF0, 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0x3B, 0x80, 0x3D, 0xE0, + 0x3E, 0x78, 0x3F, 0x3F, 0xFF, 0x8F, 0xFD, 0xC1, 0xF8, 0xE0, 0x00, 0x70, + 0x00, 0x38, 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, + 0xC0, 0xE3, 0xFD, 0xFF, 0xFF, 0xFE, 0x0F, 0x01, 0xE0, 0x38, 0x07, 0x00, + 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, 0xC0, 0x38, 0x07, 0x00, 0xE0, + 0x1C, 0x03, 0x80, 0x00, 0x0F, 0xF0, 0x7F, 0xF9, 0xFF, 0xF7, 0xC0, 0x6E, + 0x00, 0x1C, 0x00, 0x3C, 0x00, 0x3F, 0xC0, 0x3F, 0xF0, 0x1F, 0xF0, 0x03, + 0xF0, 0x00, 0xF0, 0x00, 0xE0, 0x01, 0xE0, 0x03, 0xF8, 0x1F, 0xFF, 0xFC, + 0xFF, 0xF0, 0x3F, 0x80, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x81, 0xFF, + 0xFF, 0xFF, 0xFF, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, + 0x01, 0xC0, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0xC0, 0x3F, 0xC7, 0xF8, + 0x3F, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xF0, 0x0F, 0x78, 0x3F, 0x7F, 0xFF, 0x3F, + 0xF7, 0x0F, 0xC7, 0xE0, 0x00, 0xEE, 0x00, 0x39, 0xC0, 0x07, 0x3C, 0x01, + 0xE3, 0x80, 0x38, 0x70, 0x07, 0x0F, 0x01, 0xE0, 0xE0, 0x38, 0x1C, 0x0F, + 0x01, 0xC1, 0xC0, 0x38, 0x38, 0x07, 0x8F, 0x00, 0x71, 0xC0, 0x0E, 0x38, + 0x00, 0xEE, 0x00, 0x1D, 0xC0, 0x03, 0xF8, 0x00, 0x3E, 0x00, 0x07, 0xC0, + 0x00, 0xE0, 0x1E, 0x01, 0xFC, 0x0F, 0xC0, 0xF7, 0x03, 0xF0, 0x39, 0xC0, + 0xFC, 0x0E, 0x70, 0x3F, 0x03, 0x9E, 0x1C, 0xE1, 0xE3, 0x87, 0x38, 0x70, + 0xE1, 0xCE, 0x1C, 0x38, 0x73, 0x87, 0x07, 0x38, 0x73, 0x81, 0xCE, 0x1C, + 0xE0, 0x73, 0x87, 0x38, 0x1D, 0xE1, 0xEE, 0x03, 0xF0, 0x3F, 0x00, 0xFC, + 0x0F, 0xC0, 0x3F, 0x03, 0xF0, 0x0F, 0xC0, 0xFC, 0x01, 0xE0, 0x1E, 0x00, + 0x78, 0x07, 0x80, 0x78, 0x01, 0xE7, 0x80, 0x78, 0x78, 0x1E, 0x07, 0x03, + 0x80, 0xF0, 0xF0, 0x0F, 0x3C, 0x00, 0xFF, 0x00, 0x0F, 0xC0, 0x00, 0xF0, + 0x00, 0x1E, 0x00, 0x07, 0xE0, 0x01, 0xFE, 0x00, 0x79, 0xC0, 0x1E, 0x3C, + 0x03, 0x83, 0xC0, 0xF0, 0x38, 0x3C, 0x07, 0x8F, 0x00, 0x7B, 0xC0, 0x07, + 0x80, 0xE0, 0x00, 0xEE, 0x00, 0x39, 0xC0, 0x07, 0x1C, 0x01, 0xE3, 0x80, + 0x38, 0x78, 0x0F, 0x07, 0x01, 0xC0, 0xF0, 0x38, 0x0E, 0x0E, 0x01, 0xC1, + 0xC0, 0x1C, 0x78, 0x03, 0x8E, 0x00, 0x79, 0xC0, 0x07, 0x70, 0x00, 0xFE, + 0x00, 0x0F, 0xC0, 0x01, 0xF0, 0x00, 0x1E, 0x00, 0x03, 0x80, 0x00, 0x70, + 0x00, 0x1C, 0x00, 0x03, 0x80, 0x00, 0xF0, 0x01, 0xFC, 0x00, 0x3F, 0x00, + 0x07, 0xC0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x01, 0xE0, 0x03, + 0xC0, 0x0F, 0x00, 0x3C, 0x00, 0xF0, 0x03, 0xC0, 0x07, 0x80, 0x1E, 0x00, + 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x3C, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x00, 0xF8, 0x1F, 0xC0, 0xFE, 0x0F, 0x00, 0x70, 0x03, + 0x80, 0x1C, 0x00, 0xE0, 0x07, 0x00, 0x38, 0x01, 0xC0, 0x0E, 0x00, 0x70, + 0x07, 0x03, 0xF8, 0x1F, 0x00, 0xFE, 0x00, 0xF0, 0x03, 0xC0, 0x0E, 0x00, + 0x70, 0x03, 0x80, 0x1C, 0x00, 0xE0, 0x07, 0x00, 0x38, 0x01, 0xC0, 0x0E, + 0x00, 0x78, 0x01, 0xFC, 0x0F, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0xF8, 0x07, 0xF0, + 0x3F, 0x80, 0x1E, 0x00, 0x70, 0x03, 0x80, 0x1C, 0x00, 0xE0, 0x07, 0x00, + 0x38, 0x01, 0xC0, 0x0E, 0x00, 0x70, 0x03, 0xC0, 0x0F, 0xE0, 0x1F, 0x03, + 0xF8, 0x1E, 0x01, 0xE0, 0x0E, 0x00, 0x70, 0x03, 0x80, 0x1C, 0x00, 0xE0, + 0x07, 0x00, 0x38, 0x01, 0xC0, 0x0E, 0x00, 0xF0, 0x7F, 0x03, 0xF8, 0x1F, + 0x00, 0x0F, 0xC0, 0x05, 0xFF, 0xE0, 0x7F, 0xFF, 0xFF, 0xFC, 0x1F, 0xFE, + 0xC0, 0x0F, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x3F, 0x80, 0x0F, 0xFE, 0x01, + 0xFF, 0xF0, 0x3E, 0x0F, 0x07, 0x80, 0x30, 0xF0, 0x01, 0x0E, 0x00, 0x00, + 0xE0, 0x00, 0x1C, 0x00, 0x07, 0xFF, 0xF0, 0xFF, 0xFE, 0x01, 0xC0, 0x00, + 0x1C, 0x00, 0x01, 0xC0, 0x00, 0x1C, 0x00, 0x07, 0xFF, 0xC0, 0xFF, 0xF8, + 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0xE0, 0x00, 0x0F, 0x00, 0x10, 0x78, + 0x03, 0x03, 0xE0, 0xF0, 0x1F, 0xFF, 0x00, 0xFF, 0xE0, 0x03, 0xF8, 0x77, + 0x77, 0x6E, 0xEC, 0x00, 0x7E, 0x01, 0xFC, 0x07, 0xF8, 0x1E, 0x00, 0x38, + 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x1F, 0xFC, 0x3F, 0xF8, 0x7F, 0xF0, + 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, + 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, + 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x01, + 0xE0, 0x7F, 0x80, 0xFE, 0x01, 0xF8, 0x00, 0x71, 0xDC, 0x77, 0x1D, 0xC7, + 0x61, 0xB8, 0xEE, 0x3B, 0x0C, 0xE0, 0x0E, 0x00, 0xFC, 0x01, 0xC0, 0x1F, + 0x80, 0x38, 0x03, 0xF0, 0x07, 0x00, 0x70, 0x03, 0x80, 0x07, 0x00, 0x0E, + 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x7F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFC, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, + 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, + 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, + 0x38, 0x00, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, + 0x70, 0x00, 0xE0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x0E, 0x00, 0x1C, + 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, + 0x0E, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xE0, 0x01, 0xC0, 0x03, + 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x1F, 0x80, 0x06, + 0x00, 0x00, 0x07, 0xF8, 0x01, 0xC0, 0x00, 0x01, 0xE7, 0x80, 0x30, 0x00, + 0x00, 0x38, 0x70, 0x0C, 0x00, 0x00, 0x0E, 0x07, 0x03, 0x80, 0x00, 0x01, + 0xC0, 0xE0, 0x60, 0x00, 0x00, 0x38, 0x1C, 0x1C, 0x00, 0x00, 0x07, 0x03, + 0x83, 0x00, 0x00, 0x00, 0xE0, 0x70, 0xC0, 0x00, 0x00, 0x1C, 0x0E, 0x38, + 0x00, 0x00, 0x01, 0xC3, 0x86, 0x00, 0x00, 0x00, 0x3C, 0xF1, 0xC0, 0x00, + 0x00, 0x03, 0xFC, 0x30, 0xFC, 0x03, 0xF0, 0x3F, 0x0C, 0x3F, 0xC0, 0xFF, + 0x00, 0x03, 0x8F, 0x3C, 0x3C, 0xF0, 0x00, 0x61, 0xC3, 0x87, 0x0E, 0x00, + 0x1C, 0x70, 0x39, 0xC0, 0xE0, 0x03, 0x0E, 0x07, 0x38, 0x1C, 0x00, 0xC1, + 0xC0, 0xE7, 0x03, 0x80, 0x38, 0x38, 0x1C, 0xE0, 0x70, 0x06, 0x07, 0x03, + 0x9C, 0x0E, 0x01, 0xC0, 0xE0, 0x73, 0x81, 0xC0, 0x30, 0x0E, 0x1C, 0x38, + 0x70, 0x0C, 0x01, 0xE7, 0x87, 0x9E, 0x03, 0x80, 0x1F, 0xE0, 0x7F, 0x80, + 0x60, 0x01, 0xF8, 0x07, 0xE0, 0x01, 0x03, 0x07, 0x0E, 0x1C, 0x38, 0x70, + 0xE0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x01, 0x37, 0x76, 0xEE, + 0xEE, 0x77, 0x77, 0x6E, 0xEC, 0x30, 0xDC, 0x77, 0x1D, 0x86, 0xE3, 0xB8, + 0xEE, 0x3B, 0x8E, 0x71, 0xDC, 0x77, 0x1D, 0xC7, 0x61, 0xB8, 0xEE, 0x3B, + 0x0C, 0x1E, 0x1F, 0xE7, 0xFB, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD, 0xFE, 0x7F, + 0x87, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x38, 0x1F, 0xFC, + 0xF0, 0xF1, 0x83, 0xE7, 0xC6, 0x0D, 0x9B, 0x18, 0x33, 0xCC, 0x60, 0xCF, + 0x31, 0x83, 0x18, 0xC6, 0x0C, 0x03, 0x18, 0x30, 0x0C, 0x60, 0xC0, 0x30, + 0x80, 0xC0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x07, 0x0E, 0x1C, 0x38, + 0x70, 0xE0, 0xC0, 0x80, 0x00, 0x01, 0xE0, 0x78, 0x0E, 0x03, 0x80, 0xE0, + 0x38, 0x00, 0x07, 0x8F, 0xF1, 0xFE, 0x3F, 0xC7, 0x80, 0x03, 0xC0, 0x00, + 0x0F, 0x00, 0x00, 0x1D, 0xF0, 0x00, 0x73, 0xE0, 0x01, 0xC7, 0xC0, 0x07, + 0x1D, 0xC0, 0x00, 0x3B, 0x80, 0x00, 0x77, 0x00, 0x01, 0xC7, 0x00, 0x03, + 0x8E, 0x00, 0x0F, 0x1E, 0x00, 0x1C, 0x1C, 0x00, 0x38, 0x38, 0x00, 0xF0, + 0x78, 0x01, 0xC0, 0x70, 0x03, 0x80, 0xE0, 0x0E, 0x00, 0xE0, 0x1C, 0x01, + 0xC0, 0x78, 0x03, 0xC0, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x07, 0xFF, 0xFF, + 0x0E, 0x00, 0x0E, 0x1C, 0x00, 0x1C, 0x70, 0x00, 0x1C, 0xE0, 0x00, 0x39, + 0xC0, 0x00, 0x77, 0x00, 0x00, 0x70, 0x00, 0xFE, 0x00, 0xFF, 0xC0, 0xFF, + 0xE0, 0xF0, 0x30, 0x70, 0x00, 0x70, 0x00, 0x38, 0x00, 0x1C, 0x00, 0x0E, + 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE0, 0x03, 0xFF, 0xE1, + 0xFF, 0xF0, 0xFF, 0xF8, 0x0E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, + 0x00, 0xE0, 0x00, 0x70, 0x00, 0x38, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xC0, 0x40, 0x00, 0x5C, 0x00, 0x1D, 0xE7, 0xCF, 0x1F, 0xFF, 0xC1, + 0xFF, 0xF0, 0x3C, 0x1E, 0x07, 0x01, 0xC1, 0xC0, 0x1C, 0x38, 0x03, 0x87, + 0x00, 0x70, 0xE0, 0x0E, 0x1C, 0x01, 0xC1, 0xC0, 0x70, 0x3C, 0x1E, 0x0F, + 0xFF, 0xC1, 0xFF, 0xFC, 0x79, 0xF1, 0xDC, 0x00, 0x1D, 0x00, 0x01, 0x00, + 0xF0, 0x01, 0xEE, 0x00, 0x39, 0xE0, 0x0F, 0x1C, 0x01, 0xC3, 0xC0, 0x78, + 0x38, 0x0E, 0x07, 0x83, 0xC0, 0x70, 0x70, 0x0F, 0x1E, 0x00, 0xE3, 0x80, + 0x1E, 0xF0, 0x3F, 0xDF, 0xE7, 0xFF, 0xFC, 0x03, 0xE0, 0x00, 0x7C, 0x00, + 0x07, 0x00, 0x00, 0xE0, 0x0F, 0xFF, 0xF9, 0xFF, 0xFF, 0x00, 0x70, 0x00, + 0x0E, 0x00, 0x01, 0xC0, 0x00, 0x38, 0x00, 0x07, 0x00, 0x00, 0xE0, 0x00, + 0x1C, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x03, 0xFF, 0xFF, 0xFF, + 0xFF, 0xF8, 0x0F, 0xE0, 0xFF, 0xC3, 0x87, 0x1C, 0x04, 0x70, 0x01, 0xC0, + 0x07, 0x80, 0x0F, 0x00, 0x1F, 0x00, 0xFE, 0x07, 0x3E, 0x38, 0x7C, 0xE0, + 0x7B, 0x80, 0xFE, 0x01, 0xFC, 0x07, 0x7C, 0x1C, 0xF8, 0xE0, 0xFB, 0x81, + 0xF8, 0x01, 0xF0, 0x03, 0xC0, 0x07, 0x80, 0x0E, 0x00, 0x39, 0x00, 0xE7, + 0x07, 0x1F, 0xF8, 0x1F, 0xC0, 0xF1, 0xFE, 0x3F, 0xC7, 0xF8, 0xF0, 0x00, + 0x7F, 0x00, 0x00, 0xFF, 0xE0, 0x01, 0xE0, 0x3C, 0x01, 0xC0, 0x07, 0x01, + 0xC0, 0x01, 0xC1, 0xC3, 0xF8, 0x71, 0xC3, 0xFE, 0x18, 0xC3, 0xC1, 0x06, + 0x63, 0x80, 0x03, 0x63, 0xC0, 0x00, 0xF1, 0xC0, 0x00, 0x78, 0xE0, 0x00, + 0x3C, 0x70, 0x00, 0x1E, 0x38, 0x00, 0x0F, 0x1C, 0x00, 0x07, 0x8F, 0x00, + 0x03, 0x63, 0x80, 0x03, 0x30, 0xF0, 0x41, 0x9C, 0x3F, 0xE1, 0xC7, 0x0F, + 0xE1, 0xC1, 0xC0, 0x01, 0xC0, 0x70, 0x01, 0xC0, 0x1E, 0x03, 0xC0, 0x03, + 0xFF, 0x80, 0x00, 0x7F, 0x00, 0x00, 0x01, 0x02, 0x06, 0x0C, 0x1C, 0x38, + 0x70, 0xE1, 0xC3, 0x87, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xE1, 0xC0, 0xE1, + 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xC1, 0x80, 0x81, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x07, 0x00, + 0x00, 0x1C, 0x00, 0x00, 0x70, 0x00, 0x01, 0xC0, 0x00, 0x07, 0x00, 0x00, + 0x1C, 0x00, 0x00, 0x70, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x7F, 0x00, 0x00, + 0xFF, 0xE0, 0x01, 0xE0, 0x3C, 0x01, 0xC0, 0x07, 0x01, 0xC0, 0x01, 0xC1, + 0xC7, 0xF8, 0x71, 0xC3, 0xFE, 0x18, 0xC1, 0xC7, 0x86, 0x60, 0xE1, 0xC3, + 0x60, 0x70, 0xE0, 0xF0, 0x38, 0xF0, 0x78, 0x1F, 0xF0, 0x3C, 0x0F, 0xE0, + 0x1E, 0x07, 0x38, 0x0F, 0x03, 0x8C, 0x07, 0x81, 0xC7, 0x03, 0x60, 0xE3, + 0x83, 0x30, 0x70, 0xE1, 0x9C, 0x38, 0x71, 0xC7, 0x1C, 0x1D, 0xC1, 0xC0, + 0x01, 0xC0, 0x70, 0x01, 0xC0, 0x1E, 0x03, 0xC0, 0x03, 0xFF, 0x80, 0x00, + 0x7F, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0x07, 0xF1, 0xC7, 0x70, 0x7C, 0x07, + 0x80, 0xF0, 0x1F, 0x07, 0x71, 0xC7, 0xF0, 0x7C, 0x00, 0x00, 0x38, 0x00, + 0x00, 0x70, 0x00, 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, 0x03, 0x80, 0x00, + 0x07, 0x00, 0x00, 0x0E, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFC, 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, 0x03, 0x80, 0x00, 0x07, + 0x00, 0x00, 0x0E, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xC0, 0x7E, 0x3F, 0xEC, 0x1C, 0x03, 0x00, 0xC0, 0x70, 0x18, 0x0C, 0x0E, + 0x07, 0x03, 0x81, 0xC0, 0xFF, 0xFF, 0xF0, 0x1F, 0x8F, 0xF9, 0x83, 0x80, + 0x30, 0x0E, 0x3F, 0x87, 0xF0, 0x07, 0x00, 0x60, 0x0C, 0x01, 0xC0, 0xFF, + 0xFC, 0xFE, 0x00, 0x0F, 0x1E, 0x1C, 0x38, 0x70, 0xE0, 0x01, 0xE0, 0x78, + 0x0E, 0x03, 0x80, 0xE0, 0x38, 0x00, 0x07, 0x8F, 0xF1, 0xFE, 0x3F, 0xC7, + 0x80, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x1D, 0xF0, 0x00, 0x73, 0xE0, + 0x01, 0xC7, 0xC0, 0x07, 0x1D, 0xC0, 0x00, 0x3B, 0x80, 0x00, 0x77, 0x00, + 0x01, 0xC7, 0x00, 0x03, 0x8E, 0x00, 0x0F, 0x1E, 0x00, 0x1C, 0x1C, 0x00, + 0x38, 0x38, 0x00, 0xF0, 0x78, 0x01, 0xC0, 0x70, 0x03, 0x80, 0xE0, 0x0E, + 0x00, 0xE0, 0x1C, 0x01, 0xC0, 0x78, 0x03, 0xC0, 0xFF, 0xFF, 0x81, 0xFF, + 0xFF, 0x07, 0xFF, 0xFF, 0x0E, 0x00, 0x0E, 0x1C, 0x00, 0x1C, 0x70, 0x00, + 0x1C, 0xE0, 0x00, 0x39, 0xC0, 0x00, 0x77, 0x00, 0x00, 0x70, 0xFF, 0xF0, + 0x07, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x77, 0xFF, 0xF9, 0xCF, 0xFF, 0xF7, + 0x1F, 0xFF, 0xEC, 0x38, 0x00, 0x00, 0x70, 0x00, 0x00, 0xE0, 0x00, 0x01, + 0xC0, 0x00, 0x03, 0x80, 0x00, 0x07, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x1C, + 0x00, 0x00, 0x3F, 0xFF, 0x80, 0x7F, 0xFF, 0x00, 0xFF, 0xFE, 0x01, 0xC0, + 0x00, 0x03, 0x80, 0x00, 0x07, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x1C, 0x00, + 0x00, 0x38, 0x00, 0x00, 0x70, 0x00, 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, + 0x03, 0xFF, 0xFC, 0x07, 0xFF, 0xF8, 0x0F, 0xFF, 0xF0, 0x07, 0x00, 0x00, + 0x03, 0x80, 0x00, 0x01, 0xDC, 0x00, 0x1C, 0xE7, 0x00, 0x07, 0x31, 0xC0, + 0x01, 0xD8, 0x70, 0x00, 0x70, 0x1C, 0x00, 0x1C, 0x07, 0x00, 0x07, 0x01, + 0xC0, 0x01, 0xC0, 0x70, 0x00, 0x70, 0x1C, 0x00, 0x1C, 0x07, 0x00, 0x07, + 0x01, 0xC0, 0x01, 0xC0, 0x7F, 0xFF, 0xF0, 0x1F, 0xFF, 0xFC, 0x07, 0xFF, + 0xFF, 0x01, 0xC0, 0x01, 0xC0, 0x70, 0x00, 0x70, 0x1C, 0x00, 0x1C, 0x07, + 0x00, 0x07, 0x01, 0xC0, 0x01, 0xC0, 0x70, 0x00, 0x70, 0x1C, 0x00, 0x1C, + 0x07, 0x00, 0x07, 0x01, 0xC0, 0x01, 0xC0, 0x70, 0x00, 0x70, 0x1C, 0x00, + 0x1C, 0x07, 0x00, 0x07, 0x07, 0x03, 0x81, 0xDC, 0xE7, 0x71, 0xD8, 0x70, + 0x1C, 0x07, 0x01, 0xC0, 0x70, 0x1C, 0x07, 0x01, 0xC0, 0x70, 0x1C, 0x07, + 0x01, 0xC0, 0x70, 0x1C, 0x07, 0x01, 0xC0, 0x70, 0x1C, 0x07, 0x01, 0xC0, + 0x70, 0x1C, 0x07, 0x81, 0x01, 0x83, 0x03, 0x87, 0x03, 0x87, 0x03, 0x87, + 0x03, 0x87, 0x03, 0x87, 0x03, 0x87, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE1, + 0xC3, 0x87, 0x0E, 0x1C, 0x38, 0x30, 0x60, 0x40, 0x80, 0x07, 0x00, 0x00, + 0x01, 0xC0, 0x00, 0x00, 0x70, 0x7F, 0x80, 0x1C, 0x3F, 0xFC, 0x03, 0x1F, + 0xFF, 0xC0, 0xC7, 0xE0, 0x7C, 0x01, 0xE0, 0x03, 0xC0, 0x78, 0x00, 0x3C, + 0x0E, 0x00, 0x03, 0x81, 0xC0, 0x00, 0x78, 0x70, 0x00, 0x07, 0x0E, 0x00, + 0x00, 0xE1, 0xC0, 0x00, 0x1E, 0x38, 0x00, 0x01, 0xC7, 0x00, 0x00, 0x38, + 0xE0, 0x00, 0x07, 0x1C, 0x00, 0x00, 0xE3, 0x80, 0x00, 0x3C, 0x70, 0x00, + 0x07, 0x0E, 0x00, 0x00, 0xE0, 0xE0, 0x00, 0x3C, 0x1C, 0x00, 0x07, 0x03, + 0xC0, 0x01, 0xE0, 0x3C, 0x00, 0x78, 0x03, 0xF0, 0x3E, 0x00, 0x3F, 0xFF, + 0x80, 0x03, 0xFF, 0xE0, 0x00, 0x0F, 0xE0, 0x00, 0x3C, 0x00, 0x07, 0x07, + 0xE0, 0x00, 0x30, 0x33, 0x00, 0x03, 0x00, 0x18, 0x00, 0x38, 0x00, 0xC0, + 0x01, 0x80, 0x06, 0x00, 0x1C, 0x00, 0x30, 0x01, 0xC0, 0x01, 0x80, 0x0C, + 0x00, 0x0C, 0x00, 0xE0, 0x00, 0x60, 0x06, 0x00, 0x03, 0x00, 0x60, 0x00, + 0x18, 0x07, 0x1F, 0x8F, 0xFC, 0x31, 0xFF, 0x7F, 0xE3, 0x08, 0x1C, 0x00, + 0x38, 0x00, 0x60, 0x01, 0x80, 0x03, 0x00, 0x18, 0x00, 0x30, 0x01, 0xC0, + 0x03, 0x80, 0x0C, 0x00, 0x38, 0x00, 0xE0, 0x03, 0x80, 0x06, 0x00, 0x38, + 0x00, 0x60, 0x03, 0x80, 0x07, 0x00, 0x38, 0x00, 0x30, 0x03, 0xFF, 0x03, + 0x00, 0x1F, 0xF8, 0x38, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xE0, + 0x00, 0x00, 0x1C, 0xE0, 0x00, 0xF3, 0x8F, 0x00, 0x0E, 0x70, 0x78, 0x01, + 0xE6, 0x03, 0x80, 0x3C, 0x00, 0x3C, 0x03, 0x80, 0x01, 0xE0, 0x78, 0x00, + 0x0E, 0x0F, 0x00, 0x00, 0xF0, 0xE0, 0x00, 0x07, 0x1E, 0x00, 0x00, 0x3B, + 0xC0, 0x00, 0x03, 0xF8, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0E, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, + 0xE0, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x0E, 0x00, + 0x00, 0x00, 0xE0, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, + 0x0E, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x0E, 0x00, 0x0F, 0x00, 0x00, + 0x01, 0xC0, 0x00, 0x00, 0x70, 0x7F, 0x80, 0x1C, 0x3F, 0xFC, 0x07, 0x1F, + 0xFF, 0xE1, 0xC7, 0xE0, 0x7E, 0x01, 0xF0, 0x03, 0xE0, 0x38, 0x00, 0x3C, + 0x0F, 0x00, 0x03, 0xC1, 0xC0, 0x00, 0x38, 0x78, 0x00, 0x07, 0x0E, 0x00, + 0x00, 0x71, 0xC0, 0x00, 0x0E, 0x38, 0x00, 0x01, 0xC7, 0x00, 0x00, 0x38, + 0xE0, 0x00, 0x07, 0x1C, 0x00, 0x00, 0xE3, 0x80, 0x00, 0x3C, 0x38, 0x00, + 0x07, 0x07, 0x00, 0x00, 0xE0, 0xF0, 0x00, 0x38, 0x0E, 0x00, 0x07, 0x00, + 0xE0, 0x01, 0xC0, 0x0E, 0x00, 0x70, 0x00, 0xE0, 0x1C, 0x03, 0xFE, 0x07, + 0xFC, 0x7F, 0xC0, 0xFF, 0x8F, 0xF8, 0x1F, 0xF0, 0x00, 0xE0, 0x38, 0x0E, + 0x03, 0x80, 0x60, 0x18, 0x00, 0x07, 0x8F, 0xF1, 0xFE, 0x3F, 0xC7, 0x80, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, + 0xC0, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, 0xC0, + 0x3C, 0x03, 0x80, 0x7F, 0x07, 0xE0, 0x7C, 0x00, 0x7C, 0x00, 0x00, 0xF8, + 0x00, 0x01, 0xF0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x1D, 0xC0, + 0x00, 0x71, 0xC0, 0x00, 0xE3, 0x80, 0x03, 0xC7, 0x80, 0x07, 0x07, 0x00, + 0x0E, 0x0E, 0x00, 0x3C, 0x1E, 0x00, 0x70, 0x1C, 0x00, 0xE0, 0x38, 0x03, + 0x80, 0x38, 0x07, 0x00, 0x70, 0x1E, 0x00, 0xF0, 0x3F, 0xFF, 0xE0, 0x7F, + 0xFF, 0xC1, 0xFF, 0xFF, 0xC3, 0x80, 0x03, 0x87, 0x00, 0x07, 0x1C, 0x00, + 0x07, 0x38, 0x00, 0x0E, 0x70, 0x00, 0x1D, 0xC0, 0x00, 0x1C, 0xFF, 0xF8, + 0x3F, 0xFF, 0x8F, 0xFF, 0xF3, 0x80, 0x3C, 0xE0, 0x07, 0xB8, 0x00, 0xEE, + 0x00, 0x3B, 0x80, 0x0E, 0xE0, 0x03, 0xB8, 0x01, 0xEE, 0x00, 0xF3, 0xFF, + 0xF8, 0xFF, 0xFC, 0x3F, 0xFF, 0xCE, 0x00, 0xFB, 0x80, 0x0E, 0xE0, 0x01, + 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x01, 0xF8, 0x00, 0xFE, + 0x00, 0xFB, 0xFF, 0xFC, 0xFF, 0xFE, 0x3F, 0xFE, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, + 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, 0x1C, 0x00, 0x38, + 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, 0x0E, 0x00, + 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x00, 0x00, 0x7C, + 0x00, 0x00, 0xF8, 0x00, 0x01, 0xF0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0xE0, + 0x00, 0x1D, 0xC0, 0x00, 0x71, 0xC0, 0x00, 0xE3, 0x80, 0x03, 0xC7, 0x80, + 0x07, 0x07, 0x00, 0x0E, 0x0E, 0x00, 0x3C, 0x1E, 0x00, 0x70, 0x1C, 0x00, + 0xE0, 0x38, 0x03, 0x80, 0x38, 0x07, 0x00, 0x70, 0x1E, 0x00, 0xF0, 0x38, + 0x00, 0xE0, 0x70, 0x01, 0xC1, 0xE0, 0x03, 0xC3, 0x80, 0x03, 0x87, 0x00, + 0x07, 0x1C, 0x00, 0x07, 0x3F, 0xFF, 0xFE, 0x7F, 0xFF, 0xFD, 0xFF, 0xFF, + 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xFF, + 0xFE, 0xFF, 0xFE, 0xFF, 0xFE, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xBF, 0xFF, + 0xFC, 0x00, 0x01, 0xC0, 0x00, 0x1E, 0x00, 0x01, 0xE0, 0x00, 0x1E, 0x00, + 0x01, 0xE0, 0x00, 0x0E, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x07, 0x80, 0x00, 0x78, 0x00, 0x07, + 0x80, 0x00, 0x38, 0x00, 0x03, 0xC0, 0x00, 0x3C, 0x00, 0x03, 0xC0, 0x00, + 0x3C, 0x00, 0x01, 0xC0, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xC0, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xF0, 0x00, 0x7E, + 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, + 0x00, 0x1F, 0x80, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, + 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xF0, + 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, 0x00, 0x07, 0xE0, + 0x00, 0xFC, 0x00, 0x1C, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, + 0xF0, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x3C, 0x00, 0x3C, 0x78, 0x00, + 0x1E, 0x70, 0x00, 0x0E, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE3, 0xFF, 0xC7, 0xE3, 0xFF, 0xC7, 0xE3, 0xFF, 0xC7, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0x70, 0x00, + 0x0E, 0x78, 0x00, 0x1E, 0x3C, 0x00, 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x81, + 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xE0, 0x03, 0xCE, 0x00, + 0x78, 0xE0, 0x0F, 0x0E, 0x01, 0xE0, 0xE0, 0x3C, 0x0E, 0x07, 0x80, 0xE0, + 0xF0, 0x0E, 0x1E, 0x00, 0xE3, 0xC0, 0x0E, 0x78, 0x00, 0xEF, 0x00, 0x0F, + 0xE0, 0x00, 0xFE, 0x00, 0x0F, 0xF0, 0x00, 0xEF, 0x80, 0x0E, 0x7C, 0x00, + 0xE3, 0xE0, 0x0E, 0x1F, 0x00, 0xE0, 0xF8, 0x0E, 0x07, 0xC0, 0xE0, 0x3E, + 0x0E, 0x01, 0xF0, 0xE0, 0x0F, 0x8E, 0x00, 0x7C, 0xE0, 0x03, 0xEE, 0x00, + 0x1F, 0x00, 0x7C, 0x00, 0x00, 0xF8, 0x00, 0x01, 0xF0, 0x00, 0x07, 0x70, + 0x00, 0x0E, 0xE0, 0x00, 0x1D, 0xC0, 0x00, 0x71, 0xC0, 0x00, 0xE3, 0x80, + 0x03, 0xC7, 0x80, 0x07, 0x07, 0x00, 0x0E, 0x0E, 0x00, 0x3C, 0x1E, 0x00, + 0x70, 0x1C, 0x00, 0xE0, 0x38, 0x03, 0x80, 0x38, 0x07, 0x00, 0x70, 0x1E, + 0x00, 0xF0, 0x38, 0x00, 0xE0, 0x70, 0x01, 0xC1, 0xE0, 0x03, 0xC3, 0x80, + 0x03, 0x87, 0x00, 0x07, 0x1C, 0x00, 0x07, 0x38, 0x00, 0x0E, 0x70, 0x00, + 0x1D, 0xC0, 0x00, 0x1C, 0xF8, 0x00, 0x3F, 0xF8, 0x00, 0xFF, 0xF0, 0x01, + 0xFF, 0xE0, 0x03, 0xFF, 0xE0, 0x0F, 0xFD, 0xC0, 0x1D, 0xFB, 0x80, 0x3B, + 0xF3, 0x80, 0xE7, 0xE7, 0x01, 0xCF, 0xCF, 0x07, 0x9F, 0x8E, 0x0E, 0x3F, + 0x1C, 0x1C, 0x7E, 0x1C, 0x70, 0xFC, 0x38, 0xE1, 0xF8, 0x71, 0xC3, 0xF0, + 0x77, 0x07, 0xE0, 0xEE, 0x0F, 0xC1, 0xFC, 0x1F, 0x81, 0xF0, 0x3F, 0x03, + 0xE0, 0x7E, 0x03, 0x80, 0xFC, 0x00, 0x01, 0xF8, 0x00, 0x03, 0xF0, 0x00, + 0x07, 0xE0, 0x00, 0x0F, 0xC0, 0x00, 0x1C, 0xF8, 0x00, 0xFF, 0x00, 0x1F, + 0xF0, 0x03, 0xFE, 0x00, 0x7F, 0xE0, 0x0F, 0xDC, 0x01, 0xFB, 0xC0, 0x3F, + 0x38, 0x07, 0xE7, 0x80, 0xFC, 0x70, 0x1F, 0x8F, 0x03, 0xF0, 0xE0, 0x7E, + 0x0E, 0x0F, 0xC1, 0xC1, 0xF8, 0x1C, 0x3F, 0x03, 0xC7, 0xE0, 0x38, 0xFC, + 0x07, 0x9F, 0x80, 0x73, 0xF0, 0x0F, 0x7E, 0x00, 0xEF, 0xC0, 0x1F, 0xF8, + 0x01, 0xFF, 0x00, 0x3F, 0xE0, 0x03, 0xFC, 0x00, 0x7C, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFC, 0x3F, 0xFC, 0x3F, + 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, 0x81, + 0xF8, 0x3E, 0x00, 0x7C, 0x3C, 0x00, 0x3C, 0x78, 0x00, 0x1E, 0x70, 0x00, + 0x0E, 0x70, 0x00, 0x0E, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0x70, 0x00, 0x0E, 0x78, 0x00, + 0x1E, 0x3C, 0x00, 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x81, 0xF8, 0x0F, 0xFF, + 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, + 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0x80, 0x03, 0xF0, 0x00, 0x7E, + 0x00, 0x0F, 0xC0, 0x01, 0xF8, 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, + 0x00, 0x1F, 0x80, 0x03, 0xF0, 0x00, 0x7E, 0x00, 0x0F, 0xC0, 0x01, 0xF8, + 0x00, 0x3F, 0x00, 0x07, 0xE0, 0x00, 0xFC, 0x00, 0x1C, 0xFF, 0xE0, 0xFF, + 0xF8, 0xFF, 0xFC, 0xE0, 0x3E, 0xE0, 0x0F, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x3E, 0xFF, 0xFC, 0xFF, + 0xF8, 0xFF, 0xE0, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x78, 0x00, 0x3C, + 0x00, 0x1E, 0x00, 0x0F, 0x00, 0x07, 0x80, 0x03, 0xC0, 0x01, 0xE0, 0x00, + 0xF0, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x03, 0x80, 0x07, 0x80, 0x0F, + 0x00, 0x1E, 0x00, 0x1C, 0x00, 0x3C, 0x00, 0x78, 0x00, 0xF0, 0x00, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFE, 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, + 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, + 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, + 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x00, 0x07, 0x00, + 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, + 0x80, 0x00, 0xF0, 0x00, 0x7B, 0xC0, 0x07, 0x8E, 0x00, 0x38, 0x78, 0x03, + 0xC1, 0xE0, 0x3C, 0x07, 0x01, 0xC0, 0x3C, 0x1E, 0x00, 0xF1, 0xE0, 0x03, + 0x8E, 0x00, 0x1E, 0xF0, 0x00, 0x7F, 0x00, 0x01, 0xF0, 0x00, 0x0F, 0x80, + 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, + 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, + 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x00, + 0x1C, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x07, 0x00, 0x00, 0x1F, 0xF0, 0x00, + 0x7F, 0xFF, 0x00, 0x7F, 0xFF, 0xC0, 0x7E, 0x73, 0xF0, 0x78, 0x38, 0x3C, + 0x78, 0x1C, 0x0F, 0x38, 0x0E, 0x03, 0xB8, 0x07, 0x00, 0xFC, 0x03, 0x80, + 0x7E, 0x01, 0xC0, 0x3F, 0x00, 0xE0, 0x1F, 0x80, 0x70, 0x0F, 0xC0, 0x38, + 0x07, 0x70, 0x1C, 0x07, 0x3C, 0x0E, 0x07, 0x8F, 0x07, 0x07, 0x83, 0xF3, + 0x9F, 0x80, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0x80, 0x03, 0xFE, 0x00, 0x00, + 0x38, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x78, 0x00, 0x78, + 0xF0, 0x03, 0xC1, 0xC0, 0x0E, 0x07, 0x80, 0x78, 0x0F, 0x03, 0xC0, 0x1C, + 0x0E, 0x00, 0x78, 0x78, 0x00, 0xF3, 0xC0, 0x01, 0xCE, 0x00, 0x07, 0xF8, + 0x00, 0x0F, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x03, 0xF0, 0x00, + 0x0F, 0xC0, 0x00, 0x7F, 0x80, 0x03, 0xCF, 0x00, 0x0E, 0x1C, 0x00, 0x78, + 0x78, 0x03, 0xC0, 0xF0, 0x0E, 0x01, 0xC0, 0x78, 0x07, 0x83, 0xC0, 0x0F, + 0x0E, 0x00, 0x1C, 0x78, 0x00, 0x7B, 0xC0, 0x00, 0xF0, 0xE0, 0x1C, 0x03, + 0xF0, 0x0E, 0x01, 0xF8, 0x07, 0x00, 0xFC, 0x03, 0x80, 0x7E, 0x01, 0xC0, + 0x3F, 0x00, 0xE0, 0x1F, 0x80, 0x70, 0x0F, 0xC0, 0x38, 0x07, 0xE0, 0x1C, + 0x03, 0xF0, 0x0E, 0x01, 0xF8, 0x07, 0x00, 0xFE, 0x03, 0x80, 0xE7, 0x01, + 0xC0, 0x73, 0x80, 0xE0, 0x38, 0xE0, 0x70, 0x38, 0x78, 0x38, 0x3C, 0x1E, + 0x1C, 0x3C, 0x07, 0xCE, 0x7C, 0x01, 0xFF, 0xFC, 0x00, 0x7F, 0xFC, 0x00, + 0x0F, 0xF8, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x70, 0x00, 0x00, 0x38, 0x00, + 0x00, 0x1C, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x03, 0xFF, + 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x3C, 0x00, + 0x1C, 0x78, 0x00, 0x1E, 0x70, 0x00, 0x0E, 0xF0, 0x00, 0x0F, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x07, 0xE0, 0x00, 0x07, 0x70, 0x00, 0x0E, 0x70, 0x00, + 0x0E, 0x78, 0x00, 0x1E, 0x38, 0x00, 0x1C, 0x1C, 0x00, 0x38, 0x0E, 0x00, + 0x70, 0x07, 0x00, 0xE0, 0xFF, 0x81, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x81, + 0xFF, 0xF1, 0xFE, 0x3F, 0xC7, 0xF8, 0xF0, 0x00, 0x00, 0x03, 0x80, 0x70, + 0x0E, 0x01, 0xC0, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, + 0x01, 0xC0, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, + 0xC0, 0x38, 0x07, 0x00, 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x07, 0x8F, 0x00, + 0x3C, 0x78, 0x01, 0xE3, 0xC0, 0x0F, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x03, 0xC0, 0x01, 0xEF, 0x00, 0x1E, 0x38, 0x00, 0xE1, 0xE0, 0x0F, 0x07, + 0x80, 0xF0, 0x1C, 0x07, 0x00, 0xF0, 0x78, 0x03, 0xC7, 0x80, 0x0E, 0x38, + 0x00, 0x7B, 0xC0, 0x01, 0xFC, 0x00, 0x07, 0xC0, 0x00, 0x3E, 0x00, 0x00, + 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, + 0x00, 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x00, 0x07, + 0x00, 0x00, 0x38, 0x00, 0x01, 0xC0, 0x00, 0x0E, 0x00, 0x00, 0x1C, 0x00, + 0x07, 0x80, 0x00, 0xE0, 0x00, 0x38, 0x00, 0x0E, 0x00, 0x03, 0x80, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x38, 0x7F, 0xCF, 0x1F, + 0xFD, 0xC7, 0x87, 0xB8, 0xE0, 0x7F, 0x3C, 0x07, 0xC7, 0x00, 0xF8, 0xE0, + 0x1F, 0x1C, 0x03, 0xC3, 0x80, 0x38, 0x70, 0x07, 0x0E, 0x01, 0xE1, 0xC0, + 0x3C, 0x3C, 0x0F, 0xC3, 0x81, 0xF8, 0x78, 0x7F, 0x87, 0xFF, 0xFC, 0x7F, + 0xCF, 0x83, 0xF0, 0xF0, 0x00, 0x1C, 0x00, 0x70, 0x01, 0xC0, 0x07, 0x00, + 0x0C, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xE0, 0x7F, + 0xF1, 0xFF, 0xE7, 0x80, 0xCE, 0x00, 0x1C, 0x00, 0x3C, 0x00, 0x3F, 0xE0, + 0x1F, 0xC0, 0x7F, 0x83, 0xC0, 0x0F, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x78, + 0x00, 0xF8, 0x06, 0xFF, 0xFC, 0xFF, 0xF8, 0x3F, 0xC0, 0x00, 0x0E, 0x00, + 0x1C, 0x00, 0x38, 0x00, 0x70, 0x00, 0x60, 0x00, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xE3, 0xF0, 0xEF, 0xFC, 0xFF, 0xFE, 0xFC, 0x1E, 0xF8, + 0x0F, 0xF0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, + 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x07, 0x07, 0x03, 0x83, 0x83, + 0x83, 0x80, 0x00, 0x00, 0x00, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, + 0xC0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xC0, 0xF0, 0x3F, + 0x8F, 0xC3, 0xE0, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0x03, + 0x00, 0x06, 0x00, 0x00, 0x00, 0x78, 0xF0, 0x78, 0xF0, 0x78, 0xF0, 0x78, + 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, + 0x1C, 0xE0, 0x1C, 0xE0, 0x0E, 0xE0, 0x0E, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x0F, 0xE0, + 0x0E, 0xE0, 0x1E, 0xF0, 0x3C, 0x78, 0x7C, 0x7F, 0xF8, 0x3F, 0xF0, 0x0F, + 0xC0, 0x07, 0xE1, 0xC3, 0xFE, 0x78, 0xFF, 0xEE, 0x3C, 0x3D, 0xC7, 0x03, + 0xF9, 0xE0, 0x3E, 0x38, 0x07, 0xC7, 0x00, 0xF8, 0xE0, 0x1E, 0x1C, 0x01, + 0xC3, 0x80, 0x38, 0x70, 0x0F, 0x0E, 0x01, 0xE1, 0xE0, 0x7E, 0x1C, 0x0F, + 0xC3, 0xC3, 0xFC, 0x3F, 0xFF, 0xE3, 0xFE, 0x7C, 0x1F, 0x87, 0x80, 0x0F, + 0xE0, 0x1F, 0xFC, 0x1F, 0xFF, 0x0F, 0x07, 0x8F, 0x01, 0xE7, 0x00, 0x73, + 0x80, 0x39, 0xC0, 0x1C, 0xE0, 0x0E, 0x70, 0x0F, 0x38, 0x0F, 0x1C, 0x0F, + 0x8E, 0x7F, 0x87, 0x3F, 0xC3, 0x9F, 0xF9, 0xC0, 0x3E, 0xE0, 0x07, 0x70, + 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0x3F, 0x00, 0x1F, 0xC0, 0x1D, + 0xF8, 0x3E, 0xFF, 0xFE, 0x7F, 0xFE, 0x39, 0xFC, 0x1C, 0x00, 0x0E, 0x00, + 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE0, 0x00, 0x70, 0x00, 0x00, + 0xE0, 0x00, 0xFF, 0x00, 0x3B, 0xE0, 0x07, 0x1E, 0x01, 0xE1, 0xC0, 0x38, + 0x3C, 0x0F, 0x03, 0x81, 0xC0, 0x70, 0x38, 0x0F, 0x0E, 0x00, 0xE1, 0xC0, + 0x1C, 0x78, 0x03, 0xCE, 0x00, 0x3B, 0xC0, 0x07, 0x70, 0x00, 0xFE, 0x00, + 0x0F, 0x80, 0x01, 0xF0, 0x00, 0x3E, 0x00, 0x03, 0x80, 0x00, 0x70, 0x00, + 0x0E, 0x00, 0x01, 0xC0, 0x00, 0x38, 0x00, 0x07, 0x00, 0x00, 0xE0, 0x00, + 0x1C, 0x00, 0x03, 0xFE, 0x03, 0xFF, 0xC1, 0xFF, 0xF0, 0xF0, 0x04, 0x38, + 0x00, 0x0E, 0x00, 0x03, 0xE0, 0x00, 0x7F, 0xC0, 0x0F, 0xFC, 0x07, 0xFF, + 0x83, 0x81, 0xF1, 0xC0, 0x1E, 0x70, 0x03, 0xB8, 0x00, 0xFE, 0x00, 0x1F, + 0x80, 0x07, 0xE0, 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0xC0, 0x0F, 0x70, + 0x03, 0x9E, 0x01, 0xE3, 0xC0, 0xF0, 0xFF, 0xFC, 0x0F, 0xFC, 0x00, 0xFC, + 0x00, 0x07, 0xF0, 0x3F, 0xF8, 0xFF, 0xF3, 0xC0, 0x67, 0x00, 0x0E, 0x00, + 0x1E, 0x00, 0x1F, 0xF0, 0x0F, 0xE0, 0x3F, 0xC1, 0xE0, 0x07, 0x80, 0x0E, + 0x00, 0x1C, 0x00, 0x3C, 0x00, 0x7C, 0x03, 0x7F, 0xFE, 0x7F, 0xFC, 0x1F, + 0xE0, 0x7F, 0xFF, 0x7F, 0xFF, 0x7F, 0xFF, 0x00, 0x7E, 0x00, 0xF8, 0x03, + 0xE0, 0x07, 0xC0, 0x0F, 0x80, 0x1E, 0x00, 0x1C, 0x00, 0x38, 0x00, 0x78, + 0x00, 0x70, 0x00, 0x70, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xE0, + 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xF0, 0x00, 0x70, 0x00, 0x78, 0x00, 0x3E, + 0x00, 0x1F, 0xF8, 0x0F, 0xFE, 0x01, 0xFE, 0x00, 0x0F, 0x00, 0x07, 0x00, + 0x07, 0x00, 0x0F, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1C, 0xE3, 0xF0, 0xEF, + 0xFC, 0xFF, 0xFE, 0xFC, 0x1E, 0xF8, 0x0F, 0xF0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0x00, + 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, 0x07, 0x00, + 0x07, 0x03, 0xF0, 0x03, 0xFF, 0x01, 0xFF, 0xE0, 0xF8, 0x7C, 0x38, 0x07, + 0x1E, 0x01, 0xE7, 0x00, 0x39, 0xC0, 0x0E, 0xF0, 0x03, 0xF8, 0x00, 0x7E, + 0x00, 0x1F, 0x80, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, + 0x07, 0xE0, 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1D, 0xC0, 0x0E, 0x70, 0x03, + 0x9E, 0x01, 0xE3, 0x80, 0x70, 0xF8, 0x7C, 0x1F, 0xFE, 0x03, 0xFF, 0x00, + 0x3F, 0x00, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, + 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xF0, 0x7F, 0x3F, 0x1F, 0xE0, 0x3C, 0x70, + 0x3C, 0x38, 0x3C, 0x1C, 0x3C, 0x0E, 0x3C, 0x07, 0x3C, 0x03, 0xBC, 0x01, + 0xFE, 0x00, 0xFF, 0x80, 0x7F, 0xC0, 0x3C, 0xF0, 0x1C, 0x3C, 0x0E, 0x0F, + 0x07, 0x03, 0xC3, 0x80, 0xE1, 0xC0, 0x78, 0xE0, 0x1E, 0x70, 0x07, 0xB8, + 0x01, 0xE0, 0x1F, 0x00, 0x03, 0xF0, 0x00, 0x7F, 0x00, 0x01, 0xE0, 0x00, + 0x1E, 0x00, 0x01, 0xC0, 0x00, 0x38, 0x00, 0x07, 0x80, 0x00, 0x70, 0x00, + 0x1E, 0x00, 0x03, 0xE0, 0x00, 0xFC, 0x00, 0x1F, 0xC0, 0x07, 0xB8, 0x00, + 0xE7, 0x00, 0x3C, 0xF0, 0x07, 0x0E, 0x00, 0xE1, 0xC0, 0x38, 0x1C, 0x07, + 0x03, 0x81, 0xC0, 0x78, 0x38, 0x07, 0x0E, 0x00, 0xE1, 0xC0, 0x1E, 0x78, + 0x01, 0xCE, 0x00, 0x3B, 0xC0, 0x03, 0x80, 0xE0, 0x07, 0x38, 0x01, 0xCE, + 0x00, 0x73, 0x80, 0x1C, 0xE0, 0x07, 0x38, 0x01, 0xCE, 0x00, 0x73, 0x80, + 0x1C, 0xE0, 0x07, 0x38, 0x01, 0xCE, 0x00, 0x73, 0x80, 0x1C, 0xE0, 0x07, + 0x38, 0x01, 0xCF, 0x00, 0xF3, 0xE0, 0xFC, 0xFF, 0xFF, 0xFB, 0xFC, 0xFE, + 0x7E, 0x3B, 0x80, 0x00, 0xE0, 0x00, 0x38, 0x00, 0x0E, 0x00, 0x03, 0x80, + 0x00, 0xE0, 0x00, 0x38, 0x00, 0x00, 0xE0, 0x0E, 0x78, 0x07, 0x1C, 0x01, + 0xCE, 0x00, 0xE7, 0x80, 0x39, 0xC0, 0x1C, 0xE0, 0x0E, 0x78, 0x07, 0x1C, + 0x03, 0x8E, 0x03, 0xC7, 0x01, 0xC1, 0xC1, 0xE0, 0xE0, 0xE0, 0x70, 0xE0, + 0x1C, 0xF0, 0x0E, 0xF0, 0x07, 0xF0, 0x01, 0xF0, 0x00, 0xF0, 0x00, 0x7F, + 0xFC, 0xFF, 0xF9, 0xFF, 0xF0, 0xFC, 0x03, 0xC0, 0x0F, 0x00, 0x1C, 0x00, + 0x38, 0x00, 0x70, 0x00, 0xF0, 0x00, 0xF8, 0x00, 0xFF, 0x80, 0x3F, 0x03, + 0xFE, 0x0F, 0x80, 0x3C, 0x00, 0xF0, 0x01, 0xC0, 0x03, 0x80, 0x07, 0x00, + 0x0E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x3F, 0x00, 0x3F, 0xF0, 0x3F, 0xF8, + 0x1F, 0xF0, 0x00, 0xF0, 0x00, 0xE0, 0x01, 0xC0, 0x07, 0x80, 0x1E, 0x00, + 0x3C, 0x00, 0x70, 0x03, 0xF0, 0x03, 0xFF, 0x03, 0xFF, 0xF0, 0xF0, 0x3C, + 0x78, 0x07, 0x9C, 0x00, 0xEE, 0x00, 0x3F, 0x80, 0x07, 0xE0, 0x01, 0xF8, + 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xF0, 0x03, 0xDC, 0x00, 0xE7, 0x80, + 0x78, 0xF0, 0x3C, 0x3F, 0xFF, 0x03, 0xFF, 0x00, 0x3F, 0x00, 0xFF, 0xFF, + 0xBF, 0xFF, 0xEF, 0xFF, 0xF8, 0xE0, 0x38, 0x38, 0x0E, 0x0E, 0x03, 0x83, + 0x80, 0xE0, 0xE0, 0x38, 0x38, 0x0E, 0x0E, 0x03, 0x83, 0x80, 0xE0, 0xE0, + 0x38, 0x38, 0x0E, 0x0E, 0x03, 0x83, 0x80, 0xE0, 0xE0, 0x38, 0x38, 0x0F, + 0xCE, 0x01, 0xF3, 0x80, 0x7C, 0x03, 0xF8, 0x03, 0xFF, 0x81, 0xFF, 0xF0, + 0xF0, 0x3C, 0x78, 0x07, 0x9C, 0x00, 0xEE, 0x00, 0x3F, 0x80, 0x07, 0xE0, + 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x03, 0xFC, 0x00, + 0xEF, 0x80, 0x7B, 0xF0, 0x3C, 0xEF, 0xFF, 0x39, 0xFF, 0x0E, 0x3F, 0x03, + 0x80, 0x00, 0xE0, 0x00, 0x38, 0x00, 0x0E, 0x00, 0x03, 0x80, 0x00, 0xE0, + 0x00, 0x38, 0x00, 0x00, 0x03, 0xF8, 0x1F, 0xFC, 0x7F, 0xF9, 0xF0, 0x37, + 0x80, 0x0E, 0x00, 0x3C, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, + 0x07, 0x00, 0x0F, 0x00, 0x0E, 0x00, 0x1E, 0x00, 0x1F, 0x00, 0x1F, 0xF0, + 0x1F, 0xF8, 0x0F, 0xF0, 0x00, 0xF0, 0x00, 0xE0, 0x01, 0xC0, 0x07, 0x80, + 0x1E, 0x00, 0x3C, 0x00, 0x70, 0x07, 0xFF, 0xF1, 0xFF, 0xFF, 0x3F, 0xFF, + 0xF7, 0xC0, 0xF0, 0x78, 0x07, 0x87, 0x00, 0x38, 0xE0, 0x03, 0xCE, 0x00, + 0x1C, 0xE0, 0x01, 0xCE, 0x00, 0x1C, 0xE0, 0x01, 0xCE, 0x00, 0x1C, 0xF0, + 0x03, 0xC7, 0x00, 0x38, 0x78, 0x07, 0x83, 0xC0, 0xF0, 0x3F, 0xFE, 0x00, + 0xFF, 0xC0, 0x03, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, + 0x38, 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, + 0x00, 0xE0, 0x00, 0x70, 0x00, 0x38, 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x07, + 0x00, 0x03, 0xC0, 0x00, 0xFE, 0x00, 0x3F, 0x00, 0x0F, 0x80, 0xE0, 0x1C, + 0xE0, 0x1C, 0xE0, 0x0E, 0xE0, 0x0E, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, + 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x0F, 0xE0, 0x0E, + 0xE0, 0x1E, 0xF0, 0x3C, 0x78, 0x7C, 0x7F, 0xF8, 0x3F, 0xF0, 0x0F, 0xC0, + 0x06, 0x3C, 0x03, 0xCF, 0xE0, 0xFB, 0xFE, 0x3E, 0x71, 0xE7, 0x8E, 0x1D, + 0xE1, 0xC3, 0xFC, 0x38, 0x3F, 0x07, 0x07, 0xE0, 0xE0, 0xFC, 0x1C, 0x1F, + 0x83, 0x83, 0xF0, 0x70, 0x7E, 0x0E, 0x0E, 0xE1, 0xC3, 0x9E, 0x38, 0xF1, + 0xE7, 0x3C, 0x3F, 0xFF, 0x83, 0xFF, 0xE0, 0x1F, 0xF0, 0x00, 0x70, 0x00, + 0x0E, 0x00, 0x01, 0xC0, 0x00, 0x38, 0x00, 0x07, 0x00, 0x00, 0xE0, 0x00, + 0x1C, 0x00, 0xF0, 0x03, 0xFF, 0x00, 0xEF, 0xC0, 0x78, 0x78, 0x1C, 0x0E, + 0x0E, 0x03, 0xC7, 0x80, 0x71, 0xC0, 0x1C, 0xF0, 0x03, 0xB8, 0x00, 0xFE, + 0x00, 0x3F, 0x00, 0x07, 0xC0, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x3E, 0x00, + 0x0F, 0xC0, 0x07, 0xF0, 0x01, 0xDC, 0x00, 0xF3, 0x80, 0x38, 0xE0, 0x1E, + 0x3C, 0x07, 0x07, 0x03, 0x81, 0xE1, 0xE0, 0x3F, 0x70, 0x0F, 0xFC, 0x00, + 0xF0, 0xE0, 0xE0, 0xFC, 0x1C, 0x1F, 0x83, 0x83, 0xF0, 0x70, 0x7E, 0x0E, + 0x0F, 0xC1, 0xC1, 0xF8, 0x38, 0x3F, 0x07, 0x07, 0xE0, 0xE0, 0xFC, 0x1C, + 0x1F, 0x83, 0x83, 0xF0, 0x70, 0x7E, 0x0E, 0x0F, 0xE1, 0xC3, 0xDC, 0x38, + 0x73, 0xE7, 0x3E, 0x3F, 0xFF, 0x83, 0xFF, 0xE0, 0x0F, 0xE0, 0x00, 0x70, + 0x00, 0x0E, 0x00, 0x01, 0xC0, 0x00, 0x38, 0x00, 0x07, 0x00, 0x00, 0xE0, + 0x00, 0x1C, 0x00, 0x1C, 0x00, 0x1C, 0x1C, 0x00, 0x07, 0x0E, 0x00, 0x03, + 0x8E, 0x00, 0x00, 0xE7, 0x00, 0x00, 0x73, 0x80, 0x00, 0x3B, 0x80, 0x00, + 0x0F, 0xC0, 0x00, 0x07, 0xE0, 0x1C, 0x03, 0xF0, 0x0E, 0x01, 0xF8, 0x07, + 0x00, 0xFC, 0x03, 0x80, 0x7E, 0x01, 0xC0, 0x3F, 0x81, 0xF0, 0x3D, 0xC0, + 0xD8, 0x1C, 0xF0, 0xEE, 0x1E, 0x3F, 0xF7, 0xFE, 0x0F, 0xF1, 0xFE, 0x03, + 0xF0, 0x7E, 0x00, 0xF1, 0xFE, 0x3F, 0xC7, 0xF8, 0xF0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, 0xC0, 0x38, 0x07, 0x00, + 0xE0, 0x1C, 0x03, 0x80, 0x70, 0x0E, 0x01, 0xC0, 0x38, 0x07, 0x80, 0x70, + 0x0F, 0xE0, 0xFC, 0x0F, 0x80, 0x7C, 0xF8, 0x7C, 0xF8, 0x7C, 0xF8, 0x7C, + 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, + 0x1C, 0xE0, 0x1C, 0xE0, 0x0E, 0xE0, 0x0E, 0xE0, 0x07, 0xE0, 0x07, 0xE0, + 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x0F, 0xE0, + 0x0E, 0xE0, 0x1E, 0xF0, 0x3C, 0x78, 0x7C, 0x7F, 0xF8, 0x3F, 0xF0, 0x0F, + 0xC0, 0x00, 0x1E, 0x00, 0x07, 0x00, 0x03, 0x80, 0x01, 0xC0, 0x00, 0xE0, + 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00, + 0xFF, 0xC0, 0xFF, 0xFC, 0x3C, 0x0F, 0x1E, 0x01, 0xE7, 0x00, 0x3B, 0x80, + 0x0F, 0xE0, 0x01, 0xF8, 0x00, 0x7E, 0x00, 0x1F, 0x80, 0x07, 0xE0, 0x01, + 0xFC, 0x00, 0xF7, 0x00, 0x39, 0xE0, 0x1E, 0x3C, 0x0F, 0x0F, 0xFF, 0xC0, + 0xFF, 0xC0, 0x0F, 0xC0, 0x00, 0x70, 0x00, 0xE0, 0x01, 0xC0, 0x01, 0x80, + 0x03, 0x80, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xE0, 0x1C, 0xE0, 0x1C, 0xE0, 0x0E, 0xE0, 0x0E, 0xE0, 0x07, 0xE0, 0x07, + 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x07, 0xE0, 0x0F, 0xE0, 0x0F, + 0xE0, 0x0E, 0xE0, 0x1E, 0xF0, 0x3C, 0x78, 0x7C, 0x7F, 0xF8, 0x3F, 0xF0, + 0x0F, 0xC0, 0x00, 0x01, 0xC0, 0x00, 0x01, 0xE0, 0x00, 0x00, 0xE0, 0x00, + 0x00, 0xE0, 0x00, 0x00, 0xE0, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x07, + 0x07, 0x00, 0x01, 0xC3, 0x80, 0x00, 0xE3, 0x80, 0x00, 0x39, 0xC0, 0x00, + 0x1C, 0xE0, 0x00, 0x0E, 0xE0, 0x00, 0x03, 0xF0, 0x00, 0x01, 0xF8, 0x07, + 0x00, 0xFC, 0x03, 0x80, 0x7E, 0x01, 0xC0, 0x3F, 0x00, 0xE0, 0x1F, 0x80, + 0x70, 0x0F, 0xE0, 0x7C, 0x0F, 0x70, 0x36, 0x07, 0x3C, 0x3B, 0x87, 0x8F, + 0xFD, 0xFF, 0x83, 0xFC, 0x7F, 0x80, 0xFC, 0x1F, 0x80, +}; + +const GFXglyph FreeSans18pt_Win1253Glyphs[] PROGMEM = { +/* 0x01 */ { 0, 33, 36, 45, 6, -29 }, +/* 0x02 */ { 149, 33, 36, 45, 6, -29 }, +/* 0x03 */ { 298, 35, 36, 45, 5, -29 }, +/* 0x04 */ { 456, 42, 36, 45, 1, -29 }, +/* 0x05 */ { 645, 35, 36, 45, 5, -29 }, +/* 0x06 */ { 803, 35, 36, 45, 5, -29 }, +/* 0x07 */ { 961, 0, 0, 0, 0, 0 }, +/* 0x08 */ { 961, 37, 36, 45, 4, -29 }, +/* 0x09 */ { 1128, 40, 28, 45, 2, -25 }, +/* 0x0A */ { 1268, 0, 0, 0, 0, 0 }, +/* 0x0B */ { 1268, 39, 36, 45, 3, -29 }, +/* 0x0C */ { 1444, 35, 36, 45, 5, -29 }, +/* 0x0D */ { 1602, 0, 0, 0, 0, 0 }, +/* 0x0E */ { 1602, 35, 36, 45, 5, -29 }, +/* 0x0F */ { 1760, 35, 37, 45, 5, -29 }, +/* 0x10 */ { 1922, 34, 36, 45, 5, -29 }, +/* 0x11 */ { 2075, 35, 36, 45, 5, -29 }, +/* 0x12 */ { 2233, 34, 36, 45, 5, -29 }, +/* 0x13 */ { 2386, 35, 36, 45, 5, -29 }, +/* 0x14 */ { 2544, 35, 36, 45, 5, -29 }, +/* 0x15 */ { 2702, 38, 36, 45, 4, -29 }, +/* 0x16 */ { 2873, 28, 36, 45, 8, -29 }, +/* 0x17 */ { 2999, 37, 30, 45, 4, -26 }, +/* 0x18 */ { 3138, 42, 30, 45, 1, -26 }, +/* 0x19 */ { 3296, 35, 36, 45, 5, -29 }, +/* 0x1A */ { 3454, 0, 0, 0, 0, 0 }, +/* 0x1B */ { 3454, 42, 37, 45, 1, -29 }, +/* 0x1C */ { 3649, 35, 36, 45, 5, -29 }, +/* 0x1D */ { 3807, 36, 36, 45, 4, -28 }, +/* 0x1E */ { 3969, 35, 36, 45, 4, -29 }, +/* 0x1F */ { 4127, 25, 36, 45, 10, -29 }, +/* 0x20 */ { 4240, 1, 1, 11, 0, 0 }, +/* 0x21 */ { 4241, 3, 26, 14, 5, -25 }, +/* 0x22 */ { 4251, 10, 9, 16, 3, -25 }, +/* 0x23 */ { 4263, 24, 25, 29, 3, -24 }, +/* 0x24 */ { 4338, 17, 32, 22, 3, -26 }, +/* 0x25 */ { 4406, 29, 26, 33, 2, -25 }, +/* 0x26 */ { 4501, 24, 26, 27, 2, -25 }, +/* 0x27 */ { 4579, 3, 9, 10, 3, -25 }, +/* 0x28 */ { 4583, 8, 31, 14, 3, -26 }, +/* 0x29 */ { 4614, 8, 31, 14, 3, -26 }, +/* 0x2A */ { 4645, 16, 16, 18, 1, -25 }, +/* 0x2B */ { 4677, 23, 23, 29, 4, -22 }, +/* 0x2C */ { 4744, 4, 8, 11, 3, -3 }, +/* 0x2D */ { 4748, 9, 3, 13, 2, -10 }, +/* 0x2E */ { 4752, 3, 4, 11, 4, -3 }, +/* 0x2F */ { 4754, 12, 29, 12, 0, -25 }, +/* 0x30 */ { 4798, 18, 26, 22, 2, -25 }, +/* 0x31 */ { 4857, 15, 26, 22, 4, -25 }, +/* 0x32 */ { 4906, 16, 26, 22, 3, -25 }, +/* 0x33 */ { 4958, 17, 26, 22, 3, -25 }, +/* 0x34 */ { 5014, 19, 26, 22, 2, -25 }, +/* 0x35 */ { 5076, 17, 26, 22, 3, -25 }, +/* 0x36 */ { 5132, 18, 26, 22, 2, -25 }, +/* 0x37 */ { 5191, 16, 26, 22, 3, -25 }, +/* 0x38 */ { 5243, 18, 26, 22, 2, -25 }, +/* 0x39 */ { 5302, 18, 26, 22, 2, -25 }, +/* 0x3A */ { 5361, 3, 18, 12, 4, -17 }, +/* 0x3B */ { 5368, 4, 22, 12, 3, -17 }, +/* 0x3C */ { 5379, 22, 19, 29, 4, -19 }, +/* 0x3D */ { 5432, 22, 10, 29, 4, -15 }, +/* 0x3E */ { 5460, 22, 19, 29, 4, -19 }, +/* 0x3F */ { 5513, 14, 26, 19, 3, -25 }, +/* 0x40 */ { 5559, 31, 31, 35, 2, -24 }, +/* 0x41 */ { 5680, 23, 26, 24, 0, -25 }, +/* 0x42 */ { 5755, 18, 26, 24, 3, -25 }, +/* 0x43 */ { 5814, 21, 26, 24, 2, -25 }, +/* 0x44 */ { 5883, 21, 26, 27, 3, -25 }, +/* 0x45 */ { 5952, 16, 26, 22, 3, -25 }, +/* 0x46 */ { 6004, 15, 26, 20, 3, -25 }, +/* 0x47 */ { 6053, 22, 26, 27, 2, -25 }, +/* 0x48 */ { 6125, 19, 26, 26, 3, -25 }, +/* 0x49 */ { 6187, 3, 26, 10, 3, -25 }, +/* 0x4A */ { 6197, 8, 33, 10, -2, -25 }, +/* 0x4B */ { 6230, 20, 26, 23, 3, -25 }, +/* 0x4C */ { 6295, 16, 26, 20, 3, -25 }, +/* 0x4D */ { 6347, 23, 26, 30, 3, -25 }, +/* 0x4E */ { 6422, 19, 26, 26, 3, -25 }, +/* 0x4F */ { 6484, 24, 26, 28, 2, -25 }, +/* 0x50 */ { 6562, 16, 26, 21, 3, -25 }, +/* 0x51 */ { 6614, 24, 31, 28, 2, -25 }, +/* 0x52 */ { 6707, 19, 26, 24, 3, -25 }, +/* 0x53 */ { 6769, 18, 26, 22, 2, -25 }, +/* 0x54 */ { 6828, 21, 26, 21, 0, -25 }, +/* 0x55 */ { 6897, 19, 26, 26, 3, -25 }, +/* 0x56 */ { 6959, 23, 26, 24, 0, -25 }, +/* 0x57 */ { 7034, 32, 26, 35, 1, -25 }, +/* 0x58 */ { 7138, 22, 26, 24, 1, -25 }, +/* 0x59 */ { 7210, 21, 26, 21, 0, -25 }, +/* 0x5A */ { 7279, 21, 26, 24, 2, -25 }, +/* 0x5B */ { 7348, 7, 31, 14, 3, -26 }, +/* 0x5C */ { 7376, 12, 29, 12, 0, -25 }, +/* 0x5D */ { 7420, 7, 31, 14, 3, -26 }, +/* 0x5E */ { 7448, 22, 10, 29, 4, -25 }, +/* 0x5F */ { 7476, 18, 3, 18, 0, 6 }, +/* 0x60 */ { 7483, 8, 6, 18, 3, -27 }, +/* 0x61 */ { 7489, 16, 19, 21, 2, -18 }, +/* 0x62 */ { 7527, 17, 27, 22, 3, -26 }, +/* 0x63 */ { 7585, 15, 19, 19, 2, -18 }, +/* 0x64 */ { 7621, 17, 27, 22, 2, -26 }, +/* 0x65 */ { 7679, 18, 19, 22, 2, -18 }, +/* 0x66 */ { 7722, 12, 27, 12, 1, -26 }, +/* 0x67 */ { 7763, 17, 26, 22, 2, -18 }, +/* 0x68 */ { 7819, 16, 27, 22, 3, -26 }, +/* 0x69 */ { 7873, 3, 27, 10, 3, -26 }, +/* 0x6A */ { 7884, 7, 34, 10, -1, -26 }, +/* 0x6B */ { 7914, 17, 27, 20, 3, -26 }, +/* 0x6C */ { 7972, 3, 27, 10, 3, -26 }, +/* 0x6D */ { 7983, 29, 19, 34, 3, -18 }, +/* 0x6E */ { 8052, 16, 19, 22, 3, -18 }, +/* 0x6F */ { 8090, 18, 19, 21, 2, -18 }, +/* 0x70 */ { 8133, 17, 26, 22, 3, -18 }, +/* 0x71 */ { 8189, 17, 26, 22, 2, -18 }, +/* 0x72 */ { 8245, 11, 19, 14, 3, -18 }, +/* 0x73 */ { 8272, 15, 19, 18, 2, -18 }, +/* 0x74 */ { 8308, 11, 24, 14, 1, -23 }, +/* 0x75 */ { 8341, 16, 19, 22, 3, -18 }, +/* 0x76 */ { 8379, 19, 19, 21, 1, -18 }, +/* 0x77 */ { 8425, 26, 19, 29, 1, -18 }, +/* 0x78 */ { 8487, 19, 19, 21, 1, -18 }, +/* 0x79 */ { 8533, 19, 26, 21, 1, -18 }, +/* 0x7A */ { 8595, 15, 19, 18, 2, -18 }, +/* 0x7B */ { 8631, 13, 32, 22, 5, -26 }, +/* 0x7C */ { 8683, 3, 35, 12, 4, -26 }, +/* 0x7D */ { 8697, 13, 32, 22, 4, -26 }, +/* 0x7E */ { 8749, 22, 6, 29, 4, -13 }, +/* 0x7F */ { 8766, 0, 0, 0, 0, 0 }, +/* 0x80 */ { 8766, 20, 26, 22, 0, -25 }, +/* 0x81 */ { 8831, 0, 0, 0, 0, 0 }, +/* 0x82 */ { 8831, 4, 8, 11, 3, -3 }, +/* 0x83 */ { 8835, 15, 34, 12, -2, -26 }, +/* 0x84 */ { 8899, 10, 8, 18, 3, -3 }, +/* 0x85 */ { 8909, 27, 4, 35, 4, -3 }, +/* 0x86 */ { 8923, 15, 29, 18, 1, -25 }, +/* 0x87 */ { 8978, 15, 29, 18, 1, -25 }, +/* 0x88 */ { 9033, 0, 0, 0, 0, 0 }, +/* 0x89 */ { 9033, 43, 26, 47, 2, -25 }, +/* 0x8A */ { 9173, 0, 0, 0, 0, 0 }, +/* 0x8B */ { 9173, 8, 16, 14, 3, -17 }, +/* 0x8C */ { 9189, 0, 0, 0, 0, 0 }, +/* 0x8D */ { 9189, 0, 0, 0, 0, 0 }, +/* 0x8E */ { 9189, 0, 0, 0, 0, 0 }, +/* 0x8F */ { 9189, 0, 0, 0, 0, 0 }, +/* 0x90 */ { 9189, 0, 0, 0, 0, 0 }, +/* 0x91 */ { 9189, 4, 8, 11, 3, -25 }, +/* 0x92 */ { 9193, 4, 8, 11, 3, -25 }, +/* 0x93 */ { 9197, 10, 8, 18, 3, -25 }, +/* 0x94 */ { 9207, 10, 8, 18, 3, -25 }, +/* 0x95 */ { 9217, 10, 10, 21, 5, -17 }, +/* 0x96 */ { 9230, 14, 3, 18, 2, -10 }, +/* 0x97 */ { 9236, 32, 3, 35, 2, -10 }, +/* 0x98 */ { 9248, 0, 0, 0, 0, 0 }, +/* 0x99 */ { 9248, 22, 10, 35, 5, -25 }, +/* 0x9A */ { 9276, 0, 0, 0, 0, 0 }, +/* 0x9B */ { 9276, 8, 16, 14, 3, -17 }, +/* 0x9C */ { 9292, 0, 0, 0, 0, 0 }, +/* 0x9D */ { 9292, 0, 0, 0, 0, 0 }, +/* 0x9E */ { 9292, 0, 0, 0, 0, 0 }, +/* 0x9F */ { 9292, 0, 0, 0, 0, 0 }, +/* 0xA0 */ { 9292, 1, 1, 11, 0, 0 }, +/* 0xA1 */ { 9293, 11, 11, 18, 4, -33 }, +/* 0xA2 */ { 9309, 23, 28, 24, 0, -27 }, +/* 0xA3 */ { 9390, 17, 26, 22, 2, -25 }, +/* 0xA4 */ { 9446, 19, 19, 22, 2, -19 }, +/* 0xA5 */ { 9492, 19, 26, 22, 1, -25 }, +/* 0xA6 */ { 9554, 3, 31, 12, 4, -24 }, +/* 0xA7 */ { 9566, 14, 29, 18, 2, -25 }, +/* 0xA8 */ { 9617, 11, 4, 18, 4, -26 }, +/* 0xA9 */ { 9623, 25, 25, 35, 5, -24 }, +/* 0xAA */ { 9702, 0, 0, 0, 0, 0 }, +/* 0xAB */ { 9702, 15, 16, 21, 3, -17 }, +/* 0xAC */ { 9732, 22, 10, 29, 4, -14 }, +/* 0xAD */ { 9760, 9, 3, 13, 2, -10 }, +/* 0xAE */ { 9764, 25, 25, 35, 5, -24 }, +/* 0xAF */ { 9843, 35, 3, 35, 0, -10 }, +/* 0xB0 */ { 9857, 11, 11, 18, 3, -25 }, +/* 0xB1 */ { 9873, 23, 22, 29, 3, -21 }, +/* 0xB2 */ { 9937, 10, 14, 14, 2, -25 }, +/* 0xB3 */ { 9955, 11, 14, 14, 2, -25 }, +/* 0xB4 */ { 9975, 8, 6, 18, 7, -27 }, +/* 0xB5 */ { 9981, 11, 11, 18, 4, -33 }, +/* 0xB6 */ { 9997, 23, 28, 24, 0, -27 }, +/* 0xB7 */ { 10078, 3, 4, 11, 4, -13 }, +/* 0xB8 */ { 10080, 23, 28, 26, 0, -27 }, +/* 0xB9 */ { 10161, 26, 28, 30, 0, -27 }, +/* 0xBA */ { 10252, 10, 28, 14, 0, -27 }, +/* 0xBB */ { 10287, 15, 16, 21, 3, -17 }, +/* 0xBC */ { 10317, 27, 28, 28, 0, -27 }, +/* 0xBD */ { 10412, 29, 26, 34, 3, -25 }, +/* 0xBE */ { 10507, 28, 28, 29, 0, -27 }, +/* 0xBF */ { 10605, 27, 28, 29, 0, -27 }, +/* 0xC0 */ { 10700, 11, 34, 12, 0, -33 }, +/* 0xC1 */ { 10747, 23, 26, 24, 0, -25 }, +/* 0xC2 */ { 10822, 18, 26, 24, 3, -25 }, +/* 0xC3 */ { 10881, 15, 26, 19, 3, -25 }, +/* 0xC4 */ { 10930, 23, 26, 24, 0, -25 }, +/* 0xC5 */ { 11005, 16, 26, 22, 3, -25 }, +/* 0xC6 */ { 11057, 21, 26, 24, 2, -25 }, +/* 0xC7 */ { 11126, 19, 26, 26, 3, -25 }, +/* 0xC8 */ { 11188, 24, 26, 28, 2, -25 }, +/* 0xC9 */ { 11266, 3, 26, 10, 3, -25 }, +/* 0xCA */ { 11276, 20, 26, 23, 3, -25 }, +/* 0xCB */ { 11341, 23, 26, 24, 0, -25 }, +/* 0xCC */ { 11416, 23, 26, 30, 3, -25 }, +/* 0xCD */ { 11491, 19, 26, 26, 3, -25 }, +/* 0xCE */ { 11553, 16, 26, 22, 3, -25 }, +/* 0xCF */ { 11605, 24, 26, 28, 2, -25 }, +/* 0xD0 */ { 11683, 19, 26, 25, 3, -25 }, +/* 0xD1 */ { 11745, 16, 26, 21, 3, -25 }, +/* 0xD2 */ { 11797, 0, 0, 0, 0, 0 }, +/* 0xD3 */ { 11797, 16, 26, 22, 3, -25 }, +/* 0xD4 */ { 11849, 21, 26, 21, 0, -25 }, +/* 0xD5 */ { 11918, 21, 26, 21, 0, -25 }, +/* 0xD6 */ { 11987, 25, 26, 29, 2, -25 }, +/* 0xD7 */ { 12069, 22, 26, 24, 1, -25 }, +/* 0xD8 */ { 12141, 25, 26, 29, 2, -25 }, +/* 0xD9 */ { 12223, 24, 26, 28, 2, -25 }, +/* 0xDA */ { 12301, 11, 32, 10, -1, -31 }, +/* 0xDB */ { 12345, 21, 32, 21, 0, -31 }, +/* 0xDC */ { 12429, 19, 28, 23, 2, -27 }, +/* 0xDD */ { 12496, 15, 28, 19, 2, -27 }, +/* 0xDE */ { 12549, 16, 35, 22, 3, -27 }, +/* 0xDF */ { 12619, 9, 28, 12, 3, -27 }, +/* 0xE0 */ { 12651, 16, 35, 21, 3, -33 }, +/* 0xE1 */ { 12721, 19, 19, 23, 2, -18 }, +/* 0xE2 */ { 12767, 17, 34, 22, 3, -26 }, +/* 0xE3 */ { 12840, 19, 26, 21, 1, -18 }, +/* 0xE4 */ { 12902, 18, 26, 22, 2, -25 }, +/* 0xE5 */ { 12961, 15, 19, 19, 2, -18 }, +/* 0xE6 */ { 12997, 16, 34, 20, 2, -26 }, +/* 0xE7 */ { 13065, 16, 26, 22, 3, -18 }, +/* 0xE8 */ { 13117, 18, 27, 22, 2, -26 }, +/* 0xE9 */ { 13178, 8, 19, 12, 3, -18 }, +/* 0xEA */ { 13197, 17, 19, 21, 3, -18 }, +/* 0xEB */ { 13238, 19, 27, 21, 1, -26 }, +/* 0xEC */ { 13303, 18, 26, 22, 3, -18 }, +/* 0xED */ { 13362, 17, 19, 20, 1, -18 }, +/* 0xEE */ { 13403, 15, 34, 19, 2, -26 }, +/* 0xEF */ { 13467, 18, 19, 21, 2, -18 }, +/* 0xF0 */ { 13510, 18, 19, 21, 2, -18 }, +/* 0xF1 */ { 13553, 18, 26, 23, 3, -18 }, +/* 0xF2 */ { 13612, 15, 26, 20, 2, -18 }, +/* 0xF3 */ { 13661, 20, 19, 23, 2, -18 }, +/* 0xF4 */ { 13709, 17, 19, 21, 2, -18 }, +/* 0xF5 */ { 13750, 16, 19, 21, 3, -17 }, +/* 0xF6 */ { 13788, 19, 26, 23, 2, -18 }, +/* 0xF7 */ { 13850, 18, 26, 20, 1, -18 }, +/* 0xF8 */ { 13909, 19, 26, 23, 2, -18 }, +/* 0xF9 */ { 13971, 25, 19, 29, 2, -17 }, +/* 0xFA */ { 14031, 11, 27, 12, 0, -26 }, +/* 0xFB */ { 14069, 16, 28, 21, 3, -26 }, +/* 0xFC */ { 14125, 18, 28, 21, 2, -27 }, +/* 0xFD */ { 14188, 16, 29, 21, 3, -27 }, +/* 0xFE */ { 14246, 25, 29, 29, 2, -27 }, +/* 0xFF */ { 14337, 0, 0, 0, 0, 0 }, +}; + +const GFXfont FreeSans18pt_Win1253 PROGMEM = { +(uint8_t*)FreeSans18pt_Win1253Bitmaps, +(GFXglyph*)FreeSans18pt_Win1253Glyphs, +0x01, 0xFF, 41 +}; diff --git a/src/graphics/niche/Fonts/FreeSans24pt_Win1253.h b/src/graphics/niche/Fonts/FreeSans24pt_Win1253.h new file mode 100644 index 00000000000..7efefd443aa --- /dev/null +++ b/src/graphics/niche/Fonts/FreeSans24pt_Win1253.h @@ -0,0 +1,2429 @@ +// trunk-ignore-all(clang-format) +#pragma once +/* PROPERTIES + +FONT_NAME FreeSans24pt_Win1253 +*/ +const uint8_t FreeSans24pt_Win1253Bitmaps[] PROGMEM = { + 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x00, + 0x00, 0x0C, 0xE0, 0x00, 0x00, 0x00, 0x03, 0x0C, 0x00, 0x00, 0x00, 0x00, + 0x60, 0xC0, 0x00, 0x00, 0x00, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x01, 0x83, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x60, 0x00, 0x00, 0x00, 0x06, 0x0C, 0x00, + 0x00, 0x00, 0x00, 0xC1, 0x80, 0x00, 0x00, 0x00, 0x18, 0x30, 0x00, 0x00, + 0x00, 0x06, 0x06, 0x00, 0x00, 0x00, 0x01, 0xC0, 0xC0, 0x00, 0x00, 0x00, + 0x70, 0x30, 0x00, 0x00, 0x00, 0x1C, 0x06, 0x00, 0x00, 0x00, 0x07, 0x00, + 0xC0, 0x00, 0x00, 0x03, 0x80, 0x13, 0xFF, 0x00, 0x00, 0xE0, 0x07, 0xFF, + 0xF0, 0x00, 0x70, 0x00, 0xF0, 0x06, 0x00, 0x0C, 0x00, 0x38, 0x00, 0xC0, + 0x03, 0x00, 0x06, 0x00, 0x18, 0x00, 0x60, 0x00, 0xC0, 0x03, 0x01, 0xFC, + 0x00, 0x18, 0x00, 0x67, 0xFF, 0x00, 0x03, 0x80, 0x38, 0xC0, 0x00, 0x00, + 0x3F, 0xFF, 0xD8, 0x00, 0x00, 0x07, 0xFE, 0x1F, 0x00, 0x00, 0x01, 0xE0, + 0x01, 0xE0, 0x00, 0x00, 0x3C, 0x00, 0x1C, 0x00, 0x00, 0x0F, 0x00, 0x03, + 0x80, 0x00, 0x03, 0xA0, 0x00, 0xF0, 0x00, 0x00, 0xE6, 0x00, 0x1E, 0x00, + 0x00, 0x78, 0xF0, 0x1F, 0xC0, 0x00, 0x1C, 0x0F, 0xFF, 0xD8, 0x00, 0x02, + 0x01, 0xC0, 0x7B, 0x00, 0x00, 0x00, 0x60, 0x03, 0xE0, 0x00, 0x00, 0x0C, + 0x00, 0x3C, 0x00, 0x00, 0x01, 0x80, 0x07, 0x80, 0x00, 0x00, 0x30, 0x00, + 0xF0, 0x00, 0x00, 0x07, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x7F, 0x86, 0xFF, + 0x80, 0x00, 0x1F, 0xFF, 0x9F, 0xFE, 0x00, 0x03, 0x00, 0xC0, 0x01, 0xF0, + 0x00, 0xC0, 0x18, 0x00, 0x07, 0x00, 0x18, 0x03, 0x00, 0x00, 0x3C, 0x01, + 0x80, 0x60, 0x00, 0x03, 0xFC, 0x7E, 0x1C, 0x00, 0x00, 0x0F, 0xFF, 0xFF, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x01, 0x80, 0xF8, 0x00, + 0x00, 0x0F, 0xFF, 0xFF, 0x80, 0x00, 0x07, 0xE0, 0x78, 0x38, 0x00, 0x03, + 0xC0, 0x0C, 0x03, 0x00, 0x00, 0xE0, 0x03, 0x00, 0x60, 0x00, 0xF8, 0x00, + 0x60, 0x0C, 0x3F, 0xFC, 0x00, 0x06, 0x03, 0xC7, 0xF8, 0x00, 0x00, 0xFF, + 0xFC, 0xC0, 0x00, 0x00, 0x0F, 0xE0, 0xD8, 0x00, 0x00, 0x03, 0x80, 0x0F, + 0x00, 0x00, 0x00, 0x60, 0x01, 0xE0, 0x00, 0x00, 0x0C, 0x00, 0x3C, 0x00, + 0x00, 0x01, 0x80, 0x07, 0x80, 0x00, 0x00, 0x30, 0x01, 0xB0, 0x00, 0x04, + 0x03, 0xFF, 0xF6, 0x00, 0x00, 0xE0, 0x7F, 0xFE, 0xC0, 0x00, 0x0F, 0x1C, + 0x01, 0xF8, 0x00, 0x00, 0x73, 0x00, 0x0F, 0x00, 0x00, 0x07, 0xC0, 0x01, + 0xE0, 0x00, 0x00, 0x78, 0x00, 0x1C, 0x00, 0x00, 0x07, 0x80, 0x07, 0x80, + 0x00, 0x00, 0xF8, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0xFF, 0x3F, 0xC0, 0x00, + 0x01, 0xFF, 0xFE, 0xFF, 0xE0, 0x00, 0x70, 0x03, 0x80, 0x7E, 0x00, 0x0C, + 0x00, 0x30, 0x00, 0xC0, 0x01, 0x80, 0x06, 0x00, 0x18, 0x00, 0x30, 0x00, + 0xC0, 0x01, 0x80, 0x07, 0x00, 0x18, 0x00, 0x18, 0x00, 0x78, 0x03, 0x00, + 0x01, 0xC0, 0x0F, 0xFF, 0xC0, 0x00, 0x1E, 0x00, 0x8F, 0xF0, 0x00, 0x00, + 0xE0, 0x18, 0x00, 0x00, 0x00, 0x0E, 0x03, 0x00, 0x00, 0x00, 0x00, 0x60, + 0x60, 0x00, 0x00, 0x00, 0x0E, 0x06, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xC0, + 0x00, 0x00, 0x00, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x01, 0x83, 0x00, 0x00, + 0x00, 0x00, 0x30, 0x60, 0x00, 0x00, 0x00, 0x06, 0x0C, 0x00, 0x00, 0x00, + 0x00, 0xC1, 0x80, 0x00, 0x00, 0x00, 0x18, 0x30, 0x00, 0x00, 0x00, 0x03, + 0x06, 0x00, 0x00, 0x00, 0x00, 0x61, 0x80, 0x00, 0x00, 0x00, 0x06, 0x70, + 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, + 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, + 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF8, 0x00, + 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, + 0x00, 0xE0, 0x00, 0x00, 0x07, 0x00, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, + 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, 0x00, 0x00, 0x00, 0x00, 0x60, + 0x0E, 0x00, 0x00, 0x00, 0x00, 0x70, 0x1C, 0x00, 0x00, 0x00, 0x00, 0x30, + 0x18, 0x00, 0x00, 0x00, 0x00, 0x18, 0x38, 0x00, 0x00, 0x00, 0x00, 0x1C, + 0x30, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x70, 0x00, 0x00, 0x00, 0x00, 0x0C, + 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x03, 0xC0, 0x03, 0xC0, 0x06, + 0x60, 0x07, 0xE0, 0x07, 0xE0, 0x06, 0xC0, 0x0C, 0x30, 0x0C, 0x30, 0x03, + 0xC0, 0x18, 0x30, 0x0C, 0x38, 0x03, 0xC0, 0x18, 0x18, 0x18, 0x18, 0x03, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x0D, 0x00, 0x00, 0x4A, 0x03, + 0xC0, 0x69, 0x00, 0x00, 0xD2, 0x03, 0xC0, 0x4B, 0x00, 0x00, 0x96, 0x03, + 0xC0, 0xD2, 0x00, 0x01, 0xA4, 0x03, 0x60, 0x10, 0x00, 0x00, 0x20, 0x06, + 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, + 0x70, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x30, 0x00, 0xC0, 0x03, 0x00, 0x0C, + 0x38, 0x01, 0xF0, 0x0F, 0x80, 0x1C, 0x18, 0x00, 0x7F, 0xFE, 0x00, 0x18, + 0x1C, 0x00, 0x0F, 0xF0, 0x00, 0x30, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x70, + 0x06, 0x00, 0x00, 0x00, 0x00, 0x60, 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, + 0x03, 0x80, 0x00, 0x00, 0x01, 0x80, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x00, + 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, + 0x00, 0x1F, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, + 0x00, 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFC, + 0x00, 0x00, 0x00, 0x00, 0x0F, 0xC0, 0x0F, 0xC0, 0x00, 0x00, 0x00, 0x1F, + 0x00, 0x00, 0xF8, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x00, 0x1C, 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x18, 0x00, + 0x00, 0x00, 0x06, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x01, 0x80, 0x00, + 0x1C, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x1C, 0x1C, 0x00, 0x00, 0x38, + 0x38, 0x00, 0x0C, 0x3C, 0x00, 0x00, 0x0F, 0x0C, 0x00, 0x0E, 0x38, 0x00, + 0x00, 0x01, 0xC7, 0x00, 0x06, 0x38, 0x00, 0x00, 0x00, 0x71, 0x80, 0x07, + 0x18, 0x00, 0x00, 0x00, 0x18, 0xE0, 0x03, 0x08, 0x00, 0x00, 0x00, 0x04, + 0x30, 0x01, 0x80, 0x3E, 0x00, 0x07, 0xC0, 0x18, 0x00, 0xC0, 0x71, 0xC0, + 0x0C, 0x38, 0x0C, 0x00, 0xC0, 0x60, 0x30, 0x0C, 0x06, 0x03, 0x00, 0x60, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x80, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xC0, 0x1F, 0xC0, 0x00, 0x00, 0x00, 0x0F, 0xE0, 0x1F, 0x60, 0x00, 0x00, + 0x00, 0x06, 0xF8, 0x3C, 0x30, 0x00, 0x00, 0x00, 0x03, 0x0E, 0x38, 0x10, + 0x00, 0x00, 0x00, 0x00, 0x83, 0x98, 0x18, 0x00, 0x00, 0x00, 0x00, 0x40, + 0xD8, 0x0C, 0x60, 0x00, 0x00, 0x06, 0x30, 0x3C, 0x04, 0x6F, 0xC0, 0x00, + 0xFF, 0x98, 0x1E, 0x02, 0x30, 0x1F, 0xFF, 0xE0, 0xC4, 0x0F, 0x03, 0x1C, + 0x00, 0x00, 0x00, 0xE2, 0x07, 0x81, 0x07, 0xE0, 0x00, 0x07, 0xE1, 0x83, + 0x61, 0x83, 0xFF, 0xFF, 0xFF, 0xF0, 0x63, 0x1F, 0xC0, 0xFF, 0xFF, 0xFF, + 0xF0, 0x3F, 0x86, 0x30, 0x3F, 0xFF, 0xFF, 0xF0, 0x32, 0x00, 0x18, 0x0F, + 0xFF, 0xFF, 0xF0, 0x18, 0x00, 0x06, 0x03, 0xFC, 0x0F, 0xF0, 0x18, 0x00, + 0x03, 0x80, 0x78, 0x01, 0xE0, 0x1C, 0x00, 0x00, 0xE0, 0x0E, 0x01, 0xC0, + 0x1C, 0x00, 0x00, 0x30, 0x00, 0xFF, 0x00, 0x0C, 0x00, 0x00, 0x0E, 0x00, + 0x00, 0x00, 0x1C, 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0x1C, 0x00, 0x00, + 0x00, 0xE0, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, + 0x00, 0x00, 0x00, 0x07, 0x80, 0x00, 0x78, 0x00, 0x00, 0x00, 0x01, 0xF8, + 0x01, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0xE0, 0x00, 0x00, 0x00, + 0x00, 0x01, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x02, 0x0C, 0x00, 0x00, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x00, 0x1F, 0x03, 0x80, 0x00, 0x00, + 0x1F, 0x3F, 0x03, 0x00, 0x00, 0x00, 0x7F, 0xC3, 0x00, 0x07, 0x80, 0x00, + 0xC7, 0x83, 0x00, 0x1F, 0x80, 0x03, 0x07, 0x03, 0x00, 0x63, 0x00, 0x06, + 0x07, 0x03, 0x01, 0xC6, 0x00, 0x06, 0x07, 0x03, 0x03, 0x0C, 0x00, 0x06, + 0x07, 0x03, 0x06, 0x18, 0x00, 0x0E, 0x07, 0x03, 0x0C, 0x30, 0x00, 0x7E, + 0x07, 0x03, 0x18, 0x60, 0x00, 0xC6, 0x07, 0x03, 0x30, 0xC0, 0x03, 0x06, + 0x07, 0x03, 0x61, 0x80, 0x06, 0x06, 0x07, 0x03, 0xC1, 0x80, 0x0E, 0x06, + 0x07, 0x03, 0x83, 0x00, 0x0E, 0x06, 0x07, 0x03, 0x06, 0x00, 0x0E, 0x06, + 0x06, 0x06, 0x06, 0x00, 0x3E, 0x06, 0x00, 0x18, 0x0C, 0x00, 0xFE, 0x06, + 0x00, 0x30, 0x18, 0x03, 0x8E, 0x06, 0x00, 0x40, 0x18, 0x06, 0x0E, 0x06, + 0x01, 0x80, 0x30, 0x0C, 0x0E, 0x00, 0x03, 0x00, 0x30, 0x1C, 0x0E, 0x00, + 0x06, 0x00, 0x60, 0x1C, 0x0E, 0x00, 0x0C, 0x00, 0x60, 0x1C, 0x0E, 0x00, + 0x18, 0x00, 0xC0, 0x1C, 0x08, 0x00, 0x30, 0x01, 0x80, 0x1C, 0x00, 0x00, + 0x30, 0x03, 0x0C, 0x1C, 0x00, 0x00, 0x60, 0x02, 0x0C, 0x1C, 0x00, 0x00, + 0x60, 0x0F, 0x0E, 0x1C, 0x00, 0x00, 0xC0, 0x1B, 0x0C, 0x1C, 0x00, 0x00, + 0x00, 0x36, 0x00, 0x1C, 0x00, 0x00, 0x00, 0x67, 0x00, 0x1C, 0x00, 0x00, + 0x00, 0xC7, 0x80, 0x1C, 0x00, 0x00, 0x03, 0x07, 0x00, 0x1C, 0x00, 0x00, + 0x06, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00, 0x00, 0x1C, 0x00, 0x00, + 0x30, 0x00, 0x00, 0x0C, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x0E, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x0F, 0x00, 0x1C, 0x00, 0x00, 0x00, 0x07, 0x80, 0xF0, + 0x00, 0x00, 0x00, 0x03, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00, + 0x00, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x00, 0x00, 0x01, 0xB0, 0x00, 0x00, 0x01, 0x80, 0x06, 0x60, 0x00, 0x00, + 0x07, 0x80, 0x1C, 0xC0, 0x00, 0x00, 0x0F, 0xC0, 0x31, 0x80, 0x00, 0x00, + 0x19, 0xC0, 0xC3, 0x00, 0x00, 0x00, 0x11, 0xE1, 0x82, 0x00, 0x00, 0x00, + 0x30, 0xE6, 0x06, 0x00, 0x00, 0x00, 0x60, 0xFF, 0xCC, 0x00, 0x00, 0x00, + 0xC1, 0xFF, 0xF8, 0x3F, 0x80, 0x01, 0x8F, 0x80, 0xFF, 0xFF, 0x00, 0x01, + 0xBC, 0x00, 0x7E, 0x06, 0x00, 0x03, 0xE0, 0x00, 0x38, 0x18, 0x00, 0x07, + 0x80, 0x00, 0x38, 0x70, 0x00, 0x0E, 0x00, 0x00, 0x39, 0xC0, 0x01, 0xF8, + 0x00, 0x00, 0x33, 0x00, 0xFF, 0xF0, 0x00, 0x00, 0x7C, 0x03, 0xF0, 0xC0, + 0x00, 0x00, 0x78, 0x06, 0x03, 0x80, 0x00, 0x00, 0xE0, 0x0E, 0x06, 0x00, + 0x00, 0x00, 0xC0, 0x0F, 0x0C, 0x00, 0x00, 0x01, 0xE0, 0x07, 0x18, 0x00, + 0x00, 0x03, 0xE0, 0x07, 0xB0, 0x00, 0x00, 0x06, 0xF0, 0x03, 0xE0, 0x00, + 0x00, 0x0C, 0x70, 0x01, 0xC0, 0x00, 0x00, 0x18, 0x78, 0x01, 0x80, 0x00, + 0x00, 0x30, 0x38, 0x03, 0x80, 0x00, 0x00, 0xC0, 0x30, 0x0F, 0x00, 0x00, + 0x01, 0x8F, 0xE0, 0x3F, 0x00, 0x00, 0x07, 0xFF, 0x00, 0x66, 0x00, 0x00, + 0x0F, 0xC0, 0x01, 0x8E, 0x00, 0x00, 0x38, 0x00, 0x07, 0x0E, 0x00, 0x00, + 0xF0, 0x00, 0x0C, 0x0E, 0x00, 0x03, 0xE0, 0x00, 0x30, 0x3F, 0x00, 0x1E, + 0xC0, 0x00, 0x6F, 0xFF, 0x80, 0xF8, 0x80, 0x00, 0xFE, 0x0F, 0xFF, 0xC1, + 0x80, 0x00, 0xC0, 0x19, 0xFF, 0x83, 0x00, 0x00, 0x00, 0x30, 0x33, 0x86, + 0x00, 0x00, 0x00, 0x60, 0xE3, 0xCC, 0x00, 0x00, 0x00, 0x41, 0x81, 0xCC, + 0x00, 0x00, 0x00, 0xC6, 0x01, 0xF8, 0x00, 0x00, 0x01, 0x9C, 0x00, 0xF0, + 0x00, 0x00, 0x03, 0x30, 0x00, 0x00, 0x00, 0x00, 0x06, 0xC0, 0x00, 0x00, + 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x7F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x1F, 0x00, + 0x00, 0x00, 0x00, 0xE0, 0x01, 0xC0, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x70, + 0x00, 0x00, 0x00, 0xE0, 0x00, 0x1C, 0x00, 0x00, 0x00, 0x60, 0x00, 0x06, + 0x00, 0x00, 0x00, 0x60, 0x00, 0x01, 0x80, 0x00, 0x00, 0x30, 0x00, 0x00, + 0xC0, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x30, 0x00, 0x07, 0xFE, 0x00, 0x00, + 0x18, 0x00, 0x07, 0x83, 0xC0, 0x00, 0x0C, 0x00, 0x07, 0x00, 0x60, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x18, 0x00, 0x01, 0x00, 0x03, 0x80, 0x06, 0x00, + 0x01, 0x80, 0x01, 0x80, 0x03, 0x00, 0x00, 0xC0, 0x00, 0xC0, 0x01, 0x80, + 0x00, 0x70, 0x07, 0xE0, 0x00, 0x00, 0x00, 0x3E, 0x0F, 0xF0, 0x00, 0x00, + 0x00, 0x01, 0xCF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x66, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, + 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x01, 0x9E, 0x00, 0x00, + 0x00, 0x00, 0x03, 0xC7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, + 0x00, 0x60, 0x00, 0x60, 0x00, 0x70, 0x00, 0x70, 0x00, 0x70, 0x00, 0x78, + 0x00, 0x78, 0x00, 0x78, 0x00, 0x7C, 0x00, 0x7C, 0x00, 0x7C, 0x00, 0x3E, + 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x07, + 0x00, 0x07, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x00, 0xC0, 0x00, 0x00, + 0x00, 0x70, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x78, 0x00, 0xF0, 0x00, 0x00, + 0x00, 0x7C, 0x00, 0xF8, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x7C, 0x00, 0x00, + 0x00, 0x1E, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x07, 0x00, 0x0E, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x07, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF8, + 0x00, 0x00, 0x00, 0x00, 0x0F, 0x80, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x78, + 0x00, 0x70, 0x00, 0x00, 0x00, 0x03, 0x80, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x1C, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x03, 0x80, 0x00, + 0x00, 0x07, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x0C, + 0x00, 0x00, 0x0F, 0xE0, 0x00, 0x00, 0x30, 0x00, 0x00, 0xFF, 0xE0, 0x00, + 0x00, 0xE0, 0x00, 0x07, 0x83, 0xC0, 0x00, 0x01, 0x80, 0x00, 0x38, 0x03, + 0x80, 0x00, 0x06, 0x00, 0x01, 0xC0, 0x07, 0x00, 0x00, 0x18, 0x00, 0x06, + 0x00, 0x0C, 0x00, 0x00, 0x60, 0x00, 0x38, 0x00, 0x38, 0x00, 0x01, 0x80, + 0x00, 0xC0, 0x00, 0x60, 0x00, 0x06, 0x00, 0x03, 0x00, 0x01, 0x80, 0x00, + 0x1C, 0x00, 0x7C, 0x00, 0x00, 0x00, 0x00, 0x7C, 0x03, 0xF0, 0x00, 0x00, + 0x00, 0x00, 0x38, 0x3C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x31, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xB8, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x60, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x19, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x63, 0x80, + 0x00, 0x00, 0x00, 0x00, 0x03, 0x07, 0x81, 0x80, 0x00, 0x00, 0x00, 0x38, + 0x0F, 0xFF, 0x00, 0x00, 0x00, 0x0F, 0xC0, 0x07, 0xF7, 0x00, 0x60, 0x00, + 0x7C, 0x00, 0x00, 0x0F, 0xFF, 0xC0, 0x07, 0x00, 0x00, 0x00, 0x07, 0xF3, + 0xC0, 0x78, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0x03, 0xF8, 0x00, 0x00, 0x00, 0x7F, 0x80, 0x00, 0x1F, 0xE0, 0x00, + 0x1F, 0xFE, 0x00, 0x07, 0xFF, 0x80, 0x07, 0xCF, 0xF8, 0x01, 0xF3, 0xBE, + 0x00, 0xFC, 0xD9, 0xC0, 0x38, 0x3C, 0xF0, 0x1F, 0xEC, 0xFE, 0x07, 0xE3, + 0x6F, 0x83, 0xB7, 0xC7, 0xB0, 0xDF, 0x33, 0xD8, 0x33, 0x1C, 0x39, 0x99, + 0xBB, 0x1C, 0xC7, 0xF0, 0xC1, 0xD9, 0xD9, 0xF0, 0xCE, 0x6F, 0x0E, 0x1E, + 0xFF, 0x8F, 0x0E, 0x66, 0x70, 0xF1, 0xB6, 0x78, 0x30, 0xF6, 0xC3, 0x8D, + 0x99, 0xE1, 0x83, 0x8D, 0xBC, 0x3C, 0xCD, 0x8E, 0x1C, 0x3C, 0xCF, 0xE3, + 0x6C, 0x78, 0x61, 0xE3, 0x6C, 0x7F, 0x33, 0xC3, 0x87, 0x1B, 0x33, 0xC3, + 0xFB, 0x1C, 0x1C, 0x79, 0x9B, 0x1C, 0x3D, 0xF0, 0xC1, 0xE6, 0xD8, 0xF0, + 0xE3, 0xC7, 0x0E, 0x1F, 0x67, 0x87, 0x0F, 0x3C, 0x30, 0xF1, 0xBE, 0x38, + 0x30, 0xFB, 0x63, 0x8D, 0x98, 0xE1, 0xC3, 0x8D, 0xE6, 0x3C, 0xCF, 0x86, + 0x1E, 0x3E, 0xC6, 0x63, 0x6C, 0x78, 0x71, 0xF3, 0x7C, 0x63, 0x33, 0xC3, + 0x87, 0x99, 0xB3, 0xCC, 0x3B, 0x1C, 0x1C, 0x6D, 0x8F, 0x1C, 0xC1, 0xF0, + 0xE1, 0xE6, 0x78, 0x70, 0xF8, 0x1F, 0x0F, 0x1B, 0x63, 0x83, 0x0F, 0x80, + 0xF8, 0xF9, 0x9E, 0x1C, 0x38, 0xF0, 0x0F, 0xCD, 0xD8, 0xE1, 0xE3, 0xCF, + 0x00, 0x7E, 0xCF, 0x86, 0x1F, 0x36, 0xE0, 0x03, 0x3C, 0x38, 0x71, 0xBB, + 0x3C, 0x00, 0x39, 0xC1, 0x87, 0x99, 0xF1, 0xC0, 0x01, 0xCC, 0x1C, 0x6D, + 0x87, 0x38, 0x00, 0x0C, 0xE1, 0xE6, 0x78, 0x33, 0x00, 0x00, 0x6F, 0x1B, + 0x63, 0x83, 0xE0, 0x00, 0x03, 0xD9, 0x9E, 0x1C, 0x3C, 0x00, 0x00, 0x1C, + 0xF8, 0xE1, 0xE3, 0x80, 0x00, 0x00, 0xC7, 0x87, 0x1F, 0x30, 0x00, 0x00, + 0x06, 0x38, 0x79, 0x9E, 0x00, 0x00, 0x00, 0x31, 0xC7, 0xD8, 0xC0, 0x00, + 0x00, 0x01, 0x9E, 0x6F, 0x98, 0x00, 0x00, 0x00, 0x0D, 0xB6, 0x3B, 0x00, + 0x00, 0x00, 0x00, 0x79, 0xE1, 0xE0, 0x00, 0x00, 0x00, 0x03, 0x8E, 0x1C, + 0x00, 0x00, 0x00, 0x00, 0x0C, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, + 0x60, 0x00, 0x00, 0x00, 0x00, 0x03, 0x7C, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x1F, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7C, 0x00, 0x00, 0x00, 0x00, + 0x03, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x00, 0x00, + 0x3F, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xE0, 0x00, 0x00, 0x00, 0x01, + 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, + 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x0F, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x1F, + 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0x80, 0x00, 0x01, 0xFF, + 0xFF, 0xFF, 0x00, 0x00, 0x0F, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x3F, 0xFF, + 0xFF, 0xFC, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, + 0xFF, 0xF8, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x0F, 0xFF, 0xFF, + 0xFF, 0xF8, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x3F, 0xE3, 0xFF, + 0x1F, 0xF0, 0x00, 0x7F, 0x03, 0xF8, 0x0F, 0xE0, 0x00, 0xFC, 0x03, 0xE0, + 0x0F, 0xE0, 0x07, 0xF0, 0xC3, 0x87, 0x1F, 0xC0, 0x1F, 0xE3, 0xC7, 0x1F, + 0x1F, 0xE0, 0xFF, 0xC7, 0x8E, 0x3E, 0x3F, 0xE1, 0xFF, 0x8F, 0x1C, 0x7C, + 0x7F, 0xE7, 0xFF, 0x0C, 0x38, 0x71, 0xFF, 0xDF, 0xFF, 0x00, 0xF8, 0x03, + 0xFF, 0xFF, 0xFF, 0x03, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0x9F, 0xFC, 0x7F, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFB, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x03, 0xFF, + 0xF7, 0xFF, 0xE0, 0x00, 0x07, 0xFF, 0xEF, 0xFF, 0xC0, 0x00, 0x1F, 0xFF, + 0x8F, 0xFF, 0xC0, 0x00, 0x3F, 0xFF, 0x1F, 0xFF, 0xC0, 0x00, 0xFF, 0xFC, + 0x1F, 0xFF, 0xE0, 0x07, 0xFF, 0xF0, 0x1F, 0xFF, 0xF0, 0x3F, 0xFF, 0xE0, + 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, + 0x0F, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0x00, 0x00, + 0x01, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x3F, 0xFE, 0x00, 0x00, 0x00, + 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x18, 0xE0, 0x00, 0x00, 0x00, + 0x00, 0x20, 0xC0, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xC0, 0x00, 0x00, 0x00, + 0x01, 0x81, 0x80, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, + 0x06, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x06, 0x38, 0x00, 0x00, 0x00, 0x00, + 0x07, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xF8, 0x00, 0x00, 0x00, 0x03, + 0xC0, 0x3C, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x60, + 0x00, 0x0E, 0x00, 0x00, 0x01, 0x80, 0x00, 0x0E, 0x00, 0x00, 0x06, 0x04, + 0x00, 0x0C, 0x00, 0x00, 0x08, 0x38, 0x00, 0x0C, 0x00, 0x00, 0x30, 0xE0, + 0x00, 0x08, 0x00, 0x00, 0x43, 0x00, 0x00, 0x18, 0x00, 0x01, 0x86, 0x00, + 0x00, 0x10, 0x00, 0x03, 0x18, 0x00, 0x00, 0x30, 0x00, 0x04, 0x30, 0x00, + 0x00, 0x60, 0x00, 0x18, 0x40, 0x00, 0x00, 0xC0, 0x00, 0x30, 0x00, 0x00, + 0x00, 0x80, 0x00, 0x60, 0x00, 0x00, 0x01, 0x00, 0x00, 0xC0, 0x00, 0x00, + 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x00, 0x02, 0x00, 0x00, 0x00, + 0x0C, 0x00, 0x04, 0x00, 0x00, 0x00, 0x18, 0x00, 0x18, 0x00, 0x00, 0x00, + 0x30, 0x00, 0x30, 0x00, 0x00, 0x00, 0x20, 0x00, 0x60, 0x00, 0x00, 0x00, + 0x60, 0x00, 0x80, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x3F, 0xFF, 0xFE, 0x00, + 0x80, 0x07, 0xE0, 0x00, 0x00, 0x01, 0x80, 0x18, 0x00, 0x00, 0x00, 0x01, + 0x80, 0x60, 0x00, 0x00, 0x00, 0x03, 0x81, 0x80, 0x00, 0x00, 0x00, 0x03, + 0x86, 0x00, 0x00, 0x00, 0x00, 0x03, 0x18, 0x00, 0xFF, 0xFF, 0xF0, 0x03, + 0x30, 0xFF, 0xFF, 0xFF, 0xFF, 0xC3, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFB, + 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0xFF, 0xFB, + 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xF3, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0x81, + 0xFF, 0xFF, 0x87, 0xFF, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, + 0x03, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x70, 0x00, 0x00, 0x0E, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x00, 0x03, + 0x80, 0x00, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, + 0x00, 0x00, 0x00, 0x00, 0x60, 0x0E, 0x08, 0x00, 0x00, 0x30, 0x70, 0x1C, + 0x3C, 0x00, 0x00, 0x3C, 0x38, 0x18, 0xE0, 0x00, 0x00, 0x0F, 0x18, 0x39, + 0xC0, 0x00, 0x00, 0x03, 0x9C, 0x33, 0x80, 0x00, 0x00, 0x01, 0xCC, 0x73, + 0x00, 0x00, 0x00, 0x00, 0xCC, 0x60, 0x1F, 0x00, 0x00, 0xF8, 0x06, 0x60, + 0x7F, 0xC0, 0x03, 0xFE, 0x06, 0x60, 0xF3, 0xE0, 0x07, 0x8F, 0x06, 0xC0, + 0xF3, 0xA0, 0x07, 0x8F, 0x03, 0xC0, 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, + 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, + 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, + 0x33, 0x80, 0x03, 0x8C, 0x03, 0xC0, 0x33, 0xBF, 0xFB, 0x8C, 0x03, 0xC0, + 0x33, 0xFC, 0x7F, 0x8C, 0x03, 0xC0, 0x33, 0xC0, 0x07, 0x8C, 0x03, 0x60, + 0x33, 0x80, 0x03, 0x8C, 0x06, 0x60, 0x33, 0x80, 0x03, 0x8C, 0x06, 0x60, + 0x33, 0x80, 0x03, 0x8C, 0x06, 0x30, 0x33, 0xFF, 0xFF, 0x8C, 0x0C, 0x30, + 0x33, 0xFF, 0xFF, 0x8C, 0x0C, 0x38, 0x33, 0xFF, 0xFF, 0x8C, 0x18, 0x18, + 0x33, 0xFF, 0xFF, 0x8C, 0x18, 0x0C, 0x33, 0xFF, 0xFF, 0x8C, 0x30, 0x0E, + 0x33, 0xF0, 0x1F, 0x8C, 0x70, 0x06, 0x33, 0x80, 0x03, 0x8C, 0x60, 0x03, + 0x33, 0x80, 0x03, 0x8C, 0xC0, 0x01, 0xB3, 0x80, 0x03, 0x8D, 0x80, 0x07, + 0xF3, 0x80, 0x03, 0x8F, 0xC0, 0x0E, 0x03, 0x80, 0x03, 0x80, 0xF0, 0x0C, + 0x01, 0xE0, 0x0F, 0x00, 0x30, 0x07, 0x00, 0x78, 0x1C, 0x00, 0xE0, 0x03, + 0x00, 0x18, 0x18, 0x00, 0xC0, 0x03, 0x80, 0x3F, 0xFC, 0x03, 0xC0, 0x01, + 0xFF, 0xF7, 0xEF, 0xFF, 0x80, 0x00, 0x7F, 0xC0, 0x03, 0xFE, 0x00, 0x00, + 0x00, 0x1D, 0xC0, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00, + 0x03, 0x33, 0x00, 0x00, 0x00, 0x00, 0x0C, 0xCC, 0x00, 0x00, 0x00, 0x00, + 0x3F, 0xF0, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x06, + 0x33, 0x00, 0x00, 0x00, 0x00, 0x18, 0xC4, 0x00, 0x00, 0x00, 0x00, 0x63, + 0x18, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x07, 0x7B, + 0x80, 0x00, 0x00, 0x00, 0x18, 0xC6, 0x00, 0x00, 0x00, 0x00, 0x43, 0x18, + 0x00, 0x00, 0x00, 0x03, 0x0C, 0x60, 0x00, 0x00, 0x00, 0x0C, 0x30, 0x80, + 0x00, 0x00, 0x00, 0x30, 0xC3, 0x00, 0x00, 0x00, 0x00, 0xC3, 0x0C, 0x00, + 0x00, 0x00, 0x03, 0x0C, 0x30, 0x00, 0x00, 0x00, 0x0C, 0x30, 0xC0, 0x00, + 0x00, 0x00, 0x60, 0xC3, 0x00, 0x00, 0x00, 0x01, 0x83, 0x06, 0x00, 0x00, + 0x00, 0x06, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x30, 0x30, 0x60, 0x00, 0x00, + 0x00, 0xC0, 0xC0, 0x80, 0x00, 0x00, 0x03, 0x03, 0x03, 0x00, 0x00, 0x00, + 0x0C, 0x0C, 0x0C, 0x00, 0x00, 0x00, 0x30, 0x30, 0x30, 0x00, 0x00, 0x01, + 0x80, 0xC0, 0xC0, 0x00, 0x00, 0x36, 0x03, 0x01, 0xA0, 0x00, 0x03, 0xF0, + 0x0C, 0x07, 0xF0, 0x00, 0x3F, 0x80, 0x30, 0x07, 0xF0, 0x03, 0xCC, 0x00, + 0xC0, 0x18, 0xF0, 0x7C, 0x18, 0x03, 0x00, 0x60, 0xF3, 0xC0, 0x60, 0x0C, + 0x01, 0x80, 0xE0, 0x01, 0xC0, 0x30, 0x0C, 0x00, 0x80, 0x03, 0x01, 0xE0, + 0x70, 0x00, 0x00, 0x06, 0x1F, 0xC1, 0x80, 0x00, 0x00, 0x1C, 0x63, 0x8C, + 0x00, 0x00, 0x00, 0x3B, 0x03, 0x70, 0x00, 0x00, 0x00, 0x7C, 0x0F, 0x80, + 0x00, 0x00, 0x00, 0xF0, 0x7C, 0x00, 0x00, 0x00, 0x01, 0xE1, 0xE0, 0x00, + 0x00, 0x00, 0x03, 0x8F, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x1C, 0x00, 0x00, + 0x00, 0x00, 0xE0, 0x38, 0x00, 0x00, 0x00, 0x07, 0x00, 0x38, 0x00, 0x00, + 0x00, 0x38, 0x00, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, + 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, + 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, + 0xE0, 0x00, 0x00, 0x07, 0x00, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, 0x03, + 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, 0x03, 0x00, 0x00, 0x00, 0x60, 0x0E, + 0x0F, 0x00, 0x00, 0x00, 0x70, 0x1C, 0x1C, 0x00, 0x00, 0x00, 0x30, 0x18, + 0x30, 0x00, 0x00, 0x00, 0x18, 0x38, 0x60, 0x00, 0x00, 0x00, 0x1C, 0x30, + 0x40, 0x00, 0x00, 0x18, 0x0C, 0x70, 0x03, 0xC0, 0x00, 0x78, 0x0C, 0x60, + 0x03, 0xC0, 0x00, 0xE0, 0x06, 0x60, 0x07, 0xE0, 0x03, 0xC0, 0x06, 0x60, + 0x07, 0xE0, 0x07, 0x00, 0x06, 0xC0, 0x07, 0xE0, 0x0F, 0xC0, 0x03, 0xC0, + 0x03, 0xC0, 0x07, 0xF8, 0x03, 0xC0, 0x03, 0xC0, 0x00, 0x38, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x1F, 0x83, 0xC0, 0x00, 0x00, 0xE0, 0x3F, 0xC3, 0xC0, + 0x00, 0x00, 0xF0, 0x3F, 0xC0, 0x60, 0x00, 0x00, 0x38, 0x3F, 0xFC, 0x60, + 0x00, 0x00, 0x18, 0x3F, 0xFE, 0x60, 0x00, 0x00, 0x38, 0x3F, 0xFF, 0x70, + 0x00, 0x00, 0x70, 0x3F, 0xFF, 0x30, 0x00, 0x00, 0x70, 0x1F, 0xFF, 0x38, + 0x00, 0x00, 0x18, 0x1F, 0xFF, 0x18, 0x00, 0x00, 0x18, 0x1F, 0xFE, 0x1C, + 0x00, 0x00, 0x38, 0x3F, 0xFC, 0x0E, 0x00, 0x00, 0xF0, 0x3F, 0xF8, 0x07, + 0x00, 0x00, 0xE0, 0x0F, 0xE0, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, + 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, + 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x38, 0x01, 0x80, 0x00, 0x00, + 0x07, 0xE0, 0x06, 0x00, 0x00, 0xC0, 0x3C, 0x00, 0x00, 0x38, 0x03, 0x01, + 0xC0, 0x18, 0x00, 0x60, 0x1C, 0x06, 0x00, 0x60, 0x00, 0xC0, 0x00, 0x38, + 0x00, 0xC0, 0x03, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x0E, 0x00, 0x03, 0x0E, + 0x00, 0x00, 0x19, 0x80, 0x0C, 0xFE, 0x00, 0x00, 0x67, 0x00, 0x37, 0x18, + 0x00, 0x01, 0x84, 0x00, 0xF8, 0x30, 0xF0, 0x06, 0x00, 0x01, 0xC0, 0xC7, + 0xF0, 0x18, 0x00, 0x03, 0x02, 0x38, 0xF0, 0xE0, 0x1C, 0x0F, 0xF8, 0xC0, + 0xF7, 0x00, 0x78, 0x3F, 0xC3, 0x00, 0xF8, 0x00, 0x40, 0x40, 0x0C, 0x00, + 0x00, 0x00, 0x1F, 0x80, 0x18, 0x00, 0x00, 0x01, 0xFF, 0x80, 0x60, 0x60, + 0x00, 0x0F, 0x1F, 0x80, 0xC1, 0x80, 0x00, 0x30, 0x67, 0x03, 0x06, 0x00, + 0x01, 0xC1, 0x8E, 0x38, 0x00, 0x00, 0x06, 0x06, 0x0F, 0xE0, 0x00, 0x00, + 0x18, 0x18, 0x3E, 0x00, 0x07, 0x00, 0xE0, 0xE3, 0xF0, 0x00, 0x3C, 0x03, + 0x83, 0x0C, 0xC1, 0x81, 0xC0, 0x1E, 0x18, 0x71, 0x87, 0x0C, 0x00, 0x78, + 0x61, 0x86, 0x08, 0x30, 0x01, 0xF0, 0x06, 0x18, 0x00, 0x80, 0x0C, 0xC0, + 0x00, 0x30, 0x06, 0x00, 0x33, 0x00, 0x00, 0xC0, 0x18, 0x01, 0xC6, 0x00, + 0x1F, 0xC0, 0x60, 0x07, 0x1C, 0x0F, 0xEF, 0x81, 0x00, 0x1C, 0x38, 0x3E, + 0x37, 0x8C, 0x00, 0xD8, 0x70, 0x00, 0xCF, 0xE0, 0x03, 0x30, 0xE0, 0x06, + 0x0F, 0x00, 0x18, 0xE1, 0xF0, 0x38, 0x00, 0x00, 0x61, 0xC1, 0xFF, 0xC0, + 0x00, 0x73, 0xC3, 0x81, 0xFC, 0x00, 0x01, 0xCF, 0x07, 0x87, 0xC0, 0x00, + 0x00, 0x36, 0x07, 0xFC, 0x00, 0x20, 0x01, 0x9C, 0x0F, 0x80, 0x00, 0xC0, + 0x06, 0x3C, 0xF8, 0x00, 0x03, 0x00, 0x30, 0x3F, 0x00, 0x00, 0x04, 0x0C, + 0xC1, 0xF0, 0x00, 0x00, 0x00, 0x3B, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x6F, + 0xE0, 0x00, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x01, 0xE0, 0x07, 0x80, 0x00, 0x00, + 0x07, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x38, 0x00, 0x00, + 0x30, 0x00, 0x00, 0x0C, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x03, 0x00, 0x01, + 0x80, 0x00, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, + 0x00, 0x00, 0x00, 0x00, 0x60, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x30, 0x0C, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x18, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, + 0x00, 0x00, 0x00, 0x00, 0x18, 0x30, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x30, + 0x03, 0xC0, 0x03, 0xC0, 0x0C, 0x60, 0x03, 0xC0, 0x03, 0xC0, 0x06, 0x60, + 0x07, 0xE0, 0x07, 0xE0, 0x06, 0x60, 0x07, 0xE0, 0x07, 0xE0, 0x06, 0xC0, + 0x07, 0xE0, 0x07, 0xE0, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, + 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, + 0x60, 0x00, 0x00, 0x06, 0x03, 0xC0, 0xDF, 0x80, 0x01, 0xFF, 0x03, 0x60, + 0xC0, 0x7F, 0xFF, 0x83, 0x06, 0x60, 0xE0, 0x00, 0x00, 0x07, 0x06, 0x60, + 0xFE, 0x00, 0x00, 0x7F, 0x06, 0x30, 0x7F, 0xFF, 0xFF, 0xFE, 0x0C, 0x30, + 0x3F, 0xFF, 0xFF, 0xFC, 0x0C, 0x38, 0x1F, 0xFF, 0xFF, 0xF8, 0x18, 0x18, + 0x0F, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xF8, 0x1F, 0xE0, 0x30, 0x0C, + 0x01, 0xE0, 0x07, 0x80, 0x30, 0x06, 0x00, 0x70, 0x0E, 0x00, 0x60, 0x03, + 0x00, 0x0F, 0xF0, 0x00, 0xC0, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, 0x00, + 0xC0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, + 0x1C, 0x00, 0x00, 0x38, 0x00, 0x00, 0x07, 0x00, 0x00, 0xE0, 0x00, 0x00, + 0x01, 0xE0, 0x07, 0x80, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, + 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, + 0xE0, 0x00, 0x00, 0x07, 0x00, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, 0x03, + 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, 0x00, 0x00, 0x00, 0x00, 0x60, 0x0E, + 0x00, 0x00, 0x00, 0x00, 0x70, 0x1C, 0x03, 0x00, 0x01, 0x80, 0x30, 0x18, + 0x07, 0x00, 0x00, 0xE0, 0x18, 0x38, 0x3C, 0x00, 0x00, 0x7C, 0x1C, 0x30, + 0xF8, 0x00, 0x00, 0x1F, 0x0C, 0x70, 0x40, 0x00, 0x00, 0x02, 0x0C, 0x60, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, + 0x0F, 0xC0, 0x03, 0xF0, 0x06, 0xC0, 0x11, 0xE0, 0x06, 0x38, 0x03, 0xC0, + 0x60, 0xF8, 0x18, 0x1E, 0x03, 0xC0, 0x40, 0xF8, 0x10, 0x1E, 0x03, 0xC0, + 0xC0, 0xFC, 0x30, 0x1F, 0x03, 0xC0, 0xC1, 0xFC, 0x30, 0x3F, 0x03, 0xC0, + 0xE3, 0xFC, 0x38, 0xFF, 0x03, 0xC0, 0xFF, 0x3C, 0x3F, 0xCF, 0x03, 0xC0, + 0xFF, 0x3C, 0x3F, 0xCF, 0x03, 0xC0, 0x7F, 0xF8, 0x1F, 0xFE, 0x03, 0xC0, + 0x7F, 0xF8, 0x1F, 0xFE, 0x03, 0x60, 0x3F, 0xF0, 0x0F, 0xFC, 0x06, 0x60, + 0x0F, 0xC0, 0x03, 0xF0, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x70, + 0x00, 0x00, 0x00, 0x00, 0x0C, 0x30, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x38, + 0x00, 0x00, 0x00, 0x00, 0x1C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x18, 0x1C, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x0E, 0x00, 0x07, 0xE0, 0x00, 0x70, 0x06, + 0x00, 0x1F, 0xF0, 0x00, 0x60, 0x03, 0x00, 0x08, 0x10, 0x00, 0xC0, 0x03, + 0x80, 0x00, 0x00, 0x01, 0x80, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x00, 0x00, + 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, + 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xE0, 0x00, 0x00, + 0x00, 0x1F, 0x80, 0x1F, 0x00, 0x60, 0x00, 0x0F, 0x80, 0x00, 0x78, 0x0E, + 0x00, 0x03, 0xC0, 0x00, 0x03, 0xC3, 0xC0, 0x00, 0xE0, 0x00, 0x00, 0x1C, + 0xCC, 0x00, 0x38, 0x00, 0x00, 0x01, 0xF8, 0xC0, 0x1C, 0x00, 0x00, 0x00, + 0x1E, 0x1C, 0x03, 0x00, 0x00, 0x00, 0x01, 0x81, 0x80, 0xC0, 0x00, 0x00, + 0x00, 0x70, 0x38, 0x38, 0x00, 0x00, 0x00, 0x0C, 0x03, 0x0E, 0x00, 0x00, + 0x00, 0x03, 0x80, 0x71, 0x80, 0x00, 0x00, 0x00, 0x60, 0x06, 0x70, 0x00, + 0x00, 0x00, 0x0C, 0x00, 0xCC, 0x00, 0x00, 0x00, 0x01, 0x80, 0x1B, 0x80, + 0x00, 0x00, 0x00, 0x30, 0x03, 0x60, 0x00, 0x00, 0x00, 0x06, 0x00, 0x6C, + 0x00, 0x00, 0x00, 0x20, 0x60, 0x19, 0x80, 0x1F, 0xC0, 0x3F, 0x86, 0x06, + 0x60, 0x06, 0x1C, 0x0E, 0x38, 0x3F, 0x8C, 0x00, 0x81, 0x81, 0x01, 0x00, + 0x31, 0x80, 0x00, 0x00, 0x00, 0x00, 0x06, 0x30, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xC6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0x03, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x63, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0C, 0x60, 0x30, 0x00, 0x00, 0x07, 0x01, 0x8C, 0x0D, 0xF8, + 0x00, 0x1F, 0xE0, 0x70, 0xC1, 0x80, 0xFF, 0xFE, 0x06, 0x0C, 0x18, 0x38, + 0x00, 0x00, 0x01, 0x81, 0x83, 0x07, 0xF0, 0x00, 0x03, 0xF0, 0x30, 0x70, + 0x7F, 0xFF, 0xFF, 0xFC, 0x0C, 0x06, 0x07, 0xFF, 0xFF, 0xFF, 0x81, 0x80, + 0xE0, 0x7F, 0xFF, 0xFF, 0xE0, 0x70, 0x0C, 0x07, 0xFF, 0xFF, 0xF8, 0x0C, + 0x01, 0xC0, 0x7F, 0x81, 0xFE, 0x03, 0x00, 0x1C, 0x07, 0xC0, 0x0F, 0x00, + 0xE0, 0x01, 0x80, 0x1C, 0x03, 0x80, 0x38, 0x00, 0x18, 0x00, 0xFF, 0x80, + 0x0E, 0x00, 0x01, 0x80, 0x00, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, + 0x00, 0xE0, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x38, 0x00, 0x00, 0x1E, 0x00, + 0x00, 0x1E, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x0F, + 0xC0, 0x0F, 0xC0, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x00, 0x0F, + 0xE0, 0x00, 0x00, 0x00, 0x7F, 0x80, 0x00, 0x00, 0x01, 0xFE, 0x00, 0x00, + 0x00, 0x0F, 0xF8, 0x00, 0x00, 0x00, 0x7F, 0xE0, 0x00, 0x00, 0x03, 0xFF, + 0x00, 0x00, 0x00, 0x1F, 0xFC, 0x00, 0x00, 0x00, 0xFF, 0xE0, 0x00, 0x00, + 0x07, 0xFF, 0x00, 0x00, 0x00, 0x7F, 0xF8, 0x00, 0x00, 0x03, 0xFF, 0xE0, + 0x00, 0x00, 0x3F, 0xFF, 0x00, 0x00, 0x01, 0xFF, 0xF8, 0x00, 0x08, 0x1F, + 0xFF, 0xC0, 0x00, 0xC1, 0xFF, 0xBE, 0x00, 0x0C, 0x1F, 0xF3, 0xF0, 0x00, + 0xE0, 0xFF, 0x1F, 0x80, 0x0F, 0x0F, 0xF1, 0xF8, 0x00, 0x78, 0x7F, 0x0F, + 0xC0, 0x07, 0xE7, 0xF0, 0x7E, 0x00, 0x3F, 0x3F, 0x03, 0xF0, 0x01, 0xF9, + 0xF0, 0x1F, 0x81, 0x1F, 0xEF, 0x80, 0xFE, 0x08, 0xFF, 0xF8, 0x07, 0xFD, + 0xE7, 0xFF, 0xC0, 0x3F, 0xFF, 0xBF, 0xFE, 0x00, 0xFF, 0xFD, 0xFF, 0xF0, + 0x07, 0xFF, 0xEF, 0xFF, 0x80, 0x1F, 0xFF, 0x7F, 0xFC, 0x00, 0x7F, 0xFF, + 0xFF, 0xF0, 0x01, 0xFF, 0xFF, 0xFB, 0x80, 0x07, 0xFF, 0xFF, 0xCE, 0x00, + 0x1F, 0xFB, 0xFC, 0x20, 0x00, 0xFF, 0x9F, 0xE0, 0x00, 0x03, 0xFC, 0xFF, + 0x00, 0x00, 0x1F, 0xE3, 0xF8, 0x00, 0x00, 0xFF, 0x1F, 0xC0, 0x00, 0x07, + 0xF0, 0x7E, 0x00, 0x00, 0x3F, 0x83, 0xF0, 0x00, 0x01, 0xF8, 0x0F, 0xC0, + 0x00, 0x1F, 0x80, 0x3E, 0x00, 0x00, 0xF8, 0x00, 0xF8, 0x00, 0x0F, 0x80, + 0x03, 0xF0, 0x01, 0xF8, 0x00, 0x07, 0xE0, 0x3F, 0x00, 0x00, 0x0F, 0xFF, + 0xE0, 0x00, 0x00, 0x0F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF8, 0x00, + 0x00, 0x00, 0x00, 0xFC, 0x1F, 0xC0, 0x00, 0x00, 0x00, 0x70, 0x00, 0x38, + 0x00, 0x00, 0x00, 0x70, 0x00, 0x07, 0x00, 0x00, 0x00, 0x38, 0x00, 0x00, + 0x60, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x06, 0x00, 0x00, + 0x01, 0x80, 0x00, 0x03, 0x00, 0x10, 0x00, 0x30, 0x00, 0x01, 0x80, 0x3F, + 0x80, 0x0E, 0x00, 0x00, 0x60, 0x1C, 0x70, 0x01, 0x80, 0x00, 0x30, 0x0E, + 0x06, 0x00, 0x30, 0x00, 0x18, 0x03, 0x00, 0xC0, 0x0E, 0x00, 0x06, 0x00, + 0x80, 0x37, 0x81, 0x80, 0x03, 0x00, 0x60, 0x07, 0x60, 0x30, 0x00, 0xC1, + 0xF8, 0x01, 0x98, 0x0C, 0x00, 0x60, 0x1E, 0x00, 0x46, 0x01, 0x80, 0x18, + 0x00, 0x80, 0x01, 0x80, 0x30, 0x0C, 0x00, 0x30, 0x00, 0xC0, 0x0E, 0x03, + 0x00, 0x0C, 0x00, 0x30, 0x01, 0x81, 0x80, 0x01, 0x80, 0x0C, 0x00, 0x30, + 0x60, 0x00, 0x70, 0x03, 0x00, 0x06, 0x18, 0x00, 0x0E, 0x00, 0x60, 0x01, + 0x8C, 0x00, 0x0F, 0xC0, 0x18, 0x00, 0x33, 0x00, 0x0F, 0xF0, 0x03, 0x00, + 0x0C, 0xC0, 0x01, 0x06, 0x00, 0x60, 0x01, 0xB0, 0x00, 0x01, 0x80, 0x0C, + 0x00, 0x6C, 0x00, 0x00, 0x20, 0x01, 0xC0, 0x0B, 0x00, 0x00, 0x0C, 0x00, + 0x38, 0x03, 0xC0, 0x00, 0x01, 0x80, 0x06, 0x00, 0xF0, 0x00, 0x00, 0x60, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x0D, 0x80, 0x00, 0x03, + 0x80, 0x00, 0x03, 0x60, 0x00, 0x00, 0x70, 0x00, 0x00, 0x9C, 0x00, 0x00, + 0x0E, 0x00, 0x00, 0x63, 0x80, 0x00, 0x01, 0xC0, 0x00, 0x18, 0x70, 0x00, + 0x00, 0x38, 0x00, 0x0C, 0x0F, 0x00, 0x00, 0x07, 0x00, 0x07, 0x00, 0xF8, + 0x00, 0x00, 0x70, 0x03, 0x80, 0x0F, 0xFF, 0x7F, 0xFF, 0xFF, 0xC0, 0x00, + 0x7F, 0xFF, 0xF0, 0xFF, 0xC0, 0x00, 0x00, 0x03, 0xFF, 0xC0, 0x00, 0x00, + 0x00, 0x00, 0x0F, 0xC3, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x38, + 0x00, 0x00, 0x00, 0x00, 0x70, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0xE0, + 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0xC0, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x01, 0x80, 0x00, 0x01, 0x80, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0xC0, + 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x70, 0x0C, 0x00, 0x00, + 0x00, 0x60, 0x1C, 0xFC, 0x0C, 0x00, 0x00, 0x00, 0x30, 0x3E, 0xC6, 0x18, + 0x04, 0x00, 0x60, 0x18, 0x63, 0xC6, 0x38, 0x0E, 0x00, 0xF0, 0x18, 0xC3, + 0xC3, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x83, 0xC1, 0xF0, 0x0F, 0x00, 0xF0, + 0x0F, 0x83, 0x60, 0x30, 0x0E, 0x00, 0x60, 0x18, 0x06, 0x60, 0x18, 0x00, + 0x00, 0x00, 0x18, 0x0E, 0x30, 0x0C, 0x00, 0x00, 0x00, 0x30, 0x0C, 0x38, + 0x0C, 0x00, 0x38, 0x00, 0x60, 0x18, 0x1C, 0x06, 0x00, 0x3C, 0x00, 0x60, + 0x30, 0x0C, 0x06, 0x00, 0x7C, 0x00, 0xC0, 0x70, 0x06, 0x03, 0x00, 0x38, + 0x00, 0xC0, 0x60, 0x07, 0x03, 0x00, 0x00, 0x01, 0x80, 0xE0, 0x07, 0x01, + 0x80, 0x00, 0x01, 0x81, 0xE0, 0x0D, 0x81, 0x80, 0x00, 0x01, 0x81, 0xA0, + 0x0D, 0x81, 0x80, 0x00, 0x03, 0x03, 0x30, 0x08, 0xC0, 0xC0, 0x00, 0x03, + 0x03, 0x30, 0x18, 0xC0, 0xC0, 0x00, 0x03, 0x03, 0x10, 0x18, 0xC0, 0xC0, + 0x00, 0x03, 0x02, 0x10, 0x18, 0xC0, 0xC0, 0x00, 0x02, 0x02, 0x18, 0x18, + 0x00, 0xC0, 0x00, 0x02, 0x00, 0x18, 0x18, 0x00, 0xC0, 0x00, 0x06, 0x00, + 0x18, 0x18, 0x00, 0xC0, 0x00, 0x02, 0x00, 0x18, 0x18, 0x00, 0xC0, 0x00, + 0x02, 0x00, 0x18, 0x18, 0x00, 0xC0, 0x00, 0x03, 0x00, 0x10, 0x08, 0x00, + 0xC0, 0x00, 0x03, 0x00, 0x30, 0x0C, 0x01, 0xFF, 0xFF, 0xFF, 0x00, 0x70, + 0x06, 0x03, 0xFF, 0xFF, 0xFF, 0x80, 0xE0, 0x03, 0xFF, 0x00, 0x00, 0x00, + 0xFF, 0xC0, 0x01, 0xFE, 0x00, 0x00, 0x00, 0x7F, 0x80, 0x00, 0x00, 0x1F, + 0xF8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, 0xE0, + 0x07, 0xE0, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x3C, 0x00, + 0x00, 0x3C, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, 0xE0, 0x00, + 0x00, 0x07, 0x00, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, + 0x00, 0x00, 0xC0, 0x06, 0x00, 0x00, 0x00, 0x00, 0x60, 0x0E, 0x00, 0x00, + 0x00, 0x00, 0x70, 0x1C, 0x00, 0x00, 0x00, 0x00, 0x30, 0x18, 0x00, 0x00, + 0x00, 0x00, 0x18, 0x38, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x30, 0x00, 0x00, + 0x00, 0x00, 0x0C, 0x70, 0x0F, 0xC0, 0x03, 0xF0, 0x0C, 0x60, 0x3F, 0xF0, + 0x0F, 0xFC, 0x06, 0x60, 0x6F, 0xD8, 0x1B, 0xF6, 0x06, 0x60, 0x6F, 0xD8, + 0x1B, 0xF6, 0x06, 0xC0, 0xC7, 0x8C, 0x31, 0xE3, 0x03, 0xC0, 0xC0, 0x0C, + 0x30, 0x03, 0x03, 0xC0, 0xC0, 0x0C, 0x30, 0x03, 0x03, 0xC0, 0xC0, 0x0C, + 0x30, 0x03, 0x03, 0xC0, 0xE0, 0x1C, 0x38, 0x07, 0x03, 0xC0, 0x60, 0x18, + 0x18, 0x06, 0x03, 0xC0, 0x38, 0x70, 0x0E, 0x1C, 0x03, 0xC0, 0x1F, 0xE0, + 0x07, 0xF8, 0x03, 0xC0, 0x0F, 0xC0, 0x03, 0xF0, 0x03, 0xC0, 0x00, 0x00, + 0x00, 0x00, 0x03, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, + 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x70, 0x00, 0x00, + 0x00, 0x00, 0x0C, 0x30, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x38, 0x00, 0x00, + 0x00, 0x00, 0x1C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x18, 0x1C, 0x00, 0x00, + 0x00, 0x00, 0x30, 0x0E, 0x00, 0x3F, 0xFC, 0x00, 0x70, 0x06, 0x00, 0x3F, + 0xFC, 0x00, 0x60, 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x80, 0x00, + 0x00, 0x01, 0x80, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x00, 0x00, 0x70, 0x00, + 0x00, 0x0E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x1F, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x01, 0xFF, + 0xFF, 0x80, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x01, + 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xFF, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0x7E, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x0F, 0x80, + 0x00, 0x00, 0x03, 0xC0, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x07, 0x00, 0x00, + 0x00, 0xE0, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x38, + 0x00, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x0C, 0x00, + 0x00, 0x60, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x07, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x78, 0x03, 0x80, 0x01, 0x80, 0x00, + 0x01, 0x8C, 0x01, 0x80, 0x03, 0x80, 0x00, 0x01, 0x06, 0x01, 0xC0, 0x03, + 0x00, 0x7C, 0x03, 0x00, 0x00, 0xC0, 0x03, 0x00, 0xC7, 0x03, 0x00, 0x00, + 0xC0, 0x06, 0x01, 0x83, 0x00, 0x00, 0x00, 0x60, 0x06, 0x01, 0x80, 0x00, + 0x00, 0x00, 0x60, 0x06, 0x01, 0x80, 0x00, 0x00, 0x60, 0x60, 0x0C, 0x00, + 0x00, 0x00, 0x00, 0x60, 0x70, 0x0C, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x30, + 0x0C, 0x00, 0x00, 0x00, 0x01, 0x80, 0x30, 0x0C, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x30, 0x0C, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x30, 0x0C, 0x02, 0x00, + 0x00, 0x1C, 0x00, 0x30, 0x0C, 0x03, 0xC0, 0x00, 0x70, 0x00, 0x30, 0x0C, + 0x00, 0xF8, 0x03, 0xC0, 0x00, 0x30, 0x0C, 0x00, 0x1F, 0xFF, 0x00, 0x00, + 0x30, 0x0D, 0xC0, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x1E, 0x60, 0x00, 0x00, + 0x00, 0x04, 0xF8, 0x3E, 0x31, 0xC0, 0x00, 0x03, 0x88, 0x7C, 0x22, 0x1A, + 0x60, 0x00, 0x06, 0x58, 0xC4, 0x23, 0x0E, 0x30, 0x00, 0x0C, 0x70, 0xC4, + 0x31, 0x8C, 0x30, 0x00, 0x0C, 0x61, 0x8C, 0x70, 0x84, 0x30, 0x00, 0x0C, + 0x63, 0x0E, 0xC8, 0xC2, 0x30, 0x00, 0x0C, 0x42, 0x1B, 0xC4, 0x60, 0x18, + 0x00, 0x18, 0x04, 0x23, 0xE6, 0x20, 0x18, 0x00, 0x18, 0x04, 0x47, 0x63, + 0x00, 0x18, 0x00, 0x18, 0x00, 0x86, 0x71, 0x80, 0x0C, 0x00, 0x30, 0x01, + 0x0E, 0xCC, 0x00, 0x0C, 0x00, 0x30, 0x00, 0x3B, 0xC6, 0x00, 0x0C, 0x00, + 0x30, 0x00, 0x63, 0xC2, 0x00, 0x0C, 0x00, 0x30, 0x00, 0x43, 0x60, 0x00, + 0x0C, 0x00, 0x30, 0x00, 0x06, 0x30, 0x00, 0x18, 0x00, 0x18, 0x00, 0x0C, + 0x1C, 0x00, 0x18, 0x00, 0x18, 0x00, 0x38, 0x07, 0x00, 0x3F, 0xC3, 0xFC, + 0x00, 0xE0, 0x01, 0xC0, 0xE7, 0xFF, 0xE7, 0x03, 0x80, 0x00, 0x3F, 0x80, + 0x00, 0x01, 0xFC, 0x00, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x1F, + 0x00, 0x00, 0xF8, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x70, + 0x00, 0x00, 0x0E, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x07, 0x00, 0x01, 0x80, + 0x00, 0x00, 0x01, 0x80, 0x03, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x06, 0x00, + 0x00, 0x00, 0x00, 0x60, 0x0E, 0x03, 0x80, 0x00, 0x00, 0x70, 0x1C, 0x0F, + 0x80, 0x00, 0x00, 0x30, 0x18, 0x1C, 0x00, 0x00, 0x00, 0x18, 0x38, 0x38, + 0x00, 0x00, 0x00, 0x1C, 0x30, 0x70, 0x00, 0x00, 0x00, 0x0C, 0x70, 0x60, + 0x00, 0x00, 0x00, 0x0C, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, + 0x00, 0x00, 0x18, 0x06, 0x60, 0x03, 0xC0, 0x00, 0x78, 0x06, 0xC0, 0x03, + 0xC0, 0x00, 0xE0, 0x03, 0xC0, 0x07, 0xE0, 0x03, 0xC0, 0x03, 0xC0, 0x07, + 0xE0, 0x07, 0x00, 0x03, 0xC0, 0x07, 0xE0, 0x0F, 0xC0, 0x03, 0xC0, 0x03, + 0xC0, 0x07, 0xF8, 0x03, 0xC0, 0x03, 0x80, 0x00, 0x38, 0x03, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0x60, 0x00, + 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x03, + 0x00, 0x00, 0x40, 0x06, 0x70, 0x03, 0xC0, 0x01, 0xC0, 0x0C, 0x30, 0x01, + 0xF0, 0x0F, 0x80, 0x0C, 0x38, 0x00, 0x7F, 0xFE, 0x00, 0x1C, 0x18, 0x00, + 0x0F, 0xF0, 0x00, 0x18, 0x1C, 0x00, 0x00, 0x00, 0x00, 0x30, 0x0E, 0x00, + 0x00, 0x00, 0x00, 0x70, 0x06, 0x00, 0x00, 0x00, 0x00, 0x60, 0x03, 0x00, + 0x00, 0x00, 0x00, 0xC0, 0x03, 0x80, 0x00, 0x00, 0x01, 0x80, 0x01, 0xE0, + 0x00, 0x00, 0x07, 0x00, 0x00, 0x70, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x3C, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, + 0xE0, 0x07, 0xE0, 0x00, 0x00, 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, + 0x1F, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x7F, 0xEF, 0xF8, 0x00, 0x00, 0x00, + 0xFB, 0xC0, 0x1F, 0x00, 0x00, 0x00, 0xF0, 0x70, 0x03, 0xC0, 0x00, 0x01, + 0xE0, 0x08, 0x00, 0x78, 0x00, 0x01, 0xC0, 0x00, 0x3F, 0x8E, 0x00, 0x01, + 0xC1, 0xE0, 0x1F, 0xF3, 0x80, 0x01, 0xC0, 0xF0, 0x00, 0x3C, 0xE0, 0x01, + 0xC0, 0xFC, 0x00, 0x00, 0x38, 0x01, 0xC0, 0x7E, 0x00, 0x00, 0x0E, 0x00, + 0xC0, 0x3F, 0x00, 0x78, 0x03, 0x00, 0xE0, 0x1F, 0x00, 0x3C, 0x01, 0xC0, + 0x60, 0x07, 0x80, 0x3F, 0x00, 0x60, 0x60, 0x00, 0x00, 0x1F, 0x80, 0x18, + 0x30, 0x00, 0x00, 0x0F, 0xC0, 0x0C, 0x38, 0x00, 0x00, 0x03, 0xC0, 0x03, + 0x18, 0x00, 0x00, 0x01, 0xE0, 0x01, 0x8C, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xCE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x76, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x1B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0D, 0x80, 0x00, 0x7F, 0x00, 0x00, + 0x06, 0xC0, 0x01, 0xFF, 0xE0, 0x00, 0x03, 0x60, 0x00, 0xE0, 0x3C, 0x00, + 0x01, 0xB0, 0x00, 0x00, 0x07, 0x00, 0x00, 0xD8, 0x00, 0x00, 0x01, 0x80, + 0x00, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x37, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0x1E, 0x30, 0x01, 0xFC, 0x00, 0x00, 0x1B, 0x0C, 0x07, 0x03, 0x00, + 0x00, 0x0D, 0x86, 0x0C, 0x01, 0x80, 0x00, 0x06, 0x61, 0x98, 0x03, 0xC0, + 0x00, 0x03, 0x30, 0x70, 0x0F, 0xC0, 0x00, 0x03, 0x08, 0x00, 0x1F, 0x80, + 0x00, 0x01, 0x86, 0x00, 0x1E, 0x00, 0x00, 0x01, 0x83, 0x00, 0x3E, 0x00, + 0x00, 0x01, 0xC1, 0x80, 0x1B, 0x00, 0x00, 0x00, 0xC0, 0xC0, 0x08, 0x80, + 0x00, 0x00, 0xC0, 0x60, 0x00, 0x40, 0x00, 0x00, 0xE0, 0x30, 0x00, 0xF0, + 0x00, 0x00, 0xE0, 0x18, 0x00, 0xD8, 0x00, 0x00, 0xE0, 0x0C, 0x00, 0x0C, + 0x00, 0x00, 0xE0, 0x06, 0x00, 0x0C, 0x00, 0x01, 0xE0, 0x01, 0x00, 0x0F, + 0x00, 0x03, 0xC0, 0x00, 0xC0, 0x01, 0x80, 0x07, 0x80, 0x00, 0x30, 0x01, + 0xFE, 0xFF, 0x00, 0x00, 0x0E, 0x03, 0xBF, 0xFC, 0x00, 0x00, 0x01, 0xFE, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF7, 0xF0, 0x00, 0x00, 0x00, + 0x03, 0x9C, 0xC8, 0x00, 0x00, 0x00, 0x0E, 0x19, 0x8E, 0x00, 0x00, 0x00, + 0x78, 0x33, 0x9F, 0xC0, 0x00, 0x03, 0xC0, 0x67, 0x3D, 0xF0, 0x00, 0x0E, + 0x01, 0xCC, 0x6C, 0x3C, 0x00, 0x18, 0x07, 0x38, 0xC8, 0x0E, 0x00, 0x30, + 0x04, 0x63, 0x10, 0x03, 0x80, 0x60, 0x00, 0xC6, 0x60, 0x01, 0xC0, 0x60, + 0x00, 0x18, 0xC0, 0x00, 0xE0, 0xC0, 0x00, 0x13, 0x00, 0x00, 0x60, 0xC0, + 0x00, 0x0F, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x1B, 0x00, 0x00, 0x38, 0xC0, + 0x3F, 0x63, 0x00, 0x00, 0x18, 0xC0, 0x60, 0x8E, 0x00, 0x00, 0x0C, 0xC0, + 0x00, 0x1C, 0x00, 0x00, 0x0C, 0x60, 0x00, 0x78, 0x00, 0x00, 0x06, 0x70, + 0x00, 0xE0, 0x01, 0xC0, 0x06, 0x38, 0x03, 0xE0, 0x03, 0xE0, 0x06, 0x1C, + 0x1F, 0xF0, 0x03, 0xF0, 0x07, 0x1F, 0xFB, 0xF0, 0x03, 0xF0, 0x03, 0x30, + 0x83, 0xF0, 0x03, 0xF0, 0x03, 0x30, 0x01, 0xE0, 0x03, 0xE0, 0x03, 0x30, + 0x01, 0xE0, 0x01, 0xC0, 0x03, 0x30, 0x00, 0x00, 0x00, 0x00, 0x03, 0x30, + 0x00, 0x00, 0x00, 0x00, 0x03, 0x30, 0x00, 0x00, 0x00, 0x00, 0x03, 0x30, + 0x00, 0x00, 0x00, 0x00, 0x03, 0x10, 0x00, 0x00, 0x00, 0x00, 0x03, 0x18, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x18, 0x00, 0x00, 0x00, 0x00, 0x06, 0x18, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x0C, 0x00, 0xE0, 0x01, 0x80, 0x0E, 0x0C, + 0x00, 0xFF, 0xFF, 0xC0, 0x0C, 0x0C, 0x00, 0x1F, 0xFE, 0x00, 0x1C, 0x06, + 0x00, 0x00, 0x00, 0x00, 0x18, 0x03, 0x00, 0x00, 0x00, 0x00, 0x38, 0x03, + 0x00, 0x00, 0x00, 0x00, 0x70, 0x01, 0x80, 0x00, 0x00, 0x00, 0x60, 0x00, + 0xC0, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x60, 0x00, 0x00, 0x03, 0x80, 0x00, + 0x38, 0x00, 0x00, 0x07, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x0F, 0x80, 0x00, 0x7C, 0x00, 0x00, 0x03, 0xF0, 0x03, 0xF0, 0x00, 0x00, + 0x00, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x0F, 0xFC, 0x00, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x00, 0x1F, 0xC0, 0x00, 0x00, 0x06, 0x38, 0x00, 0x00, + 0x03, 0x07, 0x00, 0x00, 0x00, 0xC0, 0xF0, 0x00, 0x00, 0x18, 0x0E, 0x00, + 0x00, 0x07, 0x01, 0xC0, 0x00, 0x03, 0xE0, 0x3C, 0x00, 0x01, 0xDC, 0x03, + 0x80, 0x00, 0x61, 0xC0, 0x70, 0x00, 0x18, 0x38, 0x06, 0x00, 0x06, 0x07, + 0x00, 0xC0, 0x00, 0xC0, 0xF0, 0x30, 0x00, 0x38, 0x0C, 0x06, 0x00, 0x07, + 0x01, 0x81, 0xC0, 0x00, 0xE0, 0x60, 0x30, 0x00, 0x1F, 0xEC, 0x06, 0x00, + 0x3F, 0x7F, 0x01, 0x80, 0x1C, 0x01, 0xF8, 0x30, 0x1E, 0x00, 0x0F, 0x0C, + 0x0E, 0x00, 0x00, 0xE3, 0x0E, 0x00, 0x00, 0x18, 0x63, 0x00, 0x7C, 0x03, + 0x19, 0x80, 0x7F, 0xC0, 0xC6, 0x60, 0x78, 0x38, 0x19, 0x98, 0x38, 0x06, + 0x06, 0x23, 0xFC, 0x00, 0xC0, 0xCC, 0x7C, 0x00, 0x30, 0x03, 0x3C, 0x00, + 0x0C, 0x00, 0xDF, 0x80, 0x03, 0x00, 0x3E, 0x30, 0x00, 0xC0, 0x0F, 0x0E, + 0x00, 0x30, 0x03, 0xC1, 0x80, 0x0C, 0x00, 0xB0, 0x30, 0x06, 0x00, 0x6C, + 0x0E, 0x03, 0x80, 0x1B, 0x01, 0xC1, 0xC0, 0x06, 0x60, 0x3F, 0xE0, 0x03, + 0x18, 0x03, 0xE0, 0x00, 0xC7, 0x00, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, + 0x18, 0x18, 0x00, 0x00, 0x06, 0x07, 0x00, 0x00, 0x03, 0x00, 0xE0, 0x00, + 0x01, 0xC0, 0x1C, 0x00, 0x00, 0xE0, 0x03, 0xC0, 0x00, 0xF0, 0x00, 0x3E, + 0x01, 0xF0, 0x00, 0x03, 0xFF, 0xF0, 0x00, 0x00, 0x1F, 0xE0, 0x00, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xC0, 0xF0, 0x7F, + 0x83, 0xFC, 0x1F, 0xE0, 0xFF, 0x07, 0xF8, 0x3F, 0xC1, 0xFE, 0x0F, 0xF0, + 0x7F, 0x83, 0xFC, 0x1F, 0xE0, 0xFF, 0x07, 0x80, 0x00, 0x07, 0x81, 0xE0, + 0x00, 0x07, 0x81, 0xE0, 0x00, 0x07, 0x01, 0xE0, 0x00, 0x0F, 0x01, 0xC0, + 0x00, 0x0F, 0x03, 0xC0, 0x00, 0x0F, 0x03, 0xC0, 0x00, 0x0E, 0x03, 0xC0, + 0x00, 0x1E, 0x03, 0x80, 0x00, 0x1E, 0x03, 0x80, 0x00, 0x1E, 0x07, 0x80, + 0x1F, 0xFF, 0xFF, 0xFF, 0x1F, 0xFF, 0xFF, 0xFF, 0x1F, 0xFF, 0xFF, 0xFF, + 0x1F, 0xFF, 0xFF, 0xFF, 0x00, 0x3C, 0x0F, 0x00, 0x00, 0x38, 0x0F, 0x00, + 0x00, 0x78, 0x0E, 0x00, 0x00, 0x78, 0x1E, 0x00, 0x00, 0x70, 0x1E, 0x00, + 0x00, 0xF0, 0x1E, 0x00, 0x00, 0xF0, 0x1C, 0x00, 0xFF, 0xFF, 0xFF, 0xF8, + 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xFF, 0xF8, + 0x01, 0xE0, 0x78, 0x00, 0x01, 0xC0, 0x78, 0x00, 0x01, 0xC0, 0x78, 0x00, + 0x03, 0xC0, 0x70, 0x00, 0x03, 0xC0, 0xF0, 0x00, 0x03, 0xC0, 0xF0, 0x00, + 0x03, 0x80, 0xF0, 0x00, 0x07, 0x80, 0xE0, 0x00, 0x07, 0x80, 0xE0, 0x00, + 0x07, 0x81, 0xE0, 0x00, 0x00, 0x30, 0x00, 0x00, 0xC0, 0x00, 0x03, 0x00, + 0x00, 0x0C, 0x00, 0x00, 0x30, 0x00, 0x07, 0xFF, 0x00, 0xFF, 0xFF, 0x07, + 0xFF, 0xFC, 0x3F, 0xFF, 0xF1, 0xF8, 0xC1, 0xCF, 0x83, 0x00, 0x3C, 0x0C, + 0x00, 0xF0, 0x30, 0x03, 0xC0, 0xC0, 0x0F, 0x03, 0x00, 0x3E, 0x0C, 0x00, + 0x7E, 0x30, 0x01, 0xFF, 0xC0, 0x03, 0xFF, 0xE0, 0x03, 0xFF, 0xF0, 0x03, + 0xFF, 0xE0, 0x00, 0xFF, 0xC0, 0x03, 0x1F, 0x80, 0x0C, 0x1F, 0x00, 0x30, + 0x7C, 0x00, 0xC0, 0xF0, 0x03, 0x03, 0xC0, 0x0C, 0x0F, 0x00, 0x30, 0x7E, + 0x00, 0xC3, 0xEF, 0x83, 0x3F, 0xBF, 0xFF, 0xFC, 0xFF, 0xFF, 0xE1, 0xFF, + 0xFF, 0x00, 0x7F, 0xE0, 0x00, 0x0C, 0x00, 0x00, 0x30, 0x00, 0x00, 0xC0, + 0x00, 0x03, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x30, 0x00, 0x00, 0xC0, 0x00, + 0x07, 0xE0, 0x00, 0x0E, 0x00, 0x3F, 0xF0, 0x00, 0x3C, 0x00, 0xFF, 0xF0, + 0x00, 0x70, 0x03, 0xE1, 0xE0, 0x01, 0xE0, 0x07, 0x81, 0xE0, 0x03, 0x80, + 0x0F, 0x03, 0xC0, 0x0F, 0x00, 0x3C, 0x03, 0xC0, 0x3C, 0x00, 0x78, 0x07, + 0x80, 0x70, 0x00, 0xF0, 0x0F, 0x01, 0xE0, 0x01, 0xE0, 0x1E, 0x03, 0x80, + 0x03, 0xC0, 0x3C, 0x0F, 0x00, 0x07, 0x80, 0x78, 0x1C, 0x00, 0x0F, 0x00, + 0xF0, 0x70, 0x00, 0x0F, 0x03, 0xC1, 0xE0, 0x00, 0x1E, 0x07, 0x83, 0x80, + 0x00, 0x3E, 0x1F, 0x0F, 0x00, 0x00, 0x3F, 0xFC, 0x1C, 0x00, 0x00, 0x3F, + 0xF0, 0x78, 0x1F, 0x80, 0x1F, 0x81, 0xE0, 0xFF, 0xC0, 0x00, 0x03, 0x83, + 0xFF, 0xC0, 0x00, 0x0F, 0x0F, 0x87, 0xC0, 0x00, 0x1C, 0x1E, 0x07, 0x80, + 0x00, 0x78, 0x3C, 0x0F, 0x00, 0x00, 0xE0, 0xF0, 0x0F, 0x00, 0x03, 0x81, + 0xE0, 0x1E, 0x00, 0x0F, 0x03, 0xC0, 0x3C, 0x00, 0x1C, 0x07, 0x80, 0x78, + 0x00, 0x78, 0x0F, 0x00, 0xF0, 0x00, 0xE0, 0x1E, 0x01, 0xE0, 0x03, 0xC0, + 0x3C, 0x03, 0xC0, 0x07, 0x00, 0x3C, 0x0F, 0x00, 0x1C, 0x00, 0x78, 0x1E, + 0x00, 0x78, 0x00, 0xF8, 0x78, 0x00, 0xE0, 0x00, 0xFF, 0xF0, 0x03, 0xC0, + 0x00, 0xFF, 0xC0, 0x07, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x3F, 0x80, 0x00, + 0x00, 0xFF, 0xF0, 0x00, 0x03, 0xFF, 0xF8, 0x00, 0x07, 0xFF, 0xF8, 0x00, + 0x07, 0xE0, 0x78, 0x00, 0x0F, 0x80, 0x08, 0x00, 0x0F, 0x80, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, + 0x07, 0xE0, 0x00, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, + 0x1F, 0xFC, 0x00, 0x00, 0x1E, 0x7E, 0x00, 0x3C, 0x3E, 0x3F, 0x00, 0x3C, + 0x7C, 0x1F, 0x80, 0x7C, 0x78, 0x0F, 0xC0, 0x78, 0xF8, 0x07, 0xE0, 0x78, + 0xF0, 0x03, 0xF0, 0x78, 0xF0, 0x01, 0xF8, 0xF0, 0xF0, 0x00, 0xFC, 0xF0, + 0xF0, 0x00, 0x7E, 0xE0, 0xF0, 0x00, 0x3F, 0xE0, 0xF8, 0x00, 0x1F, 0xC0, + 0x78, 0x00, 0x0F, 0xC0, 0x7C, 0x00, 0x0F, 0xC0, 0x3E, 0x00, 0x3F, 0xE0, + 0x3F, 0xC0, 0xFF, 0xF0, 0x1F, 0xFF, 0xFC, 0xF8, 0x0F, 0xFF, 0xF8, 0x7E, + 0x03, 0xFF, 0xE0, 0x3F, 0x00, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x03, 0xE0, 0x78, 0x1E, 0x03, 0xC0, 0xF0, 0x1E, 0x07, + 0x80, 0xF0, 0x3C, 0x07, 0x80, 0xF0, 0x3C, 0x07, 0x80, 0xF0, 0x1E, 0x07, + 0x80, 0xF0, 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x3C, 0x07, 0x80, + 0xF0, 0x1E, 0x03, 0xC0, 0x3C, 0x07, 0x80, 0xF0, 0x1E, 0x01, 0xE0, 0x3C, + 0x07, 0x80, 0x78, 0x0F, 0x00, 0xF0, 0x1E, 0x01, 0xE0, 0x3C, 0x03, 0xC0, + 0x7C, 0xF8, 0x0F, 0x00, 0xF0, 0x1E, 0x01, 0xE0, 0x3C, 0x03, 0xC0, 0x78, + 0x07, 0x80, 0xF0, 0x1E, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x0F, 0x01, + 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, + 0x3C, 0x07, 0x81, 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x3C, 0x07, 0x80, 0xF0, + 0x3C, 0x07, 0x81, 0xE0, 0x3C, 0x0F, 0x01, 0xE0, 0x78, 0x1F, 0x00, 0x00, + 0x70, 0x00, 0x03, 0x80, 0x00, 0x1C, 0x00, 0x00, 0xE0, 0x04, 0x07, 0x01, + 0x78, 0x38, 0x3F, 0xE1, 0xC3, 0xE7, 0xCE, 0x7C, 0x0F, 0x77, 0x80, 0x1F, + 0xF0, 0x00, 0x7F, 0x00, 0x03, 0xF8, 0x00, 0x3F, 0xE0, 0x07, 0xBB, 0xC0, + 0xF9, 0xCF, 0x9F, 0x0E, 0x1F, 0xF0, 0x70, 0x7A, 0x03, 0x80, 0x80, 0x1C, + 0x00, 0x00, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, 0x00, 0x00, 0x07, 0x80, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x01, 0xE0, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, + 0x78, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x03, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFC, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x01, 0xE0, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, + 0x78, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x3C, 0xF3, 0xCF, 0x3C, 0xE7, 0x9C, 0x73, 0xCE, 0x00, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x0F, 0x00, 0x1F, + 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x3C, 0x00, 0x3C, 0x00, 0x3C, + 0x00, 0x78, 0x00, 0x78, 0x00, 0x78, 0x00, 0xF8, 0x00, 0xF0, 0x00, 0xF0, + 0x01, 0xF0, 0x01, 0xE0, 0x01, 0xE0, 0x01, 0xE0, 0x03, 0xC0, 0x03, 0xC0, + 0x03, 0xC0, 0x07, 0x80, 0x07, 0x80, 0x07, 0x80, 0x0F, 0x80, 0x0F, 0x00, + 0x0F, 0x00, 0x1F, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x3C, 0x00, + 0x3C, 0x00, 0x3C, 0x00, 0x7C, 0x00, 0x78, 0x00, 0x78, 0x00, 0xF8, 0x00, + 0xF0, 0x00, 0x00, 0x7F, 0x00, 0x03, 0xFF, 0xC0, 0x07, 0xFF, 0xE0, 0x0F, + 0xFF, 0xF0, 0x1F, 0x81, 0xF8, 0x1F, 0x00, 0xF8, 0x3E, 0x00, 0x7C, 0x3C, + 0x00, 0x3C, 0x7C, 0x00, 0x3E, 0x78, 0x00, 0x1E, 0x78, 0x00, 0x1E, 0xF8, + 0x00, 0x1E, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF8, 0x00, 0x1E, 0x78, 0x00, 0x1E, 0x78, 0x00, 0x1E, 0x7C, + 0x00, 0x3E, 0x3C, 0x00, 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x00, 0xF8, 0x1F, + 0x81, 0xF8, 0x0F, 0xFF, 0xF0, 0x07, 0xFF, 0xE0, 0x03, 0xFF, 0xC0, 0x00, + 0xFE, 0x00, 0x03, 0xF0, 0x03, 0xFF, 0x00, 0xFF, 0xF0, 0x0F, 0xFF, 0x00, + 0xFC, 0xF0, 0x0C, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x07, 0xFC, 0x00, 0xFF, 0xFE, 0x0F, 0xFF, 0xFE, 0x3F, + 0xFF, 0xFC, 0xFE, 0x03, 0xF3, 0x80, 0x03, 0xE8, 0x00, 0x07, 0x80, 0x00, + 0x1F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, + 0x00, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x80, 0x00, 0x3C, 0x00, + 0x01, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x7C, 0x00, 0x03, 0xE0, 0x00, 0x1F, + 0x00, 0x00, 0xF8, 0x00, 0x07, 0xC0, 0x00, 0x3E, 0x00, 0x01, 0xF0, 0x00, + 0x0F, 0x80, 0x00, 0x7C, 0x00, 0x03, 0xE0, 0x00, 0x1F, 0x00, 0x00, 0xF8, + 0x00, 0x07, 0xC0, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xFE, 0x00, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, + 0x83, 0xFF, 0xFF, 0x87, 0x00, 0x3F, 0x80, 0x00, 0x1F, 0x00, 0x00, 0x1F, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, 0xF0, + 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1F, 0x00, 0x00, 0xFC, 0x01, + 0xFF, 0xF0, 0x03, 0xFF, 0x80, 0x07, 0xFF, 0x80, 0x0F, 0xFF, 0xC0, 0x00, + 0x1F, 0xC0, 0x00, 0x0F, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, 0x80, 0x00, + 0x0F, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, + 0xF0, 0x00, 0x03, 0xE0, 0x00, 0x0F, 0xA0, 0x00, 0x3F, 0x7C, 0x01, 0xFC, + 0xFF, 0xFF, 0xF1, 0xFF, 0xFF, 0xC1, 0xFF, 0xFE, 0x00, 0x3F, 0xE0, 0x00, + 0x00, 0x03, 0xF0, 0x00, 0x03, 0xF8, 0x00, 0x01, 0xFC, 0x00, 0x01, 0xFE, + 0x00, 0x01, 0xEF, 0x00, 0x00, 0xE7, 0x80, 0x00, 0xF3, 0xC0, 0x00, 0xF1, + 0xE0, 0x00, 0x78, 0xF0, 0x00, 0x78, 0x78, 0x00, 0x78, 0x3C, 0x00, 0x3C, + 0x1E, 0x00, 0x3C, 0x0F, 0x00, 0x3C, 0x07, 0x80, 0x1E, 0x03, 0xC0, 0x1E, + 0x01, 0xE0, 0x1E, 0x00, 0xF0, 0x0F, 0x00, 0x78, 0x0F, 0x00, 0x3C, 0x0F, + 0x80, 0x1E, 0x07, 0x80, 0x0F, 0x07, 0x80, 0x07, 0x83, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x3C, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x07, 0x80, 0x00, 0x03, + 0xC0, 0x00, 0x01, 0xE0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x78, 0x00, 0x7F, + 0xFF, 0xE1, 0xFF, 0xFF, 0x87, 0xFF, 0xFE, 0x1F, 0xFF, 0xF8, 0x78, 0x00, + 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x01, + 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0xFE, 0x00, 0x7F, 0xFF, 0x01, 0xFF, + 0xFF, 0x07, 0xFF, 0xFE, 0x1E, 0x03, 0xFC, 0x40, 0x01, 0xF0, 0x00, 0x03, + 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, + 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, + 0x07, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xFA, 0x00, 0x07, 0xCF, 0x00, 0x7F, + 0x3F, 0xFF, 0xF8, 0xFF, 0xFF, 0xC3, 0xFF, 0xFC, 0x01, 0xFF, 0xC0, 0x00, + 0x00, 0x1F, 0xF0, 0x00, 0xFF, 0xFC, 0x03, 0xFF, 0xFC, 0x07, 0xFF, 0xFC, + 0x0F, 0xE0, 0x0C, 0x1F, 0x80, 0x00, 0x1E, 0x00, 0x00, 0x3E, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, 0x78, 0x00, 0x00, 0x78, 0x00, 0x00, + 0xF8, 0x7F, 0x00, 0xF1, 0xFF, 0xE0, 0xF3, 0xFF, 0xF0, 0xF7, 0xFF, 0xF8, + 0xFF, 0xC1, 0xFC, 0xFF, 0x00, 0x7E, 0xFE, 0x00, 0x3E, 0xFC, 0x00, 0x1E, + 0xFC, 0x00, 0x1F, 0xF8, 0x00, 0x0F, 0xF8, 0x00, 0x0F, 0xF8, 0x00, 0x0F, + 0x78, 0x00, 0x0F, 0x78, 0x00, 0x0F, 0x78, 0x00, 0x0F, 0x7C, 0x00, 0x1F, + 0x3C, 0x00, 0x1E, 0x3E, 0x00, 0x3E, 0x1F, 0x00, 0x7C, 0x1F, 0xC1, 0xFC, + 0x0F, 0xFF, 0xF8, 0x07, 0xFF, 0xF0, 0x03, 0xFF, 0xE0, 0x00, 0x7F, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, + 0x00, 0x78, 0x00, 0x03, 0xE0, 0x00, 0x0F, 0x00, 0x00, 0x7C, 0x00, 0x01, + 0xE0, 0x00, 0x07, 0x80, 0x00, 0x3E, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x03, 0xE0, 0x00, 0x0F, 0x00, 0x00, + 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x3E, 0x00, 0x00, 0xF0, + 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x03, 0xE0, 0x00, + 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x3E, + 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0x00, 0xFF, + 0x00, 0x07, 0xFF, 0xE0, 0x0F, 0xFF, 0xF0, 0x1F, 0xFF, 0xF8, 0x3F, 0x81, + 0xFC, 0x3E, 0x00, 0x7C, 0x7C, 0x00, 0x3E, 0x78, 0x00, 0x1E, 0x78, 0x00, + 0x1E, 0x78, 0x00, 0x1E, 0x78, 0x00, 0x1E, 0x78, 0x00, 0x1E, 0x3C, 0x00, + 0x3C, 0x3E, 0x00, 0x7C, 0x1F, 0x81, 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, + 0xC0, 0x07, 0xFF, 0xE0, 0x1F, 0xFF, 0xF8, 0x3F, 0x81, 0xFC, 0x7C, 0x00, + 0x3E, 0x78, 0x00, 0x1E, 0xF8, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF8, 0x00, + 0x1F, 0x78, 0x00, 0x1E, 0x7C, 0x00, 0x3E, 0x3F, 0x00, 0xFC, 0x3F, 0xFF, + 0xFC, 0x1F, 0xFF, 0xF8, 0x07, 0xFF, 0xE0, 0x00, 0xFF, 0x00, 0x00, 0xFE, + 0x00, 0x07, 0xFF, 0xC0, 0x0F, 0xFF, 0xE0, 0x1F, 0xFF, 0xF0, 0x3F, 0x83, + 0xF8, 0x7E, 0x00, 0xF8, 0x7C, 0x00, 0x7C, 0x78, 0x00, 0x3C, 0xF8, 0x00, + 0x3E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, + 0x1F, 0xF0, 0x00, 0x1F, 0xF0, 0x00, 0x1F, 0xF8, 0x00, 0x3F, 0x78, 0x00, + 0x3F, 0x7C, 0x00, 0x7F, 0x7E, 0x00, 0xFF, 0x3F, 0x83, 0xFF, 0x1F, 0xFF, + 0xEF, 0x0F, 0xFF, 0xCF, 0x07, 0xFF, 0x8F, 0x00, 0xFE, 0x1E, 0x00, 0x00, + 0x1E, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x7C, 0x00, 0x00, 0xF8, 0x00, 0x01, 0xF8, 0x30, 0x07, 0xF0, 0x3F, 0xFF, + 0xE0, 0x3F, 0xFF, 0xC0, 0x3F, 0xFF, 0x00, 0x0F, 0xF8, 0x00, 0xFF, 0xFF, + 0xFF, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xFF, + 0xFF, 0x3C, 0xF3, 0xCF, 0x3C, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x03, 0xCF, 0x3C, 0xF3, 0xCE, 0x79, 0xC7, 0x3C, 0xE0, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0xFE, 0x00, 0x00, + 0x3F, 0xF0, 0x00, 0x07, 0xFF, 0x00, 0x01, 0xFF, 0xE0, 0x00, 0x7F, 0xF8, + 0x00, 0x1F, 0xFE, 0x00, 0x03, 0xFF, 0x80, 0x00, 0xFF, 0xF0, 0x00, 0x3F, + 0xFC, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x0F, 0xE0, 0x00, 0x00, 0x7F, 0xC0, + 0x00, 0x03, 0xFF, 0xC0, 0x00, 0x03, 0xFF, 0xC0, 0x00, 0x03, 0xFF, 0x80, + 0x00, 0x07, 0xFF, 0x80, 0x00, 0x07, 0xFF, 0x80, 0x00, 0x07, 0xFF, 0x80, + 0x00, 0x07, 0xFF, 0x00, 0x00, 0x0F, 0xFC, 0x00, 0x00, 0x0F, 0xE0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x80, 0x00, 0x00, + 0x07, 0x80, 0x00, 0x00, 0x3F, 0x80, 0x00, 0x01, 0xFF, 0x80, 0x00, 0x07, + 0xFF, 0x00, 0x00, 0x0F, 0xFF, 0x00, 0x00, 0x0F, 0xFF, 0x00, 0x00, 0x0F, + 0xFF, 0x00, 0x00, 0x0F, 0xFE, 0x00, 0x00, 0x1F, 0xFE, 0x00, 0x00, 0x1F, + 0xFE, 0x00, 0x00, 0x1F, 0xF0, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x07, 0xFC, + 0x00, 0x01, 0xFF, 0xE0, 0x00, 0x7F, 0xF8, 0x00, 0x0F, 0xFE, 0x00, 0x03, + 0xFF, 0xC0, 0x00, 0xFF, 0xF0, 0x00, 0x3F, 0xFC, 0x00, 0x07, 0xFF, 0x00, + 0x00, 0x7F, 0xE0, 0x00, 0x03, 0xF8, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, + 0x80, 0x00, 0x00, 0x00, 0x07, 0xF0, 0x0F, 0xFF, 0x07, 0xFF, 0xF3, 0xFF, + 0xFE, 0xF8, 0x1F, 0xB8, 0x01, 0xF8, 0x00, 0x7C, 0x00, 0x0F, 0x00, 0x03, + 0xC0, 0x00, 0xF0, 0x00, 0x3C, 0x00, 0x1E, 0x00, 0x0F, 0x80, 0x07, 0xC0, + 0x03, 0xE0, 0x01, 0xF8, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0x1F, 0x00, 0x0F, + 0x80, 0x03, 0xC0, 0x00, 0xF0, 0x00, 0x3C, 0x00, 0x0F, 0x00, 0x03, 0xC0, + 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x00, + 0x3E, 0x00, 0x0F, 0x80, 0x03, 0xE0, 0x00, 0xF8, 0x00, 0x3E, 0x00, 0x00, + 0x00, 0x7F, 0xC0, 0x00, 0x00, 0x03, 0xFF, 0xFC, 0x00, 0x00, 0x07, 0xFF, + 0xFF, 0xC0, 0x00, 0x0F, 0xFF, 0xFF, 0xF0, 0x00, 0x0F, 0xF8, 0x03, 0xFE, + 0x00, 0x0F, 0xE0, 0x00, 0x3F, 0x80, 0x0F, 0xC0, 0x00, 0x07, 0xE0, 0x0F, + 0x80, 0x00, 0x01, 0xF8, 0x0F, 0x80, 0x00, 0x00, 0x7C, 0x0F, 0x80, 0x00, + 0x00, 0x1F, 0x07, 0x80, 0x00, 0x00, 0x07, 0x87, 0xC0, 0x1F, 0x87, 0x81, + 0xE3, 0xC0, 0x1F, 0xF3, 0xC0, 0xF3, 0xE0, 0x3F, 0xFD, 0xE0, 0x7D, 0xE0, + 0x1F, 0xFF, 0xF0, 0x1E, 0xF0, 0x1F, 0x83, 0xF8, 0x0F, 0xF0, 0x0F, 0x00, + 0x7C, 0x07, 0xF8, 0x0F, 0x80, 0x3E, 0x03, 0xFC, 0x07, 0x80, 0x0F, 0x01, + 0xFE, 0x03, 0xC0, 0x07, 0x80, 0xFF, 0x01, 0xE0, 0x03, 0xC0, 0x7F, 0x80, + 0xF0, 0x01, 0xE0, 0x7B, 0xC0, 0x78, 0x00, 0xF0, 0x3D, 0xE0, 0x3E, 0x00, + 0xF8, 0x3E, 0xF0, 0x0F, 0x00, 0x7C, 0x3E, 0x3C, 0x07, 0xE0, 0xFE, 0x7E, + 0x1E, 0x01, 0xFF, 0xFF, 0xFE, 0x0F, 0x00, 0xFF, 0xF7, 0xFE, 0x07, 0xC0, + 0x3F, 0xF3, 0xFC, 0x01, 0xF0, 0x07, 0xE1, 0xF0, 0x00, 0xF8, 0x00, 0x00, + 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x00, + 0x07, 0xE0, 0x00, 0x00, 0x80, 0x01, 0xFC, 0x00, 0x01, 0xE0, 0x00, 0x7F, + 0x80, 0x01, 0xF0, 0x00, 0x1F, 0xF8, 0x07, 0xF8, 0x00, 0x03, 0xFF, 0xFF, + 0xF8, 0x00, 0x00, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x0F, 0xFF, 0xE0, 0x00, + 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0x1F, + 0xC0, 0x00, 0x00, 0x3F, 0x80, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x01, 0xFF, + 0x00, 0x00, 0x03, 0xDE, 0x00, 0x00, 0x0F, 0xBE, 0x00, 0x00, 0x1E, 0x3C, + 0x00, 0x00, 0x3C, 0x78, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x01, 0xE0, 0xF0, + 0x00, 0x03, 0xC1, 0xE0, 0x00, 0x0F, 0x01, 0xE0, 0x00, 0x1E, 0x03, 0xC0, + 0x00, 0x7C, 0x07, 0xC0, 0x00, 0xF0, 0x07, 0x80, 0x01, 0xE0, 0x0F, 0x00, + 0x07, 0xC0, 0x1F, 0x00, 0x0F, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x3E, 0x00, + 0x78, 0x00, 0x3C, 0x00, 0xFF, 0xFF, 0xF8, 0x03, 0xFF, 0xFF, 0xF8, 0x07, + 0xFF, 0xFF, 0xF0, 0x0F, 0xFF, 0xFF, 0xE0, 0x3E, 0x00, 0x03, 0xE0, 0x78, + 0x00, 0x03, 0xC1, 0xF0, 0x00, 0x07, 0xC3, 0xC0, 0x00, 0x07, 0x87, 0x80, + 0x00, 0x0F, 0x1F, 0x00, 0x00, 0x1F, 0x3C, 0x00, 0x00, 0x1E, 0x78, 0x00, + 0x00, 0x3D, 0xE0, 0x00, 0x00, 0x3C, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xE0, + 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xF8, 0xF0, 0x01, 0xFC, 0xF0, 0x00, 0x7E, + 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, + 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0x7C, 0xF0, 0x01, 0xFC, + 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xE0, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xF8, + 0xF0, 0x00, 0xFC, 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x1F, 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0xFE, + 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x80, + 0x00, 0x0F, 0xFC, 0x00, 0x07, 0xFF, 0xF8, 0x01, 0xFF, 0xFF, 0xE0, 0x3F, + 0xFF, 0xFF, 0x07, 0xF0, 0x07, 0xF0, 0xFC, 0x00, 0x0F, 0x1F, 0x00, 0x00, + 0x31, 0xE0, 0x00, 0x01, 0x3E, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x78, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x78, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, + 0x78, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x7C, 0x00, 0x00, 0x03, 0xE0, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x11, 0xF0, 0x00, 0x03, 0x0F, 0xC0, 0x00, + 0xF0, 0x7F, 0x80, 0x7F, 0x03, 0xFF, 0xFF, 0xF0, 0x1F, 0xFF, 0xFE, 0x00, + 0x7F, 0xFF, 0x80, 0x00, 0xFF, 0xC0, 0xFF, 0xFF, 0x00, 0x07, 0xFF, 0xFF, + 0x80, 0x3F, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0xFC, 0x0F, 0x00, 0x1F, 0xF8, + 0x78, 0x00, 0x0F, 0xE3, 0xC0, 0x00, 0x1F, 0x1E, 0x00, 0x00, 0x7C, 0xF0, + 0x00, 0x01, 0xE7, 0x80, 0x00, 0x0F, 0xBC, 0x00, 0x00, 0x3D, 0xE0, 0x00, + 0x01, 0xEF, 0x00, 0x00, 0x0F, 0xF8, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x01, + 0xFE, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0x80, 0x00, 0x03, 0xFC, + 0x00, 0x00, 0x1F, 0xE0, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x07, 0xF8, 0x00, + 0x00, 0x7F, 0xC0, 0x00, 0x03, 0xDE, 0x00, 0x00, 0x1E, 0xF0, 0x00, 0x01, + 0xF7, 0x80, 0x00, 0x0F, 0x3C, 0x00, 0x00, 0xF9, 0xE0, 0x00, 0x0F, 0x8F, + 0x00, 0x01, 0xFC, 0x78, 0x00, 0x7F, 0xC3, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, + 0xFF, 0x80, 0xFF, 0xFF, 0xF0, 0x07, 0xFF, 0xF8, 0x00, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x03, + 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, + 0x0F, 0xFF, 0xFF, 0xBF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFB, 0xFF, 0xFF, 0xEF, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, + 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xFF, 0xFF, 0xEF, 0xFF, 0xFE, + 0xFF, 0xFF, 0xEF, 0xFF, 0xFE, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x0F, 0xFC, + 0x00, 0x01, 0xFF, 0xFF, 0x00, 0x1F, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFE, + 0x07, 0xF8, 0x07, 0xF8, 0x3F, 0x00, 0x01, 0xE1, 0xF0, 0x00, 0x01, 0x8F, + 0x80, 0x00, 0x02, 0x3E, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x07, 0x80, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x03, 0xC0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x03, 0xC0, 0x00, 0xFF, 0xFF, 0x00, 0x03, 0xFF, 0xFC, 0x00, 0x0F, 0xFF, + 0xF0, 0x00, 0x3F, 0xFF, 0xC0, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x03, 0xDE, + 0x00, 0x00, 0x0F, 0x78, 0x00, 0x00, 0x3D, 0xE0, 0x00, 0x00, 0xF7, 0xC0, + 0x00, 0x03, 0xCF, 0x80, 0x00, 0x0F, 0x3E, 0x00, 0x00, 0x3C, 0x7E, 0x00, + 0x00, 0xF0, 0xFC, 0x00, 0x07, 0xC1, 0xFE, 0x00, 0x7F, 0x03, 0xFF, 0xFF, + 0xF8, 0x07, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xF8, 0x00, 0x03, 0xFF, 0x00, + 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, + 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, + 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, + 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, + 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, + 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, + 0x00, 0x00, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xE0, 0x3C, 0x07, + 0x80, 0xF0, 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x3C, 0x07, 0x80, + 0xF0, 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xF0, + 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x1E, + 0x03, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x3C, 0x07, + 0x83, 0xF7, 0xFC, 0xFF, 0x9F, 0xC3, 0xE0, 0x00, 0xF0, 0x00, 0x1F, 0x9E, + 0x00, 0x07, 0xE3, 0xC0, 0x01, 0xF8, 0x78, 0x00, 0x7E, 0x0F, 0x00, 0x1F, + 0x81, 0xE0, 0x07, 0xE0, 0x3C, 0x01, 0xF8, 0x07, 0x80, 0x7E, 0x00, 0xF0, + 0x1F, 0x80, 0x1E, 0x07, 0xE0, 0x03, 0xC3, 0xF0, 0x00, 0x78, 0xFC, 0x00, + 0x0F, 0x3F, 0x00, 0x01, 0xEF, 0xC0, 0x00, 0x3F, 0xF0, 0x00, 0x07, 0xFC, + 0x00, 0x00, 0xFF, 0x80, 0x00, 0x1F, 0xF8, 0x00, 0x03, 0xDF, 0x80, 0x00, + 0x79, 0xF8, 0x00, 0x0F, 0x1F, 0x80, 0x01, 0xE0, 0xF8, 0x00, 0x3C, 0x0F, + 0x80, 0x07, 0x80, 0xF8, 0x00, 0xF0, 0x0F, 0x80, 0x1E, 0x00, 0xF8, 0x03, + 0xC0, 0x0F, 0x80, 0x78, 0x00, 0xF8, 0x0F, 0x00, 0x0F, 0x81, 0xE0, 0x00, + 0xF8, 0x3C, 0x00, 0x0F, 0x87, 0x80, 0x00, 0xF8, 0xF0, 0x00, 0x0F, 0x9E, + 0x00, 0x00, 0xFC, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, + 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, + 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x00, + 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xF0, 0x00, 0x07, + 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, + 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, + 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x03, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xFE, 0x00, 0x00, + 0xFF, 0xFC, 0x00, 0x01, 0xFF, 0xFC, 0x00, 0x07, 0xFF, 0xF8, 0x00, 0x0F, + 0xFF, 0xF0, 0x00, 0x1F, 0xFE, 0xF0, 0x00, 0x7B, 0xFD, 0xE0, 0x00, 0xF7, + 0xFB, 0xE0, 0x03, 0xEF, 0xF3, 0xC0, 0x07, 0x9F, 0xE7, 0x80, 0x0F, 0x3F, + 0xC7, 0x80, 0x3C, 0x7F, 0x8F, 0x00, 0x78, 0xFF, 0x1F, 0x01, 0xF1, 0xFE, + 0x1E, 0x03, 0xC3, 0xFC, 0x3C, 0x07, 0x87, 0xF8, 0x3C, 0x1E, 0x0F, 0xF0, + 0x78, 0x3C, 0x1F, 0xE0, 0xF8, 0xF8, 0x3F, 0xC0, 0xF1, 0xE0, 0x7F, 0x81, + 0xE3, 0xC0, 0xFF, 0x01, 0xEF, 0x01, 0xFE, 0x03, 0xDE, 0x03, 0xFC, 0x07, + 0xFC, 0x07, 0xF8, 0x07, 0xF0, 0x0F, 0xF0, 0x0F, 0xE0, 0x1F, 0xE0, 0x1F, + 0xC0, 0x3F, 0xC0, 0x1F, 0x00, 0x7F, 0x80, 0x00, 0x00, 0xFF, 0x00, 0x00, + 0x01, 0xFE, 0x00, 0x00, 0x03, 0xFC, 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, + 0x0F, 0xF0, 0x00, 0x00, 0x1F, 0xE0, 0x00, 0x00, 0x3C, 0xFC, 0x00, 0x03, + 0xFF, 0x80, 0x00, 0xFF, 0xE0, 0x00, 0x3F, 0xFC, 0x00, 0x0F, 0xFF, 0x00, + 0x03, 0xFF, 0xE0, 0x00, 0xFF, 0x78, 0x00, 0x3F, 0xDF, 0x00, 0x0F, 0xF3, + 0xE0, 0x03, 0xFC, 0x78, 0x00, 0xFF, 0x1F, 0x00, 0x3F, 0xC3, 0xC0, 0x0F, + 0xF0, 0xF8, 0x03, 0xFC, 0x1E, 0x00, 0xFF, 0x07, 0xC0, 0x3F, 0xC0, 0xF0, + 0x0F, 0xF0, 0x3E, 0x03, 0xFC, 0x07, 0xC0, 0xFF, 0x00, 0xF0, 0x3F, 0xC0, + 0x3E, 0x0F, 0xF0, 0x07, 0x83, 0xFC, 0x01, 0xF0, 0xFF, 0x00, 0x3C, 0x3F, + 0xC0, 0x0F, 0x8F, 0xF0, 0x01, 0xE3, 0xFC, 0x00, 0x7C, 0xFF, 0x00, 0x0F, + 0xBF, 0xC0, 0x01, 0xEF, 0xF0, 0x00, 0x7F, 0xFC, 0x00, 0x0F, 0xFF, 0x00, + 0x03, 0xFF, 0xC0, 0x00, 0x7F, 0xF0, 0x00, 0x1F, 0xFC, 0x00, 0x03, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0x80, + 0x03, 0xFF, 0xFF, 0xC0, 0x07, 0xF0, 0x0F, 0xE0, 0x0F, 0xC0, 0x03, 0xF0, + 0x1F, 0x80, 0x01, 0xF8, 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, + 0x7C, 0x00, 0x00, 0x3C, 0x78, 0x00, 0x00, 0x3E, 0x78, 0x00, 0x00, 0x1E, + 0x78, 0x00, 0x00, 0x1E, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0x78, 0x00, 0x00, 0x1E, + 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x7C, 0x00, 0x00, 0x3C, + 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, 0x1F, 0x80, 0x01, 0xF8, + 0x0F, 0xC0, 0x03, 0xF0, 0x07, 0xF0, 0x0F, 0xE0, 0x03, 0xFF, 0xFF, 0xC0, + 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x7F, 0xFE, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0xFF, 0xFE, 0x03, 0xFF, 0xFE, 0x0F, 0xFF, 0xFE, 0x3F, 0xFF, 0xFC, 0xF0, + 0x03, 0xFB, 0xC0, 0x03, 0xEF, 0x00, 0x07, 0xBC, 0x00, 0x1F, 0xF0, 0x00, + 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, + 0xC0, 0x01, 0xFF, 0x00, 0x07, 0xBC, 0x00, 0x3E, 0xF0, 0x03, 0xFB, 0xFF, + 0xFF, 0xCF, 0xFF, 0xFE, 0x3F, 0xFF, 0xE0, 0xFF, 0xFE, 0x03, 0xC0, 0x00, + 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, + 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0x80, 0x03, 0xFF, + 0xFF, 0xC0, 0x07, 0xF0, 0x0F, 0xE0, 0x0F, 0xC0, 0x03, 0xF0, 0x1F, 0x80, + 0x01, 0xF8, 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, 0x7C, 0x00, + 0x00, 0x3C, 0x78, 0x00, 0x00, 0x3E, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, + 0x00, 0x1E, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, + 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x7C, 0x00, 0x00, 0x3E, 0x3E, 0x00, + 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, 0x1F, 0x80, 0x01, 0xF8, 0x0F, 0xC0, + 0x03, 0xF0, 0x07, 0xF0, 0x0F, 0xE0, 0x03, 0xFF, 0xFF, 0xC0, 0x01, 0xFF, + 0xFF, 0x80, 0x00, 0x7F, 0xFE, 0x00, 0x00, 0x0F, 0xFF, 0x00, 0x00, 0x00, + 0x1F, 0x80, 0x00, 0x00, 0x0F, 0xC0, 0x00, 0x00, 0x07, 0xE0, 0x00, 0x00, + 0x03, 0xF0, 0x00, 0x00, 0x01, 0xF8, 0xFF, 0xFE, 0x00, 0x1F, 0xFF, 0xF8, + 0x03, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xF8, 0x0F, 0x00, 0x3F, 0x81, 0xE0, + 0x01, 0xF0, 0x3C, 0x00, 0x1F, 0x07, 0x80, 0x01, 0xE0, 0xF0, 0x00, 0x3C, + 0x1E, 0x00, 0x07, 0x83, 0xC0, 0x00, 0xF0, 0x78, 0x00, 0x1E, 0x0F, 0x00, + 0x03, 0xC1, 0xE0, 0x00, 0xF8, 0x3C, 0x00, 0x3E, 0x07, 0x80, 0x1F, 0xC0, + 0xFF, 0xFF, 0xF0, 0x1F, 0xFF, 0xFC, 0x03, 0xFF, 0xFE, 0x00, 0x7F, 0xFF, + 0xF0, 0x0F, 0x00, 0x7E, 0x01, 0xE0, 0x03, 0xE0, 0x3C, 0x00, 0x3E, 0x07, + 0x80, 0x07, 0xC0, 0xF0, 0x00, 0x7C, 0x1E, 0x00, 0x07, 0x83, 0xC0, 0x00, + 0xF8, 0x78, 0x00, 0x0F, 0x0F, 0x00, 0x01, 0xF1, 0xE0, 0x00, 0x1E, 0x3C, + 0x00, 0x03, 0xE7, 0x80, 0x00, 0x3E, 0xF0, 0x00, 0x03, 0xDE, 0x00, 0x00, + 0x7C, 0x00, 0xFF, 0x80, 0x07, 0xFF, 0xF8, 0x1F, 0xFF, 0xFC, 0x3F, 0xFF, + 0xFC, 0x3F, 0x00, 0xFC, 0x7C, 0x00, 0x1C, 0x78, 0x00, 0x04, 0xF0, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0xF8, 0x00, 0x00, 0x7C, 0x00, 0x00, 0x7F, 0xC0, 0x00, 0x3F, 0xFE, + 0x00, 0x3F, 0xFF, 0xC0, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xF8, 0x00, 0x7F, + 0xFC, 0x00, 0x03, 0xFE, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x3F, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x1E, 0xE0, 0x00, 0x3E, 0xFE, 0x01, + 0xFC, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xF8, 0x3F, 0xFF, 0xE0, 0x03, 0xFF, + 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, + 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, + 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, + 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, + 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, + 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFE, 0x00, 0x01, + 0xF7, 0x80, 0x00, 0x79, 0xE0, 0x00, 0x1E, 0x7C, 0x00, 0x0F, 0x8F, 0x80, + 0x07, 0xC3, 0xF8, 0x07, 0xF0, 0x7F, 0xFF, 0xF8, 0x0F, 0xFF, 0xFC, 0x00, + 0xFF, 0xFC, 0x00, 0x0F, 0xFC, 0x00, 0xF0, 0x00, 0x00, 0x1E, 0xF0, 0x00, + 0x00, 0x79, 0xE0, 0x00, 0x00, 0xF3, 0xE0, 0x00, 0x03, 0xE3, 0xC0, 0x00, + 0x07, 0x87, 0x80, 0x00, 0x0F, 0x0F, 0x80, 0x00, 0x3E, 0x0F, 0x00, 0x00, + 0x78, 0x1F, 0x00, 0x01, 0xF0, 0x1E, 0x00, 0x03, 0xC0, 0x3C, 0x00, 0x07, + 0x80, 0x7C, 0x00, 0x1F, 0x00, 0x78, 0x00, 0x3C, 0x00, 0xF0, 0x00, 0x78, + 0x00, 0xF0, 0x01, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x03, 0xE0, 0x0F, 0x80, + 0x03, 0xC0, 0x1E, 0x00, 0x07, 0x80, 0x3C, 0x00, 0x0F, 0x80, 0xF8, 0x00, + 0x0F, 0x01, 0xE0, 0x00, 0x1E, 0x03, 0xC0, 0x00, 0x1E, 0x0F, 0x00, 0x00, + 0x3C, 0x1E, 0x00, 0x00, 0x7C, 0x7C, 0x00, 0x00, 0x78, 0xF0, 0x00, 0x00, + 0xF1, 0xE0, 0x00, 0x01, 0xF7, 0xC0, 0x00, 0x01, 0xEF, 0x00, 0x00, 0x03, + 0xFE, 0x00, 0x00, 0x03, 0xF8, 0x00, 0x00, 0x07, 0xF0, 0x00, 0x00, 0x0F, + 0xE0, 0x00, 0x00, 0x0F, 0x80, 0x00, 0xF0, 0x00, 0x3F, 0x80, 0x01, 0xFF, + 0x00, 0x07, 0xF0, 0x00, 0x7D, 0xE0, 0x00, 0xFE, 0x00, 0x0F, 0x3C, 0x00, + 0x1F, 0xC0, 0x01, 0xE7, 0x80, 0x07, 0xFC, 0x00, 0x3C, 0xF8, 0x00, 0xF7, + 0x80, 0x0F, 0x8F, 0x00, 0x1E, 0xF0, 0x01, 0xE1, 0xE0, 0x03, 0xDE, 0x00, + 0x3C, 0x3C, 0x00, 0xFB, 0xE0, 0x07, 0x87, 0xC0, 0x1E, 0x3C, 0x01, 0xF0, + 0x78, 0x03, 0xC7, 0x80, 0x3C, 0x0F, 0x00, 0x78, 0xF0, 0x07, 0x81, 0xE0, + 0x1F, 0x1F, 0x00, 0xF0, 0x3E, 0x03, 0xC1, 0xE0, 0x3E, 0x03, 0xC0, 0x78, + 0x3C, 0x07, 0x80, 0x78, 0x0F, 0x07, 0x80, 0xF0, 0x0F, 0x03, 0xE0, 0xF8, + 0x1E, 0x01, 0xF0, 0x78, 0x0F, 0x07, 0xC0, 0x1E, 0x0F, 0x01, 0xE0, 0xF0, + 0x03, 0xC1, 0xE0, 0x3C, 0x1E, 0x00, 0x78, 0x7C, 0x07, 0xC3, 0xC0, 0x0F, + 0x8F, 0x00, 0x78, 0xF8, 0x00, 0xF1, 0xE0, 0x0F, 0x1E, 0x00, 0x1E, 0x3C, + 0x01, 0xE3, 0xC0, 0x03, 0xCF, 0x80, 0x3E, 0x78, 0x00, 0x7D, 0xE0, 0x03, + 0xDF, 0x00, 0x07, 0xBC, 0x00, 0x7B, 0xC0, 0x00, 0xF7, 0x80, 0x0F, 0x78, + 0x00, 0x1E, 0xF0, 0x01, 0xEF, 0x00, 0x03, 0xFC, 0x00, 0x1F, 0xE0, 0x00, + 0x3F, 0x80, 0x03, 0xF8, 0x00, 0x07, 0xF0, 0x00, 0x7F, 0x00, 0x00, 0xFE, + 0x00, 0x0F, 0xE0, 0x00, 0x1F, 0x80, 0x00, 0xFC, 0x00, 0x3E, 0x00, 0x01, + 0xF0, 0xF8, 0x00, 0x1F, 0x03, 0xC0, 0x01, 0xF0, 0x1F, 0x00, 0x0F, 0x80, + 0x7C, 0x00, 0xF8, 0x01, 0xE0, 0x0F, 0x80, 0x0F, 0x80, 0x7C, 0x00, 0x3E, + 0x07, 0xC0, 0x00, 0xF0, 0x7C, 0x00, 0x07, 0xC3, 0xE0, 0x00, 0x1F, 0x3E, + 0x00, 0x00, 0xFB, 0xE0, 0x00, 0x03, 0xFF, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0x00, 0x7F, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x0F, 0xC0, 0x00, 0x00, + 0xFE, 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, 0x7F, 0xE0, 0x00, 0x07, 0xDF, + 0x00, 0x00, 0x3C, 0x7C, 0x00, 0x03, 0xE1, 0xF0, 0x00, 0x3E, 0x0F, 0x80, + 0x03, 0xE0, 0x3E, 0x00, 0x1F, 0x00, 0xF0, 0x01, 0xF0, 0x07, 0xC0, 0x1F, + 0x00, 0x1F, 0x00, 0xF8, 0x00, 0x78, 0x0F, 0x80, 0x03, 0xE0, 0xF8, 0x00, + 0x0F, 0x87, 0xC0, 0x00, 0x3C, 0x7C, 0x00, 0x01, 0xF7, 0xC0, 0x00, 0x07, + 0xC0, 0xF8, 0x00, 0x01, 0xF7, 0xC0, 0x00, 0x3E, 0x3E, 0x00, 0x07, 0xC3, + 0xE0, 0x00, 0x7C, 0x1F, 0x00, 0x0F, 0x80, 0xF8, 0x01, 0xF0, 0x0F, 0x80, + 0x1F, 0x00, 0x7C, 0x03, 0xE0, 0x03, 0xE0, 0x7C, 0x00, 0x3E, 0x07, 0xC0, + 0x01, 0xF0, 0xF8, 0x00, 0x0F, 0x9F, 0x00, 0x00, 0xF9, 0xF0, 0x00, 0x07, + 0xFE, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0x1F, 0x80, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x7F, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xE7, 0xFF, + 0xFF, 0xFE, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, + 0x80, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, + 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x3E, + 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x00, 0x00, + 0x01, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, + 0x00, 0x00, 0x1F, 0x00, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, + 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x03, 0xF0, + 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, + 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, + 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, + 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF0, 0xF0, 0x00, 0xF8, 0x00, 0x78, 0x00, 0x78, 0x00, + 0x7C, 0x00, 0x3C, 0x00, 0x3C, 0x00, 0x3C, 0x00, 0x1E, 0x00, 0x1E, 0x00, + 0x1E, 0x00, 0x1F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x80, 0x07, 0x80, + 0x07, 0x80, 0x07, 0x80, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x01, 0xE0, + 0x01, 0xE0, 0x01, 0xE0, 0x01, 0xF0, 0x00, 0xF0, 0x00, 0xF0, 0x00, 0xF8, + 0x00, 0x78, 0x00, 0x78, 0x00, 0x78, 0x00, 0x3C, 0x00, 0x3C, 0x00, 0x3C, + 0x00, 0x3E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1F, 0x00, 0x0F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, + 0x78, 0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, + 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x07, 0x83, + 0xC1, 0xE0, 0xF0, 0x78, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x0F, + 0x80, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x0F, 0xF8, 0x00, 0x00, 0xFF, 0xE0, + 0x00, 0x0F, 0xDF, 0x80, 0x00, 0xFC, 0x7E, 0x00, 0x0F, 0xC1, 0xF8, 0x00, + 0xF8, 0x07, 0xE0, 0x0F, 0x80, 0x0F, 0x80, 0xF8, 0x00, 0x3E, 0x0F, 0x80, + 0x00, 0xF8, 0xF8, 0x00, 0x03, 0xEF, 0x80, 0x00, 0x0F, 0x80, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x0F, + 0x80, 0xF0, 0x0F, 0x00, 0xF0, 0x0E, 0x01, 0xE0, 0x1E, 0x01, 0xE0, 0x01, + 0xFE, 0x00, 0x7F, 0xFE, 0x03, 0xFF, 0xFE, 0x0F, 0xFF, 0xF8, 0x3C, 0x03, + 0xF0, 0x80, 0x03, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, 0x00, 0x3C, + 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x7F, 0xFF, 0x0F, 0xFF, 0xFC, 0x7F, + 0xFF, 0xF3, 0xFF, 0xFF, 0xDF, 0xC0, 0x0F, 0x78, 0x00, 0x3F, 0xE0, 0x00, + 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x1F, 0xF0, 0x00, 0x7F, 0xC0, 0x03, 0xFF, + 0x80, 0x1F, 0xDF, 0x81, 0xFF, 0x7F, 0xFF, 0xBC, 0xFF, 0xFC, 0xF1, 0xFF, + 0xE3, 0xC1, 0xFC, 0x00, 0xF0, 0x00, 0x01, 0xE0, 0x00, 0x03, 0xC0, 0x00, + 0x07, 0x80, 0x00, 0x0F, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x78, 0x00, 0x00, 0xF0, 0x00, 0x01, 0xE0, 0xFE, 0x03, 0xC7, 0xFF, 0x07, + 0x9F, 0xFF, 0x0F, 0x7F, 0xFF, 0x1F, 0xF0, 0x7E, 0x3F, 0x80, 0x3E, 0x7E, + 0x00, 0x3E, 0xF8, 0x00, 0x3D, 0xF0, 0x00, 0x7B, 0xE0, 0x00, 0xFF, 0x80, + 0x00, 0xFF, 0x00, 0x01, 0xFE, 0x00, 0x03, 0xFC, 0x00, 0x07, 0xF8, 0x00, + 0x0F, 0xF0, 0x00, 0x1F, 0xE0, 0x00, 0x3F, 0xC0, 0x00, 0x7F, 0xC0, 0x01, + 0xFF, 0x80, 0x03, 0xDF, 0x00, 0x07, 0xBF, 0x00, 0x1F, 0x7F, 0x00, 0x7C, + 0xFF, 0x83, 0xF1, 0xEF, 0xFF, 0xE3, 0xCF, 0xFF, 0x87, 0x8F, 0xFE, 0x00, + 0x07, 0xE0, 0x00, 0x00, 0x7F, 0x80, 0x3F, 0xFE, 0x07, 0xFF, 0xF0, 0xFF, + 0xFF, 0x1F, 0xC0, 0xF3, 0xF0, 0x01, 0x7C, 0x00, 0x07, 0xC0, 0x00, 0x78, + 0x00, 0x0F, 0x80, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF8, 0x00, 0x07, 0x80, 0x00, 0x7C, 0x00, 0x03, 0xC0, 0x00, 0x3F, 0x00, + 0x11, 0xFC, 0x0F, 0x0F, 0xFF, 0xF0, 0x7F, 0xFF, 0x03, 0xFF, 0xE0, 0x07, + 0xF0, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, + 0xF0, 0x00, 0x01, 0xE0, 0x00, 0x03, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, + 0x00, 0x00, 0x1E, 0x01, 0xF8, 0x3C, 0x1F, 0xFC, 0x78, 0x7F, 0xFC, 0xF1, + 0xFF, 0xFD, 0xE7, 0xF0, 0x7F, 0xCF, 0x80, 0x3F, 0xBE, 0x00, 0x3F, 0x78, + 0x00, 0x3E, 0xF0, 0x00, 0x7F, 0xE0, 0x00, 0xFF, 0x80, 0x00, 0xFF, 0x00, + 0x01, 0xFE, 0x00, 0x03, 0xFC, 0x00, 0x07, 0xF8, 0x00, 0x0F, 0xF0, 0x00, + 0x1F, 0xE0, 0x00, 0x3F, 0xC0, 0x00, 0x7F, 0xC0, 0x01, 0xF7, 0x80, 0x03, + 0xEF, 0x00, 0x07, 0xDF, 0x00, 0x1F, 0x9F, 0x00, 0x7F, 0x3F, 0x83, 0xFE, + 0x3F, 0xFF, 0xBC, 0x3F, 0xFE, 0x78, 0x3F, 0xF8, 0xF0, 0x1F, 0xC0, 0x00, + 0x00, 0x7F, 0x80, 0x03, 0xFF, 0xE0, 0x07, 0xFF, 0xF0, 0x0F, 0xFF, 0xF8, + 0x1F, 0x80, 0xFC, 0x3E, 0x00, 0x3E, 0x3C, 0x00, 0x1E, 0x78, 0x00, 0x1E, + 0x78, 0x00, 0x0F, 0x70, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x7C, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x3F, 0x00, 0x02, 0x1F, 0xC0, 0x3E, + 0x0F, 0xFF, 0xFE, 0x07, 0xFF, 0xFE, 0x01, 0xFF, 0xFC, 0x00, 0x7F, 0xC0, + 0x00, 0xFF, 0x03, 0xFF, 0x07, 0xFF, 0x07, 0xFF, 0x0F, 0x80, 0x0F, 0x00, + 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0xFF, 0xFE, 0xFF, 0xFE, + 0xFF, 0xFE, 0xFF, 0xFE, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, + 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, + 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, + 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, 0x0F, 0x00, + 0x01, 0xFC, 0x00, 0x0F, 0xFE, 0x3C, 0x3F, 0xFE, 0x78, 0xFF, 0xFE, 0xF3, + 0xF8, 0x3F, 0xE7, 0xC0, 0x1F, 0xDF, 0x00, 0x1F, 0xBC, 0x00, 0x1F, 0x78, + 0x00, 0x3F, 0xF0, 0x00, 0x7F, 0xC0, 0x00, 0x7F, 0x80, 0x00, 0xFF, 0x00, + 0x01, 0xFE, 0x00, 0x03, 0xFC, 0x00, 0x07, 0xF8, 0x00, 0x0F, 0xF0, 0x00, + 0x1F, 0xE0, 0x00, 0x7D, 0xE0, 0x00, 0xFB, 0xC0, 0x01, 0xF7, 0xC0, 0x07, + 0xE7, 0xC0, 0x1F, 0xCF, 0xE0, 0xFF, 0x8F, 0xFF, 0xEF, 0x0F, 0xFF, 0x9E, + 0x0F, 0xFE, 0x3C, 0x07, 0xF0, 0x78, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x07, 0x80, 0x00, 0x1F, 0x08, 0x00, 0x7C, 0x1E, 0x03, 0xF8, 0x3F, + 0xFF, 0xE0, 0x7F, 0xFF, 0x80, 0x7F, 0xFE, 0x00, 0x1F, 0xE0, 0x00, 0xF0, + 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, + 0xC1, 0xFC, 0x0F, 0x1F, 0xFC, 0x3C, 0xFF, 0xF8, 0xF7, 0xFF, 0xF3, 0xFE, + 0x07, 0xEF, 0xE0, 0x0F, 0xBF, 0x00, 0x1E, 0xF8, 0x00, 0x7F, 0xE0, 0x00, + 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, + 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, + 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, + 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, + 0x00, 0x0F, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x83, 0xC1, 0xE0, + 0xF0, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xE0, 0xF0, 0x78, 0x3C, + 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x07, + 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, + 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x0F, 0x8F, 0xBF, 0xDF, 0xCF, 0xE7, 0xC0, + 0xF0, 0x00, 0x01, 0xE0, 0x00, 0x03, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, 0xF0, + 0x00, 0x01, 0xE0, 0x00, 0x03, 0xC0, 0x07, 0xE7, 0x80, 0x1F, 0x8F, 0x00, + 0x7E, 0x1E, 0x01, 0xF8, 0x3C, 0x07, 0xE0, 0x78, 0x1F, 0x80, 0xF0, 0x7E, + 0x01, 0xE1, 0xF8, 0x03, 0xCF, 0xC0, 0x07, 0xBF, 0x00, 0x0F, 0xFC, 0x00, + 0x1F, 0xF0, 0x00, 0x3F, 0xE0, 0x00, 0x7F, 0xE0, 0x00, 0xF7, 0xE0, 0x01, + 0xE7, 0xE0, 0x03, 0xC7, 0xE0, 0x07, 0x87, 0xE0, 0x0F, 0x07, 0xE0, 0x1E, + 0x07, 0xE0, 0x3C, 0x07, 0xE0, 0x78, 0x07, 0xE0, 0xF0, 0x07, 0xE1, 0xE0, + 0x03, 0xE3, 0xC0, 0x03, 0xE7, 0x80, 0x03, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x00, 0x7E, 0x00, 0x3F, 0x83, 0xC7, 0xFE, 0x03, 0xFF, 0x0F, + 0x3F, 0xFC, 0x1F, 0xFE, 0x3D, 0xFF, 0xF8, 0xFF, 0xFC, 0xFF, 0x83, 0xF7, + 0xC1, 0xFB, 0xF8, 0x07, 0xDC, 0x03, 0xEF, 0xC0, 0x0F, 0xE0, 0x07, 0xBE, + 0x00, 0x3F, 0x00, 0x1F, 0xF8, 0x00, 0x7C, 0x00, 0x3F, 0xC0, 0x01, 0xE0, + 0x00, 0xFF, 0x00, 0x07, 0x80, 0x03, 0xFC, 0x00, 0x1E, 0x00, 0x0F, 0xF0, + 0x00, 0x78, 0x00, 0x3F, 0xC0, 0x01, 0xE0, 0x00, 0xFF, 0x00, 0x07, 0x80, + 0x03, 0xFC, 0x00, 0x1E, 0x00, 0x0F, 0xF0, 0x00, 0x78, 0x00, 0x3F, 0xC0, + 0x01, 0xE0, 0x00, 0xFF, 0x00, 0x07, 0x80, 0x03, 0xFC, 0x00, 0x1E, 0x00, + 0x0F, 0xF0, 0x00, 0x78, 0x00, 0x3F, 0xC0, 0x01, 0xE0, 0x00, 0xFF, 0x00, + 0x07, 0x80, 0x03, 0xFC, 0x00, 0x1E, 0x00, 0x0F, 0xF0, 0x00, 0x78, 0x00, + 0x3F, 0xC0, 0x01, 0xE0, 0x00, 0xFF, 0x00, 0x07, 0x80, 0x03, 0xC0, 0x00, + 0x7F, 0x03, 0xC7, 0xFF, 0x0F, 0x3F, 0xFE, 0x3D, 0xFF, 0xFC, 0xFF, 0x81, + 0xFB, 0xF8, 0x03, 0xEF, 0xC0, 0x07, 0xBE, 0x00, 0x1F, 0xF8, 0x00, 0x3F, + 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, + 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, + 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, + 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, + 0x03, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, + 0xFF, 0xF8, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x7C, 0x00, 0x3E, 0x7C, + 0x00, 0x3E, 0x78, 0x00, 0x1E, 0xF8, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x1F, 0x78, + 0x00, 0x1E, 0x7C, 0x00, 0x3E, 0x7C, 0x00, 0x3E, 0x3E, 0x00, 0x7C, 0x1F, + 0x81, 0xF8, 0x1F, 0xFF, 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, + 0xFF, 0x00, 0x00, 0x7F, 0x01, 0xE3, 0xFF, 0x83, 0xCF, 0xFF, 0x87, 0xBF, + 0xFF, 0x8F, 0xF8, 0x3F, 0x1F, 0xC0, 0x1F, 0x3F, 0x00, 0x1F, 0x7C, 0x00, + 0x1E, 0xF8, 0x00, 0x3D, 0xF0, 0x00, 0x7F, 0xC0, 0x00, 0x7F, 0x80, 0x00, + 0xFF, 0x00, 0x01, 0xFE, 0x00, 0x03, 0xFC, 0x00, 0x07, 0xF8, 0x00, 0x0F, + 0xF0, 0x00, 0x1F, 0xE0, 0x00, 0x3F, 0xE0, 0x00, 0xFF, 0xC0, 0x01, 0xEF, + 0x80, 0x03, 0xDF, 0x80, 0x0F, 0xBF, 0x80, 0x3E, 0x7F, 0xC1, 0xF8, 0xF7, + 0xFF, 0xF1, 0xE7, 0xFF, 0xC3, 0xC7, 0xFF, 0x07, 0x83, 0xF0, 0x0F, 0x00, + 0x00, 0x1E, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, 0xF0, 0x00, + 0x01, 0xE0, 0x00, 0x03, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0x00, 0xFC, 0x00, 0x0F, 0xFE, 0x3C, 0x3F, 0xFE, 0x78, 0xFF, 0xFE, + 0xF3, 0xF8, 0x3F, 0xE7, 0xC0, 0x1F, 0xDF, 0x00, 0x1F, 0xBC, 0x00, 0x1F, + 0x78, 0x00, 0x3F, 0xF0, 0x00, 0x7F, 0xC0, 0x00, 0x7F, 0x80, 0x00, 0xFF, + 0x00, 0x01, 0xFE, 0x00, 0x03, 0xFC, 0x00, 0x07, 0xF8, 0x00, 0x0F, 0xF0, + 0x00, 0x1F, 0xE0, 0x00, 0x3F, 0xE0, 0x00, 0xFB, 0xC0, 0x01, 0xF7, 0x80, + 0x03, 0xEF, 0x80, 0x0F, 0xCF, 0x80, 0x3F, 0x9F, 0xC1, 0xFF, 0x1F, 0xFF, + 0xDE, 0x1F, 0xFF, 0x3C, 0x1F, 0xFC, 0x78, 0x0F, 0xE0, 0xF0, 0x00, 0x01, + 0xE0, 0x00, 0x03, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, 0x00, 0x00, 0xF0, 0x00, 0x01, 0xE0, + 0x00, 0x7F, 0xE3, 0xFF, 0xCF, 0xFF, 0xBF, 0xFF, 0xF8, 0x1F, 0xC0, 0x3F, + 0x00, 0x7C, 0x00, 0xF8, 0x01, 0xE0, 0x03, 0xC0, 0x07, 0x80, 0x0F, 0x00, + 0x1E, 0x00, 0x3C, 0x00, 0x78, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x07, + 0x80, 0x0F, 0x00, 0x1E, 0x00, 0x3C, 0x00, 0x78, 0x00, 0xF0, 0x01, 0xE0, + 0x03, 0xC0, 0x00, 0x03, 0xFC, 0x03, 0xFF, 0xF0, 0xFF, 0xFF, 0x3F, 0xFF, + 0xE7, 0xE0, 0x3D, 0xF0, 0x00, 0xBC, 0x00, 0x07, 0x80, 0x00, 0xF0, 0x00, + 0x1E, 0x00, 0x03, 0xE0, 0x00, 0x3F, 0x00, 0x07, 0xFE, 0x00, 0x7F, 0xFC, + 0x03, 0xFF, 0xC0, 0x0F, 0xFE, 0x00, 0x1F, 0xC0, 0x00, 0xFC, 0x00, 0x07, + 0x80, 0x00, 0xF0, 0x00, 0x1E, 0x00, 0x03, 0xE0, 0x00, 0xFF, 0xC0, 0x7E, + 0xFF, 0xFF, 0xDF, 0xFF, 0xF1, 0xFF, 0xF8, 0x07, 0xFC, 0x00, 0x1E, 0x00, + 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1E, 0x00, 0x1E, 0x00, + 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, + 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, + 0x1E, 0x00, 0x1E, 0x00, 0x1E, 0x00, 0x1F, 0x00, 0x0F, 0xFF, 0x0F, 0xFF, + 0x07, 0xFF, 0x01, 0xFF, 0x00, 0x00, 0x03, 0xC0, 0x00, 0xFF, 0x00, 0x03, + 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, + 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, + 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, + 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x1F, 0xF8, + 0x00, 0x7D, 0xE0, 0x03, 0xF7, 0xC0, 0x1F, 0xDF, 0x81, 0xFF, 0x3F, 0xFF, + 0xBC, 0x7F, 0xFC, 0xF0, 0xFF, 0xE3, 0xC0, 0xFE, 0x00, 0xF0, 0x00, 0x07, + 0xBC, 0x00, 0x07, 0x9E, 0x00, 0x03, 0xCF, 0x80, 0x03, 0xE3, 0xC0, 0x01, + 0xE1, 0xE0, 0x00, 0xF0, 0xF8, 0x00, 0xF8, 0x3C, 0x00, 0x78, 0x1E, 0x00, + 0x3C, 0x07, 0x80, 0x3C, 0x03, 0xC0, 0x1E, 0x01, 0xF0, 0x1F, 0x00, 0x78, + 0x0F, 0x00, 0x3C, 0x07, 0x80, 0x1F, 0x07, 0xC0, 0x07, 0x83, 0xC0, 0x03, + 0xC1, 0xE0, 0x01, 0xF1, 0xF0, 0x00, 0x78, 0xF0, 0x00, 0x3E, 0xF8, 0x00, + 0x0F, 0x78, 0x00, 0x07, 0xBC, 0x00, 0x03, 0xFE, 0x00, 0x00, 0xFE, 0x00, + 0x00, 0x7F, 0x00, 0x00, 0x3F, 0x80, 0x00, 0xF0, 0x03, 0xF8, 0x01, 0xFF, + 0x00, 0x7F, 0x00, 0x7D, 0xE0, 0x0F, 0xE0, 0x0F, 0x3C, 0x01, 0xFC, 0x01, + 0xE7, 0x80, 0x7F, 0xC0, 0x3C, 0xF8, 0x0F, 0x78, 0x0F, 0x8F, 0x01, 0xEF, + 0x01, 0xE1, 0xE0, 0x3D, 0xE0, 0x3C, 0x3C, 0x0F, 0x9E, 0x07, 0x83, 0xC1, + 0xE3, 0xC1, 0xF0, 0x78, 0x3C, 0x78, 0x3C, 0x0F, 0x07, 0x8F, 0x07, 0x81, + 0xE1, 0xE0, 0xF0, 0xF0, 0x1E, 0x3C, 0x1E, 0x3C, 0x03, 0xC7, 0x83, 0xC7, + 0x80, 0x78, 0xF0, 0x78, 0xF0, 0x0F, 0x3C, 0x07, 0x9E, 0x00, 0xF7, 0x80, + 0xF7, 0x80, 0x1E, 0xF0, 0x1E, 0xF0, 0x03, 0xFE, 0x03, 0xFE, 0x00, 0x7F, + 0x80, 0x3F, 0xC0, 0x07, 0xF0, 0x07, 0xF0, 0x00, 0xFE, 0x00, 0xFE, 0x00, + 0x1F, 0xC0, 0x1F, 0xC0, 0x03, 0xF0, 0x01, 0xF8, 0x00, 0x3E, 0x00, 0x3E, + 0x00, 0x7C, 0x00, 0x0F, 0x9F, 0x00, 0x0F, 0x87, 0xC0, 0x0F, 0x81, 0xF0, + 0x0F, 0x80, 0xF8, 0x07, 0xC0, 0x3E, 0x07, 0xC0, 0x0F, 0x87, 0xC0, 0x03, + 0xE7, 0xC0, 0x01, 0xF3, 0xE0, 0x00, 0x7F, 0xE0, 0x00, 0x1F, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x03, 0xF0, 0x00, 0x03, 0xF8, 0x00, 0x01, 0xFE, 0x00, + 0x01, 0xFF, 0x80, 0x01, 0xF7, 0xC0, 0x01, 0xF1, 0xF0, 0x00, 0xF8, 0x7C, + 0x00, 0xF8, 0x3E, 0x00, 0xF8, 0x0F, 0x80, 0xF8, 0x03, 0xE0, 0x7C, 0x00, + 0xF8, 0x7C, 0x00, 0x7C, 0x7C, 0x00, 0x1F, 0x7C, 0x00, 0x07, 0xC0, 0xF8, + 0x00, 0x0F, 0xBC, 0x00, 0x07, 0x9E, 0x00, 0x03, 0xCF, 0x80, 0x03, 0xE3, + 0xC0, 0x01, 0xE1, 0xF0, 0x01, 0xF0, 0x78, 0x00, 0xF0, 0x3C, 0x00, 0x78, + 0x1F, 0x00, 0x7C, 0x07, 0x80, 0x3C, 0x03, 0xE0, 0x3E, 0x00, 0xF0, 0x1E, + 0x00, 0x78, 0x0F, 0x00, 0x3E, 0x0F, 0x80, 0x0F, 0x07, 0x80, 0x07, 0xC7, + 0xC0, 0x01, 0xE3, 0xC0, 0x00, 0xF1, 0xE0, 0x00, 0x7D, 0xE0, 0x00, 0x1E, + 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x03, 0xF8, 0x00, 0x01, 0xFC, 0x00, 0x00, + 0xFC, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x0F, 0x80, 0x00, 0x07, 0x80, 0x00, 0x07, 0xC0, 0x00, 0x07, 0xE0, 0x00, + 0x07, 0xE0, 0x00, 0x3F, 0xF0, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x07, 0xE0, 0x00, 0x00, 0x7F, 0xFF, 0xFB, 0xFF, 0xFF, 0xDF, 0xFF, 0xFE, + 0xFF, 0xFF, 0xF0, 0x00, 0x1F, 0x00, 0x01, 0xF8, 0x00, 0x1F, 0x80, 0x00, + 0xF8, 0x00, 0x0F, 0x80, 0x00, 0xF8, 0x00, 0x0F, 0x80, 0x00, 0xF8, 0x00, + 0x0F, 0xC0, 0x00, 0xFC, 0x00, 0x07, 0xC0, 0x00, 0x7C, 0x00, 0x07, 0xC0, + 0x00, 0x7C, 0x00, 0x07, 0xE0, 0x00, 0x7E, 0x00, 0x07, 0xE0, 0x00, 0x3E, + 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xC0, 0x00, 0x0F, 0xC0, 0x1F, 0xF0, 0x0F, 0xFC, 0x03, 0xFF, 0x01, 0xF8, + 0x00, 0x7C, 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, + 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, 0x07, + 0x80, 0x01, 0xE0, 0x00, 0xF8, 0x00, 0x7C, 0x03, 0xFF, 0x00, 0xFF, 0x00, + 0x3F, 0xC0, 0x0F, 0xF8, 0x00, 0x3F, 0x00, 0x03, 0xE0, 0x00, 0xF8, 0x00, + 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, 0x07, + 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, + 0x00, 0x7C, 0x00, 0x1F, 0x80, 0x03, 0xFF, 0x00, 0xFF, 0xC0, 0x1F, 0xF0, + 0x01, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xF0, 0xFE, 0x00, 0x3F, 0xE0, 0x0F, 0xFC, 0x03, 0xFF, 0x00, 0x07, + 0xE0, 0x00, 0xF8, 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, + 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, + 0x07, 0x80, 0x01, 0xE0, 0x00, 0x7C, 0x00, 0x0F, 0x80, 0x03, 0xFF, 0x00, + 0x3F, 0xC0, 0x0F, 0xF0, 0x07, 0xFC, 0x03, 0xF0, 0x01, 0xF0, 0x00, 0x7C, + 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, + 0x07, 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x1E, 0x00, 0x07, 0x80, 0x01, + 0xE0, 0x00, 0xF8, 0x00, 0x7E, 0x03, 0xFF, 0x00, 0xFF, 0xC0, 0x3F, 0xE0, + 0x0F, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0x80, 0x00, 0x47, 0xFF, + 0x80, 0x0E, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, + 0xFB, 0x80, 0x0F, 0xFF, 0x18, 0x00, 0x0F, 0xE0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x03, 0xF8, 0x00, 0x07, 0xFF, 0xC0, 0x03, 0xFF, 0xF8, 0x03, 0xFF, + 0xFF, 0x00, 0xFC, 0x07, 0xC0, 0x7C, 0x00, 0x70, 0x3E, 0x00, 0x0C, 0x0F, + 0x00, 0x01, 0x07, 0xC0, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x3E, 0x00, 0x00, 0x7F, 0xFF, 0xFC, 0x1F, 0xFF, 0xFE, 0x0F, 0xFF, 0xFF, + 0x80, 0x3C, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x7F, 0xFF, 0xC0, 0x1F, 0xFF, 0xE0, 0x0F, + 0xFF, 0xF8, 0x00, 0x3E, 0x00, 0x00, 0x07, 0x80, 0x00, 0x01, 0xE0, 0x00, + 0x00, 0x78, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x03, 0xE0, 0x00, 0x40, 0xF8, + 0x00, 0x30, 0x1F, 0x80, 0x1C, 0x03, 0xF8, 0x1F, 0x00, 0x7F, 0xFF, 0xC0, + 0x0F, 0xFF, 0xE0, 0x01, 0xFF, 0xF0, 0x00, 0x0F, 0xE0, 0x3C, 0xF3, 0xCF, + 0x3C, 0xE7, 0x9C, 0x73, 0xCE, 0x00, 0x00, 0x0F, 0xF0, 0x03, 0xFF, 0x00, + 0x7F, 0xF0, 0x07, 0xFF, 0x00, 0xF8, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x0F, 0xFF, 0xE0, 0xFF, 0xFE, + 0x0F, 0xFF, 0xE0, 0xFF, 0xFE, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x01, 0xF0, + 0x00, 0x3E, 0x00, 0xFF, 0xE0, 0x0F, 0xFC, 0x00, 0xFF, 0x80, 0x0F, 0xF0, + 0x00, 0x3C, 0x1E, 0x78, 0x3C, 0xF0, 0x79, 0xE0, 0xF3, 0xC1, 0xE7, 0x03, + 0x9E, 0x0F, 0x38, 0x1C, 0x70, 0x39, 0xE0, 0xF3, 0x81, 0xC0, 0xF0, 0x00, + 0xF0, 0x00, 0xFF, 0x00, 0x0F, 0x00, 0x0F, 0xF0, 0x00, 0xF0, 0x00, 0xFF, + 0x00, 0x0F, 0x00, 0x0F, 0xF0, 0x00, 0xF0, 0x00, 0xFF, 0x00, 0x0F, 0x00, + 0x0F, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF0, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, + 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x07, 0xE0, 0x00, 0x0E, 0x00, 0x00, 0x00, + 0x07, 0xFE, 0x00, 0x07, 0x80, 0x00, 0x00, 0x03, 0xFF, 0xC0, 0x01, 0xC0, + 0x00, 0x00, 0x01, 0xF0, 0xF0, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x78, 0x1E, + 0x00, 0x38, 0x00, 0x00, 0x00, 0x1E, 0x07, 0x80, 0x1E, 0x00, 0x00, 0x00, + 0x0F, 0x00, 0xF0, 0x0F, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x3C, 0x03, 0x80, + 0x00, 0x00, 0x00, 0xF0, 0x0F, 0x01, 0xE0, 0x00, 0x00, 0x00, 0x3C, 0x03, + 0xC0, 0x70, 0x00, 0x00, 0x00, 0x0F, 0x00, 0xF0, 0x3C, 0x00, 0x00, 0x00, + 0x03, 0xC0, 0x3C, 0x0E, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x0F, 0x07, 0x00, + 0x00, 0x00, 0x00, 0x1E, 0x07, 0x83, 0xC0, 0x00, 0x00, 0x00, 0x07, 0x81, + 0xE0, 0xE0, 0x00, 0x00, 0x00, 0x01, 0xF0, 0xF8, 0x78, 0x00, 0x00, 0x00, + 0x00, 0x3F, 0xFC, 0x1C, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFE, 0x0F, 0x03, + 0xF0, 0x00, 0x7E, 0x00, 0x7E, 0x07, 0x83, 0xFF, 0x00, 0x7F, 0xE0, 0x00, + 0x01, 0xC1, 0xFF, 0xE0, 0x3F, 0xFC, 0x00, 0x00, 0xF0, 0xF8, 0x7C, 0x1F, + 0x0F, 0x80, 0x00, 0x38, 0x3C, 0x0F, 0x07, 0x81, 0xE0, 0x00, 0x1E, 0x0F, + 0x03, 0xC1, 0xE0, 0x78, 0x00, 0x07, 0x07, 0x80, 0x78, 0xF0, 0x0F, 0x00, + 0x03, 0x81, 0xE0, 0x1E, 0x3C, 0x03, 0xC0, 0x01, 0xE0, 0x78, 0x07, 0x8F, + 0x00, 0xF0, 0x00, 0x70, 0x1E, 0x01, 0xE3, 0xC0, 0x3C, 0x00, 0x3C, 0x07, + 0x80, 0x78, 0xF0, 0x0F, 0x00, 0x0E, 0x01, 0xE0, 0x1E, 0x3C, 0x03, 0xC0, + 0x07, 0x80, 0x78, 0x07, 0x8F, 0x00, 0xF0, 0x01, 0xC0, 0x0F, 0x03, 0xC1, + 0xE0, 0x78, 0x00, 0xE0, 0x03, 0xC0, 0xF0, 0x78, 0x1E, 0x00, 0x78, 0x00, + 0xF8, 0x78, 0x1F, 0x0F, 0x00, 0x1C, 0x00, 0x1F, 0xFE, 0x03, 0xFF, 0xC0, + 0x0F, 0x00, 0x03, 0xFF, 0x00, 0x7F, 0xE0, 0x03, 0x80, 0x00, 0x3F, 0x00, + 0x07, 0xE0, 0x00, 0x20, 0x0C, 0x03, 0x80, 0xF0, 0x3C, 0x0F, 0x03, 0xC1, + 0xF0, 0x7C, 0x1F, 0x03, 0xC0, 0x7C, 0x07, 0xC0, 0x7C, 0x03, 0xC0, 0x3C, + 0x03, 0xC0, 0x3C, 0x03, 0x80, 0x30, 0x02, 0x1C, 0xF3, 0x8E, 0x79, 0xCF, + 0x3C, 0xF3, 0xCF, 0x00, 0x3C, 0xF3, 0xCF, 0x3D, 0xE7, 0x9C, 0x73, 0xCE, + 0x00, 0x1C, 0x0E, 0x78, 0x3C, 0xE0, 0x71, 0xC0, 0xE7, 0x83, 0xCE, 0x07, + 0x3C, 0x1E, 0x78, 0x3C, 0xF0, 0x79, 0xE0, 0xF3, 0xC1, 0xE0, 0x3C, 0x1E, + 0x78, 0x3C, 0xF0, 0x79, 0xE0, 0xF3, 0xC1, 0xE7, 0x03, 0x9E, 0x0F, 0x38, + 0x1C, 0x70, 0x39, 0xE0, 0xF3, 0x81, 0xC0, 0x0F, 0xC0, 0x7F, 0x83, 0xFF, + 0x1F, 0xFE, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF7, 0xFF, 0x8F, 0xFC, 0x1F, 0xE0, 0x3F, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xE0, 0x1F, 0xFF, 0xF3, 0xE0, 0x7C, 0x1C, + 0x07, 0xE1, 0xF8, 0x38, 0x0E, 0xE3, 0x70, 0x70, 0x1C, 0xCC, 0xE0, 0xE0, + 0x39, 0xF9, 0xC1, 0xC0, 0x71, 0xE3, 0x83, 0x80, 0xE1, 0xC7, 0x07, 0x01, + 0xC3, 0x0E, 0x0E, 0x03, 0x80, 0x1C, 0x1C, 0x07, 0x00, 0x38, 0x38, 0x0E, + 0x00, 0x70, 0x70, 0x1C, 0x00, 0xE0, 0x80, 0x18, 0x03, 0x80, 0x78, 0x07, + 0x80, 0x78, 0x07, 0x80, 0x7C, 0x07, 0xC0, 0x7C, 0x07, 0x81, 0xF0, 0x7C, + 0x1F, 0x07, 0x81, 0xE0, 0x78, 0x1E, 0x03, 0x80, 0x60, 0x08, 0x00, 0x00, + 0x00, 0x3E, 0x00, 0xF8, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x00, + 0xF0, 0x03, 0xC0, 0x0F, 0x00, 0x00, 0x03, 0xE1, 0xF7, 0xC3, 0xEF, 0x87, + 0xDF, 0x0F, 0xBE, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x01, 0xE0, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x7C, 0x7C, 0x00, + 0x00, 0xF1, 0xFC, 0x00, 0x03, 0xC3, 0xF8, 0x00, 0x0F, 0x07, 0xF0, 0x00, + 0x1C, 0x1F, 0xF0, 0x00, 0x00, 0x3D, 0xE0, 0x00, 0x00, 0xFB, 0xE0, 0x00, + 0x01, 0xE3, 0xC0, 0x00, 0x03, 0xC7, 0x80, 0x00, 0x0F, 0x8F, 0x80, 0x00, + 0x1E, 0x0F, 0x00, 0x00, 0x3C, 0x1E, 0x00, 0x00, 0xF0, 0x1E, 0x00, 0x01, + 0xE0, 0x3C, 0x00, 0x07, 0xC0, 0x7C, 0x00, 0x0F, 0x00, 0x78, 0x00, 0x1E, + 0x00, 0xF0, 0x00, 0x7C, 0x01, 0xF0, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xE0, + 0x03, 0xE0, 0x07, 0x80, 0x03, 0xC0, 0x0F, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, + 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFE, 0x03, 0xE0, 0x00, + 0x3E, 0x07, 0x80, 0x00, 0x3C, 0x1F, 0x00, 0x00, 0x7C, 0x3C, 0x00, 0x00, + 0x78, 0x78, 0x00, 0x00, 0xF1, 0xF0, 0x00, 0x01, 0xF3, 0xC0, 0x00, 0x01, + 0xE7, 0x80, 0x00, 0x03, 0xDE, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x1F, 0xF0, + 0x01, 0xFF, 0xF0, 0x0F, 0xFF, 0xC0, 0x7F, 0xFF, 0x03, 0xF0, 0x0C, 0x0F, + 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, + 0x00, 0x78, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, 0x00, + 0x78, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x03, 0xFF, 0xFF, 0x0F, 0xFF, + 0xFC, 0x3F, 0xFF, 0xF0, 0xFF, 0xFF, 0xC0, 0x1E, 0x00, 0x00, 0x78, 0x00, + 0x01, 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x01, + 0xE0, 0x00, 0x07, 0x80, 0x00, 0x1E, 0x00, 0x00, 0x78, 0x00, 0x3F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x20, 0x00, + 0x01, 0x1C, 0x00, 0x00, 0xEF, 0x80, 0x00, 0x7D, 0xF0, 0xFC, 0x3E, 0x3E, + 0xFF, 0xDF, 0x07, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xC0, 0x1F, 0x87, 0xE0, + 0x0F, 0x80, 0x7C, 0x03, 0xC0, 0x0F, 0x01, 0xF0, 0x03, 0xE0, 0x78, 0x00, + 0x78, 0x1E, 0x00, 0x1E, 0x07, 0x80, 0x07, 0x81, 0xE0, 0x01, 0xE0, 0x7C, + 0x00, 0xF8, 0x0F, 0x00, 0x3C, 0x03, 0xE0, 0x1F, 0x00, 0x7E, 0x1F, 0x80, + 0x3F, 0xFF, 0xF0, 0x1F, 0xFF, 0xFE, 0x0F, 0xBF, 0xF7, 0xC7, 0xC3, 0xF0, + 0xFB, 0xE0, 0x00, 0x1F, 0x70, 0x00, 0x03, 0x88, 0x00, 0x00, 0x40, 0xF8, + 0x00, 0x07, 0xDE, 0x00, 0x01, 0xE7, 0xC0, 0x00, 0xF8, 0xF8, 0x00, 0x7C, + 0x1E, 0x00, 0x1E, 0x07, 0xC0, 0x0F, 0x80, 0xF0, 0x03, 0xC0, 0x3E, 0x01, + 0xF0, 0x07, 0x80, 0x78, 0x01, 0xF0, 0x3E, 0x00, 0x3C, 0x0F, 0x00, 0x0F, + 0x87, 0x80, 0x01, 0xF3, 0xE0, 0x1F, 0xFC, 0xFF, 0xE7, 0xFF, 0xFF, 0xF9, + 0xFF, 0xFF, 0xFE, 0x00, 0x7F, 0x80, 0x00, 0x0F, 0xC0, 0x00, 0x03, 0xF0, + 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x1F, 0xFF, 0xFF, 0xE7, 0xFF, + 0xFF, 0xF9, 0xFF, 0xFF, 0xFE, 0x00, 0x1E, 0x00, 0x00, 0x07, 0x80, 0x00, + 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x07, 0x80, + 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x07, + 0x80, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, + 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xFE, + 0x00, 0xFF, 0xF0, 0x7F, 0xFE, 0x0F, 0x83, 0xC3, 0xE0, 0x08, 0x78, 0x00, + 0x0F, 0x00, 0x01, 0xE0, 0x00, 0x3E, 0x00, 0x03, 0xE0, 0x00, 0x7E, 0x00, + 0x07, 0xF0, 0x01, 0xFF, 0x00, 0x7B, 0xF8, 0x1E, 0x1F, 0x83, 0xC1, 0xFC, + 0xF0, 0x0F, 0xDE, 0x00, 0xFB, 0xC0, 0x0F, 0xFC, 0x00, 0xFF, 0x80, 0x1E, + 0xF8, 0x03, 0xCF, 0xC0, 0x78, 0xFC, 0x1E, 0x0F, 0xE7, 0xC0, 0x7F, 0xF0, + 0x03, 0xF8, 0x00, 0x3F, 0x00, 0x03, 0xF0, 0x00, 0x1E, 0x00, 0x03, 0xE0, + 0x00, 0x3C, 0x00, 0x07, 0x80, 0x00, 0xF0, 0x80, 0x3E, 0x1E, 0x0F, 0x83, + 0xFF, 0xE0, 0x7F, 0xF8, 0x03, 0xFC, 0x00, 0xF8, 0x7F, 0xE1, 0xFF, 0x87, + 0xFE, 0x1F, 0xF8, 0x7C, 0x00, 0x07, 0xF8, 0x00, 0x00, 0x0F, 0xFF, 0xC0, + 0x00, 0x0F, 0x80, 0x7C, 0x00, 0x07, 0x00, 0x03, 0x80, 0x03, 0x80, 0x00, + 0x70, 0x01, 0x80, 0x00, 0x06, 0x00, 0xC0, 0x3F, 0xC0, 0xC0, 0x60, 0x3F, + 0xFC, 0x18, 0x38, 0x3F, 0xFF, 0x07, 0x0C, 0x1F, 0x80, 0xC0, 0xC6, 0x07, + 0xC0, 0x00, 0x19, 0x83, 0xE0, 0x00, 0x06, 0x60, 0xF0, 0x00, 0x01, 0xB0, + 0x7C, 0x00, 0x00, 0x3C, 0x1E, 0x00, 0x00, 0x0F, 0x07, 0x80, 0x00, 0x03, + 0xC1, 0xE0, 0x00, 0x00, 0xF0, 0x78, 0x00, 0x00, 0x3C, 0x1E, 0x00, 0x00, + 0x0F, 0x07, 0x80, 0x00, 0x03, 0xC1, 0xF0, 0x00, 0x00, 0xD8, 0x3C, 0x00, + 0x00, 0x66, 0x0F, 0x80, 0x00, 0x19, 0x81, 0xF0, 0x00, 0x06, 0x30, 0x7E, + 0x03, 0x03, 0x0E, 0x0F, 0xFF, 0xC1, 0xC1, 0x80, 0xFF, 0xF0, 0x60, 0x30, + 0x0F, 0xF0, 0x30, 0x06, 0x00, 0x00, 0x18, 0x00, 0xE0, 0x00, 0x1C, 0x00, + 0x1C, 0x00, 0x0E, 0x00, 0x03, 0xE0, 0x1F, 0x00, 0x00, 0x3F, 0xFF, 0x00, + 0x00, 0x01, 0xFE, 0x00, 0x00, 0x00, 0x20, 0x08, 0x03, 0x00, 0xC0, 0x38, + 0x0E, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x07, + 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x3C, 0x0F, 0x01, 0xF0, 0x7C, + 0x07, 0xC1, 0xF0, 0x1F, 0x07, 0xC0, 0x3C, 0x0F, 0x00, 0xF0, 0x3C, 0x03, + 0xC0, 0xF0, 0x0F, 0x03, 0xC0, 0x38, 0x0E, 0x00, 0xC0, 0x30, 0x02, 0x00, + 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x3C, 0x00, + 0x00, 0x01, 0xE0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x03, 0xC0, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x07, + 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x07, 0xF8, 0x00, 0x00, + 0x0F, 0xFF, 0xC0, 0x00, 0x0F, 0x80, 0x7C, 0x00, 0x07, 0x00, 0x03, 0x80, + 0x03, 0x80, 0x00, 0x70, 0x01, 0x80, 0x00, 0x06, 0x00, 0xC3, 0xFF, 0x80, + 0xC0, 0x60, 0xFF, 0xF8, 0x18, 0x38, 0x3C, 0x0F, 0x07, 0x0C, 0x0F, 0x01, + 0xE0, 0xC6, 0x03, 0xC0, 0x78, 0x19, 0x80, 0xF0, 0x1E, 0x06, 0x60, 0x3C, + 0x07, 0x81, 0xB0, 0x0F, 0x01, 0xE0, 0x3C, 0x03, 0xC0, 0xF0, 0x0F, 0x00, + 0xFF, 0xF8, 0x03, 0xC0, 0x3F, 0xF0, 0x00, 0xF0, 0x0F, 0x1E, 0x00, 0x3C, + 0x03, 0xC3, 0xC0, 0x0F, 0x00, 0xF0, 0x78, 0x03, 0xC0, 0x3C, 0x1E, 0x00, + 0xD8, 0x0F, 0x07, 0xC0, 0x66, 0x03, 0xC0, 0xF0, 0x19, 0x80, 0xF0, 0x3E, + 0x06, 0x30, 0x3C, 0x07, 0x83, 0x0E, 0x0F, 0x01, 0xF1, 0xC1, 0x83, 0xC0, + 0x3C, 0x60, 0x30, 0xF0, 0x0F, 0xB0, 0x06, 0x00, 0x00, 0x18, 0x00, 0xE0, + 0x00, 0x1C, 0x00, 0x1C, 0x00, 0x0E, 0x00, 0x03, 0xE0, 0x1F, 0x00, 0x00, + 0x3F, 0xFF, 0x00, 0x00, 0x01, 0xFE, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x07, 0xC0, 0x3F, 0xE0, + 0xFF, 0xE3, 0xC1, 0xE7, 0x01, 0xDC, 0x01, 0xF8, 0x03, 0xF0, 0x07, 0xE0, + 0x0F, 0xC0, 0x1D, 0xC0, 0x73, 0xC1, 0xE3, 0xFF, 0x83, 0xFE, 0x01, 0xF0, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, + 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x00, 0x78, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x03, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFC, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x01, 0xE0, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x78, 0x00, + 0x00, 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xC3, 0xFF, 0xCF, 0xFF, 0xB0, + 0x1F, 0x00, 0x3C, 0x00, 0x70, 0x01, 0xC0, 0x0E, 0x00, 0x78, 0x03, 0xC0, + 0x1E, 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x03, 0xE0, 0x1E, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xC0, 0x1F, 0xC1, 0xFF, 0xC7, 0xFF, 0x98, 0x0F, 0x00, + 0x1C, 0x00, 0x70, 0x03, 0x83, 0xFE, 0x0F, 0xE0, 0x3F, 0xE0, 0x07, 0x80, + 0x07, 0x00, 0x1C, 0x00, 0x70, 0x03, 0xF0, 0x1F, 0xFF, 0xFB, 0xFF, 0xC3, + 0xFC, 0x00, 0x03, 0xE0, 0xF8, 0x1E, 0x07, 0x81, 0xE0, 0x78, 0x0F, 0x03, + 0xC0, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0xF8, 0x01, 0xE0, 0x07, 0x80, 0x1E, + 0x00, 0x78, 0x00, 0xF0, 0x03, 0xC0, 0x0F, 0x00, 0x00, 0x03, 0xE1, 0xF7, + 0xC3, 0xEF, 0x87, 0xDF, 0x0F, 0xBE, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x00, + 0x01, 0xE0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, + 0x7C, 0x7C, 0x00, 0x00, 0xF1, 0xFC, 0x00, 0x03, 0xC3, 0xF8, 0x00, 0x0F, + 0x07, 0xF0, 0x00, 0x1C, 0x1F, 0xF0, 0x00, 0x00, 0x3D, 0xE0, 0x00, 0x00, + 0xFB, 0xE0, 0x00, 0x01, 0xE3, 0xC0, 0x00, 0x03, 0xC7, 0x80, 0x00, 0x0F, + 0x8F, 0x80, 0x00, 0x1E, 0x0F, 0x00, 0x00, 0x3C, 0x1E, 0x00, 0x00, 0xF0, + 0x1E, 0x00, 0x01, 0xE0, 0x3C, 0x00, 0x07, 0xC0, 0x7C, 0x00, 0x0F, 0x00, + 0x78, 0x00, 0x1E, 0x00, 0xF0, 0x00, 0x7C, 0x01, 0xF0, 0x00, 0xF0, 0x01, + 0xE0, 0x03, 0xE0, 0x03, 0xE0, 0x07, 0x80, 0x03, 0xC0, 0x0F, 0xFF, 0xFF, + 0x80, 0x3F, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFE, + 0x03, 0xE0, 0x00, 0x3E, 0x07, 0x80, 0x00, 0x3C, 0x1F, 0x00, 0x00, 0x7C, + 0x3C, 0x00, 0x00, 0x78, 0x78, 0x00, 0x00, 0xF1, 0xF0, 0x00, 0x01, 0xF3, + 0xC0, 0x00, 0x01, 0xE7, 0x80, 0x00, 0x03, 0xDE, 0x00, 0x00, 0x03, 0xC0, + 0xFF, 0xFF, 0xFF, 0x03, 0xC0, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x01, 0xE7, 0xFF, 0xFF, 0xE7, 0x8F, + 0xFF, 0xFF, 0xCF, 0x1F, 0xFF, 0xFF, 0xBC, 0x3F, 0xFF, 0xFF, 0xF0, 0x78, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x1E, 0x00, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x01, 0xFF, 0xFF, 0xF0, 0x03, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, + 0xC0, 0x0F, 0xFF, 0xFF, 0x80, 0x1E, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x00, 0x78, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, + 0x03, 0xC0, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0x1E, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x01, 0xFF, 0xFF, 0xF8, 0x03, 0xFF, 0xFF, 0xF0, 0x07, + 0xFF, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xC0, 0x03, 0xE0, 0x00, 0x00, 0x00, + 0x78, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x07, 0x80, 0x00, 0x00, + 0x01, 0xE7, 0x80, 0x00, 0x1E, 0x3C, 0xF0, 0x00, 0x03, 0xCF, 0x1E, 0x00, + 0x00, 0x7B, 0xC3, 0xC0, 0x00, 0x0F, 0x70, 0x78, 0x00, 0x01, 0xE0, 0x0F, + 0x00, 0x00, 0x3C, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x3C, 0x00, 0x00, 0xF0, + 0x07, 0x80, 0x00, 0x1E, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x1E, 0x00, 0x00, + 0x78, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x78, 0x00, 0x01, 0xE0, 0x0F, 0x00, + 0x00, 0x3C, 0x01, 0xFF, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0xFF, 0xF0, 0x07, + 0xFF, 0xFF, 0xFE, 0x00, 0xFF, 0xFF, 0xFF, 0xC0, 0x1E, 0x00, 0x00, 0x78, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x78, 0x00, 0x01, 0xE0, 0x0F, 0x00, 0x00, + 0x3C, 0x01, 0xE0, 0x00, 0x07, 0x80, 0x3C, 0x00, 0x00, 0xF0, 0x07, 0x80, + 0x00, 0x1E, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x1E, 0x00, 0x00, 0x78, 0x03, + 0xC0, 0x00, 0x0F, 0x00, 0x78, 0x00, 0x01, 0xE0, 0x0F, 0x00, 0x00, 0x3C, + 0x01, 0xE0, 0x00, 0x07, 0x80, 0x3C, 0x00, 0x00, 0xF0, 0x07, 0x80, 0x00, + 0x1E, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x03, 0xE0, 0x3E, 0x01, 0xE0, 0x1E, + 0x01, 0xE7, 0x8E, 0x3C, 0xF1, 0xEF, 0x0F, 0xF0, 0x78, 0x03, 0xC0, 0x1E, + 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x01, 0xE0, 0x0F, 0x00, 0x78, 0x03, 0xC0, + 0x1E, 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x01, 0xE0, 0x0F, 0x00, 0x78, 0x03, + 0xC0, 0x1E, 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x01, 0xE0, 0x0F, 0x00, 0x78, + 0x03, 0xC0, 0x1E, 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x80, 0x20, 0x06, 0x01, + 0x80, 0x38, 0x0E, 0x01, 0xE0, 0x78, 0x07, 0x81, 0xE0, 0x1E, 0x07, 0x80, + 0x78, 0x1E, 0x01, 0xF0, 0x7C, 0x07, 0xC1, 0xF0, 0x1F, 0x07, 0xC0, 0x78, + 0x1E, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x78, 0x1E, 0x07, + 0x81, 0xE0, 0x78, 0x1E, 0x07, 0x81, 0xE0, 0x38, 0x0E, 0x01, 0x80, 0x60, + 0x08, 0x02, 0x00, 0x00, 0x03, 0xE0, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x00, 0x00, 0xF0, 0x0F, 0xF0, 0x00, 0x1E, 0x07, + 0xFF, 0xE0, 0x01, 0xE1, 0xFF, 0xFF, 0x80, 0x3C, 0x3F, 0xFF, 0xFC, 0x07, + 0x87, 0xF0, 0x0F, 0xE0, 0x70, 0xFC, 0x00, 0x3F, 0x00, 0x1F, 0x00, 0x01, + 0xF8, 0x03, 0xE0, 0x00, 0x0F, 0x80, 0x3E, 0x00, 0x00, 0x7C, 0x07, 0xC0, + 0x00, 0x03, 0xC0, 0x78, 0x00, 0x00, 0x3E, 0x07, 0x80, 0x00, 0x01, 0xE0, + 0xF8, 0x00, 0x00, 0x1E, 0x0F, 0x00, 0x00, 0x01, 0xF0, 0xF0, 0x00, 0x00, + 0x0F, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0xF0, 0x00, 0x00, 0x0F, 0x0F, 0x00, + 0x00, 0x00, 0xF0, 0xF0, 0x00, 0x00, 0x0F, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0xF0, 0x00, 0x00, 0x0F, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0xF0, 0x00, 0x00, + 0x1F, 0x0F, 0x80, 0x00, 0x01, 0xE0, 0x78, 0x00, 0x00, 0x1E, 0x07, 0x80, + 0x00, 0x03, 0xE0, 0x7C, 0x00, 0x00, 0x3C, 0x03, 0xE0, 0x00, 0x07, 0xC0, + 0x3E, 0x00, 0x00, 0xF8, 0x01, 0xF0, 0x00, 0x1F, 0x80, 0x0F, 0xC0, 0x03, + 0xF0, 0x00, 0x7F, 0x00, 0xFE, 0x00, 0x03, 0xFF, 0xFF, 0xC0, 0x00, 0x1F, + 0xFF, 0xF8, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x00, 0x01, 0xFF, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0x1E, 0x01, 0xFE, 0x00, 0x00, 0x78, 0x03, 0xFC, 0x00, + 0x00, 0xE0, 0x07, 0x38, 0x00, 0x03, 0xC0, 0x00, 0x70, 0x00, 0x07, 0x00, + 0x00, 0xE0, 0x00, 0x1E, 0x00, 0x01, 0xC0, 0x00, 0x78, 0x00, 0x03, 0x80, + 0x00, 0xE0, 0x00, 0x07, 0x00, 0x03, 0xC0, 0x00, 0x0E, 0x00, 0x07, 0x00, + 0x00, 0x1C, 0x00, 0x1E, 0x00, 0x00, 0x38, 0x00, 0x78, 0x00, 0x00, 0x70, + 0x00, 0xE0, 0x00, 0x00, 0xE0, 0x03, 0xC0, 0x00, 0x01, 0xC0, 0x07, 0x00, + 0x00, 0x03, 0x80, 0x1E, 0x0F, 0xF0, 0xFF, 0xF8, 0x78, 0x7F, 0xF9, 0xFF, + 0xF0, 0xE0, 0xFF, 0xFB, 0xFF, 0xE3, 0xC1, 0x80, 0xF0, 0x00, 0x07, 0x00, + 0x00, 0xF0, 0x00, 0x1E, 0x00, 0x00, 0xE0, 0x00, 0x78, 0x00, 0x01, 0x80, + 0x00, 0xE0, 0x00, 0x07, 0x00, 0x03, 0xC0, 0x00, 0x1C, 0x00, 0x07, 0x00, + 0x00, 0x78, 0x00, 0x1E, 0x00, 0x01, 0xE0, 0x00, 0x38, 0x00, 0x07, 0x80, + 0x00, 0xE0, 0x00, 0x1E, 0x00, 0x03, 0xC0, 0x00, 0xF8, 0x00, 0x07, 0x00, + 0x03, 0xE0, 0x00, 0x1E, 0x00, 0x1F, 0x00, 0x00, 0x38, 0x00, 0x3F, 0xFF, + 0x00, 0xE0, 0x00, 0x7F, 0xFE, 0x03, 0xC0, 0x00, 0xFF, 0xFC, 0x07, 0x00, + 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, + 0x00, 0x1F, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x00, 0x1E, 0x3E, 0x00, 0x00, 0x78, 0xF0, 0x7C, 0x00, 0x03, + 0xE3, 0x81, 0xF0, 0x00, 0x1F, 0x1E, 0x03, 0xE0, 0x00, 0x78, 0xF0, 0x07, + 0xC0, 0x03, 0xE0, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00, 0x3E, 0x00, 0x78, + 0x00, 0x00, 0x7C, 0x03, 0xE0, 0x00, 0x00, 0xF0, 0x1F, 0x00, 0x00, 0x03, + 0xE0, 0x78, 0x00, 0x00, 0x07, 0xC3, 0xE0, 0x00, 0x00, 0x0F, 0x1F, 0x00, + 0x00, 0x00, 0x3E, 0x78, 0x00, 0x00, 0x00, 0x7F, 0xE0, 0x00, 0x00, 0x00, + 0xFF, 0x00, 0x00, 0x00, 0x03, 0xF8, 0x00, 0x00, 0x00, 0x07, 0xE0, 0x00, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x03, + 0xC0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x03, 0xC0, 0x00, + 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x00, 0xF0, + 0x0F, 0xF8, 0x00, 0x1E, 0x07, 0xFF, 0xE0, 0x03, 0xC1, 0xFF, 0xFF, 0x80, + 0x78, 0x3F, 0xFF, 0xFE, 0x07, 0x07, 0xF8, 0x0F, 0xF0, 0xF0, 0xFC, 0x00, + 0x3F, 0x80, 0x1F, 0x80, 0x00, 0xF8, 0x03, 0xE0, 0x00, 0x07, 0xC0, 0x3E, + 0x00, 0x00, 0x3E, 0x07, 0xC0, 0x00, 0x01, 0xE0, 0x78, 0x00, 0x00, 0x1E, + 0x07, 0x80, 0x00, 0x01, 0xF0, 0x78, 0x00, 0x00, 0x0F, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0xF0, 0x00, 0x00, 0x0F, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0xF0, + 0x00, 0x00, 0x0F, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0xF0, 0x00, 0x00, 0x0F, + 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x78, 0x00, 0x00, 0x0F, 0x07, 0x80, 0x00, + 0x01, 0xF0, 0x78, 0x00, 0x00, 0x1E, 0x03, 0xC0, 0x00, 0x01, 0xE0, 0x3C, + 0x00, 0x00, 0x3E, 0x01, 0xE0, 0x00, 0x07, 0xC0, 0x1F, 0x00, 0x00, 0x78, + 0x00, 0xF8, 0x00, 0x0F, 0x80, 0x07, 0xC0, 0x01, 0xF0, 0x00, 0x3E, 0x00, + 0x3E, 0x00, 0x01, 0xF0, 0x0F, 0xC0, 0x0F, 0xFF, 0x81, 0xFF, 0xF0, 0xFF, + 0xF8, 0x1F, 0xFF, 0x0F, 0xFF, 0x81, 0xFF, 0xF0, 0xFF, 0xF8, 0x1F, 0xFF, + 0x00, 0x3E, 0x00, 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x3C, 0x00, + 0xF0, 0x03, 0xC0, 0x07, 0x00, 0x00, 0x03, 0xE1, 0xF7, 0xC3, 0xEF, 0x87, + 0xDF, 0x0F, 0xBE, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x07, 0x80, 0x0F, 0x00, 0x1E, + 0x00, 0x3C, 0x00, 0x78, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC0, 0x07, 0x80, + 0x0F, 0x00, 0x1E, 0x00, 0x3C, 0x00, 0x78, 0x00, 0xF0, 0x01, 0xE0, 0x03, + 0xC0, 0x03, 0xC0, 0x07, 0x80, 0x0F, 0x80, 0x1F, 0xF0, 0x1F, 0xE0, 0x1F, + 0xC0, 0x0F, 0x80, 0x00, 0x07, 0xC0, 0x00, 0x00, 0x1F, 0xC0, 0x00, 0x00, + 0x3F, 0x80, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x03, + 0xDE, 0x00, 0x00, 0x0F, 0xBE, 0x00, 0x00, 0x1E, 0x3C, 0x00, 0x00, 0x3C, + 0x78, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x01, 0xE0, 0xF0, 0x00, 0x03, 0xC1, + 0xE0, 0x00, 0x0F, 0x01, 0xE0, 0x00, 0x1E, 0x03, 0xC0, 0x00, 0x7C, 0x07, + 0xC0, 0x00, 0xF0, 0x07, 0x80, 0x01, 0xE0, 0x0F, 0x00, 0x07, 0xC0, 0x1F, + 0x00, 0x0F, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x78, 0x00, 0x3C, + 0x00, 0xFF, 0xFF, 0xF8, 0x03, 0xFF, 0xFF, 0xF8, 0x07, 0xFF, 0xFF, 0xF0, + 0x0F, 0xFF, 0xFF, 0xE0, 0x3E, 0x00, 0x03, 0xE0, 0x78, 0x00, 0x03, 0xC1, + 0xF0, 0x00, 0x07, 0xC3, 0xC0, 0x00, 0x07, 0x87, 0x80, 0x00, 0x0F, 0x1F, + 0x00, 0x00, 0x1F, 0x3C, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x3D, 0xE0, + 0x00, 0x00, 0x3C, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xE0, 0xFF, 0xFF, 0xF8, + 0xFF, 0xFF, 0xF8, 0xF0, 0x01, 0xFC, 0xF0, 0x00, 0x7E, 0xF0, 0x00, 0x3E, + 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x1E, + 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0x7C, 0xF0, 0x01, 0xFC, 0xFF, 0xFF, 0xF8, + 0xFF, 0xFF, 0xE0, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xF8, 0xF0, 0x00, 0xFC, + 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0x1E, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x1F, 0xF0, 0x00, 0x3E, 0xF0, 0x00, 0xFE, 0xFF, 0xFF, 0xFC, + 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x78, 0x00, + 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, + 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, + 0x1E, 0x00, 0x00, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, + 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, + 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x0F, 0x00, + 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xF0, 0x00, 0x07, + 0x80, 0x00, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0x1F, 0xC0, 0x00, 0x00, + 0x3F, 0x80, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x01, 0xEF, 0x00, 0x00, 0x03, + 0xDE, 0x00, 0x00, 0x0F, 0xBE, 0x00, 0x00, 0x1E, 0x3C, 0x00, 0x00, 0x3C, + 0x78, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x01, 0xE0, 0xF0, 0x00, 0x03, 0xC1, + 0xE0, 0x00, 0x0F, 0x01, 0xE0, 0x00, 0x1E, 0x03, 0xC0, 0x00, 0x7C, 0x07, + 0xC0, 0x00, 0xF0, 0x07, 0x80, 0x01, 0xE0, 0x0F, 0x00, 0x07, 0xC0, 0x1F, + 0x00, 0x0F, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x78, 0x00, 0x3C, + 0x00, 0xF0, 0x00, 0x78, 0x03, 0xE0, 0x00, 0xF8, 0x07, 0x80, 0x00, 0xF0, + 0x0F, 0x00, 0x01, 0xE0, 0x3E, 0x00, 0x01, 0xE0, 0x78, 0x00, 0x03, 0xC1, + 0xF0, 0x00, 0x07, 0xC3, 0xC0, 0x00, 0x07, 0x87, 0x80, 0x00, 0x0F, 0x1F, + 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFD, 0xFF, + 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, + 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, 0xFF, 0xFF, 0xBF, 0xFF, 0xFE, 0xFF, + 0xFF, 0xFB, 0xFF, 0xFF, 0xEF, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, + 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF0, 0x7F, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xE7, + 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, + 0x1F, 0x80, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, + 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x01, 0xF0, 0x00, 0x00, + 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x00, + 0x00, 0x01, 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, + 0xF8, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x3E, 0x00, + 0x00, 0x07, 0xC0, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x03, + 0xF0, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x07, 0xC0, 0x00, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, + 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, + 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, + 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, + 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, + 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, + 0x00, 0x00, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, + 0xFF, 0xFF, 0x80, 0x03, 0xFF, 0xFF, 0xC0, 0x07, 0xF0, 0x0F, 0xE0, 0x0F, + 0xC0, 0x03, 0xF0, 0x1F, 0x80, 0x01, 0xF8, 0x3F, 0x00, 0x00, 0x7C, 0x3E, + 0x00, 0x00, 0x7C, 0x7C, 0x00, 0x00, 0x3C, 0x78, 0x00, 0x00, 0x1E, 0x78, + 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0xF0, 0x00, 0x00, 0x0F, 0xF0, + 0x00, 0x00, 0x0F, 0xF0, 0xFF, 0xFF, 0x0F, 0xF0, 0xFF, 0xFF, 0x0F, 0xF0, + 0xFF, 0xFF, 0x0F, 0xF0, 0xFF, 0xFF, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, + 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0x78, + 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x7C, + 0x00, 0x00, 0x3C, 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, 0x1F, + 0x80, 0x01, 0xF8, 0x0F, 0xC0, 0x03, 0xF0, 0x07, 0xF0, 0x0F, 0xE0, 0x03, + 0xFF, 0xFF, 0xC0, 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x7F, 0xFE, 0x00, 0x00, + 0x0F, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x1F, 0x9E, + 0x00, 0x07, 0xE3, 0xC0, 0x01, 0xF8, 0x78, 0x00, 0x7E, 0x0F, 0x00, 0x1F, + 0x81, 0xE0, 0x07, 0xE0, 0x3C, 0x01, 0xF8, 0x07, 0x80, 0x7E, 0x00, 0xF0, + 0x1F, 0x80, 0x1E, 0x07, 0xE0, 0x03, 0xC3, 0xF0, 0x00, 0x78, 0xFC, 0x00, + 0x0F, 0x3F, 0x00, 0x01, 0xEF, 0xC0, 0x00, 0x3F, 0xF0, 0x00, 0x07, 0xFC, + 0x00, 0x00, 0xFF, 0x80, 0x00, 0x1F, 0xF8, 0x00, 0x03, 0xDF, 0x80, 0x00, + 0x79, 0xF8, 0x00, 0x0F, 0x1F, 0x80, 0x01, 0xE0, 0xF8, 0x00, 0x3C, 0x0F, + 0x80, 0x07, 0x80, 0xF8, 0x00, 0xF0, 0x0F, 0x80, 0x1E, 0x00, 0xF8, 0x03, + 0xC0, 0x0F, 0x80, 0x78, 0x00, 0xF8, 0x0F, 0x00, 0x0F, 0x81, 0xE0, 0x00, + 0xF8, 0x3C, 0x00, 0x0F, 0x87, 0x80, 0x00, 0xF8, 0xF0, 0x00, 0x0F, 0x9E, + 0x00, 0x00, 0xFC, 0x00, 0x07, 0xC0, 0x00, 0x00, 0x1F, 0xC0, 0x00, 0x00, + 0x3F, 0x80, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x01, 0xEF, 0x00, 0x00, 0x03, + 0xDE, 0x00, 0x00, 0x0F, 0xBE, 0x00, 0x00, 0x1E, 0x3C, 0x00, 0x00, 0x3C, + 0x78, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x01, 0xE0, 0xF0, 0x00, 0x03, 0xC1, + 0xE0, 0x00, 0x0F, 0x01, 0xE0, 0x00, 0x1E, 0x03, 0xC0, 0x00, 0x7C, 0x07, + 0xC0, 0x00, 0xF0, 0x07, 0x80, 0x01, 0xE0, 0x0F, 0x00, 0x07, 0xC0, 0x1F, + 0x00, 0x0F, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x78, 0x00, 0x3C, + 0x00, 0xF0, 0x00, 0x78, 0x03, 0xE0, 0x00, 0xF8, 0x07, 0x80, 0x00, 0xF0, + 0x0F, 0x00, 0x01, 0xE0, 0x3E, 0x00, 0x03, 0xE0, 0x78, 0x00, 0x03, 0xC1, + 0xF0, 0x00, 0x07, 0xC3, 0xC0, 0x00, 0x07, 0x87, 0x80, 0x00, 0x0F, 0x1F, + 0x00, 0x00, 0x1F, 0x3C, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x3D, 0xE0, + 0x00, 0x00, 0x3C, 0xFE, 0x00, 0x00, 0xFF, 0xFC, 0x00, 0x01, 0xFF, 0xFC, + 0x00, 0x07, 0xFF, 0xF8, 0x00, 0x0F, 0xFF, 0xF0, 0x00, 0x1F, 0xFE, 0xF0, + 0x00, 0x7B, 0xFD, 0xE0, 0x00, 0xF7, 0xFB, 0xE0, 0x03, 0xEF, 0xF3, 0xC0, + 0x07, 0x9F, 0xE7, 0x80, 0x0F, 0x3F, 0xC7, 0x80, 0x3C, 0x7F, 0x8F, 0x00, + 0x78, 0xFF, 0x1F, 0x01, 0xF1, 0xFE, 0x1E, 0x03, 0xC3, 0xFC, 0x3C, 0x07, + 0x87, 0xF8, 0x3C, 0x1E, 0x0F, 0xF0, 0x78, 0x3C, 0x1F, 0xE0, 0xF8, 0xF8, + 0x3F, 0xC0, 0xF1, 0xE0, 0x7F, 0x81, 0xE3, 0xC0, 0xFF, 0x01, 0xEF, 0x01, + 0xFE, 0x03, 0xDE, 0x03, 0xFC, 0x07, 0xFC, 0x07, 0xF8, 0x07, 0xF0, 0x0F, + 0xF0, 0x0F, 0xE0, 0x1F, 0xE0, 0x1F, 0xC0, 0x3F, 0xC0, 0x1F, 0x00, 0x7F, + 0x80, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x01, 0xFE, 0x00, 0x00, 0x03, 0xFC, + 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x1F, 0xE0, + 0x00, 0x00, 0x3C, 0xFC, 0x00, 0x03, 0xFF, 0x80, 0x00, 0xFF, 0xE0, 0x00, + 0x3F, 0xFC, 0x00, 0x0F, 0xFF, 0x00, 0x03, 0xFF, 0xE0, 0x00, 0xFF, 0x78, + 0x00, 0x3F, 0xDF, 0x00, 0x0F, 0xF3, 0xE0, 0x03, 0xFC, 0x78, 0x00, 0xFF, + 0x1F, 0x00, 0x3F, 0xC3, 0xC0, 0x0F, 0xF0, 0xF8, 0x03, 0xFC, 0x1E, 0x00, + 0xFF, 0x07, 0xC0, 0x3F, 0xC0, 0xF0, 0x0F, 0xF0, 0x3E, 0x03, 0xFC, 0x07, + 0xC0, 0xFF, 0x00, 0xF0, 0x3F, 0xC0, 0x3E, 0x0F, 0xF0, 0x07, 0x83, 0xFC, + 0x01, 0xF0, 0xFF, 0x00, 0x3C, 0x3F, 0xC0, 0x0F, 0x8F, 0xF0, 0x01, 0xE3, + 0xFC, 0x00, 0x7C, 0xFF, 0x00, 0x0F, 0xBF, 0xC0, 0x01, 0xEF, 0xF0, 0x00, + 0x7F, 0xFC, 0x00, 0x0F, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x00, 0x7F, 0xF0, + 0x00, 0x1F, 0xFC, 0x00, 0x03, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x87, 0xFF, 0xFC, + 0x3F, 0xFF, 0xE1, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0x80, + 0x03, 0xFF, 0xFF, 0xC0, 0x07, 0xF0, 0x0F, 0xE0, 0x0F, 0xC0, 0x03, 0xF0, + 0x1F, 0x80, 0x01, 0xF8, 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, + 0x7C, 0x00, 0x00, 0x3C, 0x78, 0x00, 0x00, 0x3E, 0x78, 0x00, 0x00, 0x1E, + 0x78, 0x00, 0x00, 0x1E, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, + 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0x78, 0x00, 0x00, 0x1E, + 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x7C, 0x00, 0x00, 0x3C, + 0x3E, 0x00, 0x00, 0x7C, 0x3E, 0x00, 0x00, 0x7C, 0x1F, 0x80, 0x01, 0xF8, + 0x0F, 0xC0, 0x03, 0xF0, 0x07, 0xF0, 0x0F, 0xE0, 0x03, 0xFF, 0xFF, 0xC0, + 0x01, 0xFF, 0xFF, 0x80, 0x00, 0x7F, 0xFE, 0x00, 0x00, 0x0F, 0xF0, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, + 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x3F, + 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, 0x00, + 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, 0x00, + 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x00, + 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, 0x00, + 0x00, 0xFF, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x0F, 0xF0, 0x00, 0x03, 0xFC, + 0x00, 0x00, 0xF0, 0xFF, 0xFE, 0x03, 0xFF, 0xFE, 0x0F, 0xFF, 0xFE, 0x3F, + 0xFF, 0xFC, 0xF0, 0x03, 0xFB, 0xC0, 0x03, 0xEF, 0x00, 0x07, 0xBC, 0x00, + 0x1F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, + 0xF0, 0x00, 0x3F, 0xC0, 0x01, 0xFF, 0x00, 0x07, 0xBC, 0x00, 0x3E, 0xF0, + 0x03, 0xFB, 0xFF, 0xFF, 0xCF, 0xFF, 0xFE, 0x3F, 0xFF, 0xE0, 0xFF, 0xFE, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, + 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, + 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFC, 0x00, 0x01, 0xF8, 0x00, 0x03, 0xF0, 0x00, 0x07, 0xE0, 0x00, 0x0F, + 0xC0, 0x00, 0x1F, 0x80, 0x00, 0x3E, 0x00, 0x00, 0x7C, 0x00, 0x00, 0xF8, + 0x00, 0x01, 0xF0, 0x00, 0x03, 0xE0, 0x00, 0x07, 0xC0, 0x00, 0x1F, 0x00, + 0x00, 0xF8, 0x00, 0x07, 0xC0, 0x00, 0x3E, 0x00, 0x01, 0xF8, 0x00, 0x07, + 0xC0, 0x00, 0x3E, 0x00, 0x01, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x3E, 0x00, + 0x01, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x7C, 0x00, 0x03, 0xE0, 0x00, 0x0F, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0xF8, 0x00, + 0x01, 0xF7, 0xC0, 0x00, 0x3E, 0x3E, 0x00, 0x07, 0xC3, 0xE0, 0x00, 0x7C, + 0x1F, 0x00, 0x0F, 0x80, 0xF8, 0x01, 0xF0, 0x0F, 0x80, 0x1F, 0x00, 0x7C, + 0x03, 0xE0, 0x03, 0xE0, 0x7C, 0x00, 0x3E, 0x07, 0xC0, 0x01, 0xF0, 0xF8, + 0x00, 0x0F, 0x9F, 0x00, 0x00, 0xF9, 0xF0, 0x00, 0x07, 0xFE, 0x00, 0x00, + 0x3F, 0xC0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x03, 0xFF, 0xFF, + 0xC0, 0x0F, 0xFF, 0xFF, 0xF0, 0x1F, 0xE3, 0xC7, 0xF8, 0x3F, 0x83, 0xC1, + 0xFC, 0x3E, 0x03, 0xC0, 0x7C, 0x7C, 0x03, 0xC0, 0x3E, 0x78, 0x03, 0xC0, + 0x1E, 0xF8, 0x03, 0xC0, 0x1F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF8, 0x03, 0xC0, 0x1F, 0x78, 0x03, 0xC0, + 0x1E, 0x7C, 0x03, 0xC0, 0x3E, 0x3E, 0x03, 0xC0, 0x7C, 0x3F, 0x83, 0xC1, + 0xFC, 0x1F, 0xE3, 0xC7, 0xF8, 0x0F, 0xFF, 0xFF, 0xF0, 0x03, 0xFF, 0xFF, + 0xC0, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x3E, 0x00, 0x01, 0xF0, 0xF8, 0x00, 0x1F, 0x03, 0xC0, 0x01, 0xF0, + 0x1F, 0x00, 0x0F, 0x80, 0x7C, 0x00, 0xF8, 0x01, 0xE0, 0x0F, 0x80, 0x0F, + 0x80, 0x7C, 0x00, 0x3E, 0x07, 0xC0, 0x00, 0xF0, 0x7C, 0x00, 0x07, 0xC3, + 0xE0, 0x00, 0x1F, 0x3E, 0x00, 0x00, 0xFB, 0xE0, 0x00, 0x03, 0xFF, 0x00, + 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, + 0x0F, 0xC0, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, 0x7F, + 0xE0, 0x00, 0x07, 0xDF, 0x00, 0x00, 0x3C, 0x7C, 0x00, 0x03, 0xE1, 0xF0, + 0x00, 0x3E, 0x0F, 0x80, 0x03, 0xE0, 0x3E, 0x00, 0x1F, 0x00, 0xF0, 0x01, + 0xF0, 0x07, 0xC0, 0x1F, 0x00, 0x1F, 0x00, 0xF8, 0x00, 0x78, 0x0F, 0x80, + 0x03, 0xE0, 0xF8, 0x00, 0x0F, 0x87, 0xC0, 0x00, 0x3C, 0x7C, 0x00, 0x01, + 0xF7, 0xC0, 0x00, 0x07, 0xC0, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0x78, 0x03, 0xC0, 0x1E, 0x78, 0x03, 0xC0, 0x1E, 0x78, 0x03, 0xC0, + 0x1E, 0x7C, 0x03, 0xC0, 0x3C, 0x3E, 0x03, 0xC0, 0x7C, 0x3E, 0x03, 0xC0, + 0x7C, 0x1F, 0x03, 0xC0, 0xF8, 0x0F, 0xC3, 0xC3, 0xF0, 0x07, 0xF3, 0xCF, + 0xE0, 0x03, 0xFF, 0xFF, 0xC0, 0x01, 0xFF, 0xFF, 0x80, 0x00, 0xFF, 0xFF, + 0x00, 0x00, 0x1F, 0xF8, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, + 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x0F, 0xF0, + 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0x80, 0x03, 0xFF, 0xFF, + 0xC0, 0x07, 0xF0, 0x0F, 0xE0, 0x0F, 0xC0, 0x03, 0xF0, 0x1F, 0x00, 0x00, + 0xF8, 0x3E, 0x00, 0x00, 0x7C, 0x3C, 0x00, 0x00, 0x3C, 0x7C, 0x00, 0x00, + 0x3E, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0xF0, 0x00, 0x00, + 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, + 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, + 0x0F, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x00, 0x1F, 0x78, 0x00, 0x00, + 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x7C, 0x00, 0x00, 0x3E, 0x3C, 0x00, 0x00, + 0x3C, 0x3E, 0x00, 0x00, 0x7C, 0x1E, 0x00, 0x00, 0xF8, 0x0F, 0x00, 0x00, + 0xF0, 0x0F, 0x80, 0x01, 0xF0, 0x07, 0xE0, 0x07, 0xE0, 0x03, 0xF0, 0x0F, + 0xC0, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xF8, 0x1F, + 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xF8, 0x7F, 0xE1, 0xFF, 0x87, 0xFE, 0x1F, + 0xF8, 0x7C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, + 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, + 0x00, 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x01, 0xE0, 0x07, + 0x80, 0x1E, 0x00, 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x01, + 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, + 0x78, 0x01, 0xE0, 0x07, 0x80, 0x1E, 0x00, 0x78, 0x01, 0xE0, 0x01, 0xF0, + 0xF8, 0x00, 0x1F, 0x0F, 0x80, 0x01, 0xF0, 0xF8, 0x00, 0x1F, 0x0F, 0x80, + 0x01, 0xF0, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x00, 0x01, + 0xF7, 0xC0, 0x00, 0x3E, 0x3E, 0x00, 0x07, 0xC3, 0xE0, 0x00, 0x7C, 0x1F, + 0x00, 0x0F, 0x80, 0xF8, 0x01, 0xF0, 0x0F, 0x80, 0x1F, 0x00, 0x7C, 0x03, + 0xE0, 0x03, 0xE0, 0x7C, 0x00, 0x3E, 0x07, 0xC0, 0x01, 0xF0, 0xF8, 0x00, + 0x0F, 0x9F, 0x00, 0x00, 0xF9, 0xF0, 0x00, 0x07, 0xFE, 0x00, 0x00, 0x3F, + 0xC0, 0x00, 0x03, 0xFC, 0x00, 0x00, 0x1F, 0x80, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, + 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x01, 0xE0, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x03, 0xC0, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x70, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xF0, 0x00, 0x07, + 0xFF, 0x07, 0x83, 0xFF, 0xE3, 0xE1, 0xFF, 0xFC, 0xF0, 0xFC, 0x3F, 0x3C, + 0x3C, 0x03, 0xDF, 0x1F, 0x00, 0xFF, 0x87, 0x80, 0x1F, 0xE1, 0xE0, 0x07, + 0xF8, 0xF8, 0x01, 0xFC, 0x3C, 0x00, 0x7F, 0x0F, 0x00, 0x0F, 0x83, 0xC0, + 0x03, 0xE0, 0xF0, 0x00, 0xF8, 0x3C, 0x00, 0x3C, 0x0F, 0x00, 0x0F, 0x03, + 0xC0, 0x03, 0xE0, 0xF0, 0x01, 0xF8, 0x3C, 0x00, 0x7E, 0x07, 0x80, 0x1F, + 0x81, 0xE0, 0x0F, 0xE0, 0x7C, 0x03, 0xFC, 0x0F, 0x81, 0xFF, 0x83, 0xF0, + 0xFB, 0xFC, 0x7F, 0xFE, 0xFF, 0x0F, 0xFF, 0x1F, 0xC1, 0xFF, 0x81, 0xF0, + 0x1F, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, 0xF8, 0x00, 0x3E, 0x00, 0x07, + 0x80, 0x01, 0xE0, 0x00, 0x78, 0x00, 0x0E, 0x00, 0x03, 0xC0, 0x00, 0xF0, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0x80, 0xFF, 0xFC, 0x3F, 0xFF, + 0x8F, 0xFF, 0xF3, 0xF8, 0x06, 0x7C, 0x00, 0x0F, 0x00, 0x01, 0xE0, 0x00, + 0x3C, 0x00, 0x07, 0x80, 0x00, 0x78, 0x00, 0x07, 0xC0, 0x00, 0x3F, 0xE0, + 0x0F, 0xFC, 0x03, 0xFF, 0x81, 0xFF, 0xF0, 0x3F, 0x00, 0x0F, 0x80, 0x01, + 0xE0, 0x00, 0x3C, 0x00, 0x07, 0x80, 0x00, 0xF0, 0x00, 0x1F, 0x00, 0x01, + 0xF8, 0x01, 0x9F, 0xFF, 0xF3, 0xFF, 0xFE, 0x1F, 0xFF, 0xC0, 0x7F, 0xE0, + 0x00, 0x00, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xF0, 0x00, + 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xE0, 0x00, 0x07, 0x00, 0x00, 0x38, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xC0, 0xF1, 0xFF, 0xC3, + 0xCF, 0xFF, 0x8F, 0x7F, 0xFF, 0x3F, 0xE0, 0x7E, 0xFE, 0x00, 0xFB, 0xF0, + 0x01, 0xEF, 0x80, 0x07, 0xFE, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, + 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, + 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, + 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, + 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, + 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, 0x00, 0x0F, + 0x01, 0xE0, 0x3C, 0x07, 0xC0, 0x78, 0x0F, 0x01, 0xE0, 0x1C, 0x03, 0xC0, + 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, + 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, + 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, 0xF0, 0x0F, 0x00, + 0xF8, 0x07, 0xC0, 0x7F, 0xC3, 0xFC, 0x1F, 0xC0, 0xFC, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x78, 0x00, 0x07, 0x80, 0x00, 0x78, + 0x00, 0x07, 0x80, 0x00, 0x38, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x0F, + 0x87, 0xC0, 0x7C, 0x3E, 0x03, 0xE1, 0xF0, 0x1F, 0x0F, 0x80, 0xF8, 0x7C, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x78, 0x01, 0xE3, 0xC0, 0x07, 0x9E, 0x00, 0x3C, + 0xF0, 0x00, 0xF7, 0x80, 0x07, 0xBC, 0x00, 0x3D, 0xE0, 0x00, 0xFF, 0x00, + 0x07, 0xF8, 0x00, 0x3F, 0xC0, 0x01, 0xFE, 0x00, 0x0F, 0xF0, 0x00, 0x7F, + 0x80, 0x03, 0xFC, 0x00, 0x1F, 0xE0, 0x01, 0xFF, 0x00, 0x0F, 0x78, 0x00, + 0x7B, 0xC0, 0x07, 0xDE, 0x00, 0x3C, 0xF8, 0x03, 0xE3, 0xC0, 0x3E, 0x1F, + 0x87, 0xF0, 0x7F, 0xFF, 0x03, 0xFF, 0xF0, 0x07, 0xFE, 0x00, 0x1F, 0xC0, + 0x00, 0x01, 0xFC, 0x00, 0x01, 0xFF, 0xC1, 0xE0, 0xFF, 0xF8, 0xF8, 0x7F, + 0xFF, 0x3C, 0x3F, 0x0F, 0xCF, 0x0F, 0x00, 0xF7, 0xC7, 0xC0, 0x3F, 0xE1, + 0xE0, 0x07, 0xF8, 0x78, 0x01, 0xFE, 0x3E, 0x00, 0x7F, 0x0F, 0x00, 0x1F, + 0xC3, 0xC0, 0x03, 0xE0, 0xF0, 0x00, 0xF8, 0x3C, 0x00, 0x3E, 0x0F, 0x00, + 0x0F, 0x03, 0xC0, 0x03, 0xC0, 0xF0, 0x00, 0xF8, 0x3C, 0x00, 0x7E, 0x0F, + 0x00, 0x1F, 0x81, 0xE0, 0x07, 0xE0, 0x78, 0x03, 0xF8, 0x1F, 0x00, 0xFF, + 0x03, 0xE0, 0x7F, 0xE0, 0xFC, 0x3E, 0xFF, 0x1F, 0xFF, 0xBF, 0xC3, 0xFF, + 0xC7, 0xF0, 0x7F, 0xE0, 0x7C, 0x07, 0xF0, 0x00, 0x03, 0xFC, 0x00, 0x3F, + 0xFC, 0x01, 0xFF, 0xFC, 0x0F, 0xFF, 0xF8, 0x7E, 0x07, 0xE1, 0xF0, 0x07, + 0xCF, 0x80, 0x0F, 0x3E, 0x00, 0x3C, 0xF0, 0x00, 0xF3, 0xC0, 0x03, 0xCF, + 0x00, 0x0F, 0x3C, 0x00, 0x3C, 0xF0, 0x01, 0xF3, 0xC0, 0x0F, 0x8F, 0x00, + 0x7E, 0x3C, 0x07, 0xF0, 0xF1, 0xFF, 0xC3, 0xC7, 0xFC, 0x0F, 0x1F, 0xFC, + 0x3C, 0x7F, 0xF8, 0xF0, 0x07, 0xF3, 0xC0, 0x03, 0xEF, 0x00, 0x07, 0xBC, + 0x00, 0x1F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, + 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x01, 0xFF, 0x80, 0x07, 0xBF, 0x00, 0x3E, + 0xFF, 0x03, 0xFB, 0xFF, 0xFF, 0xCF, 0xFF, 0xFE, 0x3D, 0xFF, 0xF0, 0xF1, + 0xFE, 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, + 0xC0, 0x00, 0x00, 0xF0, 0x00, 0x07, 0xFF, 0x00, 0x01, 0xEF, 0xE0, 0x00, + 0x7B, 0xF8, 0x00, 0x3E, 0x1F, 0x00, 0x0F, 0x03, 0xC0, 0x07, 0xC0, 0xF0, + 0x01, 0xE0, 0x1E, 0x00, 0xF8, 0x07, 0x80, 0x3C, 0x01, 0xF0, 0x0F, 0x00, + 0x3C, 0x07, 0xC0, 0x0F, 0x01, 0xE0, 0x03, 0xE0, 0xF8, 0x00, 0x78, 0x3C, + 0x00, 0x1E, 0x0F, 0x00, 0x07, 0xC7, 0x80, 0x00, 0xF1, 0xE0, 0x00, 0x3C, + 0xF8, 0x00, 0x0F, 0xBC, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x7F, 0x80, 0x00, + 0x1F, 0xE0, 0x00, 0x03, 0xF0, 0x00, 0x00, 0xFC, 0x00, 0x00, 0x3F, 0x00, + 0x00, 0x07, 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x07, 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, + 0x1E, 0x00, 0x00, 0x07, 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, + 0x00, 0xFF, 0xC0, 0x03, 0xFF, 0xF0, 0x0F, 0xFF, 0xF8, 0x0F, 0xFF, 0xF8, + 0x1F, 0x80, 0x38, 0x1E, 0x00, 0x08, 0x1E, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x1F, 0x80, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xFF, 0x00, 0x07, 0xFF, 0xC0, + 0x07, 0xFF, 0xF0, 0x1F, 0x1F, 0xF8, 0x1E, 0x01, 0xFC, 0x3C, 0x00, 0x7C, + 0x78, 0x00, 0x3E, 0x78, 0x00, 0x1E, 0x70, 0x00, 0x1F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, + 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x1F, 0x78, 0x00, 0x1E, + 0x78, 0x00, 0x3E, 0x7C, 0x00, 0x3E, 0x3E, 0x00, 0x7C, 0x3F, 0x81, 0xFC, + 0x1F, 0xFF, 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0x00, + 0x03, 0xFF, 0x01, 0xFF, 0xF8, 0x7F, 0xFF, 0x1F, 0xFF, 0xE7, 0xF0, 0x0C, + 0xF8, 0x00, 0x1E, 0x00, 0x03, 0xC0, 0x00, 0x78, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x7F, 0xC0, 0x1F, 0xF8, 0x07, 0xFF, 0x03, + 0xFF, 0xE0, 0x7E, 0x00, 0x1F, 0x00, 0x03, 0xC0, 0x00, 0x78, 0x00, 0x0F, + 0x00, 0x01, 0xE0, 0x00, 0x3E, 0x00, 0x03, 0xF0, 0x03, 0x3F, 0xFF, 0xE7, + 0xFF, 0xFC, 0x3F, 0xFF, 0x80, 0xFF, 0xC0, 0x7F, 0xFF, 0xFB, 0xFF, 0xFF, + 0xDF, 0xFF, 0xFE, 0xFF, 0xFF, 0xF0, 0x00, 0xFF, 0x00, 0x0F, 0xE0, 0x00, + 0xFC, 0x00, 0x1F, 0xC0, 0x01, 0xF8, 0x00, 0x1F, 0x80, 0x01, 0xF8, 0x00, + 0x1F, 0x80, 0x00, 0xF8, 0x00, 0x0F, 0x80, 0x00, 0xF8, 0x00, 0x07, 0x80, + 0x00, 0x7C, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x01, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x1E, 0x00, 0x00, 0xF0, 0x00, + 0x07, 0x80, 0x00, 0x3C, 0x00, 0x01, 0xF0, 0x00, 0x07, 0x80, 0x00, 0x3E, + 0x00, 0x01, 0xF0, 0x00, 0x07, 0xE0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0xFC, + 0x01, 0xFF, 0xF8, 0x07, 0xFF, 0xE0, 0x07, 0xFF, 0x00, 0x00, 0x7C, 0x00, + 0x01, 0xE0, 0x00, 0x0F, 0x00, 0x00, 0x78, 0x00, 0x03, 0xC0, 0x00, 0x3E, + 0x00, 0x07, 0xE0, 0x00, 0x3F, 0x00, 0x01, 0xF0, 0x00, 0x0E, 0x00, 0x00, + 0x7F, 0x03, 0xC7, 0xFF, 0x0F, 0x3F, 0xFE, 0x3D, 0xFF, 0xFC, 0xFF, 0x81, + 0xFB, 0xF8, 0x03, 0xEF, 0xC0, 0x07, 0xBE, 0x00, 0x1F, 0xF8, 0x00, 0x3F, + 0xC0, 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, + 0x00, 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, + 0xFF, 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, + 0x00, 0x03, 0xFC, 0x00, 0x0F, 0xF0, 0x00, 0x3F, 0xC0, 0x00, 0xFF, 0x00, + 0x03, 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, + 0xC0, 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x00, 0xF0, 0x00, 0x03, 0xC0, + 0x00, 0x0F, 0x00, 0x00, 0x3C, 0x00, 0x7E, 0x00, 0x01, 0xFF, 0x80, 0x07, + 0xFF, 0xE0, 0x0F, 0xFF, 0xF0, 0x1F, 0x81, 0xF8, 0x1F, 0x00, 0xF8, 0x3E, + 0x00, 0x7C, 0x3C, 0x00, 0x3C, 0x7C, 0x00, 0x3E, 0x78, 0x00, 0x1E, 0x78, + 0x00, 0x1E, 0x78, 0x00, 0x1E, 0xF0, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0x78, 0x00, 0x1E, 0x78, + 0x00, 0x1E, 0x78, 0x00, 0x1E, 0x7C, 0x00, 0x3E, 0x3C, 0x00, 0x3C, 0x3E, + 0x00, 0x7C, 0x1F, 0x00, 0xF8, 0x1F, 0x81, 0xF0, 0x0F, 0xFF, 0xF0, 0x07, + 0xFF, 0xE0, 0x01, 0xFF, 0x80, 0x00, 0x7E, 0x00, 0xF0, 0x3C, 0x0F, 0x03, + 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF0, + 0x3C, 0x0F, 0x03, 0xC0, 0xF0, 0x3C, 0x0F, 0x03, 0xC0, 0xF8, 0x1F, 0x07, + 0xFC, 0xFF, 0x1F, 0xC3, 0xF0, 0xF0, 0x01, 0xF3, 0xC0, 0x1F, 0x8F, 0x00, + 0xFC, 0x3C, 0x07, 0xE0, 0xF0, 0x3F, 0x03, 0xC1, 0xF8, 0x0F, 0x0F, 0xC0, + 0x3C, 0x7E, 0x00, 0xF3, 0xF0, 0x03, 0xDF, 0x80, 0x0F, 0xFE, 0x00, 0x3F, + 0xFC, 0x00, 0xFE, 0xF8, 0x03, 0xF1, 0xE0, 0x0F, 0x87, 0xC0, 0x3C, 0x0F, + 0x80, 0xF0, 0x1E, 0x03, 0xC0, 0x7C, 0x0F, 0x00, 0xF8, 0x3C, 0x01, 0xF0, + 0xF0, 0x07, 0xC3, 0xC0, 0x0F, 0x8F, 0x00, 0x1F, 0x3C, 0x00, 0x7C, 0xF0, + 0x00, 0xFB, 0xC0, 0x01, 0xF0, 0x0F, 0xC0, 0x00, 0x07, 0xF0, 0x00, 0x03, + 0xFC, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x03, 0xE0, 0x00, + 0x00, 0xF0, 0x00, 0x00, 0x7C, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x07, 0xC0, 0x00, 0x01, 0xE0, 0x00, 0x01, 0xF0, 0x00, 0x00, 0xFC, + 0x00, 0x00, 0xFE, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x7F, 0xC0, 0x00, 0x3D, + 0xE0, 0x00, 0x3E, 0xF8, 0x00, 0x1E, 0x3C, 0x00, 0x1F, 0x1E, 0x00, 0x0F, + 0x0F, 0x80, 0x0F, 0x83, 0xC0, 0x07, 0x81, 0xE0, 0x07, 0xC0, 0xF8, 0x03, + 0xC0, 0x3C, 0x03, 0xE0, 0x1F, 0x01, 0xE0, 0x07, 0x80, 0xF0, 0x03, 0xC0, + 0xF8, 0x01, 0xF0, 0x78, 0x00, 0x78, 0x7C, 0x00, 0x3C, 0x3C, 0x00, 0x1F, + 0x3E, 0x00, 0x07, 0x9E, 0x00, 0x03, 0xDF, 0x00, 0x00, 0xF0, 0xF0, 0x00, + 0x3C, 0x78, 0x00, 0x1E, 0x3C, 0x00, 0x0F, 0x1E, 0x00, 0x07, 0x8F, 0x00, + 0x03, 0xC7, 0x80, 0x01, 0xE3, 0xC0, 0x00, 0xF1, 0xE0, 0x00, 0x78, 0xF0, + 0x00, 0x3C, 0x78, 0x00, 0x1E, 0x3C, 0x00, 0x0F, 0x1E, 0x00, 0x07, 0x8F, + 0x00, 0x03, 0xC7, 0x80, 0x01, 0xE3, 0xC0, 0x00, 0xF1, 0xE0, 0x00, 0x78, + 0xF0, 0x00, 0x3C, 0x78, 0x00, 0x1E, 0x3C, 0x00, 0x0F, 0x1F, 0x00, 0x0F, + 0x8F, 0x80, 0x07, 0xC7, 0xE0, 0x07, 0xE3, 0xFC, 0x0F, 0xFB, 0xFF, 0xFF, + 0xFF, 0xF7, 0xFF, 0x9F, 0xF9, 0xFF, 0x8F, 0xFC, 0x3F, 0x03, 0xDE, 0x00, + 0x00, 0x0F, 0x00, 0x00, 0x07, 0x80, 0x00, 0x03, 0xC0, 0x00, 0x01, 0xE0, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x00, 0xF0, 0x01, 0xE3, 0xE0, 0x03, 0xC7, 0x80, 0x0F, 0x1E, + 0x00, 0x1E, 0x7C, 0x00, 0x78, 0xF0, 0x01, 0xE3, 0xC0, 0x03, 0xCF, 0x80, + 0x0F, 0x1E, 0x00, 0x3C, 0x78, 0x00, 0xF1, 0xE0, 0x03, 0xC3, 0xC0, 0x0F, + 0x0F, 0x00, 0x7C, 0x3C, 0x01, 0xE0, 0xF8, 0x0F, 0x81, 0xE0, 0x3E, 0x07, + 0x81, 0xF0, 0x1F, 0x0F, 0x80, 0x3C, 0x3E, 0x00, 0xF1, 0xF0, 0x03, 0xEF, + 0x80, 0x07, 0xFC, 0x00, 0x1F, 0xE0, 0x00, 0x7F, 0x80, 0x00, 0xFC, 0x00, + 0x03, 0xE0, 0x00, 0x7F, 0xFF, 0xE7, 0xFF, 0xFE, 0x7F, 0xFF, 0xE7, 0xFF, + 0xFE, 0x07, 0xF0, 0x01, 0xF8, 0x00, 0x3E, 0x00, 0x03, 0xC0, 0x00, 0x78, + 0x00, 0x07, 0x80, 0x00, 0x78, 0x00, 0x07, 0x80, 0x00, 0x7C, 0x00, 0x03, + 0xE0, 0x00, 0x3F, 0xE0, 0x01, 0xFF, 0xF8, 0x07, 0xFF, 0x80, 0x3F, 0xF8, + 0x0F, 0xFF, 0x81, 0xFC, 0x00, 0x3E, 0x00, 0x07, 0xC0, 0x00, 0x78, 0x00, + 0x0F, 0x80, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, + 0x00, 0xF0, 0x00, 0x0F, 0x80, 0x00, 0x7C, 0x00, 0x07, 0xE0, 0x00, 0x3F, + 0x80, 0x03, 0xFF, 0xF0, 0x0F, 0xFF, 0xC0, 0x7F, 0xFE, 0x00, 0xFF, 0xE0, + 0x00, 0x1F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x01, 0xF0, 0x00, 0x7E, 0x00, 0x07, 0xE0, 0x00, 0x7C, 0x00, 0x07, + 0x00, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, 0xFF, + 0xF8, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x7C, 0x00, 0x3E, 0x7C, 0x00, + 0x3E, 0x78, 0x00, 0x1E, 0xF8, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x1F, 0x78, 0x00, + 0x1E, 0x7C, 0x00, 0x3E, 0x7C, 0x00, 0x3E, 0x3E, 0x00, 0x7C, 0x1F, 0x81, + 0xF8, 0x1F, 0xFF, 0xF8, 0x0F, 0xFF, 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, + 0x00, 0xFF, 0xFF, 0xFF, 0x7F, 0xFF, 0xFF, 0xBF, 0xFF, 0xFF, 0xDF, 0xFF, + 0xFF, 0xE1, 0xE0, 0x07, 0x80, 0xF0, 0x03, 0xC0, 0x78, 0x01, 0xE0, 0x3C, + 0x00, 0xF0, 0x1E, 0x00, 0x78, 0x0F, 0x00, 0x3C, 0x07, 0x80, 0x1E, 0x03, + 0xC0, 0x0F, 0x01, 0xE0, 0x07, 0x80, 0xF0, 0x03, 0xC0, 0x78, 0x01, 0xE0, + 0x3C, 0x00, 0xF0, 0x1E, 0x00, 0x78, 0x0F, 0x00, 0x3C, 0x07, 0x80, 0x1E, + 0x03, 0xC0, 0x0F, 0x01, 0xE0, 0x07, 0x80, 0xF0, 0x03, 0xC0, 0x78, 0x01, + 0xF0, 0x3C, 0x00, 0xFF, 0x1E, 0x00, 0x3F, 0x8F, 0x00, 0x1F, 0xC0, 0x00, + 0x07, 0xE0, 0x00, 0xFF, 0x00, 0x03, 0xFF, 0xC0, 0x07, 0xFF, 0xF0, 0x0F, + 0xFF, 0xF8, 0x1F, 0x81, 0xF8, 0x3E, 0x00, 0x7C, 0x7C, 0x00, 0x3E, 0x7C, + 0x00, 0x3E, 0x78, 0x00, 0x1E, 0xF8, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, + 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF8, 0x00, 0x1F, 0xF8, + 0x00, 0x1E, 0xFC, 0x00, 0x3E, 0xFC, 0x00, 0x3E, 0xFE, 0x00, 0x7C, 0xFF, + 0x81, 0xF8, 0xF7, 0xFF, 0xF8, 0xF3, 0xFF, 0xF0, 0xF1, 0xFF, 0xC0, 0xF0, + 0x7F, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, 0x00, 0x00, 0xF0, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x7F, 0x80, 0x3F, 0xFE, 0x07, 0xFF, + 0xF0, 0xFF, 0xFF, 0x1F, 0xC0, 0xF3, 0xF0, 0x01, 0x7C, 0x00, 0x07, 0xC0, + 0x00, 0x78, 0x00, 0x0F, 0x80, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, + 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x00, 0x0F, + 0x00, 0x00, 0xF8, 0x00, 0x07, 0x80, 0x00, 0x7C, 0x00, 0x07, 0xC0, 0x00, + 0x3E, 0x00, 0x01, 0xF8, 0x00, 0x1F, 0xFE, 0x00, 0x7F, 0xF8, 0x03, 0xFF, + 0xC0, 0x0F, 0xFC, 0x00, 0x03, 0xE0, 0x00, 0x1E, 0x00, 0x01, 0xE0, 0x00, + 0x1E, 0x00, 0x01, 0xE0, 0x00, 0x3E, 0x00, 0x0F, 0xC0, 0x00, 0xFC, 0x00, + 0x0F, 0x80, 0x00, 0xE0, 0x00, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xF0, 0xFF, + 0xFF, 0xFC, 0x7F, 0xFF, 0xFF, 0x3F, 0x81, 0xFC, 0x0F, 0x80, 0x1F, 0x07, + 0xC0, 0x03, 0xE1, 0xE0, 0x00, 0x78, 0xF8, 0x00, 0x1E, 0x3E, 0x00, 0x07, + 0xCF, 0x00, 0x00, 0xF3, 0xC0, 0x00, 0x3C, 0xF0, 0x00, 0x0F, 0x3C, 0x00, + 0x03, 0xCF, 0x00, 0x00, 0xF3, 0xC0, 0x00, 0x3C, 0xF0, 0x00, 0x0F, 0x3E, + 0x00, 0x07, 0xC7, 0x80, 0x01, 0xE1, 0xE0, 0x00, 0xF8, 0x7C, 0x00, 0x3E, + 0x0F, 0x80, 0x1F, 0x01, 0xF8, 0x1F, 0x80, 0x7F, 0xFF, 0xE0, 0x0F, 0xFF, + 0xF0, 0x00, 0xFF, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x3C, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, + 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x3E, 0x00, 0x00, + 0x1F, 0x00, 0x00, 0x1F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x07, 0xF0, 0x00, + 0x03, 0xF0, 0xF0, 0x03, 0xC7, 0x80, 0x0F, 0x3C, 0x00, 0x79, 0xE0, 0x01, + 0xEF, 0x00, 0x0F, 0x78, 0x00, 0x7B, 0xC0, 0x01, 0xFE, 0x00, 0x0F, 0xF0, + 0x00, 0x7F, 0x80, 0x03, 0xFC, 0x00, 0x1F, 0xE0, 0x00, 0xFF, 0x00, 0x07, + 0xF8, 0x00, 0x3F, 0xC0, 0x03, 0xFE, 0x00, 0x1E, 0xF0, 0x00, 0xF7, 0x80, + 0x0F, 0xBC, 0x00, 0x79, 0xF0, 0x07, 0xC7, 0x80, 0x7C, 0x3F, 0x0F, 0xE0, + 0xFF, 0xFE, 0x07, 0xFF, 0xE0, 0x0F, 0xFC, 0x00, 0x3F, 0x80, 0x00, 0x00, + 0xC3, 0xE0, 0x00, 0xF1, 0xFE, 0x00, 0xFC, 0xFF, 0xC0, 0x7F, 0x3F, 0xF8, + 0x3F, 0x9F, 0x3F, 0x0F, 0x87, 0x87, 0xC7, 0xC1, 0xE0, 0xF9, 0xF0, 0x78, + 0x1E, 0x78, 0x1E, 0x07, 0xBE, 0x07, 0x81, 0xFF, 0x01, 0xE0, 0x3F, 0xC0, + 0x78, 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, 0xE0, 0x3F, + 0xC0, 0x78, 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x81, 0xE0, + 0x7D, 0xE0, 0x78, 0x1E, 0x7C, 0x1E, 0x07, 0x9F, 0x07, 0x83, 0xE3, 0xE1, + 0xE1, 0xF0, 0x7E, 0x79, 0xF8, 0x1F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0x00, + 0x3F, 0xFF, 0x00, 0x03, 0xFF, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x07, 0x80, + 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x07, + 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0xF8, 0x00, 0x1F, 0xFE, 0x00, 0x3E, 0xFF, 0x00, 0x3E, 0xFF, 0x80, 0x7C, + 0x0F, 0x80, 0x7C, 0x07, 0x80, 0xF8, 0x07, 0xC0, 0xF8, 0x03, 0xC1, 0xF0, + 0x03, 0xE1, 0xF0, 0x01, 0xE3, 0xE0, 0x01, 0xE3, 0xC0, 0x01, 0xF7, 0xC0, + 0x00, 0xFF, 0x80, 0x00, 0xFF, 0x80, 0x00, 0x7F, 0x00, 0x00, 0x7F, 0x00, + 0x00, 0x7E, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x7C, 0x00, 0x00, 0x7E, 0x00, + 0x00, 0xFE, 0x00, 0x00, 0xFE, 0x00, 0x01, 0xFF, 0x00, 0x01, 0xFF, 0x00, + 0x03, 0xEF, 0x80, 0x03, 0xC7, 0x80, 0x07, 0xC7, 0x80, 0x07, 0x83, 0xC0, + 0x0F, 0x83, 0xC0, 0x1F, 0x03, 0xC0, 0x1F, 0x01, 0xE0, 0x3E, 0x01, 0xF0, + 0x3E, 0x01, 0xFF, 0x7C, 0x00, 0xFF, 0x7C, 0x00, 0x7F, 0xF8, 0x00, 0x1F, + 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, 0xE0, 0x3F, 0xC0, 0x78, + 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, 0xE0, 0x3F, 0xC0, + 0x78, 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, 0xE0, 0x3F, + 0xC0, 0x78, 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, 0xE0, + 0x3F, 0xC0, 0x78, 0x0F, 0xF0, 0x1E, 0x03, 0xFC, 0x07, 0x80, 0xFF, 0x01, + 0xE0, 0x7F, 0xE0, 0x78, 0x1E, 0x7C, 0x1E, 0x0F, 0x9F, 0x87, 0x87, 0xE3, + 0xF9, 0xE7, 0xF0, 0x7F, 0xFF, 0xF8, 0x0F, 0xFF, 0xFC, 0x00, 0xFF, 0xFC, + 0x00, 0x0F, 0xF8, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x07, + 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x00, 0x1E, 0x00, 0x00, + 0x07, 0x80, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x78, 0x00, 0x0F, 0x00, 0x00, + 0xF0, 0x1E, 0x00, 0x00, 0x78, 0x1E, 0x00, 0x00, 0x78, 0x3C, 0x00, 0x00, + 0x3C, 0x3C, 0x00, 0x00, 0x3C, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, + 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x70, 0x00, 0x00, 0x0E, 0xF0, 0x00, 0x00, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, + 0x0F, 0xF0, 0x07, 0xC0, 0x0F, 0xF8, 0x07, 0xE0, 0x1F, 0x78, 0x07, 0xE0, + 0x1E, 0x78, 0x07, 0xE0, 0x1E, 0x7C, 0x0F, 0xF0, 0x3E, 0x3E, 0x1E, 0xF8, + 0x7C, 0x3F, 0xFE, 0x7F, 0xFC, 0x1F, 0xFC, 0x7F, 0xF8, 0x0F, 0xFC, 0x3F, + 0xF0, 0x03, 0xF0, 0x0F, 0xC0, 0xF8, 0x7F, 0xE1, 0xFF, 0x87, 0xFE, 0x1F, + 0xF8, 0x7C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, + 0x03, 0xC0, 0x0F, 0x00, 0x3C, 0x00, 0xF0, 0x03, 0xC0, 0x0F, 0x00, 0x3C, + 0x00, 0xF0, 0x03, 0xC0, 0x0F, 0x00, 0x3C, 0x00, 0xF0, 0x03, 0xC0, 0x0F, + 0x00, 0x3C, 0x00, 0xF0, 0x03, 0xC0, 0x0F, 0x00, 0x1E, 0x00, 0x78, 0x01, + 0xF0, 0x07, 0xFC, 0x0F, 0xF0, 0x1F, 0xC0, 0x1F, 0x3E, 0x1F, 0x01, 0xF0, + 0xF8, 0x0F, 0x87, 0xC0, 0x7C, 0x3E, 0x03, 0xE1, 0xF0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0xE0, 0x07, 0x8F, 0x00, 0x1E, 0x78, 0x00, 0xF3, 0xC0, 0x03, 0xDE, + 0x00, 0x1E, 0xF0, 0x00, 0xF7, 0x80, 0x03, 0xFC, 0x00, 0x1F, 0xE0, 0x00, + 0xFF, 0x00, 0x07, 0xF8, 0x00, 0x3F, 0xC0, 0x01, 0xFE, 0x00, 0x0F, 0xF0, + 0x00, 0x7F, 0x80, 0x07, 0xFC, 0x00, 0x3D, 0xE0, 0x01, 0xEF, 0x00, 0x1F, + 0x78, 0x00, 0xF3, 0xE0, 0x0F, 0x8F, 0x00, 0xF8, 0x7E, 0x1F, 0xC1, 0xFF, + 0xFC, 0x0F, 0xFF, 0xC0, 0x1F, 0xF8, 0x00, 0x7F, 0x00, 0x00, 0x00, 0x01, + 0xE0, 0x00, 0x03, 0xE0, 0x00, 0x03, 0xC0, 0x00, 0x07, 0x80, 0x00, 0x0F, + 0x00, 0x00, 0x1E, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x78, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x03, 0xFF, + 0xC0, 0x0F, 0xFF, 0xF0, 0x1F, 0xFF, 0xF8, 0x1F, 0x81, 0xF8, 0x3E, 0x00, + 0x7C, 0x7C, 0x00, 0x3E, 0x7C, 0x00, 0x3E, 0x78, 0x00, 0x1E, 0xF8, 0x00, + 0x1F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, 0x0F, 0xF0, 0x00, + 0x0F, 0xF8, 0x00, 0x1F, 0x78, 0x00, 0x1E, 0x7C, 0x00, 0x3E, 0x7C, 0x00, + 0x3E, 0x3E, 0x00, 0x7C, 0x1F, 0x81, 0xF8, 0x1F, 0xFF, 0xF8, 0x0F, 0xFF, + 0xF0, 0x03, 0xFF, 0xC0, 0x00, 0xFF, 0x00, 0x00, 0x0F, 0x00, 0x00, 0xF8, + 0x00, 0x0F, 0x80, 0x00, 0x78, 0x00, 0x07, 0x80, 0x00, 0x78, 0x00, 0x03, + 0x80, 0x00, 0x3C, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x01, 0xE3, 0xC0, 0x07, 0x9E, 0x00, + 0x3C, 0xF0, 0x00, 0xF7, 0x80, 0x07, 0xBC, 0x00, 0x3D, 0xE0, 0x00, 0xFF, + 0x00, 0x07, 0xF8, 0x00, 0x3F, 0xC0, 0x01, 0xFE, 0x00, 0x0F, 0xF0, 0x00, + 0x7F, 0x80, 0x03, 0xFC, 0x00, 0x1F, 0xE0, 0x01, 0xFF, 0x00, 0x0F, 0x78, + 0x00, 0x7B, 0xC0, 0x07, 0xDE, 0x00, 0x3C, 0xF8, 0x03, 0xE3, 0xC0, 0x3E, + 0x1F, 0x87, 0xF0, 0x7F, 0xFF, 0x03, 0xFF, 0xF0, 0x07, 0xFE, 0x00, 0x1F, + 0xC0, 0x00, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, + 0x1E, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x03, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0xF0, 0x1E, 0x00, + 0x00, 0x78, 0x1E, 0x00, 0x00, 0x78, 0x3C, 0x00, 0x00, 0x3C, 0x3C, 0x00, + 0x00, 0x3C, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, 0x00, 0x1E, 0x78, 0x00, + 0x00, 0x1E, 0x70, 0x00, 0x00, 0x0E, 0xF0, 0x00, 0x00, 0x0F, 0xF0, 0x03, + 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, + 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x03, 0xC0, 0x0F, 0xF0, 0x07, + 0xC0, 0x0F, 0xF8, 0x07, 0xE0, 0x1F, 0x78, 0x07, 0xE0, 0x1E, 0x78, 0x07, + 0xE0, 0x1E, 0x7C, 0x0F, 0xF0, 0x3E, 0x3E, 0x1E, 0xF8, 0x7C, 0x3F, 0xFE, + 0x7F, 0xFC, 0x1F, 0xFC, 0x7F, 0xF8, 0x0F, 0xFC, 0x3F, 0xF0, 0x03, 0xF0, + 0x0F, 0xC0, +}; + +const GFXglyph FreeSans24pt_Win1253Glyphs[] PROGMEM = { +/* 0x01 */ { 0, 43, 48, 60, 8, -39 }, +/* 0x02 */ { 258, 43, 48, 60, 8, -39 }, +/* 0x03 */ { 516, 48, 48, 60, 6, -39 }, +/* 0x04 */ { 804, 57, 48, 60, 1, -39 }, +/* 0x05 */ { 1146, 47, 48, 60, 6, -39 }, +/* 0x06 */ { 1428, 47, 48, 60, 6, -39 }, +/* 0x07 */ { 1710, 0, 0, 0, 0, 0 }, +/* 0x08 */ { 1710, 49, 48, 60, 5, -39 }, +/* 0x09 */ { 2004, 54, 38, 60, 3, -34 }, +/* 0x0A */ { 2261, 0, 0, 0, 0, 0 }, +/* 0x0B */ { 2261, 52, 48, 60, 4, -39 }, +/* 0x0C */ { 2573, 47, 48, 60, 6, -39 }, +/* 0x0D */ { 2855, 0, 0, 0, 0, 0 }, +/* 0x0E */ { 2855, 47, 48, 60, 6, -39 }, +/* 0x0F */ { 3137, 48, 49, 60, 6, -39 }, +/* 0x10 */ { 3431, 46, 48, 60, 7, -39 }, +/* 0x11 */ { 3707, 48, 48, 60, 6, -39 }, +/* 0x12 */ { 3995, 46, 48, 60, 7, -39 }, +/* 0x13 */ { 4271, 48, 46, 60, 6, -38 }, +/* 0x14 */ { 4547, 48, 48, 60, 6, -39 }, +/* 0x15 */ { 4835, 51, 48, 60, 4, -39 }, +/* 0x16 */ { 5141, 37, 47, 60, 11, -38 }, +/* 0x17 */ { 5359, 50, 40, 60, 5, -35 }, +/* 0x18 */ { 5609, 56, 40, 60, 2, -35 }, +/* 0x19 */ { 5889, 48, 48, 60, 6, -39 }, +/* 0x1A */ { 6177, 0, 0, 0, 0, 0 }, +/* 0x1B */ { 6177, 56, 49, 60, 2, -39 }, +/* 0x1C */ { 6520, 48, 48, 60, 6, -39 }, +/* 0x1D */ { 6808, 49, 49, 60, 5, -39 }, +/* 0x1E */ { 7109, 48, 47, 60, 5, -38 }, +/* 0x1F */ { 7391, 34, 48, 60, 13, -39 }, +/* 0x20 */ { 7595, 1, 1, 15, 0, 0 }, +/* 0x21 */ { 7596, 5, 34, 19, 7, -33 }, +/* 0x22 */ { 7618, 13, 13, 22, 4, -33 }, +/* 0x23 */ { 7640, 32, 35, 39, 4, -34 }, +/* 0x24 */ { 7780, 22, 42, 30, 4, -34 }, +/* 0x25 */ { 7896, 39, 36, 45, 3, -34 }, +/* 0x26 */ { 8072, 32, 36, 37, 3, -34 }, +/* 0x27 */ { 8216, 4, 13, 13, 4, -33 }, +/* 0x28 */ { 8223, 11, 42, 18, 4, -35 }, +/* 0x29 */ { 8281, 11, 42, 18, 4, -35 }, +/* 0x2A */ { 8339, 21, 22, 24, 1, -34 }, +/* 0x2B */ { 8397, 30, 30, 39, 5, -29 }, +/* 0x2C */ { 8510, 6, 11, 15, 4, -5 }, +/* 0x2D */ { 8519, 12, 4, 17, 2, -14 }, +/* 0x2E */ { 8525, 4, 6, 15, 6, -5 }, +/* 0x2F */ { 8528, 16, 39, 16, 0, -33 }, +/* 0x30 */ { 8606, 24, 36, 30, 3, -34 }, +/* 0x31 */ { 8714, 20, 34, 30, 5, -33 }, +/* 0x32 */ { 8799, 22, 35, 30, 4, -34 }, +/* 0x33 */ { 8896, 23, 36, 30, 4, -34 }, +/* 0x34 */ { 9000, 25, 34, 30, 2, -33 }, +/* 0x35 */ { 9107, 22, 35, 30, 4, -33 }, +/* 0x36 */ { 9204, 24, 36, 30, 3, -34 }, +/* 0x37 */ { 9312, 22, 34, 30, 4, -33 }, +/* 0x38 */ { 9406, 24, 36, 30, 3, -34 }, +/* 0x39 */ { 9514, 24, 36, 30, 3, -34 }, +/* 0x3A */ { 9622, 5, 24, 16, 6, -23 }, +/* 0x3B */ { 9637, 6, 29, 16, 4, -23 }, +/* 0x3C */ { 9659, 29, 25, 39, 5, -26 }, +/* 0x3D */ { 9750, 29, 14, 39, 5, -21 }, +/* 0x3E */ { 9801, 29, 25, 39, 5, -26 }, +/* 0x3F */ { 9892, 18, 35, 25, 3, -34 }, +/* 0x40 */ { 9971, 41, 41, 47, 3, -32 }, +/* 0x41 */ { 10182, 31, 34, 32, 0, -33 }, +/* 0x42 */ { 10314, 24, 34, 32, 4, -33 }, +/* 0x43 */ { 10416, 28, 36, 33, 3, -34 }, +/* 0x44 */ { 10542, 29, 34, 36, 4, -33 }, +/* 0x45 */ { 10666, 22, 34, 30, 4, -33 }, +/* 0x46 */ { 10760, 20, 34, 27, 4, -33 }, +/* 0x47 */ { 10845, 30, 36, 36, 3, -34 }, +/* 0x48 */ { 10980, 26, 34, 35, 4, -33 }, +/* 0x49 */ { 11091, 4, 34, 14, 4, -33 }, +/* 0x4A */ { 11108, 11, 43, 14, -3, -33 }, +/* 0x4B */ { 11168, 27, 34, 31, 4, -33 }, +/* 0x4C */ { 11283, 21, 34, 26, 4, -33 }, +/* 0x4D */ { 11373, 31, 34, 41, 4, -33 }, +/* 0x4E */ { 11505, 26, 34, 35, 4, -33 }, +/* 0x4F */ { 11616, 32, 36, 37, 3, -34 }, +/* 0x50 */ { 11760, 22, 34, 28, 4, -33 }, +/* 0x51 */ { 11854, 32, 41, 37, 3, -34 }, +/* 0x52 */ { 12018, 27, 34, 33, 4, -33 }, +/* 0x53 */ { 12133, 24, 36, 30, 3, -34 }, +/* 0x54 */ { 12241, 28, 34, 29, 0, -33 }, +/* 0x55 */ { 12360, 26, 35, 34, 5, -33 }, +/* 0x56 */ { 12474, 31, 34, 32, 0, -33 }, +/* 0x57 */ { 12606, 43, 34, 46, 2, -33 }, +/* 0x58 */ { 12789, 29, 34, 31, 1, -33 }, +/* 0x59 */ { 12913, 28, 34, 29, 0, -33 }, +/* 0x5A */ { 13032, 28, 34, 32, 2, -33 }, +/* 0x5B */ { 13151, 10, 42, 18, 4, -35 }, +/* 0x5C */ { 13204, 16, 39, 16, 0, -33 }, +/* 0x5D */ { 13282, 9, 42, 18, 4, -35 }, +/* 0x5E */ { 13330, 29, 13, 39, 5, -33 }, +/* 0x5F */ { 13378, 24, 4, 24, 0, 8 }, +/* 0x60 */ { 13390, 11, 9, 24, 4, -37 }, +/* 0x61 */ { 13403, 22, 28, 29, 3, -26 }, +/* 0x62 */ { 13480, 23, 37, 30, 4, -35 }, +/* 0x63 */ { 13587, 20, 28, 26, 3, -26 }, +/* 0x64 */ { 13657, 23, 37, 30, 3, -35 }, +/* 0x65 */ { 13764, 24, 28, 29, 3, -26 }, +/* 0x66 */ { 13848, 16, 36, 17, 1, -35 }, +/* 0x67 */ { 13920, 23, 37, 30, 3, -26 }, +/* 0x68 */ { 14027, 22, 36, 30, 4, -35 }, +/* 0x69 */ { 14126, 4, 36, 13, 4, -35 }, +/* 0x6A */ { 14144, 9, 46, 13, -1, -35 }, +/* 0x6B */ { 14196, 23, 36, 27, 4, -35 }, +/* 0x6C */ { 14300, 4, 36, 13, 4, -35 }, +/* 0x6D */ { 14318, 38, 27, 46, 4, -26 }, +/* 0x6E */ { 14447, 22, 27, 30, 4, -26 }, +/* 0x6F */ { 14522, 24, 28, 29, 3, -26 }, +/* 0x70 */ { 14606, 23, 37, 30, 4, -26 }, +/* 0x71 */ { 14713, 23, 37, 30, 3, -26 }, +/* 0x72 */ { 14820, 15, 27, 19, 4, -26 }, +/* 0x73 */ { 14871, 19, 28, 24, 3, -26 }, +/* 0x74 */ { 14938, 16, 33, 18, 1, -32 }, +/* 0x75 */ { 15004, 22, 28, 30, 4, -26 }, +/* 0x76 */ { 15081, 25, 26, 28, 1, -25 }, +/* 0x77 */ { 15163, 35, 26, 38, 2, -25 }, +/* 0x78 */ { 15277, 25, 26, 28, 1, -25 }, +/* 0x79 */ { 15359, 25, 36, 28, 1, -25 }, +/* 0x7A */ { 15472, 21, 26, 25, 2, -25 }, +/* 0x7B */ { 15541, 18, 43, 30, 6, -35 }, +/* 0x7C */ { 15638, 4, 47, 16, 6, -35 }, +/* 0x7D */ { 15662, 18, 43, 30, 6, -35 }, +/* 0x7E */ { 15759, 29, 9, 39, 5, -18 }, +/* 0x7F */ { 15792, 0, 0, 0, 0, 0 }, +/* 0x80 */ { 15792, 26, 36, 30, 0, -34 }, +/* 0x81 */ { 15909, 0, 0, 0, 0, 0 }, +/* 0x82 */ { 15909, 6, 11, 15, 4, -5 }, +/* 0x83 */ { 15918, 20, 46, 17, -3, -35 }, +/* 0x84 */ { 16033, 15, 11, 24, 4, -5 }, +/* 0x85 */ { 16054, 36, 6, 47, 5, -5 }, +/* 0x86 */ { 16081, 20, 39, 24, 2, -33 }, +/* 0x87 */ { 16179, 20, 39, 24, 2, -33 }, +/* 0x88 */ { 16277, 0, 0, 0, 0, 0 }, +/* 0x89 */ { 16277, 58, 36, 63, 3, -34 }, +/* 0x8A */ { 16538, 0, 0, 0, 0, 0 }, +/* 0x8B */ { 16538, 11, 21, 19, 4, -23 }, +/* 0x8C */ { 16567, 0, 0, 0, 0, 0 }, +/* 0x8D */ { 16567, 0, 0, 0, 0, 0 }, +/* 0x8E */ { 16567, 0, 0, 0, 0, 0 }, +/* 0x8F */ { 16567, 0, 0, 0, 0, 0 }, +/* 0x90 */ { 16567, 0, 0, 0, 0, 0 }, +/* 0x91 */ { 16567, 6, 11, 15, 4, -33 }, +/* 0x92 */ { 16576, 6, 11, 15, 4, -33 }, +/* 0x93 */ { 16585, 15, 11, 24, 4, -33 }, +/* 0x94 */ { 16606, 15, 11, 24, 4, -33 }, +/* 0x95 */ { 16627, 14, 14, 28, 7, -23 }, +/* 0x96 */ { 16652, 19, 4, 24, 2, -14 }, +/* 0x97 */ { 16662, 42, 4, 47, 2, -14 }, +/* 0x98 */ { 16683, 0, 0, 0, 0, 0 }, +/* 0x99 */ { 16683, 31, 13, 47, 6, -33 }, +/* 0x9A */ { 16734, 0, 0, 0, 0, 0 }, +/* 0x9B */ { 16734, 11, 21, 19, 4, -23 }, +/* 0x9C */ { 16763, 0, 0, 0, 0, 0 }, +/* 0x9D */ { 16763, 0, 0, 0, 0, 0 }, +/* 0x9E */ { 16763, 0, 0, 0, 0, 0 }, +/* 0x9F */ { 16763, 0, 0, 0, 0, 0 }, +/* 0xA0 */ { 16763, 1, 1, 15, 0, 0 }, +/* 0xA1 */ { 16764, 15, 15, 24, 5, -45 }, +/* 0xA2 */ { 16793, 31, 38, 33, 0, -37 }, +/* 0xA3 */ { 16941, 22, 35, 30, 3, -34 }, +/* 0xA4 */ { 17038, 26, 26, 30, 2, -26 }, +/* 0xA5 */ { 17123, 26, 34, 30, 2, -33 }, +/* 0xA6 */ { 17234, 4, 40, 16, 6, -31 }, +/* 0xA7 */ { 17254, 19, 39, 24, 2, -34 }, +/* 0xA8 */ { 17347, 14, 5, 24, 5, -35 }, +/* 0xA9 */ { 17356, 34, 34, 47, 7, -33 }, +/* 0xAA */ { 17501, 0, 0, 0, 0, 0 }, +/* 0xAB */ { 17501, 21, 21, 29, 4, -23 }, +/* 0xAC */ { 17557, 29, 13, 39, 5, -19 }, +/* 0xAD */ { 17605, 12, 4, 17, 2, -14 }, +/* 0xAE */ { 17611, 34, 34, 47, 7, -33 }, +/* 0xAF */ { 17756, 47, 4, 47, 0, -14 }, +/* 0xB0 */ { 17780, 15, 15, 24, 4, -34 }, +/* 0xB1 */ { 17809, 30, 30, 39, 5, -29 }, +/* 0xB2 */ { 17922, 14, 19, 19, 2, -34 }, +/* 0xB3 */ { 17956, 14, 19, 19, 2, -34 }, +/* 0xB4 */ { 17990, 11, 9, 24, 9, -37 }, +/* 0xB5 */ { 18003, 15, 15, 24, 5, -45 }, +/* 0xB6 */ { 18032, 31, 38, 33, 0, -37 }, +/* 0xB7 */ { 18180, 4, 6, 15, 5, -18 }, +/* 0xB8 */ { 18183, 31, 38, 35, 0, -37 }, +/* 0xB9 */ { 18331, 35, 38, 41, 0, -37 }, +/* 0xBA */ { 18498, 13, 38, 19, 0, -37 }, +/* 0xBB */ { 18560, 21, 21, 29, 4, -23 }, +/* 0xBC */ { 18616, 36, 39, 38, 0, -37 }, +/* 0xBD */ { 18792, 39, 36, 46, 4, -34 }, +/* 0xBE */ { 18968, 38, 38, 39, 0, -37 }, +/* 0xBF */ { 19149, 36, 38, 39, 0, -37 }, +/* 0xC0 */ { 19320, 15, 46, 16, 0, -45 }, +/* 0xC1 */ { 19407, 31, 34, 32, 0, -33 }, +/* 0xC2 */ { 19539, 24, 34, 32, 4, -33 }, +/* 0xC3 */ { 19641, 21, 34, 25, 4, -33 }, +/* 0xC4 */ { 19731, 31, 34, 32, 0, -33 }, +/* 0xC5 */ { 19863, 22, 34, 30, 4, -33 }, +/* 0xC6 */ { 19957, 28, 34, 32, 2, -33 }, +/* 0xC7 */ { 20076, 26, 34, 35, 4, -33 }, +/* 0xC8 */ { 20187, 32, 36, 38, 3, -34 }, +/* 0xC9 */ { 20331, 4, 34, 14, 4, -33 }, +/* 0xCA */ { 20348, 27, 34, 31, 4, -33 }, +/* 0xCB */ { 20463, 31, 34, 32, 0, -33 }, +/* 0xCC */ { 20595, 31, 34, 41, 4, -33 }, +/* 0xCD */ { 20727, 26, 34, 35, 4, -33 }, +/* 0xCE */ { 20838, 21, 34, 29, 4, -33 }, +/* 0xCF */ { 20928, 32, 36, 37, 3, -34 }, +/* 0xD0 */ { 21072, 26, 34, 34, 4, -33 }, +/* 0xD1 */ { 21183, 22, 34, 28, 4, -33 }, +/* 0xD2 */ { 21277, 0, 0, 0, 0, 0 }, +/* 0xD3 */ { 21277, 22, 34, 29, 4, -33 }, +/* 0xD4 */ { 21371, 28, 34, 29, 0, -33 }, +/* 0xD5 */ { 21490, 28, 34, 29, 0, -33 }, +/* 0xD6 */ { 21609, 32, 34, 38, 3, -33 }, +/* 0xD7 */ { 21745, 29, 34, 31, 1, -33 }, +/* 0xD8 */ { 21869, 32, 34, 38, 3, -33 }, +/* 0xD9 */ { 22005, 32, 35, 38, 3, -34 }, +/* 0xDA */ { 22145, 14, 44, 14, -1, -43 }, +/* 0xDB */ { 22222, 28, 44, 29, 0, -43 }, +/* 0xDC */ { 22376, 26, 39, 31, 3, -37 }, +/* 0xDD */ { 22503, 19, 39, 25, 3, -37 }, +/* 0xDE */ { 22596, 22, 48, 30, 4, -37 }, +/* 0xDF */ { 22728, 12, 38, 16, 4, -37 }, +/* 0xE0 */ { 22785, 21, 47, 28, 4, -45 }, +/* 0xE1 */ { 22909, 26, 28, 31, 3, -26 }, +/* 0xE2 */ { 23000, 22, 46, 29, 4, -35 }, +/* 0xE3 */ { 23127, 26, 36, 28, 1, -25 }, +/* 0xE4 */ { 23244, 24, 36, 30, 3, -34 }, +/* 0xE5 */ { 23352, 19, 28, 25, 3, -26 }, +/* 0xE6 */ { 23419, 21, 47, 25, 2, -35 }, +/* 0xE7 */ { 23543, 22, 37, 30, 4, -26 }, +/* 0xE8 */ { 23645, 24, 37, 30, 3, -35 }, +/* 0xE9 */ { 23756, 10, 26, 16, 4, -25 }, +/* 0xEA */ { 23789, 22, 26, 27, 4, -25 }, +/* 0xEB */ { 23861, 25, 36, 27, 1, -35 }, +/* 0xEC */ { 23974, 25, 36, 30, 4, -25 }, +/* 0xED */ { 24087, 22, 26, 26, 2, -25 }, +/* 0xEE */ { 24159, 20, 47, 25, 2, -35 }, +/* 0xEF */ { 24277, 24, 28, 29, 3, -26 }, +/* 0xF0 */ { 24361, 25, 27, 28, 2, -25 }, +/* 0xF1 */ { 24446, 24, 37, 31, 4, -26 }, +/* 0xF2 */ { 24557, 20, 38, 28, 3, -26 }, +/* 0xF3 */ { 24652, 26, 27, 30, 3, -25 }, +/* 0xF4 */ { 24740, 24, 26, 28, 2, -25 }, +/* 0xF5 */ { 24818, 21, 26, 28, 4, -24 }, +/* 0xF6 */ { 24887, 26, 37, 32, 3, -26 }, +/* 0xF7 */ { 25008, 24, 36, 26, 1, -25 }, +/* 0xF8 */ { 25116, 26, 36, 32, 3, -25 }, +/* 0xF9 */ { 25233, 32, 26, 38, 3, -24 }, +/* 0xFA */ { 25337, 14, 36, 16, 0, -35 }, +/* 0xFB */ { 25400, 21, 37, 28, 4, -35 }, +/* 0xFC */ { 25498, 24, 39, 29, 3, -37 }, +/* 0xFD */ { 25615, 21, 39, 28, 4, -37 }, +/* 0xFE */ { 25718, 32, 39, 38, 3, -37 }, +/* 0xFF */ { 25874, 0, 0, 0, 0, 0 }, +}; + +const GFXfont FreeSans24pt_Win1253 PROGMEM = { +(uint8_t*)FreeSans24pt_Win1253Bitmaps, +(GFXglyph*)FreeSans24pt_Win1253Glyphs, +0x01, 0xFF, 55 +}; diff --git a/src/graphics/niche/InkHUD/AppletFont.h b/src/graphics/niche/InkHUD/AppletFont.h index 8374c7f617f..c97c12c36fe 100644 --- a/src/graphics/niche/InkHUD/AppletFont.h +++ b/src/graphics/niche/InkHUD/AppletFont.h @@ -88,8 +88,12 @@ class AppletFont // Greek #include "graphics/niche/Fonts/FreeSans12pt_Win1253.h" +#include "graphics/niche/Fonts/FreeSans18pt_Win1253.h" +#include "graphics/niche/Fonts/FreeSans24pt_Win1253.h" #include "graphics/niche/Fonts/FreeSans6pt_Win1253.h" #include "graphics/niche/Fonts/FreeSans9pt_Win1253.h" +#define FREESANS_24PT_WIN1253 InkHUD::AppletFont(FreeSans24pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -5, 3) +#define FREESANS_18PT_WIN1253 InkHUD::AppletFont(FreeSans18pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -4, 2) #define FREESANS_12PT_WIN1253 InkHUD::AppletFont(FreeSans12pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -3, 1) #define FREESANS_9PT_WIN1253 InkHUD::AppletFont(FreeSans9pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -2, -1) #define FREESANS_6PT_WIN1253 InkHUD::AppletFont(FreeSans6pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -1, -2) diff --git a/src/input/TouchScreenImpl1.cpp b/src/input/TouchScreenImpl1.cpp index 69dcab04e9c..14f95b73a52 100644 --- a/src/input/TouchScreenImpl1.cpp +++ b/src/input/TouchScreenImpl1.cpp @@ -29,7 +29,8 @@ void TouchScreenImpl1::init() return; #else TouchScreenBase::init(true); - inputBroker->registerSource(this); + if (inputBroker) + inputBroker->registerSource(this); #endif } diff --git a/variants/esp32s3/t5s3_epaper/nicheGraphics.h b/variants/esp32s3/t5s3_epaper/nicheGraphics.h index 699a82de0f9..18217800bb6 100644 --- a/variants/esp32s3/t5s3_epaper/nicheGraphics.h +++ b/variants/esp32s3/t5s3_epaper/nicheGraphics.h @@ -6,26 +6,27 @@ NicheGraphics attempts a different approach: Per-device config takes place in this setupNicheGraphics() method (And a small amount in platformio.ini) -This file sets up InkHUD for Heltec VM-E290. -Different NicheGraphics UIs and different hardware variants will each have their own setup procedure. +This file sets up InkHUD for the LilyGo T5-E-Paper-S3-Pro. + +The board uses a 4.7" ED047TC1 parallel e-paper display (960×540, 8-bit parallel interface). +This is driven via the FastEPD library through the NicheGraphics ED047TC1 driver adapter. */ #pragma once #include "configuration.h" -#include "mesh/MeshModule.h" #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS // InkHUD-specific components // --------------------------- -// #include "graphics/niche/InkHUD/InkHUD.h" -#include "graphics/niche/InkHUD/WindowManager.h" +#include "graphics/niche/InkHUD/InkHUD.h" // Applets #include "graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h" #include "graphics/niche/InkHUD/Applets/User/DM/DMApplet.h" +#include "graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.h" #include "graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h" #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" @@ -34,26 +35,20 @@ Different NicheGraphics UIs and different hardware variants will each have their // Shared NicheGraphics components // -------------------------------- #include "graphics/niche/Drivers/Backlight/LatchingBacklight.h" -#include "graphics/niche/Drivers/EInk/DEPG0290BNS800.h" +#include "graphics/niche/Drivers/EInk/ED047TC1.h" #include "graphics/niche/Inputs/TwoButton.h" void setupNicheGraphics() { using namespace NicheGraphics; - // SPI - // ----------------------------- - - // Display is connected to HSPI - SPIClass *hspi = new SPIClass(HSPI); - hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); - // E-Ink Driver // ----------------------------- + // The ED047TC1 is a parallel display — no SPI bus setup needed. + // begin() args are part of the EInk interface but are ignored for parallel displays. - // Use E-Ink driver - Drivers::EInk *driver = new Drivers::DEPG0290BNS800; - driver->begin(hspi, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY); + Drivers::EInk *driver = new Drivers::ED047TC1; + driver->begin(nullptr, 0, 0, 0); // InkHUD // ---------------------------- @@ -67,57 +62,57 @@ void setupNicheGraphics() // Set how unhealthy additional FAST updates beyond this number are inkhud->setDisplayResilience(7, 1.5); - // Prepare fonts - InkHUD::Applet::fontLarge = FREESANS_9PT_WIN1252; - InkHUD::Applet::fontSmall = FREESANS_6PT_WIN1252; + // Prepare fonts — use larger sizes to suit the 4.7" screen at ~234 DPI + InkHUD::Applet::fontLarge = FREESANS_24PT_WIN1253; + InkHUD::Applet::fontMedium = FREESANS_18PT_WIN1253; + InkHUD::Applet::fontSmall = FREESANS_12PT_WIN1253; - // Init settings, and customize defaults + // Customize default settings inkhud->persistence->settings.userTiles.maxCount = 2; // How many tiles can the display handle? - inkhud->persistence->settings.rotation = 1; // 90 degrees clockwise + inkhud->persistence->settings.rotation = 3; // 270 degrees clockwise inkhud->persistence->settings.userTiles.count = 1; // One tile only by default, keep things simple for new users - inkhud->persistence->settings.optionalMenuItems.nextTile = false; // Behavior handled by aux button instead - inkhud->persistence->settings.optionalFeatures.batteryIcon = true; // Device definitely has a battery + inkhud->persistence->settings.optionalFeatures.batteryIcon = true; + inkhud->persistence->settings.optionalMenuItems.backlight = true; - // Setup backlight - // Note: AUX button behavior configured further down - Drivers::LatchingBacklight *backlight = Drivers::LatchingBacklight::getInstance(); - backlight->setPin(PIN_EINK_EN); + // Alignment must cancel rotation for visual-frame touch input: (rotation + alignment) % 4 == 0. + inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; // Pick applets // Note: order of applets determines priority of "auto-show" feature - // Optional arguments for defaults: - // - is activated? - // - is autoshown? - // - is foreground on a specific tile (index)? - inkhud->addApplet("All Messages", new InkHUD::AllMessageApplet, true, true); // Activated, autoshown - inkhud->addApplet("DMs", new InkHUD::DMApplet); - inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); + inkhud->addApplet("All Messages", new InkHUD::AllMessageApplet, false, false); // Not Active, not autoshown + inkhud->addApplet("DMs", new InkHUD::DMApplet, true, true); // Activated, Autoshown + inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, true); // Activated, Autoshown + inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1), false, false); // Not Active, not autoshown + inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false); // Activated, not autoshown + inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false); // Activated, not autoshown inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 - // inkhud->addApplet("Basic", new InkHUD::BasicExampleApplet); - // inkhud->addApplet("NewMsg", new InkHUD::NewMsgExampleApplet); + inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // Not Active, not autoshown + + // Backlight + // ---------------------------- + Drivers::LatchingBacklight *backlight = Drivers::LatchingBacklight::getInstance(); + backlight->setPin(BOARD_BL_EN); // GPIO11 on V2 // Start running InkHUD inkhud->begin(); + // Touch navigation requires joystick mode — enforce post-begin so flash cannot override. + inkhud->persistence->settings.joystick.enabled = true; + inkhud->persistence->settings.joystick.aligned = true; + // Buttons // -------------------------- Inputs::TwoButton *buttons = Inputs::TwoButton::getInstance(); // A shared NicheGraphics component - // Setup the main user button (0) + // Setup the main user button (boot button, GPIO 0) buttons->setWiring(0, BUTTON_PIN); - buttons->setHandlerShortPress(0, []() { InkHUD::InkHUD::getInstance()->shortpress(); }); - buttons->setHandlerLongPress(0, []() { InkHUD::InkHUD::getInstance()->longpress(); }); + buttons->setHandlerShortPress(0, [inkhud]() { inkhud->shortpress(); }); + buttons->setHandlerLongPress(0, [inkhud]() { inkhud->longpress(); }); - // Setup the aux button (1) - // Bonus feature of VME290 - buttons->setWiring(1, BUTTON_PIN_SECONDARY); - buttons->setHandlerShortPress(1, []() { InkHUD::InkHUD::getInstance()->nextTile(); }); + // No dedicated aux button on this board buttons->start(); } -#endif \ No newline at end of file +#endif diff --git a/variants/esp32s3/t5s3_epaper/variant.cpp b/variants/esp32s3/t5s3_epaper/variant.cpp index e10d7c34757..3bc010ce061 100644 --- a/variants/esp32s3/t5s3_epaper/variant.cpp +++ b/variants/esp32s3/t5s3_epaper/variant.cpp @@ -2,21 +2,123 @@ #ifdef T5_S3_EPAPER_PRO +#include "Observer.h" #include "TouchDrvGT911.hpp" #include "Wire.h" +#include "input/InputBroker.h" #include "input/TouchScreenImpl1.h" +#include "sleep.h" + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +#include "graphics/niche/InkHUD/InkHUD.h" +#include "graphics/niche/InkHUD/SystemApplet.h" + +// Bridges touch events from TouchScreenImpl1 directly into InkHUD, +// bypassing the InputBroker (which is excluded in InkHUD builds). +// Routing mirrors the mini-epaper-s3 two-way rocker pattern: +// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu) +// - Nav up/down: navUp/navDown always (menu scroll) +// - Tap: shortpress (cycle applets / confirm in menu) +// - Long press: longpress (open menu / back) +class TouchInkHUDBridge : public Observer +{ + int onNotify(const InputEvent *e) override + { + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + + // Keep alignment in sync with the current rotation so that visual-frame gestures + // always pass through nav functions without remapping: (rotation + alignment) % 4 == 0. + inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; + + // Check whether a system applet (e.g. menu) is currently handling input + bool systemHandlingInput = false; + for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + systemHandlingInput = true; + break; + } + } + + switch (e->inputEvent) { + case INPUT_BROKER_USER_PRESS: + inkhud->shortpress(); + break; + case INPUT_BROKER_SELECT: + inkhud->longpress(); + break; + case INPUT_BROKER_LEFT: + if (systemHandlingInput) + inkhud->navUp(); + else + inkhud->prevApplet(); + break; + case INPUT_BROKER_RIGHT: + if (systemHandlingInput) + inkhud->navDown(); + else + inkhud->nextApplet(); + break; + case INPUT_BROKER_UP: + inkhud->navUp(); + break; + case INPUT_BROKER_DOWN: + inkhud->navDown(); + break; + default: + break; + } + return 0; + } +}; + +static TouchInkHUDBridge touchBridge; +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS TouchDrvGT911 touch; +// Commands the GT911 into standby before the Wire bus is torn down. +// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here. +struct TouchDeepSleepObserver { + int onDeepSleep(void *) + { + touch.sleep(); + return 0; + } + CallbackObserver observer{this, &TouchDeepSleepObserver::onDeepSleep}; +} static touchDeepSleepObserver; + bool readTouch(int16_t *x, int16_t *y) { if (!digitalRead(GT911_PIN_INT)) { int16_t raw_x; int16_t raw_y; if (touch.getPoint(&raw_x, &raw_y)) { - // rotate 90° for landscape - *x = raw_y; - *y = EPD_WIDTH - 1 - raw_x; +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. + // rotation=3 is the physical identity (device's default orientation). + switch (NicheGraphics::InkHUD::InkHUD::getInstance()->persistence->settings.rotation) { + default: + case 3: + *x = raw_x; + *y = raw_y; + break; // identity + case 2: + *x = (EPD_WIDTH - 1) - raw_y; + *y = raw_x; + break; // 90° CW tilt + case 1: + *x = (EPD_HEIGHT - 1) - raw_x; + *y = (EPD_WIDTH - 1) - raw_y; + break; // 180° flip + case 0: + *x = raw_y; + *y = (EPD_HEIGHT - 1) - raw_x; + break; // 90° CCW tilt + } +#else + *x = raw_x; + *y = raw_y; +#endif LOG_DEBUG("touched(%d/%d)", *x, *y); return true; } @@ -31,15 +133,46 @@ void earlyInitVariant() pinMode(SDCARD_CS, OUTPUT); digitalWrite(SDCARD_CS, HIGH); pinMode(BOARD_BL_EN, OUTPUT); + + // Program GT911 touch controller to I2C address 0x14 (GT911_SLAVE_ADDRESS_H) before + // the I2C bus scan runs. GPIO3 (INT) defaults LOW on ESP32-S3 cold boot, which would + // leave the GT911 at 0x5D (GT911_SLAVE_ADDRESS_L) — the same address as the SFA30 + // air quality sensor — causing a false-positive SFA30 detection during the I2C scan. + // + // GT911 datasheet §4.3 "Address Selection": + // Pull INT HIGH before releasing RST → device latches address 0x14 (SLAVE_ADDRESS_H) + // Pull INT LOW before releasing RST → device latches address 0x5D (SLAVE_ADDRESS_L) + // Minimum RST assert time: 100 µs; minimum startup time after RST deassert: 5 ms. + // + // lateInitVariant() calls touch.begin() which repeats this sequence internally while + // also performing full I2C initialisation; the double-reset is harmless. + pinMode(GT911_PIN_RST, OUTPUT); + digitalWrite(GT911_PIN_RST, LOW); + pinMode(GT911_PIN_INT, OUTPUT); + digitalWrite(GT911_PIN_INT, HIGH); // HIGH → latch address 0x14 + delay(1); // > 100 µs + digitalWrite(GT911_PIN_RST, HIGH); + delay(10); // > 5 ms startup + pinMode(GT911_PIN_INT, INPUT); // release INT for interrupt use +} + +void variant_shutdown() +{ + // Ensure frontlight is off during deep sleep + digitalWrite(BOARD_BL_EN, LOW); } // T5-S3-ePaper Pro specific (late-) init void lateInitVariant(void) { touch.setPins(GT911_PIN_RST, GT911_PIN_INT); - if (touch.begin(Wire, GT911_SLAVE_ADDRESS_L, GT911_PIN_SDA, GT911_PIN_SCL)) { + if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) { + touchDeepSleepObserver.observer.observe(¬ifyDeepSleep); touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch); touchScreenImpl1->init(); +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + touchBridge.observe(touchScreenImpl1); +#endif } else { LOG_ERROR("Failed to find touch controller!"); } diff --git a/variants/esp32s3/t5s3_epaper/variant.h b/variants/esp32s3/t5s3_epaper/variant.h index c2c0013732f..803b582af03 100644 --- a/variants/esp32s3/t5s3_epaper/variant.h +++ b/variants/esp32s3/t5s3_epaper/variant.h @@ -26,9 +26,9 @@ #define GT911_PIN_RST 9 #endif -#define PCF85063_RTC 0x51 +#define PCF8563_RTC 0x51 #define HAS_RTC 1 -#define PCF85063_INT 2 +#define PCF8563_INT 2 #define USE_POWERSAVE #define SLEEP_TIME 120 From db9fdd6794a365f2593bc93b770194dbd47e145e Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:43:35 +0100 Subject: [PATCH 042/225] Fix: filter out SKIPPED tests in PlatformIO output to improve log clarity (#10214) --- .github/workflows/test_native.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 2fabf0591ed..1e22d74d165 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -86,7 +86,13 @@ jobs: run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini - name: PlatformIO Tests - run: platformio test -e coverage -v --junit-output-path testreport.xml + run: | + set -o pipefail + # Filter out SKIPPED summary rows for hardware variants that can't run on the + # native host. They flood the log and make it harder to spot real failures. + # The JUnit XML is written directly to testreport.xml before the pipe, so + # the test artifact is unaffected. + platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$" - name: Save test results if: always() # run this step even if previous step failed From 2b5daf24387d7eccdf294d10f83092c98a23fc90 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 21 Apr 2026 20:02:56 -0500 Subject: [PATCH 043/225] T watch pinfix (#10231) * Minor button debugging bits * pin0 is a pin, pin -1 means disabled --- src/input/ButtonThread.cpp | 10 ++++++++-- variants/esp32s3/t-watch-s3/variant.h | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/input/ButtonThread.cpp b/src/input/ButtonThread.cpp index df8de49053a..3b0a46e88d2 100644 --- a/src/input/ButtonThread.cpp +++ b/src/input/ButtonThread.cpp @@ -143,6 +143,10 @@ int32_t ButtonThread::runOnce() leadUpSequenceActive = false; resetLeadUpSequence(); } +#ifdef INPUT_DEBUG + if (buttonCurrentlyPressed) + LOG_WARN("Button held for %u ms", millis() - buttonPressStartTime); +#endif // Progressive lead-up sound system if (!_suppressLeadUp && buttonCurrentlyPressed && (millis() - buttonPressStartTime) >= BUTTON_LEADUP_MS) { @@ -311,7 +315,8 @@ int32_t ButtonThread::runOnce() void ButtonThread::attachButtonInterrupts() { // Interrupt for user button, during normal use. Improves responsiveness. - attachInterrupt(_pinNum, _intRoutine, CHANGE); + if (_intRoutine != nullptr) + attachInterrupt(_pinNum, _intRoutine, CHANGE); } /* @@ -320,7 +325,8 @@ void ButtonThread::attachButtonInterrupts() */ void ButtonThread::detachButtonInterrupts() { - detachInterrupt(_pinNum); + if (_intRoutine != nullptr) + detachInterrupt(_pinNum); } #ifdef ARCH_ESP32 diff --git a/variants/esp32s3/t-watch-s3/variant.h b/variants/esp32s3/t-watch-s3/variant.h index df275c31d23..507d6b7dc35 100644 --- a/variants/esp32s3/t-watch-s3/variant.h +++ b/variants/esp32s3/t-watch-s3/variant.h @@ -39,7 +39,7 @@ #define DAC_I2S_BCK 48 #define DAC_I2S_WS 15 #define DAC_I2S_DOUT 46 -#define DAC_I2S_MCLK 0 +#define DAC_I2S_MCLK -1 #define HAS_AXP2101 From b53fe7a1e7d20f9b063d56a61181cfddb2a671e4 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 21 Apr 2026 20:02:56 -0500 Subject: [PATCH 044/225] T watch pinfix (#10231) * Minor button debugging bits * pin0 is a pin, pin -1 means disabled --- src/input/ButtonThread.cpp | 10 ++++++++-- variants/esp32s3/t-watch-s3/variant.h | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/input/ButtonThread.cpp b/src/input/ButtonThread.cpp index df8de49053a..3b0a46e88d2 100644 --- a/src/input/ButtonThread.cpp +++ b/src/input/ButtonThread.cpp @@ -143,6 +143,10 @@ int32_t ButtonThread::runOnce() leadUpSequenceActive = false; resetLeadUpSequence(); } +#ifdef INPUT_DEBUG + if (buttonCurrentlyPressed) + LOG_WARN("Button held for %u ms", millis() - buttonPressStartTime); +#endif // Progressive lead-up sound system if (!_suppressLeadUp && buttonCurrentlyPressed && (millis() - buttonPressStartTime) >= BUTTON_LEADUP_MS) { @@ -311,7 +315,8 @@ int32_t ButtonThread::runOnce() void ButtonThread::attachButtonInterrupts() { // Interrupt for user button, during normal use. Improves responsiveness. - attachInterrupt(_pinNum, _intRoutine, CHANGE); + if (_intRoutine != nullptr) + attachInterrupt(_pinNum, _intRoutine, CHANGE); } /* @@ -320,7 +325,8 @@ void ButtonThread::attachButtonInterrupts() */ void ButtonThread::detachButtonInterrupts() { - detachInterrupt(_pinNum); + if (_intRoutine != nullptr) + detachInterrupt(_pinNum); } #ifdef ARCH_ESP32 diff --git a/variants/esp32s3/t-watch-s3/variant.h b/variants/esp32s3/t-watch-s3/variant.h index df275c31d23..507d6b7dc35 100644 --- a/variants/esp32s3/t-watch-s3/variant.h +++ b/variants/esp32s3/t-watch-s3/variant.h @@ -39,7 +39,7 @@ #define DAC_I2S_BCK 48 #define DAC_I2S_WS 15 #define DAC_I2S_DOUT 46 -#define DAC_I2S_MCLK 0 +#define DAC_I2S_MCLK -1 #define HAS_AXP2101 From fb1de111d71cbe8e191afd54965151d9ff8c6880 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:34:48 -0500 Subject: [PATCH 045/225] Update LovyanGFX to v1.2.20 (#10232) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32/chatter2/platformio.ini | 2 +- variants/esp32/m5stack_core/platformio.ini | 2 +- variants/esp32/wiphone/platformio.ini | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini | 2 +- variants/esp32s3/mesh-tab/platformio.ini | 2 +- variants/esp32s3/picomputer-s3/platformio.ini | 2 +- variants/esp32s3/rak_wismesh_tap_v2/platformio.ini | 2 +- variants/esp32s3/t-deck/platformio.ini | 2 +- variants/esp32s3/t-watch-s3/platformio.ini | 2 +- variants/esp32s3/tlora-pager/platformio.ini | 2 +- variants/esp32s3/tracksenger/platformio.ini | 4 ++-- variants/esp32s3/unphone/platformio.ini | 2 +- variants/native/portduino.ini | 2 +- 16 files changed, 17 insertions(+), 17 deletions(-) diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index a14e407a10c..bb3824fce08 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -12,4 +12,4 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 diff --git a/variants/esp32/m5stack_core/platformio.ini b/variants/esp32/m5stack_core/platformio.ini index 8fbbae8956b..bded13c3b6b 100644 --- a/variants/esp32/m5stack_core/platformio.ini +++ b/variants/esp32/m5stack_core/platformio.ini @@ -35,4 +35,4 @@ lib_ignore = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 diff --git a/variants/esp32/wiphone/platformio.ini b/variants/esp32/wiphone/platformio.ini index fbd77be7542..8def28b90c8 100644 --- a/variants/esp32/wiphone/platformio.ini +++ b/variants/esp32/wiphone/platformio.ini @@ -11,7 +11,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 # renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander sparkfun/SX1509 IO Expander@3.0.6 # renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102 diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 5a5004a456a..5306e9b5d17 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -131,6 +131,6 @@ build_flags = lib_deps = ${heltec_v4_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index 33643c54168..b096c3a7b27 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -24,4 +24,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini index ab6592afb5a..2c714be5768 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index ebf0118bbed..c75b78fa596 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -21,4 +21,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 diff --git a/variants/esp32s3/mesh-tab/platformio.ini b/variants/esp32s3/mesh-tab/platformio.ini index a153ba9fb3a..716e94d6238 100644 --- a/variants/esp32s3/mesh-tab/platformio.ini +++ b/variants/esp32s3/mesh-tab/platformio.ini @@ -55,7 +55,7 @@ lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 [mesh_tab_xpt2046] extends = mesh_tab_base diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index 6f218a126de..62228799c32 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -25,7 +25,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 build_src_filter = ${esp32s3_base.build_src_filter} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index 7847410ae99..8f1eb593ade 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -37,7 +37,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 [env:rak_wismesh_tap_v2-tft] extends = env:rak_wismesh_tap_v2 diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 1b3599464e4..3e2756612a9 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index 35239681887..e2539bd6cf9 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -22,7 +22,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index 832f9d7d7f6..a38493905a8 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -33,7 +33,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/tracksenger/platformio.ini b/variants/esp32s3/tracksenger/platformio.ini index c006cf835d7..d862392b00f 100644 --- a/variants/esp32s3/tracksenger/platformio.ini +++ b/variants/esp32s3/tracksenger/platformio.ini @@ -22,7 +22,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 [env:tracksenger-lcd] custom_meshtastic_hw_model = 48 @@ -48,7 +48,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 [env:tracksenger-oled] custom_meshtastic_hw_model = 48 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index 3c342e2ac1f..8903b4b2b13 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -37,7 +37,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 # TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0 https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 87d8431a3e8..77974c8e52c 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -27,7 +27,7 @@ lib_deps = # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto rweather/Crypto@0.4.0 # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.20 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main https://github.com/pine64/libch341-spi-userspace/archive/23c42319a69cffcb65868e3c72e6bed83974a393.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library From a71084172334ec0a1f8b360ce3bfe7f940b59b77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:35:07 -0500 Subject: [PATCH 046/225] Upgrade trunk (#10236) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 91f3c06fc70..735c04d3ee1 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,9 +9,9 @@ plugins: lint: enabled: - checkov@3.2.524 - - renovate@43.136.3 + - renovate@43.139.2 - prettier@3.8.3 - - trufflehog@3.94.3 + - trufflehog@3.95.2 - yamllint@1.38.0 - bandit@1.9.4 - trivy@0.70.0 From a4b55bc6f24ae6528251dd816c7ef7fe9f85b2d3 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 22 Apr 2026 12:41:49 -0400 Subject: [PATCH 047/225] cardputer-adv: Move variant.cpp -> extra_variants/variant.cpp (#10242) Fixes issues with #includes inherited from `configuration.h` when building for pioarduino. Aligns cardputer-adv with other variants like t_deck_pro. --- .../extra_variants}/m5stack_cardputer_adv/variant.cpp | 7 ++++++- variants/esp32s3/m5stack_cardputer_adv/platformio.ini | 3 --- 2 files changed, 6 insertions(+), 4 deletions(-) rename {variants/esp32s3 => src/platform/extra_variants}/m5stack_cardputer_adv/variant.cpp (97%) diff --git a/variants/esp32s3/m5stack_cardputer_adv/variant.cpp b/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp similarity index 97% rename from variants/esp32s3/m5stack_cardputer_adv/variant.cpp rename to src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp index 2bbe8e2e332..7ec9dca807a 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/variant.cpp +++ b/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp @@ -1,6 +1,9 @@ -#include "AudioBoard.h" #include "configuration.h" +#ifdef M5STACK_CARDPUTER_ADV + +#include "AudioBoard.h" + DriverPins PinsAudioBoardES8311; AudioBoard board(AudioDriverES8311, PinsAudioBoardES8311); @@ -38,3 +41,5 @@ void lateInitVariant() es8311_write_reg(0x32, 0xBF); // DAC volume (0dB) es8311_write_reg(0x37, 0x08); // EQ bypass } + +#endif diff --git a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini index 3b378ed942a..69c4f52a5bd 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini +++ b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini @@ -10,9 +10,6 @@ build_flags = -D M5STACK_CARDPUTER_ADV -D ARDUINO_USB_CDC_ON_BOOT=1 -I variants/esp32s3/m5stack_cardputer_adv -build_src_filter = - ${esp32s3_base.build_src_filter} - +<../variants/esp32s3/m5stack_cardputer_adv> lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main From fcb9ec0c2d8cd7b1dc6d4c2fe22e35ee1aa84358 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 22 Apr 2026 12:42:02 -0400 Subject: [PATCH 048/225] t5s3-epaper: Move variant.cpp -> extra_variants/variant.cpp (#10241) Fixes issues with #includes inherited from `configuration.h` when building for pioarduino. Aligns t5s3_epaper with other variants like t_deck_pro. --- .../extra_variants/t5s3_epaper/variant.cpp | 144 +++++++++++++++++ variants/esp32s3/t5s3_epaper/platformio.ini | 2 +- variants/esp32s3/t5s3_epaper/variant.cpp | 147 +----------------- 3 files changed, 148 insertions(+), 145 deletions(-) create mode 100644 src/platform/extra_variants/t5s3_epaper/variant.cpp diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp new file mode 100644 index 00000000000..827b3f5bd71 --- /dev/null +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -0,0 +1,144 @@ +#include "configuration.h" + +#ifdef T5_S3_EPAPER_PRO + +#include "Observer.h" +#include "TouchDrvGT911.hpp" +#include "Wire.h" +#include "input/InputBroker.h" +#include "input/TouchScreenImpl1.h" +#include "sleep.h" + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +#include "graphics/niche/InkHUD/InkHUD.h" +#include "graphics/niche/InkHUD/SystemApplet.h" + +// Bridges touch events from TouchScreenImpl1 directly into InkHUD, +// bypassing the InputBroker (which is excluded in InkHUD builds). +// Routing mirrors the mini-epaper-s3 two-way rocker pattern: +// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu) +// - Nav up/down: navUp/navDown always (menu scroll) +// - Tap: shortpress (cycle applets / confirm in menu) +// - Long press: longpress (open menu / back) +class TouchInkHUDBridge : public Observer +{ + int onNotify(const InputEvent *e) override + { + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + + // Keep alignment in sync with the current rotation so that visual-frame gestures + // always pass through nav functions without remapping: (rotation + alignment) % 4 == 0. + inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; + + // Check whether a system applet (e.g. menu) is currently handling input + bool systemHandlingInput = false; + for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + systemHandlingInput = true; + break; + } + } + + switch (e->inputEvent) { + case INPUT_BROKER_USER_PRESS: + inkhud->shortpress(); + break; + case INPUT_BROKER_SELECT: + inkhud->longpress(); + break; + case INPUT_BROKER_LEFT: + if (systemHandlingInput) + inkhud->navUp(); + else + inkhud->prevApplet(); + break; + case INPUT_BROKER_RIGHT: + if (systemHandlingInput) + inkhud->navDown(); + else + inkhud->nextApplet(); + break; + case INPUT_BROKER_UP: + inkhud->navUp(); + break; + case INPUT_BROKER_DOWN: + inkhud->navDown(); + break; + default: + break; + } + return 0; + } +}; + +static TouchInkHUDBridge touchBridge; +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +TouchDrvGT911 touch; + +// Commands the GT911 into standby before the Wire bus is torn down. +// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here. +struct TouchDeepSleepObserver { + int onDeepSleep(void *) + { + touch.sleep(); + return 0; + } + CallbackObserver observer{this, &TouchDeepSleepObserver::onDeepSleep}; +} static touchDeepSleepObserver; + +bool readTouch(int16_t *x, int16_t *y) +{ + if (!digitalRead(GT911_PIN_INT)) { + int16_t raw_x; + int16_t raw_y; + if (touch.getPoint(&raw_x, &raw_y)) { +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. + // rotation=3 is the physical identity (device's default orientation). + switch (NicheGraphics::InkHUD::InkHUD::getInstance()->persistence->settings.rotation) { + default: + case 3: + *x = raw_x; + *y = raw_y; + break; // identity + case 2: + *x = (EPD_WIDTH - 1) - raw_y; + *y = raw_x; + break; // 90° CW tilt + case 1: + *x = (EPD_HEIGHT - 1) - raw_x; + *y = (EPD_WIDTH - 1) - raw_y; + break; // 180° flip + case 0: + *x = raw_y; + *y = (EPD_HEIGHT - 1) - raw_x; + break; // 90° CCW tilt + } +#else + *x = raw_x; + *y = raw_y; +#endif + LOG_DEBUG("touched(%d/%d)", *x, *y); + return true; + } + } + return false; +} + +// T5-S3-ePaper Pro specific (late-) init +void lateInitVariant(void) +{ + touch.setPins(GT911_PIN_RST, GT911_PIN_INT); + if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) { + touchDeepSleepObserver.observer.observe(¬ifyDeepSleep); + touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch); + touchScreenImpl1->init(); +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + touchBridge.observe(touchScreenImpl1); +#endif + } else { + LOG_ERROR("Failed to find touch controller!"); + } +} +#endif diff --git a/variants/esp32s3/t5s3_epaper/platformio.ini b/variants/esp32s3/t5s3_epaper/platformio.ini index bad36706c94..2001133c77f 100644 --- a/variants/esp32s3/t5s3_epaper/platformio.ini +++ b/variants/esp32s3/t5s3_epaper/platformio.ini @@ -5,7 +5,7 @@ board_build.partition = default_16MB.csv board_check = true upload_protocol = esptool build_flags = -fno-strict-aliasing - ${esp32_base.build_flags} + ${esp32s3_base.build_flags} -I variants/esp32s3/t5s3_epaper -D T5_S3_EPAPER_PRO -D USE_EINK diff --git a/variants/esp32s3/t5s3_epaper/variant.cpp b/variants/esp32s3/t5s3_epaper/variant.cpp index 3bc010ce061..6cae0e5c05a 100644 --- a/variants/esp32s3/t5s3_epaper/variant.cpp +++ b/variants/esp32s3/t5s3_epaper/variant.cpp @@ -1,130 +1,6 @@ -#include "configuration.h" - -#ifdef T5_S3_EPAPER_PRO - -#include "Observer.h" -#include "TouchDrvGT911.hpp" -#include "Wire.h" -#include "input/InputBroker.h" -#include "input/TouchScreenImpl1.h" -#include "sleep.h" - -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS -#include "graphics/niche/InkHUD/InkHUD.h" -#include "graphics/niche/InkHUD/SystemApplet.h" - -// Bridges touch events from TouchScreenImpl1 directly into InkHUD, -// bypassing the InputBroker (which is excluded in InkHUD builds). -// Routing mirrors the mini-epaper-s3 two-way rocker pattern: -// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu) -// - Nav up/down: navUp/navDown always (menu scroll) -// - Tap: shortpress (cycle applets / confirm in menu) -// - Long press: longpress (open menu / back) -class TouchInkHUDBridge : public Observer -{ - int onNotify(const InputEvent *e) override - { - auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); - - // Keep alignment in sync with the current rotation so that visual-frame gestures - // always pass through nav functions without remapping: (rotation + alignment) % 4 == 0. - inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; - - // Check whether a system applet (e.g. menu) is currently handling input - bool systemHandlingInput = false; - for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { - if (sa->handleInput) { - systemHandlingInput = true; - break; - } - } - - switch (e->inputEvent) { - case INPUT_BROKER_USER_PRESS: - inkhud->shortpress(); - break; - case INPUT_BROKER_SELECT: - inkhud->longpress(); - break; - case INPUT_BROKER_LEFT: - if (systemHandlingInput) - inkhud->navUp(); - else - inkhud->prevApplet(); - break; - case INPUT_BROKER_RIGHT: - if (systemHandlingInput) - inkhud->navDown(); - else - inkhud->nextApplet(); - break; - case INPUT_BROKER_UP: - inkhud->navUp(); - break; - case INPUT_BROKER_DOWN: - inkhud->navDown(); - break; - default: - break; - } - return 0; - } -}; - -static TouchInkHUDBridge touchBridge; -#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS - -TouchDrvGT911 touch; - -// Commands the GT911 into standby before the Wire bus is torn down. -// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here. -struct TouchDeepSleepObserver { - int onDeepSleep(void *) - { - touch.sleep(); - return 0; - } - CallbackObserver observer{this, &TouchDeepSleepObserver::onDeepSleep}; -} static touchDeepSleepObserver; - -bool readTouch(int16_t *x, int16_t *y) -{ - if (!digitalRead(GT911_PIN_INT)) { - int16_t raw_x; - int16_t raw_y; - if (touch.getPoint(&raw_x, &raw_y)) { -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. - // rotation=3 is the physical identity (device's default orientation). - switch (NicheGraphics::InkHUD::InkHUD::getInstance()->persistence->settings.rotation) { - default: - case 3: - *x = raw_x; - *y = raw_y; - break; // identity - case 2: - *x = (EPD_WIDTH - 1) - raw_y; - *y = raw_x; - break; // 90° CW tilt - case 1: - *x = (EPD_HEIGHT - 1) - raw_x; - *y = (EPD_WIDTH - 1) - raw_y; - break; // 180° flip - case 0: - *x = raw_y; - *y = (EPD_HEIGHT - 1) - raw_x; - break; // 90° CCW tilt - } -#else - *x = raw_x; - *y = raw_y; -#endif - LOG_DEBUG("touched(%d/%d)", *x, *y); - return true; - } - } - return false; -} +#include "variant.h" +#include "Arduino.h" +#include "pins_arduino.h" void earlyInitVariant() { @@ -161,20 +37,3 @@ void variant_shutdown() // Ensure frontlight is off during deep sleep digitalWrite(BOARD_BL_EN, LOW); } - -// T5-S3-ePaper Pro specific (late-) init -void lateInitVariant(void) -{ - touch.setPins(GT911_PIN_RST, GT911_PIN_INT); - if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) { - touchDeepSleepObserver.observer.observe(¬ifyDeepSleep); - touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch); - touchScreenImpl1->init(); -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - touchBridge.observe(touchScreenImpl1); -#endif - } else { - LOG_ERROR("Failed to find touch controller!"); - } -} -#endif \ No newline at end of file From d8b11f0b14cf321eaca2c9bd33eb4cba163a2e11 Mon Sep 17 00:00:00 2001 From: Jason P Date: Wed, 22 Apr 2026 11:42:25 -0500 Subject: [PATCH 049/225] Improve options to align to names of UI options (#10240) --- .github/ISSUE_TEMPLATE/Bug Report.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/Bug Report.yml b/.github/ISSUE_TEMPLATE/Bug Report.yml index bc77e8c1b8c..cdf4823445e 100644 --- a/.github/ISSUE_TEMPLATE/Bug Report.yml +++ b/.github/ISSUE_TEMPLATE/Bug Report.yml @@ -75,11 +75,11 @@ body: - type: checkboxes id: mui attributes: - label: Is this bug report about any UI component firmware like InkHUD or Meshtatic UI (MUI)? + label: Is this bug report about any UI (https://meshtastic.org/docs/configuration/device-uis/) component firmware? options: - - label: Meshtastic UI aka MUI colorTFT - - label: InkHUD ePaper - - label: OLED slide UI on any display + - label: Meshtastic UI aka MUI + - label: InkHUD + - label: BaseUI - type: input id: version From 28e705de5c495d38124a1609fefd9cf03132ba30 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 22 Apr 2026 14:27:48 -0500 Subject: [PATCH 050/225] Detach power interrupts for sleep (#10230) * Detach power interrupts for sleep * Gate PMU IRQ behind a found PMU --- src/Power.cpp | 129 ++++++++++++++++++++++++++++++++++++++------------ src/power.h | 18 ++++++- src/sleep.cpp | 11 ++--- 3 files changed, 121 insertions(+), 37 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 26b9615254e..934e09d6edf 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -746,37 +746,17 @@ bool Power::setup() found = true; #endif } -#ifdef EXT_PWR_DETECT - attachInterrupt( - EXT_PWR_DETECT, - []() { - power->setIntervalFromNow(0); - runASAP = true; - }, - CHANGE); -#endif -#ifdef BATTERY_CHARGING_INV - attachInterrupt( - BATTERY_CHARGING_INV, - []() { - power->setIntervalFromNow(0); - runASAP = true; - }, - CHANGE); -#endif -#ifdef EXT_CHRG_DETECT - attachInterrupt( - EXT_CHRG_DETECT, - []() { - power->setIntervalFromNow(0); - runASAP = true; - BaseType_t higherWake = 0; - }, - CHANGE); -#endif + attachPowerInterrupts(); enabled = found; low_voltage_counter = 0; +#ifdef ARCH_ESP32 + // Register callbacks for before and after lightsleep + // Used to detach and reattach interrupts + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif + return found; } @@ -1055,6 +1035,97 @@ int32_t Power::runOnce() return (statusHandler && statusHandler->isInitialized()) ? (1000 * 20) : RUN_SAME; } +#ifdef ARCH_ESP32 + +// Detach our class' interrupts before lightsleep +// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press +int Power::beforeLightSleep(void *unused) +{ + LOG_WARN("Detaching power interrupts for sleep"); + detachPowerInterrupts(); + return 0; // Indicates success +} + +// Reconfigure our interrupts +// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep +int Power::afterLightSleep(esp_sleep_wakeup_cause_t cause) +{ + attachPowerInterrupts(); + return 0; // Indicates success +} + +#endif + +/* + * Attach (or re-attach) hardware interrupts for power management + * Public method. Used outside class when waking from MCU sleep + */ +void Power::attachPowerInterrupts() +{ +#ifdef EXT_PWR_DETECT + attachInterrupt( + EXT_PWR_DETECT, + []() { + power->setIntervalFromNow(0); + runASAP = true; + }, + CHANGE); +#endif +#ifdef BATTERY_CHARGING_INV + attachInterrupt( + BATTERY_CHARGING_INV, + []() { + power->setIntervalFromNow(0); + runASAP = true; + }, + CHANGE); +#endif +#ifdef EXT_CHRG_DETECT + attachInterrupt( + EXT_CHRG_DETECT, + []() { + power->setIntervalFromNow(0); + runASAP = true; + BaseType_t higherWake = 0; + }, + CHANGE); +#endif +#ifdef PMU_IRQ + if (PMU) { + attachInterrupt( + PMU_IRQ, + [] { + pmu_irq = true; + power->setIntervalFromNow(0); + runASAP = true; + }, + FALLING); + } +#endif +} + +/* + * Detach the "normal" button interrupts. + * Public method. Used before attaching a "wake-on-button" interrupt for MCU sleep + */ +void Power::detachPowerInterrupts() +{ +#ifdef EXT_PWR_DETECT + detachInterrupt(EXT_PWR_DETECT); +#endif +#ifdef BATTERY_CHARGING_INV + detachInterrupt(BATTERY_CHARGING_INV); +#endif +#ifdef EXT_CHRG_DETECT + detachInterrupt(EXT_CHRG_DETECT); +#endif +#ifdef PMU_IRQ + if (PMU) { + detachInterrupt(PMU_IRQ); + } +#endif +} + /** * Init the power manager chip * @@ -1332,8 +1403,6 @@ bool Power::axpChipInit() } pinMode(PMU_IRQ, INPUT); - attachInterrupt( - PMU_IRQ, [] { pmu_irq = true; }, FALLING); // we do not look for AXPXXX_CHARGING_FINISHED_IRQ & AXPXXX_CHARGING_IRQ // because it occurs repeatedly while there is no battery also it could cause diff --git a/src/power.h b/src/power.h index d46eaadd27e..dfc46d6793c 100644 --- a/src/power.h +++ b/src/power.h @@ -86,7 +86,7 @@ extern RAK9154Sensor rak9154Sensor; extern XPowersLibInterface *PMU; #endif -class Power : private concurrency::OSThread +class Power : public concurrency::OSThread { public: @@ -101,6 +101,14 @@ class Power : private concurrency::OSThread void setStatusHandler(meshtastic::PowerStatus *handler) { statusHandler = handler; } const uint16_t OCV[11] = {OCV_ARRAY}; +#ifdef ARCH_ESP32 + int beforeLightSleep(void *unused); + int afterLightSleep(esp_sleep_wakeup_cause_t cause); +#endif + + void attachPowerInterrupts(); + void detachPowerInterrupts(); + protected: meshtastic::PowerStatus *statusHandler; @@ -125,6 +133,14 @@ class Power : private concurrency::OSThread // open circuit voltage lookup table uint8_t low_voltage_counter; uint32_t lastLogTime = 0; + +#ifdef ARCH_ESP32 + // Get notified when lightsleep begins and ends + CallbackObserver lsObserver = CallbackObserver(this, &Power::beforeLightSleep); + CallbackObserver lsEndObserver = + CallbackObserver(this, &Power::afterLightSleep); +#endif + #ifdef DEBUG_HEAP uint32_t lastheap; #endif diff --git a/src/sleep.cpp b/src/sleep.cpp index 9c044eaf7ac..64bd0c48033 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -497,13 +497,12 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); notifyLightSleepEnd.notifyObservers(cause); // Button interrupts are reattached here -#ifdef BUTTON_PIN if (cause == ESP_SLEEP_WAKEUP_GPIO) { - LOG_INFO("Exit light sleep gpio: btn=%d", - !digitalRead(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN)); - } else -#endif - { + LOG_INFO("Exit light sleep gpio"); + // If we woke because of a GPIO, it's possible power needs to run to handle. + power->setIntervalFromNow(0); + runASAP = true; + } else { LOG_INFO("Exit light sleep cause: %d", cause); } From 6171ad8c14b634d01b2c6afda3c398d55102aaee Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 22 Apr 2026 19:35:13 -0500 Subject: [PATCH 051/225] Remove duplicate GPIO init block in setup() from bad merge in PR #9378 The T-Deck-Pro V1.1 PR (#9378) had a backwards merge (e7b66281) that pulled 236 commits of master INTO a 1-commit fork branch, then a second merge (8fd0a7f2) duplicated the entire T_DECK/T_DECK_PRO/T_LORA_PAGER/HACKADAY peripheral init block in setup(). Also removes a stray digitalWrite(BLE_LED, LED_STATE_OFF) in the HACKADAY section that was an artifact of the evil merge at e7b66281. --- src/main.cpp | 63 ---------------------------------------------------- 1 file changed, 63 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index e517f94f08d..cee18bb7546 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -347,69 +347,6 @@ void setup() #endif #endif -#if defined(T_DECK) - // GPIO10 manages all peripheral power supplies - // Turn on peripheral power immediately after MUC starts. - // If some boards are turned on late, ESP32 will reset due to low voltage. - // ESP32-C3(Keyboard) , MAX98357A(Audio Power Amplifier) , - // TF Card , Display backlight(AW9364DNR) , AN48841B(Trackball) , ES7210(Decoder) - pinMode(KB_POWERON, OUTPUT); - digitalWrite(KB_POWERON, HIGH); - // T-Deck has all three SPI peripherals (TFT, SD, LoRa) attached to the same SPI bus - // We need to initialize all CS pins in advance otherwise there will be SPI communication issues - // e.g. when detecting the SD card - 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); -#elif defined(T_DECK_PRO) - pinMode(LORA_EN, OUTPUT); - digitalWrite(LORA_EN, HIGH); - pinMode(LORA_CS, OUTPUT); - digitalWrite(LORA_CS, HIGH); - pinMode(SDCARD_CS, OUTPUT); - digitalWrite(SDCARD_CS, HIGH); - pinMode(PIN_EINK_CS, OUTPUT); - digitalWrite(PIN_EINK_CS, HIGH); -#if PIN_EINK_RES >= 0 - pinMode(PIN_EINK_RES, OUTPUT); - digitalWrite(PIN_EINK_RES, HIGH); -#endif - pinMode(CST328_PIN_RST, OUTPUT); - digitalWrite(CST328_PIN_RST, HIGH); -#elif defined(T_LORA_PAGER) - pinMode(LORA_CS, OUTPUT); - digitalWrite(LORA_CS, HIGH); - pinMode(SDCARD_CS, OUTPUT); - digitalWrite(SDCARD_CS, HIGH); - pinMode(TFT_CS, OUTPUT); - digitalWrite(TFT_CS, HIGH); - pinMode(KB_INT, INPUT_PULLUP); - // io expander - io.begin(Wire, XL9555_SLAVE_ADDRESS0, SDA, SCL); - io.pinMode(EXPANDS_DRV_EN, OUTPUT); - io.digitalWrite(EXPANDS_DRV_EN, HIGH); - io.pinMode(EXPANDS_AMP_EN, OUTPUT); - io.digitalWrite(EXPANDS_AMP_EN, LOW); - io.pinMode(EXPANDS_LORA_EN, OUTPUT); - io.digitalWrite(EXPANDS_LORA_EN, HIGH); - io.pinMode(EXPANDS_GPS_EN, OUTPUT); - io.digitalWrite(EXPANDS_GPS_EN, HIGH); - io.pinMode(EXPANDS_KB_EN, OUTPUT); - io.digitalWrite(EXPANDS_KB_EN, HIGH); - io.pinMode(EXPANDS_SD_EN, OUTPUT); - io.digitalWrite(EXPANDS_SD_EN, HIGH); - io.pinMode(EXPANDS_GPIO_EN, OUTPUT); - io.digitalWrite(EXPANDS_GPIO_EN, HIGH); - io.pinMode(EXPANDS_SD_PULLEN, INPUT); -#elif defined(HACKADAY_COMMUNICATOR) - pinMode(KB_INT, INPUT); - digitalWrite(BLE_LED, LED_STATE_OFF); -#endif - #if defined(T_DECK) // GPIO10 manages all peripheral power supplies // Turn on peripheral power immediately after MUC starts. From a6b1a69630f4efff1879e94966287be92c502011 Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:04:37 -0500 Subject: [PATCH 052/225] StoreForwardModule::historyAdd: memcpy source size, not buffer capacity (#10250) `memcpy(... p.payload.bytes, meshtastic_Constants_DATA_PAYLOAD_LEN)` reads past the actual payload when the incoming packet's payload is shorter than `DATA_PAYLOAD_LEN` (237 bytes). The code just above already records the correct size: this->packetHistory[...].payload_size = p.payload.size; but then the memcpy ignores that and copies the full buffer capacity, pulling uninitialized / adjacent memory bytes into the history entry. Those extra bytes are later rebroadcast whenever the Store & Forward module replays the packet. Fix: memcpy using `p.payload.size` (the actual payload length) instead of the constant buffer capacity. Classification: bounded out-of-bounds READ into the protobuf scratch buffer. Not directly exploitable for RCE (the destination buffer is also DATA_PAYLOAD_LEN), but leaks adjacent memory into replayed packets and is a latent correctness bug. --- src/modules/StoreForwardModule.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index 6df0e18f0db..6c2efe83fd8 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -206,7 +206,7 @@ void StoreForwardModule::historyAdd(const meshtastic_MeshPacket &mp) this->packetHistory[this->packetHistoryTotalCount].hop_limit = mp.hop_limit; this->packetHistory[this->packetHistoryTotalCount].via_mqtt = mp.via_mqtt; this->packetHistory[this->packetHistoryTotalCount].transport_mechanism = mp.transport_mechanism; - memcpy(this->packetHistory[this->packetHistoryTotalCount].payload, p.payload.bytes, meshtastic_Constants_DATA_PAYLOAD_LEN); + memcpy(this->packetHistory[this->packetHistoryTotalCount].payload, p.payload.bytes, p.payload.size); this->packetHistoryTotalCount++; } From 92c0133ef9b7f26c04b03f0d9332700eeca62288 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 22 Apr 2026 21:47:20 -0500 Subject: [PATCH 053/225] Finish evil merge cleanup (#10253) --- src/main.cpp | 68 +--------------------------------------------------- 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index cee18bb7546..6f78c0b960b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -340,73 +340,7 @@ void setup() #ifdef BLE_LED pinMode(BLE_LED, OUTPUT); -#ifdef BLE_LED_INVERTED - digitalWrite(BLE_LED, HIGH); -#else - digitalWrite(BLE_LED, LOW); -#endif -#endif - -#if defined(T_DECK) - // GPIO10 manages all peripheral power supplies - // Turn on peripheral power immediately after MUC starts. - // If some boards are turned on late, ESP32 will reset due to low voltage. - // ESP32-C3(Keyboard) , MAX98357A(Audio Power Amplifier) , - // TF Card , Display backlight(AW9364DNR) , AN48841B(Trackball) , ES7210(Decoder) - pinMode(KB_POWERON, OUTPUT); - digitalWrite(KB_POWERON, HIGH); - // T-Deck has all three SPI peripherals (TFT, SD, LoRa) attached to the same SPI bus - // We need to initialize all CS pins in advance otherwise there will be SPI communication issues - // e.g. when detecting the SD card - 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); -#elif defined(T_DECK_PRO) - pinMode(LORA_EN, OUTPUT); - digitalWrite(LORA_EN, HIGH); - pinMode(LORA_CS, OUTPUT); - digitalWrite(LORA_CS, HIGH); - pinMode(SDCARD_CS, OUTPUT); - digitalWrite(SDCARD_CS, HIGH); - pinMode(PIN_EINK_CS, OUTPUT); - digitalWrite(PIN_EINK_CS, HIGH); -#if PIN_EINK_RES >= 0 - pinMode(PIN_EINK_RES, OUTPUT); - digitalWrite(PIN_EINK_RES, HIGH); -#endif - pinMode(CST328_PIN_RST, OUTPUT); - digitalWrite(CST328_PIN_RST, HIGH); -#elif defined(T_LORA_PAGER) - pinMode(LORA_CS, OUTPUT); - digitalWrite(LORA_CS, HIGH); - pinMode(SDCARD_CS, OUTPUT); - digitalWrite(SDCARD_CS, HIGH); - pinMode(TFT_CS, OUTPUT); - digitalWrite(TFT_CS, HIGH); - pinMode(KB_INT, INPUT_PULLUP); - // io expander - io.begin(Wire, XL9555_SLAVE_ADDRESS0, SDA, SCL); - io.pinMode(EXPANDS_DRV_EN, OUTPUT); - io.digitalWrite(EXPANDS_DRV_EN, HIGH); - io.pinMode(EXPANDS_AMP_EN, OUTPUT); - io.digitalWrite(EXPANDS_AMP_EN, LOW); - io.pinMode(EXPANDS_LORA_EN, OUTPUT); - io.digitalWrite(EXPANDS_LORA_EN, HIGH); - io.pinMode(EXPANDS_GPS_EN, OUTPUT); - io.digitalWrite(EXPANDS_GPS_EN, HIGH); - io.pinMode(EXPANDS_KB_EN, OUTPUT); - io.digitalWrite(EXPANDS_KB_EN, HIGH); - io.pinMode(EXPANDS_SD_EN, OUTPUT); - io.digitalWrite(EXPANDS_SD_EN, HIGH); - io.pinMode(EXPANDS_GPIO_EN, OUTPUT); - io.digitalWrite(EXPANDS_GPIO_EN, HIGH); - io.pinMode(EXPANDS_SD_PULLEN, INPUT); -#elif defined(HACKADAY_COMMUNICATOR) - pinMode(KB_INT, INPUT); + digitalWrite(BLE_LED, LED_STATE_OFF); #endif concurrency::hasBeenSetup = true; From 7c27f4e2dff201c41413404a64e2b603c016fed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 23 Apr 2026 12:37:05 +0200 Subject: [PATCH 054/225] Revert "Update LovyanGFX to v1.2.20 (#10232)" (#10269) This reverts commit fb1de111d71cbe8e191afd54965151d9ff8c6880. --- variants/esp32/chatter2/platformio.ini | 2 +- variants/esp32/m5stack_core/platformio.ini | 2 +- variants/esp32/wiphone/platformio.ini | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini | 2 +- variants/esp32s3/mesh-tab/platformio.ini | 2 +- variants/esp32s3/picomputer-s3/platformio.ini | 2 +- variants/esp32s3/rak_wismesh_tap_v2/platformio.ini | 2 +- variants/esp32s3/t-deck/platformio.ini | 2 +- variants/esp32s3/t-watch-s3/platformio.ini | 2 +- variants/esp32s3/tlora-pager/platformio.ini | 2 +- variants/esp32s3/tracksenger/platformio.ini | 4 ++-- variants/esp32s3/unphone/platformio.ini | 2 +- variants/native/portduino.ini | 2 +- 16 files changed, 17 insertions(+), 17 deletions(-) diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index bb3824fce08..a14e407a10c 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -12,4 +12,4 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 diff --git a/variants/esp32/m5stack_core/platformio.ini b/variants/esp32/m5stack_core/platformio.ini index bded13c3b6b..8fbbae8956b 100644 --- a/variants/esp32/m5stack_core/platformio.ini +++ b/variants/esp32/m5stack_core/platformio.ini @@ -35,4 +35,4 @@ lib_ignore = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 diff --git a/variants/esp32/wiphone/platformio.ini b/variants/esp32/wiphone/platformio.ini index 8def28b90c8..fbd77be7542 100644 --- a/variants/esp32/wiphone/platformio.ini +++ b/variants/esp32/wiphone/platformio.ini @@ -11,7 +11,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander sparkfun/SX1509 IO Expander@3.0.6 # renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102 diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 5306e9b5d17..5a5004a456a 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -131,6 +131,6 @@ build_flags = lib_deps = ${heltec_v4_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index b096c3a7b27..33643c54168 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -24,4 +24,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini index 2c714be5768..ab6592afb5a 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index c75b78fa596..ebf0118bbed 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -21,4 +21,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 diff --git a/variants/esp32s3/mesh-tab/platformio.ini b/variants/esp32s3/mesh-tab/platformio.ini index 716e94d6238..a153ba9fb3a 100644 --- a/variants/esp32s3/mesh-tab/platformio.ini +++ b/variants/esp32s3/mesh-tab/platformio.ini @@ -55,7 +55,7 @@ lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 [mesh_tab_xpt2046] extends = mesh_tab_base diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index 62228799c32..6f218a126de 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -25,7 +25,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 build_src_filter = ${esp32s3_base.build_src_filter} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index 8f1eb593ade..7847410ae99 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -37,7 +37,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 [env:rak_wismesh_tap_v2-tft] extends = env:rak_wismesh_tap_v2 diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 3e2756612a9..1b3599464e4 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index e2539bd6cf9..35239681887 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -22,7 +22,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index a38493905a8..832f9d7d7f6 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -33,7 +33,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/tracksenger/platformio.ini b/variants/esp32s3/tracksenger/platformio.ini index d862392b00f..c006cf835d7 100644 --- a/variants/esp32s3/tracksenger/platformio.ini +++ b/variants/esp32s3/tracksenger/platformio.ini @@ -22,7 +22,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 [env:tracksenger-lcd] custom_meshtastic_hw_model = 48 @@ -48,7 +48,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 [env:tracksenger-oled] custom_meshtastic_hw_model = 48 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index 8903b4b2b13..3c342e2ac1f 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -37,7 +37,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0 https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 77974c8e52c..87d8431a3e8 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -27,7 +27,7 @@ lib_deps = # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto rweather/Crypto@0.4.0 # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main https://github.com/pine64/libch341-spi-userspace/archive/23c42319a69cffcb65868e3c72e6bed83974a393.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library From 4c24218afbc958a6810c6810a1660319a9a21f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 23 Apr 2026 12:40:27 +0200 Subject: [PATCH 055/225] Revert "Update LovyanGFX to v1.2.20 (#10232)" (#10269) (#10270) This reverts commit fb1de111d71cbe8e191afd54965151d9ff8c6880. --- variants/esp32/chatter2/platformio.ini | 2 +- variants/esp32/m5stack_core/platformio.ini | 2 +- variants/esp32/wiphone/platformio.ini | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini | 2 +- variants/esp32s3/mesh-tab/platformio.ini | 2 +- variants/esp32s3/picomputer-s3/platformio.ini | 2 +- variants/esp32s3/rak_wismesh_tap_v2/platformio.ini | 2 +- variants/esp32s3/t-deck/platformio.ini | 2 +- variants/esp32s3/t-watch-s3/platformio.ini | 2 +- variants/esp32s3/tlora-pager/platformio.ini | 2 +- variants/esp32s3/tracksenger/platformio.ini | 4 ++-- variants/esp32s3/unphone/platformio.ini | 2 +- variants/native/portduino.ini | 2 +- 16 files changed, 17 insertions(+), 17 deletions(-) diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index bb3824fce08..a14e407a10c 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -12,4 +12,4 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 diff --git a/variants/esp32/m5stack_core/platformio.ini b/variants/esp32/m5stack_core/platformio.ini index bded13c3b6b..8fbbae8956b 100644 --- a/variants/esp32/m5stack_core/platformio.ini +++ b/variants/esp32/m5stack_core/platformio.ini @@ -35,4 +35,4 @@ lib_ignore = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 diff --git a/variants/esp32/wiphone/platformio.ini b/variants/esp32/wiphone/platformio.ini index 8def28b90c8..fbd77be7542 100644 --- a/variants/esp32/wiphone/platformio.ini +++ b/variants/esp32/wiphone/platformio.ini @@ -11,7 +11,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander sparkfun/SX1509 IO Expander@3.0.6 # renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102 diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 5306e9b5d17..5a5004a456a 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -131,6 +131,6 @@ build_flags = lib_deps = ${heltec_v4_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index b096c3a7b27..33643c54168 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -24,4 +24,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini index 2c714be5768..ab6592afb5a 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index c75b78fa596..ebf0118bbed 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -21,4 +21,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 diff --git a/variants/esp32s3/mesh-tab/platformio.ini b/variants/esp32s3/mesh-tab/platformio.ini index 716e94d6238..a153ba9fb3a 100644 --- a/variants/esp32s3/mesh-tab/platformio.ini +++ b/variants/esp32s3/mesh-tab/platformio.ini @@ -55,7 +55,7 @@ lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 [mesh_tab_xpt2046] extends = mesh_tab_base diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index 62228799c32..6f218a126de 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -25,7 +25,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 build_src_filter = ${esp32s3_base.build_src_filter} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index 8f1eb593ade..7847410ae99 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -37,7 +37,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 [env:rak_wismesh_tap_v2-tft] extends = env:rak_wismesh_tap_v2 diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 3e2756612a9..1b3599464e4 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index e2539bd6cf9..35239681887 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -22,7 +22,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index a38493905a8..832f9d7d7f6 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -33,7 +33,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/tracksenger/platformio.ini b/variants/esp32s3/tracksenger/platformio.ini index d862392b00f..c006cf835d7 100644 --- a/variants/esp32s3/tracksenger/platformio.ini +++ b/variants/esp32s3/tracksenger/platformio.ini @@ -22,7 +22,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 [env:tracksenger-lcd] custom_meshtastic_hw_model = 48 @@ -48,7 +48,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 [env:tracksenger-oled] custom_meshtastic_hw_model = 48 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index 8903b4b2b13..3c342e2ac1f 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -37,7 +37,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 # TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0 https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 77974c8e52c..87d8431a3e8 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -27,7 +27,7 @@ lib_deps = # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto rweather/Crypto@0.4.0 # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.20 + lovyan03/LovyanGFX@1.2.19 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main https://github.com/pine64/libch341-spi-userspace/archive/23c42319a69cffcb65868e3c72e6bed83974a393.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library From 66971a0a267f29047fc32a6197841e3cf37431c8 Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Thu, 23 Apr 2026 06:16:08 -0500 Subject: [PATCH 056/225] RadioLibInterface: clear static `instance` on destruction to prevent UAF (#10254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constructor sets `RadioLibInterface::instance = this` immediately, before `init()` runs. `initLoRa()` in RadioInterface.cpp creates each radio variant with `new SX1262Interface(...)` or similar, then calls `init()`, and if init fails the `unique_ptr` is reset to nullptr — destroying the object — while the static `instance` pointer continues to point at the freed memory. Main loop then checks `RadioLibInterface::instance != nullptr` and calls `pollMissedIrqs()` or `resetAGC()` on the dangling pointer → Guru Meditation (IllegalInstruction / LoadProhibited). Reported in #9880 on an ESP32-S3 dev board without radio hardware attached, where init always fails and the leftover pointer crashes the device on the next `loop()` iteration. Fix: add a virtual destructor to `RadioLibInterface` that clears the static pointer iff it still references this object. A later successful init() may have replaced `instance` with a different interface — the `instance == this` guard preserves that case. Fixes #9880 Co-authored-by: Ben Meadors --- src/mesh/RadioLibInterface.cpp | 10 ++++++++++ src/mesh/RadioLibInterface.h | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 7ef707e0db4..6024d06b6b8 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -46,6 +46,16 @@ RadioLibInterface::RadioLibInterface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE c #endif } +RadioLibInterface::~RadioLibInterface() +{ + // If the static `instance` pointer still references us, clear it. + // A later successful init() may have replaced `instance` with a newer + // interface — don't clobber that case. + if (instance == this) { + instance = nullptr; + } +} + #ifdef ARCH_ESP32 // ESP32 doesn't use that flag #define YIELD_FROM_ISR(x) portYIELD_FROM_ISR() diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 310ca76bb24..2859558ed81 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -136,6 +136,13 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified RadioLibInterface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, RADIOLIB_PIN_TYPE busy, PhysicalLayer *iface = NULL); + /** + * Clear the static `instance` pointer if it still points at us, so callers + * that check `RadioLibInterface::instance != nullptr` don't dereference a + * freed object after a failed init() + unique_ptr reset. + */ + virtual ~RadioLibInterface(); + virtual ErrorCode send(meshtastic_MeshPacket *p) override; /** From 48747ee43dc63926d09c2c38060da9526ac7a6cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 06:16:23 -0500 Subject: [PATCH 057/225] Upgrade trunk (#10266) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 735c04d3ee1..ec6239207ad 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,7 +9,7 @@ plugins: lint: enabled: - checkov@3.2.524 - - renovate@43.139.2 + - renovate@43.139.6 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 @@ -19,7 +19,7 @@ lint: - ruff@0.15.11 - isort@8.0.1 - markdownlint@0.48.0 - - oxipng@10.1.0 + - oxipng@10.1.1 - svgo@4.0.1 - actionlint@1.7.12 - flake8@7.3.0 From 399dde0f4beecdf645d052789507d44606f64390 Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Thu, 23 Apr 2026 06:19:05 -0500 Subject: [PATCH 058/225] Router: demote cross-channel decrypt failures from ERROR to DEBUG (#10259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Invalid protobufs (bad psk?)" and "Invalid portnum (bad psk?)" messages fire every time a neighbor transmits on a channel whose 8-bit hash matches one of ours but the PSK differs. In RF environments with multiple mesh groups nearby this is routine — a single device can see dozens of these per minute from SAR/MeshCA/private networks sharing a hash collision. LOG_ERROR for a benign "not our PSK" event: - spams the log when you have any neighboring mesh group - makes a genuine PSK misconfiguration on YOUR own channel indistinguishable from the constant cross-channel noise - hides actual errors in the stream LOG_DEBUG matches how similar decryption-failure paths are handled elsewhere (eg. the PKC "decrypt attempted but failed" uses LOG_WARN). Dropping the trailing "!" as well — these are expected events, not exceptional ones. --- src/mesh/Router.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 836cd1a2291..e0473a14e14 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -499,9 +499,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) meshtastic_Data decodedtmp; memset(&decodedtmp, 0, sizeof(decodedtmp)); if (!pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp)) { - LOG_ERROR("Invalid protobufs in received mesh packet id=0x%08x (bad psk?)!", p->id); + LOG_DEBUG("Invalid protobufs in received mesh packet id=0x%08x (bad psk?)", p->id); } else if (decodedtmp.portnum == meshtastic_PortNum_UNKNOWN_APP) { - LOG_ERROR("Invalid portnum (bad psk?)!"); + LOG_DEBUG("Invalid portnum (bad psk?)"); #if !(MESHTASTIC_EXCLUDE_PKI) } else if (!owner.is_licensed && isToUs(p) && decodedtmp.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP) { LOG_WARN("Rejecting legacy DM"); From 55bf8c25fcd8e1abc6ee41d0813984645d9df236 Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Thu, 23 Apr 2026 06:42:05 -0500 Subject: [PATCH 059/225] PhoneAPI: add missing tak_tag case + skip reserved gap in module-config iteration (#10256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PhoneAPI: add missing tak_tag case + skip reserved gap in module-config iteration The STATE_SEND_MODULECONFIG state machine iterates config_state through ModuleConfigType enum values (1..MAX+1 = 16) and switches on meshtastic_ModuleConfig_*_tag values. Two of the resulting tag values land in the default / LOG_ERROR path: 1. `tak_tag` (16) — the meshtastic_ModuleConfig_TAKConfig struct exists in the protobuf and has a `.tak` member in payload_variant, but no PhoneAPI case ever sends it to the phone. As a result, Android clients can't read the persisted TAK (Team Awareness Kit) module config at all. Added case that sends moduleConfig.tak, matching the pattern used for all other module-config tags (paxcounter, traffic_management, etc.). NodeDB already persists the struct via the moduleConfig protobuf save; this just wires the read path to the phone. 2. Tag 14 — reserved gap in the oneof numbering. No payload_variant member exists at tag 14. Without this patch, every phone reconnect walks through config_state=14 and hits `LOG_ERROR("Unknown module config type %d", config_state)`. On an active deployment that's ~1,400 LOG_ERROR lines per day per node — burying real errors. Added explicit `case 14: break;` so the gap is silently skipped. Also: lowered the `default:` log level from LOG_ERROR to LOG_DEBUG. A truly-new unknown type number would indicate firmware lagging the protobuf — annoying but not an error event worth LOG_ERROR, especially since this path runs on every phone handshake. If a new ModuleConfig tag appears, devs will notice via the phone UI missing it, not via log. Observed on a Station G2 fleet: 1403 "Unknown module config type 16" and 1427 "Unknown module config type 14" LOG_ERROR lines in 24 hours from routine phone reconnects. * Get rid of the placeholder --------- Co-authored-by: Ben Meadors --- src/mesh/PhoneAPI.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 714e6110865..cb25efb770f 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -470,8 +470,13 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag; fromRadioScratch.moduleConfig.payload_variant.traffic_management = moduleConfig.traffic_management; break; + case meshtastic_ModuleConfig_tak_tag: + LOG_DEBUG("Send module config: tak"); + fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_tak_tag; + fromRadioScratch.moduleConfig.payload_variant.tak = moduleConfig.tak; + break; default: - LOG_ERROR("Unknown module config type %d", config_state); + LOG_DEBUG("Unhandled module config type %d", config_state); } config_state++; From 22d50fe437a6b10cea6e1bdabfff2b4835ce955a Mon Sep 17 00:00:00 2001 From: Catalin Patulea Date: Thu, 23 Apr 2026 07:18:41 -0700 Subject: [PATCH 060/225] NimbleBluetooth misc cleanups (#10264) * Delete unused clearNVS() (last used in commit 761804b1). * virtual methods: add 'override' to ensure we get the signature right. This is a safety net for pioarduino/NimBLE work where there's multiple similar variants of the same method (eg. onConnect) and it's easy to get the wrong one and accidentally miss a callback. --- src/nimble/NimbleBluetooth.cpp | 33 +++++++++++++-------------------- src/nimble/NimbleBluetooth.h | 3 +-- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 3bb4ce8179d..5a4d150aeb1 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -323,7 +323,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread /** * Subclasses can use this as a hook to provide custom notifications for their transport (i.e. bluetooth notifies) */ - virtual void onNowHasData(uint32_t fromRadioNum) + virtual void onNowHasData(uint32_t fromRadioNum) override { PhoneAPI::onNowHasData(fromRadioNum); @@ -350,7 +350,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread } /// Check the current underlying physical link to see if the client is currently connected - virtual bool checkIsConnected() { return bleServer && bleServer->getConnectedCount() > 0; } + virtual bool checkIsConnected() override { return bleServer && bleServer->getConnectedCount() > 0; } void requestHighThroughputConnection(uint16_t conn_handle) { @@ -412,9 +412,9 @@ static uint8_t lastToRadio[MAX_TO_FROM_RADIO_SIZE]; class NimbleBluetoothToRadioCallback : public NimBLECharacteristicCallbacks { #ifdef NIMBLE_TWO - virtual void onWrite(NimBLECharacteristic *pCharacteristic, NimBLEConnInfo &connInfo) + virtual void onWrite(NimBLECharacteristic *pCharacteristic, NimBLEConnInfo &connInfo) override #else - virtual void onWrite(NimBLECharacteristic *pCharacteristic) + virtual void onWrite(NimBLECharacteristic *pCharacteristic) override #endif { @@ -464,9 +464,9 @@ class NimbleBluetoothToRadioCallback : public NimBLECharacteristicCallbacks class NimbleBluetoothFromRadioCallback : public NimBLECharacteristicCallbacks { #ifdef NIMBLE_TWO - virtual void onRead(NimBLECharacteristic *pCharacteristic, NimBLEConnInfo &connInfo) + virtual void onRead(NimBLECharacteristic *pCharacteristic, NimBLEConnInfo &connInfo) override #else - virtual void onRead(NimBLECharacteristic *pCharacteristic) + virtual void onRead(NimBLECharacteristic *pCharacteristic) override #endif { // CAUTION: This callback runs in the NimBLE task!!! Don't do anything except communicate with the main task's runOnce. @@ -582,9 +582,9 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks private: NimbleBluetooth *ble; - virtual uint32_t onPassKeyDisplay() + virtual uint32_t onPassKeyDisplay() override #else - virtual uint32_t onPassKeyRequest() + virtual uint32_t onPassKeyRequest() override #endif { uint32_t passkey = config.bluetooth.fixed_pin; @@ -635,9 +635,9 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks } #ifdef NIMBLE_TWO - virtual void onAuthenticationComplete(NimBLEConnInfo &connInfo) + virtual void onAuthenticationComplete(NimBLEConnInfo &connInfo) override #else - virtual void onAuthenticationComplete(ble_gap_conn_desc *desc) + virtual void onAuthenticationComplete(ble_gap_conn_desc *desc) override #endif { LOG_INFO("BLE authentication complete"); @@ -655,7 +655,7 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks } #ifdef NIMBLE_TWO - virtual void onConnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo) + virtual void onConnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo) override { LOG_INFO("BLE incoming connection %s", connInfo.getAddress().toString().c_str()); @@ -683,11 +683,11 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks #endif #ifdef NIMBLE_TWO - virtual void onDisconnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo, int reason) + virtual void onDisconnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo, int reason) override { LOG_INFO("BLE disconnect reason: %d", reason); #else - virtual void onDisconnect(NimBLEServer *pServer, ble_gap_conn_desc *desc) + virtual void onDisconnect(NimBLEServer *pServer, ble_gap_conn_desc *desc) override { LOG_INFO("BLE disconnect"); #endif @@ -989,11 +989,4 @@ void NimbleBluetooth::sendLog(const uint8_t *logMessage, size_t length) #endif } -void clearNVS() -{ - NimBLEDevice::deleteAllBonds(); -#ifdef ARCH_ESP32 - ESP.restart(); -#endif -} #endif diff --git a/src/nimble/NimbleBluetooth.h b/src/nimble/NimbleBluetooth.h index 458fa4a674c..a7b14ff7322 100644 --- a/src/nimble/NimbleBluetooth.h +++ b/src/nimble/NimbleBluetooth.h @@ -24,5 +24,4 @@ class NimbleBluetooth : BluetoothApi #endif }; -void setBluetoothEnable(bool enable); -void clearNVS(); \ No newline at end of file +void setBluetoothEnable(bool enable); \ No newline at end of file From 031f332ec161e0d852216c71896e8bdd41fa1908 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 23 Apr 2026 13:16:49 -0500 Subject: [PATCH 061/225] We have HardwareRNG, let's use it! (#10274) --- src/platform/nrf52/NRF52Bluetooth.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 307e35b0c25..52e45ccccc9 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -1,6 +1,7 @@ #include "NRF52Bluetooth.h" #include "BLEDfuSecure.h" #include "BluetoothCommon.h" +#include "HardwareRNG.h" #include "PowerFSM.h" #include "configuration.h" #include "main.h" @@ -272,9 +273,13 @@ void NRF52Bluetooth::setup() Bluefruit.setTxPower(NRF52_BLE_TX_POWER); #endif if (config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { - configuredPasskey = config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN - ? config.bluetooth.fixed_pin - : random(100000, 999999); + if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN) { + configuredPasskey = config.bluetooth.fixed_pin; + } else { + uint32_t hwrand = 0; + HardwareRNG::fill(reinterpret_cast(&hwrand), sizeof(hwrand)); + configuredPasskey = hwrand % 900000u + 100000u; + } auto pinString = std::to_string(configuredPasskey); LOG_INFO("Bluetooth pin set to '%i'", configuredPasskey); Bluefruit.Security.setPIN(pinString.c_str()); From 2ed7bba5e7f5516586f638957fb09d9bf6186311 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Fri, 24 Apr 2026 02:24:05 +0800 Subject: [PATCH 062/225] fix(Power): refactor EXT_CHRG_DETECT to compile-time macros (#10191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the EXT_PWR_DETECT pattern: replace runtime static variables (ext_chrg_detect_mode, ext_chrg_detect_value) with compile-time macros. Auto-infer EXT_CHRG_DETECT_VALUE from EXT_CHRG_DETECT_MODE when the mode is INPUT_PULLUP (→ LOW) or INPUT_PULLDOWN (→ HIGH); default to HIGH. This fixes inverted polarity on variants that define EXT_CHRG_DETECT_MODE INPUT_PULLUP without an explicit EXT_CHRG_DETECT_VALUE (e.g. russell): previously the runtime default of HIGH caused isCharging() to return the opposite of the correct value. With auto-inference the correct LOW active level is now derived at compile time. Remove the now-redundant EXT_CHRG_DETECT_VALUE HIGH from ELECROW-ThinkNode-M4 variant.h since HIGH is the inferred default. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Andrew Yong Co-authored-by: Jonathan Bennett --- src/Power.cpp | 25 ++++++++++--------- .../nrf52840/ELECROW-ThinkNode-M4/variant.h | 1 - 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 934e09d6edf..0478420e1a0 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -96,14 +96,15 @@ static const adc_atten_t atten = ADC_ATTENUATION; #ifdef EXT_CHRG_DETECT #ifndef EXT_CHRG_DETECT_MODE -static const uint8_t ext_chrg_detect_mode = INPUT; -#else -static const uint8_t ext_chrg_detect_mode = EXT_CHRG_DETECT_MODE; +#define EXT_CHRG_DETECT_MODE INPUT +// If using internal pull resistors, we can infer EXT_CHRG_DETECT_VALUE +#elif EXT_CHRG_DETECT_MODE == INPUT_PULLUP +#define EXT_CHRG_DETECT_VALUE LOW +#elif EXT_CHRG_DETECT_MODE == INPUT_PULLDOWN +#define EXT_CHRG_DETECT_VALUE HIGH #endif #ifndef EXT_CHRG_DETECT_VALUE -static const uint8_t ext_chrg_detect_value = HIGH; -#else -static const uint8_t ext_chrg_detect_value = EXT_CHRG_DETECT_VALUE; +#define EXT_CHRG_DETECT_VALUE HIGH #endif #endif @@ -511,9 +512,9 @@ class AnalogBatteryLevel : public HasBatteryLevel } #endif #if defined(ELECROW_ThinkNode_M6) - return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value || isVbusIn(); + return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn(); #elif EXT_CHRG_DETECT - return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value; + return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE; #elif defined(BATTERY_CHARGING_INV) return !digitalRead(BATTERY_CHARGING_INV); #else @@ -653,7 +654,7 @@ bool Power::analogInit() #endif #endif #ifdef EXT_CHRG_DETECT - pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode); + pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE); #endif #ifdef BATTERY_PIN @@ -1875,7 +1876,7 @@ class SerialBatteryLevel : public HasBatteryLevel { #if defined(EXT_CHRG_DETECT) - return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value; + return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE; #endif return false; @@ -1884,7 +1885,7 @@ class SerialBatteryLevel : public HasBatteryLevel virtual bool isCharging() override { #ifdef EXT_CHRG_DETECT - return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value; + return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE; #endif // by default, we check the battery voltage only @@ -1909,7 +1910,7 @@ bool Power::serialBatteryInit() pinMode(EXT_PWR_DETECT, INPUT); #endif #ifdef EXT_CHRG_DETECT - pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode); + pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE); #endif bool result = serialBatteryLevel.runOnce(); diff --git a/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h b/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h index 2cfe948e3c4..2164bcedc74 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h +++ b/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h @@ -85,7 +85,6 @@ static const uint8_t A0 = PIN_A0; // charger status #define EXT_CHRG_DETECT (32 + 6) -#define EXT_CHRG_DETECT_VALUE HIGH // SPI #define SPI_INTERFACES_COUNT 1 From 2cc13a1132d94b66a9505e7f07ee2d3e83bd0c95 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 23 Apr 2026 14:19:33 -0500 Subject: [PATCH 063/225] Sane sanitization --- platformio.ini | 1 + src/mesh/TypeConversions.cpp | 5 + src/meshUtils.cpp | 89 ++++++++++++++++ src/meshUtils.h | 4 + src/modules/AdminModule.cpp | 8 ++ test/test_utf8/test_main.cpp | 195 +++++++++++++++++++++++++++++++++++ 6 files changed, 302 insertions(+) create mode 100644 test/test_utf8/test_main.cpp diff --git a/platformio.ini b/platformio.ini index cd22fab6e81..a97b813fa59 100644 --- a/platformio.ini +++ b/platformio.ini @@ -29,6 +29,7 @@ build_flags = -Wno-missing-field-initializers -DUSE_THREAD_NAMES -DTINYGPS_OPTION_NO_CUSTOM_FIELDS -DPB_ENABLE_MALLOC=1 + -DPB_VALIDATE_UTF8=1 -DRADIOLIB_EXCLUDE_CC1101=1 -DRADIOLIB_EXCLUDE_NRF24=1 -DRADIOLIB_EXCLUDE_RF69=1 diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index 201a703e210..3798daf28a4 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -1,6 +1,7 @@ #include "TypeConversions.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" #include "mesh/generated/meshtastic/mesh.pb.h" +#include "meshUtils.h" meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite) { @@ -82,8 +83,10 @@ meshtastic_UserLite TypeConversions::ConvertToUserLite(meshtastic_User user) strncpy(lite.long_name, user.long_name, sizeof(lite.long_name)); lite.long_name[sizeof(lite.long_name) - 1] = '\0'; + sanitizeUtf8(lite.long_name, sizeof(lite.long_name)); strncpy(lite.short_name, user.short_name, sizeof(lite.short_name)); lite.short_name[sizeof(lite.short_name) - 1] = '\0'; + sanitizeUtf8(lite.short_name, sizeof(lite.short_name)); lite.hw_model = user.hw_model; lite.role = user.role; lite.is_licensed = user.is_licensed; @@ -102,8 +105,10 @@ meshtastic_User TypeConversions::ConvertToUser(uint32_t nodeNum, meshtastic_User snprintf(user.id, sizeof(user.id), "!%08x", nodeNum); strncpy(user.long_name, lite.long_name, sizeof(user.long_name)); user.long_name[sizeof(user.long_name) - 1] = '\0'; + sanitizeUtf8(user.long_name, sizeof(user.long_name)); strncpy(user.short_name, lite.short_name, sizeof(user.short_name)); user.short_name[sizeof(user.short_name) - 1] = '\0'; + sanitizeUtf8(user.short_name, sizeof(user.short_name)); user.hw_model = lite.hw_model; user.role = lite.role; user.is_licensed = lite.is_licensed; diff --git a/src/meshUtils.cpp b/src/meshUtils.cpp index 1a4497101aa..89c5488873b 100644 --- a/src/meshUtils.cpp +++ b/src/meshUtils.cpp @@ -117,4 +117,93 @@ size_t pb_string_length(const char *str, size_t max_len) } } return len; +} + +bool sanitizeUtf8(char *buf, size_t bufSize) +{ + if (!buf || bufSize == 0) + return false; + + // Ensure null-terminated within buffer + buf[bufSize - 1] = '\0'; + + bool replaced = false; + size_t i = 0; + size_t len = strlen(buf); + + while (i < len) { + uint8_t b = (uint8_t)buf[i]; + + // Determine expected sequence length from lead byte + size_t seqLen; + uint32_t minCodepoint; + if (b <= 0x7F) { + // ASCII — valid single byte + i++; + continue; + } else if ((b & 0xE0) == 0xC0) { + seqLen = 2; + minCodepoint = 0x80; // Reject overlong + } else if ((b & 0xF0) == 0xE0) { + seqLen = 3; + minCodepoint = 0x800; + } else if ((b & 0xF8) == 0xF0) { + seqLen = 4; + minCodepoint = 0x10000; + } else { + // Invalid lead byte (0x80-0xBF or 0xF8+) + buf[i] = '?'; + replaced = true; + i++; + continue; + } + + // Check that we have enough bytes remaining + if (i + seqLen > len) { + // Truncated sequence at end of string — replace remaining bytes + for (size_t j = i; j < len; j++) { + buf[j] = '?'; + } + replaced = true; + break; + } + + // Validate continuation bytes (must be 10xxxxxx) + bool valid = true; + for (size_t j = 1; j < seqLen; j++) { + if (((uint8_t)buf[i + j] & 0xC0) != 0x80) { + valid = false; + break; + } + } + + if (valid) { + // Decode codepoint to check for overlong encodings and surrogates + uint32_t cp = 0; + if (seqLen == 2) + cp = b & 0x1F; + else if (seqLen == 3) + cp = b & 0x0F; + else + cp = b & 0x07; + for (size_t j = 1; j < seqLen; j++) + cp = (cp << 6) | ((uint8_t)buf[i + j] & 0x3F); + + if (cp < minCodepoint || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) { + // Overlong encoding, out of Unicode range, or surrogate half + valid = false; + } + } + + if (valid) { + i += seqLen; + } else { + // Replace only the lead byte; continuation bytes will be caught on next iteration + buf[i] = '?'; + replaced = true; + i++; + } + } + + return replaced; } \ No newline at end of file diff --git a/src/meshUtils.h b/src/meshUtils.h index da3a4593bdf..6a15229fb79 100644 --- a/src/meshUtils.h +++ b/src/meshUtils.h @@ -38,6 +38,10 @@ const std::string vformat(const char *const zcFormat, ...); // Get actual string length for nanopb char array fields. size_t pb_string_length(const char *str, size_t max_len); +// Sanitize a fixed-size char buffer in-place by replacing invalid UTF-8 sequences with '?'. +// Ensures the result is null-terminated within bufSize. Returns true if any bytes were replaced. +bool sanitizeUtf8(char *buf, size_t bufSize); + /// Calculate 2^n without calling pow() - used for spreading factor and other calculations inline uint32_t pow_of_2(uint32_t n) { diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 8a1843bcb2f..468e8d91e96 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -599,10 +599,14 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) if (*o.long_name) { changed |= strcmp(owner.long_name, o.long_name); strncpy(owner.long_name, o.long_name, sizeof(owner.long_name)); + owner.long_name[sizeof(owner.long_name) - 1] = '\0'; + sanitizeUtf8(owner.long_name, sizeof(owner.long_name)); } if (*o.short_name) { changed |= strcmp(owner.short_name, o.short_name); strncpy(owner.short_name, o.short_name, sizeof(owner.short_name)); + owner.short_name[sizeof(owner.short_name) - 1] = '\0'; + sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); } snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum()); @@ -1400,7 +1404,11 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) // Set call sign and override lora limitations for licensed use strncpy(owner.long_name, p.call_sign, sizeof(owner.long_name)); + owner.long_name[sizeof(owner.long_name) - 1] = '\0'; + sanitizeUtf8(owner.long_name, sizeof(owner.long_name)); strncpy(owner.short_name, p.short_name, sizeof(owner.short_name)); + owner.short_name[sizeof(owner.short_name) - 1] = '\0'; + sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); owner.is_licensed = true; config.lora.override_duty_cycle = true; config.lora.tx_power = p.tx_power; diff --git a/test/test_utf8/test_main.cpp b/test/test_utf8/test_main.cpp new file mode 100644 index 00000000000..7ac64653d22 --- /dev/null +++ b/test/test_utf8/test_main.cpp @@ -0,0 +1,195 @@ +#include "meshUtils.h" +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +// --- Valid UTF-8 should pass through unchanged --- + +void test_ascii_unchanged() +{ + char buf[32] = "Hello World"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("Hello World", buf); +} + +void test_valid_2byte_unchanged() +{ + // "café" — é is C3 A9 + char buf[16] = "caf\xC3\xA9"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("caf\xC3\xA9", buf); +} + +void test_valid_3byte_unchanged() +{ + // "€" is E2 82 AC + char buf[16] = "\xE2\x82\xAC"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("\xE2\x82\xAC", buf); +} + +void test_valid_4byte_emoji_unchanged() +{ + // 🌙 is F0 9F 8C 99 + char buf[16] = "\xF0\x9F\x8C\x99"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("\xF0\x9F\x8C\x99", buf); +} + +void test_valid_mixed_unchanged() +{ + // "Hi 🌙!" — mix of ASCII and 4-byte + char buf[16] = "Hi \xF0\x9F\x8C\x99!"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("Hi \xF0\x9F\x8C\x99!", buf); +} + +void test_empty_string() +{ + char buf[8] = ""; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("", buf); +} + +// --- Invalid sequences observed in the wild --- + +void test_truncated_4byte_at_end() +{ + // Name with valid emoji 🌙 followed by a truncated 4-byte sequence + ASCII + char buf[32] = "Lunar Tower \xF0\x9F\x8C\x99\xF0\x9F\x97" + "4"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + // The 🌙 should be preserved; F0 9F 97 is an incomplete 4-byte sequence, + // '4' (0x34) is not a valid continuation byte + TEST_ASSERT_EQUAL_STRING("Lunar Tower \xF0\x9F\x8C\x99???4", buf); +} + +void test_lone_lead_bytes_without_continuations() +{ + // Mixed ASCII with stray multibyte lead bytes (E1, F3) lacking proper continuations + char buf[32] = "Mesht\xE1\xF3tic 37e2"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + // E1 expects 2 continuation bytes, but F3 is not a continuation → E1 replaced + // F3 expects 3 continuation bytes, 't','i','c' are not continuations → F3 replaced + TEST_ASSERT_EQUAL_STRING("Mesht??tic 37e2", buf); +} + +// --- Edge cases --- + +void test_bare_continuation_byte() +{ + // 0x80 alone is invalid (continuation byte with no lead) + char buf[8] = "\x80"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("?", buf); +} + +void test_overlong_2byte() +{ + // C0 AF is an overlong encoding of U+002F '/' + char buf[8] = "\xC0\xAF"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + // C0 is a 2-byte lead, AF is valid continuation, but codepoint 0x2F < 0x80 → overlong + // C0 replaced, AF (now bare continuation) also replaced + TEST_ASSERT_EQUAL_STRING("??", buf); +} + +void test_surrogate_half() +{ + // ED A0 80 encodes U+D800 (surrogate half — invalid in UTF-8) + char buf[8] = "\xED\xA0\x80"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("???", buf); +} + +void test_5byte_sequence_rejected() +{ + // F8 80 80 80 80 — 5-byte sequence, not valid UTF-8 + char buf[8] = "\xF8\x80\x80\x80\x80"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + // F8 is invalid lead (>= 0xF8), each 0x80 is bare continuation + TEST_ASSERT_EQUAL_STRING("?????", buf); +} + +void test_truncated_3byte_at_buffer_end() +{ + // Buffer is exactly 4 bytes: E2 82 then forced null at [3] + char buf[4]; + buf[0] = '\xE2'; + buf[1] = '\x82'; + buf[2] = '\0'; // String ends before the 3-byte sequence completes + buf[3] = '\0'; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("??", buf); +} + +void test_null_termination_enforced() +{ + // Fill buffer completely with no null terminator + char buf[5]; + memset(buf, 'A', sizeof(buf)); + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + // Should be null-terminated and content preserved (all ASCII) + TEST_ASSERT_EQUAL_STRING("AAAA", buf); +} + +void test_null_buffer() +{ + TEST_ASSERT_FALSE(sanitizeUtf8(nullptr, 10)); +} + +void test_zero_size() +{ + char buf[4] = "Hi"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, 0)); + // Buffer should be untouched + TEST_ASSERT_EQUAL_STRING("Hi", buf); +} + +void test_valid_max_codepoint() +{ + // U+10FFFF = F4 8F BF BF (maximum valid Unicode codepoint) + char buf[8] = "\xF4\x8F\xBF\xBF"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("\xF4\x8F\xBF\xBF", buf); +} + +void test_above_max_codepoint() +{ + // U+110000 = F4 90 80 80 (just above maximum valid Unicode) + char buf[8] = "\xF4\x90\x80\x80"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); +} + +int main(int argc, char **argv) +{ + UNITY_BEGIN(); + + // Valid UTF-8 passthrough + RUN_TEST(test_ascii_unchanged); + RUN_TEST(test_valid_2byte_unchanged); + RUN_TEST(test_valid_3byte_unchanged); + RUN_TEST(test_valid_4byte_emoji_unchanged); + RUN_TEST(test_valid_mixed_unchanged); + RUN_TEST(test_empty_string); + + // Invalid sequences observed in the wild + RUN_TEST(test_truncated_4byte_at_end); + RUN_TEST(test_lone_lead_bytes_without_continuations); + + // Edge cases + RUN_TEST(test_bare_continuation_byte); + RUN_TEST(test_overlong_2byte); + RUN_TEST(test_surrogate_half); + RUN_TEST(test_5byte_sequence_rejected); + RUN_TEST(test_truncated_3byte_at_buffer_end); + RUN_TEST(test_null_termination_enforced); + RUN_TEST(test_null_buffer); + RUN_TEST(test_zero_size); + RUN_TEST(test_valid_max_codepoint); + RUN_TEST(test_above_max_codepoint); + + return UNITY_END(); +} From b2d980fc255f151f05e6262ea8c851a226a187e2 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Fri, 24 Apr 2026 03:32:17 +0800 Subject: [PATCH 064/225] feat(Power): support EXT_PWR_DETECT_MODE & EXT_PWR_DETECT_VALUE, simplify EXT_PWR_DETECT (#10140) Assisted-by: Claude Sonnet 4.6 Signed-off-by: Andrew Yong --- src/Power.cpp | 46 +++++++++---------- .../heltec_capsule_sensor_v3/variant.h | 1 + variants/esp32s3/heltec_sensor_hub/variant.h | 1 + 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 0478420e1a0..97bacafd296 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -94,6 +94,20 @@ static const adc_atten_t atten = ADC_ATTENUATION; #endif #endif // BATTERY_PIN && ARCH_ESP32 +#ifdef EXT_PWR_DETECT +#ifndef EXT_PWR_DETECT_MODE +#define EXT_PWR_DETECT_MODE INPUT +// If using internal pull resistors, we can infer EXT_PWR_DETECT_VALUE +#elif EXT_PWR_DETECT_MODE == INPUT_PULLUP +#define EXT_PWR_DETECT_VALUE LOW +#elif EXT_PWR_DETECT_MODE == INPUT_PULLDOWN +#define EXT_PWR_DETECT_VALUE HIGH +#endif +#ifndef EXT_PWR_DETECT_VALUE +#define EXT_PWR_DETECT_VALUE HIGH +#endif +#endif + #ifdef EXT_CHRG_DETECT #ifndef EXT_CHRG_DETECT_MODE #define EXT_CHRG_DETECT_MODE INPUT @@ -470,28 +484,14 @@ class AnalogBatteryLevel : public HasBatteryLevel virtual bool isBatteryConnect() override { return getBatteryPercent() != -1; } #endif - /// If we see a battery voltage higher than physics allows - assume charger is - /// pumping in power On some boards we don't have the power management chip - /// (like AXPxxxx) so we use EXT_PWR_DETECT GPIO pin to detect external power - /// source + // Detect if an external power source is connected if we don’t have a PMIC; + // Firstly prefer EXT_PWR_DETECT GPIO if available, + // secondly try an nRF52-specific routine on some variants, + // lastly provide a fallback to indicate external power when fully charged. virtual bool isVbusIn() override { #ifdef EXT_PWR_DETECT -#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB) - // if external powered that pin will be pulled down - if (digitalRead(EXT_PWR_DETECT) == LOW) { - return true; - } - // if it's not LOW - check the battery -#else - // if external powered that pin will be pulled up - if (digitalRead(EXT_PWR_DETECT) == HIGH) { - return true; - } - // if it's not HIGH - check the battery -#endif - // If we have an EXT_PWR_DETECT pin and it indicates no external power, believe it. - return false; + return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE; // technically speaking this should work for all(?) NRF52 boards // but needs testing across multiple devices. NRF52 USB would not even work if @@ -647,11 +647,7 @@ Power::Power() : OSThread("Power") bool Power::analogInit() { #ifdef EXT_PWR_DETECT -#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB) - pinMode(EXT_PWR_DETECT, INPUT_PULLUP); -#else - pinMode(EXT_PWR_DETECT, INPUT); -#endif + pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE); #endif #ifdef EXT_CHRG_DETECT pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE); @@ -1907,7 +1903,7 @@ SerialBatteryLevel serialBatteryLevel; bool Power::serialBatteryInit() { #ifdef EXT_PWR_DETECT - pinMode(EXT_PWR_DETECT, INPUT); + pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE); #endif #ifdef EXT_CHRG_DETECT pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE); diff --git a/variants/esp32s3/heltec_capsule_sensor_v3/variant.h b/variants/esp32s3/heltec_capsule_sensor_v3/variant.h index 3ee5545a8cf..f689b20a8a1 100644 --- a/variants/esp32s3/heltec_capsule_sensor_v3/variant.h +++ b/variants/esp32s3/heltec_capsule_sensor_v3/variant.h @@ -1,6 +1,7 @@ #define LED_POWER 33 #define LED_POWER2 34 #define EXT_PWR_DETECT 35 +#define EXT_PWR_DETECT_MODE INPUT_PULLUP #define BUTTON_PIN 18 #define BUTTON_ACTIVE_LOW false diff --git a/variants/esp32s3/heltec_sensor_hub/variant.h b/variants/esp32s3/heltec_sensor_hub/variant.h index 8c5d31c9aa7..64255c038da 100644 --- a/variants/esp32s3/heltec_sensor_hub/variant.h +++ b/variants/esp32s3/heltec_sensor_hub/variant.h @@ -1,4 +1,5 @@ #define EXT_PWR_DETECT 20 +#define EXT_PWR_DETECT_MODE INPUT_PULLUP #define BUTTON_PIN 17 #define BUTTON_ACTIVE_LOW false From 56c897e8268eaa391aeeb8c67d584993180aca3c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 23 Apr 2026 14:42:29 -0500 Subject: [PATCH 065/225] Can't LOG when we don't have logging set up yet in the native test suite Co-authored-by: Copilot --- src/mesh/HardwareRNG.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index b455128ac1f..58a17d795a1 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -48,7 +48,9 @@ bool mixWithLoRaEntropy(uint8_t *buffer, size_t length) // and return false so callers know no extra mixing occurred. RadioLibInterface *radio = RadioLibInterface::instance; if (!radio) { +#ifndef PIO_UNIT_TESTING LOG_ERROR("No radio instance available to provide entropy"); +#endif return false; } From 7b3f58875a9b49ee72fdc3bb545c424db3da2632 Mon Sep 17 00:00:00 2001 From: "Valentin V. Bartenev" Date: Thu, 23 Apr 2026 22:44:39 +0300 Subject: [PATCH 066/225] Fix example comment in airtime.h (#10275) Looks like a copy'n'paste typo from the previous line. It definitely meant to be RX_ALL_LOG according to comment. --- src/airtime.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/airtime.h b/src/airtime.h index 3ed7b6d7c4b..8e3e6c5578d 100644 --- a/src/airtime.h +++ b/src/airtime.h @@ -19,8 +19,8 @@ TX_LOG + RX_LOG = Total air time for a particular meshtastic channel. - TX_LOG + RX_LOG = Total air time for a particular meshtastic channel, including - other lora radios. + TX_LOG + RX_ALL_LOG = Total air time for a particular meshtastic channel, including + other lora radios. RX_ALL_LOG - RX_LOG = Other lora radios on our frequency channel. */ From 837637b70c9dbd6145aeea28a77ec977cc93387c Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 23 Apr 2026 15:37:35 -0500 Subject: [PATCH 067/225] Only enable wakeup via EXT_CHRG_DETECT if we shut down due to low power (#10263) --- src/power.h | 1 + variants/nrf52840/ELECROW-ThinkNode-M6/variant.cpp | 11 ++++++++--- variants/nrf52840/ELECROW-ThinkNode-M6/variant.h | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/power.h b/src/power.h index dfc46d6793c..4b5ef609daa 100644 --- a/src/power.h +++ b/src/power.h @@ -100,6 +100,7 @@ class Power : public concurrency::OSThread virtual int32_t runOnce() override; void setStatusHandler(meshtastic::PowerStatus *handler) { statusHandler = handler; } const uint16_t OCV[11] = {OCV_ARRAY}; + bool isLowBattery() { return low_voltage_counter >= 10; }; #ifdef ARCH_ESP32 int beforeLightSleep(void *unused); diff --git a/variants/nrf52840/ELECROW-ThinkNode-M6/variant.cpp b/variants/nrf52840/ELECROW-ThinkNode-M6/variant.cpp index a43755c063e..f15a03f4d5c 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M6/variant.cpp +++ b/variants/nrf52840/ELECROW-ThinkNode-M6/variant.cpp @@ -20,6 +20,7 @@ #include "variant.h" #include "nrf.h" +#include "power.h" #include "wiring_constants.h" #include "wiring_digital.h" @@ -65,7 +66,11 @@ void variant_shutdown() nrf_gpio_pin_sense_t sense1 = NRF_GPIO_PIN_SENSE_LOW; nrf_gpio_cfg_sense_set(PIN_BUTTON1, sense1); - nrf_gpio_cfg_input(EXT_CHRG_DETECT, NRF_GPIO_PIN_PULLUP); // Configure the pin to be woken up as an input - nrf_gpio_pin_sense_t sense2 = NRF_GPIO_PIN_SENSE_LOW; - nrf_gpio_cfg_sense_set(EXT_CHRG_DETECT, sense2); + // If we are sleeping because of low battery, wake up when the solar charger detects power. + // But if the user intentionally put us to sleep with the button, don't wake up just because the lights are on + if (power->isLowBattery()) { + nrf_gpio_cfg_input(EXT_CHRG_DETECT, NRF_GPIO_PIN_PULLUP); // Configure the pin to be woken up as an input + nrf_gpio_pin_sense_t sense2 = NRF_GPIO_PIN_SENSE_LOW; + nrf_gpio_cfg_sense_set(EXT_CHRG_DETECT, sense2); + } } diff --git a/variants/nrf52840/ELECROW-ThinkNode-M6/variant.h b/variants/nrf52840/ELECROW-ThinkNode-M6/variant.h index 2ebb7903140..48b27c669c6 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M6/variant.h +++ b/variants/nrf52840/ELECROW-ThinkNode-M6/variant.h @@ -138,7 +138,7 @@ static const uint8_t A0 = PIN_A0; #define HAS_SOLAR -#define OCV_ARRAY 4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3450 +#define OCV_ARRAY 4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3490 #ifdef __cplusplus } From 83a98c81f642dbcbf025ca86e436501133e51e8b Mon Sep 17 00:00:00 2001 From: Colby Dillion Date: Thu, 23 Apr 2026 18:04:34 -0500 Subject: [PATCH 068/225] Hash table index for O(1) packet history lookups (#9499) * Use hash table for O(1) lookup of recently seen packets * Eliminate a packet lookup during deduplication * Infinite loop checks for find and remove * Consolidate conditional compilation * Exclude hash table from minimal build * Additional comment on hash table capacity * Unit tests for packet history changes * Update incorrect comment about size clamp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Const --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ben Meadors --- src/configuration.h | 1 + src/mesh/NextHopRouter.cpp | 7 +- src/mesh/PacketHistory.cpp | 182 +++++- src/mesh/PacketHistory.h | 26 + src/meshUtils.h | 18 + test/test_packet_history/test_main.cpp | 834 +++++++++++++++++++++++++ 6 files changed, 1053 insertions(+), 15 deletions(-) create mode 100644 test/test_packet_history/test_main.cpp diff --git a/src/configuration.h b/src/configuration.h index 84dabee4e83..efd9ddcf76d 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -499,6 +499,7 @@ along with this program. If not, see . #define MESHTASTIC_EXCLUDE_PKI 1 #define MESHTASTIC_EXCLUDE_POWER_FSM 1 #define MESHTASTIC_EXCLUDE_TZ 1 +#define MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH 1 #endif // Turn off all optional modules diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 13f948a7b7b..e8613d45729 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -101,9 +101,12 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast 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 - bool wasAlreadyRelayer = wasRelayer(p->relay_node, p->decoded.request_id, p->to); + // Single lookup for both relayer checks on the same (request_id, to) pair + bool wasAlreadyRelayer = false; bool weWereSoleRelayer = false; - bool weWereRelayer = wasRelayer(ourRelayID, p->decoded.request_id, p->to, &weWereSoleRelayer); + 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, diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index 845a936d4cd..8289f0078f8 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -1,6 +1,7 @@ #include "PacketHistory.h" #include "configuration.h" #include "mesh-pb-constants.h" +#include "meshUtils.h" #ifdef ARCH_PORTDUINO #include "platform/portduino/PortduinoGlue.h" @@ -23,6 +24,14 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPa size = PACKETHISTORY_MAX; // Use default size if invalid } +#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH + // Ensure capacity fits in uint16_t hash index (HASH_EMPTY = 0xFFFF is the sentinel) + if (size >= HASH_EMPTY) { + LOG_WARN("Packet History - Clamping size %d to %d (hash index limit)", size, HASH_EMPTY - 1); + size = HASH_EMPTY - 1; + } +#endif + // Allocate memory for the recent packets array recentPacketsCapacity = size; recentPackets = new PacketRecord[recentPacketsCapacity]; @@ -35,6 +44,20 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPa // Initialize the recent packets array to zero memset(recentPackets, 0, sizeof(PacketRecord) * recentPacketsCapacity); + +#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH + // Allocate hash index with load factor <= 0.5 for short probe chains + hashCapacity = nextPowerOf2(recentPacketsCapacity * 2); + hashMask = hashCapacity - 1; + hashIndex = new uint16_t[hashCapacity]; + if (!hashIndex) { + LOG_ERROR("Packet History - Hash index allocation failed for %d entries", hashCapacity); + hashCapacity = 0; + hashMask = 0; + return; + } + memset(hashIndex, 0xFF, sizeof(uint16_t) * hashCapacity); // Fill with HASH_EMPTY (0xFFFF) +#endif } PacketHistory::~PacketHistory() @@ -42,6 +65,12 @@ PacketHistory::~PacketHistory() recentPacketsCapacity = 0; delete[] recentPackets; recentPackets = NULL; +#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH + delete[] hashIndex; + hashIndex = NULL; + hashCapacity = 0; + hashMask = 0; +#endif } /** Update recentPackets and return true if we have already seen this packet */ @@ -194,7 +223,78 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd return seenRecently; } -/** Find a packet record in history. +#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH +// Hash function for (sender, id) pairs. Uses xor-shift mixing for good distribution. +uint32_t PacketHistory::hashSlot(NodeNum sender, PacketId id) const +{ + uint32_t h = sender ^ (id * 0x9E3779B9); // Fibonacci hashing constant + h ^= h >> 16; + h *= 0x45d9f3b; + h ^= h >> 16; + return h & hashMask; +} + +void PacketHistory::hashInsert(NodeNum sender, PacketId id, uint16_t slotIdx) +{ + if (!hashIndex) + return; + uint32_t bucket = hashSlot(sender, id); + // Guard against infinite loop if hash table is corrupted (no HASH_EMPTY slots) + for (uint32_t i = 0; i < hashCapacity; i++) { + if (hashIndex[bucket] == HASH_EMPTY) { + hashIndex[bucket] = slotIdx; + return; + } + bucket = (bucket + 1) & hashMask; + } + LOG_ERROR("Packet History - hashInsert: table full or corrupted, rebuilding"); + hashRebuild(); +} + +void PacketHistory::hashRemove(NodeNum sender, PacketId id) +{ + if (!hashIndex) + return; + uint32_t bucket = hashSlot(sender, id); + for (uint32_t i = 0; i < hashCapacity; i++) { + if (hashIndex[bucket] == HASH_EMPTY) + 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 + hashIndex[bucket] = HASH_EMPTY; + uint32_t next = (bucket + 1) & hashMask; + for (uint32_t j = 0; j < hashCapacity; j++) { + if (hashIndex[next] == HASH_EMPTY) + break; + uint16_t displaced = hashIndex[next]; + hashIndex[next] = HASH_EMPTY; + if (displaced < recentPacketsCapacity) { + const auto &rec = recentPackets[displaced]; + hashInsert(rec.sender, rec.id, displaced); + } + next = (next + 1) & hashMask; + } + return; + } + bucket = (bucket + 1) & hashMask; + } +} + +void PacketHistory::hashRebuild() +{ + if (!hashIndex) + return; + memset(hashIndex, 0xFF, sizeof(uint16_t) * hashCapacity); + for (uint32_t i = 0; i < recentPacketsCapacity; i++) { + if (recentPackets[i].rxTimeMsec != 0) + hashInsert(recentPackets[i].sender, recentPackets[i].id, (uint16_t)i); + } +} +#endif + +/** Find a packet record in history using the hash index for O(1) average lookup. + * Falls back to linear scan if hash index is unavailable. * @return pointer to PacketRecord if found, NULL if not found */ PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id) { @@ -205,23 +305,40 @@ PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id) return NULL; } - PacketRecord *it = NULL; - for (it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) { - if (it->id == id && it->sender == sender) { +#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH + // Use hash index for O(1) lookup when available + if (hashIndex) { + uint32_t bucket = hashSlot(sender, id); + for (uint32_t i = 0; i < hashCapacity; i++) { + if (hashIndex[bucket] == HASH_EMPTY) + break; + uint16_t idx = hashIndex[bucket]; + if (idx < recentPacketsCapacity && recentPackets[idx].id == id && recentPackets[idx].sender == sender) { #if VERBOSE_PACKET_HISTORY - LOG_DEBUG("Packet History - find: s=%08x id=%08x FOUND nh=%02x rby=%02x %02x %02x age=%d slot=%d/%d", it->sender, - it->id, it->next_hop, it->relayed_by[0], it->relayed_by[1], it->relayed_by[2], millis() - (it->rxTimeMsec), - it - recentPackets, recentPacketsCapacity); + LOG_DEBUG("Packet History - find: s=%08x id=%08x FOUND nh=%02x rby=%02x %02x %02x age=%d slot=%d/%d", + recentPackets[idx].sender, recentPackets[idx].id, recentPackets[idx].next_hop, + recentPackets[idx].relayed_by[0], recentPackets[idx].relayed_by[1], recentPackets[idx].relayed_by[2], + millis() - (recentPackets[idx].rxTimeMsec), idx, recentPacketsCapacity); #endif - // only the first match is returned, so be careful not to create duplicate entries - return it; // Return pointer to the found record + return &recentPackets[idx]; + } + bucket = (bucket + 1) & hashMask; } - } - #if VERBOSE_PACKET_HISTORY - LOG_DEBUG("Packet History - find: s=%08x id=%08x NOT FOUND", sender, id); + LOG_DEBUG("Packet History - find: s=%08x id=%08x NOT FOUND", sender, id); +#endif + return NULL; + } #endif - return NULL; // Not found + + // Linear scan (sole path when hash excluded, fallback when hash allocation failed) + for (PacketRecord *it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) { + if (it->id == id && it->sender == sender) { + return it; + } + } + + return NULL; } /** Insert/Replace oldest PacketRecord in recentPackets. */ @@ -327,8 +444,22 @@ void PacketHistory::insert(const PacketRecord &r) return; // Return early if we can't update the history } +#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH + // Maintain hash index: remove old entry if evicting a different packet, then insert new entry + bool isMatchingSlot = (tu->id == r.id && tu->sender == r.sender); + if (!isMatchingSlot && tu->rxTimeMsec != 0) { + hashRemove(tu->sender, tu->id); + } + *tu = r; // store the packet + if (!isMatchingSlot) { + hashInsert(r.sender, r.id, (uint16_t)(tu - recentPackets)); + } +#else + *tu = r; // store the packet +#endif + #if VERBOSE_PACKET_HISTORY LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER", tu - recentPackets, recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], @@ -396,6 +527,31 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r, boo return found; } +// Check two relayers against the same packet record with a single find() call, +// avoiding redundant O(N) lookups when both are checked for the same (id, sender) pair. +void PacketHistory::checkRelayers(uint8_t relayer1, uint8_t relayer2, uint32_t id, NodeNum sender, bool *r1Result, bool *r2Result, + bool *r2WasSole) +{ + *r1Result = false; + *r2Result = false; + if (r2WasSole) + *r2WasSole = false; + + if (!initOk()) { + LOG_ERROR("PacketHistory - checkRelayers: NOT INITIALIZED!"); + return; + } + + const PacketRecord *found = find(sender, id); + if (!found) + return; + + if (relayer1 != 0) + *r1Result = wasRelayer(relayer1, *found); + if (relayer2 != 0) + *r2Result = wasRelayer(relayer2, *found, r2WasSole); +} + // Remove a relayer from the list of relayers of a packet in the history given an ID and sender void PacketHistory::removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender) { diff --git a/src/mesh/PacketHistory.h b/src/mesh/PacketHistory.h index 9b6a9328099..a11e2d038f8 100644 --- a/src/mesh/PacketHistory.h +++ b/src/mesh/PacketHistory.h @@ -28,6 +28,22 @@ class PacketHistory 0; // Can be set in constructor, no need to recompile. Used to allocate memory for mx_recentPackets. PacketRecord *recentPackets = NULL; // Simple and fixed in size. Debloat. +#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH + // Open-addressing hash table for O(1) lookup in find(), replacing the O(N) linear scan. + // Maps (sender, id) -> index into recentPackets[]. Uses linear probing with a load factor <= 0.5. + // The load factor invariant holds permanently: hashCapacity = 2 * nextPowerOf2(recentPacketsCapacity), + // and at most recentPacketsCapacity entries can ever be live (one per recentPackets[] slot). + static constexpr uint16_t HASH_EMPTY = 0xFFFF; + uint16_t *hashIndex = NULL; + uint32_t hashCapacity = 0; // Always a power of 2 + uint32_t hashMask = 0; // hashCapacity - 1, for fast modular indexing + + uint32_t hashSlot(NodeNum sender, PacketId id) const; + void hashInsert(NodeNum sender, PacketId id, uint16_t slotIdx); + void hashRemove(NodeNum sender, PacketId id); + void hashRebuild(); +#endif + /** Find a packet record in history. * @param sender NodeNum * @param id PacketId @@ -70,6 +86,16 @@ class PacketHistory * @return true if node was indeed a relayer, false if not */ bool wasRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender, bool *wasSole = nullptr); + /** + * Check two relayers against the same packet record with a single lookup. + * Avoids redundant find() calls when checking multiple relayers for the same (id, sender) pair. + * @param r1Result set to true if relayer1 was a relayer + * @param r2Result set to true if relayer2 was a relayer + * @param r2WasSole if not nullptr, set to true if relayer2 was the sole relayer + */ + void checkRelayers(uint8_t relayer1, uint8_t relayer2, uint32_t id, NodeNum sender, bool *r1Result, bool *r2Result, + bool *r2WasSole = nullptr); + // Remove a relayer from the list of relayers of a packet in the history given an ID and sender void removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender); diff --git a/src/meshUtils.h b/src/meshUtils.h index da3a4593bdf..fe94ead2f5c 100644 --- a/src/meshUtils.h +++ b/src/meshUtils.h @@ -11,6 +11,24 @@ template constexpr const T &clamp(const T &v, const T &lo, const T &hi return (v < lo) ? lo : (hi < v) ? hi : v; } +/// Return the smallest power of 2 >= n (undefined for n > 2^31) +static inline uint32_t nextPowerOf2(uint32_t n) +{ + if (n <= 1) + return 1; +#if defined(__GNUC__) + return 1U << (32 - __builtin_clz(n - 1)); +#else + n--; + n |= n >> 1; + n |= n >> 2; + n |= n >> 4; + n |= n >> 8; + n |= n >> 16; + return n + 1; +#endif +} + #if HAS_SCREEN #define IF_SCREEN(X) \ if (screen) { \ diff --git a/test/test_packet_history/test_main.cpp b/test/test_packet_history/test_main.cpp new file mode 100644 index 00000000000..2453956c5f9 --- /dev/null +++ b/test/test_packet_history/test_main.cpp @@ -0,0 +1,834 @@ +/* + * Unit tests for PacketHistory — the packet deduplication engine + * used by the mesh routing stack. + * + * PacketHistory maintains a fixed-size array of PacketRecords with an + * optional hash table for O(1) lookup. It tracks which nodes relayed + * each packet, supports LRU-style eviction, and detects fallback-to- + * flooding and hop-limit upgrades. + */ + +#include "PacketHistory.h" + +#include "TestUtil.h" +#include + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +static constexpr uint32_t OUR_NODE_NUM = 0xDEAD1234; +static constexpr uint8_t OUR_RELAY_ID = 0x34; // getLastByteOfNodeNum(OUR_NODE_NUM) +static constexpr uint32_t SMALL_CAPACITY = 8; + +// --------------------------------------------------------------------------- +// Per-test state +// --------------------------------------------------------------------------- +static PacketHistory *ph = nullptr; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +static meshtastic_MeshPacket makePacket(uint32_t from, uint32_t id, uint8_t hop_limit = 3, + uint8_t next_hop = NO_NEXT_HOP_PREFERENCE, uint8_t relay_node = 0) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.id = id; + p.hop_limit = hop_limit; + p.next_hop = next_hop; + p.relay_node = relay_node; + return p; +} + +// --------------------------------------------------------------------------- +// setUp / tearDown — called before and after every test +// --------------------------------------------------------------------------- +void setUp(void) +{ + myNodeInfo.my_node_num = OUR_NODE_NUM; + ph = new PacketHistory(SMALL_CAPACITY); +} + +void tearDown(void) +{ + delete ph; + ph = nullptr; +} + +// =========================================================================== +// Group 1 — Initialization +// =========================================================================== + +void test_init_valid_size(void) +{ + PacketHistory h(8); + TEST_ASSERT_TRUE(h.initOk()); +} + +void test_init_minimum_size(void) +{ + PacketHistory h(4); + TEST_ASSERT_TRUE(h.initOk()); +} + +void test_init_too_small_falls_back(void) +{ + // Sizes < 4 or > PACKETHISTORY_MAX are clamped to PACKETHISTORY_MAX inside the constructor + PacketHistory h(2); + TEST_ASSERT_TRUE(h.initOk()); +} + +// =========================================================================== +// Group 2 — Basic Deduplication +// =========================================================================== + +void test_first_packet_not_seen(void) +{ + auto p = makePacket(0x1111, 100); + TEST_ASSERT_FALSE(ph->wasSeenRecently(&p)); +} + +void test_same_packet_seen_twice(void) +{ + auto p = makePacket(0x1111, 100); + TEST_ASSERT_FALSE(ph->wasSeenRecently(&p)); // first time + TEST_ASSERT_TRUE(ph->wasSeenRecently(&p)); // duplicate +} + +void test_different_id_not_confused(void) +{ + auto p1 = makePacket(0x1111, 100); + auto p2 = makePacket(0x1111, 200); + ph->wasSeenRecently(&p1); + TEST_ASSERT_FALSE(ph->wasSeenRecently(&p2)); +} + +void test_different_sender_not_confused(void) +{ + auto p1 = makePacket(0x1111, 100); + auto p2 = makePacket(0x2222, 100); + ph->wasSeenRecently(&p1); + TEST_ASSERT_FALSE(ph->wasSeenRecently(&p2)); +} + +void test_withUpdate_false_no_insert(void) +{ + auto p = makePacket(0x1111, 100); + // First call with withUpdate=false: should not store + TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, /*withUpdate=*/false)); + // Second call with withUpdate=true: still not found because first didn't store + TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, /*withUpdate=*/true)); +} + +void test_withUpdate_true_inserts(void) +{ + auto p = makePacket(0x1111, 100); + TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, /*withUpdate=*/true)); + TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, /*withUpdate=*/false)); // found without inserting again +} + +// =========================================================================== +// Group 3 — LRU Eviction +// =========================================================================== + +void test_fill_capacity_all_found(void) +{ + for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) { + auto p = makePacket(0xAAAA, i); + ph->wasSeenRecently(&p); + } + // All 8 should be found + for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) { + auto p = makePacket(0xAAAA, i); + TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false)); + } +} + +void test_eviction_oldest_replaced(void) +{ + // Fill all 8 slots + for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) { + auto p = makePacket(0xAAAA, i); + ph->wasSeenRecently(&p); + } + + // Advance time so the eviction logic can distinguish "oldest" from "newest". + // insert() uses (now_millis - rxTimeMsec) > OldtrxTimeMsec with strict >, so + // entries with identical timestamps all have age 0 and none gets selected. + delay(1); + + // Insert a 9th packet — should evict the oldest + auto p9 = makePacket(0xAAAA, 9); + ph->wasSeenRecently(&p9); + + // The 9th should be found + TEST_ASSERT_TRUE(ph->wasSeenRecently(&p9, false)); + + // At least one of the originals should have been evicted + int evicted = 0; + for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) { + auto p = makePacket(0xAAAA, i); + if (!ph->wasSeenRecently(&p, false)) + evicted++; + } + TEST_ASSERT_TRUE(evicted > 0); +} + +void test_matching_slot_reused(void) +{ + // Insert packet, then re-insert same (sender, id) — should reuse slot, not evict others + auto p1 = makePacket(0xAAAA, 1); + auto p2 = makePacket(0xBBBB, 2); + ph->wasSeenRecently(&p1); + ph->wasSeenRecently(&p2); + + // Re-observe p1 (triggers merge path) + ph->wasSeenRecently(&p1); + + // Both should still be present + TEST_ASSERT_TRUE(ph->wasSeenRecently(&p1, false)); + TEST_ASSERT_TRUE(ph->wasSeenRecently(&p2, false)); +} + +void test_free_slot_preferred(void) +{ + // Insert 4 packets into capacity-8 history — next insert should use a free slot, not evict + for (uint32_t i = 1; i <= 4; i++) { + auto p = makePacket(0xAAAA, i); + ph->wasSeenRecently(&p); + } + auto p5 = makePacket(0xAAAA, 5); + ph->wasSeenRecently(&p5); + + // All 5 should be present (no eviction needed) + for (uint32_t i = 1; i <= 5; i++) { + auto p = makePacket(0xAAAA, i); + TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false)); + } +} + +void test_evict_all_old_packets(void) +{ + // Fill with packets 1..8 + for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) { + auto p = makePacket(0xAAAA, i); + ph->wasSeenRecently(&p); + } + + // Advance time so the replacement batch can evict the originals + delay(1); + + // Replace all with packets 101..108 + for (uint32_t i = 101; i <= 100 + SMALL_CAPACITY; i++) { + auto p = makePacket(0xBBBB, i); + ph->wasSeenRecently(&p); + } + // None of the originals should be found + for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) { + auto p = makePacket(0xAAAA, i); + TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, false)); + } + // All new ones should be found + for (uint32_t i = 101; i <= 100 + SMALL_CAPACITY; i++) { + auto p = makePacket(0xBBBB, i); + TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false)); + } +} + +// =========================================================================== +// Group 4 — Relayer Tracking +// =========================================================================== + +void test_wasRelayer_true(void) +{ + // Non-us relay_nodes only enter relayed_by[] through the "heard-back" merge path: + // we must have relayed first, then observe the packet return at hop_limit-1. + auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p1); + + // Heard-back from 0xCC at hop_limit=2 (ourTxHopLimit-1) triggers the merge + auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xCC); + ph->wasSeenRecently(&p2); + + TEST_ASSERT_TRUE(ph->wasRelayer(0xCC, 100, 0x1111)); +} + +void test_wasRelayer_false(void) +{ + auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, 0xAA); + ph->wasSeenRecently(&p); + // 0xCC was never a relayer + TEST_ASSERT_FALSE(ph->wasRelayer(0xCC, 100, 0x1111)); +} + +void test_wasRelayer_zero_returns_false(void) +{ + auto p = makePacket(0x1111, 100); + ph->wasSeenRecently(&p); + TEST_ASSERT_FALSE(ph->wasRelayer(0, 100, 0x1111)); +} + +void test_wasRelayer_not_found(void) +{ + // Packet not in history at all + TEST_ASSERT_FALSE(ph->wasRelayer(0xAA, 999, 0x9999)); +} + +void test_wasRelayer_wasSole_true(void) +{ + // relay_node = ourRelayID → relayed_by[0] = ourRelayID + auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p); + + bool wasSole = false; + bool result = ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111, &wasSole); + TEST_ASSERT_TRUE(result); + TEST_ASSERT_TRUE(wasSole); +} + +void test_wasRelayer_wasSole_false(void) +{ + // First observation: we relay + auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p1); + + // Second observation: different relayer adds to record + auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB); + ph->wasSeenRecently(&p2); + + bool wasSole = true; + bool result = ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111, &wasSole); + TEST_ASSERT_TRUE(result); + TEST_ASSERT_FALSE(wasSole); +} + +void test_wasRelayer_all_six_slots(void) +{ + // First observation: we relay with hop_limit=3 (fills slot 0, ourTxHopLimit=3) + auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p); + + // Each heard-back must satisfy: hop_limit == ourTxHopLimit OR ourTxHopLimit-1. + // Using hop_limit=2 (ourTxHopLimit-1) for all, which triggers the heard-back + // merge path each time. Each new relay_node pushes to slot 0 and shifts existing + // relayers right, eventually filling all NUM_RELAYERS(6) slots. + uint8_t relayers[] = {0x11, 0x22, 0x33, 0x44, 0x55}; + for (int i = 0; i < 5; i++) { + auto pn = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, relayers[i]); + ph->wasSeenRecently(&pn); + } + + // All 6 should be detected + TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111)); + for (int i = 0; i < 5; i++) { + TEST_ASSERT_TRUE(ph->wasRelayer(relayers[i], 100, 0x1111)); + } +} + +// =========================================================================== +// Group 5 — removeRelayer +// =========================================================================== + +void test_removeRelayer_removes(void) +{ + auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p1); + TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111)); + + ph->removeRelayer(OUR_RELAY_ID, 100, 0x1111); + TEST_ASSERT_FALSE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111)); +} + +void test_removeRelayer_compacts(void) +{ + // We relay first + auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p1); + // Second relayer + auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB); + ph->wasSeenRecently(&p2); + + // Remove us, 0xBB should still be found + ph->removeRelayer(OUR_RELAY_ID, 100, 0x1111); + TEST_ASSERT_FALSE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111)); + TEST_ASSERT_TRUE(ph->wasRelayer(0xBB, 100, 0x1111)); +} + +void test_removeRelayer_nonexistent_safe(void) +{ + auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p); + // Removing a relayer that doesn't exist should not crash + ph->removeRelayer(0xFF, 100, 0x1111); + // Original should still be there + TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111)); +} + +void test_removeRelayer_packet_not_found_safe(void) +{ + // Packet not in history — should not crash + ph->removeRelayer(0xAA, 999, 0x9999); +} + +// =========================================================================== +// Group 6 — checkRelayers +// =========================================================================== + +void test_checkRelayers_both_found(void) +{ + // We relay first + auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p1); + // Second relayer + auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB); + ph->wasSeenRecently(&p2); + + bool r1 = false, r2 = false; + ph->checkRelayers(OUR_RELAY_ID, 0xBB, 100, 0x1111, &r1, &r2); + TEST_ASSERT_TRUE(r1); + TEST_ASSERT_TRUE(r2); +} + +void test_checkRelayers_one_found(void) +{ + auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p); + + bool r1 = false, r2 = false; + ph->checkRelayers(OUR_RELAY_ID, 0xCC, 100, 0x1111, &r1, &r2); + TEST_ASSERT_TRUE(r1); + TEST_ASSERT_FALSE(r2); +} + +void test_checkRelayers_r2WasSole(void) +{ + auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p); + + bool r1 = false, r2 = false, r2Sole = false; + // relayer1=0xCC (not found), relayer2=OUR_RELAY_ID (sole relayer) + ph->checkRelayers(0xCC, OUR_RELAY_ID, 100, 0x1111, &r1, &r2, &r2Sole); + TEST_ASSERT_FALSE(r1); + TEST_ASSERT_TRUE(r2); + TEST_ASSERT_TRUE(r2Sole); +} + +// =========================================================================== +// Group 7 — wasSeenRecently Merge Logic +// =========================================================================== + +void test_merge_preserves_original_next_hop(void) +{ + // First observation with next_hop=0x55 + auto p1 = makePacket(0x1111, 100, 3, 0x55, 0xAA); + ph->wasSeenRecently(&p1); + + // Re-observation with different next_hop + auto p2 = makePacket(0x1111, 100, 2, 0x77, 0xBB); + ph->wasSeenRecently(&p2); + + // The stored next_hop should still be 0x55 (the original) + // We verify via weWereNextHop: if we set original next_hop to ourRelayID, it should detect it + auto p3 = makePacket(0x1111, 200, 3, OUR_RELAY_ID, 0xAA); + ph->wasSeenRecently(&p3); + auto p4 = makePacket(0x1111, 200, 2, 0x99, 0xBB); + bool weWereNextHop = false; + ph->wasSeenRecently(&p4, true, nullptr, &weWereNextHop); + TEST_ASSERT_TRUE(weWereNextHop); +} + +void test_merge_preserves_highest_hop_limit(void) +{ + // First observation with hop_limit=5 + auto p1 = makePacket(0x1111, 100, 5); + ph->wasSeenRecently(&p1); + + // Re-observation with hop_limit=2 (lower) + auto p2 = makePacket(0x1111, 100, 2); + ph->wasSeenRecently(&p2); + + // Third observation with hop_limit=3 should not trigger upgrade (highest was 5) + bool wasUpgraded = true; + auto p3 = makePacket(0x1111, 100, 3); + ph->wasSeenRecently(&p3, true, nullptr, nullptr, &wasUpgraded); + TEST_ASSERT_FALSE(wasUpgraded); +} + +void test_merge_no_duplicate_relayers(void) +{ + // Observe with relayer 0xAA (stored via relay_node, but only slot 0 for ourRelayID) + // We need to use ourRelayID for the first observation to get it into slot 0 + auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p1); + + // Re-observe with same relay_node=ourRelayID — should not create duplicates + auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p2); + + // ourRelayID should appear exactly once — wasSole should still be true + bool wasSole = false; + TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111, &wasSole)); + TEST_ASSERT_TRUE(wasSole); +} + +void test_merge_adds_new_relayer(void) +{ + auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p1); + + auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB); + ph->wasSeenRecently(&p2); + + TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111)); + TEST_ASSERT_TRUE(ph->wasRelayer(0xBB, 100, 0x1111)); +} + +void test_merge_we_relay_sets_slot_zero(void) +{ + // When relay_node == ourRelayID, relayed_by[0] should be set to ourRelayID + auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p); + + TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111)); +} + +void test_merge_heard_back_stores_relay_node(void) +{ + // First: we relay (hop_limit=3) + auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + ph->wasSeenRecently(&p1); + + // Second: we hear the packet back with hop_limit=2 (one less), from relay_node=0xCC + // This triggers the "heard back" logic: weWereRelayer && hop_limit == ourTxHopLimit-1 + auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xCC); + ph->wasSeenRecently(&p2); + + TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111)); + TEST_ASSERT_TRUE(ph->wasRelayer(0xCC, 100, 0x1111)); +} + +// =========================================================================== +// Group 8 — Fallback-to-Flooding Detection +// =========================================================================== + +void test_fallback_detected(void) +{ + // The fallback condition requires wasRelayer(relay_node) && !wasRelayer(ourRelayID). + // Non-us relayers only enter relayed_by[] via the heard-back merge path, which + // also stores ourRelayID. So we must removeRelayer(ourRelayID) to satisfy both. + // + // Scenario: we relay a directed packet, hear it back from 0xAA, then the router + // removes us from the relayer list. Later the sender falls back to flooding. + + // Step 1: We relay (directed to next_hop=0x55) + auto p1 = makePacket(0x1111, 100, 3, 0x55, OUR_RELAY_ID); + ph->wasSeenRecently(&p1); + + // Step 2: Heard-back from 0xAA at hop_limit-1 → stores 0xAA in relayed_by + auto p2 = makePacket(0x1111, 100, 2, 0x55, 0xAA); + ph->wasSeenRecently(&p2); + + // Step 3: Router removes us from the relayer list + ph->removeRelayer(OUR_RELAY_ID, 100, 0x1111); + + // Step 4: Sender falls back to flooding — same packet, NO_NEXT_HOP_PREFERENCE, from 0xAA + auto p3 = makePacket(0x1111, 100, 1, NO_NEXT_HOP_PREFERENCE, 0xAA); + bool wasFallback = false; + ph->wasSeenRecently(&p3, true, &wasFallback); + TEST_ASSERT_TRUE(wasFallback); +} + +void test_fallback_not_when_we_relayed(void) +{ + // First observation: directed, we relayed it + auto p1 = makePacket(0x1111, 100, 3, 0x55, OUR_RELAY_ID); + ph->wasSeenRecently(&p1); + + // Second observation: fallback to flooding from same relayer (us) + // But since we already relayed, wasFallback should be false + auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID); + bool wasFallback = false; + ph->wasSeenRecently(&p2, true, &wasFallback); + TEST_ASSERT_FALSE(wasFallback); +} + +void test_fallback_not_on_first_observation(void) +{ + // First time seen — can't be a fallback + auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, 0xAA); + bool wasFallback = false; + ph->wasSeenRecently(&p, true, &wasFallback); + TEST_ASSERT_FALSE(wasFallback); +} + +// =========================================================================== +// Group 9 — Next-Hop and Upgrade Detection +// =========================================================================== + +void test_weWereNextHop_true(void) +{ + // Packet directed to us (next_hop = ourRelayID) + auto p1 = makePacket(0x1111, 100, 3, OUR_RELAY_ID, 0xAA); + ph->wasSeenRecently(&p1); + + // Re-observe: check if we were the original next_hop + auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB); + bool weWereNextHop = false; + ph->wasSeenRecently(&p2, true, nullptr, &weWereNextHop); + TEST_ASSERT_TRUE(weWereNextHop); +} + +void test_weWereNextHop_false(void) +{ + // Packet directed to someone else + auto p1 = makePacket(0x1111, 100, 3, 0x99, 0xAA); + ph->wasSeenRecently(&p1); + + auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB); + bool weWereNextHop = false; + ph->wasSeenRecently(&p2, true, nullptr, &weWereNextHop); + TEST_ASSERT_FALSE(weWereNextHop); +} + +void test_wasUpgraded_true(void) +{ + // First observation with hop_limit=3 → stored as highestHopLimit bits 0-2 = 3 + auto p1 = makePacket(0x1111, 100, 3); + ph->wasSeenRecently(&p1); + + // Re-observation with hop_limit=5 + // The upgrade check on line 122 compares the raw packed byte found->hop_limit against p->hop_limit. + // found->hop_limit has highestHopLimit=3 in bits 0-2 (and possibly ourTxHopLimit in bits 3-5). + // So the packed byte value is 3 (or more if ourTxHopLimit was set), and p->hop_limit is 5. + // Since 3 < 5 (with no ourTxHopLimit set), this should detect an upgrade. + auto p2 = makePacket(0x1111, 100, 5); + bool wasUpgraded = false; + ph->wasSeenRecently(&p2, true, nullptr, nullptr, &wasUpgraded); + TEST_ASSERT_TRUE(wasUpgraded); +} + +void test_wasUpgraded_false(void) +{ + auto p1 = makePacket(0x1111, 100, 5); + ph->wasSeenRecently(&p1); + + // Same or lower hop_limit + auto p2 = makePacket(0x1111, 100, 3); + bool wasUpgraded = false; + ph->wasSeenRecently(&p2, true, nullptr, nullptr, &wasUpgraded); + TEST_ASSERT_FALSE(wasUpgraded); +} + +// =========================================================================== +// Group 10 — Edge Cases +// =========================================================================== + +void test_packet_id_zero_not_stored(void) +{ + auto p = makePacket(0x1111, 0); + TEST_ASSERT_FALSE(ph->wasSeenRecently(&p)); + TEST_ASSERT_FALSE(ph->wasSeenRecently(&p)); // still not found +} + +void test_sender_zero_substituted(void) +{ + // from=0 means "from us" — getFrom() substitutes nodeDB->getNodeNum() + auto p = makePacket(0, 100); + ph->wasSeenRecently(&p); + + // Should be stored under our node num, not 0 + auto p2 = makePacket(OUR_NODE_NUM, 100); + TEST_ASSERT_TRUE(ph->wasSeenRecently(&p2, false)); +} + +void test_uninitialized_wasSeenRecently(void) +{ + // Simulate uninitialized state — create a PacketHistory that looks uninitialized + // We can't easily make allocation fail, but we can test the initOk guard with a destructed one + PacketHistory h(4); + TEST_ASSERT_TRUE(h.initOk()); // sanity check + h.~PacketHistory(); + + auto p = makePacket(0x1111, 100); + TEST_ASSERT_FALSE(h.wasSeenRecently(&p)); + + // Reconstruct in place to allow proper destruction + new (&h) PacketHistory(4); +} + +void test_uninitialized_wasRelayer(void) +{ + PacketHistory h(4); + h.~PacketHistory(); + + TEST_ASSERT_FALSE(h.wasRelayer(0xAA, 100, 0x1111)); + + new (&h) PacketHistory(4); +} + +void test_multiple_instances_independent(void) +{ + PacketHistory h2(SMALL_CAPACITY); + + auto p = makePacket(0x1111, 100); + ph->wasSeenRecently(&p); + + // h2 should NOT find it + TEST_ASSERT_FALSE(h2.wasSeenRecently(&p, false)); + + // ph should still find it + TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false)); +} + +// =========================================================================== +// Group 11 — Hash Table Stress +// =========================================================================== + +void test_many_packets_no_false_negatives(void) +{ + PacketHistory big(64); + for (uint32_t i = 1; i <= 64; i++) { + auto p = makePacket(0xAAAA, i); + big.wasSeenRecently(&p); + } + for (uint32_t i = 1; i <= 64; i++) { + auto p = makePacket(0xAAAA, i); + TEST_ASSERT_TRUE_MESSAGE(big.wasSeenRecently(&p, false), "False negative in hash table"); + } +} + +void test_many_packets_no_false_positives(void) +{ + PacketHistory big(64); + for (uint32_t i = 1; i <= 64; i++) { + auto p = makePacket(0xAAAA, i); + big.wasSeenRecently(&p); + } + // IDs 65..128 were never inserted + for (uint32_t i = 65; i <= 128; i++) { + auto p = makePacket(0xAAAA, i); + TEST_ASSERT_FALSE_MESSAGE(big.wasSeenRecently(&p, false), "False positive in hash table"); + } +} + +void test_churn_correctness(void) +{ + // Insert 3x capacity to force heavy eviction. + // Advance time between each generation so eviction can distinguish old from new. + PacketHistory big(32); + uint32_t capacity = 32; + uint32_t generations = 3; + + for (uint32_t gen = 0; gen < generations; gen++) { + if (gen > 0) + delay(1); // Ensure new generation has a newer timestamp than the old + for (uint32_t i = 1; i <= capacity; i++) { + auto p = makePacket(0xAAAA, gen * capacity + i); + big.wasSeenRecently(&p); + } + } + + uint32_t total = capacity * generations; + + // Only the most recent 32 should be present (due to LRU eviction) + for (uint32_t i = total - 31; i <= total; i++) { + auto p = makePacket(0xAAAA, i); + TEST_ASSERT_TRUE_MESSAGE(big.wasSeenRecently(&p, false), "Recent packet lost after churn"); + } + // Older packets should be gone + int found = 0; + for (uint32_t i = 1; i <= total - capacity; i++) { + auto p = makePacket(0xAAAA, i); + if (big.wasSeenRecently(&p, false)) + found++; + } + TEST_ASSERT_EQUAL_INT_MESSAGE(0, found, "Evicted packets should not be found"); +} + +// =========================================================================== +// Test runner +// =========================================================================== + +void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + UNITY_BEGIN(); + + // Group 1 — Initialization + RUN_TEST(test_init_valid_size); + RUN_TEST(test_init_minimum_size); + RUN_TEST(test_init_too_small_falls_back); + + // Group 2 — Basic Deduplication + RUN_TEST(test_first_packet_not_seen); + RUN_TEST(test_same_packet_seen_twice); + RUN_TEST(test_different_id_not_confused); + RUN_TEST(test_different_sender_not_confused); + RUN_TEST(test_withUpdate_false_no_insert); + RUN_TEST(test_withUpdate_true_inserts); + + // Group 3 — LRU Eviction + RUN_TEST(test_fill_capacity_all_found); + RUN_TEST(test_eviction_oldest_replaced); + RUN_TEST(test_matching_slot_reused); + RUN_TEST(test_free_slot_preferred); + RUN_TEST(test_evict_all_old_packets); + + // Group 4 — Relayer Tracking + RUN_TEST(test_wasRelayer_true); + RUN_TEST(test_wasRelayer_false); + RUN_TEST(test_wasRelayer_zero_returns_false); + RUN_TEST(test_wasRelayer_not_found); + RUN_TEST(test_wasRelayer_wasSole_true); + RUN_TEST(test_wasRelayer_wasSole_false); + RUN_TEST(test_wasRelayer_all_six_slots); + + // Group 5 — removeRelayer + RUN_TEST(test_removeRelayer_removes); + RUN_TEST(test_removeRelayer_compacts); + RUN_TEST(test_removeRelayer_nonexistent_safe); + RUN_TEST(test_removeRelayer_packet_not_found_safe); + + // Group 6 — checkRelayers + RUN_TEST(test_checkRelayers_both_found); + RUN_TEST(test_checkRelayers_one_found); + RUN_TEST(test_checkRelayers_r2WasSole); + + // Group 7 — Merge Logic + RUN_TEST(test_merge_preserves_original_next_hop); + RUN_TEST(test_merge_preserves_highest_hop_limit); + RUN_TEST(test_merge_no_duplicate_relayers); + RUN_TEST(test_merge_adds_new_relayer); + RUN_TEST(test_merge_we_relay_sets_slot_zero); + RUN_TEST(test_merge_heard_back_stores_relay_node); + + // Group 8 — Fallback-to-Flooding Detection + RUN_TEST(test_fallback_detected); + RUN_TEST(test_fallback_not_when_we_relayed); + RUN_TEST(test_fallback_not_on_first_observation); + + // Group 9 — Next-Hop and Upgrade Detection + RUN_TEST(test_weWereNextHop_true); + RUN_TEST(test_weWereNextHop_false); + RUN_TEST(test_wasUpgraded_true); + RUN_TEST(test_wasUpgraded_false); + + // Group 10 — Edge Cases + RUN_TEST(test_packet_id_zero_not_stored); + RUN_TEST(test_sender_zero_substituted); + RUN_TEST(test_uninitialized_wasSeenRecently); + RUN_TEST(test_uninitialized_wasRelayer); + RUN_TEST(test_multiple_instances_independent); + + // Group 11 — Hash Table Stress + RUN_TEST(test_many_packets_no_false_negatives); + RUN_TEST(test_many_packets_no_false_positives); + RUN_TEST(test_churn_correctness); + + exit(UNITY_END()); +} + +void loop() {} From d9195944dff29a94b58f2e6c1a884e2ce111462f Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:20:07 -0500 Subject: [PATCH 069/225] PositionModule::sendLostAndFoundText: use stack buffer, eliminate heap alloc (#10251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PositionModule::sendLostAndFoundText: use stack buffer, eliminate heap alloc The lost-and-found message was built with an unnecessary heap allocation: char *message = new char[60]; sprintf(message, "..."...); ... delete[] message; Two problems: 1. **Buffer too small.** The format string expands with two %f (IEEE 754 doubles), which `sprintf` prints with full precision — easily 15+ digits each plus separators — so the actual rendered string can run 40-50 characters before even considering the emoji (4 UTF-8 bytes) and the embedded BEL. A pathological lat/lon can overflow 60 bytes and corrupt heap metadata. Unbounded `sprintf` with no size check. 2. **Heap churn in a GPS callback.** This function is called from the position-update path which is already heap-sensitive. An infrequent 60-byte transient alloc isn't catastrophic, but stack is trivially available here and removes the failure mode entirely. Fix: replace with a 128-byte stack buffer and `snprintf` bounded by `sizeof(message)`. Drop the matching `delete[]` since there's nothing to delete. Behavior is identical on the happy path; the overflow case now truncates safely instead of scribbling over heap. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * PositionModule.cpp: add trailing newline for clang-format * Address Copilot review: cleaner snprintf size handling Review feedback from @Copilot on PR #10251: the ternary-plus-static-cast form mixed signed/unsigned types (int written vs. pb_size_t payload.size vs. size_t sizeof(message)) and was harder to read than necessary. Cleaner form: const size_t msg_len = std::min(static_cast(written), sizeof(message) - 1); p->decoded.payload.size = msg_len; Same behaviour (clamp to buffer-minus-NUL) with one explicit cast and a size_t variable that names the meaning. Handles the encoding-error path (written < 0) separately so no bad values leak into payload.size. * Trunk --------- Co-authored-by: Ben Meadors Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/modules/PositionModule.cpp | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 0378d01e74b..ac81e9c577b 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -492,15 +492,24 @@ void PositionModule::sendLostAndFoundText() { meshtastic_MeshPacket *p = allocDataPacket(); p->to = NODENUM_BROADCAST; - char *message = new char[60]; - sprintf(message, "🚨I'm lost! Lat / Lon: %f, %f\a", (lastGpsLatitude * 1e-7), (lastGpsLongitude * 1e-7)); + char message[128]; + int written = snprintf(message, sizeof(message), "🚨I'm lost! Lat / Lon: %f, %f\a", (lastGpsLatitude * 1e-7), + (lastGpsLongitude * 1e-7)); p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; p->want_ack = false; - p->decoded.payload.size = strlen(message); - memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); + if (written < 0) { + // snprintf encoding error — send an empty payload rather than uninitialized bytes. + p->decoded.payload.size = 0; + } else { + // Clamp to buffer capacity (snprintf returns "would-have-written" which can exceed the buffer). + const size_t msg_len = std::min(static_cast(written), sizeof(message) - 1); + p->decoded.payload.size = msg_len; + if (msg_len > 0) { + memcpy(p->decoded.payload.bytes, message, msg_len); + } + } service->sendToMesh(p, RX_SRC_LOCAL, true); - delete[] message; } // Helper: return imprecise (truncated + centered) lat/lon as int32 using current precision @@ -580,4 +589,4 @@ void PositionModule::handleNewPosition() } } -#endif \ No newline at end of file +#endif From 5ea3d143dac713893fe29e2b466f4d46da8f8da0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:30:47 -0500 Subject: [PATCH 070/225] Update meshtastic-esp8266-oled-ssd1306 digest to 6bfd1f1 (#10277) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index a97b813fa59..102c93a3143 100644 --- a/platformio.ini +++ b/platformio.ini @@ -68,7 +68,7 @@ monitor_speed = 115200 monitor_filters = direct lib_deps = # renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master - https://github.com/meshtastic/esp8266-oled-ssd1306/archive/21e484f409cde18d44012caef84c244eb5ca28f3.zip + https://github.com/meshtastic/esp8266-oled-ssd1306/archive/6bfd1f135e1ebe37afd6050bb4b9964cea3fcfda.zip # renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip # renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master From 924411de592ea8c39135fb898e1e7c3d0604589a Mon Sep 17 00:00:00 2001 From: Emanuele <262411497+Emanuele-Mb@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:53:59 +0200 Subject: [PATCH 071/225] T-Watch S3 Power button managment (#9855) * PMU interrupt pin defined in t-watch s3 * Implement button control on T-Watch S3 Added interrupt handling for the Power/Corona button on T-Watch S3, I use it to control screen state. * Reducing labels * Reducing labels * Updated the comment * ISR is now IRAM-safe Updated interrupt management not to cause random crashes. * Trunk * Simplify and use INPUT_BROKER_CANCEL --------- Co-authored-by: Jonathan Bennett --- src/Power.cpp | 11 +++++++++++ variants/esp32s3/t-watch-s3/variant.h | 2 ++ 2 files changed, 13 insertions(+) diff --git a/src/Power.cpp b/src/Power.cpp index 97bacafd296..49e95bd0cc1 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -1000,6 +1000,17 @@ int32_t Power::runOnce() powerFSM.trigger(EVENT_POWER_CONNECTED); } +#ifdef T_WATCH_S3 + /* + In the T-Watch S3 this code fragment reacts to the short press of the button by switching the + display on and off + */ + if (PMU->isPekeyShortPressIrq()) { + LOG_INFO("Input: Corona Button Click"); + InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0}; + inputBroker->injectInputEvent(&event); + } +#endif /* Other things we could check if we cared... diff --git a/variants/esp32s3/t-watch-s3/variant.h b/variants/esp32s3/t-watch-s3/variant.h index 507d6b7dc35..aca491a6d57 100644 --- a/variants/esp32s3/t-watch-s3/variant.h +++ b/variants/esp32s3/t-watch-s3/variant.h @@ -60,6 +60,8 @@ #define BUTTON_PIN 0 // only for Plus version +#define PMU_IRQ 21 // Interrupt pin for the PMU + #define USE_SX1262 #define USE_SX1268 From ba9cadc14da1de00b36c5ed8a895306ae43160d6 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 24 Apr 2026 06:09:37 +0300 Subject: [PATCH 072/225] Fix INA226 detection for non-TI compatible chip (Silergy) (#10247) * Fix INA226 detection for non-TI compatible chip (Silergy) * Removed extra I2C transaction + 20ms delay on every scan of address 0x40 (including real SHT2x sensors). Changes suggested by Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Apply formatting (trunk fmt) --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/detect/ScanI2CTwoWire.cpp | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index e298663a0c0..e3471c32a9e 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -415,30 +415,45 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) #if !defined(M5STACK_UNITC6L) case INA_ADDR: // Same as SHT2X case INA_ADDR_ALTERNATE: - case INA_ADDR_WAVESHARE_UPS: - registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2); - LOG_DEBUG("Register MFG_UID: 0x%x", registerValue); - if (registerValue == 0x5449) { - registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2); - LOG_DEBUG("Register DIE_UID: 0x%x", registerValue); + case INA_ADDR_WAVESHARE_UPS: { + uint16_t mfg = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2); + + LOG_DEBUG("Register MFG_UID: 0x%x", mfg); - if (registerValue == 0x2260) { + // Only read DIE_UID for vendors we recognize as INA-compatible to avoid + // an extra I2C transaction + delay on other devices sharing this address. + if (mfg == 0x5449 || mfg == 0x190F) { + uint16_t die = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2); + LOG_DEBUG("Register DIE_UID: 0x%x", die); + + // TI INA226 or fully compatible clones (e.g. TPA626) + if (mfg == 0x5449 && die == 0x2260) { logFoundDevice("INA226", (uint8_t)addr.address); type = INA226; - } else { + } + // Silergy SQ52201 (INA226-compatible with different IDs) + else if (mfg == 0x190F && die == 0x0000) { + logFoundDevice("INA226 (SQ52201)", (uint8_t)addr.address); + type = INA226; + } + // TI INA260 + else if (mfg == 0x5449) { logFoundDevice("INA260", (uint8_t)addr.address); type = INA260; } + } #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR - } else if (detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) { + if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) { logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address); type = SHTXX; + } #endif - } else { // Assume INA219 if none of the above ones are found + else { // Assume INA219 if none of the above ones are found logFoundDevice("INA219", (uint8_t)addr.address); type = INA219; } break; + } case INA3221_ADDR: registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2); LOG_DEBUG("Register MFG_UID FE: 0x%x", registerValue); From 7adfc3f992fb3fa3d7a4cee1470d9eeff838db44 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Fri, 24 Apr 2026 00:17:33 -0500 Subject: [PATCH 073/225] Remove incorrect LED_STATE_ON definition for t-beam-s3 (#10280) Fixes #9912 and #10170 --- variants/esp32s3/tbeam-s3-core/variant.h | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/variants/esp32s3/tbeam-s3-core/variant.h b/variants/esp32s3/tbeam-s3-core/variant.h index 9ce4aade9cc..2637e7f78be 100644 --- a/variants/esp32s3/tbeam-s3-core/variant.h +++ b/variants/esp32s3/tbeam-s3-core/variant.h @@ -9,8 +9,6 @@ #define BUTTON_PIN 0 // The middle button GPIO on the T-Beam S3 // #define EXT_NOTIFY_OUT 13 // Default pin to use for Ext Notify Module. -#define LED_STATE_ON 0 // State when LED is lit - // TTGO uses a common pinout for their SX1262 vs RF95 modules - both can be enabled and we will probe at runtime for RF95 and if // not found then probe for SX1262 #define USE_SX1262 @@ -76,4 +74,4 @@ // has 32768 Hz crystal #define HAS_32768HZ 1 -#define USE_SH1106 \ No newline at end of file +#define USE_SH1106 From 8e653122c774276311bfa0f4915501cda07f01c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:40:44 -0500 Subject: [PATCH 074/225] Upgrade trunk (#10284) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index ec6239207ad..f90f4f4ac51 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,7 +9,7 @@ plugins: lint: enabled: - checkov@3.2.524 - - renovate@43.139.6 + - renovate@43.141.0 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 From 04b819a7b5fcdcabbc73755b2e47659ae0e171c2 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Fri, 24 Apr 2026 00:17:33 -0500 Subject: [PATCH 075/225] Remove incorrect LED_STATE_ON definition for t-beam-s3 (#10280) Fixes #9912 and #10170 --- variants/esp32s3/tbeam-s3-core/variant.h | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/variants/esp32s3/tbeam-s3-core/variant.h b/variants/esp32s3/tbeam-s3-core/variant.h index 9ce4aade9cc..2637e7f78be 100644 --- a/variants/esp32s3/tbeam-s3-core/variant.h +++ b/variants/esp32s3/tbeam-s3-core/variant.h @@ -9,8 +9,6 @@ #define BUTTON_PIN 0 // The middle button GPIO on the T-Beam S3 // #define EXT_NOTIFY_OUT 13 // Default pin to use for Ext Notify Module. -#define LED_STATE_ON 0 // State when LED is lit - // TTGO uses a common pinout for their SX1262 vs RF95 modules - both can be enabled and we will probe at runtime for RF95 and if // not found then probe for SX1262 #define USE_SX1262 @@ -76,4 +74,4 @@ // has 32768 Hz crystal #define HAS_32768HZ 1 -#define USE_SH1106 \ No newline at end of file +#define USE_SH1106 From d47301defc06c610d79d3513c03e2e9ebac95193 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 14 Apr 2026 14:32:48 -0500 Subject: [PATCH 076/225] Add PortduinoSetOptions to overwrite the realhardware bool (#10157) Co-authored-by: Ben Meadors --- src/platform/portduino/PortduinoGlue.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 9e0a1b2a57e..660bad0f259 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -650,7 +650,9 @@ void portduinoSetup() if (verboseEnabled && portduino_config.logoutputlevel != level_trace) { portduino_config.logoutputlevel = level_debug; } - + if (portduino_config.lora_spi_dev != "") { + portduinoSetOptions({.realHardware = true}); + } return; } From 439b87b8606055a4d1998d4f36e1398f2858518f Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 22 Apr 2026 14:27:48 -0500 Subject: [PATCH 077/225] Detach power interrupts for sleep (#10230) * Detach power interrupts for sleep * Gate PMU IRQ behind a found PMU --- src/Power.cpp | 129 ++++++++++++++++++++++++++++++++++++++------------ src/power.h | 18 ++++++- src/sleep.cpp | 11 ++--- 3 files changed, 121 insertions(+), 37 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index d82c870ed56..ecdda8dd979 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -717,37 +717,17 @@ bool Power::setup() found = true; #endif } -#ifdef EXT_PWR_DETECT - attachInterrupt( - EXT_PWR_DETECT, - []() { - power->setIntervalFromNow(0); - runASAP = true; - }, - CHANGE); -#endif -#ifdef BATTERY_CHARGING_INV - attachInterrupt( - BATTERY_CHARGING_INV, - []() { - power->setIntervalFromNow(0); - runASAP = true; - }, - CHANGE); -#endif -#ifdef EXT_CHRG_DETECT - attachInterrupt( - EXT_CHRG_DETECT, - []() { - power->setIntervalFromNow(0); - runASAP = true; - BaseType_t higherWake = 0; - }, - CHANGE); -#endif + attachPowerInterrupts(); enabled = found; low_voltage_counter = 0; +#ifdef ARCH_ESP32 + // Register callbacks for before and after lightsleep + // Used to detach and reattach interrupts + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif + return found; } @@ -1026,6 +1006,97 @@ int32_t Power::runOnce() return (statusHandler && statusHandler->isInitialized()) ? (1000 * 20) : RUN_SAME; } +#ifdef ARCH_ESP32 + +// Detach our class' interrupts before lightsleep +// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press +int Power::beforeLightSleep(void *unused) +{ + LOG_WARN("Detaching power interrupts for sleep"); + detachPowerInterrupts(); + return 0; // Indicates success +} + +// Reconfigure our interrupts +// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep +int Power::afterLightSleep(esp_sleep_wakeup_cause_t cause) +{ + attachPowerInterrupts(); + return 0; // Indicates success +} + +#endif + +/* + * Attach (or re-attach) hardware interrupts for power management + * Public method. Used outside class when waking from MCU sleep + */ +void Power::attachPowerInterrupts() +{ +#ifdef EXT_PWR_DETECT + attachInterrupt( + EXT_PWR_DETECT, + []() { + power->setIntervalFromNow(0); + runASAP = true; + }, + CHANGE); +#endif +#ifdef BATTERY_CHARGING_INV + attachInterrupt( + BATTERY_CHARGING_INV, + []() { + power->setIntervalFromNow(0); + runASAP = true; + }, + CHANGE); +#endif +#ifdef EXT_CHRG_DETECT + attachInterrupt( + EXT_CHRG_DETECT, + []() { + power->setIntervalFromNow(0); + runASAP = true; + BaseType_t higherWake = 0; + }, + CHANGE); +#endif +#ifdef PMU_IRQ + if (PMU) { + attachInterrupt( + PMU_IRQ, + [] { + pmu_irq = true; + power->setIntervalFromNow(0); + runASAP = true; + }, + FALLING); + } +#endif +} + +/* + * Detach the "normal" button interrupts. + * Public method. Used before attaching a "wake-on-button" interrupt for MCU sleep + */ +void Power::detachPowerInterrupts() +{ +#ifdef EXT_PWR_DETECT + detachInterrupt(EXT_PWR_DETECT); +#endif +#ifdef BATTERY_CHARGING_INV + detachInterrupt(BATTERY_CHARGING_INV); +#endif +#ifdef EXT_CHRG_DETECT + detachInterrupt(EXT_CHRG_DETECT); +#endif +#ifdef PMU_IRQ + if (PMU) { + detachInterrupt(PMU_IRQ); + } +#endif +} + /** * Init the power manager chip * @@ -1303,8 +1374,6 @@ bool Power::axpChipInit() } pinMode(PMU_IRQ, INPUT); - attachInterrupt( - PMU_IRQ, [] { pmu_irq = true; }, FALLING); // we do not look for AXPXXX_CHARGING_FINISHED_IRQ & AXPXXX_CHARGING_IRQ // because it occurs repeatedly while there is no battery also it could cause diff --git a/src/power.h b/src/power.h index b129e2b74cc..d819aeb47fe 100644 --- a/src/power.h +++ b/src/power.h @@ -81,7 +81,7 @@ extern RAK9154Sensor rak9154Sensor; extern XPowersLibInterface *PMU; #endif -class Power : private concurrency::OSThread +class Power : public concurrency::OSThread { public: @@ -96,6 +96,14 @@ class Power : private concurrency::OSThread void setStatusHandler(meshtastic::PowerStatus *handler) { statusHandler = handler; } const uint16_t OCV[11] = {OCV_ARRAY}; +#ifdef ARCH_ESP32 + int beforeLightSleep(void *unused); + int afterLightSleep(esp_sleep_wakeup_cause_t cause); +#endif + + void attachPowerInterrupts(); + void detachPowerInterrupts(); + protected: meshtastic::PowerStatus *statusHandler; @@ -120,6 +128,14 @@ class Power : private concurrency::OSThread // open circuit voltage lookup table uint8_t low_voltage_counter; uint32_t lastLogTime = 0; + +#ifdef ARCH_ESP32 + // Get notified when lightsleep begins and ends + CallbackObserver lsObserver = CallbackObserver(this, &Power::beforeLightSleep); + CallbackObserver lsEndObserver = + CallbackObserver(this, &Power::afterLightSleep); +#endif + #ifdef DEBUG_HEAP uint32_t lastheap; #endif diff --git a/src/sleep.cpp b/src/sleep.cpp index 9c044eaf7ac..64bd0c48033 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -497,13 +497,12 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); notifyLightSleepEnd.notifyObservers(cause); // Button interrupts are reattached here -#ifdef BUTTON_PIN if (cause == ESP_SLEEP_WAKEUP_GPIO) { - LOG_INFO("Exit light sleep gpio: btn=%d", - !digitalRead(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN)); - } else -#endif - { + LOG_INFO("Exit light sleep gpio"); + // If we woke because of a GPIO, it's possible power needs to run to handle. + power->setIntervalFromNow(0); + runASAP = true; + } else { LOG_INFO("Exit light sleep cause: %d", cause); } From 9306e6606794578f9dbb3bbad8588ef6c3148b00 Mon Sep 17 00:00:00 2001 From: George <509474+giannoug@users.noreply.github.com> Date: Sat, 25 Apr 2026 04:20:39 +0300 Subject: [PATCH 078/225] fix(inkhud): scale MapApplet markers with fontSmall line height (#10288) Marker boxes, the own-node bullseye, and the labeled-marker cross were all hardcoded in pixels (11px box, r=8 circle, 12px cross). On the T5S3 with a 12pt fontSmall (~17px line height) the hop-count digit overflowed its box entirely. Sizes now derive from fontSmall.lineHeight() so the applet renders correctly on both small (6pt) and large (12pt+) display variants. Co-authored-by: Claude Sonnet 4.6 --- .../InkHUD/Applets/Bases/Map/MapApplet.cpp | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp index 06ddd5bb007..63ccaa2163c 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp @@ -43,8 +43,8 @@ void InkHUD::MapApplet::onRender(bool full) // Add white halo outline first constexpr int outlinePad = 1; - int boxSize = 11; - int radius = 2; // rounded corner radius + int boxSize = fontSmall.lineHeight() + 2; // scale with font so digit fits + int radius = max(2, boxSize / 6); // White halo background fillRoundedRect(x, y, boxSize + (outlinePad * 2), boxSize + (outlinePad * 2), radius + 1, WHITE); @@ -143,17 +143,19 @@ void InkHUD::MapApplet::onRender(bool full) int16_t centerX = X(0.5) + (self.eastMeters * metersToPx); int16_t centerY = Y(0.5) - (self.northMeters * metersToPx); + int16_t r = fontSmall.lineHeight() / 2; // scale marker with font + // White fill background + halo - fillCircle(centerX, centerY, 8, WHITE); // big white base - drawCircle(centerX, centerY, 8, WHITE); // crisp edge + fillCircle(centerX, centerY, r + 2, WHITE); + drawCircle(centerX, centerY, r + 2, WHITE); // Black bullseye on top - drawCircle(centerX, centerY, 6, BLACK); - fillCircle(centerX, centerY, 2, BLACK); + drawCircle(centerX, centerY, r, BLACK); + fillCircle(centerX, centerY, max(2, r / 4), BLACK); // Crosshairs - drawLine(centerX - 8, centerY, centerX + 8, centerY, BLACK); - drawLine(centerX, centerY - 8, centerX, centerY + 8, BLACK); + drawLine(centerX - r - 2, centerY, centerX + r + 2, centerY, BLACK); + drawLine(centerX, centerY - r - 2, centerX, centerY + r + 2, BLACK); } } @@ -382,9 +384,9 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node) constexpr uint16_t paddingH = 2; constexpr uint16_t paddingW = 4; - uint16_t paddingInnerW = 2; // Zero'd out if no text - constexpr uint16_t markerSizeMax = 12; // Size of cross (if marker uses a cross) - constexpr uint16_t markerSizeMin = 5; + uint16_t paddingInnerW = 2; // Zero'd out if no text + uint16_t markerSizeMax = fontSmall.lineHeight(); // Scale cross with font + uint16_t markerSizeMin = max(5, fontSmall.lineHeight() / 3); int16_t textX; int16_t textY; From 7421953e8f54081a163351be7c11676298a72cc0 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Sat, 25 Apr 2026 06:22:24 -0400 Subject: [PATCH 079/225] InkHUD: Add full touch support to T5s3 (#10286) * InkHUD touch rework * Applet Switcher * Update ED047TC1.cpp * trunk fix * Custom tip screen for T5s3 * Update TouchScreenImpl1.cpp * Update ED047TC1.cpp * Delete variant.cpp --- src/PowerFSM.cpp | 59 +- src/graphics/niche/Drivers/EInk/ED047TC1.cpp | 151 ++++- src/graphics/niche/InkHUD/Applet.h | 9 + .../System/AppSwitcher/AppSwitcherApplet.cpp | 545 ++++++++++++++++ .../System/AppSwitcher/AppSwitcherApplet.h | 51 ++ .../System/Keyboard/KeyboardApplet.cpp | 599 ++++++++++++------ .../Applets/System/Keyboard/KeyboardApplet.h | 114 +++- .../InkHUD/Applets/System/Menu/MenuAction.h | 3 +- .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 349 +++++++++- .../InkHUD/Applets/System/Menu/MenuApplet.h | 22 +- .../InkHUD/Applets/System/Menu/MenuPage.h | 3 +- .../System/Notification/TouchStatusApplet.cpp | 30 + .../System/Notification/TouchStatusApplet.h | 29 + .../InkHUD/Applets/System/Tips/TipsApplet.cpp | 57 +- .../InkHUD/Applets/System/Tips/TipsApplet.h | 5 +- src/graphics/niche/InkHUD/Events.cpp | 406 +++++++----- src/graphics/niche/InkHUD/Events.h | 23 +- src/graphics/niche/InkHUD/InkHUD.cpp | 122 +++- src/graphics/niche/InkHUD/InkHUD.h | 17 + src/graphics/niche/InkHUD/Persistence.h | 2 +- src/graphics/niche/InkHUD/WindowManager.cpp | 113 +++- src/graphics/niche/InkHUD/WindowManager.h | 5 +- src/input/TouchScreenBase.cpp | 57 +- src/input/TouchScreenBase.h | 3 + src/input/TouchScreenImpl1.cpp | 33 +- src/input/TouchScreenImpl1.h | 2 + .../extra_variants/t5s3_epaper/variant.cpp | 144 ----- src/sleep.cpp | 18 +- variants/esp32s3/t5s3_epaper/nicheGraphics.h | 25 +- variants/esp32s3/t5s3_epaper/variant.cpp | 582 ++++++++++++++++- variants/esp32s3/t5s3_epaper/variant.h | 33 +- 31 files changed, 3020 insertions(+), 591 deletions(-) create mode 100644 src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp create mode 100644 src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.h create mode 100644 src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.cpp create mode 100644 src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.h delete mode 100644 src/platform/extra_variants/t5s3_epaper/variant.cpp diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index b11f37cf0a5..a1610109c99 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -58,6 +58,35 @@ static bool isPowered() return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB()); } +#if defined(T5_S3_EPAPER_PRO) +static void t5BacklightOffForSleep() +{ + t5BacklightSetForcedBySleep(true); +} + +static void t5BacklightWakeFromSleep() +{ + t5BacklightSetForcedBySleep(false); +} + +static void t5BacklightOffForTimeout() +{ + t5BacklightSetForcedByTimeout(true); + t5TouchSetForcedByTimeout(true); +} + +static void t5BacklightOnFromUserInput() +{ + t5BacklightHandleUserInput(); + t5TouchHandleUserInput(); +} +#else +static void t5BacklightOffForSleep() {} +static void t5BacklightWakeFromSleep() {} +static void t5BacklightOffForTimeout() {} +static void t5BacklightOnFromUserInput() {} +#endif + static void sdsEnter() { LOG_POWERFSM("State: SDS"); @@ -87,6 +116,7 @@ static void lsEnter() LOG_POWERFSM("lsEnter begin, ls_secs=%u", config.power.ls_secs); if (screen) screen->setOn(false); + t5BacklightOffForSleep(); secsSlept = 0; // How long have we been sleeping this time // LOG_INFO("lsEnter end"); @@ -159,6 +189,8 @@ static void lsIdle() static void lsExit() { LOG_POWERFSM("State: lsExit"); + // Lift the light-sleep force-off gate when leaving LS. + t5BacklightWakeFromSleep(); } static void nbEnter() @@ -180,6 +212,8 @@ static void darkEnter() setBluetoothEnable(true); if (screen) screen->setOn(false); + // Screen timeout enters DARK; ensure backlight also turns off. + t5BacklightOffForTimeout(); } static void serialEnter() @@ -289,12 +323,13 @@ void PowerFSM_setup() powerFSM.add_transition(&stateNB, &stateNB, EVENT_PACKET_FOR_PHONE, NULL, "Received packet, resetting win wake"); // Handle press events - note: we ignore button presses when in API mode - powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, NULL, "Press"); - powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, NULL, "Press"); - powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, NULL, "Press"); - powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, NULL, "Press"); - powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, NULL, "Press"); // reenter On to restart our timers - powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, NULL, + powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press"); + powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press"); + powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press"); + powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, t5BacklightOnFromUserInput, "Press"); + powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, + "Press"); // reenter On to restart our timers + powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, t5BacklightOnFromUserInput, "Press"); // Allow button to work while in serial API // Handle critically low power battery by forcing deep sleep @@ -314,11 +349,13 @@ void PowerFSM_setup() powerFSM.add_transition(&stateSERIAL, &stateSHUTDOWN, EVENT_SHUTDOWN, NULL, "Shutdown"); // Inputbroker - powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, NULL, "Input Device"); - powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, NULL, "Input Device"); - powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, NULL, "Input Device"); - powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer - powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer + powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device"); + powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device"); + powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device"); + powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, + "Input Device"); // restarts the sleep timer + powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, t5BacklightOnFromUserInput, + "Input Device"); // restarts the sleep timer powerFSM.add_transition(&stateDARK, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing"); powerFSM.add_transition(&stateON, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing"); diff --git a/src/graphics/niche/Drivers/EInk/ED047TC1.cpp b/src/graphics/niche/Drivers/EInk/ED047TC1.cpp index f1189045b0a..2e283737c8f 100644 --- a/src/graphics/niche/Drivers/EInk/ED047TC1.cpp +++ b/src/graphics/niche/Drivers/EInk/ED047TC1.cpp @@ -25,6 +25,116 @@ using namespace NicheGraphics::Drivers; +#if defined(T5_S3_EPAPER_PRO_V2) +// FastEPD helper symbols are defined in FastEPD.inl with C++ linkage. +extern void bbepPCA9535DigitalWrite(uint8_t pin, uint8_t value); +extern uint8_t bbepPCA9535DigitalRead(uint8_t pin); +extern int bbepI2CWrite(unsigned char iAddr, unsigned char *pData, int iLen); +extern int bbepI2CReadRegister(unsigned char iAddr, unsigned char u8Register, unsigned char *pData, int iLen); +#endif + +namespace +{ +#if defined(T5_S3_EPAPER_PRO_V2) +// FastEPD default V2 power callback blocks forever waiting for PWRGOOD. +// Replace it with a timeout-safe version so boot never deadlocks. +int safeEPDiyV7EinkPower(void *pBBEP, int bOn) +{ + static bool warnedPgood = false; + static bool warnedTpsPg = false; + static bool warnedTpsWrite = false; + + FASTEPDSTATE *pState = static_cast(pBBEP); + if (!pState) { + return BBEP_ERROR_BAD_PARAMETER; + } + + if (bOn == pState->pwr_on) { + return BBEP_SUCCESS; + } + + if (bOn) { + bbepPCA9535DigitalWrite(8, 1); // OE on + bbepPCA9535DigitalWrite(9, 1); // GMOD on + bbepPCA9535DigitalWrite(13, 1); // WAKEUP on + bbepPCA9535DigitalWrite(11, 1); // PWRUP on + bbepPCA9535DigitalWrite(12, 1); // VCOM CTRL on + delay(1); + + const uint32_t pgoodStart = millis(); + bool pgoodSeen = false; + while (!bbepPCA9535DigitalRead(14)) { // CFG_PIN_PWRGOOD + if ((millis() - pgoodStart) > 1200) { + if (!warnedPgood) { + LOG_WARN("ED047TC1: PWRGOOD timeout, continuing with fallback power-on path"); + warnedPgood = true; + } + break; + } + delay(1); + } + if (bbepPCA9535DigitalRead(14)) { + pgoodSeen = true; + } + + uint8_t ucTemp[4] = {0}; + ucTemp[0] = 0x01; // TPS_REG_ENABLE + ucTemp[1] = 0x3f; // enable rails + const int tpsEnableRc = bbepI2CWrite(0x68, ucTemp, 2); + + const int vcom = pState->iVCOM / -10; + ucTemp[0] = 3; // VCOM registers 3+4 (L + H) + ucTemp[1] = static_cast(vcom); + ucTemp[2] = static_cast(vcom >> 8); + const int tpsVcomRc = bbepI2CWrite(0x68, ucTemp, 3); + if ((tpsEnableRc == 0 || tpsVcomRc == 0) && !warnedTpsWrite) { + LOG_WARN("ED047TC1: TPS write did not ACK, continuing with fallback"); + warnedTpsWrite = true; + } + + int iTimeout = 0; + uint8_t u8Value = 0; + while (iTimeout < 220 && ((u8Value & 0xfa) != 0xfa)) { + bbepI2CReadRegister(0x68, 0x0F, &u8Value, 1); // TPS_REG_PG + iTimeout++; + delay(1); + } + if (iTimeout >= 220 && !warnedTpsPg) { + if (pgoodSeen) { + LOG_WARN("ED047TC1: TPS power-good register timeout, panel may still work"); + } else { + LOG_WARN("ED047TC1: TPS power-good register timeout after PWRGOOD fallback"); + } + warnedTpsPg = true; + } + + pState->pwr_on = 1; + } else { + bbepPCA9535DigitalWrite(8, 0); // OE off + bbepPCA9535DigitalWrite(9, 0); // GMOD off + bbepPCA9535DigitalWrite(11, 0); // PWRUP off + bbepPCA9535DigitalWrite(12, 0); // VCOM CTRL off + delay(1); + bbepPCA9535DigitalWrite(13, 0); // WAKEUP off + pState->pwr_on = 0; + } + + return BBEP_SUCCESS; +} +#endif + +class SafeFastEPD : public FASTEPD +{ + public: + void installSafePowerHandler() + { +#if defined(T5_S3_EPAPER_PRO_V2) + _state.pfnEinkPower = safeEPDiyV7EinkPower; +#endif + } +}; +} // namespace + 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 @@ -34,24 +144,48 @@ void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_ (void)pin_busy; (void)pin_rst; - epaper = new FASTEPD; + SafeFastEPD *safeEpaper = new SafeFastEPD; + epaper = safeEpaper; + int initRc = BBEP_ERROR_BAD_PARAMETER; #if defined(T5_S3_EPAPER_PRO_V1) - epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000); + initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000); #elif defined(T5_S3_EPAPER_PRO_V2) - epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000); + initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000); // Initialize all PCA9535 port-0 pins as outputs / HIGH for (int i = 0; i < 8; i++) { epaper->ioPinMode(i, OUTPUT); epaper->ioWrite(i, HIGH); } + // On this board, the physical side key is labeled IO48; electrically it maps to PCA9535 IO12 (bit 2 on port-1). + // FastEPD's generic V7 init drives 8..13 as outputs; force IO12 back to input + // 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" #endif - epaper->setMode(BB_MODE_1BPP); - epaper->clearWhite(); - epaper->fullUpdate(true); // Blocking initial clear + if (initRc != BBEP_SUCCESS) { + LOG_ERROR("ED047TC1 initPanel failed rc=%d", initRc); + return; + } + + safeEpaper->installSafePowerHandler(); + + const int modeRc = epaper->setMode(BB_MODE_1BPP); + if (modeRc != BBEP_SUCCESS) { + LOG_WARN("ED047TC1 setMode failed rc=%d", modeRc); + } + + const int clearRc = epaper->clearWhite(); + if (clearRc != BBEP_SUCCESS) { + LOG_WARN("ED047TC1 clearWhite failed rc=%d", clearRc); + } + + const int fullRc = epaper->fullUpdate(true); // Blocking initial clear + if (fullRc != BBEP_SUCCESS) { + LOG_WARN("ED047TC1 initial fullUpdate failed rc=%d", fullRc); + } } void ED047TC1::update(uint8_t *imageData, UpdateTypes type) @@ -111,9 +245,8 @@ void ED047TC1::update(uint8_t *imageData, UpdateTypes type) epaper->fullUpdate(CLEAR_SLOW, false); epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline } else { - // FAST: true partial update — compares pCurrent vs pPrevious and only applies - // the update waveform to rows that actually changed. Unchanged rows get a neutral - // signal (no visible effect). partialUpdate() updates pPrevious internally. + // FAST: true partial update - compares pCurrent vs pPrevious and only applies + // update waveform to rows that changed. partialUpdate() updates pPrevious. epaper->partialUpdate(false, 0, dstTotalRows - 1); } } diff --git a/src/graphics/niche/InkHUD/Applet.h b/src/graphics/niche/InkHUD/Applet.h index 3c14c26077e..6b727f27352 100644 --- a/src/graphics/niche/InkHUD/Applet.h +++ b/src/graphics/niche/InkHUD/Applet.h @@ -104,6 +104,15 @@ class Applet : public GFX virtual void onFreeText(char c) {} virtual void onFreeTextDone() {} virtual void onFreeTextCancel() {} + // Absolute display-space touch point, for touch-friendly UI interactions. + // Return true if consumed. + virtual bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) + { + (void)x; + (void)y; + (void)longPress; + return false; + } // List of inputs which can be subscribed to enum InputMask { // | No Joystick | With Joystick | BUTTON_SHORT = 1, // | Button Click | Joystick Center Click | diff --git a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp new file mode 100644 index 00000000000..005327baeaf --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp @@ -0,0 +1,545 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./AppSwitcherApplet.h" + +#include "graphics/niche/InkHUD/InkHUD.h" +#include "graphics/niche/InkHUD/Tile.h" + +#include +#include + +using namespace NicheGraphics; + +namespace +{ +static constexpr uint16_t BODY_MARGIN_X = 8; +static constexpr uint16_t BODY_MARGIN_Y = 6; +static constexpr uint16_t SLOT_GAP_X = 8; +static constexpr uint16_t SLOT_GAP_Y = 8; +static constexpr uint8_t ICON_RADIUS = 8; +static constexpr uint16_t FOOTER_PAD = 4; +static constexpr uint16_t LABEL_BOTTOM_PAD = 1; +static constexpr uint16_t LABEL_GAP_Y = 1; +static constexpr uint16_t TITLE_H_PAD = 8; + +static constexpr uint8_t GRID_COLS = 3; +static constexpr uint8_t GRID_ROWS = 4; +static constexpr uint8_t ICON_NATIVE_SIZE = 48; +static constexpr uint8_t ICON_OUTLINE_STROKE = 1; + +enum class IconKind : uint8_t { GENERIC, ALL_MESSAGES, DMS, CHANNEL, POSITIONS, RECENTS, HEARD, FAVORITES }; + +struct GridLayout { + uint16_t footerH = 0; + uint16_t bodyTop = 0; + uint16_t bodyBottom = 0; + uint16_t slotW = 0; + uint16_t slotH = 0; + uint16_t iconBox = 0; +}; + +/* + * Icons sourced from Material Design Icons PNG set (Apache 2.0): + * https://github.com/material-icons/material-icons-png + * + * Families used: outline-2x (48x48) + * apps, markunread, chat, forum, place, history, hearing, star_border + */ +static constexpr uint64_t icon_generic_apps[48] = { + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, + 0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, + 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL, + 0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, + 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL, + 0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, + 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL, + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, +}; + +static constexpr uint64_t icon_all_messages[48] = { + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, + 0x000000000000ULL, 0x000000000000ULL, 0x07FFFFFFFFE0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, + 0x0FC0000003F0ULL, 0x0FE0000007F0ULL, 0x0FF800001FF0ULL, 0x0FFE00007FF0ULL, 0x0FFF0000FFF0ULL, 0x0F7FC003FEF0ULL, + 0x0F1FE007F8F0ULL, 0x0F07F81FE0F0ULL, 0x0F03FE7FC0F0ULL, 0x0F00FFFF00F0ULL, 0x0F007FFE00F0ULL, 0x0F001FF800F0ULL, + 0x0F0007E000F0ULL, 0x0F0003C000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, + 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, + 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x07FFFFFFFFE0ULL, 0x000000000000ULL, 0x000000000000ULL, + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, +}; + +static constexpr uint64_t icon_dms[48] = { + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x07FFFFFFFFE0ULL, 0x0FFFFFFFFFF0ULL, + 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, + 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, + 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, + 0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, + 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F7FFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFE0ULL, + 0x0FF000000000ULL, 0x0FE000000000ULL, 0x0FC000000000ULL, 0x0F8000000000ULL, 0x0F0000000000ULL, 0x0E0000000000ULL, + 0x0C0000000000ULL, 0x080000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, +}; + +static constexpr uint64_t icon_channel[48] = { + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x0FFFFFFFC000ULL, 0x0FFFFFFFC000ULL, + 0x0FFFFFFFC000ULL, 0x0FFFFFFFC000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL, + 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, + 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F7FFFFFC3F0ULL, 0x0FFFFFFFC3F0ULL, + 0x0FFFFFFFC3F0ULL, 0x0FFFFFFFC3F0ULL, 0x0FF0000003F0ULL, 0x0FE0000003F0ULL, 0x0FC0000003F0ULL, 0x0F80000003F0ULL, + 0x0F0FFFFFFFF0ULL, 0x0E0FFFFFFFF0ULL, 0x0C0FFFFFFFF0ULL, 0x080FFFFFFFF0ULL, 0x000FFFFFFFF0ULL, 0x000FFFFFFFF0ULL, + 0x000000000FF0ULL, 0x0000000007F0ULL, 0x0000000003F0ULL, 0x0000000001F0ULL, 0x0000000000F0ULL, 0x000000000070ULL, + 0x000000000030ULL, 0x000000000010ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, +}; + +static constexpr uint64_t icon_positions[48] = { + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x00001FF80000ULL, 0x00007FFE0000ULL, + 0x0001FFFF8000ULL, 0x0003FFFFC000ULL, 0x0007FFFFE000ULL, 0x000FF00FF000ULL, 0x000FC003F000ULL, 0x001F8001F800ULL, + 0x001F0000F800ULL, 0x003F07E0FC00ULL, 0x003E0FF07C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, + 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E0FF07C00ULL, 0x003E07E07C00ULL, 0x001F0000F800ULL, + 0x001F0000F800ULL, 0x001F8001F800ULL, 0x000F8001F000ULL, 0x000FC003F000ULL, 0x0007C003E000ULL, 0x0007E007E000ULL, + 0x0003F00FC000ULL, 0x0003F00FC000ULL, 0x0001F81F8000ULL, 0x0001FC3F8000ULL, 0x0000FC3F0000ULL, 0x00007E7E0000ULL, + 0x00003FFC0000ULL, 0x00003FFC0000ULL, 0x00001FF80000ULL, 0x00000FF00000ULL, 0x00000FF00000ULL, 0x000007E00000ULL, + 0x000003C00000ULL, 0x000001800000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, +}; + +static constexpr uint64_t icon_recents[48] = { + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, + 0x00000FFF0000ULL, 0x00003FFFC000ULL, 0x0000FFFFF000ULL, 0x0001FFFFF800ULL, 0x0007FFFFFE00ULL, 0x000FF801FF00ULL, + 0x000FE0007F00ULL, 0x001FC0003F80ULL, 0x003F00000FC0ULL, 0x003F00000FC0ULL, 0x007E00E007E0ULL, 0x007C00E003E0ULL, + 0x00FC00E003F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, + 0x3FFFC0F001F0ULL, 0x1FFF80FC01F0ULL, 0x0FFF00FF01F0ULL, 0x07FE003F81F0ULL, 0x03FC001FC1F0ULL, 0x01F80007C3F0ULL, + 0x00F0000183E0ULL, 0x0060000007E0ULL, 0x000000000FC0ULL, 0x000000000FC0ULL, 0x0001C0003F80ULL, 0x0003E0007F00ULL, + 0x0007F801FF00ULL, 0x0007FFFFFE00ULL, 0x0001FFFFF800ULL, 0x0000FFFFF000ULL, 0x00003FFFC000ULL, 0x00000FFF0000ULL, + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, +}; + +static constexpr uint64_t icon_heard[48] = { + 0x000000000000ULL, 0x000000000000ULL, 0x000800000000ULL, 0x001C00000000ULL, 0x003E01FF8000ULL, 0x007F07FFE000ULL, + 0x007E1FFFF800ULL, 0x00FC3FFFFC00ULL, 0x00F87FFFFE00ULL, 0x01F8FF00FF00ULL, 0x01F0FC003F00ULL, 0x01F1F8001F80ULL, + 0x03E1F0000F80ULL, 0x03E3F07E0FC0ULL, 0x03E3E0FF07C0ULL, 0x03E3E1FF87C0ULL, 0x03E3E1FF87C0ULL, 0x03E3E1FF87C0ULL, + 0x03E3E1FF8000ULL, 0x03E3E1FF8000ULL, 0x03E3E1FF8000ULL, 0x03E3E0FF0000ULL, 0x03E3F07E0000ULL, 0x03E1F0000000ULL, + 0x01F1F8000000ULL, 0x01F1F8000000ULL, 0x01F8FC000000ULL, 0x00F87E000000ULL, 0x00FC7F800000ULL, 0x007E3FC00000ULL, + 0x007F1FE00000ULL, 0x003E0FF00000ULL, 0x001C03F00000ULL, 0x000801F80000ULL, 0x000000F80000ULL, 0x000000FC0000ULL, + 0x0000007C07C0ULL, 0x0000007E07C0ULL, 0x0000003F0FC0ULL, 0x0000003FFFC0ULL, 0x0000001FFF80ULL, 0x0000000FFF00ULL, + 0x00000007FE00ULL, 0x00000003FC00ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, +}; + +static constexpr uint64_t icon_favorites[48] = { + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000001800000ULL, 0x000001800000ULL, + 0x000003C00000ULL, 0x000003C00000ULL, 0x000003C00000ULL, 0x000007E00000ULL, 0x000007E00000ULL, 0x00000FF00000ULL, + 0x00000FF00000ULL, 0x00001FF80000ULL, 0x00001FF80000ULL, 0x00001E780000ULL, 0x00003E7C0000ULL, 0x003FFC3FFC00ULL, + 0x0FFFFC3FFFF0ULL, 0x0FFFF81FFFF0ULL, 0x03FFF81FFFC0ULL, 0x01F800001F80ULL, 0x00FC00003F00ULL, 0x007E00007E00ULL, + 0x003F8001FC00ULL, 0x001FC003F800ULL, 0x000FE007F000ULL, 0x0003E007C000ULL, 0x0003E007C000ULL, 0x0003C003C000ULL, + 0x0003C003C000ULL, 0x0003C3C3C000ULL, 0x0007CFF3E000ULL, 0x00079FF9E000ULL, 0x0007FFFFE000ULL, 0x0007FE7FE000ULL, + 0x000FFC3FF000ULL, 0x000FF00FF000ULL, 0x000FC003F000ULL, 0x000F8001F000ULL, 0x001E00007000ULL, 0x001800001800ULL, + 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, +}; + +using IconBitmap = const uint64_t *; + +IconBitmap iconBitmapForKind(IconKind kind) +{ + switch (kind) { + case IconKind::ALL_MESSAGES: + return icon_all_messages; + case IconKind::DMS: + return icon_dms; + case IconKind::CHANNEL: + return icon_channel; + case IconKind::POSITIONS: + return icon_positions; + case IconKind::RECENTS: + return icon_recents; + case IconKind::HEARD: + return icon_heard; + case IconKind::FAVORITES: + return icon_favorites; + case IconKind::GENERIC: + default: + return icon_generic_apps; + } +} + +GridLayout computeLayout(const InkHUD::Applet *applet) +{ + GridLayout layout; + + const uint16_t w = applet->width(); + const uint16_t h = applet->height(); + + layout.footerH = InkHUD::Applet::fontSmall.lineHeight() + (FOOTER_PAD * 2); + layout.bodyTop = BODY_MARGIN_Y; + layout.bodyBottom = (h > (layout.footerH + BODY_MARGIN_Y)) ? (h - layout.footerH - BODY_MARGIN_Y) : layout.bodyTop; + + const uint16_t bodyW = (w > (BODY_MARGIN_X * 2)) ? (w - (BODY_MARGIN_X * 2)) : 1; + const uint16_t bodyH = (layout.bodyBottom > layout.bodyTop) ? (layout.bodyBottom - layout.bodyTop) : 1; + const uint16_t gapsX = SLOT_GAP_X * (GRID_COLS - 1); + const uint16_t gapsY = SLOT_GAP_Y * (GRID_ROWS - 1); + + layout.slotW = (bodyW > gapsX) ? ((bodyW - gapsX) / GRID_COLS) : 1; + layout.slotH = (bodyH > gapsY) ? ((bodyH - gapsY) / GRID_ROWS) : 1; + + const uint16_t maxIconW = (layout.slotW > 6) ? (layout.slotW - 6) : layout.slotW; + const uint16_t maxIconH = (layout.slotH > (InkHUD::Applet::fontSmall.lineHeight() + LABEL_GAP_Y + LABEL_BOTTOM_PAD + 6)) + ? (layout.slotH - InkHUD::Applet::fontSmall.lineHeight() - LABEL_GAP_Y - LABEL_BOTTOM_PAD - 6) + : layout.slotH / 2; + layout.iconBox = std::max(20, std::min(maxIconW, maxIconH)); + return layout; +} + +std::string lowercase(const char *name) +{ + if (!name) + return ""; + std::string out(name); + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + return out; +} + +IconKind iconKindForAppletName(const char *name) +{ + const std::string lower = lowercase(name); + if (lower.find("all message") != std::string::npos || lower.find("messages") != std::string::npos) + return IconKind::ALL_MESSAGES; + if (lower.find("dm") != std::string::npos) + return IconKind::DMS; + if (lower.find("channel") != std::string::npos) + return IconKind::CHANNEL; + if (lower.find("position") != std::string::npos) + return IconKind::POSITIONS; + if (lower.find("recent") != std::string::npos) + return IconKind::RECENTS; + if (lower.find("heard") != std::string::npos) + return IconKind::HEARD; + if (lower.find("favorite") != std::string::npos) + return IconKind::FAVORITES; + return IconKind::GENERIC; +} + +void drawIconBitmapScaled(InkHUD::Applet *applet, IconBitmap bmp48, int16_t left, int16_t top, uint16_t boxSize, uint16_t color) +{ + if (!bmp48 || boxSize == 0) + return; + + auto srcOn = [bmp48](int16_t sx, int16_t sy) -> bool { + if (sx < 0 || sy < 0 || sx >= ICON_NATIVE_SIZE || sy >= ICON_NATIVE_SIZE) + return false; + const uint64_t rowBits = bmp48[sy]; + return (rowBits & (1ULL << (47 - sx))) != 0; + }; + + for (uint16_t y = 0; y < boxSize; y++) { + const uint8_t srcY = (uint8_t)((y * ICON_NATIVE_SIZE) / boxSize); + for (uint16_t x = 0; x < boxSize; x++) { + const uint8_t srcX = (uint8_t)((x * ICON_NATIVE_SIZE) / boxSize); + if (!srcOn(srcX, srcY)) + continue; + + const uint16_t w = std::min(ICON_OUTLINE_STROKE, boxSize - x); + const uint16_t h = std::min(ICON_OUTLINE_STROKE, boxSize - y); + applet->fillRect(left + x, top + y, w, h, color); + } + } +} +} // namespace + +InkHUD::AppSwitcherApplet::AppSwitcherApplet() +{ + alwaysRender = true; +} + +void InkHUD::AppSwitcherApplet::rebuildActiveAppletList() +{ + activeAppletIndices.clear(); + + const auto &settings = inkhud->persistence->settings; + const uint8_t tileCount = std::min(settings.userTiles.count, Persistence::MAX_TILES_GLOBAL); + const uint8_t focusedTile = (tileCount > 0) ? std::min(settings.userTiles.focused, tileCount - 1) : 0; + + // Applets displayed on other tiles should not be selectable here. + std::vector occupiedOnOtherTiles(inkhud->userApplets.size(), false); + for (uint8_t tile = 0; tile < tileCount; tile++) { + if (tile == focusedTile) + continue; + + const uint8_t appletIndex = settings.userTiles.displayedUserApplet[tile]; + if (appletIndex < occupiedOnOtherTiles.size()) + occupiedOnOtherTiles[appletIndex] = true; + } + + for (uint8_t i = 0; i < inkhud->userApplets.size(); i++) { + Applet *a = inkhud->userApplets.at(i); + if (a && a->isActive() && !occupiedOnOtherTiles[i]) + activeAppletIndices.push_back(i); + } +} + +uint8_t InkHUD::AppSwitcherApplet::cardsPerPage() const +{ + return GRID_COLS * GRID_ROWS; +} + +uint8_t InkHUD::AppSwitcherApplet::currentPage() const +{ + const uint8_t cpp = cardsPerPage(); + if (cpp == 0) + return 0; + return selectedIndex / cpp; +} + +void InkHUD::AppSwitcherApplet::stepPage(int8_t delta) +{ + if (activeAppletIndices.empty()) + return; + + const uint8_t cpp = cardsPerPage(); + const uint8_t pageCount = std::max(1, (activeAppletIndices.size() + cpp - 1) / cpp); + int16_t nextPage = (int16_t)currentPage() + delta; + while (nextPage < 0) + nextPage += pageCount; + while (nextPage >= pageCount) + nextPage -= pageCount; + + selectedIndex = std::min((uint8_t)(nextPage * cpp), activeAppletIndices.size() - 1); + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +void InkHUD::AppSwitcherApplet::clampSelection() +{ + if (activeAppletIndices.empty()) { + selectedIndex = 0; + return; + } + + if (selectedIndex >= activeAppletIndices.size()) + selectedIndex = activeAppletIndices.size() - 1; +} + +void InkHUD::AppSwitcherApplet::activateSelectedApplet() +{ + if (activeAppletIndices.empty()) { + sendToBackground(); + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + return; + } + + const uint8_t appletIndex = activeAppletIndices.at(selectedIndex); + + sendToBackground(); + inkhud->showApplet(appletIndex); +} + +void InkHUD::AppSwitcherApplet::onForeground() +{ + rebuildActiveAppletList(); + clampSelection(); + handleInput = true; + lockRequests = true; + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +void InkHUD::AppSwitcherApplet::onBackground() +{ + handleInput = false; + lockRequests = false; + + if (borrowedTileOwner) + borrowedTileOwner->bringToForeground(); + + Tile *t = getTile(); + if (t) + t->assignApplet(borrowedTileOwner); + borrowedTileOwner = nullptr; +} + +void InkHUD::AppSwitcherApplet::show(Tile *t) +{ + if (!t) + return; + + borrowedTileOwner = t->getAssignedApplet(); + if (borrowedTileOwner) + borrowedTileOwner->sendToBackground(); + + t->assignApplet(this); + bringToForeground(); +} + +void InkHUD::AppSwitcherApplet::onRender(bool full) +{ + (void)full; + + const GridLayout layout = computeLayout(this); + const uint8_t cpp = cardsPerPage(); + const uint8_t page = currentPage(); + const uint8_t pageStart = page * cpp; + + setFont(fontMedium); + setTextColor(BLACK); + + fillRect(0, 0, width(), height(), WHITE); + drawRect(0, 0, width(), height(), BLACK); + + if (activeAppletIndices.empty()) { + setFont(fontSmall); + printAt(width() / 2, height() / 2, "No Available Applets", CENTER, MIDDLE); + return; + } + + for (uint8_t i = 0; i < cpp; i++) { + const uint8_t idx = pageStart + i; + if (idx >= activeAppletIndices.size()) + break; + + const uint8_t row = i / GRID_COLS; + const uint8_t col = i % GRID_COLS; + const int16_t slotL = BODY_MARGIN_X + (col * (layout.slotW + SLOT_GAP_X)); + const int16_t slotT = layout.bodyTop + (row * (layout.slotH + SLOT_GAP_Y)); + const bool selected = (idx == selectedIndex); + + const uint8_t appletIndex = activeAppletIndices.at(idx); + Applet *a = inkhud->userApplets.at(appletIndex); + if (!a) + continue; + + const int16_t iconLeft = slotL + ((layout.slotW - layout.iconBox) / 2); + const int16_t iconTop = slotT + 1; + + // Requested style: icon in outlined rounded square only (no filled box, no outer app card). + drawRoundRect(iconLeft, iconTop, layout.iconBox, layout.iconBox, ICON_RADIUS, BLACK); + if (selected) + drawRoundRect(iconLeft + 2, iconTop + 2, layout.iconBox - 4, layout.iconBox - 4, ICON_RADIUS, BLACK); + + const IconBitmap bmp = iconBitmapForKind(iconKindForAppletName(a->name)); + drawIconBitmapScaled(this, bmp, iconLeft + 3, iconTop + 3, layout.iconBox - 6, BLACK); + + setFont(fontSmall); + std::string label = a->name ? a->name : "Applet"; + const uint16_t maxLabelW = layout.slotW > 4 ? (layout.slotW - 4) : layout.slotW; + if (getTextWidth(label) > maxLabelW) { + while (!label.empty() && getTextWidth(label + "...") > maxLabelW) + label.pop_back(); + label = label.empty() ? "..." : label + "..."; + } + const int16_t labelY = iconTop + layout.iconBox + LABEL_GAP_Y; + setTextColor(BLACK); + printAt(slotL + (layout.slotW / 2), labelY, label.c_str(), CENTER, TOP); + + if (a->isForeground()) + fillCircle(iconLeft + layout.iconBox - 4, iconTop + 4, 2, BLACK); + } + + const uint8_t pageCount = std::max(1, (activeAppletIndices.size() + cpp - 1) / cpp); + if (pageCount > 1) { + setFont(fontSmall); + setTextColor(BLACK); + const int16_t footerY = height() - layout.footerH + FOOTER_PAD; + printAt(TITLE_H_PAD, footerY, "<", LEFT, TOP); + printAt(width() - TITLE_H_PAD, footerY, ">", RIGHT, TOP); + const std::string pageText = std::to_string(page + 1) + "/" + std::to_string(pageCount); + printAt(width() / 2, footerY, pageText.c_str(), CENTER, TOP); + } +} + +bool InkHUD::AppSwitcherApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress) +{ + (void)longPress; + + Tile *t = getTile(); + if (!t || activeAppletIndices.empty()) + return true; + + const uint16_t tileL = t->getLeft(); + const uint16_t tileT = t->getTop(); + const uint16_t tileR = tileL + t->getWidth(); + const uint16_t tileB = tileT + t->getHeight(); + if (x < tileL || x >= tileR || y < tileT || y >= tileB) + return false; + + const GridLayout layout = computeLayout(this); + const uint8_t cpp = cardsPerPage(); + const uint8_t page = currentPage(); + const uint8_t pageStart = page * cpp; + const int16_t localX = (int16_t)x - (int16_t)tileL; + const int16_t localY = (int16_t)y - (int16_t)tileT; + + for (uint8_t i = 0; i < cpp; i++) { + const uint8_t idx = pageStart + i; + if (idx >= activeAppletIndices.size()) + break; + + const uint8_t row = i / GRID_COLS; + const uint8_t col = i % GRID_COLS; + const int16_t slotL = BODY_MARGIN_X + (col * (layout.slotW + SLOT_GAP_X)); + const int16_t slotT = layout.bodyTop + (row * (layout.slotH + SLOT_GAP_Y)); + + if (localX < slotL || localX >= (slotL + (int16_t)layout.slotW)) + continue; + if (localY < slotT || localY >= (slotT + (int16_t)layout.slotH)) + continue; + + selectedIndex = idx; + clampSelection(); + activateSelectedApplet(); + return true; + } + + const uint8_t pageCount = std::max(1, (activeAppletIndices.size() + cpp - 1) / cpp); + if (pageCount <= 1) + return true; + + const int16_t footerTop = height() - layout.footerH; + if (localY >= footerTop) { + if (localX < (int16_t)(width() / 3)) + stepPage(-1); + else if (localX >= (int16_t)((width() * 2) / 3)) + stepPage(1); + } + + return true; +} + +void InkHUD::AppSwitcherApplet::onButtonShortPress() +{ + if (activeAppletIndices.empty()) + return; + + selectedIndex = (selectedIndex + 1) % activeAppletIndices.size(); + clampSelection(); + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +void InkHUD::AppSwitcherApplet::onButtonLongPress() +{ + activateSelectedApplet(); +} + +void InkHUD::AppSwitcherApplet::onExitShort() +{ + sendToBackground(); + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +void InkHUD::AppSwitcherApplet::onNavUp() +{ + if (activeAppletIndices.empty()) + return; + + if (selectedIndex == 0) + selectedIndex = activeAppletIndices.size() - 1; + else + selectedIndex--; + + clampSelection(); + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +void InkHUD::AppSwitcherApplet::onNavDown() +{ + if (activeAppletIndices.empty()) + return; + + selectedIndex = (selectedIndex + 1) % activeAppletIndices.size(); + clampSelection(); + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.h b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.h new file mode 100644 index 00000000000..01ea561ae5a --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.h @@ -0,0 +1,51 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/SystemApplet.h" + +#include + +namespace NicheGraphics::InkHUD +{ + +class Tile; + +class AppSwitcherApplet : public SystemApplet +{ + public: + AppSwitcherApplet(); + + void onForeground() override; + void onBackground() override; + void onRender(bool full) override; + + void onButtonShortPress() override; + void onButtonLongPress() override; + void onExitShort() override; + void onNavUp() override; + void onNavDown() override; + bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override; + + // Open the app switcher on a user tile and temporarily replace the tile's owner. + void show(Tile *t); + + private: + void rebuildActiveAppletList(); + void clampSelection(); + uint8_t cardsPerPage() const; + uint8_t currentPage() const; + void stepPage(int8_t delta); + void activateSelectedApplet(); + + std::vector activeAppletIndices; + uint8_t selectedIndex = 0; + + Applet *borrowedTileOwner = nullptr; +}; + +} // namespace NicheGraphics::InkHUD + +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.cpp index 57581d56b6e..cb2b59953b8 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.cpp @@ -1,155 +1,100 @@ #ifdef MESHTASTIC_INCLUDE_INKHUD #include "./KeyboardApplet.h" +#include + using namespace NicheGraphics; +namespace +{ +bool usePortraitKeyboardSizing() +{ + InkHUD::InkHUD *inkhud = InkHUD::InkHUD::getInstance(); + return inkhud && inkhud->height() > inkhud->width(); +} +} // namespace + InkHUD::KeyboardApplet::KeyboardApplet() { - // Calculate row widths - for (uint8_t row = 0; row < KBD_ROWS; row++) { - rowWidths[row] = 0; - for (uint8_t col = 0; col < KBD_COLS; col++) - rowWidths[row] += keyWidths[row * KBD_COLS + col]; - } + mode = MODE_TEXT; + lastTypingMode = MODE_TEXT; + emotePage = 0; + selectedKey = 0; + prevSelectedKey = 0; + normalizeSelection(); } void InkHUD::KeyboardApplet::onRender(bool full) { - uint16_t em = fontSmall.lineHeight(); // 16 pt - uint16_t keyH = Y(1.0) / KBD_ROWS; - int16_t keyTopPadding = (keyH - fontSmall.lineHeight()) / 2; - - if (full) { // Draw full keyboard - for (uint8_t row = 0; row < KBD_ROWS; row++) { - - // Calculate the remaining space to be used as padding - int16_t keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4); - - // Draw keys - 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 = (keyWidths[index] * em) >> 4; - if (index == selectedKey) { - fgcolor = WHITE; - fillRect(keyX, keyY, keyW, keyH, BLACK); - } - drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[index], fgcolor); - xPos += keyWidths[index]; - } - } - } else { // Only draw the difference - if (selectedKey != prevSelectedKey) { - // Draw previously selected key - uint8_t row = prevSelectedKey / KBD_COLS; - int16_t keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4); - uint16_t xPos = 0; - for (uint8_t i = prevSelectedKey - (prevSelectedKey % KBD_COLS); i < prevSelectedKey; i++) - xPos += keyWidths[i]; - uint16_t keyX = ((xPos * em) >> 4) + (((prevSelectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1)); - uint16_t keyY = row * keyH; - uint16_t keyW = (keyWidths[prevSelectedKey] * em) >> 4; - fillRect(keyX, keyY, keyW, keyH, WHITE); - drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[prevSelectedKey], BLACK); - - // Draw newly selected key - row = selectedKey / KBD_COLS; - keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4); - xPos = 0; - for (uint8_t i = selectedKey - (selectedKey % KBD_COLS); i < selectedKey; i++) - xPos += keyWidths[i]; - keyX = ((xPos * em) >> 4) + (((selectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1)); - keyY = row * keyH; - keyW = (keyWidths[selectedKey] * em) >> 4; - fillRect(keyX, keyY, keyW, keyH, BLACK); - drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[selectedKey], WHITE); - } + const bool showSelection = showSelectionHighlight(); + + if (full) { + for (uint8_t i = 0; i < KBD_KEY_COUNT; i++) + drawKey(i, showSelection && i == selectedKey); + } else if (showSelection && selectedKey != prevSelectedKey) { + drawKey(prevSelectedKey, false); + drawKey(selectedKey, true); } prevSelectedKey = selectedKey; } -// Draw the key label corresponding to the char -// for most keys it draws the character itself -// for ['\b', '\n', ' ', '\x1b'] it draws special glyphs -void InkHUD::KeyboardApplet::drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color) -{ - if (key == '\b') { - // Draw backspace glyph: 13 x 9 px - /** - * [][][][][][][][][] - * [][] [] - * [][] [] [] [] - * [][] [] [] [] - * [][] [] [] - * [][] [] [] [] - * [][] [] [] [] - * [][] [] - * [][][][][][][][][] - */ - 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') { - // Draw done glyph: 12 x 9 px - /** - * [][] - * [][] - * [][] - * [][] - * [][] - * [][] [][] - * [][] [][] - * [][][] - * [] - */ - 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 == ' ') { - // Draw space glyph: 13 x 9 px - /** - * - * - * - * - * [] [] - * [] [] - * [][][][][][][][][][][][][] - * - * - */ - 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; // capitalize - 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; + uint16_t keyY = 0; + uint16_t keyW = 0; + uint16_t keyH = 0; + if (!getKeyBounds(index, keyX, keyY, keyW, keyH)) + return; + if (keyW == 0 || keyH == 0) + return; + + // Translate absolute tile coordinates into applet-local coordinates. + const int16_t localX = keyX - getTile()->getLeft(); + const int16_t localY = keyY - getTile()->getTop(); + const bool enabled = isKeyEnabledAt(index); + + // Clean background first so hidden keys never leave stale pixels when mode changes. + fillRect(localX, localY, keyW, keyH, WHITE); + + if (!enabled) + return; + + fillRoundRect(localX, localY, keyW, keyH, KEY_RADIUS, selected ? BLACK : WHITE); + drawRoundRect(localX, localY, keyW, keyH, KEY_RADIUS, BLACK); + + const int16_t labelTop = localY + ((keyH - fontSmall.lineHeight()) / 2); + drawKeyLabel(localX, labelTop, keyW, getKeyLabelAt(index), selected ? WHITE : BLACK); +} + +void InkHUD::KeyboardApplet::drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, const std::string &label, Color color) +{ + if (label.empty()) + return; + + setTextColor(color); + uint16_t textW = getTextWidth(label); + if (textW > width) { + // Keep labels readable in narrow keys. + textW = getTextWidth(".."); + printAt(left + ((width - textW) >> 1), top, ".."); + return; } + + uint16_t leftPadding = (width - textW) >> 1; + printAt(left + leftPadding, top, label); } void InkHUD::KeyboardApplet::onForeground() { - handleInput = true; // Intercept the button input for our applet - - // Select the first key + handleInput = true; + mode = MODE_TEXT; + lastTypingMode = MODE_TEXT; + emotePage = 0; selectedKey = 0; prevSelectedKey = 0; + normalizeSelection(); } void InkHUD::KeyboardApplet::onBackground() @@ -159,32 +104,12 @@ void InkHUD::KeyboardApplet::onBackground() void InkHUD::KeyboardApplet::onButtonShortPress() { - char key = keys[selectedKey]; - if (key == '\n') { - inkhud->freeTextDone(); - inkhud->closeKeyboard(); - } else if (key == '\x1b') { - inkhud->freeTextCancel(); - inkhud->closeKeyboard(); - } else { - inkhud->freeText(key); - } + inputSelectedKey(false); } void InkHUD::KeyboardApplet::onButtonLongPress() { - char key = keys[selectedKey]; - if (key == '\n') { - inkhud->freeTextDone(); - inkhud->closeKeyboard(); - } else if (key == '\x1b') { - inkhud->freeTextCancel(); - inkhud->closeKeyboard(); - } else { - if (key >= 0x61) - key -= 32; // capitalize - inkhud->freeText(key); - } + inputSelectedKey(true); } void InkHUD::KeyboardApplet::onExitShort() @@ -201,57 +126,377 @@ void InkHUD::KeyboardApplet::onExitLong() void InkHUD::KeyboardApplet::onNavUp() { - if (selectedKey < KBD_COLS) // wrap + if (selectedKey < KBD_COLS) selectedKey += KBD_COLS * (KBD_ROWS - 1); - else // move 1 row back + else selectedKey -= KBD_COLS; - // Request rendering over the previously drawn render - requestUpdate(EInk::UpdateTypes::FAST, false); - // Force an update to bypass lockRequests - inkhud->forceUpdate(EInk::UpdateTypes::FAST); + normalizeSelection(); + requestFastKeyboardRefresh(); } void InkHUD::KeyboardApplet::onNavDown() { selectedKey += KBD_COLS; - selectedKey %= (KBD_COLS * KBD_ROWS); - - // Request rendering over the previously drawn render - requestUpdate(EInk::UpdateTypes::FAST, false); - // Force an update to bypass lockRequests - inkhud->forceUpdate(EInk::UpdateTypes::FAST); + selectedKey %= KBD_KEY_COUNT; + normalizeSelection(); + requestFastKeyboardRefresh(); } void InkHUD::KeyboardApplet::onNavLeft() { - if (selectedKey % KBD_COLS == 0) // wrap + if (selectedKey % KBD_COLS == 0) selectedKey += KBD_COLS - 1; - else // move 1 column back + else selectedKey--; - // Request rendering over the previously drawn render - requestUpdate(EInk::UpdateTypes::FAST, false); - // Force an update to bypass lockRequests - inkhud->forceUpdate(EInk::UpdateTypes::FAST); + normalizeSelection(); + requestFastKeyboardRefresh(); } void InkHUD::KeyboardApplet::onNavRight() { - if (selectedKey % KBD_COLS == KBD_COLS - 1) // wrap + if (selectedKey % KBD_COLS == KBD_COLS - 1) selectedKey -= KBD_COLS - 1; - else // move 1 column forward + else selectedKey++; - // Request rendering over the previously drawn render - requestUpdate(EInk::UpdateTypes::FAST, false); - // Force an update to bypass lockRequests - inkhud->forceUpdate(EInk::UpdateTypes::FAST); + normalizeSelection(); + requestFastKeyboardRefresh(); +} + +bool InkHUD::KeyboardApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress) +{ + // If touch is outside our tile, let other handlers process it. + if (!getTile()) + return false; + const uint16_t tileL = getTile()->getLeft(); + const uint16_t tileT = getTile()->getTop(); + const uint16_t tileR = tileL + getTile()->getWidth(); + const uint16_t tileB = tileT + getTile()->getHeight(); + if (x < tileL || x >= tileR || y < tileT || y >= tileB) + return false; + + const int16_t hitIndex = getKeyIndexAt(x, y); + // Consume touches that land in keyboard whitespace/disabled cells so we don't + // fall back to generic short-press behavior (which would type the old selection). + if (hitIndex < 0) + return true; + + const uint8_t newSelected = (uint8_t)hitIndex; + if (selectedKey != newSelected) { + selectedKey = newSelected; + normalizeSelection(); + if (showSelectionHighlight()) + requestFastKeyboardRefresh(); + } + + if (!isKeyEnabledAt(selectedKey)) + return true; + + inputSelectedKey(longPress); + return true; +} + +bool InkHUD::KeyboardApplet::getKeyBounds(uint8_t index, uint16_t &left, uint16_t &top, uint16_t &width, uint16_t &height) +{ + if (index >= KBD_KEY_COUNT || !getTile()) + return false; + + const uint16_t tileW = getTile()->getWidth(); + const uint16_t tileH = getTile()->getHeight(); + const uint16_t tileL = getTile()->getLeft(); + const uint16_t tileT = getTile()->getTop(); + const uint8_t row = index / KBD_COLS; + const uint8_t col = index % KBD_COLS; + + const uint16_t totalGapY = KEY_GAP_Y * (KBD_ROWS + 1); + const uint16_t keyH = (tileH > totalGapY) ? ((tileH - totalGapY) / KBD_ROWS) : (tileH / KBD_ROWS); + top = tileT + KEY_GAP_Y + row * (keyH + KEY_GAP_Y); + height = keyH; + + const uint16_t totalGapX = KEY_GAP_X * (KBD_COLS + 1); + const uint16_t rowSpace = (tileW > totalGapX) ? (tileW - totalGapX) : tileW; + uint32_t rowUnits = 0; + const uint8_t rowStart = row * KBD_COLS; + for (uint8_t i = 0; i < KBD_COLS; i++) { + rowUnits += getKeyWidthAt(rowStart + i); + } + if (rowUnits == 0) + return false; + + uint32_t cursorX = tileL + KEY_GAP_X; + for (uint8_t i = 0; i < col; i++) { + const uint8_t rowIndex = rowStart + i; + const uint32_t keyW = ((uint32_t)rowSpace * getKeyWidthAt(rowIndex)) / rowUnits; + cursorX += keyW + KEY_GAP_X; + } + + left = (uint16_t)cursorX; + + if (col == (KBD_COLS - 1)) { + const uint32_t rightEdge = tileL + tileW - KEY_GAP_X; + width = (rightEdge > cursorX) ? (uint16_t)(rightEdge - cursorX) : 0; + } else { + width = (uint16_t)(((uint32_t)rowSpace * getKeyWidthAt(index)) / rowUnits); + } + + return true; +} + +int16_t InkHUD::KeyboardApplet::getKeyIndexAt(uint16_t x, uint16_t y) +{ + for (uint8_t i = 0; i < KBD_KEY_COUNT; i++) { + uint16_t keyL = 0; + uint16_t keyT = 0; + uint16_t keyW = 0; + uint16_t keyH = 0; + if (!getKeyBounds(i, keyL, keyT, keyW, keyH)) + return -1; + + if (keyW == 0 || keyH == 0) + continue; + + if (x >= keyL && x < (keyL + keyW) && y >= keyT && y < (keyT + keyH)) + return i; + } + + return -1; +} + +void InkHUD::KeyboardApplet::inputSelectedKey(bool longPress) +{ + inputKeyCode(getKeyCodeAt(selectedKey), longPress); +} + +void InkHUD::KeyboardApplet::inputKeyCode(int16_t keyCode, bool longPress) +{ + if (keyCode == KEY_NONE) + return; + + if (keyCode >= KEY_EMOTE_SLOT_BASE) { + const uint8_t slot = (uint8_t)(keyCode - KEY_EMOTE_SLOT_BASE); + const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + slot; + if (emoteIndex < fontEmoteCount) + inkhud->freeText((char)fontEmotes[emoteIndex]); + return; + } + + switch (keyCode) { + case KEY_BACKSPACE: + inkhud->freeText('\b'); + return; + case KEY_SEND: + inkhud->freeTextDone(); + inkhud->closeKeyboard(); + return; + case KEY_EMOTE_TOGGLE: + toggleEmoteMode(); + return; + case KEY_PUNCT_TOGGLE: + case KEY_ALPHA_TOGGLE: + togglePunctuationMode(); + return; + case KEY_EMOTE_UP: + pageEmotes(false); + return; + case KEY_EMOTE_DOWN: + pageEmotes(true); + return; + default: + break; + } + + if (keyCode >= 0 && keyCode <= 0xFF) { + char key = (char)keyCode; + if (longPress && key >= 'a' && key <= 'z') + key = (char)std::toupper((unsigned char)key); + inkhud->freeText(key); + } +} + +int16_t InkHUD::KeyboardApplet::getKeyCodeAt(uint8_t index) const +{ + if (index >= KBD_KEY_COUNT) + return KEY_NONE; + + if (mode == MODE_TEXT) + return textKeys[index]; + if (mode == MODE_PUNCT) + return punctKeys[index]; + + // Emote mode + if (index < EMOTE_SLOT_COUNT) { + const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + index; + if (emoteIndex < fontEmoteCount) + return KEY_EMOTE_SLOT_BASE + index; + return KEY_NONE; + } + + // Emote controls on the bottom row + switch (index - EMOTE_SLOT_COUNT) { + case 0: + return KEY_EMOTE_UP; + case 1: + return KEY_EMOTE_DOWN; + case 2: + return KEY_ALPHA_TOGGLE; + case 3: + return ','; + case 4: + return ' '; + case 5: + return '.'; + case 6: + return KEY_SEND; + case 7: + return KEY_BACKSPACE; + default: + return KEY_NONE; + } +} + +uint16_t InkHUD::KeyboardApplet::getKeyWidthAt(uint8_t index) const +{ + if (index >= KBD_KEY_COUNT) + return 0; + + if (mode == MODE_EMOTE) + return emoteKeyWidths[index]; + return typingKeyWidths[index]; +} + +std::string InkHUD::KeyboardApplet::getKeyLabelAt(uint8_t index) const +{ + const int16_t keyCode = getKeyCodeAt(index); + if (keyCode == KEY_NONE) + return ""; + + if (keyCode >= KEY_EMOTE_SLOT_BASE) { + const uint8_t slot = (uint8_t)(keyCode - KEY_EMOTE_SLOT_BASE); + const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + slot; + if (emoteIndex < fontEmoteCount) + return std::string(1, (char)fontEmotes[emoteIndex]); + return ""; + } + + switch (keyCode) { + case KEY_BACKSPACE: + return "DEL"; + case KEY_SEND: + return "SEND"; + case KEY_EMOTE_TOGGLE: + return std::string(1, (char)0x03); // Smiling face icon from InkHUD emote font map + case KEY_PUNCT_TOGGLE: + return "!#1"; + case KEY_ALPHA_TOGGLE: + return "ABC"; + case KEY_EMOTE_UP: + return "UP"; + case KEY_EMOTE_DOWN: + return "DN"; + default: + break; + } + + if (keyCode >= 0 && keyCode <= 0xFF) { + const char c = (char)keyCode; + if (c == ' ') + return "SPACE"; + if (c >= 'a' && c <= 'z') + return std::string(1, (char)std::toupper((unsigned char)c)); + return std::string(1, c); + } + + return ""; +} + +bool InkHUD::KeyboardApplet::isKeyEnabledAt(uint8_t index) const +{ + return getKeyCodeAt(index) != KEY_NONE; +} + +void InkHUD::KeyboardApplet::normalizeSelection() +{ + if (selectedKey >= KBD_KEY_COUNT) + selectedKey = 0; + + if (isKeyEnabledAt(selectedKey)) + return; + + for (uint8_t i = 0; i < KBD_KEY_COUNT; i++) { + if (isKeyEnabledAt(i)) { + selectedKey = i; + return; + } + } +} + +void InkHUD::KeyboardApplet::togglePunctuationMode() +{ + if (mode == MODE_EMOTE) { + mode = lastTypingMode; + } else { + mode = (mode == MODE_TEXT) ? MODE_PUNCT : MODE_TEXT; + lastTypingMode = mode; + } + + normalizeSelection(); + requestFastKeyboardRefresh(true); +} + +void InkHUD::KeyboardApplet::toggleEmoteMode() +{ + if (mode == MODE_EMOTE) { + mode = lastTypingMode; + } else { + lastTypingMode = mode; + mode = MODE_EMOTE; + } + + emotePage = 0; + normalizeSelection(); + requestFastKeyboardRefresh(true); +} + +void InkHUD::KeyboardApplet::pageEmotes(bool down) +{ + if (mode != MODE_EMOTE) + return; + + const uint8_t maxPage = (fontEmoteCount == 0) ? 0 : (uint8_t)((fontEmoteCount - 1) / EMOTE_SLOT_COUNT); + + if (down) { + if (emotePage < maxPage) + emotePage++; + } else { + if (emotePage > 0) + emotePage--; + } + + normalizeSelection(); + requestFastKeyboardRefresh(true); +} + +void InkHUD::KeyboardApplet::requestFastKeyboardRefresh(bool full) +{ + requestUpdate(EInk::UpdateTypes::FAST, full); +} + +bool InkHUD::KeyboardApplet::showSelectionHighlight() const +{ + // On touch-capable devices, prioritize input throughput over per-key highlight updates. + // E-ink refresh can lag rapid taps; skipping highlight avoids update-induced input latency. + return !inkhud->hasTouchEnabledProvider(); } uint16_t InkHUD::KeyboardApplet::getKeyboardHeight() { - const uint16_t keyH = fontSmall.lineHeight() * 1.2; - return keyH * 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; + const uint8_t rowScale = usePortraitKeyboardSizing() ? 3 : 2; + const uint16_t keyH = rowUnit * rowScale; + return (keyH * KBD_ROWS) + (KEY_GAP_Y * (KBD_ROWS + 1)); } #endif diff --git a/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.h b/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.h index 0ae181a2c82..b71376459fc 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.h +++ b/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.h @@ -12,6 +12,7 @@ System Applet to render an on-screen keyboard #include "graphics/niche/InkHUD/InkHUD.h" #include "graphics/niche/InkHUD/SystemApplet.h" #include + namespace NicheGraphics::InkHUD { @@ -31,34 +32,111 @@ class KeyboardApplet : public SystemApplet void onNavDown() override; void onNavLeft() override; void onNavRight() override; + bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override; static uint16_t getKeyboardHeight(); // used to set the keyboard tile height private: - void drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color); + enum KeyCode : int16_t { + KEY_NONE = -1, + KEY_BACKSPACE = 256, + KEY_SEND, + KEY_EMOTE_TOGGLE, + KEY_PUNCT_TOGGLE, + KEY_ALPHA_TOGGLE, + KEY_EMOTE_UP, + KEY_EMOTE_DOWN, + KEY_EMOTE_SLOT_BASE = 512 + }; + + enum KeyboardMode : uint8_t { MODE_TEXT = 0, MODE_PUNCT = 1, MODE_EMOTE = 2 }; + + void drawKey(uint8_t index, bool selected); + void drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, const std::string &label, Color color); + bool getKeyBounds(uint8_t index, uint16_t &left, uint16_t &top, uint16_t &width, uint16_t &height); + int16_t getKeyIndexAt(uint16_t x, uint16_t y); + void inputSelectedKey(bool longPress); + void inputKeyCode(int16_t keyCode, bool longPress); + int16_t getKeyCodeAt(uint8_t index) const; + uint16_t getKeyWidthAt(uint8_t index) const; + std::string getKeyLabelAt(uint8_t index) const; + bool isKeyEnabledAt(uint8_t index) const; + void normalizeSelection(); + void togglePunctuationMode(); + void toggleEmoteMode(); + void pageEmotes(bool down); + void requestFastKeyboardRefresh(bool full = false); + bool showSelectionHighlight() const; static const uint8_t KBD_COLS = 11; - static const uint8_t KBD_ROWS = 4; + 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 + static constexpr uint8_t fontEmotes[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x08, 0x09, 0x0B, 0x0C, 0x0E, 0x0F, 0x10, 0x11, + 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F}; + static constexpr uint8_t fontEmoteCount = sizeof(fontEmotes) / sizeof(fontEmotes[0]); - const char keys[KBD_COLS * KBD_ROWS] = { - '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 - }; + // Text keyboard (requested layout): + // row 0: 1..0 + // row 1: q..p + // row 2: a..l + // row 3: EMO, z..m, DEL + // row 4: !#1, comma, space, period, SEND + const int16_t textKeys[KBD_KEY_COUNT] = { + // row 0 + '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', KEY_NONE, + // row 1 + 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', KEY_NONE, + // row 2 + 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', KEY_NONE, KEY_NONE, + // row 3 + KEY_EMOTE_TOGGLE, 'z', 'x', 'c', 'v', 'b', 'n', 'm', KEY_BACKSPACE, KEY_NONE, KEY_NONE, + // row 4 + KEY_PUNCT_TOGGLE, ',', ' ', '.', KEY_SEND, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE}; - // This array represents the widths of each key in points - // 16 pt = line height of the text - const uint16_t keyWidths[KBD_COLS * KBD_ROWS] = { - 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 - }; + // Punctuation keyboard (toggle via !#1/ABC) + const int16_t punctKeys[KBD_KEY_COUNT] = { + // row 0 + '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', KEY_NONE, + // row 1 + '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', KEY_NONE, + // row 2 + '-', '_', '=', '+', '[', ']', '{', '}', '/', '?', KEY_NONE, + // row 3 + KEY_EMOTE_TOGGLE, ';', ':', '\'', '"', '<', '>', '\\', KEY_BACKSPACE, KEY_NONE, KEY_NONE, + // row 4 + KEY_ALPHA_TOGGLE, ',', ' ', '.', KEY_SEND, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE}; + + const uint16_t typingKeyWidths[KBD_KEY_COUNT] = {// row 0 + 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 0, + // row 1 + 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 0, + // row 2 + 12, 12, 12, 12, 12, 12, 12, 12, 12, 0, 0, + // row 3 + 18, 12, 12, 12, 12, 12, 12, 12, 20, 0, 0, + // row 4 + 20, 12, 56, 12, 24, 0, 0, 0, 0, 0, 0}; + + const uint16_t emoteKeyWidths[KBD_KEY_COUNT] = {// row 0 + 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, + // row 1 + 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, + // row 2 + 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, + // row 3 + 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, + // row 4 controls + 14, 14, 18, 12, 40, 12, 20, 18, 0, 0, 0}; - uint16_t rowWidths[KBD_ROWS]; - uint8_t selectedKey = 0; // selected key index + uint8_t selectedKey = 0; uint8_t prevSelectedKey = 0; + uint8_t emotePage = 0; + KeyboardMode mode = MODE_TEXT; + KeyboardMode lastTypingMode = MODE_TEXT; + static constexpr uint8_t KEY_GAP_X = 3; + static constexpr uint8_t KEY_GAP_Y = 4; + static constexpr uint8_t KEY_RADIUS = 4; }; } // namespace NicheGraphics::InkHUD diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h index 7ec76292bf3..e1f004d389f 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h @@ -109,6 +109,7 @@ enum MenuAction { TOGGLE_CHANNEL_POSITION, SET_CHANNEL_PRECISION, // Display + SET_DISPLAY_TIMEOUT, TOGGLE_DISPLAY_UNITS, // Network TOGGLE_WIFI, @@ -119,4 +120,4 @@ enum MenuAction { } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index d489d21ee96..dfef4d08522 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -8,6 +8,7 @@ #include "RTC.h" #include "Router.h" #include "airtime.h" +#include "graphics/niche/Utils/FlashData.h" #include "main.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" #include "power.h" @@ -27,6 +28,16 @@ static constexpr uint8_t MENU_TIMEOUT_SEC = 60; // How many seconds before menu // These are offered to users as possible values for settings.recentlyActiveSeconds static constexpr uint8_t RECENTS_OPTIONS_MINUTES[] = {2, 5, 10, 30, 60, 120}; +struct DisplayTimeoutOption { + uint32_t seconds; + 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"}, +}; + struct PositionPrecisionOption { uint8_t value; // proto value const char *metric; @@ -39,6 +50,77 @@ static constexpr PositionPrecisionOption POSITION_PRECISION_OPTIONS[] = { {12, "5.8 km", "3.6 mi"}, {11, "12 km", "7.3 mi"}, {10, "23 km", "15 mi"}, }; +static const char *getDisplayTimeoutLabel(uint32_t timeoutSeconds) +{ + constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]); + for (uint8_t i = 0; i < optionCount; i++) { + if (DISPLAY_TIMEOUT_OPTIONS[i].seconds == timeoutSeconds) { + return DISPLAY_TIMEOUT_OPTIONS[i].label; + } + } + + return "Custom"; +} + +static bool supportsFreeTextKeyboard(const InkHUD::InkHUD *inkhud, const InkHUD::Persistence::Settings *settings) +{ + return !inkhud->twoWayRocker && (settings->joystick.enabled || inkhud->hasTouchEnabledProvider()); +} + +static bool useTouchFriendlyMenuLayout(const InkHUD::InkHUD *inkhud) +{ + return inkhud != nullptr && inkhud->hasTouchEnabledProvider(); +} + +static uint16_t getMenuItemHeightPx(const InkHUD::InkHUD *inkhud) +{ + const bool touchFriendly = useTouchFriendlyMenuLayout(inkhud); + const uint16_t lineH = touchFriendly ? InkHUD::Applet::fontMedium.lineHeight() : InkHUD::Applet::fontSmall.lineHeight(); + const float rowScale = touchFriendly ? 1.9f : 1.6f; + uint16_t itemH = (uint16_t)(lineH * rowScale); + if (itemH == 0) { + itemH = 1; + } + return itemH; +} + +#if defined(T5_S3_EPAPER_PRO) +namespace +{ +static constexpr uint32_t T5_BACKLIGHT_PREFS_VERSION = 1; + +struct T5BacklightPrefs { + uint32_t version = T5_BACKLIGHT_PREFS_VERSION; + bool keepOn = true; +}; + +T5BacklightPrefs t5BacklightPrefs; +bool t5BacklightPrefsLoaded = false; + +bool loadT5BacklightKeepOn() +{ + if (!t5BacklightPrefsLoaded) { + T5BacklightPrefs loaded; + const bool ok = FlashData::load(&loaded, "t5_backlight"); + if (ok && loaded.version == T5_BACKLIGHT_PREFS_VERSION) { + t5BacklightPrefs = loaded; + } + t5BacklightPrefsLoaded = true; + } + + return t5BacklightPrefs.keepOn; +} + +void saveT5BacklightKeepOn(bool keepOn) +{ + loadT5BacklightKeepOn(); + t5BacklightPrefs.version = T5_BACKLIGHT_PREFS_VERSION; + t5BacklightPrefs.keepOn = keepOn; + FlashData::save(&t5BacklightPrefs, "t5_backlight"); +} +} // namespace +#endif + InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet") { // No timer tasks at boot @@ -47,7 +129,11 @@ InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet") // Note: don't get instance if we're not actually using the backlight, // or else you will unintentionally instantiate it if (settings->optionalMenuItems.backlight) { +#if defined(T5_S3_EPAPER_PRO) + t5BacklightSetUserEnabled(loadT5BacklightKeepOn()); +#else backlight = Drivers::LatchingBacklight::getInstance(); +#endif } // Initialize the Canned Message store @@ -76,9 +162,11 @@ void InkHUD::MenuApplet::onForeground() // backlight on always when menu opens. // Courtesy to T-Echo users who removed the capacitive touch button if (settings->optionalMenuItems.backlight) { +#if !defined(T5_S3_EPAPER_PRO) assert(backlight); if (!backlight->isOn()) backlight->peek(); +#endif } // Prevent user applets requesting update while menu is open @@ -106,9 +194,11 @@ void InkHUD::MenuApplet::onBackground() // Item in options submenu allows keeping backlight on after menu is closed // If this item is deselected we will turn backlight off again, now that menu is closing if (settings->optionalMenuItems.backlight) { +#if !defined(T5_S3_EPAPER_PRO) assert(backlight); if (!backlight->isLatched()) backlight->off(); +#endif } // Stop the auto-timeout @@ -333,17 +423,14 @@ void InkHUD::MenuApplet::execute(MenuItem item) handleFreeText = true; cm.freeTextItem.rawText.erase(); // clear the previous freetext message freeTextMode = true; // render input field instead of normal menu - // Open the on-screen keyboard only for full joystick devices - if (settings->joystick.enabled && !inkhud->twoWayRocker) + if (supportsFreeTextKeyboard(inkhud, settings)) inkhud->openKeyboard(); break; - case STORE_CANNEDMESSAGE_SELECTION: - if (!settings->joystick.enabled || inkhud->twoWayRocker) - cm.selectedMessageItem = &cm.messageItems.at(cursor - 1); // Minus one: offset for the initial "Send Ping" entry - else - cm.selectedMessageItem = &cm.messageItems.at(cursor - 2); // Minus two: offset for the "Send Ping" and free text entry - break; + case STORE_CANNEDMESSAGE_SELECTION: { + const uint8_t prefixItems = supportsFreeTextKeyboard(inkhud, settings) ? 2 : 1; + cm.selectedMessageItem = &cm.messageItems.at(cursor - prefixItems); + } break; case SEND_CANNEDMESSAGE: cm.selectedRecipientItem = &cm.recipientItems.at(cursor); @@ -422,14 +509,27 @@ void InkHUD::MenuApplet::execute(MenuItem item) break; case TOGGLE_BACKLIGHT: - // Note: backlight is already on in this situation - // We're marking that it should *remain* on once menu closes - assert(backlight); + // Note: backlight is already on in this situation. + // This toggle controls whether it should remain on when menu closes. +#if defined(T5_S3_EPAPER_PRO) + { + const bool keepOn = !t5BacklightIsUserEnabled(); + t5BacklightSetUserEnabled(keepOn); + saveT5BacklightKeepOn(keepOn); + if (item.checkState) + *(item.checkState) = keepOn; + } +#else + if (!backlight) + backlight = Drivers::LatchingBacklight::getInstance(); if (backlight->isLatched()) backlight->off(); else backlight->latch(); - break; + if (item.checkState) + *(item.checkState) = backlight->isLatched(); +#endif + break; case TOGGLE_12H_CLOCK: config.display.use_12h_clock = !config.display.use_12h_clock; @@ -527,6 +627,17 @@ void InkHUD::MenuApplet::execute(MenuItem item) } // Display + case SET_DISPLAY_TIMEOUT: { + // cursor - 1 because index 0 is "Back" + const uint8_t index = cursor - 1; + constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]); + if (index < optionCount) { + config.display.screen_on_secs = DISPLAY_TIMEOUT_OPTIONS[index].seconds; + nodeDB->saveToDisk(SEGMENT_CONFIG); + } + break; + } + case TOGGLE_DISPLAY_UNITS: if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC; @@ -893,11 +1004,16 @@ void InkHUD::MenuApplet::showPage(MenuPage page) previousPage = MenuPage::ROOT; items.push_back(MenuItem("Back", previousPage)); // Optional: backlight - if (settings->optionalMenuItems.backlight) - items.push_back(MenuItem(backlight->isLatched() ? "Backlight Off" : "Keep Backlight On", // Label - MenuAction::TOGGLE_BACKLIGHT, // Action - MenuPage::EXIT // Exit once complete - )); + if (settings->optionalMenuItems.backlight) { +#if defined(T5_S3_EPAPER_PRO) + keepBacklightOn = t5BacklightIsUserEnabled(); +#else + if (!backlight) + backlight = Drivers::LatchingBacklight::getInstance(); + keepBacklightOn = backlight->isLatched(); +#endif + items.push_back(MenuItem("Keep Backlight On", MenuAction::TOGGLE_BACKLIGHT, MenuPage::OPTIONS, &keepBacklightOn)); + } // Options Toggles items.push_back(MenuItem("Applets", MenuPage::APPLETS)); @@ -1109,6 +1225,9 @@ void InkHUD::MenuApplet::showPage(MenuPage page) items.push_back(MenuItem("12-Hour Clock", MenuAction::TOGGLE_12H_CLOCK, MenuPage::NODE_CONFIG_DISPLAY, &config.display.use_12h_clock)); + nodeConfigLabels.emplace_back("Screen Timeout: " + std::string(getDisplayTimeoutLabel(config.display.screen_on_secs))); + items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_DISPLAY_TIMEOUT)); + const char *unitsLabel = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? "Units: Imperial" : "Units: Metric"; @@ -1118,6 +1237,13 @@ void InkHUD::MenuApplet::showPage(MenuPage page) break; } + case NODE_CONFIG_DISPLAY_TIMEOUT: + previousPage = MenuPage::NODE_CONFIG_DISPLAY; + items.push_back(MenuItem("Back", previousPage)); + populateDisplayTimeoutPage(); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + case NODE_CONFIG_BLUETOOTH: { previousPage = MenuPage::NODE_CONFIG; items.push_back(MenuItem("Back", previousPage)); @@ -1386,10 +1512,14 @@ void InkHUD::MenuApplet::onRender(bool full) if (items.size() == 0) LOG_ERROR("Empty Menu"); + const bool touchFriendlyLayout = useTouchFriendlyMenuLayout(inkhud); + AppletFont menuItemFont = touchFriendlyLayout ? fontMedium : fontSmall; + setFont(menuItemFont); + // Dimensions for the slots where we will draw menuItems const float padding = 0.05; - const uint16_t itemH = fontSmall.lineHeight() * 1.6; - const int16_t selectInsetY = 2; + const uint16_t itemH = getMenuItemHeightPx(inkhud); + const int16_t selectInsetY = touchFriendlyLayout ? 3 : 2; const int16_t itemW = width() - X(padding) - X(padding); const int16_t itemL = X(padding); const int16_t itemR = X(1 - padding); @@ -1422,6 +1552,11 @@ void InkHUD::MenuApplet::onRender(bool full) itemT = max(siT + siH, 0); // Offset the first menu entry, so menu starts below the system info panel } + // drawSystemInfoPanel() changes font state (clock/info text). + // Restore the active menu font so ROOT page item text matches other menu pages, + // including touch-friendly layouts. + setFont(menuItemFont); + // Draw menu items // =================== @@ -1449,8 +1584,6 @@ void InkHUD::MenuApplet::onRender(bool full) // Header (non-selectable section label) if (item.isHeader) { - setFont(fontSmall); - // Header text (flush left) printAt(itemL + X(padding), center, item.label, LEFT, MIDDLE); @@ -1459,8 +1592,17 @@ void InkHUD::MenuApplet::onRender(bool full) drawLine(itemL + X(padding), underlineY, itemR - X(padding), underlineY, BLACK); } else { // Box, if currently selected - if (cursorShown && i == cursor) - drawRect(itemL, itemT + selectInsetY, itemW, itemH - (selectInsetY * 2), BLACK); + if (cursorShown && i == cursor && (!touchFriendlyLayout || !hideTouchSelectionHighlight)) { + const int16_t selTop = itemT + selectInsetY; + const int16_t selH = itemH - (selectInsetY * 2); + drawRect(itemL, selTop, itemW, selH, BLACK); + // Touch layouts need a stronger visual cue than a thin outline. + if (touchFriendlyLayout) { + const int16_t markerInset = 3; + const int16_t markerW = 4; + fillRect(itemL + markerInset, selTop + markerInset, markerW, max(1, selH - (markerInset * 2)), BLACK); + } + } // Indented normal item text printAt(itemL + X(padding * 2), center, item.label, LEFT, MIDDLE); @@ -1468,9 +1610,9 @@ void InkHUD::MenuApplet::onRender(bool full) // Checkbox, if relevant if (item.checkState) { - const uint16_t cbWH = fontSmall.lineHeight(); // Checkbox: width / height - const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left - const int16_t cbT = center - (cbWH / 2); // Checkbox : top + const uint16_t cbWH = menuItemFont.lineHeight(); // Checkbox: width / height + const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left + const int16_t cbT = center - (cbWH / 2); // Checkbox : top // Checkbox ticked if (*(item.checkState)) { drawRect(cbL, cbT, cbWH, cbWH, BLACK); @@ -1499,13 +1641,102 @@ void InkHUD::MenuApplet::onRender(bool full) } } +bool InkHUD::MenuApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress) +{ + (void)longPress; + + if (freeTextMode || !getTile()) { + return false; + } + + const uint16_t tileL = getTile()->getLeft(); + const uint16_t tileT = getTile()->getTop(); + const uint16_t tileR = tileL + getTile()->getWidth(); + const uint16_t tileB = tileT + getTile()->getHeight(); + if (x < tileL || x >= tileR || y < tileT || y >= tileB) { + return false; + } + + if (items.empty()) { + return true; + } + + // Direct touch controls should act as activity and keep the menu open. + OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL); + + // If button-driven selection is active on touch-first layouts, clear it as soon as + // touch interaction resumes so touch behavior remains direct/tap-first. + if (useTouchFriendlyMenuLayout(inkhud)) { + cursorShown = false; + hideTouchSelectionHighlight = true; + } + + const int16_t localY = (int16_t)y - (int16_t)tileT; + + // Keep geometry in sync with onRender() so touch hit-testing matches what users see. + const uint16_t itemH = getMenuItemHeightPx(inkhud); + int16_t itemT = 0; + uint8_t slotCount = (height() - itemT) / itemH; + if (slotCount == 0) { + slotCount = 1; + } + const uint16_t &siH = systemInfoPanelHeight; + const uint8_t slotsObscured = ceilf(siH / (float)itemH); + + if (currentPage == ROOT) { + int16_t siT = 0; + const int16_t scrollThreshold = (int16_t)slotCount - (int16_t)slotsObscured - 1; + if (scrollThreshold >= 0 && (int16_t)cursor >= scrollThreshold) { + siT = 0 - ((cursor - scrollThreshold) * itemH); + } + itemT = max((int16_t)(siT + siH), (int16_t)0); + } + + const uint8_t firstItem = (cursor < slotCount) ? 0 : (cursor - (slotCount - 1)); + uint16_t visibleEnd = (uint16_t)firstItem + (uint16_t)slotCount; + const uint8_t maxIndex = (uint8_t)items.size() - 1; + if (visibleEnd > maxIndex) { + visibleEnd = maxIndex; + } + const uint8_t lastItem = (uint8_t)visibleEnd; + + for (uint8_t i = firstItem; i <= lastItem; i++) { + const int16_t rowTop = itemT; + const int16_t rowBottom = itemT + itemH; + + if (localY >= rowTop && localY < rowBottom) { + if (items.at(i).isHeader) { + // Consume taps on headers so they don't fall back to button semantics. + return true; + } + + cursor = i; + cursorShown = true; + execute(items.at(cursor)); + + if (!wantsToRender()) { + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + } + return true; + } + + itemT += itemH; + } + + // Consume taps on menu whitespace so we don't trigger button-like fallback behavior. + return true; +} + void InkHUD::MenuApplet::onButtonShortPress() { if (!freeTextMode) { // Push the auto-close timer back OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL); - if (!settings->joystick.enabled) { + // Touch-first nodes keep user-button short-press as "advance selection" in menus. + // Any button-driven navigation should restore visible highlight. + hideTouchSelectionHighlight = false; + if (!settings->joystick.enabled || useTouchFriendlyMenuLayout(inkhud)) { if (!cursorShown) { cursorShown = true; // Select the first item that isn't a header @@ -1566,6 +1797,32 @@ void InkHUD::MenuApplet::onNavUp() if (!freeTextMode) { OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL); + // Touch-first menus: swipe up/down should scroll only. + // Keep cursor movement for scroll math, but selection box is hidden in onRender(). + if (useTouchFriendlyMenuLayout(inkhud)) { + hideTouchSelectionHighlight = true; + if (!cursorShown) { + cursorShown = true; + cursor = items.size() - 1; + while (items.at(cursor).isHeader) { + if (cursor == 0) { + cursorShown = false; + break; + } + cursor--; + } + } else { + do { + if (cursor == 0) + cursor = items.size() - 1; + else + cursor--; + } while (items.at(cursor).isHeader); + } + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + return; + } + if (!cursorShown) { cursorShown = true; // Select the last item that isn't a header @@ -1595,6 +1852,29 @@ void InkHUD::MenuApplet::onNavDown() if (!freeTextMode) { OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL); + // Touch-first menus: swipe up/down should scroll only. + // Keep cursor movement for scroll math, but selection box is hidden in onRender(). + if (useTouchFriendlyMenuLayout(inkhud)) { + hideTouchSelectionHighlight = true; + if (!cursorShown) { + cursorShown = true; + cursor = 0; + while (cursor < items.size() && items.at(cursor).isHeader) { + cursor++; + } + if (cursor >= items.size()) { + cursorShown = false; + cursor = 0; + } + } else { + do { + cursor = (cursor + 1) % items.size(); + } while (items.at(cursor).isHeader); + } + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + return; + } + if (!cursorShown) { cursorShown = true; // Select the first item that isn't a header @@ -1727,6 +2007,17 @@ void InkHUD::MenuApplet::populateRecentsPage() } } +void InkHUD::MenuApplet::populateDisplayTimeoutPage() +{ + constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]); + for (uint8_t i = 0; i < optionCount; i++) { + displayTimeoutSelected[i] = (config.display.screen_on_secs == DISPLAY_TIMEOUT_OPTIONS[i].seconds); + nodeConfigLabels.emplace_back(DISPLAY_TIMEOUT_OPTIONS[i].label); + items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_DISPLAY_TIMEOUT, MenuPage::NODE_CONFIG_DISPLAY, + &displayTimeoutSelected[i])); + } +} + // MenuItem entries for the "send" page // Dynamically creates menu items based on available canned messages void InkHUD::MenuApplet::populateSendPage() @@ -1734,8 +2025,8 @@ void InkHUD::MenuApplet::populateSendPage() // Position / NodeInfo packet items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT)); - // If joystick is available, include the Free Text option - if (settings->joystick.enabled && !inkhud->twoWayRocker) + // Show the Free Text option on any node that supports the on-screen keyboard. + if (supportsFreeTextKeyboard(inkhud, settings)) items.push_back(MenuItem("Free Text", MenuAction::FREE_TEXT, MenuPage::SEND)); // One menu item for each canned message diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h index b5c1c86e4c8..2d7fbd398b3 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h @@ -35,6 +35,7 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread void onFreeText(char c) override; void onFreeTextDone() override; void onFreeTextCancel() override; + bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override; void onRender(bool full) override; void show(Tile *t); // Open the menu, onto a user tile @@ -48,11 +49,12 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread void execute(MenuItem item); // Perform the MenuAction associated with a MenuItem, if any void showPage(MenuPage page); // Load and display a MenuPage - void populateSendPage(); // Dynamically create MenuItems including canned messages - void populateRecipientPage(); // Dynamically create a page of possible destinations for a canned message - void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets - void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow - void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds + void populateSendPage(); // Dynamically create MenuItems including canned messages + void populateRecipientPage(); // Dynamically create a page of possible destinations for a canned message + void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets + void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow + void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds + void populateDisplayTimeoutPage(); // Create menu items for config.display.screen_on_secs void drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text); // Draw input field for free text @@ -65,8 +67,9 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread MenuPage startPageOverride = MenuPage::ROOT; MenuPage currentPage = MenuPage::ROOT; MenuPage previousPage = MenuPage::EXIT; - uint8_t cursor = 0; // Which menu item is currently highlighted - bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection) + uint8_t cursor = 0; // Which menu item is currently highlighted + bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection) + bool hideTouchSelectionHighlight = false; // Touch scrolling keeps cursor for paging math, but can hide highlight bool freeTextMode = false; uint16_t systemInfoPanelHeight = 0; // Need to know before we render uint16_t menuTextLimit = 200; @@ -80,6 +83,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread // Recents menu checkbox state (derived from settings.recentlyActiveSeconds) static constexpr uint8_t RECENTS_COUNT = 6; bool recentsSelected[RECENTS_COUNT] = {}; + static constexpr uint8_t DISPLAY_TIMEOUT_COUNT = 7; + bool displayTimeoutSelected[DISPLAY_TIMEOUT_COUNT] = {}; // Data for selecting and sending canned messages via the menu // Placed into a sub-class for organization only @@ -116,7 +121,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread Applet *borrowedTileOwner = nullptr; // Which applet we have temporarily replaced while displaying menu - bool invertedColors = false; // Helper to display current state of config.display.displaymode in InkHUD options + bool invertedColors = false; // Helper to display current state of config.display.displaymode in InkHUD options + bool keepBacklightOn = false; // Helper to display current backlight latch state in InkHUD options }; } // namespace NicheGraphics::InkHUD diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h index 138c389bee7..9bc6bb0ccfd 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h @@ -32,6 +32,7 @@ enum MenuPage : uint8_t { NODE_CONFIG_POWER_ADC_CAL, NODE_CONFIG_NETWORK, NODE_CONFIG_DISPLAY, + NODE_CONFIG_DISPLAY_TIMEOUT, NODE_CONFIG_BLUETOOTH, NODE_CONFIG_POSITION, NODE_CONFIG_ADMIN_RESET, @@ -45,4 +46,4 @@ enum MenuPage : uint8_t { } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.cpp new file mode 100644 index 00000000000..04475c80375 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.cpp @@ -0,0 +1,30 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./TouchStatusApplet.h" + +using namespace NicheGraphics; + +InkHUD::TouchStatusApplet::TouchStatusApplet() +{ + alwaysRender = true; +} + +void InkHUD::TouchStatusApplet::onRender(bool full) +{ + (void)full; + + if (inkhud->isTouchEnabled()) { + return; + } + + setFont(fontSmall); + + const uint16_t barH = fontSmall.lineHeight() + 4; + const int16_t top = height() - barH; + + fillRect(0, top, width(), barH, WHITE); + drawLine(0, top, width() - 1, top, BLACK); + printAt(width() / 2, top + (barH / 2), "TOUCH OFF", CENTER, MIDDLE); +} + +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.h b/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.h new file mode 100644 index 00000000000..1d3ddc816da --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.h @@ -0,0 +1,29 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + + Low-profile bottom-edge touch status indicator. + Shown only while touch input is disabled. + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/SystemApplet.h" + +namespace NicheGraphics::InkHUD +{ + +class TouchStatusApplet : public SystemApplet +{ + public: + TouchStatusApplet(); + + void onRender(bool full) override; +}; + +} // namespace NicheGraphics::InkHUD + +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp index a45e8d9b578..ffc8f6f9916 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp @@ -47,6 +47,9 @@ InkHUD::TipsApplet::TipsApplet() void InkHUD::TipsApplet::onRender(bool full) { + const char *continuePrompt = + (inkhud && inkhud->hasTouchEnabledProvider()) ? "Tap screen to continue" : "Press button to continue"; + switch (tipQueue.front()) { case Tip::WELCOME: renderWelcome(); @@ -79,7 +82,7 @@ void InkHUD::TipsApplet::onRender(bool full) cursorY += fontSmall.lineHeight() / 2; drawBullet("More info at meshtastic.org"); - printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM); + printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM); } break; case Tip::PICK_REGION: { @@ -109,7 +112,7 @@ void InkHUD::TipsApplet::onRender(bool full) printWrapped(0, cursorY, width(), body); cursorY += bodyH + (fontSmall.lineHeight() / 2); - printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM); + printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM); } break; case Tip::CUSTOMIZATION: { @@ -129,10 +132,13 @@ void InkHUD::TipsApplet::onRender(bool full) printWrapped(0, cursorY, width(), body); cursorY += bodyH + (fontSmall.lineHeight() / 2); - printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM); + printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM); } break; case Tip::BUTTONS: { +#if defined(T5_S3_EPAPER_PRO) + renderT5S3ButtonsTip(); +#else setFont(fontMedium); const char *title = "Tip: Buttons"; @@ -164,7 +170,8 @@ void InkHUD::TipsApplet::onRender(bool full) drawBullet("- press: switch tile or close menu"); } - printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM); + printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM); +#endif } break; case Tip::ROTATION: { @@ -189,7 +196,7 @@ void InkHUD::TipsApplet::onRender(bool full) "To rotate the display, use the InkHUD menu. Press the user button > Options > Rotate."); } - printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM); + printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM); // Revert the "flip screen" setting, preventing this message showing again config.display.flip_screen = false; @@ -198,6 +205,42 @@ void InkHUD::TipsApplet::onRender(bool full) } } +#if defined(T5_S3_EPAPER_PRO) +void InkHUD::TipsApplet::renderT5S3ButtonsTip() +{ + setFont(fontMedium); + + const char *title = "Tip: T5-S3 Buttons"; + uint16_t h = getWrappedTextHeight(0, width(), title); + printWrapped(0, 0, width(), title); + + setFont(fontSmall); + int16_t cursorY = h + fontSmall.lineHeight(); + + auto drawBullet = [&](const char *text) { + uint16_t bh = getWrappedTextHeight(0, width(), text); + printWrapped(0, cursorY, width(), text); + cursorY += bh + (fontSmall.lineHeight() / 3); + }; + + drawBullet("BOOT button"); + drawBullet("- short press: next"); + drawBullet("- long press: open menu or select"); + + drawBullet("IO48 button"); + drawBullet("- short press: toggle touch on/off"); + drawBullet("- long press: toggle backlight on/off"); + + drawBullet("PWR button"); + drawBullet("- Hold Press to wake after Shutdown"); + + drawBullet("HOME button"); + drawBullet("- press: back/exit in InkHUD and open App switcher"); + + printAt(0, Y(1.0), "Tap screen to continue", LEFT, BOTTOM); +} +#endif + // This tip has its own render method, only because it's a big block of code // Didn't want to clutter up the switch in onRender too much void InkHUD::TipsApplet::renderWelcome() @@ -244,7 +287,9 @@ void InkHUD::TipsApplet::renderWelcome() // Block 3 - press to continue // ============================ - printAt(X(0.5), Y(1), "Press button to continue", CENTER, BOTTOM); + const char *continuePrompt = + (inkhud && inkhud->hasTouchEnabledProvider()) ? "Tap screen to continue" : "Press button to continue"; + printAt(X(0.5), Y(1), continuePrompt, CENTER, BOTTOM); } void InkHUD::TipsApplet::onForeground() diff --git a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h index 2e81d678bb8..ea2db228c20 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h +++ b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h @@ -41,6 +41,9 @@ class TipsApplet : public SystemApplet protected: void renderWelcome(); // Very first screen of tutorial +#if defined(T5_S3_EPAPER_PRO) + void renderT5S3ButtonsTip(); +#endif std::deque tipQueue; // List of tips to show, one after another @@ -49,4 +52,4 @@ class TipsApplet : public SystemApplet } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index 577a773bb49..ddcc6781b8d 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -2,6 +2,7 @@ #include "./Events.h" +#include "PowerFSM.h" #include "RTC.h" #include "buzz.h" #include "modules/ExternalNotificationModule.h" @@ -14,6 +15,19 @@ using namespace NicheGraphics; +namespace +{ +// When touch long-press opens menu, some panels report a delayed release/tap +// if the finger stays down briefly. Keep this long enough to cover that release. +constexpr uint32_t TOUCH_MENU_OPEN_TAP_SUPPRESS_MS = 1200; + +inline void noteInkHUDUserInteraction() +{ + // Keep power state and screen-timeout behavior in sync with InkHUD input activity. + powerFSM.trigger(EVENT_INPUT); +} +} // namespace + InkHUD::Events::Events() { // Get convenient references @@ -38,6 +52,8 @@ void InkHUD::Events::begin() void InkHUD::Events::onButtonShort() { + noteInkHUDUserInteraction(); + // Audio feedback (via buzzer) // Short tone playChirp(); @@ -74,6 +90,8 @@ void InkHUD::Events::onButtonShort() void InkHUD::Events::onButtonLong() { + noteInkHUDUserInteraction(); + // Audio feedback (via buzzer) // Slightly longer than playChirp playBoop(); @@ -102,193 +120,295 @@ void InkHUD::Events::onButtonLong() void InkHUD::Events::onExitShort() { - if (settings->joystick.enabled) { - // Audio feedback (via buzzer) - // Short tone - playChirp(); - // Cancel any beeping, buzzing, blinking - // Some button handling suppressed if we are dismissing an external notification (see below) - bool dismissedExt = dismissExternalNotification(); - - // Check which system applet wants to handle the button press (if any) - SystemApplet *consumer = nullptr; - for (SystemApplet *sa : inkhud->systemApplets) { - if (sa->handleInput) { - consumer = sa; - break; - } + // Preserve legacy behavior on non-touch builds: + // EXIT input is only active when joystick mode is enabled. + // Touch-capable builds intentionally bypass this joystick gate. + if (!settings->joystick.enabled && !inkhud->hasTouchEnabledProvider()) { + return; + } + + noteInkHUDUserInteraction(); + + // Audio feedback (via buzzer) + // Short tone + playChirp(); + // Cancel any beeping, buzzing, blinking + // Some button handling suppressed if we are dismissing an external notification module (see below) + bool dismissedExt = dismissExternalNotification(); + + // Check which system applet wants to handle the button press (if any) + SystemApplet *consumer = nullptr; + for (SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + consumer = sa; + break; } + } - // If no system applet is handling input, default behavior instead is change tiles - if (consumer) - consumer->onExitShort(); - else if (!dismissedExt) { // Don't change tile if this button press silenced the external notification module - Applet *userConsumer = inkhud->getActiveApplet(); + // Always let active system applets consume EXIT/HOME input (menu close, keyboard cancel, etc), + // including on touch-first nodes where joystick mode is disabled. + if (consumer) { + consumer->onExitShort(); + return; + } - if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_SHORT)) - userConsumer->onExitShort(); - else - inkhud->nextTile(); + // Touch-capable InkHUD nodes use EXIT/HOME as a quick app switcher launcher. + if (!dismissedExt && inkhud->hasTouchEnabledProvider()) { + inkhud->openAppSwitcher(); + return; + } + + if (!dismissedExt) { + Applet *userConsumer = inkhud->getActiveApplet(); + + if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_SHORT)) { + userConsumer->onExitShort(); + } else if (settings->joystick.enabled) { + // Preserve existing joystick behavior when no applet handles EXIT. + inkhud->nextTile(); } } } void InkHUD::Events::onExitLong() { - if (settings->joystick.enabled) { - // Audio feedback (via buzzer) - // Slightly longer than playChirp - playBoop(); - - // Check which system applet wants to handle the button press (if any) - SystemApplet *consumer = nullptr; - for (SystemApplet *sa : inkhud->systemApplets) { - if (sa->handleInput) { - consumer = sa; - break; - } - } + // Preserve legacy behavior on non-touch builds: + // EXIT input is only active when joystick mode is enabled. + // Touch-capable builds intentionally bypass this joystick gate. + if (!settings->joystick.enabled && !inkhud->hasTouchEnabledProvider()) { + return; + } - if (consumer) - consumer->onExitLong(); - else { - Applet *userConsumer = inkhud->getActiveApplet(); + noteInkHUDUserInteraction(); + + // Audio feedback (via buzzer) + // Slightly longer than playChirp + playBoop(); - if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_LONG)) - userConsumer->onExitLong(); - // Nothing uses exit long yet + // Check which system applet wants to handle the button press (if any) + SystemApplet *consumer = nullptr; + for (SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + consumer = sa; + break; } } + + // Always allow system applets to consume EXIT/HOME long-press. + if (consumer) { + consumer->onExitLong(); + } else { + Applet *userConsumer = inkhud->getActiveApplet(); + + if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_LONG)) + userConsumer->onExitLong(); + // Nothing uses exit long yet + } } void InkHUD::Events::onNavUp() { - if (settings->joystick.enabled) { - // Audio feedback (via buzzer) - // Short tone - playChirp(); - // Cancel any beeping, buzzing, blinking - // Some button handling suppressed if we are dismissing an external notification (see below) - bool dismissedExt = dismissExternalNotification(); - - // Check which system applet wants to handle the button press (if any) - SystemApplet *consumer = nullptr; - for (SystemApplet *sa : inkhud->systemApplets) { - if (sa->handleInput) { - consumer = sa; - break; - } - } + if (settings->joystick.enabled) + onTouchNavUp(); +} - if (consumer) - consumer->onNavUp(); - else if (!dismissedExt) { - Applet *userConsumer = inkhud->getActiveApplet(); +void InkHUD::Events::onNavDown() +{ + if (settings->joystick.enabled) + onTouchNavDown(); +} + +void InkHUD::Events::onNavLeft() +{ + if (settings->joystick.enabled) + onTouchNavLeft(); +} + +void InkHUD::Events::onNavRight() +{ + if (settings->joystick.enabled) + onTouchNavRight(); +} - if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_UP)) - userConsumer->onNavUp(); +void InkHUD::Events::onTouchNavUp() +{ + noteInkHUDUserInteraction(); + + // Audio feedback (via buzzer) + // Short tone + playChirp(); + // Cancel any beeping, buzzing, blinking + // Some button handling suppressed if we are dismissing an external notification (see below) + bool dismissedExt = dismissExternalNotification(); + + // Check which system applet wants to handle the button press (if any) + SystemApplet *consumer = nullptr; + for (SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + consumer = sa; + break; } } + + if (consumer) + consumer->onNavUp(); + else if (!dismissedExt) { + Applet *userConsumer = inkhud->getActiveApplet(); + + if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_UP)) + userConsumer->onNavUp(); + } } -void InkHUD::Events::onNavDown() +void InkHUD::Events::onTouchNavDown() { - if (settings->joystick.enabled) { - // Audio feedback (via buzzer) - // Short tone - playChirp(); - // Cancel any beeping, buzzing, blinking - // Some button handling suppressed if we are dismissing an external notification (see below) - bool dismissedExt = dismissExternalNotification(); - - // Check which system applet wants to handle the button press (if any) - SystemApplet *consumer = nullptr; - for (SystemApplet *sa : inkhud->systemApplets) { - if (sa->handleInput) { - consumer = sa; - break; - } - } + noteInkHUDUserInteraction(); - if (consumer) - consumer->onNavDown(); - else if (!dismissedExt) { - Applet *userConsumer = inkhud->getActiveApplet(); + // Audio feedback (via buzzer) + // Short tone + playChirp(); + // Cancel any beeping, buzzing, blinking + // Some button handling suppressed if we are dismissing an external notification (see below) + bool dismissedExt = dismissExternalNotification(); - if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_DOWN)) - userConsumer->onNavDown(); + // Check which system applet wants to handle the button press (if any) + SystemApplet *consumer = nullptr; + for (SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + consumer = sa; + break; } } + + if (consumer) + consumer->onNavDown(); + else if (!dismissedExt) { + Applet *userConsumer = inkhud->getActiveApplet(); + + if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_DOWN)) + userConsumer->onNavDown(); + } } -void InkHUD::Events::onNavLeft() +void InkHUD::Events::onTouchNavLeft() { - if (settings->joystick.enabled) { - // Audio feedback (via buzzer) - // Short tone - playChirp(); - // Cancel any beeping, buzzing, blinking - // Some button handling suppressed if we are dismissing an external notification (see below) - bool dismissedExt = dismissExternalNotification(); - - // Check which system applet wants to handle the button press (if any) - SystemApplet *consumer = nullptr; - for (SystemApplet *sa : inkhud->systemApplets) { - if (sa->handleInput) { - consumer = sa; - break; - } - } + noteInkHUDUserInteraction(); - // If no system applet is handling input, default behavior instead is to cycle applets - if (consumer) - consumer->onNavLeft(); - else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module - Applet *userConsumer = inkhud->getActiveApplet(); + // Audio feedback (via buzzer) + // Short tone + playChirp(); + // Cancel any beeping, buzzing, blinking + // Some button handling suppressed if we are dismissing an external notification (see below) + bool dismissedExt = dismissExternalNotification(); - if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_LEFT)) - userConsumer->onNavLeft(); - else - inkhud->prevApplet(); + // Check which system applet wants to handle the button press (if any) + SystemApplet *consumer = nullptr; + for (SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + consumer = sa; + break; } } + + // If no system applet is handling input, default behavior instead is to cycle applets + if (consumer) + consumer->onNavLeft(); + else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module + Applet *userConsumer = inkhud->getActiveApplet(); + + if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_LEFT)) + userConsumer->onNavLeft(); + else + inkhud->prevApplet(); + } } -void InkHUD::Events::onNavRight() +void InkHUD::Events::onTouchNavRight() { - if (settings->joystick.enabled) { - // Audio feedback (via buzzer) - // Short tone - playChirp(); - // Cancel any beeping, buzzing, blinking - // Some button handling suppressed if we are dismissing an external notification (see below) - bool dismissedExt = dismissExternalNotification(); - - // Check which system applet wants to handle the button press (if any) - SystemApplet *consumer = nullptr; - for (SystemApplet *sa : inkhud->systemApplets) { - if (sa->handleInput) { - consumer = sa; - break; - } + noteInkHUDUserInteraction(); + + // Audio feedback (via buzzer) + // Short tone + playChirp(); + // Cancel any beeping, buzzing, blinking + // Some button handling suppressed if we are dismissing an external notification (see below) + bool dismissedExt = dismissExternalNotification(); + + // Check which system applet wants to handle the button press (if any) + SystemApplet *consumer = nullptr; + for (SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + consumer = sa; + break; } + } - // If no system applet is handling input, default behavior instead is to cycle applets - if (consumer) - consumer->onNavRight(); - else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module - Applet *userConsumer = inkhud->getActiveApplet(); + // If no system applet is handling input, default behavior instead is to cycle applets + if (consumer) + consumer->onNavRight(); + else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module + Applet *userConsumer = inkhud->getActiveApplet(); - if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_RIGHT)) - userConsumer->onNavRight(); - else - inkhud->nextApplet(); + if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_RIGHT)) + userConsumer->onNavRight(); + else + inkhud->nextApplet(); + } +} + +void InkHUD::Events::onTouchTap(uint16_t x, uint16_t y, bool longPress) +{ + const bool touchEnabledBuild = inkhud->hasTouchEnabledProvider(); + + // A long-press used to open the menu can be followed by a synthetic/queued tap at release. + // Ignore that brief follow-up window so touch-opened menus do not auto-select an item. + if (touchEnabledBuild && !longPress && suppressTouchTapUntilMs != 0) { + if ((int32_t)(millis() - suppressTouchTapUntilMs) < 0) { + noteInkHUDUserInteraction(); + return; } + suppressTouchTapUntilMs = 0; + } + + // Give system applets (menu, keyboard, etc) first chance to consume direct touch input. + for (SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput && sa->onTouchPoint(x, y, longPress)) { + noteInkHUDUserInteraction(); + return; + } + } + + // In split layouts, tapping a different tile changes focus. + // Consume this tap so selection does not also trigger button fallback behavior. + if (inkhud->selectTileAt(x, y)) { + noteInkHUDUserInteraction(); + return; + } + + // Allow foreground user applet to consume direct touch input if it wants. + Applet *userConsumer = inkhud->getActiveApplet(); + if (userConsumer != nullptr && userConsumer->onTouchPoint(x, y, longPress)) { + noteInkHUDUserInteraction(); + return; } + + // Fallback to existing button semantics so non-touch-aware applets keep working unchanged. + if (longPress) { + onButtonLong(); + + // Only arm suppression if the long-press actually opened menu foreground. + SystemApplet *menu = inkhud->getSystemApplet("Menu"); + if (touchEnabledBuild && menu && menu->isForeground()) { + suppressTouchTapUntilMs = millis() + TOUCH_MENU_OPEN_TAP_SUPPRESS_MS; + } + } else + onButtonShort(); } void InkHUD::Events::onFreeText(char c) { + noteInkHUDUserInteraction(); + // Trigger the first system applet that wants to handle the new character for (SystemApplet *sa : inkhud->systemApplets) { if (sa->handleFreeText) { @@ -300,6 +420,8 @@ void InkHUD::Events::onFreeText(char c) void InkHUD::Events::onFreeTextDone() { + noteInkHUDUserInteraction(); + // Trigger the first system applet that wants to handle it for (SystemApplet *sa : inkhud->systemApplets) { if (sa->handleFreeText) { @@ -311,6 +433,8 @@ void InkHUD::Events::onFreeTextDone() void InkHUD::Events::onFreeTextCancel() { + noteInkHUDUserInteraction(); + // Trigger the first system applet that wants to handle it for (SystemApplet *sa : inkhud->systemApplets) { if (sa->handleFreeText) { @@ -487,4 +611,4 @@ bool InkHUD::Events::dismissExternalNotification() return true; } -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Events.h b/src/graphics/niche/InkHUD/Events.h index 873f53fd5f5..ceb298b3b89 100644 --- a/src/graphics/niche/InkHUD/Events.h +++ b/src/graphics/niche/InkHUD/Events.h @@ -17,6 +17,7 @@ however this class handles general events which concern InkHUD as a whole, e.g. #include "./InkHUD.h" #include "./Persistence.h" +#include namespace NicheGraphics::InkHUD { @@ -30,12 +31,17 @@ class Events void onButtonShort(); // User button: short press void onButtonLong(); // User button: long press void applyingChanges(); - void onExitShort(); // Exit button: short press - void onExitLong(); // Exit button: long press - void onNavUp(); // Navigate up - void onNavDown(); // Navigate down - void onNavLeft(); // Navigate left - void onNavRight(); // Navigate right + void onExitShort(); // Exit button: short press + void onExitLong(); // Exit button: long press + void onNavUp(); // Navigate up + void onNavDown(); // Navigate down + void onNavLeft(); // Navigate left + void onNavRight(); // Navigate right + void onTouchNavUp(); // Navigate up from touch input + void onTouchNavDown(); // Navigate down from touch input + void onTouchNavLeft(); // Navigate left from touch input + void onTouchNavRight(); // Navigate right from touch input + void onTouchTap(uint16_t x, uint16_t y, bool longPress); // Touch tap/long-press with coordinates // Free text typing events void onFreeText(char c); // New freetext character input @@ -79,8 +85,11 @@ class Events // If set, InkHUD's data will be erased during onReboot bool eraseOnReboot = false; + + // Suppress follow-up tap generated immediately after a touch long-press opens menu. + uint32_t suppressTouchTapUntilMs = 0; }; } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/InkHUD.cpp b/src/graphics/niche/InkHUD/InkHUD.cpp index edffda6b776..9ed88af5f3a 100644 --- a/src/graphics/niche/InkHUD/InkHUD.cpp +++ b/src/graphics/niche/InkHUD/InkHUD.cpp @@ -73,6 +73,24 @@ void InkHUD::InkHUD::begin() // LogoApplet shows boot screen here } +void InkHUD::InkHUD::setTouchEnabledProvider(TouchEnabledProvider provider) +{ + touchEnabledProvider = provider; +} + +bool InkHUD::InkHUD::hasTouchEnabledProvider() const +{ + return touchEnabledProvider != nullptr; +} + +bool InkHUD::InkHUD::isTouchEnabled() const +{ + if (!touchEnabledProvider) + return true; + + return touchEnabledProvider(); +} + // Call this when your user button gets a short press // Should be connected to an input source in nicheGraphics.h (NicheGraphics::Inputs::TwoButton?) void InkHUD::InkHUD::shortpress() @@ -175,6 +193,92 @@ void InkHUD::InkHUD::navRight() } } +// Call this when touch input needs joystick-like up navigation independent of joystick-enabled mode +void InkHUD::InkHUD::touchNavUp() +{ + switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) { + case 1: // 90 deg + events->onTouchNavLeft(); + break; + case 2: // 180 deg + events->onTouchNavDown(); + break; + case 3: // 270 deg + events->onTouchNavRight(); + break; + default: // 0 deg + events->onTouchNavUp(); + break; + } +} + +// Call this when touch input needs joystick-like down navigation independent of joystick-enabled mode +void InkHUD::InkHUD::touchNavDown() +{ + switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) { + case 1: // 90 deg + events->onTouchNavRight(); + break; + case 2: // 180 deg + events->onTouchNavUp(); + break; + case 3: // 270 deg + events->onTouchNavLeft(); + break; + default: // 0 deg + events->onTouchNavDown(); + break; + } +} + +// Call this when touch input needs joystick-like left navigation independent of joystick-enabled mode +void InkHUD::InkHUD::touchNavLeft() +{ + switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) { + case 1: // 90 deg + events->onTouchNavDown(); + break; + case 2: // 180 deg + events->onTouchNavRight(); + break; + case 3: // 270 deg + events->onTouchNavUp(); + break; + default: // 0 deg + events->onTouchNavLeft(); + break; + } +} + +// Call this when touch input needs joystick-like right navigation independent of joystick-enabled mode +void InkHUD::InkHUD::touchNavRight() +{ + switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) { + case 1: // 90 deg + events->onTouchNavUp(); + break; + case 2: // 180 deg + events->onTouchNavLeft(); + break; + case 3: // 270 deg + events->onTouchNavDown(); + break; + default: // 0 deg + events->onTouchNavRight(); + break; + } +} + +void InkHUD::InkHUD::touchTap(uint16_t x, uint16_t y) +{ + events->onTouchTap(x, y, false); +} + +void InkHUD::InkHUD::touchLongPress(uint16_t x, uint16_t y) +{ + events->onTouchTap(x, y, true); +} + // Call this for keyboard input // The Keyboard Applet also calls this void InkHUD::InkHUD::freeText(char c) @@ -223,6 +327,12 @@ void InkHUD::InkHUD::openMenu() windowManager->openMenu(); } +// Show touch-friendly app switcher (on the focused tile) +void InkHUD::InkHUD::openAppSwitcher() +{ + windowManager->openAppSwitcher(); +} + // Bring AlignStick applet to the foreground void InkHUD::InkHUD::openAlignStick() { @@ -255,6 +365,16 @@ void InkHUD::InkHUD::prevTile() windowManager->prevTile(); } +bool InkHUD::InkHUD::showApplet(uint8_t appletIndex) +{ + return windowManager->showApplet(appletIndex); +} + +bool InkHUD::InkHUD::selectTileAt(uint16_t x, uint16_t y) +{ + return windowManager->selectTileAt(x, y); +} + // Rotate the display image by 90 degrees void InkHUD::InkHUD::rotate() { @@ -376,4 +496,4 @@ void InkHUD::InkHUD::drawPixel(int16_t x, int16_t y, Color c) renderer->handlePixel(x, y, c); } -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/InkHUD.h b/src/graphics/niche/InkHUD/InkHUD.h index abd53951a00..0c1682637ee 100644 --- a/src/graphics/niche/InkHUD/InkHUD.h +++ b/src/graphics/niche/InkHUD/InkHUD.h @@ -39,6 +39,8 @@ class WindowManager; class InkHUD { public: + using TouchEnabledProvider = bool (*)(); + static InkHUD *getInstance(); // Access to this singleton class // Configuration @@ -51,6 +53,11 @@ class InkHUD void begin(); + // Optional touch-state provider for reusable touch status indicators. + void setTouchEnabledProvider(TouchEnabledProvider provider); + bool hasTouchEnabledProvider() const; + bool isTouchEnabled() const; + // Handle user-button press // - connected to an input source, in variant nicheGraphics.h @@ -62,6 +69,12 @@ class InkHUD void navDown(); void navLeft(); void navRight(); + void touchNavUp(); + void touchNavDown(); + void touchNavLeft(); + void touchNavRight(); + void touchTap(uint16_t x, uint16_t y); + void touchLongPress(uint16_t x, uint16_t y); // Freetext handlers void freeText(char c); @@ -76,11 +89,14 @@ class InkHUD void prevApplet(); NicheGraphics::InkHUD::Applet *getActiveApplet(); void openMenu(); + void openAppSwitcher(); void openAlignStick(); void openKeyboard(); void closeKeyboard(); void nextTile(); void prevTile(); + bool showApplet(uint8_t appletIndex); + bool selectTileAt(uint16_t x, uint16_t y); void rotate(); void rotateJoystick(uint8_t angle = 1); // rotate 90 deg by default void toggleBatteryIcon(); @@ -129,6 +145,7 @@ class InkHUD Events *events = nullptr; // Handle non-specific firmware events Renderer *renderer = nullptr; // Co-ordinate display updates WindowManager *windowManager = nullptr; // Multiplexing of applets + TouchEnabledProvider touchEnabledProvider = nullptr; }; } // namespace NicheGraphics::InkHUD diff --git a/src/graphics/niche/InkHUD/Persistence.h b/src/graphics/niche/InkHUD/Persistence.h index 5054b7234f3..187af2129c7 100644 --- a/src/graphics/niche/InkHUD/Persistence.h +++ b/src/graphics/niche/InkHUD/Persistence.h @@ -142,4 +142,4 @@ class Persistence } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/WindowManager.cpp b/src/graphics/niche/InkHUD/WindowManager.cpp index c4a0813d851..804ae8729b0 100644 --- a/src/graphics/niche/InkHUD/WindowManager.cpp +++ b/src/graphics/niche/InkHUD/WindowManager.cpp @@ -3,11 +3,13 @@ #include "./WindowManager.h" #include "./Applets/System/AlignStick/AlignStickApplet.h" +#include "./Applets/System/AppSwitcher/AppSwitcherApplet.h" #include "./Applets/System/BatteryIcon/BatteryIconApplet.h" #include "./Applets/System/Keyboard/KeyboardApplet.h" #include "./Applets/System/Logo/LogoApplet.h" #include "./Applets/System/Menu/MenuApplet.h" #include "./Applets/System/Notification/NotificationApplet.h" +#include "./Applets/System/Notification/TouchStatusApplet.h" #include "./Applets/System/Pairing/PairingApplet.h" #include "./Applets/System/Placeholder/PlaceholderApplet.h" #include "./Applets/System/Tips/TipsApplet.h" @@ -15,6 +17,14 @@ using namespace NicheGraphics; +namespace +{ +bool supportsOnScreenKeyboard(const InkHUD::InkHUD *inkhud, const InkHUD::Persistence::Settings *settings) +{ + return !inkhud->twoWayRocker && (settings->joystick.enabled || inkhud->hasTouchEnabledProvider()); +} +} // namespace + InkHUD::WindowManager::WindowManager() { // Convenient references @@ -132,6 +142,38 @@ void InkHUD::WindowManager::prevTile() userTiles.at(settings->userTiles.focused)->requestHighlight(); } +// Focus the user tile containing a touch coordinate. +// Returns true only when the focused tile changes. +bool InkHUD::WindowManager::selectTileAt(uint16_t x, uint16_t y) +{ + if (userTiles.size() < 2) + return false; + + const int32_t tx = x; + const int32_t ty = y; + + for (uint8_t i = 0; i < userTiles.size(); i++) { + Tile *tile = userTiles.at(i); + + const int32_t left = tile->getLeft(); + const int32_t top = tile->getTop(); + const int32_t right = left + tile->getWidth(); + const int32_t bottom = top + tile->getHeight(); + + if (tx < left || tx >= right || ty < top || ty >= bottom) + continue; + + if (settings->userTiles.focused == i) + return false; + + settings->userTiles.focused = i; + refocusTile(); + return true; + } + + return false; +} + // Show the menu (on the the focused tile) // The applet previously displayed there will be restored once the menu closes void InkHUD::WindowManager::openMenu() @@ -140,6 +182,18 @@ void InkHUD::WindowManager::openMenu() menu->show(userTiles.at(settings->userTiles.focused)); } +// Show touch-only app switcher on the focused tile +void InkHUD::WindowManager::openAppSwitcher() +{ + if (!inkhud->hasTouchEnabledProvider()) + return; + + AppSwitcherApplet *switcher = static_cast(inkhud->getSystemApplet("AppSwitcher")); + if (switcher) { + switcher->show(userTiles.at(settings->userTiles.focused)); + } +} + // Bring the AlignStick applet to the foreground void InkHUD::WindowManager::openAlignStick() { @@ -151,7 +205,7 @@ void InkHUD::WindowManager::openAlignStick() void InkHUD::WindowManager::openKeyboard() { - if (!settings->joystick.enabled || inkhud->twoWayRocker) + if (!supportsOnScreenKeyboard(inkhud, settings)) return; KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard"); @@ -165,7 +219,7 @@ void InkHUD::WindowManager::openKeyboard() void InkHUD::WindowManager::closeKeyboard() { - if (!settings->joystick.enabled || inkhud->twoWayRocker) + if (!supportsOnScreenKeyboard(inkhud, settings)) return; KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard"); @@ -279,6 +333,41 @@ void InkHUD::WindowManager::prevApplet() inkhud->forceUpdate(EInk::UpdateTypes::FAST); // bringToForeground already requested, but we're manually forcing FAST } +// Show a specific applet on the focused tile, or focus the tile where it is already shown. +bool InkHUD::WindowManager::showApplet(uint8_t appletIndex) +{ + if (appletIndex >= inkhud->userApplets.size()) + return false; + + Applet *target = inkhud->userApplets.at(appletIndex); + if (!target || !target->isActive()) + return false; + + // If target is already visible on another tile, just focus that tile. + for (uint8_t i = 0; i < userTiles.size(); i++) { + if (userTiles.at(i)->getAssignedApplet() == target) { + settings->userTiles.focused = i; + refocusTile(); + if (!settings->optionalMenuItems.nextTile) + userTiles.at(settings->userTiles.focused)->requestHighlight(); + inkhud->forceUpdate(EInk::UpdateTypes::FAST); + return true; + } + } + + // Otherwise replace the focused tile's applet. + Tile *focused = userTiles.at(settings->userTiles.focused); + Applet *current = focused->getAssignedApplet(); + if (current && current != target) + current->sendToBackground(); + + focused->assignApplet(target); + target->bringToForeground(); + settings->userTiles.displayedUserApplet[settings->userTiles.focused] = appletIndex; + inkhud->forceUpdate(EInk::UpdateTypes::FAST); + return true; +} + // Returns active applet NicheGraphics::InkHUD::Applet *InkHUD::WindowManager::getActiveApplet() { @@ -485,14 +574,21 @@ void InkHUD::WindowManager::createSystemApplets() addSystemApplet("Tips", new TipsApplet, new Tile); if (settings->joystick.enabled && !inkhud->twoWayRocker) { addSystemApplet("AlignStick", new AlignStickApplet, new Tile); + } + if (supportsOnScreenKeyboard(inkhud, settings)) { addSystemApplet("Keyboard", new KeyboardApplet, new Tile); } + if (inkhud->hasTouchEnabledProvider()) { + addSystemApplet("AppSwitcher", new AppSwitcherApplet, nullptr); + } addSystemApplet("Menu", new MenuApplet, nullptr); // Battery and notifications *behind* the menu addSystemApplet("Notification", new NotificationApplet, new Tile); addSystemApplet("BatteryIcon", new BatteryIconApplet, new Tile); + if (inkhud->hasTouchEnabledProvider()) + addSystemApplet("TouchStatus", new TouchStatusApplet, new Tile); // Special handling only, via Rendering::renderPlaceholders addSystemApplet("Placeholder", new PlaceholderApplet, nullptr); @@ -511,6 +607,8 @@ void InkHUD::WindowManager::placeSystemTiles() inkhud->getSystemApplet("Tips")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height()); if (settings->joystick.enabled && !inkhud->twoWayRocker) { inkhud->getSystemApplet("AlignStick")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height()); + } + if (supportsOnScreenKeyboard(inkhud, settings)) { const uint16_t keyboardHeight = KeyboardApplet::getKeyboardHeight(); inkhud->getSystemApplet("Keyboard") ->getTile() @@ -527,6 +625,17 @@ void InkHUD::WindowManager::placeSystemTiles() batteryIconWidth + 1, // width batteryIconHeight + 2); // height + if (inkhud->hasTouchEnabledProvider()) { + const uint16_t touchStatusH = Applet::fontSmall.lineHeight() + 4; + inkhud->getSystemApplet("TouchStatus") + ->getTile() + ->setRegion(0, inkhud->height() - touchStatusH, inkhud->width(), touchStatusH); + if (inkhud->isTouchEnabled()) + inkhud->getSystemApplet("TouchStatus")->sendToBackground(); + else + inkhud->getSystemApplet("TouchStatus")->bringToForeground(); + } + // Note: the tiles of placeholder and menu applets are manipulated specially // - menuApplet borrows user tiles // - placeholder applet is temporarily assigned to each user tile of WindowManager::getEmptyTiles diff --git a/src/graphics/niche/InkHUD/WindowManager.h b/src/graphics/niche/InkHUD/WindowManager.h index a11688cf5de..1ab33c08587 100644 --- a/src/graphics/niche/InkHUD/WindowManager.h +++ b/src/graphics/niche/InkHUD/WindowManager.h @@ -29,13 +29,16 @@ class WindowManager void nextTile(); void prevTile(); + bool selectTileAt(uint16_t x, uint16_t y); Applet *getActiveApplet(); void openMenu(); void openAlignStick(); + void openAppSwitcher(); void openKeyboard(); void closeKeyboard(); void nextApplet(); void prevApplet(); + bool showApplet(uint8_t appletIndex); void rotate(); void toggleBatteryIcon(); @@ -76,4 +79,4 @@ class WindowManager } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/input/TouchScreenBase.cpp b/src/input/TouchScreenBase.cpp index fceac74ba54..a3d03d4acf5 100644 --- a/src/input/TouchScreenBase.cpp +++ b/src/input/TouchScreenBase.cpp @@ -9,6 +9,35 @@ #define TIME_LONG_PRESS 400 #endif +// Touch sampling cadence (milliseconds). +// Can be overridden by board variants for faster touch panels. +#ifndef TOUCH_POLL_INTERVAL_IDLE +#define TOUCH_POLL_INTERVAL_IDLE 100 +#endif + +#ifndef TOUCH_POLL_INTERVAL_ACTIVE +#define TOUCH_POLL_INTERVAL_ACTIVE 20 +#endif + +#ifndef TOUCH_POLL_INTERVAL_RELEASE +#define TOUCH_POLL_INTERVAL_RELEASE 50 +#endif + +// Faster cadence used for keyboard-like tap-heavy UIs. +#ifndef TOUCH_POLL_INTERVAL_ACTIVE_FAST +#define TOUCH_POLL_INTERVAL_ACTIVE_FAST TOUCH_POLL_INTERVAL_ACTIVE +#endif + +#ifndef TOUCH_POLL_INTERVAL_RELEASE_FAST +#define TOUCH_POLL_INTERVAL_RELEASE_FAST TOUCH_POLL_INTERVAL_RELEASE +#endif + +// Ignore very short "finger lifted" glitches from noisy touch controllers. +// A release is only accepted once we've seen no-touch for at least this duration. +#ifndef TOUCH_RELEASE_GRACE_MS +#define TOUCH_RELEASE_GRACE_MS 35 +#endif + // move a minimum distance over the screen to detect a "swipe" #ifndef TOUCH_THRESHOLD_X #define TOUCH_THRESHOLD_X 30 @@ -20,7 +49,7 @@ TouchScreenBase::TouchScreenBase(const char *name, uint16_t width, uint16_t height) : concurrency::OSThread(name), _display_width(width), _display_height(height), _first_x(0), _last_x(0), _first_y(0), - _last_y(0), _start(0), _tapped(false), _originName(name) + _last_y(0), _start(0), _lastTouchSeenMs(0), _tapped(false), _originName(name) { } @@ -28,7 +57,7 @@ void TouchScreenBase::init(bool hasTouch) { if (hasTouch) { LOG_INFO("TouchScreen initialized %d %d", TOUCH_THRESHOLD_X, TOUCH_THRESHOLD_Y); - this->setInterval(100); + this->setInterval(TOUCH_POLL_INTERVAL_IDLE); } else { disable(); this->setInterval(UINT_MAX); @@ -39,6 +68,8 @@ int32_t TouchScreenBase::runOnce() { TouchEvent e; e.touchEvent = static_cast(TOUCH_ACTION_NONE); + const bool fastTapMode = fastTapModeEnabled(); + const bool allowLongPress = longPressEnabled(); // process touch events int16_t x, y; @@ -46,9 +77,13 @@ int32_t TouchScreenBase::runOnce() if (x < 0 || y < 0) // T-deck can emit phantom touch events with a negative value when turning off the screen touched = false; if (touched) { - this->setInterval(20); + _lastTouchSeenMs = millis(); + this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_ACTIVE_FAST : TOUCH_POLL_INTERVAL_ACTIVE); _last_x = x; _last_y = y; + } else if (_touchedOld && ((uint32_t)millis() - _lastTouchSeenMs) < TOUCH_RELEASE_GRACE_MS) { + // Treat brief no-touch samples as continuous touch to preserve long-press detection. + touched = true; } if (touched != _touchedOld) { if (touched) { @@ -62,7 +97,7 @@ int32_t TouchScreenBase::runOnce() time_t duration = millis() - _start; x = _last_x; y = _last_y; - this->setInterval(50); + this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_RELEASE_FAST : TOUCH_POLL_INTERVAL_RELEASE); // compute distance int16_t dx = x - _first_x; @@ -92,7 +127,7 @@ int32_t TouchScreenBase::runOnce() } // tap else { - if (duration > 0 && duration < TIME_LONG_PRESS) { + if (duration > 0 && (duration < TIME_LONG_PRESS || !allowLongPress)) { if (_tapped) { _tapped = false; } else { @@ -132,7 +167,7 @@ int32_t TouchScreenBase::runOnce() #endif // fire LONG_PRESS event without the need for release - if (touched && (time_t(millis()) - _start) > TIME_LONG_PRESS) { + if (allowLongPress && touched && (time_t(millis()) - _start) > TIME_LONG_PRESS) { // tricky: prevent reoccurring events and another touch event when releasing _start = millis() + 30000; e.touchEvent = static_cast(TOUCH_ACTION_LONG_PRESS); @@ -157,3 +192,13 @@ void TouchScreenBase::hapticFeedback() drv.go(); #endif } + +bool TouchScreenBase::fastTapModeEnabled() const +{ + return false; +} + +bool TouchScreenBase::longPressEnabled() const +{ + return true; +} diff --git a/src/input/TouchScreenBase.h b/src/input/TouchScreenBase.h index 90314cf0232..e08c4832b65 100644 --- a/src/input/TouchScreenBase.h +++ b/src/input/TouchScreenBase.h @@ -35,6 +35,8 @@ class TouchScreenBase : public Observable, public concurrenc virtual bool getTouch(int16_t &x, int16_t &y) = 0; virtual void onEvent(const TouchEvent &event) = 0; + virtual bool fastTapModeEnabled() const; + virtual bool longPressEnabled() const; volatile TouchScreenBaseStateType _state = TOUCH_EVENT_CLEARED; volatile TouchScreenBaseEventType _action = TOUCH_ACTION_NONE; @@ -49,6 +51,7 @@ class TouchScreenBase : public Observable, public concurrenc int16_t _first_x, _last_x; // horizontal swipe direction int16_t _first_y, _last_y; // vertical swipe direction time_t _start; // for LONG_PRESS + uint32_t _lastTouchSeenMs; // helps suppress brief touch-controller dropouts bool _tapped; // for DOUBLE_TAP const char *_originName; diff --git a/src/input/TouchScreenImpl1.cpp b/src/input/TouchScreenImpl1.cpp index 14f95b73a52..0f1c9d023a6 100644 --- a/src/input/TouchScreenImpl1.cpp +++ b/src/input/TouchScreenImpl1.cpp @@ -3,6 +3,12 @@ #include "PowerFSM.h" #include "configuration.h" #include "modules/ExternalNotificationModule.h" +#include + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +#include "graphics/niche/InkHUD/InkHUD.h" +#include "graphics/niche/InkHUD/SystemApplet.h" +#endif #if ARCH_PORTDUINO #include "platform/portduino/PortduinoGlue.h" @@ -39,6 +45,31 @@ bool TouchScreenImpl1::getTouch(int16_t &x, int16_t &y) return _getTouch(&x, &y); } +bool TouchScreenImpl1::fastTapModeEnabled() const +{ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + const auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + if (!inkhud) { + return false; + } + + for (auto *sa : inkhud->systemApplets) { + if (!sa || !sa->name) { + continue; + } + if (strcmp(sa->name, "Keyboard") == 0) { + return sa->isForeground(); + } + } +#endif + return false; +} + +bool TouchScreenImpl1::longPressEnabled() const +{ + return !fastTapModeEnabled(); +} + /** * @brief forward touchscreen event * @@ -83,4 +114,4 @@ void TouchScreenImpl1::onEvent(const TouchEvent &event) return; } this->notifyObservers(&e); -} \ No newline at end of file +} diff --git a/src/input/TouchScreenImpl1.h b/src/input/TouchScreenImpl1.h index 0c533845991..3629982ef64 100644 --- a/src/input/TouchScreenImpl1.h +++ b/src/input/TouchScreenImpl1.h @@ -10,6 +10,8 @@ class TouchScreenImpl1 : public TouchScreenBase protected: virtual bool getTouch(int16_t &x, int16_t &y); virtual void onEvent(const TouchEvent &event); + bool fastTapModeEnabled() const override; + bool longPressEnabled() const override; bool (*_getTouch)(int16_t *, int16_t *); }; diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp deleted file mode 100644 index 827b3f5bd71..00000000000 --- a/src/platform/extra_variants/t5s3_epaper/variant.cpp +++ /dev/null @@ -1,144 +0,0 @@ -#include "configuration.h" - -#ifdef T5_S3_EPAPER_PRO - -#include "Observer.h" -#include "TouchDrvGT911.hpp" -#include "Wire.h" -#include "input/InputBroker.h" -#include "input/TouchScreenImpl1.h" -#include "sleep.h" - -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS -#include "graphics/niche/InkHUD/InkHUD.h" -#include "graphics/niche/InkHUD/SystemApplet.h" - -// Bridges touch events from TouchScreenImpl1 directly into InkHUD, -// bypassing the InputBroker (which is excluded in InkHUD builds). -// Routing mirrors the mini-epaper-s3 two-way rocker pattern: -// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu) -// - Nav up/down: navUp/navDown always (menu scroll) -// - Tap: shortpress (cycle applets / confirm in menu) -// - Long press: longpress (open menu / back) -class TouchInkHUDBridge : public Observer -{ - int onNotify(const InputEvent *e) override - { - auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); - - // Keep alignment in sync with the current rotation so that visual-frame gestures - // always pass through nav functions without remapping: (rotation + alignment) % 4 == 0. - inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; - - // Check whether a system applet (e.g. menu) is currently handling input - bool systemHandlingInput = false; - for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { - if (sa->handleInput) { - systemHandlingInput = true; - break; - } - } - - switch (e->inputEvent) { - case INPUT_BROKER_USER_PRESS: - inkhud->shortpress(); - break; - case INPUT_BROKER_SELECT: - inkhud->longpress(); - break; - case INPUT_BROKER_LEFT: - if (systemHandlingInput) - inkhud->navUp(); - else - inkhud->prevApplet(); - break; - case INPUT_BROKER_RIGHT: - if (systemHandlingInput) - inkhud->navDown(); - else - inkhud->nextApplet(); - break; - case INPUT_BROKER_UP: - inkhud->navUp(); - break; - case INPUT_BROKER_DOWN: - inkhud->navDown(); - break; - default: - break; - } - return 0; - } -}; - -static TouchInkHUDBridge touchBridge; -#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS - -TouchDrvGT911 touch; - -// Commands the GT911 into standby before the Wire bus is torn down. -// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here. -struct TouchDeepSleepObserver { - int onDeepSleep(void *) - { - touch.sleep(); - return 0; - } - CallbackObserver observer{this, &TouchDeepSleepObserver::onDeepSleep}; -} static touchDeepSleepObserver; - -bool readTouch(int16_t *x, int16_t *y) -{ - if (!digitalRead(GT911_PIN_INT)) { - int16_t raw_x; - int16_t raw_y; - if (touch.getPoint(&raw_x, &raw_y)) { -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. - // rotation=3 is the physical identity (device's default orientation). - switch (NicheGraphics::InkHUD::InkHUD::getInstance()->persistence->settings.rotation) { - default: - case 3: - *x = raw_x; - *y = raw_y; - break; // identity - case 2: - *x = (EPD_WIDTH - 1) - raw_y; - *y = raw_x; - break; // 90° CW tilt - case 1: - *x = (EPD_HEIGHT - 1) - raw_x; - *y = (EPD_WIDTH - 1) - raw_y; - break; // 180° flip - case 0: - *x = raw_y; - *y = (EPD_HEIGHT - 1) - raw_x; - break; // 90° CCW tilt - } -#else - *x = raw_x; - *y = raw_y; -#endif - LOG_DEBUG("touched(%d/%d)", *x, *y); - return true; - } - } - return false; -} - -// T5-S3-ePaper Pro specific (late-) init -void lateInitVariant(void) -{ - touch.setPins(GT911_PIN_RST, GT911_PIN_INT); - if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) { - touchDeepSleepObserver.observer.observe(¬ifyDeepSleep); - touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch); - touchScreenImpl1->init(); -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - touchBridge.observe(touchScreenImpl1); -#endif - } else { - LOG_ERROR("Failed to find touch controller!"); - } -} -#endif diff --git a/src/sleep.cpp b/src/sleep.cpp index 64bd0c48033..a2a943a1fb0 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -316,9 +316,14 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN #ifdef HAS_PPM if (PPM) { - LOG_INFO("PMM shutdown"); - console->flush(); - PPM->shutdown(); + // BQ25896 PMIC shutdown is a hard power-off state. + // Only use it for "sleep forever" / explicit shutdown, because timed deep sleep + // must remain wakeable by RTC timer. + if (msecToWake == portMAX_DELAY) { + LOG_INFO("PPM shutdown"); + console->flush(); + PPM->shutdown(); + } } #endif @@ -425,6 +430,10 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r #ifdef KB_INT gpio_wakeup_enable((gpio_num_t)KB_INT, GPIO_INTR_LOW_LEVEL); #endif +#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); +#endif #ifdef BUTTON_PIN gpio_num_t pin = (gpio_num_t)(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN); gpio_wakeup_enable(pin, GPIO_INTR_LOW_LEVEL); @@ -472,6 +481,9 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r #ifdef KB_INT gpio_wakeup_disable((gpio_num_t)KB_INT); #endif +#ifdef BOARD_PCA9535_INT + gpio_wakeup_disable((gpio_num_t)BOARD_PCA9535_INT); +#endif #ifdef BUTTON_PIN // Disable wake-on-button interrupt. Re-attach normal button-interrupts gpio_wakeup_disable(pin); diff --git a/variants/esp32s3/t5s3_epaper/nicheGraphics.h b/variants/esp32s3/t5s3_epaper/nicheGraphics.h index 18217800bb6..6b5f434ba0c 100644 --- a/variants/esp32s3/t5s3_epaper/nicheGraphics.h +++ b/variants/esp32s3/t5s3_epaper/nicheGraphics.h @@ -34,7 +34,6 @@ This is driven via the FastEPD library through the NicheGraphics ED047TC1 driver // Shared NicheGraphics components // -------------------------------- -#include "graphics/niche/Drivers/Backlight/LatchingBacklight.h" #include "graphics/niche/Drivers/EInk/ED047TC1.h" #include "graphics/niche/Inputs/TwoButton.h" @@ -72,7 +71,7 @@ void setupNicheGraphics() inkhud->persistence->settings.rotation = 3; // 270 degrees clockwise inkhud->persistence->settings.userTiles.count = 1; // One tile only by default, keep things simple for new users inkhud->persistence->settings.optionalFeatures.batteryIcon = true; - inkhud->persistence->settings.optionalMenuItems.backlight = true; + inkhud->persistence->settings.optionalMenuItems.backlight = false; // Alignment must cancel rotation for visual-frame touch input: (rotation + alignment) % 4 == 0. inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; @@ -88,30 +87,32 @@ void setupNicheGraphics() inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // Not Active, not autoshown - // Backlight - // ---------------------------- - Drivers::LatchingBacklight *backlight = Drivers::LatchingBacklight::getInstance(); - backlight->setPin(BOARD_BL_EN); // GPIO11 on V2 + // Enable reusable InkHUD touch status indicator for this touch-capable board. + inkhud->setTouchEnabledProvider(isTouchInputEnabled); // Start running InkHUD inkhud->begin(); + // Arm GT911 capacitive-home callback only after InkHUD startup is complete. + t5SetHomeCapButtonEventsEnabled(true); - // Touch navigation requires joystick mode — enforce post-begin so flash cannot override. - inkhud->persistence->settings.joystick.enabled = true; - inkhud->persistence->settings.joystick.aligned = true; + // Keep Wireless Paper single-button semantics regardless of persisted settings: + // short press advances, long press opens menu/selects. + inkhud->persistence->settings.joystick.enabled = false; // Buttons // -------------------------- Inputs::TwoButton *buttons = Inputs::TwoButton::getInstance(); // A shared NicheGraphics component - // Setup the main user button (boot button, GPIO 0) + // #0: BOOT button (primary user input for InkHUD navigation on T5-S3) +#if defined(T5_S3_EPAPER_PRO_V1) + buttons->setWiring(0, PIN_BUTTON2); +#else buttons->setWiring(0, BUTTON_PIN); +#endif buttons->setHandlerShortPress(0, [inkhud]() { inkhud->shortpress(); }); buttons->setHandlerLongPress(0, [inkhud]() { inkhud->longpress(); }); - // No dedicated aux button on this board - buttons->start(); } diff --git a/variants/esp32s3/t5s3_epaper/variant.cpp b/variants/esp32s3/t5s3_epaper/variant.cpp index f4074bd5774..4b82be50a61 100644 --- a/variants/esp32s3/t5s3_epaper/variant.cpp +++ b/variants/esp32s3/t5s3_epaper/variant.cpp @@ -5,12 +5,17 @@ #include "Observer.h" #include "TouchDrvGT911.hpp" #include "Wire.h" +#include "buzz.h" +#include "concurrency/OSThread.h" #include "input/InputBroker.h" #include "input/TouchScreenImpl1.h" +#include "main.h" #include "sleep.h" +#include #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS #include "graphics/niche/InkHUD/InkHUD.h" +#include "graphics/niche/InkHUD/Persistence.h" #include "graphics/niche/InkHUD/SystemApplet.h" // Bridges touch events from TouchScreenImpl1 directly into InkHUD, @@ -18,8 +23,7 @@ // Routing mirrors the mini-epaper-s3 two-way rocker pattern: // - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu) // - Nav up/down: navUp/navDown always (menu scroll) -// - Tap: shortpress (cycle applets / confirm in menu) -// - Long press: longpress (open menu / back) +// - Tap/long-press: direct touch point dispatch (with fallback to short/long button semantics) class TouchInkHUDBridge : public Observer { int onNotify(const InputEvent *e) override @@ -41,28 +45,28 @@ class TouchInkHUDBridge : public Observer switch (e->inputEvent) { case INPUT_BROKER_USER_PRESS: - inkhud->shortpress(); + inkhud->touchTap(e->touchX, e->touchY); break; case INPUT_BROKER_SELECT: - inkhud->longpress(); + inkhud->touchLongPress(e->touchX, e->touchY); break; case INPUT_BROKER_LEFT: if (systemHandlingInput) - inkhud->navUp(); + inkhud->touchNavUp(); else inkhud->prevApplet(); break; case INPUT_BROKER_RIGHT: if (systemHandlingInput) - inkhud->navDown(); + inkhud->touchNavDown(); else inkhud->nextApplet(); break; case INPUT_BROKER_UP: - inkhud->navUp(); + inkhud->touchNavUp(); break; case INPUT_BROKER_DOWN: - inkhud->navDown(); + inkhud->touchNavDown(); break; default: break; @@ -76,6 +80,430 @@ static TouchInkHUDBridge touchBridge; TouchDrvGT911 touch; +namespace +{ +constexpr uint8_t BACKLIGHT_ON_LEVEL = HIGH; +constexpr uint8_t BACKLIGHT_OFF_LEVEL = LOW; +volatile bool backlightUserEnabled = true; +volatile bool backlightForcedByTimeout = false; +volatile bool backlightForcedBySleep = false; + +void applyBacklightState() +{ + const bool shouldOn = backlightUserEnabled && !backlightForcedByTimeout && !backlightForcedBySleep; + digitalWrite(BOARD_BL_EN, shouldOn ? BACKLIGHT_ON_LEVEL : BACKLIGHT_OFF_LEVEL); +} + +volatile bool touchInputEnabled = true; +volatile bool touchForcedByTimeout = false; +volatile bool touchControllerReady = false; +volatile bool touchLightSleepActive = false; +volatile bool touchNeedsWake = false; +volatile bool touchIndicatorRefreshPending = false; +volatile uint32_t touchResumeBlockUntilMs = 0; +volatile uint32_t touchStateEpoch = 1; +volatile bool homeCapButtonEventsEnabled = false; +#if HAS_SCREEN +uint32_t lastTouchIndicatorMs = 0; +#endif + +void showTouchIndicator(const char *text) +{ +#if HAS_SCREEN +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // InkHUD builds render a dedicated bottom-edge "TOUCH OFF" overlay instead of popup banners. + (void)text; + return; +#else + // Keep repeated notifications low profile and non-spammy. + if ((millis() - lastTouchIndicatorMs) < 500) { + return; + } + lastTouchIndicatorMs = millis(); + if (screen) { + screen->showSimpleBanner(text, 1400); + } +#endif +#else + (void)text; +#endif +} + +#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) +bool readPca9535Port1(uint8_t *value) +{ + if (!value) { + return false; + } + + Wire.beginTransmission(BOARD_PCA9535_ADDR); + Wire.write((uint8_t)0x01); // input port 1 + if (Wire.endTransmission(false) != 0) { + return false; + } + if (Wire.requestFrom((uint8_t)BOARD_PCA9535_ADDR, (uint8_t)1) != 1) { + return false; + } + + *value = Wire.read(); + return true; +} + +bool isPca9535SideKeyPressed() +{ + uint8_t port1 = 0xFF; + if (!readPca9535Port1(&port1)) { + return false; + } + + return (port1 & BOARD_PCA9535_BUTTON_MASK) == 0; +} + +class SideKeyInterruptThread : public concurrency::OSThread +{ + public: + SideKeyInterruptThread() : concurrency::OSThread("t5s3SideKeyInt", SAMPLE_MS) + { + // Do not run unless an edge arrives. + OSThread::disable(); + instance = this; +#ifdef ARCH_ESP32 + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif + } + + void begin() + { + pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); + attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING); + } + + protected: + int32_t runOnce() override + { + const uint32_t now = millis(); + + if (now < touchResumeBlockUntilMs) { + resetStateAndStop(); + return OSThread::disable(); + } + + if (touchLightSleepActive) { + resetStateAndStop(); + return OSThread::disable(); + } + + // Ignore side-key handling while BOOT/user button is held. + if (digitalRead(BUTTON_PIN) == LOW) { + resetStateAndStop(); + return OSThread::disable(); + } + + switch (state) { + case State::IRQ_PENDING: + // Initial debounce after expander interrupt edge. + if ((uint32_t)(now - irqAtMs) < DEBOUNCE_MS) { + return SAMPLE_MS; + } + + if (isPca9535SideKeyPressed()) { + state = State::PRESSED; + pressStartMs = now; + return SAMPLE_MS; + } + + // Spurious/cleared edge. + resetStateAndStop(); + return OSThread::disable(); + + case State::PRESSED: { + if (isPca9535SideKeyPressed()) { + // Fire long-press action as soon as threshold is reached, without waiting for release. + if (!longPressFired && (uint32_t)(now - pressStartMs) >= LONG_PRESS_MIN_MS && + (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) { + t5BacklightToggleUser(); + longPressFired = true; + lastActionMs = now; + } + return SAMPLE_MS; + } + + // Released: if long-press already fired, do nothing. Otherwise classify short press. + const uint32_t heldMs = now - pressStartMs; + if (!longPressFired && heldMs >= SHORT_PRESS_MIN_MS && (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) { + // If timeout forced touch/backlight off, short-press acts as a wake action first. + if (t5TouchIsForcedByTimeout()) { + t5TouchHandleUserInput(); + t5BacklightHandleUserInput(); + } else { + toggleTouchInputEnabled(); + } + lastActionMs = now; + } + + resetStateAndStop(); + return OSThread::disable(); + } + + case State::REST: + default: + return OSThread::disable(); + } + } + + private: + enum class State : uint8_t { + REST, + IRQ_PENDING, + PRESSED, + }; + + static constexpr uint32_t SAMPLE_MS = 15; + static constexpr uint32_t DEBOUNCE_MS = 25; + static constexpr uint32_t SHORT_PRESS_MIN_MS = 30; + static constexpr uint32_t LONG_PRESS_MIN_MS = 450; + static constexpr uint32_t ACTION_COOLDOWN_MS = 180; + + static SideKeyInterruptThread *instance; + + static void isr() + { + if (instance) { + instance->onInterruptEdge(); + } + } + + void onInterruptEdge() + { + if (touchLightSleepActive) { + return; + } + const uint32_t now = millis(); + if (now < touchResumeBlockUntilMs) { + return; + } + if (state != State::REST) { + return; + } + + state = State::IRQ_PENDING; + irqAtMs = millis(); + startThread(); + } + + void startThread() + { + if (!OSThread::enabled) { + OSThread::setIntervalFromNow(0); + OSThread::enabled = true; + runASAP = true; + } + } + + void resetStateAndStop() + { + state = State::REST; + longPressFired = false; + if (OSThread::enabled) { + OSThread::disable(); + } + } + +#ifdef ARCH_ESP32 + int onLightSleep(void *) + { + detachInterrupt(BOARD_PCA9535_INT); + // Clear any latched PCA9535 interrupt before enabling GPIO wake. + // If INT is left asserted low, light sleep exits immediately. + uint8_t ignored = 0xFF; + (void)readPca9535Port1(&ignored); + resetStateAndStop(); + return 0; + } + + int onLightSleepEnd(esp_sleep_wakeup_cause_t cause) + { + (void)cause; + // Consume any pending interrupt source before reattaching ISR. + uint8_t ignored = 0xFF; + (void)readPca9535Port1(&ignored); + pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); + attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING); + + return 0; + } + + CallbackObserver lsObserver{this, &SideKeyInterruptThread::onLightSleep}; + CallbackObserver lsEndObserver{this, + &SideKeyInterruptThread::onLightSleepEnd}; +#endif + + volatile State state = State::REST; + volatile uint32_t irqAtMs = 0; + uint32_t pressStartMs = 0; + bool longPressFired = false; + uint32_t lastActionMs = 0; +}; + +SideKeyInterruptThread *SideKeyInterruptThread::instance = nullptr; +SideKeyInterruptThread *sideKeyThread = nullptr; +#endif + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +void refreshTouchIndicatorInInkHUD(bool async = true) +{ + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + NicheGraphics::InkHUD::SystemApplet *touchStatus = nullptr; + for (auto *sa : inkhud->systemApplets) { + if (sa && sa->name && strcmp(sa->name, "TouchStatus") == 0) { + touchStatus = sa; + break; + } + } + + if (touchStatus) { + if (inkhud->isTouchEnabled()) + touchStatus->sendToBackground(); + else + touchStatus->bringToForeground(); + } + + // Re-render all applets so touch-status visibility changes are immediately reflected. + inkhud->forceUpdate(NicheGraphics::Drivers::EInk::UpdateTypes::FAST, true, async); +} +#endif + +} // namespace + +void t5BacklightSetUserEnabled(bool enabled) +{ + backlightUserEnabled = enabled; + if (enabled) { + // Manual ON should release auto-off gates. + backlightForcedByTimeout = false; + backlightForcedBySleep = false; + } + applyBacklightState(); +} + +bool t5BacklightIsUserEnabled() +{ + return backlightUserEnabled; +} + +void t5BacklightToggleUser() +{ + t5BacklightSetUserEnabled(!backlightUserEnabled); +} + +void t5BacklightSetForcedByTimeout(bool forced) +{ + backlightForcedByTimeout = forced; + applyBacklightState(); +} + +void t5BacklightSetForcedBySleep(bool forced) +{ + backlightForcedBySleep = forced; + applyBacklightState(); +} + +void t5BacklightHandleUserInput() +{ + // Screen-timeout should be lifted by direct user interaction. + backlightForcedByTimeout = false; + applyBacklightState(); +} + +void t5TouchSetForcedByTimeout(bool forced) +{ + touchForcedByTimeout = forced; + touchStateEpoch++; + touchIndicatorRefreshPending = true; + + if (forced) { + // While timeout-forced, keep controller asleep to avoid stale IRQ chatter. + touchNeedsWake = false; + if (touchControllerReady && !touchLightSleepActive) { + touch.sleep(); + } + } else if (touchInputEnabled && touchControllerReady && !touchLightSleepActive) { + // Defer wake until readTouch() so I2C settles post-state transition. + touchNeedsWake = true; + } + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + if (!touchLightSleepActive) { + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; + } +#endif +} + +bool t5TouchIsForcedByTimeout() +{ + return touchForcedByTimeout; +} + +void t5TouchHandleUserInput() +{ + t5TouchSetForcedByTimeout(false); +} + +void t5SetHomeCapButtonEventsEnabled(bool enabled) +{ + homeCapButtonEventsEnabled = enabled; +} + +bool isTouchInputEnabled() +{ + return touchInputEnabled && !touchForcedByTimeout && !touchLightSleepActive; +} + +void setTouchInputEnabled(bool enabled, bool showIndicator) +{ + if (touchInputEnabled == enabled) { + LOG_DEBUG("touchscreen1: setTouchInputEnabled no-op en=%d", enabled); + return; + } + + LOG_DEBUG("touchscreen1: setTouchInputEnabled %d -> %d (showIndicator=%d)", touchInputEnabled, enabled, showIndicator); + touchInputEnabled = enabled; + touchStateEpoch++; + + if (enabled) { + touchNeedsWake = touchControllerReady; + if (touchControllerReady && !touchLightSleepActive) { + LOG_DEBUG("touchscreen1: wakeup() on enable"); + touch.wakeup(); + touchNeedsWake = false; + } + } else { + touchNeedsWake = false; + if (touchControllerReady && !touchLightSleepActive) { + LOG_DEBUG("touchscreen1: sleep() on disable"); + touch.sleep(); + } + if (showIndicator) { + showTouchIndicator("Touch OFF"); + touchIndicatorRefreshPending = true; + } + } + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + if (showIndicator && !touchLightSleepActive) { + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; + } +#endif +} + +void toggleTouchInputEnabled() +{ + setTouchInputEnabled(!touchInputEnabled, true); +} + // Commands the GT911 into standby before the Wire bus is torn down. // notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here. struct TouchDeepSleepObserver { @@ -87,8 +515,96 @@ struct TouchDeepSleepObserver { CallbackObserver observer{this, &TouchDeepSleepObserver::onDeepSleep}; } static touchDeepSleepObserver; +#ifdef ARCH_ESP32 +struct TouchLightSleepObserver { + int onLightSleep(void *) + { + touchLightSleepActive = true; +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Render touch-off overlay before sleeping so user sees touch is unavailable. + touchIndicatorRefreshPending = true; + refreshTouchIndicatorInInkHUD(false); + touchIndicatorRefreshPending = false; +#endif + return 0; + } + + CallbackObserver observer{this, &TouchLightSleepObserver::onLightSleep}; +} static touchLightSleepObserver; + +struct TouchLightSleepEndObserver { + int onLightSleepEnd(esp_sleep_wakeup_cause_t cause) + { + (void)cause; + touchLightSleepActive = false; + + if (!touchControllerReady) { + return 0; + } + + if (touchInputEnabled && !touchForcedByTimeout) { + touchNeedsWake = true; + } else { + touchNeedsWake = false; + } + + touchStateEpoch++; + touchResumeBlockUntilMs = millis() + 150; + touchIndicatorRefreshPending = !isTouchInputEnabled(); +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Clear sleep-time touch overlay after wake. + touchIndicatorRefreshPending = true; + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; +#endif + return 0; + } + + CallbackObserver observer{this, + &TouchLightSleepEndObserver::onLightSleepEnd}; +} static touchLightSleepEndObserver; +#endif + bool readTouch(int16_t *x, int16_t *y) { +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + static uint32_t suppressUntilMs = 0; + static uint32_t seenTouchStateEpoch = 0; + + // Reset transient gesture helpers whenever touch mode changes. + if (seenTouchStateEpoch != touchStateEpoch) { + seenTouchStateEpoch = touchStateEpoch; + suppressUntilMs = 0; + } + + // Let buses and peripherals settle briefly after light-sleep wake. + if (millis() < touchResumeBlockUntilMs) { + return false; + } + + if (touchIndicatorRefreshPending) { + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; + } + + if (!isTouchInputEnabled()) { + return false; + } + + if (touchNeedsWake && touchControllerReady) { + LOG_DEBUG("touchscreen1: wakeup() on deferred resume"); + touch.wakeup(); + touchNeedsWake = false; + suppressUntilMs = millis() + 60; + return false; + } + + // After a recovery pulse, emit a brief "released" window so gesture state can reset. + if (suppressUntilMs != 0 && millis() < suppressUntilMs) { + return false; + } +#endif + if (!digitalRead(GT911_PIN_INT)) { int16_t raw_x; int16_t raw_y; @@ -123,6 +639,7 @@ bool readTouch(int16_t *x, int16_t *y) return true; } } + return false; } @@ -133,6 +650,8 @@ void earlyInitVariant() pinMode(SDCARD_CS, OUTPUT); digitalWrite(SDCARD_CS, HIGH); pinMode(BOARD_BL_EN, OUTPUT); + // Backlight uses active-HIGH brightness control. + applyBacklightState(); // Program GT911 touch controller to I2C address 0x14 (GT911_SLAVE_ADDRESS_H) before // the I2C bus scan runs. GPIO3 (INT) defaults LOW on ESP32-S3 cold boot, which would @@ -158,22 +677,65 @@ void earlyInitVariant() void variant_shutdown() { - // Ensure frontlight is off during deep sleep - digitalWrite(BOARD_BL_EN, LOW); + // Ensure backlight is off during deep sleep. + t5BacklightSetForcedBySleep(true); } void lateInitVariant() { touch.setPins(GT911_PIN_RST, GT911_PIN_INT); if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) { + // Match LilyGO sample behavior: GT911 center/home capacitive key callback. + touch.setHomeButtonCallback( + [](void *user_data) { +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + if (!homeCapButtonEventsEnabled) { + return; + } + + static uint32_t lastHomeMs = 0; + const uint32_t now = millis(); + if ((uint32_t)(now - lastHomeMs) < 220) { + return; // debounce repeated key reports while still touched + } + lastHomeMs = now; + + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + if (inkhud) { + // Route through InkHUD EXIT/HOME path (menu close, etc). + inkhud->exitShort(); + } +#else + (void)user_data; +#endif + }, + nullptr); + touchControllerReady = true; + touchInputEnabled = true; + touchForcedByTimeout = false; + touchLightSleepActive = false; + touchStateEpoch++; touchDeepSleepObserver.observer.observe(¬ifyDeepSleep); +#ifdef ARCH_ESP32 + touchLightSleepObserver.observer.observe(¬ifyLightSleep); + touchLightSleepEndObserver.observer.observe(¬ifyLightSleepEnd); +#endif touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch); touchScreenImpl1->init(); #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS touchBridge.observe(touchScreenImpl1); #endif } else { + touchControllerReady = false; LOG_ERROR("Failed to find touch controller!"); } + +#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) + // Start side-key interrupt handling after touch init is complete. + if (!sideKeyThread) { + sideKeyThread = new SideKeyInterruptThread(); + sideKeyThread->begin(); + } +#endif } #endif diff --git a/variants/esp32s3/t5s3_epaper/variant.h b/variants/esp32s3/t5s3_epaper/variant.h index 803b582af03..49579a39cca 100644 --- a/variants/esp32s3/t5s3_epaper/variant.h +++ b/variants/esp32s3/t5s3_epaper/variant.h @@ -16,6 +16,11 @@ #define I2C_SCL SCL #define HAS_TOUCHSCREEN 1 +#define TOUCH_POLL_INTERVAL_IDLE 25 +#define TOUCH_POLL_INTERVAL_ACTIVE 15 +#define TOUCH_POLL_INTERVAL_RELEASE 20 +#define TOUCH_POLL_INTERVAL_ACTIVE_FAST 8 +#define TOUCH_POLL_INTERVAL_RELEASE_FAST 8 #define GT911_PIN_SDA SDA #define GT911_PIN_SCL SCL #if defined(T5_S3_EPAPER_PRO_V1) @@ -25,6 +30,30 @@ #define GT911_PIN_INT 3 #define GT911_PIN_RST 9 #endif +// Do not use touch as a light-sleep wake source on T5-S3. +// Wake should come from physical buttons/radio/timer only. +#define SCREEN_TOUCH_INT GT911_PIN_INT + +// Touch control helpers for this variant +bool isTouchInputEnabled(); +void setTouchInputEnabled(bool enabled, bool showIndicator); +void toggleTouchInputEnabled(); + +// Backlight control helpers for this variant (non-latching behavior) +void t5BacklightSetUserEnabled(bool enabled); +bool t5BacklightIsUserEnabled(); +void t5BacklightToggleUser(); +void t5BacklightSetForcedByTimeout(bool forced); +void t5BacklightSetForcedBySleep(bool forced); +void t5BacklightHandleUserInput(); + +// Touch timeout/wake helpers for this variant +void t5TouchSetForcedByTimeout(bool forced); +bool t5TouchIsForcedByTimeout(); +void t5TouchHandleUserInput(); + +// Gate GT911 capacitive-home callback delivery until InkHUD startup is complete. +void t5SetHomeCapButtonEventsEnabled(bool enabled); #define PCF8563_RTC 0x51 #define HAS_RTC 1 @@ -45,8 +74,10 @@ #define ALT_BUTTON_PIN PIN_BUTTON2 #else #define BUTTON_PIN 0 +#define BOARD_PCA9535_ADDR 0x20 +#define BOARD_PCA9535_INT 38 +#define BOARD_PCA9535_BUTTON_MASK 0x04 #endif - // SD card #define HAS_SDCARD #define SDCARD_CS SPI_CS From 55f15076ca5bf1d90b92dd28b65373aeef0a92cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 06:08:07 -0500 Subject: [PATCH 080/225] Update protobufs (#10295) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/config.pb.h | 40 +++++++++++++++++++---- src/mesh/generated/meshtastic/mesh.pb.h | 2 ++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/protobufs b/protobufs index 4d5b500df5a..249a80855a2 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 4d5b500df5af68a4f57d3e19705cc3bb1136358c +Subproject commit 249a80855a2adb76fb0904dac8bf6285d45f330f diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index c82dd5ff5f0..0e14334d5a7 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -285,7 +285,18 @@ typedef enum _meshtastic_Config_LoRaConfig_RegionCode { /* Nepal 865MHz */ meshtastic_Config_LoRaConfig_RegionCode_NP_865 = 25, /* Brazil 902MHz */ - meshtastic_Config_LoRaConfig_RegionCode_BR_902 = 26 + meshtastic_Config_LoRaConfig_RegionCode_BR_902 = 26, + /* ITU Region 1 Amateur Radio 2m band (144-146 MHz) */ + meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M = 27, + /* ITU Region 2 / 3 Amateur Radio 2m band (144-148 MHz) */ + meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M = 28, + /* EU 866MHz band (Band no. 47b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD) */ + meshtastic_Config_LoRaConfig_RegionCode_EU_866 = 29, + /* EU 874MHz and 917MHz bands (Band no. 1 and 4 of 2022/172/EC and subsequent amendments) for Non-specific short-range devices (SRD) */ + meshtastic_Config_LoRaConfig_RegionCode_EU_874 = 30, + meshtastic_Config_LoRaConfig_RegionCode_EU_917 = 31, + /* EU 868MHz band, with narrow presets */ + meshtastic_Config_LoRaConfig_RegionCode_EU_N_868 = 32 } meshtastic_Config_LoRaConfig_RegionCode; /* Standard predefined channel settings @@ -315,7 +326,24 @@ typedef enum _meshtastic_Config_LoRaConfig_ModemPreset { meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO = 8, /* Long Range - Turbo This preset performs similarly to LongFast, but with 500Khz bandwidth. */ - meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO = 9 + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO = 9, + /* Lite Fast + Medium range preset optimized for EU 866MHz SRD band with 125kHz bandwidth. + Comparable link budget to MEDIUM_FAST but compliant with Band no. 47b of 2006/771/EC. */ + meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST = 10, + /* Lite Slow + Medium-to-moderate range preset optimized for EU 866MHz SRD band with 125kHz bandwidth. + Comparable link budget to LONG_FAST but compliant with Band no. 47b of 2006/771/EC. */ + meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW = 11, + /* Narrow Fast + Medium-to-moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. + Comparable link budget to SHORT_SLOW, but with half the data rate. + Intended to avoid interference with other devices. */ + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST = 12, + /* Narrow Slow + Moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. + Comparable link budget and data rate to LONG_FAST. */ + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13 } meshtastic_Config_LoRaConfig_ModemPreset; typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode { @@ -702,12 +730,12 @@ extern "C" { #define _meshtastic_Config_DisplayConfig_CompassOrientation_ARRAYSIZE ((meshtastic_Config_DisplayConfig_CompassOrientation)(meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED+1)) #define _meshtastic_Config_LoRaConfig_RegionCode_MIN meshtastic_Config_LoRaConfig_RegionCode_UNSET -#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_BR_902 -#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_BR_902+1)) +#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_EU_N_868 +#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868+1)) #define _meshtastic_Config_LoRaConfig_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST -#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO -#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO+1)) +#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW +#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW+1)) #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MAX meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index d7ff32cb477..f228250303a 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -315,6 +315,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_THINKNODE_M7 = 129, meshtastic_HardwareModel_THINKNODE_M8 = 130, meshtastic_HardwareModel_THINKNODE_M9 = 131, + /* The Heltec-V4-R8 uses an ESP32S3R8 chip, plus an SX1262. */ + meshtastic_HardwareModel_HELTEC_V4_R8 = 132, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ From 734709105578e65dde4ce884bdabee3b4b613bcc Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 25 Apr 2026 06:10:45 -0500 Subject: [PATCH 081/225] Add script to show unmerged commits from develop to master --- bin/show-unmerged-prs.sh | 118 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100755 bin/show-unmerged-prs.sh diff --git a/bin/show-unmerged-prs.sh b/bin/show-unmerged-prs.sh new file mode 100755 index 00000000000..2a76f63d604 --- /dev/null +++ b/bin/show-unmerged-prs.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +# Script to show commits in develop that are not in master +# with their associated PR info and commit hashes +# +# Usage: +# ./show-unmerged-prs.sh # Show all unmerged commits +# ./show-unmerged-prs.sh --bugfix # Show only bugfix-labeled PRs + +set -e + +REPO="firmware" +OWNER="meshtastic" +BASE_BRANCH="master" +HEAD_BRANCH="develop" +LIMIT=100 +FILTER_LABEL="" + +# Parse arguments +for arg in "$@"; do + case $arg in + --bugfix) + FILTER_LABEL="bugfix" + shift + ;; + --feature) + FILTER_LABEL="feature" + shift + ;; + --help) + echo "Usage: $0 [OPTIONS]" + echo "Options:" + echo " --bugfix Show only PRs labeled with 'bugfix'" + echo " --feature Show only PRs labeled with 'feature'" + echo " --help Show this help message" + exit 0 + ;; + esac +done + +if [ -n "$FILTER_LABEL" ]; then + echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH (filtered by label: $FILTER_LABEL)..." +else + echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH..." +fi +echo "" + +# Check if gh CLI is available +if ! command -v gh &> /dev/null; then + echo "ERROR: GitHub CLI (gh) not found. Please install it first." + echo "Visit: https://cli.github.com/" + exit 1 +fi + +# Get commits in develop that are not in master +# For each commit, try to find associated PR +git fetch origin develop master 2>/dev/null || true + +# Use git to get the list of commits +commits=$(git log --pretty=format:"%H|%s" origin/master..origin/develop | head -n $LIMIT) + +count=0 +displayed=0 +echo "Commits in $HEAD_BRANCH not in $BASE_BRANCH:" +echo "==============================================" +echo "" + +while IFS='|' read -r hash subject; do + ((count++)) + + # Try to find the PR for this commit + # Extract PR number, title, description, and labels + pr_response=$(gh api -X GET "/repos/$OWNER/$REPO/commits/$hash/pulls" \ + -H "Accept: application/vnd.github.v3+json" 2>/dev/null | \ + jq -r '.[0] | "\(.number)|\(.title)|\(.body // "No description")|\(.labels | map(.name) | join(","))"' 2>/dev/null || echo "||||") + + if [ -z "$pr_response" ] || [ "$pr_response" = "||||" ]; then + # If no PR found, skip if filter is active, otherwise show the commit + if [ -z "$FILTER_LABEL" ]; then + ((displayed++)) + echo "[$displayed] Commit: $hash" + echo " Subject: $subject" + echo " PR: Not found in GitHub" + echo "" + fi + else + IFS='|' read -r pr_num pr_title pr_desc pr_labels <<< "$pr_response" + + # Check if filter matches + if [ -n "$FILTER_LABEL" ]; then + # Only show if the label is in the labels list + if ! echo "$pr_labels" | grep -q "$FILTER_LABEL"; then + continue + fi + fi + + ((displayed++)) + echo "[$displayed] PR #$pr_num - $pr_title" + echo " Commit: $hash" + if [ -n "$pr_desc" ] && [ "$pr_desc" != "No description" ]; then + # Truncate description to 200 chars + desc_short="${pr_desc:0:200}" + [ ${#pr_desc} -gt 200 ] && desc_short+="..." + echo " Description: $desc_short" + fi + if [ -n "$pr_labels" ] && [ "$pr_labels" != "" ]; then + echo " Labels: $pr_labels" + fi + echo "" + fi +done <<< "$commits" + +echo "" +if [ -n "$FILTER_LABEL" ]; then + echo "Done. Showing $displayed PRs with label '$FILTER_LABEL' from $displayed commits checked." +else + echo "Done. Showing $displayed commits from $HEAD_BRANCH not in $BASE_BRANCH." +fi From a8c8fd70026aecf5f18724298394933cb45bf84f Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 25 Apr 2026 07:11:03 -0500 Subject: [PATCH 082/225] Remove redundant power interrupt methods for ESP32 --- src/power.h | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/power.h b/src/power.h index 90ada889d69..4b5ef609daa 100644 --- a/src/power.h +++ b/src/power.h @@ -102,14 +102,6 @@ class Power : public concurrency::OSThread const uint16_t OCV[11] = {OCV_ARRAY}; bool isLowBattery() { return low_voltage_counter >= 10; }; -#ifdef ARCH_ESP32 - int beforeLightSleep(void *unused); - int afterLightSleep(esp_sleep_wakeup_cause_t cause); -#endif - - void attachPowerInterrupts(); - void detachPowerInterrupts(); - #ifdef ARCH_ESP32 int beforeLightSleep(void *unused); int afterLightSleep(esp_sleep_wakeup_cause_t cause); From 2828dbe4ca4767fd8520a7eb72ccf225eaab8f1b Mon Sep 17 00:00:00 2001 From: Austin Date: Sat, 25 Apr 2026 13:36:31 -0400 Subject: [PATCH 083/225] t5s3-epaper: Move variant.cpp -> extra_variants/variant.cpp ...again (#10297) Fixes issues with #includes inherited from `configuration.h` when building for pioarduino. Aligns t5s3_epaper with other variants like t_deck_pro. Co-authored-by: Copilot Co-authored-by: Ben Meadors --- .../extra_variants/t5s3_epaper/variant.cpp | 709 +++++++++++++++++ variants/esp32s3/t5s3_epaper/variant.cpp | 720 +----------------- 2 files changed, 718 insertions(+), 711 deletions(-) create mode 100644 src/platform/extra_variants/t5s3_epaper/variant.cpp diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp new file mode 100644 index 00000000000..a829bb9806c --- /dev/null +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -0,0 +1,709 @@ +#include "configuration.h" + +#ifdef T5_S3_EPAPER_PRO + +#include "Observer.h" +#include "TouchDrvGT911.hpp" +#include "Wire.h" +#include "buzz.h" +#include "concurrency/OSThread.h" +#include "input/InputBroker.h" +#include "input/TouchScreenImpl1.h" +#include "main.h" +#include "sleep.h" +#include + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +#include "graphics/niche/InkHUD/InkHUD.h" +#include "graphics/niche/InkHUD/Persistence.h" +#include "graphics/niche/InkHUD/SystemApplet.h" + +// Bridges touch events from TouchScreenImpl1 directly into InkHUD, +// bypassing the InputBroker (which is excluded in InkHUD builds). +// Routing mirrors the mini-epaper-s3 two-way rocker pattern: +// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu) +// - Nav up/down: navUp/navDown always (menu scroll) +// - Tap/long-press: direct touch point dispatch (with fallback to short/long button semantics) +class TouchInkHUDBridge : public Observer +{ + int onNotify(const InputEvent *e) override + { + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + + // Keep alignment in sync with the current rotation so that visual-frame gestures + // always pass through nav functions without remapping: (rotation + alignment) % 4 == 0. + inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; + + // Check whether a system applet (e.g. menu) is currently handling input + bool systemHandlingInput = false; + for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + systemHandlingInput = true; + break; + } + } + + switch (e->inputEvent) { + case INPUT_BROKER_USER_PRESS: + inkhud->touchTap(e->touchX, e->touchY); + break; + case INPUT_BROKER_SELECT: + inkhud->touchLongPress(e->touchX, e->touchY); + break; + case INPUT_BROKER_LEFT: + if (systemHandlingInput) + inkhud->touchNavUp(); + else + inkhud->prevApplet(); + break; + case INPUT_BROKER_RIGHT: + if (systemHandlingInput) + inkhud->touchNavDown(); + else + inkhud->nextApplet(); + break; + case INPUT_BROKER_UP: + inkhud->touchNavUp(); + break; + case INPUT_BROKER_DOWN: + inkhud->touchNavDown(); + break; + default: + break; + } + return 0; + } +}; + +static TouchInkHUDBridge touchBridge; +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +TouchDrvGT911 touch; + +namespace +{ +constexpr uint8_t BACKLIGHT_ON_LEVEL = HIGH; +constexpr uint8_t BACKLIGHT_OFF_LEVEL = LOW; +volatile bool backlightUserEnabled = true; +volatile bool backlightForcedByTimeout = false; +volatile bool backlightForcedBySleep = false; + +void applyBacklightState() +{ + const bool shouldOn = backlightUserEnabled && !backlightForcedByTimeout && !backlightForcedBySleep; + digitalWrite(BOARD_BL_EN, shouldOn ? BACKLIGHT_ON_LEVEL : BACKLIGHT_OFF_LEVEL); +} + +volatile bool touchInputEnabled = true; +volatile bool touchForcedByTimeout = false; +volatile bool touchControllerReady = false; +volatile bool touchLightSleepActive = false; +volatile bool touchNeedsWake = false; +volatile bool touchIndicatorRefreshPending = false; +volatile uint32_t touchResumeBlockUntilMs = 0; +volatile uint32_t touchStateEpoch = 1; +volatile bool homeCapButtonEventsEnabled = false; +#if HAS_SCREEN +uint32_t lastTouchIndicatorMs = 0; +#endif + +void showTouchIndicator(const char *text) +{ +#if HAS_SCREEN +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // InkHUD builds render a dedicated bottom-edge "TOUCH OFF" overlay instead of popup banners. + (void)text; + return; +#else + // Keep repeated notifications low profile and non-spammy. + if ((millis() - lastTouchIndicatorMs) < 500) { + return; + } + lastTouchIndicatorMs = millis(); + if (screen) { + screen->showSimpleBanner(text, 1400); + } +#endif +#else + (void)text; +#endif +} + +#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) +bool readPca9535Port1(uint8_t *value) +{ + if (!value) { + return false; + } + + Wire.beginTransmission(BOARD_PCA9535_ADDR); + Wire.write((uint8_t)0x01); // input port 1 + if (Wire.endTransmission(false) != 0) { + return false; + } + if (Wire.requestFrom((uint8_t)BOARD_PCA9535_ADDR, (uint8_t)1) != 1) { + return false; + } + + *value = Wire.read(); + return true; +} + +bool isPca9535SideKeyPressed() +{ + uint8_t port1 = 0xFF; + if (!readPca9535Port1(&port1)) { + return false; + } + + return (port1 & BOARD_PCA9535_BUTTON_MASK) == 0; +} + +class SideKeyInterruptThread : public concurrency::OSThread +{ + public: + SideKeyInterruptThread() : concurrency::OSThread("t5s3SideKeyInt", SAMPLE_MS) + { + // Do not run unless an edge arrives. + OSThread::disable(); + instance = this; +#ifdef ARCH_ESP32 + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif + } + + void begin() + { + pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); + attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING); + } + + protected: + int32_t runOnce() override + { + const uint32_t now = millis(); + + if (now < touchResumeBlockUntilMs) { + resetStateAndStop(); + return OSThread::disable(); + } + + if (touchLightSleepActive) { + resetStateAndStop(); + return OSThread::disable(); + } + + // Ignore side-key handling while BOOT/user button is held. + if (digitalRead(BUTTON_PIN) == LOW) { + resetStateAndStop(); + return OSThread::disable(); + } + + switch (state) { + case State::IRQ_PENDING: + // Initial debounce after expander interrupt edge. + if ((uint32_t)(now - irqAtMs) < DEBOUNCE_MS) { + return SAMPLE_MS; + } + + if (isPca9535SideKeyPressed()) { + state = State::PRESSED; + pressStartMs = now; + return SAMPLE_MS; + } + + // Spurious/cleared edge. + resetStateAndStop(); + return OSThread::disable(); + + case State::PRESSED: { + if (isPca9535SideKeyPressed()) { + // Fire long-press action as soon as threshold is reached, without waiting for release. + if (!longPressFired && (uint32_t)(now - pressStartMs) >= LONG_PRESS_MIN_MS && + (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) { + t5BacklightToggleUser(); + longPressFired = true; + lastActionMs = now; + } + return SAMPLE_MS; + } + + // Released: if long-press already fired, do nothing. Otherwise classify short press. + const uint32_t heldMs = now - pressStartMs; + if (!longPressFired && heldMs >= SHORT_PRESS_MIN_MS && (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) { + // If timeout forced touch/backlight off, short-press acts as a wake action first. + if (t5TouchIsForcedByTimeout()) { + t5TouchHandleUserInput(); + t5BacklightHandleUserInput(); + } else { + toggleTouchInputEnabled(); + } + lastActionMs = now; + } + + resetStateAndStop(); + return OSThread::disable(); + } + + case State::REST: + default: + return OSThread::disable(); + } + } + + private: + enum class State : uint8_t { + REST, + IRQ_PENDING, + PRESSED, + }; + + static constexpr uint32_t SAMPLE_MS = 15; + static constexpr uint32_t DEBOUNCE_MS = 25; + static constexpr uint32_t SHORT_PRESS_MIN_MS = 30; + static constexpr uint32_t LONG_PRESS_MIN_MS = 450; + static constexpr uint32_t ACTION_COOLDOWN_MS = 180; + + static SideKeyInterruptThread *instance; + + static void isr() + { + if (instance) { + instance->onInterruptEdge(); + } + } + + void onInterruptEdge() + { + if (touchLightSleepActive) { + return; + } + const uint32_t now = millis(); + if (now < touchResumeBlockUntilMs) { + return; + } + if (state != State::REST) { + return; + } + + state = State::IRQ_PENDING; + irqAtMs = millis(); + startThread(); + } + + void startThread() + { + if (!OSThread::enabled) { + OSThread::setIntervalFromNow(0); + OSThread::enabled = true; + runASAP = true; + } + } + + void resetStateAndStop() + { + state = State::REST; + longPressFired = false; + if (OSThread::enabled) { + OSThread::disable(); + } + } + +#ifdef ARCH_ESP32 + int onLightSleep(void *) + { + detachInterrupt(BOARD_PCA9535_INT); + // Clear any latched PCA9535 interrupt before enabling GPIO wake. + // If INT is left asserted low, light sleep exits immediately. + uint8_t ignored = 0xFF; + (void)readPca9535Port1(&ignored); + resetStateAndStop(); + return 0; + } + + int onLightSleepEnd(esp_sleep_wakeup_cause_t cause) + { + (void)cause; + // Consume any pending interrupt source before reattaching ISR. + uint8_t ignored = 0xFF; + (void)readPca9535Port1(&ignored); + pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); + attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING); + + return 0; + } + + CallbackObserver lsObserver{this, &SideKeyInterruptThread::onLightSleep}; + CallbackObserver lsEndObserver{this, + &SideKeyInterruptThread::onLightSleepEnd}; +#endif + + volatile State state = State::REST; + volatile uint32_t irqAtMs = 0; + uint32_t pressStartMs = 0; + bool longPressFired = false; + uint32_t lastActionMs = 0; +}; + +SideKeyInterruptThread *SideKeyInterruptThread::instance = nullptr; +SideKeyInterruptThread *sideKeyThread = nullptr; +#endif + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +void refreshTouchIndicatorInInkHUD(bool async = true) +{ + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + NicheGraphics::InkHUD::SystemApplet *touchStatus = nullptr; + for (auto *sa : inkhud->systemApplets) { + if (sa && sa->name && strcmp(sa->name, "TouchStatus") == 0) { + touchStatus = sa; + break; + } + } + + if (touchStatus) { + if (inkhud->isTouchEnabled()) + touchStatus->sendToBackground(); + else + touchStatus->bringToForeground(); + } + + // Re-render all applets so touch-status visibility changes are immediately reflected. + inkhud->forceUpdate(NicheGraphics::Drivers::EInk::UpdateTypes::FAST, true, async); +} +#endif + +} // namespace + +void t5BacklightSetUserEnabled(bool enabled) +{ + backlightUserEnabled = enabled; + if (enabled) { + // Manual ON should release auto-off gates. + backlightForcedByTimeout = false; + backlightForcedBySleep = false; + } + applyBacklightState(); +} + +bool t5BacklightIsUserEnabled() +{ + return backlightUserEnabled; +} + +void t5BacklightToggleUser() +{ + t5BacklightSetUserEnabled(!backlightUserEnabled); +} + +void t5BacklightSetForcedByTimeout(bool forced) +{ + backlightForcedByTimeout = forced; + applyBacklightState(); +} + +void t5BacklightSetForcedBySleep(bool forced) +{ + backlightForcedBySleep = forced; + applyBacklightState(); +} + +void t5BacklightHandleUserInput() +{ + // Screen-timeout should be lifted by direct user interaction. + backlightForcedByTimeout = false; + applyBacklightState(); +} + +void t5TouchSetForcedByTimeout(bool forced) +{ + touchForcedByTimeout = forced; + touchStateEpoch++; + touchIndicatorRefreshPending = true; + + if (forced) { + // While timeout-forced, keep controller asleep to avoid stale IRQ chatter. + touchNeedsWake = false; + if (touchControllerReady && !touchLightSleepActive) { + touch.sleep(); + } + } else if (touchInputEnabled && touchControllerReady && !touchLightSleepActive) { + // Defer wake until readTouch() so I2C settles post-state transition. + touchNeedsWake = true; + } + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + if (!touchLightSleepActive) { + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; + } +#endif +} + +bool t5TouchIsForcedByTimeout() +{ + return touchForcedByTimeout; +} + +void t5TouchHandleUserInput() +{ + t5TouchSetForcedByTimeout(false); +} + +void t5SetHomeCapButtonEventsEnabled(bool enabled) +{ + homeCapButtonEventsEnabled = enabled; +} + +bool isTouchInputEnabled() +{ + return touchInputEnabled && !touchForcedByTimeout && !touchLightSleepActive; +} + +void setTouchInputEnabled(bool enabled, bool showIndicator) +{ + if (touchInputEnabled == enabled) { + LOG_DEBUG("touchscreen1: setTouchInputEnabled no-op en=%d", enabled); + return; + } + + LOG_DEBUG("touchscreen1: setTouchInputEnabled %d -> %d (showIndicator=%d)", touchInputEnabled, enabled, showIndicator); + touchInputEnabled = enabled; + touchStateEpoch++; + + if (enabled) { + touchNeedsWake = touchControllerReady; + if (touchControllerReady && !touchLightSleepActive) { + LOG_DEBUG("touchscreen1: wakeup() on enable"); + touch.wakeup(); + touchNeedsWake = false; + } + } else { + touchNeedsWake = false; + if (touchControllerReady && !touchLightSleepActive) { + LOG_DEBUG("touchscreen1: sleep() on disable"); + touch.sleep(); + } + if (showIndicator) { + showTouchIndicator("Touch OFF"); + touchIndicatorRefreshPending = true; + } + } + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + if (showIndicator && !touchLightSleepActive) { + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; + } +#endif +} + +void toggleTouchInputEnabled() +{ + setTouchInputEnabled(!touchInputEnabled, true); +} + +// Commands the GT911 into standby before the Wire bus is torn down. +// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here. +struct TouchDeepSleepObserver { + int onDeepSleep(void *) + { + touch.sleep(); + return 0; + } + CallbackObserver observer{this, &TouchDeepSleepObserver::onDeepSleep}; +} static touchDeepSleepObserver; + +#ifdef ARCH_ESP32 +struct TouchLightSleepObserver { + int onLightSleep(void *) + { + touchLightSleepActive = true; +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Render touch-off overlay before sleeping so user sees touch is unavailable. + touchIndicatorRefreshPending = true; + refreshTouchIndicatorInInkHUD(false); + touchIndicatorRefreshPending = false; +#endif + return 0; + } + + CallbackObserver observer{this, &TouchLightSleepObserver::onLightSleep}; +} static touchLightSleepObserver; + +struct TouchLightSleepEndObserver { + int onLightSleepEnd(esp_sleep_wakeup_cause_t cause) + { + (void)cause; + touchLightSleepActive = false; + + if (!touchControllerReady) { + return 0; + } + + if (touchInputEnabled && !touchForcedByTimeout) { + touchNeedsWake = true; + } else { + touchNeedsWake = false; + } + + touchStateEpoch++; + touchResumeBlockUntilMs = millis() + 150; + touchIndicatorRefreshPending = !isTouchInputEnabled(); +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Clear sleep-time touch overlay after wake. + touchIndicatorRefreshPending = true; + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; +#endif + return 0; + } + + CallbackObserver observer{this, + &TouchLightSleepEndObserver::onLightSleepEnd}; +} static touchLightSleepEndObserver; +#endif + +bool readTouch(int16_t *x, int16_t *y) +{ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + static uint32_t suppressUntilMs = 0; + static uint32_t seenTouchStateEpoch = 0; + + // Reset transient gesture helpers whenever touch mode changes. + if (seenTouchStateEpoch != touchStateEpoch) { + seenTouchStateEpoch = touchStateEpoch; + suppressUntilMs = 0; + } + + // Let buses and peripherals settle briefly after light-sleep wake. + if (millis() < touchResumeBlockUntilMs) { + return false; + } + + if (touchIndicatorRefreshPending) { + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; + } + + if (!isTouchInputEnabled()) { + return false; + } + + if (touchNeedsWake && touchControllerReady) { + LOG_DEBUG("touchscreen1: wakeup() on deferred resume"); + touch.wakeup(); + touchNeedsWake = false; + suppressUntilMs = millis() + 60; + return false; + } + + // After a recovery pulse, emit a brief "released" window so gesture state can reset. + if (suppressUntilMs != 0 && millis() < suppressUntilMs) { + return false; + } +#endif + + if (!digitalRead(GT911_PIN_INT)) { + int16_t raw_x; + int16_t raw_y; + if (touch.getPoint(&raw_x, &raw_y)) { +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. + // rotation=3 is the physical identity (device's default orientation). + switch (NicheGraphics::InkHUD::InkHUD::getInstance()->persistence->settings.rotation) { + default: + case 3: + *x = raw_x; + *y = raw_y; + break; // identity + case 2: + *x = (EPD_WIDTH - 1) - raw_y; + *y = raw_x; + break; // 90° CW tilt + case 1: + *x = (EPD_HEIGHT - 1) - raw_x; + *y = (EPD_WIDTH - 1) - raw_y; + break; // 180° flip + case 0: + *x = raw_y; + *y = (EPD_HEIGHT - 1) - raw_x; + break; // 90° CCW tilt + } +#else + *x = raw_x; + *y = raw_y; +#endif + LOG_DEBUG("touched(%d/%d)", *x, *y); + return true; + } + } + + return false; +} + +void variant_shutdown() +{ + // Ensure backlight is off during deep sleep. + t5BacklightSetForcedBySleep(true); +} + +void lateInitVariant() +{ + touch.setPins(GT911_PIN_RST, GT911_PIN_INT); + if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) { + // Match LilyGO sample behavior: GT911 center/home capacitive key callback. + touch.setHomeButtonCallback( + [](void *user_data) { +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + if (!homeCapButtonEventsEnabled) { + return; + } + + static uint32_t lastHomeMs = 0; + const uint32_t now = millis(); + if ((uint32_t)(now - lastHomeMs) < 220) { + return; // debounce repeated key reports while still touched + } + lastHomeMs = now; + + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + if (inkhud) { + // Route through InkHUD EXIT/HOME path (menu close, etc). + inkhud->exitShort(); + } +#else + (void)user_data; +#endif + }, + nullptr); + touchControllerReady = true; + touchInputEnabled = true; + touchForcedByTimeout = false; + touchLightSleepActive = false; + touchStateEpoch++; + touchDeepSleepObserver.observer.observe(¬ifyDeepSleep); +#ifdef ARCH_ESP32 + touchLightSleepObserver.observer.observe(¬ifyLightSleep); + touchLightSleepEndObserver.observer.observe(¬ifyLightSleepEnd); +#endif + touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch); + touchScreenImpl1->init(); +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + touchBridge.observe(touchScreenImpl1); +#endif + } else { + touchControllerReady = false; + LOG_ERROR("Failed to find touch controller!"); + } + +#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) + // Start side-key interrupt handling after touch init is complete. + if (!sideKeyThread) { + sideKeyThread = new SideKeyInterruptThread(); + sideKeyThread->begin(); + } +#endif +} +#endif diff --git a/variants/esp32s3/t5s3_epaper/variant.cpp b/variants/esp32s3/t5s3_epaper/variant.cpp index 4b82be50a61..6599ced2366 100644 --- a/variants/esp32s3/t5s3_epaper/variant.cpp +++ b/variants/esp32s3/t5s3_epaper/variant.cpp @@ -1,647 +1,9 @@ -#include "configuration.h" - -#ifdef T5_S3_EPAPER_PRO - -#include "Observer.h" -#include "TouchDrvGT911.hpp" -#include "Wire.h" -#include "buzz.h" -#include "concurrency/OSThread.h" -#include "input/InputBroker.h" -#include "input/TouchScreenImpl1.h" -#include "main.h" -#include "sleep.h" -#include - -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS -#include "graphics/niche/InkHUD/InkHUD.h" -#include "graphics/niche/InkHUD/Persistence.h" -#include "graphics/niche/InkHUD/SystemApplet.h" - -// Bridges touch events from TouchScreenImpl1 directly into InkHUD, -// bypassing the InputBroker (which is excluded in InkHUD builds). -// Routing mirrors the mini-epaper-s3 two-way rocker pattern: -// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu) -// - Nav up/down: navUp/navDown always (menu scroll) -// - Tap/long-press: direct touch point dispatch (with fallback to short/long button semantics) -class TouchInkHUDBridge : public Observer -{ - int onNotify(const InputEvent *e) override - { - auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); - - // Keep alignment in sync with the current rotation so that visual-frame gestures - // always pass through nav functions without remapping: (rotation + alignment) % 4 == 0. - inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; - - // Check whether a system applet (e.g. menu) is currently handling input - bool systemHandlingInput = false; - for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { - if (sa->handleInput) { - systemHandlingInput = true; - break; - } - } - - switch (e->inputEvent) { - case INPUT_BROKER_USER_PRESS: - inkhud->touchTap(e->touchX, e->touchY); - break; - case INPUT_BROKER_SELECT: - inkhud->touchLongPress(e->touchX, e->touchY); - break; - case INPUT_BROKER_LEFT: - if (systemHandlingInput) - inkhud->touchNavUp(); - else - inkhud->prevApplet(); - break; - case INPUT_BROKER_RIGHT: - if (systemHandlingInput) - inkhud->touchNavDown(); - else - inkhud->nextApplet(); - break; - case INPUT_BROKER_UP: - inkhud->touchNavUp(); - break; - case INPUT_BROKER_DOWN: - inkhud->touchNavDown(); - break; - default: - break; - } - return 0; - } -}; - -static TouchInkHUDBridge touchBridge; -#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS - -TouchDrvGT911 touch; - -namespace -{ -constexpr uint8_t BACKLIGHT_ON_LEVEL = HIGH; -constexpr uint8_t BACKLIGHT_OFF_LEVEL = LOW; -volatile bool backlightUserEnabled = true; -volatile bool backlightForcedByTimeout = false; -volatile bool backlightForcedBySleep = false; - -void applyBacklightState() -{ - const bool shouldOn = backlightUserEnabled && !backlightForcedByTimeout && !backlightForcedBySleep; - digitalWrite(BOARD_BL_EN, shouldOn ? BACKLIGHT_ON_LEVEL : BACKLIGHT_OFF_LEVEL); -} - -volatile bool touchInputEnabled = true; -volatile bool touchForcedByTimeout = false; -volatile bool touchControllerReady = false; -volatile bool touchLightSleepActive = false; -volatile bool touchNeedsWake = false; -volatile bool touchIndicatorRefreshPending = false; -volatile uint32_t touchResumeBlockUntilMs = 0; -volatile uint32_t touchStateEpoch = 1; -volatile bool homeCapButtonEventsEnabled = false; -#if HAS_SCREEN -uint32_t lastTouchIndicatorMs = 0; -#endif - -void showTouchIndicator(const char *text) -{ -#if HAS_SCREEN -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // InkHUD builds render a dedicated bottom-edge "TOUCH OFF" overlay instead of popup banners. - (void)text; - return; -#else - // Keep repeated notifications low profile and non-spammy. - if ((millis() - lastTouchIndicatorMs) < 500) { - return; - } - lastTouchIndicatorMs = millis(); - if (screen) { - screen->showSimpleBanner(text, 1400); - } -#endif -#else - (void)text; -#endif -} - -#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) -bool readPca9535Port1(uint8_t *value) -{ - if (!value) { - return false; - } - - Wire.beginTransmission(BOARD_PCA9535_ADDR); - Wire.write((uint8_t)0x01); // input port 1 - if (Wire.endTransmission(false) != 0) { - return false; - } - if (Wire.requestFrom((uint8_t)BOARD_PCA9535_ADDR, (uint8_t)1) != 1) { - return false; - } - - *value = Wire.read(); - return true; -} - -bool isPca9535SideKeyPressed() -{ - uint8_t port1 = 0xFF; - if (!readPca9535Port1(&port1)) { - return false; - } - - return (port1 & BOARD_PCA9535_BUTTON_MASK) == 0; -} - -class SideKeyInterruptThread : public concurrency::OSThread -{ - public: - SideKeyInterruptThread() : concurrency::OSThread("t5s3SideKeyInt", SAMPLE_MS) - { - // Do not run unless an edge arrives. - OSThread::disable(); - instance = this; -#ifdef ARCH_ESP32 - lsObserver.observe(¬ifyLightSleep); - lsEndObserver.observe(¬ifyLightSleepEnd); -#endif - } - - void begin() - { - pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); - attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING); - } - - protected: - int32_t runOnce() override - { - const uint32_t now = millis(); - - if (now < touchResumeBlockUntilMs) { - resetStateAndStop(); - return OSThread::disable(); - } - - if (touchLightSleepActive) { - resetStateAndStop(); - return OSThread::disable(); - } - - // Ignore side-key handling while BOOT/user button is held. - if (digitalRead(BUTTON_PIN) == LOW) { - resetStateAndStop(); - return OSThread::disable(); - } - - switch (state) { - case State::IRQ_PENDING: - // Initial debounce after expander interrupt edge. - if ((uint32_t)(now - irqAtMs) < DEBOUNCE_MS) { - return SAMPLE_MS; - } - - if (isPca9535SideKeyPressed()) { - state = State::PRESSED; - pressStartMs = now; - return SAMPLE_MS; - } - - // Spurious/cleared edge. - resetStateAndStop(); - return OSThread::disable(); - - case State::PRESSED: { - if (isPca9535SideKeyPressed()) { - // Fire long-press action as soon as threshold is reached, without waiting for release. - if (!longPressFired && (uint32_t)(now - pressStartMs) >= LONG_PRESS_MIN_MS && - (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) { - t5BacklightToggleUser(); - longPressFired = true; - lastActionMs = now; - } - return SAMPLE_MS; - } - - // Released: if long-press already fired, do nothing. Otherwise classify short press. - const uint32_t heldMs = now - pressStartMs; - if (!longPressFired && heldMs >= SHORT_PRESS_MIN_MS && (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) { - // If timeout forced touch/backlight off, short-press acts as a wake action first. - if (t5TouchIsForcedByTimeout()) { - t5TouchHandleUserInput(); - t5BacklightHandleUserInput(); - } else { - toggleTouchInputEnabled(); - } - lastActionMs = now; - } - - resetStateAndStop(); - return OSThread::disable(); - } - - case State::REST: - default: - return OSThread::disable(); - } - } - - private: - enum class State : uint8_t { - REST, - IRQ_PENDING, - PRESSED, - }; - - static constexpr uint32_t SAMPLE_MS = 15; - static constexpr uint32_t DEBOUNCE_MS = 25; - static constexpr uint32_t SHORT_PRESS_MIN_MS = 30; - static constexpr uint32_t LONG_PRESS_MIN_MS = 450; - static constexpr uint32_t ACTION_COOLDOWN_MS = 180; - - static SideKeyInterruptThread *instance; - - static void isr() - { - if (instance) { - instance->onInterruptEdge(); - } - } - - void onInterruptEdge() - { - if (touchLightSleepActive) { - return; - } - const uint32_t now = millis(); - if (now < touchResumeBlockUntilMs) { - return; - } - if (state != State::REST) { - return; - } - - state = State::IRQ_PENDING; - irqAtMs = millis(); - startThread(); - } - - void startThread() - { - if (!OSThread::enabled) { - OSThread::setIntervalFromNow(0); - OSThread::enabled = true; - runASAP = true; - } - } - - void resetStateAndStop() - { - state = State::REST; - longPressFired = false; - if (OSThread::enabled) { - OSThread::disable(); - } - } - -#ifdef ARCH_ESP32 - int onLightSleep(void *) - { - detachInterrupt(BOARD_PCA9535_INT); - // Clear any latched PCA9535 interrupt before enabling GPIO wake. - // If INT is left asserted low, light sleep exits immediately. - uint8_t ignored = 0xFF; - (void)readPca9535Port1(&ignored); - resetStateAndStop(); - return 0; - } - - int onLightSleepEnd(esp_sleep_wakeup_cause_t cause) - { - (void)cause; - // Consume any pending interrupt source before reattaching ISR. - uint8_t ignored = 0xFF; - (void)readPca9535Port1(&ignored); - pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); - attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING); - - return 0; - } - - CallbackObserver lsObserver{this, &SideKeyInterruptThread::onLightSleep}; - CallbackObserver lsEndObserver{this, - &SideKeyInterruptThread::onLightSleepEnd}; -#endif - - volatile State state = State::REST; - volatile uint32_t irqAtMs = 0; - uint32_t pressStartMs = 0; - bool longPressFired = false; - uint32_t lastActionMs = 0; -}; - -SideKeyInterruptThread *SideKeyInterruptThread::instance = nullptr; -SideKeyInterruptThread *sideKeyThread = nullptr; -#endif - -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS -void refreshTouchIndicatorInInkHUD(bool async = true) -{ - auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); - NicheGraphics::InkHUD::SystemApplet *touchStatus = nullptr; - for (auto *sa : inkhud->systemApplets) { - if (sa && sa->name && strcmp(sa->name, "TouchStatus") == 0) { - touchStatus = sa; - break; - } - } - - if (touchStatus) { - if (inkhud->isTouchEnabled()) - touchStatus->sendToBackground(); - else - touchStatus->bringToForeground(); - } - - // Re-render all applets so touch-status visibility changes are immediately reflected. - inkhud->forceUpdate(NicheGraphics::Drivers::EInk::UpdateTypes::FAST, true, async); -} -#endif - -} // namespace - -void t5BacklightSetUserEnabled(bool enabled) -{ - backlightUserEnabled = enabled; - if (enabled) { - // Manual ON should release auto-off gates. - backlightForcedByTimeout = false; - backlightForcedBySleep = false; - } - applyBacklightState(); -} - -bool t5BacklightIsUserEnabled() -{ - return backlightUserEnabled; -} - -void t5BacklightToggleUser() -{ - t5BacklightSetUserEnabled(!backlightUserEnabled); -} - -void t5BacklightSetForcedByTimeout(bool forced) -{ - backlightForcedByTimeout = forced; - applyBacklightState(); -} - -void t5BacklightSetForcedBySleep(bool forced) -{ - backlightForcedBySleep = forced; - applyBacklightState(); -} - -void t5BacklightHandleUserInput() -{ - // Screen-timeout should be lifted by direct user interaction. - backlightForcedByTimeout = false; - applyBacklightState(); -} - -void t5TouchSetForcedByTimeout(bool forced) -{ - touchForcedByTimeout = forced; - touchStateEpoch++; - touchIndicatorRefreshPending = true; - - if (forced) { - // While timeout-forced, keep controller asleep to avoid stale IRQ chatter. - touchNeedsWake = false; - if (touchControllerReady && !touchLightSleepActive) { - touch.sleep(); - } - } else if (touchInputEnabled && touchControllerReady && !touchLightSleepActive) { - // Defer wake until readTouch() so I2C settles post-state transition. - touchNeedsWake = true; - } - -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - if (!touchLightSleepActive) { - refreshTouchIndicatorInInkHUD(); - touchIndicatorRefreshPending = false; - } -#endif -} - -bool t5TouchIsForcedByTimeout() -{ - return touchForcedByTimeout; -} - -void t5TouchHandleUserInput() -{ - t5TouchSetForcedByTimeout(false); -} - -void t5SetHomeCapButtonEventsEnabled(bool enabled) -{ - homeCapButtonEventsEnabled = enabled; -} - -bool isTouchInputEnabled() -{ - return touchInputEnabled && !touchForcedByTimeout && !touchLightSleepActive; -} - -void setTouchInputEnabled(bool enabled, bool showIndicator) -{ - if (touchInputEnabled == enabled) { - LOG_DEBUG("touchscreen1: setTouchInputEnabled no-op en=%d", enabled); - return; - } - - LOG_DEBUG("touchscreen1: setTouchInputEnabled %d -> %d (showIndicator=%d)", touchInputEnabled, enabled, showIndicator); - touchInputEnabled = enabled; - touchStateEpoch++; - - if (enabled) { - touchNeedsWake = touchControllerReady; - if (touchControllerReady && !touchLightSleepActive) { - LOG_DEBUG("touchscreen1: wakeup() on enable"); - touch.wakeup(); - touchNeedsWake = false; - } - } else { - touchNeedsWake = false; - if (touchControllerReady && !touchLightSleepActive) { - LOG_DEBUG("touchscreen1: sleep() on disable"); - touch.sleep(); - } - if (showIndicator) { - showTouchIndicator("Touch OFF"); - touchIndicatorRefreshPending = true; - } - } - -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - if (showIndicator && !touchLightSleepActive) { - refreshTouchIndicatorInInkHUD(); - touchIndicatorRefreshPending = false; - } -#endif -} - -void toggleTouchInputEnabled() -{ - setTouchInputEnabled(!touchInputEnabled, true); -} - -// Commands the GT911 into standby before the Wire bus is torn down. -// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here. -struct TouchDeepSleepObserver { - int onDeepSleep(void *) - { - touch.sleep(); - return 0; - } - CallbackObserver observer{this, &TouchDeepSleepObserver::onDeepSleep}; -} static touchDeepSleepObserver; - -#ifdef ARCH_ESP32 -struct TouchLightSleepObserver { - int onLightSleep(void *) - { - touchLightSleepActive = true; -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // Render touch-off overlay before sleeping so user sees touch is unavailable. - touchIndicatorRefreshPending = true; - refreshTouchIndicatorInInkHUD(false); - touchIndicatorRefreshPending = false; -#endif - return 0; - } - - CallbackObserver observer{this, &TouchLightSleepObserver::onLightSleep}; -} static touchLightSleepObserver; - -struct TouchLightSleepEndObserver { - int onLightSleepEnd(esp_sleep_wakeup_cause_t cause) - { - (void)cause; - touchLightSleepActive = false; - - if (!touchControllerReady) { - return 0; - } - - if (touchInputEnabled && !touchForcedByTimeout) { - touchNeedsWake = true; - } else { - touchNeedsWake = false; - } - - touchStateEpoch++; - touchResumeBlockUntilMs = millis() + 150; - touchIndicatorRefreshPending = !isTouchInputEnabled(); -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // Clear sleep-time touch overlay after wake. - touchIndicatorRefreshPending = true; - refreshTouchIndicatorInInkHUD(); - touchIndicatorRefreshPending = false; -#endif - return 0; - } - - CallbackObserver observer{this, - &TouchLightSleepEndObserver::onLightSleepEnd}; -} static touchLightSleepEndObserver; -#endif - -bool readTouch(int16_t *x, int16_t *y) -{ -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - static uint32_t suppressUntilMs = 0; - static uint32_t seenTouchStateEpoch = 0; - - // Reset transient gesture helpers whenever touch mode changes. - if (seenTouchStateEpoch != touchStateEpoch) { - seenTouchStateEpoch = touchStateEpoch; - suppressUntilMs = 0; - } - - // Let buses and peripherals settle briefly after light-sleep wake. - if (millis() < touchResumeBlockUntilMs) { - return false; - } - - if (touchIndicatorRefreshPending) { - refreshTouchIndicatorInInkHUD(); - touchIndicatorRefreshPending = false; - } - - if (!isTouchInputEnabled()) { - return false; - } - - if (touchNeedsWake && touchControllerReady) { - LOG_DEBUG("touchscreen1: wakeup() on deferred resume"); - touch.wakeup(); - touchNeedsWake = false; - suppressUntilMs = millis() + 60; - return false; - } - - // After a recovery pulse, emit a brief "released" window so gesture state can reset. - if (suppressUntilMs != 0 && millis() < suppressUntilMs) { - return false; - } -#endif - - if (!digitalRead(GT911_PIN_INT)) { - int16_t raw_x; - int16_t raw_y; - if (touch.getPoint(&raw_x, &raw_y)) { -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. - // rotation=3 is the physical identity (device's default orientation). - switch (NicheGraphics::InkHUD::InkHUD::getInstance()->persistence->settings.rotation) { - default: - case 3: - *x = raw_x; - *y = raw_y; - break; // identity - case 2: - *x = (EPD_WIDTH - 1) - raw_y; - *y = raw_x; - break; // 90° CW tilt - case 1: - *x = (EPD_HEIGHT - 1) - raw_x; - *y = (EPD_WIDTH - 1) - raw_y; - break; // 180° flip - case 0: - *x = raw_y; - *y = (EPD_HEIGHT - 1) - raw_x; - break; // 90° CCW tilt - } -#else - *x = raw_x; - *y = raw_y; -#endif - LOG_DEBUG("touched(%d/%d)", *x, *y); - return true; - } - } - - return false; -} +// Pin-level early init only. All touch, backlight, and InkHUD code lives in +// src/platform/extra_variants/t5s3_epaper/variant.cpp where PlatformIO's +// library dependency finder can resolve headers like TouchDrvGT911.hpp. +#include "variant.h" +#include "Arduino.h" +#include "pins_arduino.h" void earlyInitVariant() { @@ -650,8 +12,9 @@ void earlyInitVariant() pinMode(SDCARD_CS, OUTPUT); digitalWrite(SDCARD_CS, HIGH); pinMode(BOARD_BL_EN, OUTPUT); - // Backlight uses active-HIGH brightness control. - applyBacklightState(); + // Backlight ON at boot (active-HIGH). Full backlight state management + // lives in src/platform/extra_variants/t5s3_epaper/variant.cpp. + digitalWrite(BOARD_BL_EN, HIGH); // Program GT911 touch controller to I2C address 0x14 (GT911_SLAVE_ADDRESS_H) before // the I2C bus scan runs. GPIO3 (INT) defaults LOW on ESP32-S3 cold boot, which would @@ -674,68 +37,3 @@ void earlyInitVariant() delay(10); // > 5 ms startup pinMode(GT911_PIN_INT, INPUT); // release INT for interrupt use } - -void variant_shutdown() -{ - // Ensure backlight is off during deep sleep. - t5BacklightSetForcedBySleep(true); -} - -void lateInitVariant() -{ - touch.setPins(GT911_PIN_RST, GT911_PIN_INT); - if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) { - // Match LilyGO sample behavior: GT911 center/home capacitive key callback. - touch.setHomeButtonCallback( - [](void *user_data) { -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - if (!homeCapButtonEventsEnabled) { - return; - } - - static uint32_t lastHomeMs = 0; - const uint32_t now = millis(); - if ((uint32_t)(now - lastHomeMs) < 220) { - return; // debounce repeated key reports while still touched - } - lastHomeMs = now; - - auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); - if (inkhud) { - // Route through InkHUD EXIT/HOME path (menu close, etc). - inkhud->exitShort(); - } -#else - (void)user_data; -#endif - }, - nullptr); - touchControllerReady = true; - touchInputEnabled = true; - touchForcedByTimeout = false; - touchLightSleepActive = false; - touchStateEpoch++; - touchDeepSleepObserver.observer.observe(¬ifyDeepSleep); -#ifdef ARCH_ESP32 - touchLightSleepObserver.observer.observe(¬ifyLightSleep); - touchLightSleepEndObserver.observer.observe(¬ifyLightSleepEnd); -#endif - touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch); - touchScreenImpl1->init(); -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - touchBridge.observe(touchScreenImpl1); -#endif - } else { - touchControllerReady = false; - LOG_ERROR("Failed to find touch controller!"); - } - -#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) - // Start side-key interrupt handling after touch init is complete. - if (!sideKeyThread) { - sideKeyThread = new SideKeyInterruptThread(); - sideKeyThread->begin(); - } -#endif -} -#endif From fb678b9337cc1193032998d93a48827b6682f0ed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:44:06 -0500 Subject: [PATCH 084/225] Update platform-native digest to 135b91e (#10300) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/native/portduino.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 87d8431a3e8..b276d2779f7 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/71ed55bb95feb3c43ebde1ec1e2e17643a424c04.zip + https://github.com/meshtastic/platform-native/archive/135b91e953db0b5f44d278f8ebd5b8d985fc03d8.zip framework = arduino build_src_filter = From 554188e90edc07ffed9b1170d01e38ed7b541447 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 25 Apr 2026 14:49:37 -0500 Subject: [PATCH 085/225] Fix main function to setup and loop for Unity test framework --- test/test_utf8/test_main.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/test_utf8/test_main.cpp b/test/test_utf8/test_main.cpp index 7ac64653d22..5a074e96eca 100644 --- a/test/test_utf8/test_main.cpp +++ b/test/test_utf8/test_main.cpp @@ -163,7 +163,7 @@ void test_above_max_codepoint() TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); } -int main(int argc, char **argv) +void setup() { UNITY_BEGIN(); @@ -191,5 +191,7 @@ int main(int argc, char **argv) RUN_TEST(test_valid_max_codepoint); RUN_TEST(test_above_max_codepoint); - return UNITY_END(); + exit(UNITY_END()); } + +void loop() {} From 7800dc3c8dba00aecb57bf6b2539930bb491bce2 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 25 Apr 2026 15:04:58 -0500 Subject: [PATCH 086/225] Enhance UTF-8 sanitization logic and add delays in test setup for reliable timing --- src/meshUtils.cpp | 4 ++-- test/test_transmit_history/test_main.cpp | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/meshUtils.cpp b/src/meshUtils.cpp index 89c5488873b..f2ee20589db 100644 --- a/src/meshUtils.cpp +++ b/src/meshUtils.cpp @@ -124,10 +124,10 @@ bool sanitizeUtf8(char *buf, size_t bufSize) if (!buf || bufSize == 0) return false; - // Ensure null-terminated within buffer + // Ensure null-terminated within buffer; report if we had to enforce it + bool replaced = (buf[bufSize - 1] != '\0'); buf[bufSize - 1] = '\0'; - bool replaced = false; size_t i = 0; size_t len = strlen(buf); diff --git a/test/test_transmit_history/test_main.cpp b/test/test_transmit_history/test_main.cpp index 3bd84b55c99..c242aa646d1 100644 --- a/test/test_transmit_history/test_main.cpp +++ b/test/test_transmit_history/test_main.cpp @@ -303,6 +303,10 @@ void setup() { initializeTestEnvironment(); + // Wait for portduino's millis() clock to start ticking before tests run + testDelay(10); + testDelay(2000); + UNITY_BEGIN(); RUN_TEST(test_setLastSentToMesh_stores_millis); From aec0805a27c8eb47ad037210071462d40419e18b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 25 Apr 2026 15:32:19 -0500 Subject: [PATCH 087/225] Trying another guard approach --- src/mesh/HardwareRNG.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index 58a17d795a1..b79b0d0127f 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -48,9 +48,11 @@ bool mixWithLoRaEntropy(uint8_t *buffer, size_t length) // and return false so callers know no extra mixing occurred. RadioLibInterface *radio = RadioLibInterface::instance; if (!radio) { -#ifndef PIO_UNIT_TESTING - LOG_ERROR("No radio instance available to provide entropy"); -#endif + // This path can run during portduinoSetup() before the console is initialized, + // both for unit-test binaries and the simulator's meshtasticd; LOG_* dereferences `console`. + if (console) { + LOG_ERROR("No radio instance available to provide entropy"); + } return false; } From 4ccdd8009051bc13b1360c8949ed85562a954ca7 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Sat, 25 Apr 2026 20:42:14 -0500 Subject: [PATCH 088/225] Add search duration check for exceeding 15 minutes (#10293) * Add search duration check for exceeding 15 minutes Added a condition to check if the search duration exceeds 15 minutes, indicating too long of a search. * trunk * Fix searchedTooLong: move 15-min cap before UINT32_MAX check, cache elapsed, add constexpr Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/b7f74430-9e7e-4a6f-8095-6176c1eee972 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Update src/gps/GPSUpdateScheduling.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove dead UINT32_MAX branch from searchedTooLong Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/6dad5b56-902e-4d0e-90c1-038a9c2df364 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/gps/GPSUpdateScheduling.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp index 5eaf7a8ba67..53d6c833f83 100644 --- a/src/gps/GPSUpdateScheduling.cpp +++ b/src/gps/GPSUpdateScheduling.cpp @@ -70,20 +70,25 @@ bool GPSUpdateScheduling::isUpdateDue() // Have we been searching for a GPS position for too long? bool GPSUpdateScheduling::searchedTooLong() { + constexpr uint32_t oneMinuteMs = 60UL * 1000UL; + constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long + uint32_t elapsed = elapsedSearchMs(); + + // Anything over 15 minutes is too long, regardless of the broadcast interval. + // TODO: Make a smarter algorithm that backs off the search dwell time when not getting a lock. + if (elapsed > maxSearchClampMs) + return true; + uint32_t minimumOrConfiguredSecs = Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs); uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs); - // If broadcast interval set to max, no such thing as "too long" - if (maxSearchMs == UINT32_MAX) - return false; // If we've been searching longer than our position broadcast interval: that's too long - else if (elapsedSearchMs() > maxSearchMs) + if (elapsed > maxSearchMs) return true; // Otherwise, not too long yet! - else - return false; + return false; } // Updates the predicted time-to-get-lock, by exponentially smoothing the latest observation From 8dde4eeee196df6f2141ce0e463f93f995af433a Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:44:56 -0400 Subject: [PATCH 089/225] BaseUI: Color Support for TFT Nodes (#10233) * True Colors on TFT (Heltec Mesh Node T114, Heltec Vision Master T190, CardPuter Adv, T-Deck, T-Lora Pager) * Theme support - New and some Classic Themes! * Colored Compass --------- Co-authored-by: Jason P Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors --- src/graphics/Screen.cpp | 112 +-- src/graphics/SharedUIDisplay.cpp | 320 +++++-- src/graphics/SharedUIDisplay.h | 4 +- src/graphics/TFTColorRegions.cpp | 819 ++++++++++++++++ src/graphics/TFTColorRegions.h | 163 ++++ src/graphics/TFTDisplay.cpp | 185 +++- src/graphics/TFTDisplay.h | 3 +- src/graphics/TFTPalette.h | 70 ++ src/graphics/draw/ClockRenderer.cpp | 10 +- src/graphics/draw/CompassRenderer.cpp | 122 +-- src/graphics/draw/CompassRenderer.h | 1 + src/graphics/draw/DebugRenderer.cpp | 35 +- src/graphics/draw/MenuHandler.cpp | 185 ++-- src/graphics/draw/MenuHandler.h | 19 +- src/graphics/draw/MessageRenderer.cpp | 146 ++- src/graphics/draw/NodeListRenderer.cpp | 57 ++ src/graphics/draw/NotificationRenderer.cpp | 36 +- src/graphics/draw/UIRenderer.cpp | 884 +++++++++++++----- src/graphics/draw/UIRenderer.h | 2 + src/motion/AccelerometerThread.h | 0 src/motion/BMA423Sensor.cpp | 0 src/motion/BMA423Sensor.h | 0 src/motion/BMX160Sensor.cpp | 0 src/motion/BMX160Sensor.h | 0 src/motion/ICM20948Sensor.cpp | 0 src/motion/ICM20948Sensor.h | 0 src/motion/LIS3DHSensor.cpp | 0 src/motion/LIS3DHSensor.h | 0 src/motion/LSM6DS3Sensor.cpp | 0 src/motion/LSM6DS3Sensor.h | 0 src/motion/MPU6050Sensor.cpp | 0 src/motion/MPU6050Sensor.h | 0 src/motion/MotionSensor.cpp | 0 src/motion/MotionSensor.h | 0 src/motion/STK8XXXSensor.cpp | 0 src/motion/STK8XXXSensor.h | 0 src/sleep.cpp | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 4 +- .../heltec_vision_master_t190/platformio.ini | 2 +- .../m5stack_cardputer_adv/platformio.ini | 2 +- variants/esp32s3/picomputer-s3/variant.h | 3 +- .../seeed-sensecap-indicator/variant.h | 2 +- variants/esp32s3/station-g2/pins_arduino.h | 0 variants/esp32s3/station-g2/platformio.ini | 0 variants/esp32s3/station-g2/variant.h | 0 variants/esp32s3/t-deck/variant.h | 2 +- variants/esp32s3/tlora-pager/variant.h | 2 +- .../esp32s3/tracksenger/internal/variant.h | 3 +- variants/esp32s3/tracksenger/lcd/variant.h | 3 +- variants/esp32s3/tracksenger/oled/variant.h | 3 +- variants/esp32s3/unphone/variant.h | 4 +- .../heltec_mesh_node_t114/platformio.ini | 2 +- .../nrf52840/heltec_mesh_node_t114/variant.h | 3 - .../nrf52840/heltec_mesh_solar/platformio.ini | 2 +- 54 files changed, 2537 insertions(+), 675 deletions(-) create mode 100644 src/graphics/TFTColorRegions.cpp create mode 100644 src/graphics/TFTColorRegions.h create mode 100644 src/graphics/TFTPalette.h mode change 100644 => 100755 src/motion/AccelerometerThread.h mode change 100644 => 100755 src/motion/BMA423Sensor.cpp mode change 100644 => 100755 src/motion/BMA423Sensor.h mode change 100644 => 100755 src/motion/BMX160Sensor.cpp mode change 100644 => 100755 src/motion/BMX160Sensor.h mode change 100644 => 100755 src/motion/ICM20948Sensor.cpp mode change 100644 => 100755 src/motion/ICM20948Sensor.h mode change 100644 => 100755 src/motion/LIS3DHSensor.cpp mode change 100644 => 100755 src/motion/LIS3DHSensor.h mode change 100644 => 100755 src/motion/LSM6DS3Sensor.cpp mode change 100644 => 100755 src/motion/LSM6DS3Sensor.h mode change 100644 => 100755 src/motion/MPU6050Sensor.cpp mode change 100644 => 100755 src/motion/MPU6050Sensor.h mode change 100644 => 100755 src/motion/MotionSensor.cpp mode change 100644 => 100755 src/motion/MotionSensor.h mode change 100644 => 100755 src/motion/STK8XXXSensor.cpp mode change 100644 => 100755 src/motion/STK8XXXSensor.h mode change 100644 => 100755 variants/esp32s3/station-g2/pins_arduino.h mode change 100644 => 100755 variants/esp32s3/station-g2/platformio.ini mode change 100644 => 100755 variants/esp32s3/station-g2/variant.h diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index f315011d86e..e8a7f685e9c 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -39,6 +39,7 @@ along with this program. If not, see . #include "draw/NodeListRenderer.h" #include "draw/NotificationRenderer.h" #include "draw/UIRenderer.h" +#include "graphics/TFTColorRegions.h" #include "modules/CannedMessageModule.h" #if !MESHTASTIC_EXCLUDE_GPS @@ -54,6 +55,7 @@ along with this program. If not, see . #include "gps/RTC.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTPalette.h" #include "graphics/emotes.h" #include "graphics/images.h" #include "input/TouchScreenImpl1.h" @@ -69,12 +71,6 @@ along with this program. If not, see . #include "target_specific.h" extern MessageStore messageStore; -#if USE_TFTDISPLAY -extern uint16_t TFT_MESH; -#else -uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94); -#endif - #if HAS_WIFI && !defined(ARCH_PORTDUINO) #include "mesh/wifi/WiFiAPClient.h" #endif @@ -109,6 +105,27 @@ namespace graphics // A text message frame + debug frame + all the node infos FrameCallback *normalFrames; static uint32_t targetFramerate = IDLE_FRAMERATE; +#if GRAPHICS_TFT_COLORING_ENABLED +static inline void prepareFrameColorRegions() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + clearTFTColorRegions(); + // Full-frame FrameMono inversion for themes that need it (e.g. light themes). + if (isThemeFullFrameInvert()) { + setAndRegisterTFTColorRole(TFTColorRole::FrameMono, getThemeBodyFg(), getThemeBodyBg(), 0, 0, screen->getWidth(), + screen->getHeight()); + } +#endif +} +#endif + +static inline void updateUiFrame(OLEDDisplayUi *ui) +{ +#if GRAPHICS_TFT_COLORING_ENABLED + prepareFrameColorRegions(); +#endif + ui->update(); +} // Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization uint32_t logo_timeout = 5000; // 4 seconds for EACH logo @@ -227,7 +244,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); ui->setTargetFPS(60); - ui->update(); + updateUiFrame(ui); } // Called to trigger a banner with custom message and duration @@ -249,7 +266,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); ui->setTargetFPS(60); - ui->update(); + updateUiFrame(ui); } // Called to trigger a banner with custom message and duration @@ -273,7 +290,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); ui->setTargetFPS(60); - ui->update(); + updateUiFrame(ui); } void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs, @@ -296,7 +313,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); ui->setTargetFPS(60); - ui->update(); + updateUiFrame(ui); } static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) @@ -388,30 +405,6 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O { graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES]; - int32_t rawRGB = uiconfig.screen_rgb_color; - - // Only validate the combined value once - if (rawRGB > 0 && rawRGB <= 255255255) { - LOG_INFO("Setting screen RGB color to user chosen: 0x%06X", rawRGB); - // Extract each component as a normal int first - int r = (rawRGB >> 16) & 0xFF; - int g = (rawRGB >> 8) & 0xFF; - int b = rawRGB & 0xFF; - if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) { - TFT_MESH = COLOR565(static_cast(r), static_cast(g), static_cast(b)); - } -#ifdef TFT_MESH_OVERRIDE - } else if (rawRGB == 0) { - LOG_INFO("Setting screen RGB color to TFT_MESH_OVERRIDE: 0x%04X", TFT_MESH_OVERRIDE); - // Default to TFT_MESH_OVERRIDE if available - TFT_MESH = TFT_MESH_OVERRIDE; -#endif - } else { - // Default best readable yellow color - LOG_INFO("Setting screen RGB color to default: (255,255,128)"); - TFT_MESH = COLOR565(255, 255, 128); - } - #if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64) dispdev = new SH1106Wire(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); @@ -474,9 +467,13 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O #endif #if defined(USE_ST7789) - static_cast(dispdev)->setRGB(TFT_MESH); + // Keep firmware and ST7789 driver region structs layout-compatible: + // we pass `graphics::colorRegions` through a type cast below. + static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion), + "graphics::TFTColorRegion layout must match ST7789 TFTColorRegion"); + static_cast(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions); #elif defined(USE_ST7796) - static_cast(dispdev)->setRGB(TFT_MESH); + static_cast(dispdev)->setRGB(TFTPalette::White); #endif ui = new OLEDDisplayUi(dispdev); @@ -663,16 +660,16 @@ void Screen::setup() static_cast(dispdev)->setSubtype(7); #endif -#if defined(USE_ST7789) && defined(TFT_MESH) - // Apply custom RGB color (e.g. Heltec T114/T190) - static_cast(dispdev)->setRGB(TFT_MESH); +#if defined(USE_ST7789) + static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion), + "graphics::TFTColorRegion layout must match ST7789 TFTColorRegion"); + static_cast(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions); #endif #if defined(MUZI_BASE) dispdev->delayPoweron = true; #endif -#if defined(USE_ST7796) && defined(TFT_MESH) - // Custom text color, if defined in variant.h - static_cast(dispdev)->setRGB(TFT_MESH); +#if defined(USE_ST7796) + static_cast(dispdev)->setRGB(TFTPalette::White); #endif // Initialize display and UI system @@ -718,7 +715,7 @@ void Screen::setup() #endif { const char *region = myRegion ? myRegion->name : nullptr; - graphics::UIRenderer::drawIconScreen(region, display, state, x, y); + graphics::UIRenderer::drawBootIconScreen(region, display, state, x, y); } }; ui->setFrames(alertFrames, 1); @@ -757,9 +754,9 @@ void Screen::setup() // Turn on display and trigger first draw handleSetOn(true); graphics::currentResolution = graphics::determineScreenResolution(dispdev->height(), dispdev->width()); - ui->update(); + updateUiFrame(ui); #ifndef USE_EINK - ui->update(); // Some SSD1306 clones drop the first draw, so run twice + updateUiFrame(ui); // Some SSD1306 clones drop the first draw, so run twice #endif serialSinceMsec = millis(); @@ -832,7 +829,7 @@ void Screen::forceDisplay(bool forceUiUpdate) do { startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow.. delay(10); - ui->update(); + updateUiFrame(ui); } while (ui->getUiState()->lastUpdate < startUpdate); // Return to normal frame rate @@ -903,9 +900,9 @@ int32_t Screen::runOnce() static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen}; static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]); ui->setFrames(bootOEMFrames, bootOEMFrameCount); - ui->update(); + updateUiFrame(ui); #ifndef USE_EINK - ui->update(); + updateUiFrame(ui); #endif showingOEMBootScreen = false; } @@ -996,7 +993,7 @@ int32_t Screen::runOnce() // this must be before the frameState == FIXED check, because we always // want to draw at least one FIXED frame before doing forceDisplay - ui->update(); + updateUiFrame(ui); // Switch to a low framerate (to save CPU) when we are not in transition // but we should only call setTargetFPS when framestate changes, because @@ -1058,7 +1055,7 @@ void Screen::setSSLFrames() // LOG_DEBUG("Show SSL frames"); static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen}; ui->setFrames(sslFrames, 1); - ui->update(); + updateUiFrame(ui); } } @@ -1094,7 +1091,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver) do { startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow.. delay(1); - ui->update(); + updateUiFrame(ui); } while (ui->getUiState()->lastUpdate < startUpdate); #if defined(USE_EINK_PARALLELDISPLAY) @@ -1469,9 +1466,15 @@ void Screen::blink() dispdev->setBrightness(254); while (count > 0) { dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight()); +#if GRAPHICS_TFT_COLORING_ENABLED + prepareFrameColorRegions(); +#endif dispdev->display(); delay(50); dispdev->clear(); +#if GRAPHICS_TFT_COLORING_ENABLED + prepareFrameColorRegions(); +#endif dispdev->display(); delay(50); count = count - 1; @@ -1605,6 +1608,9 @@ void Screen::setFastFramerate() { #if defined(M5STACK_UNITC6L) dispdev->clear(); +#if GRAPHICS_TFT_COLORING_ENABLED + prepareFrameColorRegions(); +#endif dispdev->display(); #endif // We are about to start a transition so speed up fps @@ -1816,7 +1822,7 @@ int Screen::handleInputEvent(const InputEvent *event) static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); setFastFramerate(); // Draw ASAP - ui->update(); + updateUiFrame(ui); return 0; } @@ -1831,7 +1837,7 @@ int Screen::handleInputEvent(const InputEvent *event) static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); setFastFramerate(); // Draw ASAP - ui->update(); + updateUiFrame(ui); menuHandler::handleMenuSwitch(dispdev); return 0; diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index ec50654aef3..becd3e75d4a 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -1,16 +1,20 @@ #include "configuration.h" #if HAS_SCREEN #include "MeshService.h" +#include "NodeDB.h" #include "RTC.h" #include "draw/NodeListRenderer.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/draw/UIRenderer.h" #include "main.h" #include "meshtastic/config.pb.h" #include "modules/ExternalNotificationModule.h" #include "power.h" #include +#include #include namespace graphics @@ -65,6 +69,12 @@ uint32_t lastBlinkShared = 0; bool isMailIconVisible = true; uint32_t lastMailBlink = 0; +static inline bool useClockHeaderAccentTheme(uint32_t themeId) +{ + return themeId == ThemeID::Pink || themeId == ThemeID::Creamsicle || themeId == ThemeID::MeshtasticGreen || + themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite; +} + // ********************************* // * Rounded Header when inverted * // ********************************* @@ -85,7 +95,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, // ************************* // * Common Header Drawing * // ************************* -void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date) +void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date, + bool transparent_background, bool use_title_color_override, uint16_t title_color_override) { constexpr int HEADER_OFFSET_Y = 1; y += HEADER_OFFSET_Y; @@ -100,30 +111,93 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti const int screenW = display->getWidth(); const int screenH = display->getHeight(); + const int headerHeight = highlightHeight + 2; + const uint16_t headerColorForRoles = getThemeHeaderBg(); + // Color TFT headers use a fixed dark background + white glyphs. + // Keep legacy inverted bitmap behavior only for monochrome displays. + const bool useInvertedHeaderStyle = (isInverted && !force_no_invert && !isTFTColoringEnabled() && !transparent_background); +#if GRAPHICS_TFT_COLORING_ENABLED + int statusLeftEndX = 0; + int statusRightStartX = screenW; + const bool isClockHeader = transparent_background && show_date && (!titleStr || titleStr[0] == '\0'); + const auto activeThemeId = getActiveTheme().id; + const bool useClockHeaderAccent = isClockHeader && useClockHeaderAccentTheme(activeThemeId); +#endif + + { + const uint16_t headerColor = getThemeHeaderBg(); + const uint16_t headerTextColor = getThemeHeaderText(); + const uint16_t headerTitleColorForRole = use_title_color_override ? title_color_override : headerTextColor; + uint16_t headerStatusColor = getThemeHeaderStatus(); +#if GRAPHICS_TFT_COLORING_ENABLED + // Clock frame uses transparent header + date + empty title. + // For accent clock themes (Pink/Creamsicle + classic monochrome), tint + // status items (battery outline, %, date, mail icon) to the header accent. + if (useClockHeaderAccent) { + headerStatusColor = getThemeHeaderBg(); + } + + if (transparent_background) { + // Transparent clock headers should inherit whatever body off-color is + // already active under the header (important for light/inverted themes). + const uint16_t transparentBgColor = resolveTFTOffColorAt(0, headerHeight + 1, getThemeBodyBg()); + setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, transparentBgColor, transparentBgColor, 0, 0, screenW, + headerHeight); + setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, transparentBgColor); + setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, transparentBgColor); + } else if (useInvertedHeaderStyle) { + setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, headerColor, TFTPalette::Black, 0, 0, screenW, + headerHeight); + setTFTColorRole(TFTColorRole::HeaderTitle, headerColor, headerTitleColorForRole); + setTFTColorRole(TFTColorRole::HeaderStatus, headerColor, headerStatusColor); + } else { + setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, TFTPalette::Black, headerColor, 0, 0, screenW, + headerHeight); + setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, headerColor); + setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, headerColor); + } +#endif - if (!force_no_invert) { // === Inverted Header Background === - if (isInverted) { + if (useInvertedHeaderStyle) { display->setColor(BLACK); - display->fillRect(0, 0, screenW, highlightHeight + 2); + display->fillRect(0, 0, screenW, headerHeight); display->setColor(WHITE); drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2); display->setColor(BLACK); } else { display->setColor(BLACK); - display->fillRect(0, 0, screenW, highlightHeight + 2); - display->setColor(WHITE); - if (currentResolution == ScreenResolution::High) { - display->drawLine(0, 20, screenW, 20); - } else { - display->drawLine(0, 14, screenW, 14); + display->fillRect(0, 0, screenW, headerHeight); +// Keep the legacy white separator for monochrome displays only when header background is visible. +#if !GRAPHICS_TFT_COLORING_ENABLED + if (!transparent_background) { + display->setColor(WHITE); + if (currentResolution == ScreenResolution::High) { + display->drawLine(0, 20, screenW, 20); + } else { + display->drawLine(0, 14, screenW, 14); + } } +#endif + } + + if (transparent_background) { + display->setColor(WHITE); } +#if GRAPHICS_TFT_COLORING_ENABLED + // TFT role coloring expects foreground glyph bits to be "set". + display->setColor(WHITE); +#endif + // === Screen Title === const char *headerTitle = titleStr ? titleStr : ""; const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); const int titleX = (SCREEN_WIDTH - titleWidth) / 2; +#if GRAPHICS_TFT_COLORING_ENABLED + const int titleRegionWidth = titleWidth + (config.display.heading_bold ? 3 : 2); + registerTFTColorRegion(TFTColorRole::HeaderTitle, titleX - 1, y, titleRegionWidth, FONT_HEIGHT_SMALL); +#endif UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold); } display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -152,6 +226,17 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti bool useHorizontalBattery = (currentResolution == ScreenResolution::High && screenW >= screenH); const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2; + bool hasBatteryFillRegion = false; + int16_t batteryFillRegionX = 0; + int16_t batteryFillRegionY = 0; + int16_t batteryFillRegionW = 0; + int16_t batteryFillRegionH = 0; +#if GRAPHICS_TFT_COLORING_ENABLED + uint16_t batteryFillColor = getThemeBatteryFillColor(chargePercent); + if (useClockHeaderAccent) { + batteryFillColor = getThemeHeaderBg(); + } +#endif int batteryX = 1; int batteryY = HEADER_OFFSET_Y + 1; @@ -180,6 +265,15 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti display->drawLine(batteryX + 5, batteryY + 12, batteryX + 10, batteryY + 12); int fillWidth = 14 * chargePercent / 100; display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 11); +#if GRAPHICS_TFT_COLORING_ENABLED + if (fillWidth > 0) { + hasBatteryFillRegion = true; + batteryFillRegionX = batteryX + 1; + batteryFillRegionY = batteryY + 1; + batteryFillRegionW = fillWidth; + batteryFillRegionH = 11; + } +#endif } batteryX += 18; // Icon + 2 pixels } else { @@ -194,21 +288,41 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int fillHeight = 8 * chargePercent / 100; int fillY = batteryY - fillHeight; display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight); +#if GRAPHICS_TFT_COLORING_ENABLED + if (fillHeight > 0) { + hasBatteryFillRegion = true; + batteryFillRegionX = batteryX + 1; + batteryFillRegionY = fillY + 10; + batteryFillRegionW = 5; + batteryFillRegionH = fillHeight; + } +#endif } batteryX += 9; // Icon + 2 pixels } } +#if GRAPHICS_TFT_COLORING_ENABLED + statusLeftEndX = batteryX + 2; +#endif if (chargePercent != 101) { // === Battery % Display === char chargeStr[4]; snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent); int chargeNumWidth = display->getStringWidth(chargeStr); + const int percentWidth = display->getStringWidth("%"); + const int percentX = batteryX + chargeNumWidth - 1; display->drawString(batteryX, textY, chargeStr); - display->drawString(batteryX + chargeNumWidth - 1, textY, "%"); + display->drawString(percentX, textY, "%"); +#if GRAPHICS_TFT_COLORING_ENABLED + statusLeftEndX = percentX + percentWidth + 2; +#endif if (isBold) { display->drawString(batteryX + 1, textY, chargeStr); - display->drawString(batteryX + chargeNumWidth, textY, "%"); + display->drawString(percentX + 1, textY, "%"); +#if GRAPHICS_TFT_COLORING_ENABLED + statusLeftEndX = percentX + percentWidth + 3; +#endif } } @@ -253,6 +367,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti timeStrWidth = display->getStringWidth(timeStr); } timeX = screenW - xOffset - timeStrWidth + 3; +#if GRAPHICS_TFT_COLORING_ENABLED + statusRightStartX = timeX - (useHorizontalBattery ? 22 : 16); +#endif // === Show Mail or Mute Icon to the Left of Time === int iconRightEdge = timeX - 2; @@ -278,7 +395,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int iconW = 16, iconH = 12; int iconX = iconRightEdge - iconW; int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1; - if (isInverted && !force_no_invert) { + if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2); display->setColor(BLACK); @@ -293,7 +410,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } else { int iconX = iconRightEdge - (mail_width - 2); int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; - if (isInverted && !force_no_invert) { + if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2); display->setColor(BLACK); @@ -309,7 +426,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int iconX = iconRightEdge - mute_symbol_big_width; int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2; - if (isInverted && !force_no_invert) { + if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2); display->setColor(BLACK); @@ -323,7 +440,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int iconX = iconRightEdge - mute_symbol_width; int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; - if (isInverted && !force_no_invert) { + if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2); display->setColor(BLACK); @@ -351,7 +468,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } else { // === No Time Available: Mail/Mute Icon Moves to Far Right === int iconRightEdge = screenW - xOffset; - +#if GRAPHICS_TFT_COLORING_ENABLED + statusRightStartX = screenW - (useHorizontalBattery ? 22 : 12); +#endif bool showMail = false; #ifndef USE_EINK @@ -393,6 +512,16 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } } } +#endif +#if GRAPHICS_TFT_COLORING_ENABLED + registerTFTColorRegion(TFTColorRole::HeaderStatus, 0, 0, statusLeftEndX, headerHeight); + if (statusRightStartX < screenW) { + registerTFTColorRegion(TFTColorRole::HeaderStatus, statusRightStartX, 0, screenW - statusRightStartX, headerHeight); + } + if (hasBatteryFillRegion) { + registerTFTColorRegionDirect(batteryFillRegionX, batteryFillRegionY, batteryFillRegionW, batteryFillRegionH, + batteryFillColor, headerColorForRoles); + } #endif display->setColor(WHITE); // Reset for other UI } @@ -430,14 +559,23 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) return; const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; + const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale); + const int footerH = (connection_icon_height * scale) + (2 * scale); + const int iconX = 0; + const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale); + const int iconW = connection_icon_width * scale; + const int iconH = connection_icon_height * scale; + +#if GRAPHICS_TFT_COLORING_ENABLED + // Only tint the link glyph itself on TFT; keep the footer background black. + setAndRegisterTFTColorRole(TFTColorRole::ConnectionIcon, TFTPalette::Blue, TFTPalette::Black, iconX, iconY, iconW, iconH); +#endif + display->setColor(BLACK); - display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale), - (connection_icon_height * scale) + (2 * scale)); + display->fillRect(0, footerY, SCREEN_WIDTH, footerH); display->setColor(WHITE); if (currentResolution == ScreenResolution::High) { const int bytesPerRow = (connection_icon_width + 7) / 8; - int iconX = 0; - int iconY = SCREEN_HEIGHT - (connection_icon_height * 2); for (int yy = 0; yy < connection_icon_height; ++yy) { const uint8_t *rowPtr = connection_icon + yy * bytesPerRow; @@ -451,65 +589,127 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) } } else { - display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height, - connection_icon); + display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon); } } bool isAllowedPunctuation(char c) { - const std::string allowed = ".,!?;:-_()[]{}'\"@#$/\\&+=%~^ "; - return allowed.find(c) != std::string::npos; + switch (c) { + case '.': + case ',': + case '!': + case '?': + case ';': + case ':': + case '-': + case '_': + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + case '\'': + case '"': + case '@': + case '#': + case '$': + case '/': + case '\\': + case '&': + case '+': + case '=': + case '%': + case '~': + case '^': + case ' ': + return true; + default: + return false; + } } -static void replaceAll(std::string &s, const std::string &from, const std::string &to) +static inline size_t utf8CodePointLength(unsigned char lead) { - if (from.empty()) - return; - size_t pos = 0; - while ((pos = s.find(from, pos)) != std::string::npos) { - s.replace(pos, from.size(), to); - pos += to.size(); + if ((lead & 0x80) == 0x00) { + return 1; + } + if ((lead & 0xE0) == 0xC0) { + return 2; + } + if ((lead & 0xF0) == 0xE0) { + return 3; + } + if ((lead & 0xF8) == 0xF0) { + return 4; } + return 1; } std::string sanitizeString(const std::string &input) { + static constexpr char kReplacementChar = static_cast(0xBF); // Inverted question mark in ISO-8859-1. std::string output; + output.reserve(input.size()); bool inReplacement = false; - - // Make a mutable copy so we can normalize UTF-8 “smart punctuation” into ASCII first. - std::string s = input; - - // Curly single quotes: ‘ ’ - replaceAll(s, "\xE2\x80\x98", "'"); // U+2018 - replaceAll(s, "\xE2\x80\x99", "'"); // U+2019 - - // Curly double quotes: “ ” - replaceAll(s, "\xE2\x80\x9C", "\""); // U+201C - replaceAll(s, "\xE2\x80\x9D", "\""); // U+201D - - // En dash / Em dash: – — - replaceAll(s, "\xE2\x80\x93", "-"); // U+2013 - replaceAll(s, "\xE2\x80\x94", "-"); // U+2014 - - // Non-breaking space - replaceAll(s, "\xC2\xA0", " "); // U+00A0 - - // Now do your original sanitize pass over the normalized string. - for (unsigned char uc : s) { - char c = static_cast(uc); - if (std::isalnum(uc) || isAllowedPunctuation(c)) { - output += c; - inReplacement = false; - } else { + const size_t inputSize = input.size(); + size_t i = 0; + while (i < inputSize) { + const unsigned char byte0 = static_cast(input[i]); + char normalized = '\0'; + size_t consumed = 0; + if (byte0 < 0x80) { + normalized = static_cast(byte0); + consumed = 1; + } else if ((i + 2) < inputSize && byte0 == 0xE2 && static_cast(input[i + 1]) == 0x80) { + // Smart punctuation: ' ' \" \" - - + switch (static_cast(input[i + 2])) { + case 0x98: + case 0x99: + normalized = '\''; + consumed = 3; + break; + case 0x9C: + case 0x9D: + normalized = '\"'; + consumed = 3; + break; + case 0x93: + case 0x94: + normalized = '-'; + consumed = 3; + break; + default: + break; + } + } else if ((i + 1) < inputSize && byte0 == 0xC2 && static_cast(input[i + 1]) == 0xA0) { + // Non-breaking space. + normalized = ' '; + consumed = 2; + } + if (consumed == 0) { + size_t seqLen = utf8CodePointLength(byte0); + if (seqLen > (inputSize - i)) { + seqLen = 1; + } if (!inReplacement) { - output += static_cast(0xBF); // ISO-8859-1 for inverted question mark + output.push_back(kReplacementChar); inReplacement = true; } + i += seqLen; + continue; } + const unsigned char normalizedUc = static_cast(normalized); + if (std::isalnum(normalizedUc) || isAllowedPunctuation(normalized)) { + output.push_back(normalized); + inReplacement = false; + } else if (!inReplacement) { + output.push_back(kReplacementChar); + inReplacement = true; + } + i += consumed; } - return output; } diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h index 35e767056ae..95244d09902 100644 --- a/src/graphics/SharedUIDisplay.h +++ b/src/graphics/SharedUIDisplay.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace graphics @@ -52,7 +53,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, // Shared battery/time/mail header void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = "", bool force_no_invert = false, - bool show_date = false); + bool show_date = false, bool transparent_background = false, bool use_title_color_override = false, + uint16_t title_color_override = 0); // Shared battery/time/mail header void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y); diff --git a/src/graphics/TFTColorRegions.cpp b/src/graphics/TFTColorRegions.cpp new file mode 100644 index 00000000000..877835ea657 --- /dev/null +++ b/src/graphics/TFTColorRegions.cpp @@ -0,0 +1,819 @@ +#include "TFTColorRegions.h" +#include "NodeDB.h" +#include "TFTPalette.h" + +#include + +namespace graphics +{ +TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS]; + +namespace +{ + +struct TFTRoleColorsBe { + uint16_t onColorBe; + uint16_t offColorBe; +}; + +static uint8_t colorRegionCount = 0; +static constexpr uint32_t kFnv1aOffsetBasis = 2166136261u; +static constexpr uint32_t kFnv1aPrime = 16777619u; + +static constexpr uint16_t toBe565(uint16_t color) +{ + return static_cast((color >> 8) | (color << 8)); +} + +static constexpr bool kRoleIsBody[static_cast(TFTColorRole::Count)] = { + false, // HeaderBackground + false, // HeaderTitle + false, // HeaderStatus + true, // SignalBars + true, // ConnectionIcon + true, // UtilizationFill + true, // FavoriteNode + true, // ActionMenuBorder + true, // ActionMenuBody + true, // ActionMenuTitle + true, // FrameMono + false, // BootSplash + true, // FavoriteNodeBGHighlight + false, // NavigationBar + false // NavigationArrow +}; + +static inline bool isBodyColorRole(TFTColorRole role) +{ + return kRoleIsBody[static_cast(role)]; +} + +static inline bool isMonochromeTheme(uint32_t themeId) +{ + return themeId == ThemeID::MeshtasticGreen || themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite; +} + +static inline uint16_t getMonochromeAccent(uint32_t themeId) +{ + return (themeId == ThemeID::MeshtasticGreen) ? TFTPalette::MeshtasticGreen + : (themeId == ThemeID::ClassicRed) ? TFTPalette::ClassicRed + : TFTPalette::White; +} + +static inline void replaceColor(uint16_t &value, uint16_t from, uint16_t to) +{ + if (value == from) { + value = to; + } +} + +static inline uint32_t fnv1aAppendByte(uint32_t hash, uint8_t value) +{ + return (hash ^ value) * kFnv1aPrime; +} + +static inline uint32_t fnv1aAppendU16(uint32_t hash, uint16_t value) +{ + hash = fnv1aAppendByte(hash, static_cast(value & 0xFF)); + hash = fnv1aAppendByte(hash, static_cast((value >> 8) & 0xFF)); + return hash; +} + +// Compile-time header color overrides (backward-compatible) +#ifdef TFT_HEADER_BG_COLOR_OVERRIDE +static constexpr uint16_t kHeaderBackground = TFT_HEADER_BG_COLOR_OVERRIDE; +#else +static constexpr uint16_t kHeaderBackground = TFTPalette::DarkGray; +#endif + +#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE +static constexpr uint16_t kTitleColor = TFT_HEADER_TITLE_COLOR_OVERRIDE; +#else +static constexpr uint16_t kTitleColor = TFTPalette::White; +#endif + +#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE +static constexpr uint16_t kStatusColor = TFT_HEADER_STATUS_COLOR_OVERRIDE; +#else +static constexpr uint16_t kStatusColor = TFTPalette::White; +#endif + +// Theme definitions +// Stored in kThemes[] and looked up by matching uiconfig.screen_rgb_color +// against each entry's .uniqueIdentifier field. + +static const TFTThemeDef kThemes[] = { + + // Default Dark (ThemeID::DefaultDark = 0) + { + ThemeID::DefaultDark, // id + "Default Dark", // name + 0, // uniqueIdentifier + // roles[TFTColorRole::Count] + { + {kHeaderBackground, TFTPalette::Black}, // HeaderBackground + {kHeaderBackground, kTitleColor}, // HeaderTitle + {kHeaderBackground, kStatusColor}, // HeaderStatus + {TFTPalette::Good, TFTPalette::Black}, // SignalBars + {TFTPalette::Blue, TFTPalette::Black}, // ConnectionIcon + {TFTPalette::Good, TFTPalette::Black}, // UtilizationFill + {TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNode + {TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuBorder + {TFTPalette::White, TFTPalette::Black}, // ActionMenuBody + {TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::Black}, // BootSplash + {TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNodeBGHighlight + {kStatusColor, kHeaderBackground}, // NavigationBar (icon fg, bar bg) + {kTitleColor, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::Good, // batteryFillGood + TFTPalette::Medium, // batteryFillMedium + TFTPalette::Bad, // batteryFillBad + false, // fullFrameInvert + true, // visible + }, + + // Default Light (ThemeID::DefaultLight = 1) + { + ThemeID::DefaultLight, // id + "Default Light", // name + 1, // uniqueIdentifier + { + {TFTPalette::LightGray, TFTPalette::Black}, // HeaderBackground + {TFTPalette::LightGray, TFTPalette::Black}, // HeaderTitle + {TFTPalette::LightGray, TFTPalette::Black}, // HeaderStatus + {TFTPalette::Good, TFTPalette::White}, // SignalBars + {TFTPalette::Blue, TFTPalette::White}, // ConnectionIcon + {TFTPalette::Good, TFTPalette::White}, // UtilizationFill + {TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNode + {TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuBorder + {TFTPalette::Black, TFTPalette::White}, // ActionMenuBody + {TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::Black}, // BootSplash + {TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNodeBGHighlight + {TFTPalette::Black, TFTPalette::LightGray}, // NavigationBar (icon fg, bar bg) + {TFTPalette::Black, TFTPalette::White}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::Good, // batteryFillGood + TFTPalette::Medium, // batteryFillMedium + TFTPalette::Bad, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Christmas (ThemeID::Christmas = 2) + { + ThemeID::Christmas, // id + "Christmas", // name + 2, // uniqueIdentifier + { + {TFTPalette::ChristmasRed, TFTPalette::Black}, // HeaderBackground + {TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderTitle + {TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderStatus + {TFTPalette::ChristmasGreen, TFTPalette::Pine}, // SignalBars + {TFTPalette::Gold, TFTPalette::Pine}, // ConnectionIcon + {TFTPalette::ChristmasGreen, TFTPalette::Pine}, // UtilizationFill + {TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNode + {TFTPalette::ChristmasRed, TFTPalette::Pine}, // ActionMenuBorder + {TFTPalette::White, TFTPalette::Pine}, // ActionMenuBody + {TFTPalette::ChristmasRed, TFTPalette::White}, // ActionMenuTitle + {TFTPalette::Pine, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::ChristmasRed}, // BootSplash + {TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNodeBGHighlight + {TFTPalette::Gold, TFTPalette::ChristmasRed}, // NavigationBar (icon fg, bar bg) + {TFTPalette::Gold, TFTPalette::Pine}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::ChristmasGreen, // batteryFillGood + TFTPalette::Gold, // batteryFillMedium + TFTPalette::ChristmasRed, // batteryFillBad + true, // fullFrameInvert + false, // visible + }, + + // Pink (ThemeID::Pink = 3) light variant + { + ThemeID::Pink, // id + "Pink", // name + 3, // uniqueIdentifier + { + {TFTPalette::HotPink, TFTPalette::Black}, // HeaderBackground + {TFTPalette::HotPink, TFTPalette::White}, // HeaderTitle + {TFTPalette::HotPink, TFTPalette::White}, // HeaderStatus + {TFTPalette::DeepPink, TFTPalette::PalePink}, // SignalBars + {TFTPalette::HotPink, TFTPalette::PalePink}, // ConnectionIcon + {TFTPalette::DeepPink, TFTPalette::PalePink}, // UtilizationFill + {TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNode + {TFTPalette::HotPink, TFTPalette::PalePink}, // ActionMenuBorder + {TFTPalette::Black, TFTPalette::PalePink}, // ActionMenuBody + {TFTPalette::HotPink, TFTPalette::White}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::HotPink}, // BootSplash + {TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNodeBGHighlight + {TFTPalette::White, TFTPalette::HotPink}, // NavigationBar (icon fg, bar bg) + {TFTPalette::HotPink, TFTPalette::PalePink}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::DeepPink, // batteryFillGood + TFTPalette::HotPink, // batteryFillMedium + TFTPalette::Bad, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Blue (ThemeID::Blue = 4) dark variant + { + ThemeID::Blue, // id + "Blue", // name + 4, // uniqueIdentifier + { + {TFTPalette::DeepBlue, TFTPalette::Black}, // HeaderBackground + {TFTPalette::DeepBlue, TFTPalette::White}, // HeaderTitle + {TFTPalette::DeepBlue, TFTPalette::SkyBlue}, // HeaderStatus + {TFTPalette::SkyBlue, TFTPalette::Navy}, // SignalBars + {TFTPalette::SkyBlue, TFTPalette::Navy}, // ConnectionIcon + {TFTPalette::SkyBlue, TFTPalette::Navy}, // UtilizationFill + {TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNode + {TFTPalette::DeepBlue, TFTPalette::Navy}, // ActionMenuBorder + {TFTPalette::White, TFTPalette::Navy}, // ActionMenuBody + {TFTPalette::DeepBlue, TFTPalette::White}, // ActionMenuTitle + {TFTPalette::Navy, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::DeepBlue}, // BootSplash + {TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNodeBGHighlight + {TFTPalette::SkyBlue, TFTPalette::DeepBlue}, // NavigationBar (icon fg, bar bg) + {TFTPalette::SkyBlue, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::SkyBlue, // batteryFillGood + TFTPalette::Medium, // batteryFillMedium + TFTPalette::Bad, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Creamsicle (ThemeID::Creamsicle = 5)light variant + { + ThemeID::Creamsicle, // id + "Creamsicle", // name + 5, // uniqueIdentifier + { + {TFTPalette::CreamOrange, TFTPalette::Black}, // HeaderBackground + {TFTPalette::CreamOrange, TFTPalette::White}, // HeaderTitle + {TFTPalette::CreamOrange, TFTPalette::White}, // HeaderStatus + {TFTPalette::DeepOrange, TFTPalette::Cream}, // SignalBars + {TFTPalette::CreamOrange, TFTPalette::Cream}, // ConnectionIcon + {TFTPalette::DeepOrange, TFTPalette::Cream}, // UtilizationFill + {TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNode + {TFTPalette::CreamOrange, TFTPalette::Cream}, // ActionMenuBorder + {TFTPalette::Black, TFTPalette::Cream}, // ActionMenuBody + {TFTPalette::CreamOrange, TFTPalette::White}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::CreamOrange}, // BootSplash + {TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNodeBGHighlight + {TFTPalette::White, TFTPalette::CreamOrange}, // NavigationBar (icon fg, bar bg) + {TFTPalette::CreamOrange, TFTPalette::White}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::DeepOrange, // batteryFillGood + TFTPalette::Gold, // batteryFillMedium + TFTPalette::Bad, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Meshtastic Green (ThemeID::MeshtasticGreen = 6) classic monochrome + // Pure single-color-on-black look. Every role maps foreground pixels to + // the theme color and background pixels to Black. + { + ThemeID::MeshtasticGreen, // id + "Meshtastic Green", // name + 6, // uniqueIdentifier + { + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderBackground + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderTitle + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderStatus + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // SignalBars + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ConnectionIcon + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // UtilizationFill + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNode + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBorder + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBody + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::MeshtasticGreen}, // FrameMono (bodyBg, bodyFg) + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // BootSplash + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNodeBGHighlight + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationBar + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationArrow + }, + TFTPalette::Black, // batteryFillGood + TFTPalette::Black, // batteryFillMedium + TFTPalette::Black, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Classic Red (ThemeID::ClassicRed = 7) classic monochrome + { + ThemeID::ClassicRed, // id + "Classic Red", // name + 7, // uniqueIdentifier + { + {TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderBackground + {TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderTitle + {TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderStatus + {TFTPalette::ClassicRed, TFTPalette::Black}, // SignalBars + {TFTPalette::ClassicRed, TFTPalette::Black}, // ConnectionIcon + {TFTPalette::ClassicRed, TFTPalette::Black}, // UtilizationFill + {TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNode + {TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBorder + {TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBody + {TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::ClassicRed}, // FrameMono (bodyBg, bodyFg) + {TFTPalette::ClassicRed, TFTPalette::Black}, // BootSplash + {TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNodeBGHighlight + {TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationBar + {TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationArrow + }, + TFTPalette::Black, // batteryFillGood + TFTPalette::Black, // batteryFillMedium + TFTPalette::Black, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Monochrome White (ThemeID::MonochromeWhite = 8) classic monochrome + { + ThemeID::MonochromeWhite, // id + "Monochrome White", // name + 8, // uniqueIdentifier + { + {TFTPalette::White, TFTPalette::Black}, // HeaderBackground + {TFTPalette::White, TFTPalette::Black}, // HeaderTitle + {TFTPalette::White, TFTPalette::Black}, // HeaderStatus + {TFTPalette::White, TFTPalette::Black}, // SignalBars + {TFTPalette::White, TFTPalette::Black}, // ConnectionIcon + {TFTPalette::White, TFTPalette::Black}, // UtilizationFill + {TFTPalette::White, TFTPalette::Black}, // FavoriteNode + {TFTPalette::White, TFTPalette::Black}, // ActionMenuBorder + {TFTPalette::White, TFTPalette::Black}, // ActionMenuBody + {TFTPalette::White, TFTPalette::Black}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::White}, // FrameMono (bodyBg, bodyFg) + {TFTPalette::White, TFTPalette::Black}, // BootSplash + {TFTPalette::White, TFTPalette::Black}, // FavoriteNodeBGHighlight + {TFTPalette::White, TFTPalette::Black}, // NavigationBar + {TFTPalette::White, TFTPalette::Black}, // NavigationArrow + }, + TFTPalette::Black, // batteryFillGood + TFTPalette::Black, // batteryFillMedium + TFTPalette::Black, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, +}; + +static constexpr size_t kInternalThemeCount = sizeof(kThemes) / sizeof(kThemes[0]); + +// Resolve the kThemes[] index for the currently persisted theme. Called at +// boot (indirectly via getActiveTheme()) and whenever the active theme is +// queried, so uiconfig.screen_rgb_color remains the single source of truth. +// Matches against .uniqueIdentifier - that's the field whose value is stored +// in the user's config. Falls back to 0 (DefaultDark) if no match is found, +// which gracefully handles removed or retired themes. +static inline size_t resolveThemeIndex() +{ + const uint32_t savedIdentifier = uiconfig.screen_rgb_color & 0x1F; + for (size_t i = 0; i < kInternalThemeCount; i++) { + if (kThemes[i].uniqueIdentifier == savedIdentifier) + return i; + } + return 0; // Default Dark fallback +} + +static inline bool normalizeRegion(int16_t &x, int16_t &y, int16_t &width, int16_t &height) +{ + if (width <= 0 || height <= 0) { + return false; + } + + if (x < 0) { + width += x; + x = 0; + } + if (y < 0) { + height += y; + y = 0; + } + + return width > 0 && height > 0; +} + +static inline void appendColorRegion(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColorBe, uint16_t offColorBe) +{ + // Keep the last slot permanently disabled as a sentinel for ST7789 scans. + // This leaves MAX_TFT_COLOR_REGIONS - 1 usable entries. + if (colorRegionCount >= MAX_TFT_COLOR_REGIONS - 1) { + memmove(&colorRegions[0], &colorRegions[1], sizeof(TFTColorRegion) * (MAX_TFT_COLOR_REGIONS - 2)); + colorRegionCount = MAX_TFT_COLOR_REGIONS - 2; + } + + TFTColorRegion ®ion = colorRegions[colorRegionCount++]; + region.x = x; + region.y = y; + region.width = width; + region.height = height; + region.onColorBe = onColorBe; + region.offColorBe = offColorBe; + region.enabled = true; + + // Keep one disabled sentinel after the active range for ST7789 countColorRegions(). + if (colorRegionCount < MAX_TFT_COLOR_REGIONS) { + colorRegions[colorRegionCount].enabled = false; + } + colorRegions[MAX_TFT_COLOR_REGIONS - 1].enabled = false; +} + +// Current working role colors (big-endian). Initialised to Dark defaults; +// call loadThemeDefaults() after boot / theme change to refresh. +static TFTRoleColorsBe roleColors[static_cast(TFTColorRole::Count)] = { + {toBe565(kHeaderBackground), toBe565(TFTPalette::Black)}, // HeaderBackground + {toBe565(kHeaderBackground), toBe565(kTitleColor)}, // HeaderTitle + {toBe565(kHeaderBackground), toBe565(kStatusColor)}, // HeaderStatus + {toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // SignalBars + {toBe565(TFTPalette::Blue), toBe565(TFTPalette::Black)}, // ConnectionIcon + {toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // UtilizationFill + {toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNode + {toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::Black)}, // ActionMenuBorder + {toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // ActionMenuBody + {toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::White)}, // ActionMenuTitle + {toBe565(TFTPalette::Black), toBe565(TFTPalette::White)}, // FrameMono + {toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // BootSplash + {toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNodeBGHighlight + {toBe565(kStatusColor), toBe565(kHeaderBackground)}, // NavigationBar + {toBe565(kTitleColor), toBe565(TFTPalette::Black)} // NavigationArrow +}; + +} // namespace + +// Theme accessors + +const TFTThemeDef &getActiveTheme() +{ + return kThemes[resolveThemeIndex()]; +} + +// Visible-theme accessors +// These iterate only themes flagged .visible = true, preserving kThemes[] +// order. Menu code should use these so hidden themes don't appear in the +// picker while still applying correctly if their ID is persisted. + +size_t getVisibleThemeCount() +{ + size_t count = 0; + for (size_t i = 0; i < kInternalThemeCount; i++) { + if (kThemes[i].visible) + count++; + } + return count; +} + +const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex) +{ + size_t seen = 0; + for (size_t i = 0; i < kInternalThemeCount; i++) { + if (!kThemes[i].visible) + continue; + if (seen == visibleIndex) + return kThemes[i]; + seen++; + } + // Fallback: return first theme (never trust a bad index). + return kThemes[0]; +} + +size_t getActiveVisibleThemeIndex() +{ + const size_t active = resolveThemeIndex(); + if (!kThemes[active].visible) + return SIZE_MAX; + size_t visibleIdx = 0; + for (size_t i = 0; i < active; i++) { + if (kThemes[i].visible) + visibleIdx++; + } + return visibleIdx; +} + +uint16_t getThemeHeaderBg() +{ +#if GRAPHICS_TFT_COLORING_ENABLED +#ifdef TFT_HEADER_BG_COLOR_OVERRIDE + return TFT_HEADER_BG_COLOR_OVERRIDE; +#else + return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::HeaderBackground)].onColor; +#endif +#else + return TFTPalette::DarkGray; +#endif +} + +uint16_t getThemeHeaderText() +{ +#if GRAPHICS_TFT_COLORING_ENABLED +#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE + return TFT_HEADER_TITLE_COLOR_OVERRIDE; +#else + return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::HeaderTitle)].offColor; +#endif +#else + return TFTPalette::White; +#endif +} + +uint16_t getThemeHeaderStatus() +{ +#if GRAPHICS_TFT_COLORING_ENABLED +#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE + return TFT_HEADER_STATUS_COLOR_OVERRIDE; +#else + return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::HeaderStatus)].offColor; +#endif +#else + return TFTPalette::White; +#endif +} + +uint16_t getThemeBodyBg() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::FrameMono)].onColor; +#else + return TFTPalette::Black; +#endif +} + +uint16_t getThemeBodyFg() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::FrameMono)].offColor; +#else + return TFTPalette::White; +#endif +} + +bool isThemeFullFrameInvert() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + return kThemes[resolveThemeIndex()].fullFrameInvert; +#else + return false; +#endif +} + +uint16_t getThemeBatteryFillColor(int batteryPercent) +{ + const TFTThemeDef &theme = kThemes[resolveThemeIndex()]; + if (batteryPercent <= 20) { + return theme.batteryFillBad; + } + if (batteryPercent <= 50) { + return theme.batteryFillMedium; + } + return theme.batteryFillGood; +} + +void loadThemeDefaults() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + const TFTThemeDef &theme = kThemes[resolveThemeIndex()]; + for (uint8_t i = 0; i < static_cast(TFTColorRole::Count); i++) { + roleColors[i].onColorBe = toBe565(theme.roles[i].onColor); + roleColors[i].offColorBe = toBe565(theme.roles[i].offColor); + } +#endif +} + +// Role color assignment with theme-aware transforms + +void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + return; +#endif + + const uint8_t index = static_cast(role); + if (index >= static_cast(TFTColorRole::Count)) { + return; + } + + const uint32_t themeId = uiconfig.screen_rgb_color & 0x1F; + const bool isHighlightRole = (role == TFTColorRole::FavoriteNode || role == TFTColorRole::FavoriteNodeBGHighlight); + const bool isBodyRole = !isHighlightRole && isBodyColorRole(role); + + // Classic monochrome themes collapse all non-black accents into one tone. + if (isMonochromeTheme(themeId)) { + if (onColor != TFTPalette::Black) { + onColor = getMonochromeAccent(themeId); + } + } else { + switch (themeId) { + case ThemeID::DefaultLight: + if (isHighlightRole) { + // High-contrast highlight chips on light UI. + onColor = TFTPalette::Black; + offColor = TFTPalette::Yellow; + } else if (isBodyRole) { + // Invert body colors for readability on white frames. + if (offColor == TFTPalette::Black && role != TFTColorRole::ActionMenuTitle) { + offColor = TFTPalette::White; + } + replaceColor(onColor, TFTPalette::White, TFTPalette::Black); + } + break; + case ThemeID::Christmas: + if (isHighlightRole || isBodyRole) { + replaceColor(onColor, TFTPalette::Yellow, TFTPalette::Gold); + replaceColor(offColor, TFTPalette::Black, TFTPalette::Pine); + } + break; + case ThemeID::Pink: + if (isHighlightRole) { + onColor = TFTPalette::Black; + offColor = TFTPalette::HotPink; + } else if (isBodyRole) { + replaceColor(offColor, TFTPalette::Black, TFTPalette::PalePink); + replaceColor(onColor, TFTPalette::White, TFTPalette::Black); + replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepPink); + } + break; + case ThemeID::Creamsicle: + if (isHighlightRole) { + onColor = TFTPalette::Black; + offColor = TFTPalette::CreamOrange; + } else if (isBodyRole) { + replaceColor(offColor, TFTPalette::Black, TFTPalette::Cream); + replaceColor(onColor, TFTPalette::White, TFTPalette::Black); + replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepOrange); + } + break; + case ThemeID::Blue: + if (isHighlightRole || isBodyRole) { + replaceColor(onColor, TFTPalette::Yellow, TFTPalette::SkyBlue); + replaceColor(offColor, TFTPalette::Black, TFTPalette::Navy); + } + break; + default: + break; + } + } + + roleColors[index].onColorBe = toBe565(onColor); + roleColors[index].offColorBe = toBe565(offColor); +} + +// Region registration + +void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + return; +#endif + + const uint8_t roleIndex = static_cast(role); + if (roleIndex >= static_cast(TFTColorRole::Count)) { + return; + } + + if (!normalizeRegion(x, y, width, height)) { + return; + } + + const TFTRoleColorsBe &colors = roleColors[roleIndex]; + appendColorRegion(x, y, width, height, colors.onColorBe, colors.offColorBe); +} + +void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width, + int16_t height) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + (void)role; + (void)onColor; + (void)offColor; + (void)x; + (void)y; + (void)width; + (void)height; + return; +#else + setTFTColorRole(role, onColor, offColor); + registerTFTColorRegion(role, x, y, width, height); +#endif +} + +void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + return; +#endif + + if (!normalizeRegion(x, y, width, height)) + return; + + appendColorRegion(x, y, width, height, toBe565(onColor), toBe565(offColor)); +} + +void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + (void)boxLeft; + (void)boxTop; + (void)boxWidth; + (void)boxHeight; + return; +#else + // Use theme-appropriate menu colors. + const TFTThemeDef &theme = kThemes[resolveThemeIndex()]; + const TFTThemeRoleColor &menuBody = theme.roles[static_cast(TFTColorRole::ActionMenuBody)]; + const TFTThemeRoleColor &menuBorder = theme.roles[static_cast(TFTColorRole::ActionMenuBorder)]; + + // Fill role includes a 1px shadow guard so stale frame edges are overwritten uniformly. + setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBody, menuBody.onColor, menuBody.offColor, boxLeft - 1, boxTop - 1, + boxWidth + 2, boxHeight + 2); + registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop - 2, boxWidth, 1); + registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop + boxHeight + 1, boxWidth, 1); + registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft - 2, boxTop, 1, boxHeight); + registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft + boxWidth + 1, boxTop, 1, boxHeight); + + setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBorder, menuBorder.onColor, menuBorder.offColor, boxLeft, boxTop, boxWidth, + 1); + registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop + boxHeight - 1, boxWidth, 1); + registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop, 1, boxHeight); + registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft + boxWidth - 1, boxTop, 1, boxHeight); +#endif +} + +// Frame signature & utilities + +uint32_t getTFTColorFrameSignature() +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + return 0; +#else + uint32_t hash = kFnv1aOffsetBasis; + hash = fnv1aAppendByte(hash, colorRegionCount); + for (uint8_t i = 0; i < colorRegionCount; i++) { + const TFTColorRegion &r = colorRegions[i]; + hash = fnv1aAppendU16(hash, static_cast(r.x)); + hash = fnv1aAppendU16(hash, static_cast(r.y)); + hash = fnv1aAppendU16(hash, static_cast(r.width)); + hash = fnv1aAppendU16(hash, static_cast(r.height)); + hash = fnv1aAppendU16(hash, r.onColorBe); + hash = fnv1aAppendU16(hash, r.offColorBe); + } + + return hash; +#endif +} + +uint8_t getTFTColorRegionCount() +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + return 0; +#else + return colorRegionCount; +#endif +} + +void clearTFTColorRegions() +{ + for (uint8_t i = 0; i < colorRegionCount; i++) { + colorRegions[i].enabled = false; + } + if (colorRegionCount < MAX_TFT_COLOR_REGIONS) { + colorRegions[colorRegionCount].enabled = false; + } + colorRegionCount = 0; +} + +uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor) +{ + for (int i = static_cast(colorRegionCount) - 1; i >= 0; i--) { + const TFTColorRegion &r = colorRegions[i]; + if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) { + return isset ? r.onColorBe : r.offColorBe; + } + } + return isset ? defaultOnColor : defaultOffColor; +} + +uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + (void)x; + (void)y; + return defaultOffColor; +#else + const uint16_t defaultOffBe = toBe565(defaultOffColor); + const uint16_t sampledBe = resolveTFTColorPixel(x, y, false, defaultOffBe, defaultOffBe); + return static_cast((sampledBe >> 8) | (sampledBe << 8)); +#endif +} + +} // namespace graphics diff --git a/src/graphics/TFTColorRegions.h b/src/graphics/TFTColorRegions.h new file mode 100644 index 00000000000..fd35bdb1a2d --- /dev/null +++ b/src/graphics/TFTColorRegions.h @@ -0,0 +1,163 @@ +#pragma once + +#include "configuration.h" +#include + +namespace graphics +{ + +struct TFTColorRegion { + int16_t x; + int16_t y; + int16_t width; + int16_t height; + uint16_t onColorBe; + uint16_t offColorBe; + // Required by ST7789 driver: it scans until the first disabled entry. + bool enabled = false; +}; + +static constexpr size_t MAX_TFT_COLOR_REGIONS = 48; +extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS]; + +enum class TFTColorRole : uint8_t { + HeaderBackground = 0, + HeaderTitle, + HeaderStatus, + SignalBars, + ConnectionIcon, + UtilizationFill, + FavoriteNode, + ActionMenuBorder, + ActionMenuBody, + ActionMenuTitle, + FrameMono, + BootSplash, + FavoriteNodeBGHighlight, + NavigationBar, + NavigationArrow, + 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) +#define GRAPHICS_TFT_COLORING_ENABLED 1 +#else +#define GRAPHICS_TFT_COLORING_ENABLED 0 +#endif + +static constexpr bool kTFTColoringEnabled = GRAPHICS_TFT_COLORING_ENABLED != 0; +constexpr bool isTFTColoringEnabled() +{ + return kTFTColoringEnabled; +} + +void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor); +void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height); +// Convenience helper for the common "set role then register one region" flow. +void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width, + int16_t height); +// Register a region using explicit colors (no role lookup). Use when the +// color comes from a theme field rather than a role (e.g. battery fill). +void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor); +void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight); +uint32_t getTFTColorFrameSignature(); +uint8_t getTFTColorRegionCount(); +void clearTFTColorRegions(); +uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor); +// 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); + +// -- Theme engine ------------------------------------------------------ +// Each theme has four fields that work together: +// +// id - ThemeID:: constant, used for in-code references. +// name - human-readable label shown in the theme picker. +// uniqueIdentifier - the stable numeric value persisted to +// uiconfig.screen_rgb_color and restored at boot. +// This is a CONTRACT with saved configs on disk - once +// assigned, never reuse or renumber, even if the theme is +// deleted or the kThemes[] array is reordered. +// visible - controls whether a theme appears in the picker menu. +// Hidden themes can still be restored and applied if their +// uniqueIdentifier is persisted. +// +// Display order in the menu is controlled by kThemes[] array order among +// themes where visible == true, NOT by any numeric value above. +// +// To add a new theme: +// 1. Add a unique constant in ThemeID below (next unused value). +// 2. Add a kThemes[] entry at the desired menu position, with a unique +// uniqueIdentifier that has never been used by any prior theme. +// 3. Set visible=true if it should appear in the picker. +// +// To retire a theme without breaking saved configs: +// - Preferred: keep the entry and set visible=false so existing saved +// uniqueIdentifier values still resolve to the same theme. +// - If you remove the entry, resolveThemeIndex() falls back to DefaultDark +// when the persisted uniqueIdentifier no longer matches any theme. +// - Do NOT reuse a retired uniqueIdentifier for a future theme. +namespace ThemeID +{ +constexpr uint32_t DefaultDark = 0; +constexpr uint32_t DefaultLight = 1; +constexpr uint32_t Christmas = 2; +constexpr uint32_t Pink = 3; +constexpr uint32_t Blue = 4; +constexpr uint32_t Creamsicle = 5; +constexpr uint32_t MeshtasticGreen = 6; +constexpr uint32_t ClassicRed = 7; +constexpr uint32_t MonochromeWhite = 8; +} // namespace ThemeID + +// Per-role color pair stored in native (little-endian) RGB565 format. +struct TFTThemeRoleColor { + uint16_t onColor; + uint16_t offColor; +}; + +// Complete theme definition. +struct TFTThemeDef { + uint32_t id; // ThemeID constant - in-code identifier for this theme. + const char *name; // Human-readable label shown in the theme picker. + uint32_t uniqueIdentifier; // Stable persisted value copied into uiconfig.screen_rgb_color. + // Never reuse or renumber - see file-level notes above. + TFTThemeRoleColor roles[static_cast(TFTColorRole::Count)]; + uint16_t batteryFillGood; + uint16_t batteryFillMedium; + uint16_t batteryFillBad; + bool fullFrameInvert; // Apply full-frame FrameMono inversion (ST7789 light themes) + bool visible; // Show in the theme picker menu. Hidden themes still apply + // correctly if their uniqueIdentifier is persisted (dev/legacy themes). +}; + +// Count of themes whose .visible flag is true. Use this when building menus. +size_t getVisibleThemeCount(); + +// Access the Nth visible theme (0 .. getVisibleThemeCount()-1). Hidden themes +// are skipped, preserving kThemes[] order among the visible entries. +const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex); + +// Return the theme that matches uiconfig.screen_rgb_color (falls back to Dark). +const TFTThemeDef &getActiveTheme(); + +// Return the visible-theme index for the currently active theme, or SIZE_MAX +// if the active theme is hidden (so menus can show "no selection"). +size_t getActiveVisibleThemeIndex(); + +// Convenience accessors - safe to call even when coloring is compiled out. +uint16_t getThemeHeaderBg(); +uint16_t getThemeHeaderText(); +uint16_t getThemeHeaderStatus(); +uint16_t getThemeBodyBg(); +uint16_t getThemeBodyFg(); +bool isThemeFullFrameInvert(); +uint16_t getThemeBatteryFillColor(int batteryPercent); + +// Reinitialise default roleColors from the active theme. Call after a +// theme change so that any role registered without a prior setTFTColorRole() +// picks up theme-appropriate defaults. +void loadThemeDefaults(); + +} // namespace graphics diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 4c8272955a5..7df0c57cc62 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -16,12 +16,6 @@ extern SX1509 gpioExtender; #endif -#ifdef TFT_MESH_OVERRIDE -uint16_t TFT_MESH = TFT_MESH_OVERRIDE; -#else -uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94); -#endif - #if defined(ST7735S) #include // Graphics and font library for ST7735 driver chip @@ -1140,7 +1134,9 @@ static LGFX *tft = nullptr; #endif #include "SPILock.h" +#include "TFTColorRegions.h" #include "TFTDisplay.h" +#include "TFTPalette.h" #include #ifdef UNPHONE @@ -1150,6 +1146,25 @@ extern unPhone unphone; GpioPin *TFTDisplay::backlightEnable = NULL; +namespace +{ +static constexpr uint8_t kFullRepaintChunkRows = 8; + +static inline uint16_t getThemeDefaultOnColor() +{ + return graphics::TFTPalette::White; +} + +static inline uint16_t getThemeDefaultOffColor() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + return graphics::getThemeBodyBg(); +#else + return TFT_BLACK; +#endif +} +} // namespace + TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus) { LOG_DEBUG("TFTDisplay!"); @@ -1189,14 +1204,15 @@ TFTDisplay::~TFTDisplay() free(linePixelBuffer); linePixelBuffer = nullptr; } + if (repaintChunkBuffer != nullptr) { + free(repaintChunkBuffer); + repaintChunkBuffer = nullptr; + } } // Write the buffer to the display memory void TFTDisplay::display(bool fromBlank) { - if (fromBlank) - tft->fillScreen(TFT_BLACK); - concurrency::LockGuard g(spiLock); uint32_t x, y; @@ -1205,12 +1221,70 @@ void TFTDisplay::display(bool fromBlank) uint32_t x_FirstPixelUpdate; uint32_t x_LastPixelUpdate; bool isset, dblbuf_isset; - uint16_t colorTftMesh, colorTftBlack; + uint16_t colorTftWhite, colorTftBlack; bool somethingChanged = false; - // Store colors byte-reversed so that TFT_eSPI doesn't have to swap bytes in a separate step - colorTftMesh = __builtin_bswap16(TFT_MESH); - colorTftBlack = __builtin_bswap16(TFT_BLACK); + // Theme defaults for non-role pixels. + const uint16_t defaultOnColor = getThemeDefaultOnColor(); + const uint16_t defaultOffColor = getThemeDefaultOffColor(); + static uint16_t lastDefaultOnColor = 0; + static uint16_t lastDefaultOffColor = 0; + static bool haveLastDefaults = false; + const bool themeDefaultsChanged = + !haveLastDefaults || (defaultOnColor != lastDefaultOnColor) || (defaultOffColor != lastDefaultOffColor); + const bool forceFullRepaint = fromBlank || themeDefaultsChanged; + + // If theme defaults changed, reset panel background immediately so stale pixels don't linger. + if (forceFullRepaint) { + tft->fillScreen(defaultOffColor); + } + + colorTftWhite = (defaultOnColor >> 8) | ((defaultOnColor & 0xFF) << 8); + colorTftBlack = (defaultOffColor >> 8) | ((defaultOffColor & 0xFF) << 8); + +#if GRAPHICS_TFT_COLORING_ENABLED + static uint32_t lastColorFrameSignature = 0; + const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0; + const uint32_t colorFrameSignature = graphics::getTFTColorFrameSignature(); + const bool forceFullColorRepaint = forceFullRepaint || (colorFrameSignature != lastColorFrameSignature); + + // When region roles/layout changed, color can differ even with identical monochrome glyph bits. + // Repaint full frame only for those frames, then return to diff-based updates. + if (forceFullColorRepaint) { + for (uint32_t yStart = 0; yStart < displayHeight; yStart += kFullRepaintChunkRows) { + const uint32_t rowsThisChunk = min(kFullRepaintChunkRows, displayHeight - yStart); + for (uint32_t row = 0; row < rowsThisChunk; row++) { + y = yStart + row; + y_byteIndex = (y / 8) * displayWidth; + y_byteMask = (1 << (y & 7)); + + uint16_t *chunkRow = repaintChunkBuffer + (row * displayWidth); + for (x = 0; x < displayWidth; x++) { + isset = (buffer[x + y_byteIndex] & y_byteMask) != 0; + if (hasColorRegions) { + chunkRow[x] = graphics::resolveTFTColorPixel(static_cast(x), static_cast(y), isset, + colorTftWhite, colorTftBlack); + } else { + chunkRow[x] = isset ? colorTftWhite : colorTftBlack; + } + } + } +#if defined(HACKADAY_COMMUNICATOR) + tft->draw16bitBeRGBBitmap(0, yStart, repaintChunkBuffer, displayWidth, rowsThisChunk); +#else + tft->pushImage(0, yStart, displayWidth, rowsThisChunk, repaintChunkBuffer); +#endif + } + + memcpy(buffer_back, buffer, displayBufferSize); + lastColorFrameSignature = colorFrameSignature; + haveLastDefaults = true; + lastDefaultOnColor = defaultOnColor; + lastDefaultOffColor = defaultOffColor; + graphics::clearTFTColorRegions(); + return; + } +#endif y = 0; while (y < displayHeight) { @@ -1219,7 +1293,7 @@ void TFTDisplay::display(bool fromBlank) // Step 1: Do a quick scan of 8 rows together. This allows fast-forwarding over unchanged screen areas. if (y_byteMask == 1) { - if (!fromBlank) { + if (!forceFullRepaint) { for (x = 0; x < displayWidth; x++) { if (buffer[x + y_byteIndex] != buffer_back[x + y_byteIndex]) break; @@ -1237,13 +1311,14 @@ void TFTDisplay::display(bool fromBlank) } } - // Step 2: Scan each of the 8 rows individually. Find the first pixel in each row that needs updating - for (x_FirstPixelUpdate = 0; x_FirstPixelUpdate < displayWidth; x_FirstPixelUpdate++) { - isset = buffer[x_FirstPixelUpdate + y_byteIndex] & y_byteMask; + // Step 2: Scan this row for changed span (first and last changed pixel). + uint32_t x_FirstChanged = 0; + for (x_FirstChanged = 0; x_FirstChanged < displayWidth; x_FirstChanged++) { + isset = buffer[x_FirstChanged + y_byteIndex] & y_byteMask; - if (!fromBlank) { + if (!forceFullRepaint) { // get src pixel in the page based ordering the OLED lib uses - dblbuf_isset = buffer_back[x_FirstPixelUpdate + y_byteIndex] & y_byteMask; + dblbuf_isset = buffer_back[x_FirstChanged + y_byteIndex] & y_byteMask; if (isset != dblbuf_isset) { break; } @@ -1253,43 +1328,51 @@ void TFTDisplay::display(bool fromBlank) } // Did we find a pixel that needs updating on this row? - if (x_FirstPixelUpdate < displayWidth) { - // Align the first pixel for update to an even number so the total alignment of - // the data will be at 32-bit boundary, which is required by GDMA SPI transfers. - x_FirstPixelUpdate &= ~1; - - // Step 3a: copy rest of the pixels in this row into the pixel line buffer, - // while also recording the last pixel in the row that needs updating. - // Since the first changed pixel will be looked up, the x_LastPixelUpdate will be set. - for (x = x_FirstPixelUpdate; x < displayWidth; x++) { - isset = buffer[x + y_byteIndex] & y_byteMask; - linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack; - - if (!fromBlank) { - dblbuf_isset = buffer_back[x + y_byteIndex] & y_byteMask; + if (x_FirstChanged < displayWidth) { + uint32_t x_LastChanged = displayWidth - 1; + while (x_LastChanged > x_FirstChanged) { + isset = buffer[x_LastChanged + y_byteIndex] & y_byteMask; + if (!forceFullRepaint) { + dblbuf_isset = buffer_back[x_LastChanged + y_byteIndex] & y_byteMask; if (isset != dblbuf_isset) { - x_LastPixelUpdate = x; + break; } } else if (isset) { - x_LastPixelUpdate = x; + break; } + x_LastChanged--; } - // Step 3b: Round up the last pixel to odd number to maintain 32-bit alignment for SPIs. - // Most displays will have even number of pixels in a row -- this will be in bounds - // of the displayWidth. (Hopefully odd displays will just ignore that extra pixel.) - x_LastPixelUpdate |= 1; - // Ensure the last pixel index does not exceed the display width. + + // Align the first pixel for update to an even number so the total alignment of + // the data will be at 32-bit boundary, which is required by GDMA SPI transfers. + x_FirstPixelUpdate = x_FirstChanged & ~1U; + x_LastPixelUpdate = x_LastChanged | 1U; if (x_LastPixelUpdate >= displayWidth) { x_LastPixelUpdate = displayWidth - 1; } + + // Step 3: Copy only the changed span into the pixel line buffer. + 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(x), static_cast(y), isset, + colorTftWhite, colorTftBlack); + } else { + linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack; + } +#else + linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack; +#endif + } #if defined(HACKADAY_COMMUNICATOR) tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate], (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1); #else // Step 4: Send the changed pixels on this line to the screen as a single block transfer. // This function accepts pixel data MSB first so it can dump the memory straight out the SPI port. - tft->pushRect(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1, - &linePixelBuffer[x_FirstPixelUpdate]); + tft->pushImage(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1, + &linePixelBuffer[x_FirstPixelUpdate]); #endif somethingChanged = true; } @@ -1298,6 +1381,14 @@ void TFTDisplay::display(bool fromBlank) // Copy the Buffer to the Back Buffer if (somethingChanged) memcpy(buffer_back, buffer, displayBufferSize); + +#if GRAPHICS_TFT_COLORING_ENABLED + lastColorFrameSignature = colorFrameSignature; +#endif + haveLastDefaults = true; + lastDefaultOnColor = defaultOnColor; + lastDefaultOffColor = defaultOffColor; + graphics::clearTFTColorRegions(); } void TFTDisplay::sdlLoop() @@ -1511,7 +1602,7 @@ bool TFTDisplay::connect() #else tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label #endif - tft->fillScreen(TFT_BLACK); + tft->fillScreen(getThemeDefaultOffColor()); if (this->linePixelBuffer == NULL) { this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth); @@ -1521,6 +1612,14 @@ bool TFTDisplay::connect() return false; } } + if (this->repaintChunkBuffer == NULL) { + this->repaintChunkBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * kFullRepaintChunkRows); + + if (!this->repaintChunkBuffer) { + LOG_ERROR("Not enough memory to create TFT repaint chunk buffer\n"); + return false; + } + } return true; } diff --git a/src/graphics/TFTDisplay.h b/src/graphics/TFTDisplay.h index a64922d23ab..2c86f05d291 100644 --- a/src/graphics/TFTDisplay.h +++ b/src/graphics/TFTDisplay.h @@ -63,4 +63,5 @@ class TFTDisplay : public OLEDDisplay virtual bool connect() override; uint16_t *linePixelBuffer = nullptr; -}; \ No newline at end of file + uint16_t *repaintChunkBuffer = nullptr; +}; diff --git a/src/graphics/TFTPalette.h b/src/graphics/TFTPalette.h new file mode 100644 index 00000000000..516a9f05767 --- /dev/null +++ b/src/graphics/TFTPalette.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +namespace graphics +{ +namespace TFTPalette +{ + +constexpr uint16_t rgb565(uint8_t red, uint8_t green, uint8_t blue) +{ + return static_cast(((red & 0xF8) << 8) | ((green & 0xFC) << 3) | ((blue & 0xF8) >> 3)); +} + +constexpr uint16_t Black = 0x0000; +constexpr uint16_t White = 0xFFFF; +constexpr uint16_t DarkGray = 0x4208; +constexpr uint16_t Gray = 0x8410; +constexpr uint16_t LightGray = 0xC618; + +constexpr uint16_t Red = rgb565(255, 0, 0); +constexpr uint16_t Green = rgb565(0, 255, 0); +constexpr uint16_t Blue = rgb565(0, 130, 252); +constexpr uint16_t Yellow = rgb565(255, 255, 0); +constexpr uint16_t Orange = rgb565(255, 165, 0); +constexpr uint16_t Cyan = rgb565(0, 255, 255); +constexpr uint16_t Magenta = rgb565(255, 0, 255); + +constexpr uint16_t Good = Green; +constexpr uint16_t Medium = Yellow; +constexpr uint16_t Bad = Red; + +// Christmas / seasonal accent colors +constexpr uint16_t ChristmasRed = rgb565(178, 34, 34); +constexpr uint16_t ChristmasGreen = rgb565(0, 128, 0); +constexpr uint16_t Gold = rgb565(255, 215, 0); +constexpr uint16_t Pine = rgb565(15, 35, 10); + +// Pink theme colors (light variant) +constexpr uint16_t HotPink = rgb565(255, 105, 180); +constexpr uint16_t PalePink = rgb565(255, 228, 235); +constexpr uint16_t DeepPink = rgb565(200, 50, 120); + +// Blue theme colors (dark variant) +constexpr uint16_t SkyBlue = rgb565(100, 180, 255); +constexpr uint16_t Navy = rgb565(15, 15, 50); +constexpr uint16_t DeepBlue = rgb565(30, 60, 120); + +// Creamsicle theme colors (light variant) +constexpr uint16_t CreamOrange = rgb565(255, 140, 50); +constexpr uint16_t DeepOrange = rgb565(220, 100, 20); +constexpr uint16_t Cream = rgb565(255, 248, 235); + +// Classic monochrome theme accent colors (single-color-on-black themes) +constexpr uint16_t MeshtasticGreen = rgb565(0x67, 0xEA, 0x94); +constexpr uint16_t ClassicRed = rgb565(255, 64, 64); +// Monochrome White reuses TFTPalette::White above. + +// Fast contrast picker for monochrome glyph overlays on arbitrary RGB565 backgrounds. +// Uses channel-sum brightness approximation to keep code size small. +constexpr uint16_t pickReadableMonoFg(uint16_t backgroundColor) +{ + const uint16_t r = (backgroundColor >> 11) & 0x1F; + const uint16_t g = (backgroundColor >> 5) & 0x3F; + const uint16_t b = backgroundColor & 0x1F; + return ((r + g + b) >= 70) ? DarkGray : White; +} + +} // namespace TFTPalette +} // namespace graphics diff --git a/src/graphics/draw/ClockRenderer.cpp b/src/graphics/draw/ClockRenderer.cpp index 66bbe1bfe8e..5045174be2a 100644 --- a/src/graphics/draw/ClockRenderer.cpp +++ b/src/graphics/draw/ClockRenderer.cpp @@ -145,7 +145,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1 // === Set Title, Blank for Clock const char *titleStr = ""; // === Header === - graphics::drawCommonHeader(display, x, y, titleStr, true, true); + graphics::drawCommonHeader(display, x, y, titleStr, true, true, true); uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone char timeString[16]; @@ -293,11 +293,15 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1 // Draw an analog clock void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { +#if GRAPHICS_TFT_COLORING_ENABLED + // Clear previous frame pixels so moving hands don't leave stale artifacts on TFT light theme. + display->clear(); +#endif display->setTextAlignment(TEXT_ALIGN_LEFT); // === Set Title, Blank for Clock const char *titleStr = ""; // === Header === - graphics::drawCommonHeader(display, x, y, titleStr, true, true); + graphics::drawCommonHeader(display, x, y, titleStr, true, true, true); // clock face center coordinates int16_t centerX = display->getWidth() / 2; @@ -478,4 +482,4 @@ void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 } // namespace ClockRenderer } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/CompassRenderer.cpp b/src/graphics/draw/CompassRenderer.cpp index fe54d68e714..1ee02194c0f 100644 --- a/src/graphics/draw/CompassRenderer.cpp +++ b/src/graphics/draw/CompassRenderer.cpp @@ -9,113 +9,61 @@ namespace graphics { namespace CompassRenderer { - -// Point helper class for compass calculations -struct Point { - float x, y; - Point(float x, float y) : x(x), y(y) {} - - void rotate(float angle) - { - float cos_a = cosf(angle); - float sin_a = sinf(angle); - float new_x = x * cos_a - y * sin_a; - float new_y = x * sin_a + y * cos_a; - x = new_x; - y = new_y; - } - - void scale(float factor) - { - x *= factor; - y *= factor; - } - - void translate(float dx, float dy) - { - x += dx; - y += dy; - } -}; - void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius) { - // Show the compass heading (not implemented in original) - // This could draw a "N" indicator or north arrow - // For now, we'll draw a simple north indicator - // const float radius = 17.0f; if (currentResolution == ScreenResolution::High) { radius += 4; } - float northX = 0.0f; - float northY = -radius; - if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) { - const float c = cosf(-myHeading); - const float s = sinf(-myHeading); - const float rx = northX * c - northY * s; - const float ry = northX * s + northY * c; - northX = rx; - northY = ry; - } - northX += compassX; - northY += compassY; + + const float northAngle = (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -myHeading : 0.0f; + const int16_t nX = compassX + static_cast((radius - 1) * sinf(northAngle)); + const int16_t nY = compassY - static_cast((radius - 1) * cosf(northAngle)); display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_CENTER); +#if !GRAPHICS_TFT_COLORING_ENABLED display->setColor(BLACK); const int16_t nLabelWidth = display->getStringWidth("N"); if (currentResolution == ScreenResolution::High) { - display->fillRect(northX - 8, northY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6); + display->fillRect(nX - 8, nY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6); } else { - display->fillRect(northX - 4, northY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6); + display->fillRect(nX - 4, nY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6); } +#endif display->setColor(WHITE); - display->drawString(northX, northY - 3, "N"); + display->drawString(nX, nY - 3, "N"); } -void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian) +void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing) { - Point tip(0.0f, -0.5f), tail(0.0f, 0.35f); // pointing up initially - float arrowOffsetX = 0.14f, arrowOffsetY = 0.9f; - Point leftArrow(tip.x - arrowOffsetX, tip.y + arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y + arrowOffsetY); - - Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow}; - - for (int i = 0; i < 4; i++) { - arrowPoints[i]->rotate(headingRadian); - arrowPoints[i]->scale(compassDiam * 0.6); - arrowPoints[i]->translate(compassX, compassY); - } - -#ifdef USE_EINK - display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); -#else - display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); -#endif - display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y); + const float radians = bearing * DEG_TO_RAD; + const float sinA = sinf(radians); + const float cosA = cosf(radians); + const float tipHalf = size * 0.5f; + const float lx = -(size / 6.0f); + const float ly = size / 4.0f; + const float rx = (size / 6.0f); + const float ry = size / 4.0f; + const float tx = 0.0f; + const float ty = size / 4.5f; + + const int16_t tipX = static_cast(x + (tipHalf * sinA)); + const int16_t tipY = static_cast(y - (tipHalf * cosA)); + const int16_t leftX = static_cast(x + (lx * cosA) - (ly * sinA)); + const int16_t leftY = static_cast(y + (lx * sinA) + (ly * cosA)); + const int16_t rightX = static_cast(x + (rx * cosA) - (ry * sinA)); + const int16_t rightY = static_cast(y + (rx * sinA) + (ry * cosA)); + const int16_t tailX = static_cast(x + (tx * cosA) - (ty * sinA)); + const int16_t tailY = static_cast(y + (tx * sinA) + (ty * cosA)); + + display->fillTriangle(tipX, tipY, leftX, leftY, tailX, tailY); + display->fillTriangle(tipX, tipY, rightX, rightY, tailX, tailY); } -void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing) +void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian) { - float radians = bearing * DEG_TO_RAD; - - Point tip(0, -size / 2); - Point left(-size / 6, size / 4); - Point right(size / 6, size / 4); - Point tail(0, size / 4.5); - - tip.rotate(radians); - left.rotate(radians); - right.rotate(radians); - tail.rotate(radians); - - tip.translate(x, y); - left.translate(x, y); - right.translate(x, y); - tail.translate(x, y); - - display->fillTriangle(tip.x, tip.y, left.x, left.y, tail.x, tail.y); - display->fillTriangle(tip.x, tip.y, right.x, right.y, tail.x, tail.y); + const int16_t size = static_cast(compassDiam * 0.6f); + drawArrowToNode(display, compassX, compassY, size, headingRadian * RAD_TO_DEG); } bool getHeadingRadians(double lat, double lon, float &headingRadian) diff --git a/src/graphics/draw/CompassRenderer.h b/src/graphics/draw/CompassRenderer.h index d7762384769..41adf6e6461 100644 --- a/src/graphics/draw/CompassRenderer.h +++ b/src/graphics/draw/CompassRenderer.h @@ -1,6 +1,7 @@ #pragma once #include "graphics/Screen.h" +#include "mesh/generated/meshtastic/mesh.pb.h" #include #include diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 7a12650ca6a..67136437a5e 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -11,6 +11,8 @@ #include "gps/RTC.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/TimeFormatters.h" #include "graphics/images.h" #include "main.h" @@ -469,9 +471,11 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int chUtil_y = getTextPositions(display)[line] + 3; int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50; + int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7; int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3; int chutil_percent = airTime->channelUtilizationPercent(); + const int raw_chutil_percent = chutil_percent; int centerofscreen = SCREEN_WIDTH / 2; int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; @@ -479,7 +483,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->drawString(starting_position, getTextPositions(display)[line], chUtil); - // Force 56% or higher to show a full 100% bar, text would still show related percent. + // Force 61% or higher to show a full 100% bar, text would still show related percent. if (chutil_percent >= 61) { chutil_percent = 100; } @@ -492,9 +496,9 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, float weight3 = 0.20; // Weight for 40–100% float totalWeight = weight1 + weight2 + weight3; - int seg1 = chutil_bar_width * (weight1 / totalWeight); - int seg2 = chutil_bar_width * (weight2 / totalWeight); - int seg3 = chutil_bar_width * (weight3 / totalWeight); + int seg1 = chutil_bar_max_fill * (weight1 / totalWeight); + int seg2 = chutil_bar_max_fill * (weight2 / totalWeight); + int seg3 = chutil_bar_max_fill - seg1 - seg2; // Remainder absorbs rounding errors int fillRight = 0; @@ -511,7 +515,17 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, // Fill progress if (fillRight > 0) { - display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height); +#if GRAPHICS_TFT_COLORING_ENABLED + uint16_t UtilizationFillColor = TFTPalette::Good; + if (raw_chutil_percent >= 60) { + UtilizationFillColor = TFTPalette::Bad; + } else if (raw_chutil_percent >= 35) { + UtilizationFillColor = TFTPalette::Medium; + } + setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, + starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); +#endif + display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); } display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++], @@ -584,6 +598,17 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x display->setColor(WHITE); display->drawRect(barX, barY, adjustedBarWidth, barHeight); +#if GRAPHICS_TFT_COLORING_ENABLED + uint16_t UtilizationFillColor = TFTPalette::Good; + if (percent >= 80) { + UtilizationFillColor = TFTPalette::Bad; + } else if (percent >= 60) { + UtilizationFillColor = TFTPalette::Medium; + } + setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, barX + 1, barY + 1, + fillWidth - 1, barHeight - 2); +#endif + display->fillRect(barX, barY, fillWidth, barHeight); display->setColor(WHITE); #endif diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index e92ba48394e..f31cb405b60 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -11,6 +11,7 @@ #include "buzz.h" #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" #include "graphics/draw/MessageRenderer.h" #include "graphics/draw/UIRenderer.h" #include "input/RotaryEncoderInterruptImpl1.h" @@ -30,8 +31,6 @@ #include #include -extern uint16_t TFT_MESH; - namespace graphics { @@ -2028,109 +2027,6 @@ void menuHandler::switchToMUIMenu() screen->showOverlayBanner(bannerOptions); } -void menuHandler::TFTColorPickerMenu(OLEDDisplay *display) -{ - static const ScreenColorOption colorOptions[] = { - {"Back", OptionsAction::Back}, - {"Default", OptionsAction::Select, ScreenColor(0, 0, 0, true)}, - {"Meshtastic Green", OptionsAction::Select, ScreenColor(0x67, 0xEA, 0x94)}, - {"Yellow", OptionsAction::Select, ScreenColor(255, 255, 128)}, - {"Red", OptionsAction::Select, ScreenColor(255, 64, 64)}, - {"Orange", OptionsAction::Select, ScreenColor(255, 160, 20)}, - {"Purple", OptionsAction::Select, ScreenColor(204, 153, 255)}, - {"Blue", OptionsAction::Select, ScreenColor(0, 0, 255)}, - {"Teal", OptionsAction::Select, ScreenColor(16, 102, 102)}, - {"Cyan", OptionsAction::Select, ScreenColor(0, 255, 255)}, - {"Ice", OptionsAction::Select, ScreenColor(173, 216, 230)}, - {"Pink", OptionsAction::Select, ScreenColor(255, 105, 180)}, - {"White", OptionsAction::Select, ScreenColor(255, 255, 255)}, - {"Gray", OptionsAction::Select, ScreenColor(128, 128, 128)}, - }; - - constexpr size_t colorCount = sizeof(colorOptions) / sizeof(colorOptions[0]); - static std::array colorLabels{}; - - auto bannerOptions = createStaticBannerOptions( - "Select Screen Color", colorOptions, colorLabels, [display](const ScreenColorOption &option, int) -> void { - if (option.action == OptionsAction::Back) { - menuQueue = SystemBaseMenu; - screen->runNow(); - return; - } - - if (!option.hasValue) { - return; - } - -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \ - HAS_TFT || defined(HACKADAY_COMMUNICATOR) - const ScreenColor &color = option.value; - if (color.useVariant) { - LOG_INFO("Setting color to system default or defined variant"); - } else { - LOG_INFO("Setting color to %s", option.label); - } - - uint8_t r = color.r; - uint8_t g = color.g; - uint8_t b = color.b; - - display->setColor(BLACK); - display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); - display->setColor(WHITE); - - if (color.useVariant || (r == 0 && g == 0 && b == 0)) { -#ifdef TFT_MESH_OVERRIDE - TFT_MESH = TFT_MESH_OVERRIDE; -#else - TFT_MESH = COLOR565(255, 255, 128); -#endif - } else { - TFT_MESH = COLOR565(r, g, b); - } - -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) - static_cast(screen->getDisplayDevice())->setRGB(TFT_MESH); -#endif - - screen->setFrames(graphics::Screen::FOCUS_SYSTEM); - if (color.useVariant || (r == 0 && g == 0 && b == 0)) { - uiconfig.screen_rgb_color = 0; - } else { - uiconfig.screen_rgb_color = - (static_cast(r) << 16) | (static_cast(g) << 8) | static_cast(b); - } - LOG_INFO("Storing Value of %d to uiconfig.screen_rgb_color", uiconfig.screen_rgb_color); - saveUIConfig(); -#endif - }); - - int initialSelection = 0; - if (uiconfig.screen_rgb_color == 0) { - initialSelection = 1; - } else { - uint32_t currentColor = uiconfig.screen_rgb_color; - for (size_t i = 0; i < colorCount; ++i) { - if (!colorOptions[i].hasValue) { - continue; - } - const ScreenColor &color = colorOptions[i].value; - if (color.useVariant) { - continue; - } - uint32_t encoded = - (static_cast(color.r) << 16) | (static_cast(color.g) << 8) | static_cast(color.b); - if (encoded == currentColor) { - initialSelection = static_cast(i); - break; - } - } - } - bannerOptions.InitialSelected = initialSelection; - - screen->showOverlayBanner(bannerOptions); -} - void menuHandler::rebootMenu() { static const char *optionsArray[] = {"Back", "Confirm"}; @@ -2318,9 +2214,9 @@ void menuHandler::screenOptionsMenu() bool hasSupportBrightness = false; #endif - enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits, MessageBubbles }; - static const char *optionsArray[6] = {"Back"}; - static int optionsEnumArray[6] = {Back}; + enum optionsNumbers { Back, Brightness, FrameToggles, DisplayUnits, MessageBubbles, Theme }; + static const char *optionsArray[7] = {"Back"}; + static int optionsEnumArray[7] = {Back}; int options = 1; // Only show brightness for B&W displays @@ -2329,13 +2225,6 @@ void menuHandler::screenOptionsMenu() optionsEnumArray[options++] = Brightness; } - // Only show screen color for TFT displays -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \ - HAS_TFT || defined(HACKADAY_COMMUNICATOR) - optionsArray[options] = "Screen Color"; - optionsEnumArray[options++] = ScreenColor; -#endif - optionsArray[options] = "Frame Visibility"; optionsEnumArray[options++] = FrameToggles; @@ -2345,6 +2234,11 @@ void menuHandler::screenOptionsMenu() optionsArray[options] = "Message Bubbles"; optionsEnumArray[options++] = MessageBubbles; +#if GRAPHICS_TFT_COLORING_ENABLED + optionsArray[options] = "Theme"; + optionsEnumArray[options++] = Theme; +#endif + BannerOverlayOptions bannerOptions; bannerOptions.message = "Display Options"; bannerOptions.optionsArrayPtr = optionsArray; @@ -2354,9 +2248,6 @@ void menuHandler::screenOptionsMenu() if (selected == Brightness) { menuHandler::menuQueue = menuHandler::BrightnessPicker; screen->runNow(); - } else if (selected == ScreenColor) { - menuHandler::menuQueue = menuHandler::TftColorMenuPicker; - screen->runNow(); } else if (selected == FrameToggles) { menuHandler::menuQueue = menuHandler::FrameToggles; screen->runNow(); @@ -2366,6 +2257,9 @@ void menuHandler::screenOptionsMenu() } else if (selected == MessageBubbles) { menuHandler::menuQueue = menuHandler::MessageBubblesMenu; screen->runNow(); + } else if (selected == Theme) { + menuHandler::menuQueue = menuHandler::ThemeMenu; + screen->runNow(); } else { menuQueue = SystemBaseMenu; screen->runNow(); @@ -2649,6 +2543,53 @@ void menuHandler::messageBubblesMenu() screen->showOverlayBanner(bannerOptions); } +void menuHandler::themeMenu() +{ + // Build menu dynamically from the theme table. + // Only visible themes appear! + // Slot budget: 1 for "Back" + up to kMaxThemesInMenu visible themes. + // Bump kMaxThemesInMenu if you add more themes than will fit here. + constexpr size_t kMaxThemesInMenu = 15; + const size_t visibleCount = getVisibleThemeCount(); + static const char *optionsArray[kMaxThemesInMenu + 1] = {"Back"}; + const size_t shownCount = (visibleCount < kMaxThemesInMenu) ? visibleCount : kMaxThemesInMenu; + const int options = static_cast(shownCount) + 1; // +1 for Back + + for (size_t i = 0; i < shownCount; i++) { + optionsArray[i + 1] = getVisibleThemeByIndex(i).name; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Theme"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + + // Highlight the currently active theme (visible index + 1 for the Back + // offset). If the active theme is hidden, leave selection on "Back". + const size_t activeVisible = getActiveVisibleThemeIndex(); + bannerOptions.InitialSelected = (activeVisible == SIZE_MAX) ? 0 : static_cast(activeVisible) + 1; + + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + // Back + menuHandler::menuQueue = menuHandler::ScreenOptionsMenu; + screen->runNow(); + } else { + // Selection is an index into the VISIBLE themes (1-based, slot 0 is Back). + const size_t visibleIdx = static_cast(selected - 1); + if (visibleIdx < getVisibleThemeCount()) { + // Persist the theme's uniqueIdentifier so boot-time + // resolveThemeIndex() can restore this theme on next startup. + uiconfig.screen_rgb_color = COLOR565(255, 255, (getVisibleThemeByIndex(visibleIdx).uniqueIdentifier & 0x1F) << 3); + loadThemeDefaults(); + saveUIConfig(); + screen->runNow(); + } + } + }; + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::handleMenuSwitch(OLEDDisplay *display) { if (menuQueue != MenuNone) @@ -2724,9 +2665,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case MuiPicker: switchToMUIMenu(); break; - case TftColorMenuPicker: - TFTColorPickerMenu(display); - break; case BrightnessPicker: BrightnessPickerMenu(); break; @@ -2799,6 +2737,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case MessageBubblesMenu: messageBubblesMenu(); break; + case ThemeMenu: + themeMenu(); + break; } menuQueue = MenuNone; } @@ -2810,4 +2751,4 @@ void menuHandler::saveUIConfig() } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 4a0360412dd..3ac9e606e11 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -30,7 +30,6 @@ class menuHandler ResetNodeDbMenu, BuzzerModeMenuPicker, MuiPicker, - TftColorMenuPicker, BrightnessPicker, RebootMenu, ShutdownMenu, @@ -55,7 +54,8 @@ class menuHandler NodeNameLengthMenu, FrameToggles, DisplayUnits, - MessageBubblesMenu + MessageBubblesMenu, + ThemeMenu }; static screenMenus menuQueue; static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu @@ -89,7 +89,6 @@ class menuHandler static void GPSPositionBroadcastMenu(); static void BuzzerModeMenu(); static void switchToMUIMenu(); - static void TFTColorPickerMenu(OLEDDisplay *display); static void nodeListMenu(); static void resetNodeDBMenu(); static void BrightnessPickerMenu(); @@ -110,6 +109,7 @@ class menuHandler static void frameTogglesMenu(); static void displayUnitsMenu(); static void messageBubblesMenu(); + static void themeMenu(); static void textMessageMenu(); private: @@ -136,23 +136,10 @@ template struct MenuOption { MenuOption(const char *labelIn, OptionsAction actionIn) : label(labelIn), action(actionIn), hasValue(false), value() {} }; -struct ScreenColor { - uint8_t r; - uint8_t g; - uint8_t b; - bool useVariant; - - explicit ScreenColor(uint8_t rIn = 0, uint8_t gIn = 0, uint8_t bIn = 0, bool variantIn = false) - : r(rIn), g(gIn), b(bIn), useVariant(variantIn) - { - } -}; - using RadioPresetOption = MenuOption; using LoraRegionOption = MenuOption; using TimezoneOption = MenuOption; using CompassOption = MenuOption; -using ScreenColorOption = MenuOption; using GPSToggleOption = MenuOption; using GPSFormatOption = MenuOption; using NodeNameOption = MenuOption; diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 2fd9bf54108..2260c57df14 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -11,6 +11,8 @@ #include "graphics/Screen.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/TimeFormatters.h" #include "graphics/emotes.h" #include "main.h" @@ -254,6 +256,76 @@ struct MessageBlock { bool mine; }; +#if GRAPHICS_TFT_COLORING_ENABLED +static void setDarkModeBubbleRoleColors(uint32_t themeId, bool mine) +{ + uint16_t bubbleOnColor; + uint16_t bubbleOffColor; + + if (themeId == ThemeID::Blue) { + bubbleOnColor = mine ? TFTPalette::Navy : TFTPalette::White; + bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DeepBlue; + } else { + bubbleOnColor = mine ? TFTPalette::Black : getThemeBodyFg(); + bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DarkGray; + } + + setTFTColorRole(TFTColorRole::ActionMenuBody, bubbleOnColor, bubbleOffColor); +} + +static void registerRoundedBubbleFillRegion(int x, int y, int w, int h, int radius) +{ + if (w <= 0 || h <= 0) { + return; + } + + if (radius <= 0 || w < 3 || h < 3) { + registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h); + return; + } + + // Keep region count low so we don't churn MAX_TFT_COLOR_REGIONS while + // scrolling long message lists (which can flatten older bubble corners). + int capRows = 0; + if (radius >= 4 && h >= 5) { + capRows = 2; // 5 regions total (2 top caps + middle + 2 bottom caps) + } else if (radius >= 2 && h >= 3) { + capRows = 1; // 3 regions total + } + if (capRows <= 0) { + registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h); + return; + } + + for (int row = 0; row < capRows; ++row) { + int inset = 0; + if (radius >= 4) { + inset = (row == 0) ? 2 : 1; + } else if (radius >= 2) { + inset = 1; + } + const int stripW = w - (inset * 2); + if (stripW <= 0) { + continue; + } + + const int topY = y + row; + registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, topY, stripW, 1); + + const int bottomY = y + h - 1 - row; + if (bottomY != topY) { + registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, bottomY, stripW, 1); + } + } + + const int middleY = y + capRows; + const int middleH = h - (capRows * 2); + if (middleH > 0) { + registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, middleY, w, middleH); + } +} +#endif + static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool isHeaderLine) { if (isHeaderLine) { @@ -648,6 +720,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 const int contentBottom = scrollBottom; // already excludes nav line const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN; const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2); +#if GRAPHICS_TFT_COLORING_ENABLED + const uint32_t themeId = getActiveTheme().id; + // Blue is a dark variant but uses full frame inversion, Keep it on the same filled bubble style as Default Dark. + const bool useDarkModeBubbleFill = showBubbles && (!isThemeFullFrameInvert() || themeId == ThemeID::Blue); +#endif std::vector lineTop; lineTop.resize(cachedLines.size()); @@ -686,6 +763,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 int visualBottom = getDrawnLinePixelBottom(lineTop[b.end], cachedLines[b.end], isHeader[b.end]); int bottomY = visualBottom + BUBBLE_PAD_Y; + // On high-res screens, keep a 1px gap under the header + if (currentResolution == ScreenResolution::High) { + const int minTopY = contentTop + 1; + if (topY < minTopY) { + // Preserve bubble height when we push it down from the header. + const int shift = minTopY - topY; + topY = minTopY; + bottomY += shift; + } + } + if (bi + 1 < blocks.size()) { int nextHeaderIndex = (int)blocks[bi + 1].start; int nextTop = lineTop[nextHeaderIndex]; @@ -735,24 +823,56 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 const int by = topY; const int bw = bubbleW; const int bh = bubbleH; +#if GRAPHICS_TFT_COLORING_ENABLED + const bool drawBubbleOutline = !useDarkModeBubbleFill; +#else + const bool drawBubbleOutline = true; +#endif +#if GRAPHICS_TFT_COLORING_ENABLED + if (useDarkModeBubbleFill) { + setDarkModeBubbleRoleColors(themeId, b.mine); + registerRoundedBubbleFillRegion(bx, by, bw, bh, r); + } +#endif - // Draw the 4 corner arcs using drawCircleQuads - display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left - display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right - display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left - display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right - - // Draw the 4 edges between corners - display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge - display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge - display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge - display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge + if (drawBubbleOutline) { + // Draw the 4 corner arcs using drawCircleQuads + display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left + display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right + display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left + display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right + + // Draw the 4 edges between corners + display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge + display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge + display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge + display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge + } } else if (bubbleW > 1 && bubbleH > 1) { // Fallback to simple rectangle for very small bubbles - display->drawRect(bubbleX, topY, bubbleW, bubbleH); +#if GRAPHICS_TFT_COLORING_ENABLED + const bool drawBubbleOutline = !useDarkModeBubbleFill; +#else + const bool drawBubbleOutline = true; +#endif +#if GRAPHICS_TFT_COLORING_ENABLED + if (useDarkModeBubbleFill) { + setDarkModeBubbleRoleColors(themeId, b.mine); + registerTFTColorRegion(TFTColorRole::ActionMenuBody, bubbleX, topY, bubbleW, bubbleH); + } +#endif + if (drawBubbleOutline) { + display->drawRect(bubbleX, topY, bubbleW, bubbleH); + } } } } // end if (showBubbles) +#if GRAPHICS_TFT_COLORING_ENABLED + if (useDarkModeBubbleFill) { + // Restore theme role defaults so other screens keep their intended palette. + loadThemeDefaults(); + } +#endif // Render visible lines int lineY = yOffset; @@ -772,7 +892,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 headerX = x + textIndent; } graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1, - false); + true); // Draw underline just under header text int underlineY = lineY + FONT_HEIGHT_SMALL; diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 201d267e310..00ae74b585d 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -11,6 +11,8 @@ #include "gps/RTC.h" // for getTime() function #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/images.h" #include "meshUtils.h" #include @@ -213,6 +215,33 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, } } +static inline void applyFavoriteNodeNameColor(OLEDDisplay *display, const meshtastic_NodeInfoLite *node, const char *nodeName, + int16_t nameX, int16_t y, int nameMaxWidth) +{ + if (!display || !node || !node->is_favorite || !isTFTColoringEnabled() || !nodeName) { + return; + } + + const int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName); + const int regionWidth = min(textWidth, max(0, nameMaxWidth)); + if (regionWidth <= 0) { + return; + } + + // Node list rows can begin a couple of pixels inside header space. + // Clamp favorite-name color region below the header to avoid black overlap there. + const int16_t minContentY = static_cast(FONT_HEIGHT_SMALL + 1); + const int16_t regionY = max(y, minContentY); + const int16_t yClip = regionY - y; + const int16_t regionHeight = static_cast(FONT_HEIGHT_SMALL - yClip); + if (regionHeight <= 0) { + return; + } + + setAndRegisterTFTColorRole(TFTColorRole::FavoriteNode, TFTPalette::Yellow, TFTPalette::Black, nameX, regionY, regionWidth, + regionHeight); +} + // ============================= // Entry Renderers // ============================= @@ -227,6 +256,9 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int char nodeName[96]; UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), nameMaxWidth); +#if GRAPHICS_TFT_COLORING_ENABLED + applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); +#endif bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; char timeStr[10]; @@ -286,6 +318,9 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int char nodeName[96]; UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), nameMaxWidth); +#if GRAPHICS_TFT_COLORING_ENABLED + applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); +#endif bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -315,6 +350,19 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int int barStartX = x + barsXOffset; int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2; + if (bars > 0) { + uint16_t signalBarsColor = TFTPalette::Bad; + if (bars >= 3) { + signalBarsColor = TFTPalette::Good; + } else if (bars == 2) { + signalBarsColor = TFTPalette::Medium; + } + + // Highest bar reaches 6 px in this renderer. + setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, barStartX, barStartY - 6, + (kBarCount * kBarWidth) + ((kBarCount - 1) * kBarGap), 6); + } + for (int b = 0; b < kBarCount; b++) { if (b < bars) { int height = (b * 2); @@ -350,6 +398,9 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 char nodeName[96]; UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), nameMaxWidth); +#if GRAPHICS_TFT_COLORING_ENABLED + applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); +#endif bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; char distStr[10] = ""; @@ -455,6 +506,9 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 char nodeName[96]; UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), nameMaxWidth); +#if GRAPHICS_TFT_COLORING_ENABLED + applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); +#endif bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -710,6 +764,9 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1); display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1); display->setColor(WHITE); +#if GRAPHICS_TFT_COLORING_ENABLED + registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight); +#endif // Text display->drawString(boxLeft + padding, boxTop + padding, buf); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 31eb2c3c83a..cca60d1e27e 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -7,6 +7,8 @@ #include "UIRenderer.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/images.h" #include "input/RotaryEncoderInterruptImpl1.h" #include "input/UpDownInterruptImpl1.h" @@ -608,6 +610,9 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1); display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1); display->setColor(WHITE); +#if GRAPHICS_TFT_COLORING_ENABLED + registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight); +#endif // Draw Content int16_t lineY = boxTop + vPadding; @@ -630,7 +635,21 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay if (strchr(lineBuffer, 'p') || strchr(lineBuffer, 'g') || strchr(lineBuffer, 'y') || strchr(lineBuffer, 'j')) { background_yOffset = -1; } - display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset); + const int16_t titleBarY = boxTop + 1; + const int16_t titleBarHeight = effectiveLineHeight - background_yOffset; + display->fillRect(boxLeft, titleBarY, boxWidth, titleBarHeight); +#if GRAPHICS_TFT_COLORING_ENABLED + if (alertBannerOptions > 0) { + const uint16_t titleTextColor = + (getActiveTheme().id == ThemeID::DefaultLight) ? TFTPalette::Black : getThemeHeaderText(); + // Keep title role away from border/corner pixels so rounded-corner masks are not remapped to the title text + // color. + if (boxWidth > 2 && titleBarHeight > 0) { + setAndRegisterTFTColorRole(TFTColorRole::ActionMenuTitle, getThemeHeaderBg(), titleTextColor, boxLeft + 1, + titleBarY, boxWidth - 2, titleBarHeight); + } + } +#endif display->setColor(BLACK); int yOffset = 3; if (current_notification_type == notificationTypeEnum::node_picker) { @@ -650,6 +669,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay const int barSpacing = 2; const int barHeightStep = 2; const int gap = 6; + const int maxBarHeight = totalBars * barHeightStep; int textWidth = display->getStringWidth(lineBuffer, strlen(lineBuffer), true); int barsWidth = totalBars * barWidth + (totalBars - 1) * barSpacing + gap; @@ -664,6 +684,20 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay int baseX = groupStartX + textWidth + gap; int baseY = lineY + effectiveLineHeight - 1; +#if GRAPHICS_TFT_COLORING_ENABLED + if (graphics::bannerSignalBars > 0) { + uint16_t signalBarsColor = TFTPalette::Medium; + if (graphics::bannerSignalBars <= 1) { + signalBarsColor = TFTPalette::Bad; + } else if (graphics::bannerSignalBars >= 4) { + signalBarsColor = TFTPalette::Good; + } + const int activeBars = min(graphics::bannerSignalBars, totalBars); + const int regionWidth = activeBars * barWidth + (activeBars - 1) * barSpacing; + setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, baseX, + baseY - maxBarHeight, regionWidth, maxBarHeight); + } +#endif for (int b = 0; b < totalBars; b++) { int barHeight = (b + 1) * barHeightStep; int x = baseX + b * (barWidth + barSpacing); diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 4bf4df4bf16..b75bcd17b1e 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -13,6 +13,8 @@ #include "gps/GeoCoord.h" #include "graphics/EmoteRenderer.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/TimeFormatters.h" #include "graphics/images.h" #include "main.h" @@ -30,6 +32,7 @@ namespace graphics { NodeNum UIRenderer::currentFavoriteNodeNum = 0; std::vector graphics::UIRenderer::favoritedNodes; +static bool gBootSplashBoldPass = false; static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y) { @@ -41,6 +44,347 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y) } } +struct StandardCompassNeedlePoints { + int16_t northTipX; + int16_t northTipY; + int16_t northLeftX; + int16_t northLeftY; + int16_t northRightX; + int16_t northRightY; + int16_t southTipX; + int16_t southTipY; + int16_t southLeftX; + int16_t southLeftY; + int16_t southRightX; + int16_t southRightY; +}; + +static inline void swapPoint(int16_t &ax, int16_t &ay, int16_t &bx, int16_t &by) +{ + const int16_t tx = ax; + const int16_t ty = ay; + ax = bx; + ay = by; + bx = tx; + by = ty; +} + +static inline void transformNeedlePoint(float localX, float localY, float sinHeading, float cosHeading, float scale, + int16_t centerX, int16_t centerY, int16_t &outX, int16_t &outY) +{ + const float x = ((localX * cosHeading) - (localY * sinHeading)) * scale + centerX; + const float y = ((localX * sinHeading) + (localY * cosHeading)) * scale + centerY; + outX = static_cast(x); + outY = static_cast(y); +} + +static float getCompassRingAngleOffset(float heading) +{ + return (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -heading : 0.0f; +} + +static inline StandardCompassNeedlePoints computeStandardCompassNeedlePoints(int16_t compassX, int16_t compassY, + uint16_t compassDiam, float headingRadian, + float centerGapPx) +{ + // Standard-style symmetric needle with a narrow waist and a tiny center gap + // between north/south halves to prevent seam bleed while rotating. + const float scaledDiam = compassDiam * 0.76f; + const float gapNormHalf = (centerGapPx * 0.5f) / scaledDiam; + const float sinHeading = sinf(headingRadian); + const float cosHeading = cosf(headingRadian); + + StandardCompassNeedlePoints points{}; + transformNeedlePoint(0.0f, -0.5f, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northTipX, points.northTipY); + transformNeedlePoint(-0.09f, -gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northLeftX, + points.northLeftY); + transformNeedlePoint(0.09f, -gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northRightX, + points.northRightY); + transformNeedlePoint(0.0f, 0.5f, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southTipX, points.southTipY); + transformNeedlePoint(-0.09f, gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southLeftX, + points.southLeftY); + transformNeedlePoint(0.09f, gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southRightX, + points.southRightY); + return points; +} + +static inline void drawCompassNorthOnlyLabel(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, + float heading) +{ + int16_t labelRadius = compassRadius; + // CompassRenderer::drawCompassNorth() expands radius on high-res by +4. + // Compensate so label placement stays aligned with the current UI layout. + if (currentResolution == ScreenResolution::High && labelRadius > 4) { + labelRadius -= 4; + } + graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, heading, labelRadius); +} + +static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading) +{ + const StandardCompassNeedlePoints points = + computeStandardCompassNeedlePoints(compassX, compassY, static_cast(compassRadius * 2), -heading, 0.0f); + +#ifdef USE_EINK + display->setColor(WHITE); + display->drawTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX, + points.northRightY); + display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX, + points.southRightY); +#else + // OLED variant: same needle geometry as TFT, but monochrome contrast. + display->setColor(WHITE); + display->fillTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX, + points.northRightY); + display->setColor(BLACK); + display->fillTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX, + points.southRightY); + // Keep a white outline so the black half remains visible on dark backgrounds. + display->setColor(WHITE); + display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX, + points.southRightY); +#endif + + display->drawCircle(compassX, compassY, compassRadius); + drawCompassNorthOnlyLabel(display, compassX, compassY, compassRadius, heading); +} + +#if GRAPHICS_TFT_COLORING_ENABLED +struct NeedleColorBand { + int16_t xMin; + int16_t xMax; + int16_t yMin; + int16_t yMax; + bool used; +}; + +static constexpr int kNeedleBandCount = 6; + +static inline void registerNeedleSpan(NeedleColorBand (&bands)[kNeedleBandCount], int16_t bandTop, int16_t bandHeight, int16_t y, + int16_t a, int16_t b) +{ + if (a > b) { + const int16_t t = a; + a = b; + b = t; + } + + int band = (static_cast(y - bandTop) * kNeedleBandCount) / bandHeight; + if (band < 0) { + band = 0; + } else if (band >= kNeedleBandCount) { + band = kNeedleBandCount - 1; + } + + NeedleColorBand ®ion = bands[band]; + if (!region.used) { + region.used = true; + region.xMin = a; + region.xMax = b; + region.yMin = y; + region.yMax = y; + return; + } + if (a < region.xMin) + region.xMin = a; + if (b > region.xMax) + region.xMax = b; + if (y < region.yMin) + region.yMin = y; + if (y > region.yMax) + region.yMax = y; +} + +static void drawNeedleHalfAndRegisterBands(OLEDDisplay *display, int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t x2, + int16_t y2, uint16_t onColor, uint16_t offColor) +{ + // Important for maintainers: + // The compass needle rotates continuously, so color-region registration must + // track triangle shape (or a close approximation), not only one AABB. + // Coarse rectangles can leak south color into north at diagonal angles. + // Keep this banded approach unless a replacement preserves per-angle coverage. + // Performance note: draw the triangle once via fillTriangle(), then build + // band regions in software for accurate color-role registration. + display->fillTriangle(x0, y0, x1, y1, x2, y2); + + if (y0 > y1) + swapPoint(x0, y0, x1, y1); + if (y1 > y2) + swapPoint(x1, y1, x2, y2); + if (y0 > y1) + swapPoint(x0, y0, x1, y1); + + NeedleColorBand bands[kNeedleBandCount] = {}; + + const int16_t bandTop = y0; + const int16_t bandBottom = y2; + const int16_t bandHeight = (bandBottom >= bandTop) ? static_cast(bandBottom - bandTop + 1) : 1; + + const int32_t dx01 = x1 - x0; + const int32_t dy01 = y1 - y0; + const int32_t dx02 = x2 - x0; + const int32_t dy02 = y2 - y0; + const int32_t dx12 = x2 - x1; + const int32_t dy12 = y2 - y1; + + int32_t sa = 0; + int32_t sb = 0; + int16_t y = y0; + + const int16_t last = (y1 == y2) ? y1 : static_cast(y1 - 1); + for (; y <= last; y++) { + const int16_t a = static_cast(x0 + ((dy01 != 0) ? (sa / dy01) : 0)); + const int16_t b = static_cast(x0 + ((dy02 != 0) ? (sb / dy02) : 0)); + sa += dx01; + sb += dx02; + registerNeedleSpan(bands, bandTop, bandHeight, y, a, b); + } + + sa = dx12 * static_cast(y - y1); + sb = dx02 * static_cast(y - y0); + for (; y <= y2; y++) { + const int16_t a = static_cast(x1 + ((dy12 != 0) ? (sa / dy12) : 0)); + const int16_t b = static_cast(x0 + ((dy02 != 0) ? (sb / dy02) : 0)); + sa += dx12; + sb += dx02; + registerNeedleSpan(bands, bandTop, bandHeight, y, a, b); + } + + for (int i = 0; i < kNeedleBandCount; i++) { + if (!bands[i].used) + continue; + registerTFTColorRegionDirect(bands[i].xMin, bands[i].yMin, bands[i].xMax - bands[i].xMin + 1, + bands[i].yMax - bands[i].yMin + 1, onColor, offColor); + } +} + +static inline void drawCompassCardinalLabel(OLEDDisplay *display, int16_t x, int16_t y, const char *label, int16_t textWidth) +{ + const int16_t labelTop = y - (FONT_HEIGHT_SMALL / 2); + const int16_t padX = 1; + const int16_t padY = 1; + + // Clear any ring/tick pixels behind the label so letters remain clean. + display->setColor(BLACK); + display->fillRect(x - (textWidth / 2) - padX, labelTop - padY, textWidth + (padX * 2), FONT_HEIGHT_SMALL + (padY * 2)); + + display->setColor(WHITE); + display->drawString(x, labelTop, label); +} + +static inline void drawCompassCardinalLabels(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, + float heading) +{ + const float northAngle = getCompassRingAngleOffset(heading); + const float radius = compassRadius - 1.0f; + const float sinNorth = sinf(northAngle); + const float cosNorth = cosf(northAngle); + + const int16_t nX = compassX + static_cast(radius * sinNorth); + const int16_t nY = compassY - static_cast(radius * cosNorth); + const int16_t eX = compassX + static_cast(radius * cosNorth); + const int16_t eY = compassY + static_cast(radius * sinNorth); + const int16_t sX = compassX - static_cast(radius * sinNorth); + const int16_t sY = compassY + static_cast(radius * cosNorth); + const int16_t wX = compassX - static_cast(radius * cosNorth); + const int16_t wY = compassY - static_cast(radius * sinNorth); + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + const int16_t labelWidth = static_cast(display->getStringWidth("N")); + drawCompassCardinalLabel(display, nX, nY, "N", labelWidth); + drawCompassCardinalLabel(display, eX, eY, "E", labelWidth); + drawCompassCardinalLabel(display, sX, sY, "S", labelWidth); + drawCompassCardinalLabel(display, wX, wY, "W", labelWidth); +} + +static inline void drawCompassDegreeMarkers(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, + float heading) +{ + const float baseAngle = getCompassRingAngleOffset(heading); + + constexpr int16_t majorLen = 5; + constexpr int16_t minorLen = 3; + + display->setColor(WHITE); + constexpr float kStepAngle = 15.0f * DEG_TO_RAD; + const float sinStep = sinf(kStepAngle); + const float cosStep = cosf(kStepAngle); + float sinAngle = sinf(baseAngle); + float cosAngle = cosf(baseAngle); + bool isMajor = true; + for (int tick = 0; tick < 24; tick++) { + const int16_t tickLen = isMajor ? majorLen : minorLen; + + const int16_t xOuter = compassX + static_cast((compassRadius - 1) * sinAngle); + const int16_t yOuter = compassY - static_cast((compassRadius - 1) * cosAngle); + const int16_t xInner = compassX + static_cast((compassRadius - tickLen) * sinAngle); + const int16_t yInner = compassY - static_cast((compassRadius - tickLen) * cosAngle); + display->drawLine(xInner, yInner, xOuter, yOuter); + + // Rotate [sin, cos] by a fixed step instead of recomputing trig 24x/frame. + const float nextSin = (sinAngle * cosStep) + (cosAngle * sinStep); + const float nextCos = (cosAngle * cosStep) - (sinAngle * sinStep); + sinAngle = nextSin; + cosAngle = nextCos; + isMajor = !isMajor; + } +} + +static inline void drawStandardCompassNeedle(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, + float headingRadian, uint16_t needleOffColor) +{ + const StandardCompassNeedlePoints points = + computeStandardCompassNeedlePoints(compassX, compassY, compassDiam, headingRadian, 9.0f); + + display->setColor(WHITE); +#ifdef USE_EINK + display->drawTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX, + points.northRightY); + display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX, + points.southRightY); +#else + // NOTE: do not collapse these to one region per half during "flash + // optimization". The needle spins, and coarse rectangles will bleed color + // across halves at diagonal angles. + drawNeedleHalfAndRegisterBands(display, points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, + points.northRightX, points.northRightY, TFTPalette::Red, needleOffColor); + drawNeedleHalfAndRegisterBands(display, points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, + points.southRightX, points.southRightY, TFTPalette::Blue, needleOffColor); +#endif +} + +static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading) +{ + // Compass colors should follow whatever background role is already active at this location. + const uint16_t compassBgColor = resolveTFTOffColorAt(compassX, compassY, getThemeBodyBg()); + const uint16_t compassGlyphColor = TFTPalette::pickReadableMonoFg(compassBgColor); + const int16_t pad = 2; + const int16_t labelPadX = static_cast(display->getStringWidth("W") / 2) + 2; + const int16_t labelPadY = static_cast(FONT_HEIGHT_SMALL / 2) + 2; + const int16_t boxX = compassX - compassRadius - pad - labelPadX; + const int16_t boxY = compassY - compassRadius - pad - labelPadY; + const int16_t boxW = (compassRadius * 2) + (pad * 2) + 1 + (labelPadX * 2); + const int16_t boxH = (compassRadius * 2) + (pad * 2) + 1 + (labelPadY * 2); + // Never let compass-local tint regions override the header role regions. + const int16_t bodyTop = static_cast(getTextPositions(display)[1]); + int16_t clippedY = boxY; + int16_t clippedH = boxH; + if (clippedY < bodyTop) { + clippedH = static_cast(clippedH - (bodyTop - clippedY)); + clippedY = bodyTop; + } + if (clippedH > 0) { + registerTFTColorRegionDirect(boxX, clippedY, boxW, clippedH, compassGlyphColor, compassBgColor); + } + + drawStandardCompassNeedle(display, compassX, compassY, static_cast(compassRadius * 2), -heading, compassBgColor); + display->drawCircle(compassX, compassY, compassRadius); + drawCompassDegreeMarkers(display, compassX, compassY, compassRadius, heading); + drawCompassCardinalLabels(display, compassX, compassY, compassRadius, heading); +} +#endif // GRAPHICS_TFT_COLORING_ENABLED + static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_t compassY, const char *statusLine1, const char *statusLine2) { @@ -50,6 +394,99 @@ static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_ display->setTextAlignment(TEXT_ALIGN_LEFT); } +static void drawBearingCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, + bool showCompass, float myHeading, float bearing, const char *statusLine1, + const char *statusLine2) +{ + // Shared "favorite node" compass renderer: draw ring, then either heading data or fallback status text. + display->drawCircle(compassX, compassY, compassRadius); + if (showCompass) { + CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); + CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing); + } else { + drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); + } +} + +static void drawDetailedCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, + bool validHeading, float heading, const char *statusLine1, const char *statusLine2) +{ + // Shared "position screen" compass renderer: use mono/TFT path only when heading is valid. + if (validHeading) { +#if GRAPHICS_TFT_COLORING_ENABLED + drawTftCompass(display, compassX, compassY, compassRadius, heading); +#else + drawMonoCompass(display, compassX, compassY, compassRadius, heading); +#endif + } else { + display->drawCircle(compassX, compassY, compassRadius); + drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); + } +} + +static bool computeLandscapeCompassPlacement(OLEDDisplay *display, int16_t xOffset, int16_t topY, int16_t *compassX, + int16_t *compassY, int16_t *compassRadius) +{ + // Keep compass vertically centered in the body area while reserving footer/nav space. + const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); + const int16_t usableHeight = bottomY - topY - 5; + int16_t radius = usableHeight / 2; + if (radius < 8) { + radius = 8; + } + + *compassRadius = radius; + *compassX = xOffset + SCREEN_WIDTH - radius - 8; + *compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; + return true; +} + +static bool computeBottomCompassPlacement(OLEDDisplay *display, int16_t xOffset, int16_t yBelowContent, int16_t bottomReserved, + int16_t margin, int16_t *compassX, int16_t *compassY, int16_t *compassRadius) +{ + // Return false when content leaves no room for a readable compass. + int availableHeight = SCREEN_HEIGHT - yBelowContent - bottomReserved - margin; + if (availableHeight < FONT_HEIGHT_SMALL * 2) { + return false; + } + + int16_t radius = static_cast(availableHeight / 2); + if (radius < 8) { + radius = 8; + } + if (radius * 2 > SCREEN_WIDTH - 16) { + radius = (SCREEN_WIDTH - 16) / 2; + } + + *compassRadius = radius; + *compassX = xOffset + (SCREEN_WIDTH / 2); + *compassY = static_cast(yBelowContent + (availableHeight / 2)); + return true; +} + +static void drawTruncatedStatusLine(OLEDDisplay *display, int16_t x, int16_t y, const std::string &statusText) +{ + // Fixed-buffer truncate helper replaces iterative std::string chopping to keep code size down. + char rawStatus[96]; + snprintf(rawStatus, sizeof(rawStatus), " Status: %s", statusText.c_str()); + + char clippedStatus[96]; + UIRenderer::truncateStringWithEmotes(display, rawStatus, clippedStatus, sizeof(clippedStatus), display->getWidth()); + display->drawString(x, y, clippedStatus); +} + +static int computeChannelUtilizationFill(int percent, int maxFill) +{ + // Compact linear fill mapping for the utilization bar. + if (percent <= 0 || maxFill <= 0) { + return 0; + } + if (percent >= 100) { + return maxFill; + } + return (maxFill * percent + 50) / 100; +} + void graphics::UIRenderer::rebuildFavoritedNodes() { favoritedNodes.clear(); @@ -331,7 +768,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat snprintf(titlestr, sizeof(titlestr), "*%s*", shortName); // === Draw battery/time/mail header (common across screens) === - graphics::drawCommonHeader(display, x, y, titlestr); + graphics::drawCommonHeader(display, x, y, titlestr, false, false, false, true, TFTPalette::Yellow); // ===== DYNAMIC ROW STACKING WITH YOUR MACROS ===== // 1. Each potential info row has a macro-defined Y position (not regular increments!). @@ -349,8 +786,13 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr; } + // Print node's long name (e.g. "Backpack Node") if (username) { - // Print node's long name (e.g. "Backpack Node") +#if GRAPHICS_TFT_COLORING_ENABLED + const int usernameWidth = UIRenderer::measureStringWithEmotes(display, username); + 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); } @@ -370,37 +812,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat } if (found) { - std::string statusLine = std::string(" Status: ") + found->statusText; - { - const int screenW = display->getWidth(); - const int ellipseW = display->getStringWidth("..."); - int w = display->getStringWidth(statusLine.c_str()); - - // Only do work if it overflows - if (w > screenW) { - bool truncated = false; - if (ellipseW > screenW) { - statusLine.clear(); - } else { - while (!statusLine.empty()) { - // remove one char (byte) at a time - statusLine.pop_back(); - truncated = true; - - // Measure candidate with ellipsis appended - std::string candidate = statusLine + "..."; - if (display->getStringWidth(candidate.c_str()) <= screenW) { - statusLine = std::move(candidate); - break; - } - } - if (statusLine.empty() && ellipseW <= screenW) { - statusLine = "..."; - } - } - } - } - display->drawString(x, getTextPositions(display)[line++], statusLine.c_str()); + drawTruncatedStatusLine(display, x, getTextPositions(display)[line++], found->statusText); } } #endif @@ -492,6 +904,16 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat if (!hi) maxBarHeight -= 1; int barY = yPos + (FONT_HEIGHT_SMALL - maxBarHeight) / 2; + int totalBarsWidth = (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap); + + uint16_t signalBarsColor = TFTPalette::Good; + if (qualityLabel && strcmp(qualityLabel, "Fair") == 0) { + signalBarsColor = TFTPalette::Medium; + } else if (qualityLabel && strcmp(qualityLabel, "Bad") == 0) { + signalBarsColor = TFTPalette::Bad; + } + setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, barX, barY, totalBarsWidth, + maxBarHeight); for (int bi = 0; bi < kMaxBars; bi++) { int barHeight = maxBarHeight * (bi + 1) / kMaxBars; @@ -509,23 +931,20 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat } } - curX += (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap) + 2; + curX += totalBarsWidth + 2; } // Draw hops for non-zero-hop nodes as: number + hop icon. // This path is mutually exclusive with the zero-hop signal-bars path above. if (showHops) { - // hop label display->drawString(curX, yPos, "Hop:"); curX += display->getStringWidth("Hop:") + 2; - // hop count char hopCount[6]; snprintf(hopCount, sizeof(hopCount), "%d", node->hops_away); display->drawString(curX, yPos, hopCount); curX += display->getStringWidth(hopCount) + 2; - // hop icon const int iconY = yPos + (FONT_HEIGHT_SMALL - hop_height) / 2; display->drawXbm(curX, iconY, hop_width, hop_height, hop); curX += hop_width + 1; @@ -567,48 +986,29 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat bool haveDistance = false; if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) { - double lat1 = ourNode->position.latitude_i * 1e-7; - double lon1 = ourNode->position.longitude_i * 1e-7; - double lat2 = node->position.latitude_i * 1e-7; - double lon2 = node->position.longitude_i * 1e-7; - double earthRadiusKm = 6371.0; - double dLat = (lat2 - lat1) * DEG_TO_RAD; - double dLon = (lon2 - lon1) * DEG_TO_RAD; - double a = - sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2); - double c = 2 * atan2(sqrt(a), sqrt(1 - a)); - double distanceKm = earthRadiusKm * c; - + // Use shared meter conversion, then format display units with lightweight integer rounding. + const float distanceMeters = + GeoCoord::latLongToMeter(DegD(node->position.latitude_i), DegD(node->position.longitude_i), + DegD(ourNode->position.latitude_i), DegD(ourNode->position.longitude_i)); if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - double miles = distanceKm * 0.621371; - if (miles < 0.1) { - int feet = (int)(miles * 5280); - if (feet > 0 && feet < 1000) { - snprintf(distStr, sizeof(distStr), "%sDistance:%dft", leftSideSpacing, feet); - haveDistance = true; - } else if (feet >= 1000) { - snprintf(distStr, sizeof(distStr), "%sDistance:¼mi", leftSideSpacing); - haveDistance = true; - } + const int feet = static_cast((distanceMeters * METERS_TO_FEET) + 0.5f); + if (feet > 0 && feet < 1000) { + snprintf(distStr, sizeof(distStr), "%sDistance:%dft", leftSideSpacing, feet); + haveDistance = true; } else { - int roundedMiles = (int)(miles + 0.5); - if (roundedMiles > 0 && roundedMiles < 1000) { - snprintf(distStr, sizeof(distStr), "%sDistance:%dmi", leftSideSpacing, roundedMiles); + const int miles = (feet + 2640) / 5280; // rounded to nearest mile + if (miles > 0 && miles < 1000) { + snprintf(distStr, sizeof(distStr), "%sDistance:%dmi", leftSideSpacing, miles); haveDistance = true; } } } else { - if (distanceKm < 1.0) { - int meters = (int)(distanceKm * 1000); - if (meters > 0 && meters < 1000) { - snprintf(distStr, sizeof(distStr), "%sDistance:%dm", leftSideSpacing, meters); - haveDistance = true; - } else if (meters >= 1000) { - snprintf(distStr, sizeof(distStr), "%sDistance:1km", leftSideSpacing); - haveDistance = true; - } + const int meters = static_cast(distanceMeters + 0.5f); + if (meters > 0 && meters < 1000) { + snprintf(distStr, sizeof(distStr), "%sDistance:%dm", leftSideSpacing, meters); + haveDistance = true; } else { - int km = (int)(distanceKm + 0.5); + const int km = (meters + 500) / 1000; // rounded to nearest km if (km > 0 && km < 1000) { snprintf(distStr, sizeof(distStr), "%sDistance:%dkm", leftSideSpacing, km); haveDistance = true; @@ -693,64 +1093,29 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat } // --- Compass Rendering: landscape (wide) screens use the original side-aligned logic --- - if (SCREEN_WIDTH > SCREEN_HEIGHT) { - if (showCompass || statusLine1) { + if (showCompass || statusLine1) { + int16_t compassX = 0; + int16_t compassY = 0; + int16_t compassRadius = 0; + if (SCREEN_WIDTH > SCREEN_HEIGHT) { const int16_t topY = getTextPositions(display)[1]; - const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); - const int16_t usableHeight = bottomY - topY - 5; - int16_t compassRadius = usableHeight / 2; - if (compassRadius < 8) - compassRadius = 8; - const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8; - const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; - const int16_t compassDiam = compassRadius * 2; - - display->drawCircle(compassX, compassY, compassRadius); - if (showCompass) { - CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); - CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing); - } else { - drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); - } - } - // else show nothing - } else { - // Portrait or square: put compass at the bottom and centered, scaled to fit available space - if (showCompass || statusLine1) { - int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2) - : getTextPositions(display)[1]; - const int margin = 4; -// --------- PATCH FOR EINK NAV BAR (ONLY CHANGE BELOW) ----------- + computeLandscapeCompassPlacement(display, x, topY, &compassX, &compassY, &compassRadius); + } else { + const int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2) + : getTextPositions(display)[1]; #if defined(USE_EINK) const int iconSize = (currentResolution == ScreenResolution::High) ? 16 : 8; const int navBarHeight = iconSize + 6; #else const int navBarHeight = 0; #endif - // --------- END PATCH FOR EINK NAV BAR ----------- - int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin; - - if (availableHeight < FONT_HEIGHT_SMALL * 2) + if (!computeBottomCompassPlacement(display, x, yBelowContent, navBarHeight, 4, &compassX, &compassY, + &compassRadius)) { return; - - int compassRadius = availableHeight / 2; - if (compassRadius < 8) - compassRadius = 8; - if (compassRadius * 2 > SCREEN_WIDTH - 16) - compassRadius = (SCREEN_WIDTH - 16) / 2; - - int compassX = x + SCREEN_WIDTH / 2; - int compassY = yBelowContent + availableHeight / 2; - - display->drawCircle(compassX, compassY, compassRadius); - if (showCompass) { - graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); - graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing); - } else { - drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); } } - // else show nothing + drawBearingCompassOrStatus(display, compassX, compassY, compassRadius, showCompass, myHeading, bearing, statusLine1, + statusLine2); } #endif graphics::drawCommonFooter(display, x, y); @@ -776,12 +1141,6 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta // === Content below header === - // Determine if we need to show 4 or 5 rows on the screen - int rows = 4; - if (!config.bluetooth.enabled) { - rows = 5; - } - // === First Row: Region / Channel Utilization and Uptime === bool origBold = config.display.heading_bold; config.display.heading_bold = false; @@ -845,13 +1204,15 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta // === Third Row: Channel Utilization Bluetooth Off (Only If Actually Off) === const char *chUtil = "ChUtil:"; char chUtilPercentage[10]; - snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent()); + int chutil_percent = static_cast(airTime->channelUtilizationPercent() + 0.5f); + snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%d%%", chutil_percent); int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10 : display->getStringWidth(chUtil) + 5; int chUtil_y = getTextPositions(display)[line] + 3; int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50; + int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border if (!config.bluetooth.enabled) { #if defined(USE_EINK) chutil_bar_width = (currentResolution == ScreenResolution::High) ? 50 : 30; @@ -864,50 +1225,36 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta if (!config.bluetooth.enabled) { extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 1; } - int chutil_percent = airTime->channelUtilizationPercent(); + const int raw_chutil_percent = chutil_percent; - int centerofscreen = SCREEN_WIDTH / 2; - int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; - int starting_position = centerofscreen - total_line_content_width; - if (!config.bluetooth.enabled) { - starting_position = 0; - } + // With BT disabled we pin this row left to make room for the extra "BT off" indicator. + const int starting_position = config.bluetooth.enabled ? x : 0; display->drawString(starting_position, getTextPositions(display)[line], chUtil); - // Force 56% or higher to show a full 100% bar, text would still show related percent. + // Force 61% or higher to show a full 100% bar, text would still show related percent. if (chutil_percent >= 61) { chutil_percent = 100; } - // 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 totalWeight = weight1 + weight2 + weight3; - - int seg1 = chutil_bar_width * (weight1 / totalWeight); - int seg2 = chutil_bar_width * (weight2 / totalWeight); - int seg3 = chutil_bar_width * (weight3 / totalWeight); - - int fillRight = 0; - - if (chutil_percent <= milestone1) { - fillRight = (seg1 * (chutil_percent / milestone1)); - } else if (chutil_percent <= milestone2) { - fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1))); - } else { - fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2))); - } + int fillRight = computeChannelUtilizationFill(chutil_percent, chutil_bar_max_fill); // Draw outline display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height); // Fill progress if (fillRight > 0) { - display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height); +#if GRAPHICS_TFT_COLORING_ENABLED + uint16_t UtilizationFillColor = TFTPalette::Good; + if (raw_chutil_percent >= 60) { + UtilizationFillColor = TFTPalette::Bad; + } else if (raw_chutil_percent >= 35) { + UtilizationFillColor = TFTPalette::Medium; + } + setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, + starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); +#endif + display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); } display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line], @@ -938,9 +1285,8 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) { textWidth = UIRenderer::measureStringWithEmotes(display, combinedName); nameX = (SCREEN_WIDTH - textWidth) / 2; - UIRenderer::drawStringWithEmotes( - display, nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, - combinedName, FONT_HEIGHT_SMALL, 1, false); + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + yOffset, combinedName, + FONT_HEIGHT_SMALL, 1, false); } else { // === LongName Centered === textWidth = UIRenderer::measureStringWithEmotes(display, longName); @@ -1116,6 +1462,9 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED // draw centered icon left to right and centered above the one line of app text #if defined(M5STACK_UNITC6L) display->drawXbm(x + (SCREEN_WIDTH - 50) / 2, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits); + if (gBootSplashBoldPass) { + display->drawXbm(x + (SCREEN_WIDTH - 50) / 2 + 1, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits); + } display->setFont(FONT_MEDIUM); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); @@ -1125,6 +1474,9 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED int msgX = x + (SCREEN_WIDTH - msgWidth) / 2; int msgY = y; display->drawString(msgX, msgY, upperMsg); + if (gBootSplashBoldPass) { + display->drawString(msgX + 1, msgY, upperMsg); + } } // Draw version and short name in bottom middle char footer[64]; @@ -1137,6 +1489,10 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED int footerX = x + ((SCREEN_WIDTH - footerW) / 2); UIRenderer::drawStringWithEmotes(display, footerX, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL, 1, false); + if (gBootSplashBoldPass) { + UIRenderer::drawStringWithEmotes(display, footerX + 1, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL, + 1, false); + } screen->forceDisplay(); display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code @@ -1147,21 +1503,35 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED display->setFont(FONT_MEDIUM); display->setTextAlignment(TEXT_ALIGN_LEFT); const char *title = "meshtastic.org"; - display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); + display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5, title); + if (gBootSplashBoldPass) { + display->drawString(x + getStringCenteredX(title) + 1, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5, title); + } display->setFont(FONT_SMALL); // Draw region in upper left - if (upperMsg) - display->drawString(x + 0, y + 0, upperMsg); + if (upperMsg) { + display->drawString(x + 5, y + 5, upperMsg); + if (gBootSplashBoldPass) { + display->drawString(x + 6, y + 5, upperMsg); + } + } // Draw version and short name in upper right const char *version = xstr(APP_VERSION_SHORT); - int versionX = x + SCREEN_WIDTH - display->getStringWidth(version); - display->drawString(versionX, y + 0, version); + int versionX = x + SCREEN_WIDTH - display->getStringWidth(version) - 5; + display->drawString(versionX, y + 5, version); + if (gBootSplashBoldPass) { + display->drawString(versionX + 1, y + 5, version); + } if (owner.short_name && owner.short_name[0]) { const char *shortName = owner.short_name; int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName); - int shortNameX = x + SCREEN_WIDTH - shortNameW; - UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false); + int shortNameX = x + SCREEN_WIDTH - shortNameW - 5; + UIRenderer::drawStringWithEmotes(display, shortNameX, y + 5 + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false); + if (gBootSplashBoldPass) { + UIRenderer::drawStringWithEmotes(display, shortNameX + 1, y + 5 + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, + false); + } } screen->forceDisplay(); @@ -1169,6 +1539,20 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED #endif } +void UIRenderer::drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ +#if GRAPHICS_TFT_COLORING_ENABLED + // Meshtastic brand green background with black foreground text/icon on TFT startup screen. + static constexpr uint16_t kMeshtasticGreen = TFTPalette::rgb565(103, 234, 145); + setAndRegisterTFTColorRole(TFTColorRole::BootSplash, TFTPalette::Black, kMeshtasticGreen, x, y, SCREEN_WIDTH, SCREEN_HEIGHT); + gBootSplashBoldPass = true; +#endif + drawIconScreen(upperMsg, display, state, x, y); +#if GRAPHICS_TFT_COLORING_ENABLED + gBootSplashBoldPass = false; +#endif +} + // **************************** // * My Position Screen * // **************************** @@ -1296,45 +1680,23 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU int16_t compassRadius = usableHeight / 2; if (compassRadius < 8) compassRadius = 8; - const int16_t compassDiam = compassRadius * 2; const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8; // Center vertically and nudge down slightly to keep "N" clear of header const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; - display->drawCircle(compassX, compassY, compassRadius); - if (validHeading) { - CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading); - - // "N" label - float northAngle = 0; - if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) - northAngle = -heading; - float radius = compassRadius; - int16_t nX = compassX + (radius - 1) * sin(northAngle); - int16_t nY = compassY - (radius - 1) * cos(northAngle); - int16_t nLabelWidth = display->getStringWidth("N") + 2; - int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; - - display->setColor(BLACK); - display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); - display->setColor(WHITE); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); - } else { - drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); - } + drawDetailedCompassOrStatus(display, compassX, compassY, compassRadius, validHeading, heading, statusLine1, + statusLine2); } else { // Portrait or square: put compass at the bottom and centered, scaled to fit available space // For E-Ink screens, account for navigation bar at the bottom! - int yBelowContent = textPos[5] + FONT_HEIGHT_SMALL + 2; - const int margin = 4; - int availableHeight = + const int yBelowContent = textPos[5] + FONT_HEIGHT_SMALL + 2; #if defined(USE_EINK) - SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink + const int margin = 4; + int availableHeight = SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink #else - SCREEN_HEIGHT - yBelowContent - margin; + const int margin = 4; + int availableHeight = SCREEN_HEIGHT - yBelowContent - margin; #endif if (availableHeight < FONT_HEIGHT_SMALL * 2) @@ -1349,29 +1711,8 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU int compassX = x + SCREEN_WIDTH / 2; int compassY = yBelowContent + availableHeight / 2; - display->drawCircle(compassX, compassY, compassRadius); - if (validHeading) { - CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading); - - // "N" label - float northAngle = 0; - if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) - northAngle = -heading; - float radius = compassRadius; - int16_t nX = compassX + (radius - 1) * sin(northAngle); - int16_t nY = compassY - (radius - 1) * cos(northAngle); - int16_t nLabelWidth = display->getStringWidth("N") + 2; - int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; - - display->setColor(BLACK); - display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); - display->setColor(WHITE); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); - } else { - drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); - } + drawDetailedCompassOrStatus(display, compassX, compassY, compassRadius, validHeading, heading, statusLine1, + statusLine2); } } #endif @@ -1443,18 +1784,21 @@ void UIRenderer::drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *sta #endif // Navigation bar overlay implementation -static int8_t lastFrameIndex = -1; +static int16_t lastFrameIndex = -1; static uint32_t lastFrameChangeTime = 0; constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000; // cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state) { - int currentFrame = state->currentFrame; + uint8_t frameToHighlight = state->currentFrame; + if (state->frameState == IN_TRANSITION && state->transitionFrameTarget < screen->indicatorIcons.size()) { + frameToHighlight = state->transitionFrameTarget; + } // Detect frame change and record time - if (currentFrame != lastFrameIndex) { - lastFrameIndex = currentFrame; + if (frameToHighlight != lastFrameIndex) { + lastFrameIndex = frameToHighlight; lastFrameChangeTime = millis(); } @@ -1473,15 +1817,15 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta usableWidth = iconSize; const size_t iconsPerPage = usableWidth / (iconSize + spacing); - const size_t currentPage = currentFrame / iconsPerPage; + const size_t currentPage = frameToHighlight / iconsPerPage; const size_t pageStart = currentPage * iconsPerPage; const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons); const int totalWidth = (pageEnd - pageStart) * iconSize + (pageEnd - pageStart - 1) * spacing; const int xStart = (SCREEN_WIDTH - totalWidth) / 2; - bool navBarVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS; - int y = navBarVisible ? (SCREEN_HEIGHT - iconSize - 1) : SCREEN_HEIGHT; + const bool navBarVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS; + const int y = navBarVisible ? (SCREEN_HEIGHT - iconSize - 1) : SCREEN_HEIGHT; #if defined(USE_EINK) // Only show bar briefly after switching frames @@ -1512,25 +1856,54 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta // Pre-calculate bounding rect const int rectX = xStart - 2 - bigOffset; + const int rectY = y - 2; const int rectWidth = totalWidth + 4 + (bigOffset * 2); const int rectHeight = iconSize + 6; // Clear background and draw border display->setColor(BLACK); - display->fillRect(rectX + 1, y - 2, rectWidth - 2, rectHeight - 2); +#if GRAPHICS_TFT_COLORING_ENABLED + // NavigationBar and NavigationArrow roles are fully defined in the theme table. + // We must call setTFTColorRole() before registerTFTColorRegion() because + // registerTFTColorRegion() snapshots colors from the roleColors[] working array, + // and loadThemeDefaults() isn't guaranteed to have run since boot. + const TFTThemeDef &theme = getActiveTheme(); + const auto &navBarRole = theme.roles[static_cast(TFTColorRole::NavigationBar)]; + const auto &navArrowRole = theme.roles[static_cast(TFTColorRole::NavigationArrow)]; + + setAndRegisterTFTColorRole(TFTColorRole::NavigationBar, navBarRole.onColor, navBarRole.offColor, rectX, rectY, rectWidth, + rectHeight); + setTFTColorRole(TFTColorRole::NavigationArrow, navArrowRole.onColor, navArrowRole.offColor); + display->fillRect(rectX, rectY, rectWidth, rectHeight); +#else + // Keep legacy OLED behavior untouched. + display->fillRect(rectX + 1, rectY, rectWidth - 2, rectHeight - 2); + display->setColor(WHITE); + display->drawRect(rectX, rectY, rectWidth, rectHeight); +#endif + + // Icons are 1-bit glyphs and must be drawn with WHITE to set pixels. display->setColor(WHITE); - display->drawRect(rectX, y - 2, rectWidth, rectHeight); // Icon drawing loop for the current page for (size_t i = pageStart; i < pageEnd; ++i) { const uint8_t *icon = screen->indicatorIcons[i]; const int x = xStart + (i - pageStart) * (iconSize + spacing); - const bool isActive = (i == static_cast(currentFrame)); + const bool isActive = (i == static_cast(frameToHighlight)); if (isActive) { +#if GRAPHICS_TFT_COLORING_ENABLED + // Active icon inverts on TFT: white chip with black glyph. + // Keep the buffer visibly different too, so dirty-rect updates include this region. + registerTFTColorRegion(TFTColorRole::NavigationBar, x - 1, y - 1, iconSize + 2, iconSize + 2); + display->setColor(WHITE); + display->fillRect(x - 1, y - 1, iconSize + 2, iconSize + 2); + display->setColor(BLACK); +#else display->setColor(WHITE); display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4); display->setColor(BLACK); +#endif } if (currentResolution == ScreenResolution::High) { @@ -1544,22 +1917,17 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta } } - // Compact arrow drawer - auto drawArrow = [&](bool rightSide) { - display->setColor(WHITE); - - const int offset = (currentResolution == ScreenResolution::High) ? 3 : 1; - const int halfH = rectHeight / 2; - - const int top = (y - 2) + (rectHeight - halfH) / 2; - const int bottom = top + halfH - 1; - const int midY = top + (halfH / 2); + display->setColor(WHITE); - const int maxW = 4; + const int offset = (currentResolution == ScreenResolution::High) ? 3 : 1; + const int halfH = rectHeight / 2; + const int top = rectY + (rectHeight - halfH) / 2; + const int bottom = top + halfH - 1; + const int midY = top + (halfH / 2); + const int maxW = 4; - // Determine left X coordinate - int baseX = rightSide ? (rectX + rectWidth + offset) : // right arrow - (rectX - offset - 1); // left arrow + auto drawArrow = [&](bool rightSide) { + int baseX = rightSide ? (rectX + rectWidth + offset) : (rectX - offset - 1); for (int yy = top; yy <= bottom; yy++) { int dist = abs(yy - midY); @@ -1574,21 +1942,43 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta } } }; + // Right arrow - if (pageEnd < totalIcons) { + if (navBarVisible && pageEnd < totalIcons) { + int baseX = rectX + rectWidth + offset; + int regionX = baseX; + +#if GRAPHICS_TFT_COLORING_ENABLED + registerTFTColorRegion(TFTColorRole::NavigationArrow, regionX, top, maxW, halfH); +#endif + drawArrow(true); } // Left arrow - if (pageStart > 0) { + if (navBarVisible && pageStart > 0) { + int baseX = rectX - offset - 1; + int regionX = baseX - maxW + 1; + +#if GRAPHICS_TFT_COLORING_ENABLED + registerTFTColorRegion(TFTColorRole::NavigationArrow, regionX, top, maxW, halfH); +#endif + drawArrow(false); } // Knock the corners off the square +#if GRAPHICS_TFT_COLORING_ENABLED + // TFT corner mask + registerTFTColorRegion(TFTColorRole::NavigationArrow, rectX, rectY, 1, 1); + registerTFTColorRegion(TFTColorRole::NavigationArrow, rectX + rectWidth - 1, rectY, 1, 1); +#else + // monochrome styling only display->setColor(BLACK); - display->drawRect(rectX, y - 2, 1, 1); - display->drawRect(rectX + rectWidth - 1, y - 2, 1, 1); + display->drawRect(rectX, rectY, 1, 1); + display->drawRect(rectX + rectWidth - 1, rectY, 1, 1); display->setColor(WHITE); +#endif } void UIRenderer::drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *message) diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index a705d944d78..0aeace42e2c 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -54,6 +54,8 @@ class UIRenderer static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static void drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + // Icon and screen drawing functions static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h old mode 100644 new mode 100755 diff --git a/src/motion/BMA423Sensor.cpp b/src/motion/BMA423Sensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/BMA423Sensor.h b/src/motion/BMA423Sensor.h old mode 100644 new mode 100755 diff --git a/src/motion/BMX160Sensor.cpp b/src/motion/BMX160Sensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/BMX160Sensor.h b/src/motion/BMX160Sensor.h old mode 100644 new mode 100755 diff --git a/src/motion/ICM20948Sensor.cpp b/src/motion/ICM20948Sensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/ICM20948Sensor.h b/src/motion/ICM20948Sensor.h old mode 100644 new mode 100755 diff --git a/src/motion/LIS3DHSensor.cpp b/src/motion/LIS3DHSensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/LIS3DHSensor.h b/src/motion/LIS3DHSensor.h old mode 100644 new mode 100755 diff --git a/src/motion/LSM6DS3Sensor.cpp b/src/motion/LSM6DS3Sensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/LSM6DS3Sensor.h b/src/motion/LSM6DS3Sensor.h old mode 100644 new mode 100755 diff --git a/src/motion/MPU6050Sensor.cpp b/src/motion/MPU6050Sensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/MPU6050Sensor.h b/src/motion/MPU6050Sensor.h old mode 100644 new mode 100755 diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h old mode 100644 new mode 100755 diff --git a/src/motion/STK8XXXSensor.cpp b/src/motion/STK8XXXSensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/STK8XXXSensor.h b/src/motion/STK8XXXSensor.h old mode 100644 new mode 100755 diff --git a/src/sleep.cpp b/src/sleep.cpp index a2a943a1fb0..792781f6d0d 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -79,7 +79,7 @@ RTC_DATA_ATTR int bootCount = 0; */ void setCPUFast(bool on) { -#if defined(ARCH_ESP32) && HAS_WIFI && !HAS_TFT +#if defined(ARCH_ESP32) && HAS_WIFI && !HAS_TFT && !defined(T_LORA_PAGER) && !defined(T_DECK) if (isWifiAvailable()) { /* diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 5a5004a456a..d6634aa7426 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -120,7 +120,7 @@ build_flags = -D TFT_OFFSET_Y=0 -D TFT_OFFSET_ROTATION=0 -D SCREEN_ROTATE - -D SCREEN_TRANSITION_FRAMERATE=5 + -D SCREEN_TRANSITION_FRAMERATE=30 -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness -D HAS_TOUCHSCREEN=1 -D TOUCH_I2C_PORT=0 @@ -133,4 +133,4 @@ lib_deps = ${heltec_v4_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.19 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master - https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip \ No newline at end of file + https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip diff --git a/variants/esp32s3/heltec_vision_master_t190/platformio.ini b/variants/esp32s3/heltec_vision_master_t190/platformio.ini index 3dab9f93ccb..c0c3b2f0e72 100644 --- a/variants/esp32s3/heltec_vision_master_t190/platformio.ini +++ b/variants/esp32s3/heltec_vision_master_t190/platformio.ini @@ -20,5 +20,5 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main - https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip + https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip upload_speed = 921600 diff --git a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini index 69c4f52a5bd..1144994a028 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini +++ b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini @@ -13,7 +13,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main - https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip + https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver https://github.com/pschatzmann/arduino-audio-driver/archive/v0.2.1.zip # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix diff --git a/variants/esp32s3/picomputer-s3/variant.h b/variants/esp32s3/picomputer-s3/variant.h index 7b6218f875a..60afac002bf 100644 --- a/variants/esp32s3/picomputer-s3/variant.h +++ b/variants/esp32s3/picomputer-s3/variant.h @@ -47,10 +47,9 @@ #define TFT_OFFSET_Y 0 #define TFT_OFFSET_ROTATION 0 #define SCREEN_ROTATE -#define SCREEN_TRANSITION_FRAMERATE 5 +#define SCREEN_TRANSITION_FRAMERATE 30 // Picomputer gets a white on black display -#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255) #define INPUTBROKER_MATRIX_TYPE 1 diff --git a/variants/esp32s3/seeed-sensecap-indicator/variant.h b/variants/esp32s3/seeed-sensecap-indicator/variant.h index f946528ae94..8fa9e239366 100644 --- a/variants/esp32s3/seeed-sensecap-indicator/variant.h +++ b/variants/esp32s3/seeed-sensecap-indicator/variant.h @@ -36,7 +36,7 @@ #define TFT_OFFSET_ROTATION 0 #define TFT_BL 45 #define SCREEN_ROTATE -#define SCREEN_TRANSITION_FRAMERATE 5 // fps +#define SCREEN_TRANSITION_FRAMERATE 30 // fps #define USE_TFTDISPLAY 1 #define HAS_TOUCHSCREEN 1 diff --git a/variants/esp32s3/station-g2/pins_arduino.h b/variants/esp32s3/station-g2/pins_arduino.h old mode 100644 new mode 100755 diff --git a/variants/esp32s3/station-g2/platformio.ini b/variants/esp32s3/station-g2/platformio.ini old mode 100644 new mode 100755 diff --git a/variants/esp32s3/station-g2/variant.h b/variants/esp32s3/station-g2/variant.h old mode 100644 new mode 100755 diff --git a/variants/esp32s3/t-deck/variant.h b/variants/esp32s3/t-deck/variant.h index 5d885579a95..eb1bbdfef2c 100644 --- a/variants/esp32s3/t-deck/variant.h +++ b/variants/esp32s3/t-deck/variant.h @@ -20,7 +20,7 @@ #define TFT_OFFSET_Y 0 #define TFT_OFFSET_ROTATION 0 #define SCREEN_ROTATE -#define SCREEN_TRANSITION_FRAMERATE 5 +#define SCREEN_TRANSITION_FRAMERATE 30 #define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness #define USE_TFTDISPLAY 1 #define HAS_PHYSICAL_KEYBOARD 1 diff --git a/variants/esp32s3/tlora-pager/variant.h b/variants/esp32s3/tlora-pager/variant.h index d97f864c3e8..3d63974750c 100644 --- a/variants/esp32s3/tlora-pager/variant.h +++ b/variants/esp32s3/tlora-pager/variant.h @@ -18,7 +18,7 @@ #define TFT_OFFSET_Y 0 #define TFT_OFFSET_ROTATION 3 #define SCREEN_ROTATE -#define SCREEN_TRANSITION_FRAMERATE 5 +#define SCREEN_TRANSITION_FRAMERATE 30 #define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness #define USE_TFTDISPLAY 1 #define HAS_PHYSICAL_KEYBOARD 1 diff --git a/variants/esp32s3/tracksenger/internal/variant.h b/variants/esp32s3/tracksenger/internal/variant.h index f9a20c9013c..b2822c24b29 100644 --- a/variants/esp32s3/tracksenger/internal/variant.h +++ b/variants/esp32s3/tracksenger/internal/variant.h @@ -73,7 +73,6 @@ #define SX126X_DIO3_TCXO_VOLTAGE 1.8 // Picomputer gets a white on black display -#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255) // keyboard changes @@ -89,4 +88,4 @@ { \ 26, 37, 17, 16, 15, 7 \ } -// #end keyboard \ No newline at end of file +// #end keyboard diff --git a/variants/esp32s3/tracksenger/lcd/variant.h b/variants/esp32s3/tracksenger/lcd/variant.h index 029f7753b75..6c32ff27909 100644 --- a/variants/esp32s3/tracksenger/lcd/variant.h +++ b/variants/esp32s3/tracksenger/lcd/variant.h @@ -97,7 +97,6 @@ #define SX126X_DIO3_TCXO_VOLTAGE 1.8 // Picomputer gets a white on black display -#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255) // keyboard changes @@ -113,4 +112,4 @@ { \ 26, 37, 17, 16, 15, 7 \ } -// #end keyboard \ No newline at end of file +// #end keyboard diff --git a/variants/esp32s3/tracksenger/oled/variant.h b/variants/esp32s3/tracksenger/oled/variant.h index 1f1fbbaa1b4..72762c7afef 100644 --- a/variants/esp32s3/tracksenger/oled/variant.h +++ b/variants/esp32s3/tracksenger/oled/variant.h @@ -74,7 +74,6 @@ #define SX126X_DIO3_TCXO_VOLTAGE 1.8 // Picomputer gets a white on black display -#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255) // keyboard changes @@ -90,4 +89,4 @@ { \ 26, 37, 17, 16, 15, 7 \ } -// #end keyboard \ No newline at end of file +// #end keyboard diff --git a/variants/esp32s3/unphone/variant.h b/variants/esp32s3/unphone/variant.h index 268eedea541..76c66ca643a 100644 --- a/variants/esp32s3/unphone/variant.h +++ b/variants/esp32s3/unphone/variant.h @@ -35,7 +35,7 @@ #define TFT_OFFSET_ROTATION 6 // unPhone's screen wired unusually, 0 typical #define TFT_INVERT false #define SCREEN_ROTATE true -#define SCREEN_TRANSITION_FRAMERATE 5 +#define SCREEN_TRANSITION_FRAMERATE 30 #define USE_TFTDISPLAY 1 #define HAS_TOUCHSCREEN 1 @@ -74,4 +74,4 @@ // #define BATTERY_PIN 13 // battery V measurement pin; vbat divider is here // #define ADC_CHANNEL ADC2_GPIO13_CHANNEL -// #define BAT_MEASURE_ADC_UNIT 2 \ No newline at end of file +// #define BAT_MEASURE_ADC_UNIT 2 diff --git a/variants/nrf52840/heltec_mesh_node_t114/platformio.ini b/variants/nrf52840/heltec_mesh_node_t114/platformio.ini index c9f998240a5..77beb4d3334 100644 --- a/variants/nrf52840/heltec_mesh_node_t114/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t114/platformio.ini @@ -23,4 +23,4 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_ lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main - https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip + https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip diff --git a/variants/nrf52840/heltec_mesh_node_t114/variant.h b/variants/nrf52840/heltec_mesh_node_t114/variant.h index e7385c4bbc5..509f749a8a5 100644 --- a/variants/nrf52840/heltec_mesh_node_t114/variant.h +++ b/variants/nrf52840/heltec_mesh_node_t114/variant.h @@ -57,9 +57,6 @@ extern "C" { #define TFT_OFFSET_X 0 #define TFT_OFFSET_Y 0 -// T114 gets a muted yellow on black display -#define TFT_MESH_OVERRIDE COLOR565(255, 255, 128) - // #define TFT_OFFSET_ROTATION 0 // #define SCREEN_ROTATE // #define SCREEN_TRANSITION_FRAMERATE 5 diff --git a/variants/nrf52840/heltec_mesh_solar/platformio.ini b/variants/nrf52840/heltec_mesh_solar/platformio.ini index 1b6f59a68d1..ae68455dcff 100644 --- a/variants/nrf52840/heltec_mesh_solar/platformio.ini +++ b/variants/nrf52840/heltec_mesh_solar/platformio.ini @@ -132,4 +132,4 @@ build_flags = ${heltec_mesh_solar_base.build_flags} lib_deps = ${heltec_mesh_solar_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main - https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip + https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip From b148fac34059a0946b0706feef2d528227d62830 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 26 Apr 2026 09:49:39 -0500 Subject: [PATCH 090/225] Update framework version reference for Adafruit nRF52 to latest master branch --- variants/nrf52840/nrf52.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index f42c29308fd..d11f4fc565f 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -7,7 +7,7 @@ extends = arduino_base platform_packages = ; our custom Git version with C++17 support in platform.txt # TODO renovate - platformio/framework-arduinoadafruitnrf52 @ https://github.com/meshtastic/Adafruit_nRF52_Arduino#cpp17-platform + platformio/framework-arduinoadafruitnrf52 @ https://github.com/meshtastic/Adafruit_nRF52_Arduino#master ; Don't renovate toolchain-gccarmnoneeabi platformio/toolchain-gccarmnoneeabi@~1.90301.0 From 24c4162a755e7124b9bc36b6241502974c5a395f Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Sun, 26 Apr 2026 19:58:23 -0500 Subject: [PATCH 091/225] Standardize PMU IRQ handling and enable power button cancel on tbeam-s3 (#10285) * Standardize PMU IRQ handling and enable power button as cancel on tbeam s3 * Original T-beam, too --- src/Power.cpp | 29 +++++++----------------- variants/esp32/tbeam/variant.h | 7 +++--- variants/esp32s3/t-watch-s3/variant.h | 1 + variants/esp32s3/tbeam-s3-core/variant.h | 6 ++--- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 49e95bd0cc1..17715e848a4 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -1000,11 +1000,8 @@ int32_t Power::runOnce() powerFSM.trigger(EVENT_POWER_CONNECTED); } -#ifdef T_WATCH_S3 - /* - In the T-Watch S3 this code fragment reacts to the short press of the button by switching the - display on and off - */ +#ifdef PMU_POWER_BUTTON_IS_CANCEL + // cancel action also turns the screen on and off. if (PMU->isPekeyShortPressIrq()) { LOG_INFO("Input: Corona Button Click"); InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0}; @@ -1027,13 +1024,6 @@ int32_t Power::runOnce() LOG_DEBUG("Battery removed"); } */ -#ifndef T_WATCH_S3 // FIXME - why is this triggering on the T-Watch S3? - if (PMU->isPekeyLongPressIrq()) { - LOG_DEBUG("PEK long button press"); - if (screen) - screen->setOn(false); - } -#endif PMU->clearIrqStatus(); } @@ -1102,7 +1092,7 @@ void Power::attachPowerInterrupts() if (PMU) { attachInterrupt( PMU_IRQ, - [] { + []() { pmu_irq = true; power->setIntervalFromNow(0); runASAP = true; @@ -1405,19 +1395,16 @@ bool Power::axpChipInit() uint64_t pmuIrqMask = 0; if (PMU->getChipModel() == XPOWERS_AXP192) { - pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_BAT_INSERT_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ; + pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_VBUS_REMOVE_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ; } else if (PMU->getChipModel() == XPOWERS_AXP2101) { - pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_BAT_INSERT_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ; + pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_VBUS_REMOVE_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ; } pinMode(PMU_IRQ, INPUT); - // we do not look for AXPXXX_CHARGING_FINISHED_IRQ & AXPXXX_CHARGING_IRQ - // because it occurs repeatedly while there is no battery also it could cause - // inadvertent waking from light sleep just because the battery filled we - // don't look for AXPXXX_BATT_REMOVED_IRQ because it occurs repeatedly while - // no battery installed we don't look at AXPXXX_VBUS_REMOVED_IRQ because we - // don't have anything hooked to vbus + // We wake on IRQ, so only enable the IRQs that we care about. + // we want USB plug and unplug to update the screen and LED status, + // and short press on the power button to trigger the "cancel" action in the UI (which also turns the screen on and off). PMU->enableIRQ(pmuIrqMask); PMU->clearIrqStatus(); diff --git a/variants/esp32/tbeam/variant.h b/variants/esp32/tbeam/variant.h index cca52cb9a9f..e51855b1a2c 100644 --- a/variants/esp32/tbeam/variant.h +++ b/variants/esp32/tbeam/variant.h @@ -35,9 +35,10 @@ // code) #endif -// Leave undefined to disable our PMU IRQ handler. DO NOT ENABLE THIS because the pmuirq can cause sperious interrupts -// and waking from light sleep -// #define PMU_IRQ 35 +// Voiding more warranties. +#define PMU_IRQ 35 +#define PMU_POWER_BUTTON_IS_CANCEL // maps a short click of the power button to a cancel action (turning off the screen) + #define HAS_AXP192 #define GPS_UBLOX #define GPS_RX_PIN 34 diff --git a/variants/esp32s3/t-watch-s3/variant.h b/variants/esp32s3/t-watch-s3/variant.h index aca491a6d57..fddd983046a 100644 --- a/variants/esp32s3/t-watch-s3/variant.h +++ b/variants/esp32s3/t-watch-s3/variant.h @@ -42,6 +42,7 @@ #define DAC_I2S_MCLK -1 #define HAS_AXP2101 +#define PMU_POWER_BUTTON_IS_CANCEL // maps a short click of the power button to a cancel action (turning off the screen) // PCF8563 RTC Module #define PCF8563_RTC 0x51 diff --git a/variants/esp32s3/tbeam-s3-core/variant.h b/variants/esp32s3/tbeam-s3-core/variant.h index 2637e7f78be..11e4633643f 100644 --- a/variants/esp32s3/tbeam-s3-core/variant.h +++ b/variants/esp32s3/tbeam-s3-core/variant.h @@ -46,9 +46,9 @@ #define LR11X0_DIO_AS_RF_SWITCH #endif -// Leave undefined to disable our PMU IRQ handler. DO NOT ENABLE THIS because the pmuirq can cause sperious interrupts -// and waking from light sleep -// #define PMU_IRQ 40 +// Voiding warrenties, we're gonna try the IRQ +#define PMU_IRQ 40 +#define PMU_POWER_BUTTON_IS_CANCEL // maps a short click of the power button to a cancel action (turning off the screen) #define HAS_AXP2101 // PCF8563 RTC Module From bfadf0c36ae0cc50c76b2e1b73d10b1c1f15b8d5 Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:02:42 -0500 Subject: [PATCH 092/225] fix(Router): localize p_encrypted to prevent recursive-overwrite leak (#10311) Router::handleReceived stores its allocCopy of the encrypted packet in the class member p_encrypted. callModules() invokes module replies that re-enter the router via MeshService::sendToMesh -> Router::sendLocal, which on a broadcast reply recursively calls handleReceived. The inner call overwrites the outer's p_encrypted without releasing it; on the way out it nulls the member, the outer release(p_encrypted) now releases nullptr, and the original allocation is permanently leaked. ~381 B per recursion. Promote p_encrypted to a function-local so each invocation owns its own copy for its full lifetime. The MQTT-publish null check at the call site (added by PR #9136 as a workaround for this bug) stays in place because allocCopy can still legitimately return nullptr on packetPool exhaustion. Copilot's review of PR #8999 (the original introduction) flagged this exact pattern at merge time: "Storing p_encrypted as a class member can cause issues with recursive or concurrent calls to handleReceived() since each call would overwrite the previous packet pointer." The historical reason for the member (S&F needing to retain the encrypted copy across calls) was satisfied differently by PR #9799 (ServerAPI converted to std::unique_ptr + cleanup on connection close), so the member is no longer load-bearing. Reproduces issues #9632 / #10101 / #8729 (heap leak when MeshMonitor connected; TCP drops on Station G2 / LILYGO ServerAPI dump abort). Hardware A/B on Station G2 under sustained TCP-API retry storm (open :4403, request config, disconnect mid-stream, repeat at ~0.6/s) - 9 min run: | Build | heapFree drift | rebootCount delta | | this patch | -1.5 KB (noise)| 0 | | stock 2.7.13 | -73 KB (8.1KB/min) | +1 (OOM crash) | Co-authored-by: Claude Opus 4.7 Co-authored-by: Ben Meadors --- src/mesh/Router.cpp | 11 +++++++---- src/mesh/Router.h | 3 --- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index e0473a14e14..ffeb7c5393d 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -736,9 +736,13 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) // Also, we should set the time from the ISR and it should have msec level resolution p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone - // Store a copy of encrypted packet for MQTT + // Store a copy of the encrypted packet for MQTT. + // Local, not a class member: handleReceived re-enters itself when a module + // reply broadcast goes through MeshService::sendToMesh -> Router::sendLocal, + // and a member would be silently overwritten without release on the inner + // call. Each invocation now owns its own copy (issue #9632, #10101, #8729). DEBUG_HEAP_BEFORE; - p_encrypted = packetPool.allocCopy(*p); + meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p); DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted); // Take those raw bytes and convert them back into a well structured protobuf we can understand @@ -832,8 +836,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) #endif } - packetPool.release(p_encrypted); // Release the encrypted packet - p_encrypted = nullptr; + packetPool.release(p_encrypted); // Release the encrypted packet (release() handles nullptr) } void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 0f342d57bdd..bd418869358 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -92,9 +92,6 @@ class Router : protected concurrency::OSThread, protected PacketHistory before us */ uint32_t rxDupe = 0, txRelayCanceled = 0; - // pointer to the encrypted packet - meshtastic_MeshPacket *p_encrypted = nullptr; - protected: friend class RoutingModule; From 87f1f9d349759d043d62647a2e44c2f953cc1372 Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:02:42 -0500 Subject: [PATCH 093/225] fix(Router): localize p_encrypted to prevent recursive-overwrite leak (#10311) Router::handleReceived stores its allocCopy of the encrypted packet in the class member p_encrypted. callModules() invokes module replies that re-enter the router via MeshService::sendToMesh -> Router::sendLocal, which on a broadcast reply recursively calls handleReceived. The inner call overwrites the outer's p_encrypted without releasing it; on the way out it nulls the member, the outer release(p_encrypted) now releases nullptr, and the original allocation is permanently leaked. ~381 B per recursion. Promote p_encrypted to a function-local so each invocation owns its own copy for its full lifetime. The MQTT-publish null check at the call site (added by PR #9136 as a workaround for this bug) stays in place because allocCopy can still legitimately return nullptr on packetPool exhaustion. Copilot's review of PR #8999 (the original introduction) flagged this exact pattern at merge time: "Storing p_encrypted as a class member can cause issues with recursive or concurrent calls to handleReceived() since each call would overwrite the previous packet pointer." The historical reason for the member (S&F needing to retain the encrypted copy across calls) was satisfied differently by PR #9799 (ServerAPI converted to std::unique_ptr + cleanup on connection close), so the member is no longer load-bearing. Reproduces issues #9632 / #10101 / #8729 (heap leak when MeshMonitor connected; TCP drops on Station G2 / LILYGO ServerAPI dump abort). Hardware A/B on Station G2 under sustained TCP-API retry storm (open :4403, request config, disconnect mid-stream, repeat at ~0.6/s) - 9 min run: | Build | heapFree drift | rebootCount delta | | this patch | -1.5 KB (noise)| 0 | | stock 2.7.13 | -73 KB (8.1KB/min) | +1 (OOM crash) | Co-authored-by: Claude Opus 4.7 Co-authored-by: Ben Meadors --- src/mesh/Router.cpp | 11 +++++++---- src/mesh/Router.h | 3 --- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index b231261b5d5..eb5fd41ff1b 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -719,9 +719,13 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) // Also, we should set the time from the ISR and it should have msec level resolution p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone - // Store a copy of encrypted packet for MQTT + // Store a copy of the encrypted packet for MQTT. + // Local, not a class member: handleReceived re-enters itself when a module + // reply broadcast goes through MeshService::sendToMesh -> Router::sendLocal, + // and a member would be silently overwritten without release on the inner + // call. Each invocation now owns its own copy (issue #9632, #10101, #8729). DEBUG_HEAP_BEFORE; - p_encrypted = packetPool.allocCopy(*p); + meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p); DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted); // Take those raw bytes and convert them back into a well structured protobuf we can understand @@ -815,8 +819,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) #endif } - packetPool.release(p_encrypted); // Release the encrypted packet - p_encrypted = nullptr; + packetPool.release(p_encrypted); // Release the encrypted packet (release() handles nullptr) } void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 0f342d57bdd..bd418869358 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -92,9 +92,6 @@ class Router : protected concurrency::OSThread, protected PacketHistory before us */ uint32_t rxDupe = 0, txRelayCanceled = 0; - // pointer to the encrypted packet - meshtastic_MeshPacket *p_encrypted = nullptr; - protected: friend class RoutingModule; From 06a6c3ee2062efcf8281b74c54eef35146e3ea18 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 26 Apr 2026 22:07:07 -0500 Subject: [PATCH 094/225] Native MacOS hello world (#10309) * Native MacOS hello world * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update variants/native/portduino/platformio.ini Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: ensure null-termination in getSerialString() and handle len==0 Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/e5647919-2255-48ad-bcaa-7a2c2fdbf212 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: Jonathan Bennett Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- bin/build-native.sh | 3 +- src/Power.cpp | 2 + src/RedirectablePrint.h | 2 +- src/input/InputBroker.cpp | 3 ++ src/input/LinuxInput.h | 5 ++- src/input/LinuxInputImpl.h | 3 +- src/mesh/HardwareRNG.cpp | 12 ++++- src/mesh/MeshRadio.h | 7 ++- src/mesh/RadioLibInterface.cpp | 2 +- src/mesh/RadioLibInterface.h | 2 +- src/mqtt/MQTT.cpp | 2 +- src/platform/portduino/PortduinoGlue.cpp | 18 +++++--- src/platform/portduino/USBHal.h | 10 +++-- variants/native/portduino.ini | 30 ++++++++----- variants/native/portduino/platformio.ini | 56 ++++++++++++++++++++++++ 15 files changed, 129 insertions(+), 28 deletions(-) diff --git a/bin/build-native.sh b/bin/build-native.sh index f35e46a8790..e34b7558093 100755 --- a/bin/build-native.sh +++ b/bin/build-native.sh @@ -31,5 +31,6 @@ basename=meshtasticd-$1-$VERSION pio pkg install --environment "$PIO_ENV" || platformioFailed pio run --environment "$PIO_ENV" || platformioFailed -cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_linux_$(uname -m)" +os_name=$(uname -s | tr '[:upper:]' '[:lower:]') +cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_${os_name}_$(uname -m)" cp bin/native-install.* $OUTDIR/ diff --git a/src/Power.cpp b/src/Power.cpp index 17715e848a4..bb9f554be40 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -781,8 +781,10 @@ void Power::reboot() rp2040.reboot(); #elif defined(ARCH_PORTDUINO) deInitApiServer(); +#ifdef __linux__ if (aLinuxInputImpl) aLinuxInputImpl->deInit(); +#endif SPI.end(); Wire.end(); Serial1.end(); diff --git a/src/RedirectablePrint.h b/src/RedirectablePrint.h index c66226171e4..8535933fc64 100644 --- a/src/RedirectablePrint.h +++ b/src/RedirectablePrint.h @@ -1,8 +1,8 @@ #pragma once #include "../freertosinc.h" +#include "Print.h" #include "mesh/generated/meshtastic/mesh.pb.h" -#include #include #include diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index b7c9b27a9b0..393cbc0ec22 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -390,8 +390,11 @@ void InputBroker::Init() seesawRotary = nullptr; } } +#ifdef __linux__ + // Linux evdev keyboard input only — macOS has no . aLinuxInputImpl = new LinuxInputImpl(); aLinuxInputImpl->init(); +#endif } #endif #if !MESHTASTIC_EXCLUDE_INPUTBROKER && HAS_TRACKBALL diff --git a/src/input/LinuxInput.h b/src/input/LinuxInput.h index 43d08493cfe..673d29b3c3b 100644 --- a/src/input/LinuxInput.h +++ b/src/input/LinuxInput.h @@ -1,5 +1,8 @@ #pragma once -#if ARCH_PORTDUINO +// Linux evdev keyboard input. Only compiled on Linux portduino targets; +// macOS / non-Linux builds have no or epoll, and the +// headless build doesn't need real keyboards anyway. +#if ARCH_PORTDUINO && defined(__linux__) #include "InputBroker.h" #include "concurrency/OSThread.h" #include diff --git a/src/input/LinuxInputImpl.h b/src/input/LinuxInputImpl.h index e734b029439..716c6619a35 100644 --- a/src/input/LinuxInputImpl.h +++ b/src/input/LinuxInputImpl.h @@ -1,4 +1,5 @@ -#ifdef ARCH_PORTDUINO +// Linux evdev impl. Same Linux-only gating as LinuxInput.h. +#if defined(ARCH_PORTDUINO) && defined(__linux__) #pragma once #include "LinuxInput.h" #include "main.h" diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index b79b0d0127f..a34a9477c52 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -19,8 +19,12 @@ extern Adafruit_nRFCrypto nRFCrypto; #include #elif defined(ARCH_PORTDUINO) #include -#include #include +#ifdef __linux__ +#include // getrandom() +#else +#include // arc4random_buf() on Darwin/BSD +#endif #endif namespace HardwareRNG @@ -119,10 +123,16 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) filled = true; #elif defined(ARCH_PORTDUINO) // Prefer the host OS RNG first when running under Portduino. +#ifdef __linux__ ssize_t generated = ::getrandom(buffer, length, 0); if (generated == static_cast(length)) { filled = true; } +#else + // arc4random_buf is available on Darwin/BSD and cannot fail. + ::arc4random_buf(buffer, length); + filled = true; +#endif if (!filled) { fillWithRandomDevice(buffer, length); diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 089b4b18979..fe4788bff0a 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -6,8 +6,11 @@ #include "configuration.h" #include "detect/LoRaRadioType.h" -// Sentinel marking the end of a modem preset array -static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = +// Sentinel marking the end of a modem preset array. Declared `const` rather +// than `constexpr` because the cast from 0xFF to the enum is out-of-range and +// therefore not a valid constant expression on Clang 16+ (Apple Clang on +// macOS). The value is only ever compared at runtime, so static-init is fine. +static const meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = static_cast(0xFF); // Region profile: bundles the preset list with regulatory parameters shared across regions diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 6024d06b6b8..de468cf9793 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -119,7 +119,7 @@ bool RadioLibInterface::canSendImmediately() return true; } -bool RadioLibInterface::receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag) +bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag) { bool detected = (irq & (syncWordHeaderValidFlag | preambleDetectedFlag)); // Handle false detections diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 2859558ed81..0740561f9b2 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -220,7 +220,7 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified protected: uint32_t activeReceiveStart = 0; - bool receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag); + bool receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag); /** Do any hardware setup needed on entry into send configuration for the radio. * Subclasses can customize, but must also call this base method */ diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index aba06c21005..283fcffb16d 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -32,7 +32,7 @@ #include #include -#include +#include "IPAddress.h" #if defined(ARCH_PORTDUINO) #include #elif !defined(ntohl) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 7833b36030a..fd26926d903 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -9,13 +9,10 @@ #include "PortduinoGlue.h" #include "SHA256.h" #include "api/ServerAPI.h" -#include "linux/gpio/LinuxGPIOPin.h" #include "meshUtils.h" #include #include #include -#include -#include #include #include #include @@ -25,6 +22,12 @@ #include #include +#ifdef PORTDUINO_LINUX_HARDWARE +#include "linux/gpio/LinuxGPIOPin.h" +#include +#include +#endif + #ifdef PORTDUINO_LINUX_HARDWARE #include #endif @@ -130,9 +133,9 @@ void getMacAddr(uint8_t *dmac) } } else if (portduino_config.mac_address.length() > 11) { MAC_from_string(portduino_config.mac_address, dmac); - exit; + return; } else { - +#ifdef PORTDUINO_LINUX_HARDWARE struct hci_dev_info di = {0}; di.dev_id = 0; bdaddr_t bdaddr; @@ -152,6 +155,11 @@ void getMacAddr(uint8_t *dmac) dmac[3] = di.bdaddr.b[2]; dmac[4] = di.bdaddr.b[1]; dmac[5] = di.bdaddr.b[0]; +#else + // No BlueZ on non-Linux hosts (e.g. macOS). Leave dmac at its default; + // the caller can override via the --hwid CLI flag or the YAML config. + (void)dmac; +#endif } } diff --git a/src/platform/portduino/USBHal.h b/src/platform/portduino/USBHal.h index 1725763f209..9496b2ccb45 100644 --- a/src/platform/portduino/USBHal.h +++ b/src/platform/portduino/USBHal.h @@ -5,6 +5,7 @@ #include "platform/portduino/PortduinoGlue.h" #include #include +#include #include #include #include @@ -34,7 +35,7 @@ class Ch341Hal : public RadioLibHal : RadioLibHal(PI_INPUT, PI_OUTPUT, PI_LOW, PI_HIGH, PI_RISING, PI_FALLING) { if (serial != "") { - strncpy(pinedio.serial_number, serial.c_str(), 8); + std::strncpy(pinedio.serial_number, serial.c_str(), 8); pinedio_set_option(&pinedio, PINEDIO_OPTION_SEARCH_SERIAL, 1); } // LOG_INFO("USB Serial: %s", pinedio.serial_number); @@ -59,8 +60,11 @@ class Ch341Hal : public RadioLibHal void getSerialString(char *_serial, size_t len) { - len = len > 8 ? 8 : len; - strncpy(_serial, pinedio.serial_number, len); + if (len == 0) + return; + size_t bytesCopied = (len - 1) < 8 ? (len - 1) : 8; + std::strncpy(_serial, pinedio.serial_number, bytesCopied); + _serial[bytesCopied] = '\0'; } void getProductString(char *_product_string, size_t len) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index b276d2779f7..35c8c66972b 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/135b91e953db0b5f44d278f8ebd5b8d985fc03d8.zip + https://github.com/meshtastic/platform-native/archive/4ea5e09ac7d51a593e12ec7c1ebb6cd06745ce53.zip framework = arduino build_src_filter = @@ -37,25 +37,35 @@ lib_deps = # renovate: datasource=github-tags depName=Adafruit_BME680 packageName=adafruit/Adafruit_BME680 https://github.com/adafruit/Adafruit_BME680/archive/refs/tags/2.0.6.zip -build_flags = +; Cross-platform build flags shared between native (Linux) and native-macos builds. +; Anything Linux-only (libbluetooth, libgpiod, libi2c, /sys/class/gpio, +; PORTDUINO_LINUX_HARDWARE, glibc-style _FORTIFY_SOURCE) goes in `build_flags` +; instead. UDP multicast also lives there because the firmware's +; UdpMulticastHandler.h unconditionally `#include ` on non-NRF52 +; targets, which requires the framework's bundled WiFi shim that we +; lib_ignore on macOS. +build_flags_common = ${arduino_base.build_flags} -D ARCH_PORTDUINO -fPIC - -D_FORTIFY_SOURCE=2 - -fstack-protector-all -Wstack-protector --param ssp-buffer-size=4 -Isrc/platform/portduino -DRADIOLIB_EEPROM_UNSUPPORTED - -DPORTDUINO_LINUX_HARDWARE - -DHAS_UDP_MULTICAST=1 -lpthread - -lstdc++fs - -lbluetooth - -lgpiod -lyaml-cpp - -li2c -luv -std=gnu17 -std=gnu++17 + +build_flags = + ${portduino_base.build_flags_common} + -DHAS_UDP_MULTICAST=1 + -D_FORTIFY_SOURCE=2 + -fstack-protector-all -Wstack-protector --param ssp-buffer-size=4 + -DPORTDUINO_LINUX_HARDWARE + -lstdc++fs + -lbluetooth + -lgpiod + -li2c lib_ignore = Adafruit NeoPixel Adafruit ST7735 and ST7789 Library diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 045e3edead9..c497d0c179f 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -117,3 +117,59 @@ build_flags = -lgcov --coverage -fprofile-abs-path -fsanitize=address ${env:nati test_testing_command = ${platformio.build_dir}/${this.__env__}/meshtasticd -s + +; --------------------------------------------------------------------------- +; Native build for macOS (Darwin / arm64 + x86_64). Headless meshtasticd that +; runs in SimRadio mode (`-s`) or against real LoRa hardware via a CH341 +; USB-SPI bridge. No BlueZ, libgpiod, or Linux I2C — those require Linux. +; +; Prerequisites (Homebrew): +; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config +; +; The macOS-side patches now live upstream: +; * meshtastic/platform-native — `String.h`-shadow shim, `-Wno-enum-constexpr-conversion`, +; empty-variant-dir guard. Pulled via `portduino_base.platform` zip pin. +; * meshtastic/framework-portduino — LinuxHardwareI2C macOS stubs, AsyncUDP +; SOCK_NONBLOCK fallback, Common.h __APPLE__ guard, WiFiServer.cpp extern-C +; fix, package.json URL refresh. Pulled by platform-native at its pinned commit. +; This env therefore only carries the firmware-side build flags and src filter. +; --------------------------------------------------------------------------- +[env:native-macos] +extends = native_base +; Apple's ld doesn't accept GNU ld's `-Wl,-Map,` syntax (inherited from +; the top-level platformio.ini). Strip it; the linker map isn't useful for +; the macOS dev loop anyway, and Apple ld's equivalent (`-Wl,-map,`) +; uses different argument shape. +build_unflags = -Wl,-Map,"${platformio.build_dir}"/output.map +build_flags = ${portduino_base.build_flags_common} + -I variants/native/portduino + -I/opt/homebrew/include + -I/opt/homebrew/opt/argp-standalone/include + -I/opt/homebrew/opt/yaml-cpp/include + -L/opt/homebrew/lib + -L/opt/homebrew/opt/argp-standalone/lib + -L/opt/homebrew/opt/yaml-cpp/lib + -largp + -DPORTDUINO_DARWIN + ; Headless build — variants/native/portduino/variant.h would otherwise + ; default HAS_SCREEN to 1 and pull in screen-renderer source that uses + ; VLA-with-initializer (a GNU/GCC extension Apple Clang rejects). + ; MESHTASTIC_EXCLUDE_SCREEN gates the optional `screen->setHeading(...)`- + ; style screen-driver hooks scattered through sensor sources. + -DHAS_SCREEN=0 + -DMESHTASTIC_EXCLUDE_SCREEN=1 + !pkg-config --libs openssl --silence-errors || : +; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist +; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX +; (which we lib_ignore on macOS for the issue). Neither is needed +; for the headless build. +build_src_filter = ${native_base.build_src_filter} + - + - + - + - +; LovyanGFX includes (Linux-only) and is only needed by TFT +; variants — not relevant for the headless macOS build. +lib_ignore = + ${portduino_base.lib_ignore} + LovyanGFX From 47a6c4c6a032d4444efc32f9659ab713649cc205 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:22:50 -0500 Subject: [PATCH 095/225] Upgrade trunk (#10317) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index f90f4f4ac51..178a1cc9e98 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -8,15 +8,15 @@ plugins: uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.524 - - renovate@43.141.0 + - checkov@3.2.525 + - renovate@43.142.0 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 - bandit@1.9.4 - trivy@0.70.0 - taplo@0.10.0 - - ruff@0.15.11 + - ruff@0.15.12 - isort@8.0.1 - markdownlint@0.48.0 - oxipng@10.1.1 From 126861fd1635963707b5ed3902f65aa1c1dbe23b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 26 Apr 2026 22:07:07 -0500 Subject: [PATCH 096/225] Native MacOS hello world (#10309) * Native MacOS hello world * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update variants/native/portduino/platformio.ini Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: ensure null-termination in getSerialString() and handle len==0 Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/e5647919-2255-48ad-bcaa-7a2c2fdbf212 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: Jonathan Bennett Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- bin/build-native.sh | 3 +- src/Power.cpp | 2 + src/RedirectablePrint.h | 2 +- src/input/InputBroker.cpp | 3 ++ src/input/LinuxInput.h | 5 ++- src/input/LinuxInputImpl.h | 3 +- src/mesh/HardwareRNG.cpp | 12 ++++- src/mesh/MeshRadio.h | 27 ++++++++++++ src/mesh/RadioLibInterface.cpp | 2 +- src/mesh/RadioLibInterface.h | 2 +- src/mqtt/MQTT.cpp | 2 +- src/platform/portduino/PortduinoGlue.cpp | 18 +++++--- src/platform/portduino/USBHal.h | 10 +++-- variants/native/portduino.ini | 30 ++++++++----- variants/native/portduino/platformio.ini | 56 ++++++++++++++++++++++++ 15 files changed, 151 insertions(+), 26 deletions(-) diff --git a/bin/build-native.sh b/bin/build-native.sh index f35e46a8790..e34b7558093 100755 --- a/bin/build-native.sh +++ b/bin/build-native.sh @@ -31,5 +31,6 @@ basename=meshtasticd-$1-$VERSION pio pkg install --environment "$PIO_ENV" || platformioFailed pio run --environment "$PIO_ENV" || platformioFailed -cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_linux_$(uname -m)" +os_name=$(uname -s | tr '[:upper:]' '[:lower:]') +cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_${os_name}_$(uname -m)" cp bin/native-install.* $OUTDIR/ diff --git a/src/Power.cpp b/src/Power.cpp index ecdda8dd979..1ea3a64c293 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -755,8 +755,10 @@ void Power::reboot() rp2040.reboot(); #elif defined(ARCH_PORTDUINO) deInitApiServer(); +#ifdef __linux__ if (aLinuxInputImpl) aLinuxInputImpl->deInit(); +#endif SPI.end(); Wire.end(); Serial1.end(); diff --git a/src/RedirectablePrint.h b/src/RedirectablePrint.h index c66226171e4..8535933fc64 100644 --- a/src/RedirectablePrint.h +++ b/src/RedirectablePrint.h @@ -1,8 +1,8 @@ #pragma once #include "../freertosinc.h" +#include "Print.h" #include "mesh/generated/meshtastic/mesh.pb.h" -#include #include #include diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index b7c9b27a9b0..393cbc0ec22 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -390,8 +390,11 @@ void InputBroker::Init() seesawRotary = nullptr; } } +#ifdef __linux__ + // Linux evdev keyboard input only — macOS has no . aLinuxInputImpl = new LinuxInputImpl(); aLinuxInputImpl->init(); +#endif } #endif #if !MESHTASTIC_EXCLUDE_INPUTBROKER && HAS_TRACKBALL diff --git a/src/input/LinuxInput.h b/src/input/LinuxInput.h index 43d08493cfe..673d29b3c3b 100644 --- a/src/input/LinuxInput.h +++ b/src/input/LinuxInput.h @@ -1,5 +1,8 @@ #pragma once -#if ARCH_PORTDUINO +// Linux evdev keyboard input. Only compiled on Linux portduino targets; +// macOS / non-Linux builds have no or epoll, and the +// headless build doesn't need real keyboards anyway. +#if ARCH_PORTDUINO && defined(__linux__) #include "InputBroker.h" #include "concurrency/OSThread.h" #include diff --git a/src/input/LinuxInputImpl.h b/src/input/LinuxInputImpl.h index e734b029439..716c6619a35 100644 --- a/src/input/LinuxInputImpl.h +++ b/src/input/LinuxInputImpl.h @@ -1,4 +1,5 @@ -#ifdef ARCH_PORTDUINO +// Linux evdev impl. Same Linux-only gating as LinuxInput.h. +#if defined(ARCH_PORTDUINO) && defined(__linux__) #pragma once #include "LinuxInput.h" #include "main.h" diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index b79b0d0127f..a34a9477c52 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -19,8 +19,12 @@ extern Adafruit_nRFCrypto nRFCrypto; #include #elif defined(ARCH_PORTDUINO) #include -#include #include +#ifdef __linux__ +#include // getrandom() +#else +#include // arc4random_buf() on Darwin/BSD +#endif #endif namespace HardwareRNG @@ -119,10 +123,16 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) filled = true; #elif defined(ARCH_PORTDUINO) // Prefer the host OS RNG first when running under Portduino. +#ifdef __linux__ ssize_t generated = ::getrandom(buffer, length, 0); if (generated == static_cast(length)) { filled = true; } +#else + // arc4random_buf is available on Darwin/BSD and cannot fail. + ::arc4random_buf(buffer, length); + filled = true; +#endif if (!filled) { fillWithRandomDevice(buffer, length); diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 646ca86eb5b..1b2fd096293 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -6,6 +6,33 @@ #include "configuration.h" #include "detect/LoRaRadioType.h" +// Sentinel marking the end of a modem preset array. Declared `const` rather +// than `constexpr` because the cast from 0xFF to the enum is out-of-range and +// therefore not a valid constant expression on Clang 16+ (Apple Clang on +// macOS). The value is only ever compared at runtime, so static-init is fine. +static const meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = + static_cast(0xFF); + +// Region profile: bundles the preset list with regulatory parameters shared across regions +struct RegionProfile { + const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated; first entry is the default + float spacing; // gaps between radio channels + float padding; // padding at each side of the "operating channel" + bool audioPermitted; + bool licensedOnly; // a region profile for licensed operators only + int8_t textThrottle; // throttle for text - future expansion + int8_t positionThrottle; // throttle for location data - future expansion + int8_t telemetryThrottle; // throttle for telemetry - future expansion + uint8_t overrideSlot; // a per-region override slot for if we need to fix it in place +}; + +extern const RegionProfile PROFILE_STD; +extern const RegionProfile PROFILE_EU868; +extern const RegionProfile PROFILE_UNDEF; +// extern const RegionProfile PROFILE_LITE; +// extern const RegionProfile PROFILE_NARROW; +// extern const RegionProfile PROFILE_HAM; + // Map from old region names to new region enums struct RegionInfo { meshtastic_Config_LoRaConfig_RegionCode code; diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 7ef707e0db4..5121ac4335a 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -109,7 +109,7 @@ bool RadioLibInterface::canSendImmediately() return true; } -bool RadioLibInterface::receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag) +bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag) { bool detected = (irq & (syncWordHeaderValidFlag | preambleDetectedFlag)); // Handle false detections diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 310ca76bb24..9ee60821414 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -213,7 +213,7 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified protected: uint32_t activeReceiveStart = 0; - bool receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag); + bool receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag); /** Do any hardware setup needed on entry into send configuration for the radio. * Subclasses can customize, but must also call this base method */ diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index ac022a1abec..902fd1c2b49 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -32,7 +32,7 @@ #include #include -#include +#include "IPAddress.h" #if defined(ARCH_PORTDUINO) #include #elif !defined(ntohl) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 660bad0f259..6f807772071 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -9,13 +9,10 @@ #include "PortduinoGlue.h" #include "SHA256.h" #include "api/ServerAPI.h" -#include "linux/gpio/LinuxGPIOPin.h" #include "meshUtils.h" #include #include #include -#include -#include #include #include #include @@ -25,6 +22,12 @@ #include #include +#ifdef PORTDUINO_LINUX_HARDWARE +#include "linux/gpio/LinuxGPIOPin.h" +#include +#include +#endif + #ifdef PORTDUINO_LINUX_HARDWARE #include #endif @@ -130,9 +133,9 @@ void getMacAddr(uint8_t *dmac) } } else if (portduino_config.mac_address.length() > 11) { MAC_from_string(portduino_config.mac_address, dmac); - exit; + return; } else { - +#ifdef PORTDUINO_LINUX_HARDWARE struct hci_dev_info di = {0}; di.dev_id = 0; bdaddr_t bdaddr; @@ -152,6 +155,11 @@ void getMacAddr(uint8_t *dmac) dmac[3] = di.bdaddr.b[2]; dmac[4] = di.bdaddr.b[1]; dmac[5] = di.bdaddr.b[0]; +#else + // No BlueZ on non-Linux hosts (e.g. macOS). Leave dmac at its default; + // the caller can override via the --hwid CLI flag or the YAML config. + (void)dmac; +#endif } } diff --git a/src/platform/portduino/USBHal.h b/src/platform/portduino/USBHal.h index 1725763f209..9496b2ccb45 100644 --- a/src/platform/portduino/USBHal.h +++ b/src/platform/portduino/USBHal.h @@ -5,6 +5,7 @@ #include "platform/portduino/PortduinoGlue.h" #include #include +#include #include #include #include @@ -34,7 +35,7 @@ class Ch341Hal : public RadioLibHal : RadioLibHal(PI_INPUT, PI_OUTPUT, PI_LOW, PI_HIGH, PI_RISING, PI_FALLING) { if (serial != "") { - strncpy(pinedio.serial_number, serial.c_str(), 8); + std::strncpy(pinedio.serial_number, serial.c_str(), 8); pinedio_set_option(&pinedio, PINEDIO_OPTION_SEARCH_SERIAL, 1); } // LOG_INFO("USB Serial: %s", pinedio.serial_number); @@ -59,8 +60,11 @@ class Ch341Hal : public RadioLibHal void getSerialString(char *_serial, size_t len) { - len = len > 8 ? 8 : len; - strncpy(_serial, pinedio.serial_number, len); + if (len == 0) + return; + size_t bytesCopied = (len - 1) < 8 ? (len - 1) : 8; + std::strncpy(_serial, pinedio.serial_number, bytesCopied); + _serial[bytesCopied] = '\0'; } void getProductString(char *_product_string, size_t len) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index b276d2779f7..35c8c66972b 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/135b91e953db0b5f44d278f8ebd5b8d985fc03d8.zip + https://github.com/meshtastic/platform-native/archive/4ea5e09ac7d51a593e12ec7c1ebb6cd06745ce53.zip framework = arduino build_src_filter = @@ -37,25 +37,35 @@ lib_deps = # renovate: datasource=github-tags depName=Adafruit_BME680 packageName=adafruit/Adafruit_BME680 https://github.com/adafruit/Adafruit_BME680/archive/refs/tags/2.0.6.zip -build_flags = +; Cross-platform build flags shared between native (Linux) and native-macos builds. +; Anything Linux-only (libbluetooth, libgpiod, libi2c, /sys/class/gpio, +; PORTDUINO_LINUX_HARDWARE, glibc-style _FORTIFY_SOURCE) goes in `build_flags` +; instead. UDP multicast also lives there because the firmware's +; UdpMulticastHandler.h unconditionally `#include ` on non-NRF52 +; targets, which requires the framework's bundled WiFi shim that we +; lib_ignore on macOS. +build_flags_common = ${arduino_base.build_flags} -D ARCH_PORTDUINO -fPIC - -D_FORTIFY_SOURCE=2 - -fstack-protector-all -Wstack-protector --param ssp-buffer-size=4 -Isrc/platform/portduino -DRADIOLIB_EEPROM_UNSUPPORTED - -DPORTDUINO_LINUX_HARDWARE - -DHAS_UDP_MULTICAST=1 -lpthread - -lstdc++fs - -lbluetooth - -lgpiod -lyaml-cpp - -li2c -luv -std=gnu17 -std=gnu++17 + +build_flags = + ${portduino_base.build_flags_common} + -DHAS_UDP_MULTICAST=1 + -D_FORTIFY_SOURCE=2 + -fstack-protector-all -Wstack-protector --param ssp-buffer-size=4 + -DPORTDUINO_LINUX_HARDWARE + -lstdc++fs + -lbluetooth + -lgpiod + -li2c lib_ignore = Adafruit NeoPixel Adafruit ST7735 and ST7789 Library diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 045e3edead9..c497d0c179f 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -117,3 +117,59 @@ build_flags = -lgcov --coverage -fprofile-abs-path -fsanitize=address ${env:nati test_testing_command = ${platformio.build_dir}/${this.__env__}/meshtasticd -s + +; --------------------------------------------------------------------------- +; Native build for macOS (Darwin / arm64 + x86_64). Headless meshtasticd that +; runs in SimRadio mode (`-s`) or against real LoRa hardware via a CH341 +; USB-SPI bridge. No BlueZ, libgpiod, or Linux I2C — those require Linux. +; +; Prerequisites (Homebrew): +; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config +; +; The macOS-side patches now live upstream: +; * meshtastic/platform-native — `String.h`-shadow shim, `-Wno-enum-constexpr-conversion`, +; empty-variant-dir guard. Pulled via `portduino_base.platform` zip pin. +; * meshtastic/framework-portduino — LinuxHardwareI2C macOS stubs, AsyncUDP +; SOCK_NONBLOCK fallback, Common.h __APPLE__ guard, WiFiServer.cpp extern-C +; fix, package.json URL refresh. Pulled by platform-native at its pinned commit. +; This env therefore only carries the firmware-side build flags and src filter. +; --------------------------------------------------------------------------- +[env:native-macos] +extends = native_base +; Apple's ld doesn't accept GNU ld's `-Wl,-Map,` syntax (inherited from +; the top-level platformio.ini). Strip it; the linker map isn't useful for +; the macOS dev loop anyway, and Apple ld's equivalent (`-Wl,-map,`) +; uses different argument shape. +build_unflags = -Wl,-Map,"${platformio.build_dir}"/output.map +build_flags = ${portduino_base.build_flags_common} + -I variants/native/portduino + -I/opt/homebrew/include + -I/opt/homebrew/opt/argp-standalone/include + -I/opt/homebrew/opt/yaml-cpp/include + -L/opt/homebrew/lib + -L/opt/homebrew/opt/argp-standalone/lib + -L/opt/homebrew/opt/yaml-cpp/lib + -largp + -DPORTDUINO_DARWIN + ; Headless build — variants/native/portduino/variant.h would otherwise + ; default HAS_SCREEN to 1 and pull in screen-renderer source that uses + ; VLA-with-initializer (a GNU/GCC extension Apple Clang rejects). + ; MESHTASTIC_EXCLUDE_SCREEN gates the optional `screen->setHeading(...)`- + ; style screen-driver hooks scattered through sensor sources. + -DHAS_SCREEN=0 + -DMESHTASTIC_EXCLUDE_SCREEN=1 + !pkg-config --libs openssl --silence-errors || : +; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist +; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX +; (which we lib_ignore on macOS for the issue). Neither is needed +; for the headless build. +build_src_filter = ${native_base.build_src_filter} + - + - + - + - +; LovyanGFX includes (Linux-only) and is only needed by TFT +; variants — not relevant for the headless macOS build. +lib_ignore = + ${portduino_base.lib_ignore} + LovyanGFX From 4234fe6f8649b0f64fb29ebd7ee8da21fc58ddea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:50:01 -0500 Subject: [PATCH 097/225] Update meshtastic/device-ui digest to 7289329 (#10313) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ben Meadors --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 102c93a3143..3f8f77228cf 100644 --- a/platformio.ini +++ b/platformio.ini @@ -126,7 +126,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/56e1da4e7d30abcd746a2092a30e422f8cf5fc2b.zip + https://github.com/meshtastic/device-ui/archive/728932970996ec91bdb93cb6dae29c2cb70c66e2.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From 048e5187baa717341de22eac6b5f1c107cf8f42c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:50:13 -0500 Subject: [PATCH 098/225] Update platform-native digest to 4ea5e09 (#10314) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> From f037ce216535d30d8886a6fd52274b92e8d8309c Mon Sep 17 00:00:00 2001 From: Quency-D <55523105+Quency-D@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:25:19 +0800 Subject: [PATCH 099/225] add heltec-v4-r8 board (#10268) * add heltec-v4-r8 board * Fixed default SPI pin and macro definition errors. * update platformio.ini according device-ui LGFX display definitions Co-authored-by: Copilot * fix commit reference --------- Co-authored-by: Ben Meadors Co-authored-by: mverch67 Co-authored-by: Copilot Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com> --- boards/heltec_v4_r8.json | 43 ++++++ src/configuration.h | 3 + src/graphics/TFTDisplay.cpp | 19 ++- src/mesh/NodeDB.cpp | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 4 +- variants/esp32s3/heltec_v4_r8/pins_arduino.h | 56 +++++++ variants/esp32s3/heltec_v4_r8/platformio.ini | 145 +++++++++++++++++++ variants/esp32s3/heltec_v4_r8/variant.h | 72 +++++++++ 8 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 boards/heltec_v4_r8.json create mode 100644 variants/esp32s3/heltec_v4_r8/pins_arduino.h create mode 100644 variants/esp32s3/heltec_v4_r8/platformio.ini create mode 100644 variants/esp32s3/heltec_v4_r8/variant.h diff --git a/boards/heltec_v4_r8.json b/boards/heltec_v4_r8.json new file mode 100644 index 00000000000..6dd97c84b22 --- /dev/null +++ b/boards/heltec_v4_r8.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "heltec_v4_r8" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "heltec_wifi_lora_32 v4 r8 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/", + "vendor": "heltec" +} diff --git a/src/configuration.h b/src/configuration.h index efd9ddcf76d..e0284e6c982 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -162,6 +162,9 @@ along with this program. If not, see . #elif defined(HELTEC_MESH_NODE_T096) #define NUM_PA_POINTS 22 #define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7 +#elif defined(HELTEC_V4_R8) +#define NUM_PA_POINTS 22 +#define TX_GAIN_LORA 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7 #else // If a board enables USE_KCT8103L_PA but does not match a known variant and has // not already provided a PA curve, fail at compile time to avoid unsafe defaults. diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 7df0c57cc62..a28924ba688 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -422,7 +422,7 @@ static LGFX *tft = nullptr; #elif defined(ST7789_CS) #include // Graphics and font library for ST7735 driver chip -#ifdef HELTEC_V4_TFT +#if defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) #include "chsc6x.h" #include "lgfx/v1/Touch.hpp" namespace lgfx @@ -444,7 +444,11 @@ class TOUCH_CHSC6X : public ITouch bool init(void) override { if (chsc6xTouch == nullptr) { +#if (TOUCH_I2C_PORT == 1) chsc6xTouch = new chsc6x(&Wire1, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN); +#else + chsc6xTouch = new chsc6x(&Wire, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN); +#endif } chsc6xTouch->chsc6x_init(); return true; @@ -481,7 +485,7 @@ class LGFX : public lgfx::LGFX_Device #if HAS_TOUCHSCREEN #if defined(T_WATCH_S3) || defined(ELECROW) lgfx::Touch_FT5x06 _touch_instance; -#elif defined(HELTEC_V4_TFT) +#elif defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) lgfx::TOUCH_CHSC6X _touch_instance; #else lgfx::Touch_GT911 _touch_instance; @@ -500,7 +504,11 @@ class LGFX : public lgfx::LGFX_Device cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing // 80MHz by an integer) cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving - cfg.spi_3wire = false; +#ifdef SPI_3_WIRE + cfg.spi_3wire = SPI_3_WIRE; +#else + cfg.spi_3wire = true; // Set to true if reception is done on the MOSI pin +#endif cfg.use_lock = true; // Set to true to use transaction locking cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch / // SPI_DMA_CH_AUTO=auto setting) @@ -550,8 +558,11 @@ class LGFX : public lgfx::LGFX_Device cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped cfg.dlen_16bit = false; // Set to true for panels that transmit data length in 16-bit units with 16-bit parallel or SPI +#if defined(HAS_SDCARD) cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.) - +#else + cfg.bus_shared = false; +#endif // Set the following only when the display is shifted with a driver with a variable number of pixels, such as the // ST7735 or ILI9163. // cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 6e57e89f6e1..0193585e220 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -688,7 +688,7 @@ 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(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)) && \ HAS_TFT // switch BT off by default; use TFT programming mode or hotkey to enable config.bluetooth.enabled = false; diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index d6634aa7426..ca81ab43502 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -89,8 +89,10 @@ build_flags = -D VIEW_240x320 -D DISPLAY_SET_RESOLUTION -D DISPLAY_SIZE=240x320 ; portrait mode + -D LGFX_SPI_3WIRE=true -D LGFX_PIN_SCK=17 -D LGFX_PIN_MOSI=33 + -D LGFX_PIN_MISO=-1 -D LGFX_PIN_DC=16 -D LGFX_PIN_CS=15 -D LGFX_PIN_BL=21 @@ -123,7 +125,7 @@ build_flags = -D SCREEN_TRANSITION_FRAMERATE=30 -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness -D HAS_TOUCHSCREEN=1 - -D TOUCH_I2C_PORT=0 + -D TOUCH_I2C_PORT=1 -D TOUCH_SLAVE_ADDRESS=0x2E -D SCREEN_TOUCH_INT=TOUCH_INT_PIN -D SCREEN_TOUCH_RST=TOUCH_RST_PIN diff --git a/variants/esp32s3/heltec_v4_r8/pins_arduino.h b/variants/esp32s3/heltec_v4_r8/pins_arduino.h new file mode 100644 index 00000000000..631f0751339 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/pins_arduino.h @@ -0,0 +1,56 @@ +#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 = 17; +static const uint8_t SCL = 18; + +static const uint8_t SS = 8; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 11; +static const uint8_t SCK = 9; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini new file mode 100644 index 00000000000..747cc8c49d2 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -0,0 +1,145 @@ +[heltec_v4_r8_base] +extends = esp32s3_base +board = heltec_v4_r8 +board_check = true +board_build.partitions = default_16MB.csv +build_flags = + ${esp32s3_base.build_flags} + -D HELTEC_V4_R8 + -D HAS_LORA_FEM=1 + -D BOARD_HAS_PSRAM + -I variants/esp32s3/heltec_v4_r8 + -ULED_BUILTIN + +[env:heltec-v4-r8-oled] +custom_meshtastic_hw_model = 132 +custom_meshtastic_hw_model_slug = HELTEC_V4_R8 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 R8 +custom_meshtastic_images = heltec_v4_r8.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = heltec_v4_r8_base +build_flags = + ${heltec_v4_r8_base.build_flags} + -D HELTEC_V4_R8_OLED + -D USE_SSD1306 ; Heltec_v4_R8 has an SSD1315 display (compatible with SSD1306 driver) + -D LED_POWER=46 + -D RESET_OLED=21 + -D I2C_SDA=17 + -D I2C_SCL=18 + +[env:heltec-v4-r8-tft] +custom_meshtastic_hw_model = 132 +custom_meshtastic_hw_model_slug = HELTEC_V4_R8 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 R8 TFT +custom_meshtastic_images = heltec_v4_r8_tft.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = heltec_v4_r8_base +build_flags = + ${heltec_v4_r8_base.build_flags} ;-Os + -D HELTEC_V4_R8_TFT + -D I2C_SDA=17 + -D I2C_SCL=18 + -D PIN_BUTTON2=46 + -D ALT_BUTTON_PIN=PIN_BUTTON2 + -D ALT_BUTTON_ACTIVE_LOW=false + -D PIN_BUZZER=4 + -D USE_PIN_BUZZER=PIN_BUZZER + -D CONFIG_ARDUHAL_LOG_COLORS + -D RADIOLIB_DEBUG_SPI=0 + -D RADIOLIB_DEBUG_PROTOCOL=0 + -D RADIOLIB_DEBUG_BASIC=0 + -D RADIOLIB_VERBOSE_ASSERT=0 + -D RADIOLIB_SPI_PARANOID=0 + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D INPUTDRIVER_BUTTON_TYPE=0 + -D HAS_SCREEN=1 + -D HAS_TFT=1 + -D RAM_SIZE=5120 + -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 USE_PACKET_API + -D LGFX_DRIVER=LGFX_HELTEC_V4_TFT + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_HELTEC_V4_TFT.h\" + -D VIEW_240x320 + -D DISPLAY_SET_RESOLUTION + -D DISPLAY_SIZE=240x320 ; portrait mode + -D LGFX_SPI_3WIRE=false + -D LGFX_PIN_SCK=16 + -D LGFX_PIN_MOSI=15 + -D LGFX_PIN_MISO=45 + -D LGFX_PIN_DC=48 + -D LGFX_PIN_CS=47 + -D LGFX_PIN_BL=44 + -D LGFX_PIN_RST=21 + -D CUSTOM_TOUCH_DRIVER + -D TOUCH_SDA_PIN=I2C_SDA + -D TOUCH_SCL_PIN=I2C_SCL + -D TOUCH_INT_PIN=-1 + -D TOUCH_RST_PIN=-1 +;base UI + -D TFT_CS=LGFX_PIN_CS + -D ST7789_CS=TFT_CS + -D ST7789_RS=LGFX_PIN_DC + -D ST7789_SDA=LGFX_PIN_MOSI + -D ST7789_SCK=LGFX_PIN_SCK + -D ST7789_RESET=LGFX_PIN_RST + -D ST7789_MISO=LGFX_PIN_MISO + -D ST7789_BUSY=-1 + -D ST7789_BL=LGFX_PIN_BL + -D ST7789_SPI_HOST=SPI3_HOST + -D TFT_BL=ST7789_BL + -D SPI_FREQUENCY=75000000 + -D SPI_READ_FREQUENCY=SPI_FREQUENCY + -D SPI_3_WIRE=false + -D TFT_HEIGHT=320 + -D TFT_WIDTH=240 + -D TFT_OFFSET_X=0 + -D TFT_OFFSET_Y=0 + -D TFT_OFFSET_ROTATION=0 + -D SCREEN_ROTATE + -D SCREEN_TRANSITION_FRAMERATE=5 + -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness + -D HAS_TOUCHSCREEN=1 + -D TOUCH_I2C_PORT=0 + -D TOUCH_SLAVE_ADDRESS=0x2E + -D SCREEN_TOUCH_INT=TOUCH_INT_PIN + -D SCREEN_TOUCH_RST=TOUCH_RST_PIN + +; Have SPI interface SD card slot + -D HAS_SDCARD + -D SDCARD_USE_SPI1 + -D SDCARD_USER_SPI_BEGIN + -D SPI_MOSI=LGFX_PIN_MOSI + -D SPI_SCK=LGFX_PIN_SCK + -D SPI_MISO=LGFX_PIN_MISO + -D SPI_CS=3 + -D SDCARD_CS=SPI_CS + -D SD_SPI_FREQUENCY=SPI_FREQUENCY + +lib_deps = ${heltec_v4_r8_base.lib_deps} + ${device-ui_base.lib_deps} + # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.19 + # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master + https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/variant.h b/variants/esp32s3/heltec_v4_r8/variant.h new file mode 100644 index 00000000000..1f638f24cc9 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/variant.h @@ -0,0 +1,72 @@ +#define VEXT_ENABLE 40 // active low, powers the oled display and the lora antenna boost +#define VEXT_ON_VALUE LOW +#define BUTTON_PIN 0 + +#define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage +#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider +#define ADC_MULTIPLIER 4.9 * 1.035 + +#define USE_SX1262 +#define LORA_DIO0 -1 // a No connect on the SX1262 module +#define LORA_RESET 12 +#define LORA_DIO1 14 // SX1262 IRQ +#define LORA_DIO2 13 // SX1262 BUSY +#define LORA_DIO3 // Not connected on PCB, but internally on the TTGO SX1262, if DIO3 is high the TCXO is enabled + +#define LORA_SCK 9 +#define LORA_MISO 11 +#define LORA_MOSI 10 +#define LORA_CS 8 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET + +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// Enable Traffic Management Module for Heltec V4 +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif + +// ---- KCT8103L RF FRONT END CONFIGURATION ---- +// The Heltec V4.3 uses a KCT8103L FEM chip with integrated PA and LNA +// RF path: SX1262 -> Pi attenuator -> KCT8103L PA -> Antenna +// Control logic (from KCT8103L datasheet): +// Transmit PA: CSD=1, CTX=1, CPS=1 +// Receive LNA: CSD=1, CTX=0, CPS=X (21dB gain, 1.9dB NF) +// Receive bypass: CSD=1, CTX=1, CPS=0 +// Shutdown: CSD=0, CTX=X, CPS=X +// Pin mapping: +// CPS (pin 5) -> SX1262 DIO2: TX/RX path select (automatic via SX126X_DIO2_AS_RF_SWITCH) +// CSD (pin 4) -> GPIO2: Chip enable (HIGH=on, LOW=shutdown) +// CTX (pin 6) -> GPIO5: Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) +// VCC0/VCC1 -> Vfem via U3 LDO, controlled by GPIO7 +// KCT8103L FEM: TX/RX path switching is handled by DIO2 -> CPS pin (via SX126X_DIO2_AS_RF_SWITCH) + +#define USE_KCT8103L_PA +#define LORA_PA_POWER 7 // VFEM_Ctrl - KCT8103L LDO power enable +#define LORA_KCT8103L_PA_CSD 2 // CSD - KCT8103L chip enable (HIGH=on) +#define LORA_KCT8103L_PA_CTX 5 // CTX - Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) + +#if HAS_TFT +#define USE_TFTDISPLAY 1 +#endif +/* + * GPS pins + */ +#define GPS_L76K +#define PIN_GPS_EN (42) +#define GPS_EN_ACTIVE LOW +#define PERIPHERAL_WARMUP_MS 1000 // Make sure I2C QuickLink has stable power before continuing +#define PIN_GPS_PPS (41) +// Seems to be missing on this new board +#define GPS_TX_PIN (38) // This is for bits going TOWARDS the CPU +#define GPS_RX_PIN (39) // This is for bits going TOWARDS the GPS +#define GPS_THREAD_INTERVAL 50 From d7db0f5829876e22d7fcf0640566d0f40d87ec24 Mon Sep 17 00:00:00 2001 From: Quency-D <55523105+Quency-D@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:25:19 +0800 Subject: [PATCH 100/225] add heltec-v4-r8 board (#10268) * add heltec-v4-r8 board * Fixed default SPI pin and macro definition errors. * update platformio.ini according device-ui LGFX display definitions Co-authored-by: Copilot * fix commit reference --------- Co-authored-by: Ben Meadors Co-authored-by: mverch67 Co-authored-by: Copilot Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com> --- boards/heltec_v4_r8.json | 43 ++++++ src/configuration.h | 3 + src/graphics/TFTDisplay.cpp | 19 ++- src/mesh/NodeDB.cpp | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 4 +- variants/esp32s3/heltec_v4_r8/pins_arduino.h | 56 +++++++ variants/esp32s3/heltec_v4_r8/platformio.ini | 145 +++++++++++++++++++ variants/esp32s3/heltec_v4_r8/variant.h | 72 +++++++++ 8 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 boards/heltec_v4_r8.json create mode 100644 variants/esp32s3/heltec_v4_r8/pins_arduino.h create mode 100644 variants/esp32s3/heltec_v4_r8/platformio.ini create mode 100644 variants/esp32s3/heltec_v4_r8/variant.h diff --git a/boards/heltec_v4_r8.json b/boards/heltec_v4_r8.json new file mode 100644 index 00000000000..6dd97c84b22 --- /dev/null +++ b/boards/heltec_v4_r8.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "heltec_v4_r8" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "heltec_wifi_lora_32 v4 r8 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/", + "vendor": "heltec" +} diff --git a/src/configuration.h b/src/configuration.h index 0ce28ed2824..2c084174d00 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -157,6 +157,9 @@ along with this program. If not, see . #elif defined(HELTEC_MESH_NODE_T096) #define NUM_PA_POINTS 22 #define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7 +#elif defined(HELTEC_V4_R8) +#define NUM_PA_POINTS 22 +#define TX_GAIN_LORA 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7 #else // If a board enables USE_KCT8103L_PA but does not match a known variant and has // not already provided a PA curve, fail at compile time to avoid unsafe defaults. diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 005ead292c6..b69d7948345 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -428,7 +428,7 @@ static LGFX *tft = nullptr; #elif defined(ST7789_CS) #include // Graphics and font library for ST7735 driver chip -#ifdef HELTEC_V4_TFT +#if defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) #include "chsc6x.h" #include "lgfx/v1/Touch.hpp" namespace lgfx @@ -450,7 +450,11 @@ class TOUCH_CHSC6X : public ITouch bool init(void) override { if (chsc6xTouch == nullptr) { +#if (TOUCH_I2C_PORT == 1) chsc6xTouch = new chsc6x(&Wire1, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN); +#else + chsc6xTouch = new chsc6x(&Wire, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN); +#endif } chsc6xTouch->chsc6x_init(); return true; @@ -487,7 +491,7 @@ class LGFX : public lgfx::LGFX_Device #if HAS_TOUCHSCREEN #if defined(T_WATCH_S3) || defined(ELECROW) lgfx::Touch_FT5x06 _touch_instance; -#elif defined(HELTEC_V4_TFT) +#elif defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) lgfx::TOUCH_CHSC6X _touch_instance; #else lgfx::Touch_GT911 _touch_instance; @@ -506,7 +510,11 @@ class LGFX : public lgfx::LGFX_Device cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing // 80MHz by an integer) cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving - cfg.spi_3wire = false; +#ifdef SPI_3_WIRE + cfg.spi_3wire = SPI_3_WIRE; +#else + cfg.spi_3wire = true; // Set to true if reception is done on the MOSI pin +#endif cfg.use_lock = true; // Set to true to use transaction locking cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch / // SPI_DMA_CH_AUTO=auto setting) @@ -556,8 +564,11 @@ class LGFX : public lgfx::LGFX_Device cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped cfg.dlen_16bit = false; // Set to true for panels that transmit data length in 16-bit units with 16-bit parallel or SPI +#if defined(HAS_SDCARD) cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.) - +#else + cfg.bus_shared = false; +#endif // Set the following only when the display is shifted with a driver with a variable number of pixels, such as the // ST7735 or ILI9163. // cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 33500830da8..4b79882bddc 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -688,7 +688,7 @@ 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(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)) && \ HAS_TFT // switch BT off by default; use TFT programming mode or hotkey to enable config.bluetooth.enabled = false; diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 5a5004a456a..103cac94175 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -89,8 +89,10 @@ build_flags = -D VIEW_240x320 -D DISPLAY_SET_RESOLUTION -D DISPLAY_SIZE=240x320 ; portrait mode + -D LGFX_SPI_3WIRE=true -D LGFX_PIN_SCK=17 -D LGFX_PIN_MOSI=33 + -D LGFX_PIN_MISO=-1 -D LGFX_PIN_DC=16 -D LGFX_PIN_CS=15 -D LGFX_PIN_BL=21 @@ -123,7 +125,7 @@ build_flags = -D SCREEN_TRANSITION_FRAMERATE=5 -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness -D HAS_TOUCHSCREEN=1 - -D TOUCH_I2C_PORT=0 + -D TOUCH_I2C_PORT=1 -D TOUCH_SLAVE_ADDRESS=0x2E -D SCREEN_TOUCH_INT=TOUCH_INT_PIN -D SCREEN_TOUCH_RST=TOUCH_RST_PIN diff --git a/variants/esp32s3/heltec_v4_r8/pins_arduino.h b/variants/esp32s3/heltec_v4_r8/pins_arduino.h new file mode 100644 index 00000000000..631f0751339 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/pins_arduino.h @@ -0,0 +1,56 @@ +#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 = 17; +static const uint8_t SCL = 18; + +static const uint8_t SS = 8; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 11; +static const uint8_t SCK = 9; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini new file mode 100644 index 00000000000..747cc8c49d2 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -0,0 +1,145 @@ +[heltec_v4_r8_base] +extends = esp32s3_base +board = heltec_v4_r8 +board_check = true +board_build.partitions = default_16MB.csv +build_flags = + ${esp32s3_base.build_flags} + -D HELTEC_V4_R8 + -D HAS_LORA_FEM=1 + -D BOARD_HAS_PSRAM + -I variants/esp32s3/heltec_v4_r8 + -ULED_BUILTIN + +[env:heltec-v4-r8-oled] +custom_meshtastic_hw_model = 132 +custom_meshtastic_hw_model_slug = HELTEC_V4_R8 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 R8 +custom_meshtastic_images = heltec_v4_r8.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = heltec_v4_r8_base +build_flags = + ${heltec_v4_r8_base.build_flags} + -D HELTEC_V4_R8_OLED + -D USE_SSD1306 ; Heltec_v4_R8 has an SSD1315 display (compatible with SSD1306 driver) + -D LED_POWER=46 + -D RESET_OLED=21 + -D I2C_SDA=17 + -D I2C_SCL=18 + +[env:heltec-v4-r8-tft] +custom_meshtastic_hw_model = 132 +custom_meshtastic_hw_model_slug = HELTEC_V4_R8 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 R8 TFT +custom_meshtastic_images = heltec_v4_r8_tft.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = heltec_v4_r8_base +build_flags = + ${heltec_v4_r8_base.build_flags} ;-Os + -D HELTEC_V4_R8_TFT + -D I2C_SDA=17 + -D I2C_SCL=18 + -D PIN_BUTTON2=46 + -D ALT_BUTTON_PIN=PIN_BUTTON2 + -D ALT_BUTTON_ACTIVE_LOW=false + -D PIN_BUZZER=4 + -D USE_PIN_BUZZER=PIN_BUZZER + -D CONFIG_ARDUHAL_LOG_COLORS + -D RADIOLIB_DEBUG_SPI=0 + -D RADIOLIB_DEBUG_PROTOCOL=0 + -D RADIOLIB_DEBUG_BASIC=0 + -D RADIOLIB_VERBOSE_ASSERT=0 + -D RADIOLIB_SPI_PARANOID=0 + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D INPUTDRIVER_BUTTON_TYPE=0 + -D HAS_SCREEN=1 + -D HAS_TFT=1 + -D RAM_SIZE=5120 + -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 USE_PACKET_API + -D LGFX_DRIVER=LGFX_HELTEC_V4_TFT + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_HELTEC_V4_TFT.h\" + -D VIEW_240x320 + -D DISPLAY_SET_RESOLUTION + -D DISPLAY_SIZE=240x320 ; portrait mode + -D LGFX_SPI_3WIRE=false + -D LGFX_PIN_SCK=16 + -D LGFX_PIN_MOSI=15 + -D LGFX_PIN_MISO=45 + -D LGFX_PIN_DC=48 + -D LGFX_PIN_CS=47 + -D LGFX_PIN_BL=44 + -D LGFX_PIN_RST=21 + -D CUSTOM_TOUCH_DRIVER + -D TOUCH_SDA_PIN=I2C_SDA + -D TOUCH_SCL_PIN=I2C_SCL + -D TOUCH_INT_PIN=-1 + -D TOUCH_RST_PIN=-1 +;base UI + -D TFT_CS=LGFX_PIN_CS + -D ST7789_CS=TFT_CS + -D ST7789_RS=LGFX_PIN_DC + -D ST7789_SDA=LGFX_PIN_MOSI + -D ST7789_SCK=LGFX_PIN_SCK + -D ST7789_RESET=LGFX_PIN_RST + -D ST7789_MISO=LGFX_PIN_MISO + -D ST7789_BUSY=-1 + -D ST7789_BL=LGFX_PIN_BL + -D ST7789_SPI_HOST=SPI3_HOST + -D TFT_BL=ST7789_BL + -D SPI_FREQUENCY=75000000 + -D SPI_READ_FREQUENCY=SPI_FREQUENCY + -D SPI_3_WIRE=false + -D TFT_HEIGHT=320 + -D TFT_WIDTH=240 + -D TFT_OFFSET_X=0 + -D TFT_OFFSET_Y=0 + -D TFT_OFFSET_ROTATION=0 + -D SCREEN_ROTATE + -D SCREEN_TRANSITION_FRAMERATE=5 + -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness + -D HAS_TOUCHSCREEN=1 + -D TOUCH_I2C_PORT=0 + -D TOUCH_SLAVE_ADDRESS=0x2E + -D SCREEN_TOUCH_INT=TOUCH_INT_PIN + -D SCREEN_TOUCH_RST=TOUCH_RST_PIN + +; Have SPI interface SD card slot + -D HAS_SDCARD + -D SDCARD_USE_SPI1 + -D SDCARD_USER_SPI_BEGIN + -D SPI_MOSI=LGFX_PIN_MOSI + -D SPI_SCK=LGFX_PIN_SCK + -D SPI_MISO=LGFX_PIN_MISO + -D SPI_CS=3 + -D SDCARD_CS=SPI_CS + -D SD_SPI_FREQUENCY=SPI_FREQUENCY + +lib_deps = ${heltec_v4_r8_base.lib_deps} + ${device-ui_base.lib_deps} + # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.19 + # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master + https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/variant.h b/variants/esp32s3/heltec_v4_r8/variant.h new file mode 100644 index 00000000000..1f638f24cc9 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/variant.h @@ -0,0 +1,72 @@ +#define VEXT_ENABLE 40 // active low, powers the oled display and the lora antenna boost +#define VEXT_ON_VALUE LOW +#define BUTTON_PIN 0 + +#define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage +#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider +#define ADC_MULTIPLIER 4.9 * 1.035 + +#define USE_SX1262 +#define LORA_DIO0 -1 // a No connect on the SX1262 module +#define LORA_RESET 12 +#define LORA_DIO1 14 // SX1262 IRQ +#define LORA_DIO2 13 // SX1262 BUSY +#define LORA_DIO3 // Not connected on PCB, but internally on the TTGO SX1262, if DIO3 is high the TCXO is enabled + +#define LORA_SCK 9 +#define LORA_MISO 11 +#define LORA_MOSI 10 +#define LORA_CS 8 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET + +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// Enable Traffic Management Module for Heltec V4 +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif + +// ---- KCT8103L RF FRONT END CONFIGURATION ---- +// The Heltec V4.3 uses a KCT8103L FEM chip with integrated PA and LNA +// RF path: SX1262 -> Pi attenuator -> KCT8103L PA -> Antenna +// Control logic (from KCT8103L datasheet): +// Transmit PA: CSD=1, CTX=1, CPS=1 +// Receive LNA: CSD=1, CTX=0, CPS=X (21dB gain, 1.9dB NF) +// Receive bypass: CSD=1, CTX=1, CPS=0 +// Shutdown: CSD=0, CTX=X, CPS=X +// Pin mapping: +// CPS (pin 5) -> SX1262 DIO2: TX/RX path select (automatic via SX126X_DIO2_AS_RF_SWITCH) +// CSD (pin 4) -> GPIO2: Chip enable (HIGH=on, LOW=shutdown) +// CTX (pin 6) -> GPIO5: Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) +// VCC0/VCC1 -> Vfem via U3 LDO, controlled by GPIO7 +// KCT8103L FEM: TX/RX path switching is handled by DIO2 -> CPS pin (via SX126X_DIO2_AS_RF_SWITCH) + +#define USE_KCT8103L_PA +#define LORA_PA_POWER 7 // VFEM_Ctrl - KCT8103L LDO power enable +#define LORA_KCT8103L_PA_CSD 2 // CSD - KCT8103L chip enable (HIGH=on) +#define LORA_KCT8103L_PA_CTX 5 // CTX - Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) + +#if HAS_TFT +#define USE_TFTDISPLAY 1 +#endif +/* + * GPS pins + */ +#define GPS_L76K +#define PIN_GPS_EN (42) +#define GPS_EN_ACTIVE LOW +#define PERIPHERAL_WARMUP_MS 1000 // Make sure I2C QuickLink has stable power before continuing +#define PIN_GPS_PPS (41) +// Seems to be missing on this new board +#define GPS_TX_PIN (38) // This is for bits going TOWARDS the CPU +#define GPS_RX_PIN (39) // This is for bits going TOWARDS the GPS +#define GPS_THREAD_INTERVAL 50 From c0425d74448d06019f0d4bfc0a27cedeeb2f5a84 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 27 Apr 2026 14:33:19 -0400 Subject: [PATCH 101/225] Actions: Build MacOS binary (#10319) Preliminary CI for the MacOS builds Co-authored-by: Copilot --- .github/workflows/build_macos_bin.yml | 51 +++++++++++++++++++++++++++ .github/workflows/main_matrix.yml | 15 ++++++++ 2 files changed, 66 insertions(+) create mode 100644 .github/workflows/build_macos_bin.yml diff --git a/.github/workflows/build_macos_bin.yml b/.github/workflows/build_macos_bin.yml new file mode 100644 index 00000000000..cde2dd8165b --- /dev/null +++ b/.github/workflows/build_macos_bin.yml @@ -0,0 +1,51 @@ +name: Build MacOS Binary + +on: + workflow_call: + inputs: + macos_ver: + required: false + default: "26" # ARM64 + type: string + +permissions: + contents: read + +jobs: + build-MacOS: + runs-on: macos-${{ inputs.macos_ver }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install deps + shell: bash + run: | + brew update + brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config + + - name: Get release version string + run: | + echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT + id: version + + - name: Build for MacOS + run: | + platformio run -e native-macos + env: + PKG_VERSION: ${{ steps.version.outputs.long }} + # Errors in this step should not fail the entire workflow while MacOS support is in development. + continue-on-error: true + + - name: List output files + run: ls -lah .pio/build/native-macos/ + + - name: Store binaries as an artifact + uses: actions/upload-artifact@v7 + with: + name: firmware-macos-${{ inputs.macos_ver }}-${{ steps.version.outputs.long }} + overwrite: true + path: | + .pio/build/native-macos/meshtasticd diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 88395600a71..3505d950e35 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -116,6 +116,20 @@ jobs: build_location: local secrets: inherit + MacOS: + strategy: + fail-fast: false + matrix: + macos_ver: + - "26" # ARM64 + # - '26-intel' # x86_64 + - "15" # ARM64 + # - '15-intel' # x86_64 + uses: ./.github/workflows/build_macos_bin.yml + with: + macos_ver: ${{ matrix.macos_ver }} + # secrets: inherit + package-pio-deps-native-tft: if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }} uses: ./.github/workflows/package_pio_deps.yml @@ -286,6 +300,7 @@ jobs: - gather-artifacts - build-debian-src - package-pio-deps-native-tft + # - MacOS steps: - name: Checkout uses: actions/checkout@v6 From 6c7ffa105470b0b6392e93f0a5ad42dcf402691b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 28 Apr 2026 08:31:08 -0500 Subject: [PATCH 102/225] macOS: enable CH341 LoRa-hardware path (fix serial truncation, document setup) (#10320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * macOS: enable CH341 LoRa-hardware path — fix serial truncation, document setup Verified on Apple Silicon with a CH341A USB-SPI bridge (VID 0x1A86, PID 0x5512) wired to an SX1262 (Meshstick variant) that the existing `pine64/libch341-spi-userspace` lib_dep works on macOS as-is — Apple's bundled CH34x driver only matches the CH340 *UART* variant (PID 0x7523), so the CH341A's interface 0 is left unclaimed and libusb opens / configures / claims it directly via IOUSBHostInterface. End-to-end test: meshtasticd boots, libusb claim succeeds, SX1262 init returns 0, TCP API serves the meshtastic CLI's --info / --sendtext flow. Two changes: 1. **`PortduinoGlue.cpp:497`**: pass `sizeof(serial)` (= 9) instead of the literal `8` to `Ch341Hal::getSerialString()`. The function in `USBHal.h:61-68` treats `len` as buffer size and reserves one slot for the null terminator (`bytesCopied = (len - 1) < 8 ? (len - 1) : 8`), so passing 8 produced a 7-char serial — which then broke the `strlen(serial) == 8` check at line 502, skipping the auto-MAC derivation from serial + product string. On Linux this was masked by the BlueZ HCI MAC fallback in `getMacAddr()` at lines 139-157, but on macOS that fallback is `__linux__`-guarded so the serial path is mandatory and the truncation left `mac_address` empty, causing the daemon to exit with `*** Blank MAC Address not allowed!`. 2. **`variants/native/portduino/platformio.ini`**: expand the `[env:native-macos]` comment block with a "Real LoRa hardware on macOS" section. Documents: - Why no upstream library change is needed (Apple kext targets CH340/UART, not CH341A/SPI; libusb's `#ifdef __linux__` skip is correct for macOS in this case). - How to point `meshtasticd` at an existing platform-agnostic `bin/config.d/lora-*.yaml` for CH341 hardware. - The auto-MAC-derivation contract (now working with this fix). - `ioreg` and `LIBUSB_DEBUG=4` diagnostic recipes for the failure mode where a third-party WCH `CH34xVCPDriver` *would* claim interface 0 (`kmutil unload -b ` workaround). No upstream library forks, no PR chain, no additional lib_deps — the existing `pine64/libch341-spi-userspace` + libusb-1.0 stack does the right thing on macOS already. Co-Authored-By: Claude Opus 4.7 (1M context) * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/platform/portduino/PortduinoGlue.cpp | 11 ++++++-- variants/native/portduino/platformio.ini | 33 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index fd26926d903..0f5b10a0784 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -494,10 +494,17 @@ void portduinoSetup() exit(EXIT_FAILURE); } char serial[9] = {0}; - ch341Hal->getSerialString(serial, 8); + // Pass the full buffer size (9 = 8 chars + null) to getSerialString, + // not 8. The function treats `len` as buffer size and reserves one + // slot for the null terminator, so passing 8 produced a 7-char serial + // and broke the `strlen(serial) == 8` check below — masked on Linux + // by the BlueZ HCI MAC fallback in getMacAddr(), but on macOS (where + // the BlueZ path is __linux__-guarded) it left mac_address empty and + // meshtasticd refused to start. + ch341Hal->getSerialString(serial, sizeof(serial)); std::cout << "CH341 Serial " << serial << std::endl; char product_string[96] = {0}; - ch341Hal->getProductString(product_string, 95); + ch341Hal->getProductString(product_string, sizeof(product_string)); std::cout << "CH341 Product " << product_string << std::endl; if (strlen(serial) == 8 && portduino_config.mac_address.length() < 12) { std::cout << "Deriving MAC address from Serial and Product String" << std::endl; diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index c497d0c179f..e493da77b6a 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -133,6 +133,39 @@ test_testing_command = ; SOCK_NONBLOCK fallback, Common.h __APPLE__ guard, WiFiServer.cpp extern-C ; fix, package.json URL refresh. Pulled by platform-native at its pinned commit. ; This env therefore only carries the firmware-side build flags and src filter. +; +; Real LoRa hardware on macOS: +; The same lib_dep `pine64/libch341-spi-userspace` used on Linux works on +; macOS as-is — its `libusb_detach_kernel_driver()` call is `__linux__`- +; guarded, but on macOS the kernel doesn't bind a driver to a CH341A SPI +; bridge (PID 0x5512; bDeviceClass=0xff vendor-specific) by default, so +; no detach is needed. Apple's bundled CH34x driver targets the CH340 +; *UART* variant (PID 0x7523) — different product. libusb opens the device +; and claims interface 0 directly via IOUSBHostInterface. +; +; To use, point `meshtasticd` at any of the existing `bin/config.d/lora-*.yaml` +; files that specify `spidev: ch341` — they're platform-agnostic. Example: +; pio run -e native-macos +; mkdir -p ~/.meshtasticd && cp bin/config-dist.yaml ~/.meshtasticd/config.yaml +; # Edit ~/.meshtasticd/config.yaml: ConfigDirectory: ./config.d/ +; mkdir ~/.meshtasticd/config.d && cp bin/config.d/lora-meshstick-1262.yaml ~/.meshtasticd/config.d/ +; cd ~/.meshtasticd && /path/to/firmware/.pio/build/native-macos/meshtasticd +; +; The MAC address auto-derives from the CH341's USB serial + product string +; (PortduinoGlue.cpp ~497-518); on Linux a BlueZ HCI socket is the fallback +; when that path isn't taken, but BlueZ is `__linux__`-guarded so the +; serial-derivation path is mandatory on macOS. Override with +; `MACAddress: AA:BB:CC:DD:EE:FF` in config.yaml's `General:` section if +; the device's serial isn't 8 hex chars. +; +; Diagnosing CH341 issues on macOS: +; ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512 +; Children should be `IOUSBHostInterface`. If a vendor driver class +; (e.g. `com.wch.CH34xVCPDriver` from a third-party WCH installer) +; claims interface 0, libusb will fail with LIBUSB_ERROR_BUSY. +; Workaround: `sudo kmutil unload -b `. +; LIBUSB_DEBUG=4 .pio/build/native-macos/meshtasticd +; Verbose libusb trace — useful when claim_interface fails. ; --------------------------------------------------------------------------- [env:native-macos] extends = native_base From 9c72767c0133361131f27ca039455a81ec8d4a1e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 28 Apr 2026 08:31:08 -0500 Subject: [PATCH 103/225] macOS: enable CH341 LoRa-hardware path (fix serial truncation, document setup) (#10320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * macOS: enable CH341 LoRa-hardware path — fix serial truncation, document setup Verified on Apple Silicon with a CH341A USB-SPI bridge (VID 0x1A86, PID 0x5512) wired to an SX1262 (Meshstick variant) that the existing `pine64/libch341-spi-userspace` lib_dep works on macOS as-is — Apple's bundled CH34x driver only matches the CH340 *UART* variant (PID 0x7523), so the CH341A's interface 0 is left unclaimed and libusb opens / configures / claims it directly via IOUSBHostInterface. End-to-end test: meshtasticd boots, libusb claim succeeds, SX1262 init returns 0, TCP API serves the meshtastic CLI's --info / --sendtext flow. Two changes: 1. **`PortduinoGlue.cpp:497`**: pass `sizeof(serial)` (= 9) instead of the literal `8` to `Ch341Hal::getSerialString()`. The function in `USBHal.h:61-68` treats `len` as buffer size and reserves one slot for the null terminator (`bytesCopied = (len - 1) < 8 ? (len - 1) : 8`), so passing 8 produced a 7-char serial — which then broke the `strlen(serial) == 8` check at line 502, skipping the auto-MAC derivation from serial + product string. On Linux this was masked by the BlueZ HCI MAC fallback in `getMacAddr()` at lines 139-157, but on macOS that fallback is `__linux__`-guarded so the serial path is mandatory and the truncation left `mac_address` empty, causing the daemon to exit with `*** Blank MAC Address not allowed!`. 2. **`variants/native/portduino/platformio.ini`**: expand the `[env:native-macos]` comment block with a "Real LoRa hardware on macOS" section. Documents: - Why no upstream library change is needed (Apple kext targets CH340/UART, not CH341A/SPI; libusb's `#ifdef __linux__` skip is correct for macOS in this case). - How to point `meshtasticd` at an existing platform-agnostic `bin/config.d/lora-*.yaml` for CH341 hardware. - The auto-MAC-derivation contract (now working with this fix). - `ioreg` and `LIBUSB_DEBUG=4` diagnostic recipes for the failure mode where a third-party WCH `CH34xVCPDriver` *would* claim interface 0 (`kmutil unload -b ` workaround). No upstream library forks, no PR chain, no additional lib_deps — the existing `pine64/libch341-spi-userspace` + libusb-1.0 stack does the right thing on macOS already. Co-Authored-By: Claude Opus 4.7 (1M context) * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/platform/portduino/PortduinoGlue.cpp | 11 ++++++-- variants/native/portduino/platformio.ini | 33 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 6f807772071..fbaa3c98cb7 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -490,10 +490,17 @@ void portduinoSetup() exit(EXIT_FAILURE); } char serial[9] = {0}; - ch341Hal->getSerialString(serial, 8); + // Pass the full buffer size (9 = 8 chars + null) to getSerialString, + // not 8. The function treats `len` as buffer size and reserves one + // slot for the null terminator, so passing 8 produced a 7-char serial + // and broke the `strlen(serial) == 8` check below — masked on Linux + // by the BlueZ HCI MAC fallback in getMacAddr(), but on macOS (where + // the BlueZ path is __linux__-guarded) it left mac_address empty and + // meshtasticd refused to start. + ch341Hal->getSerialString(serial, sizeof(serial)); std::cout << "CH341 Serial " << serial << std::endl; char product_string[96] = {0}; - ch341Hal->getProductString(product_string, 95); + ch341Hal->getProductString(product_string, sizeof(product_string)); std::cout << "CH341 Product " << product_string << std::endl; if (strlen(serial) == 8 && portduino_config.mac_address.length() < 12) { std::cout << "Deriving MAC address from Serial and Product String" << std::endl; diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index c497d0c179f..e493da77b6a 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -133,6 +133,39 @@ test_testing_command = ; SOCK_NONBLOCK fallback, Common.h __APPLE__ guard, WiFiServer.cpp extern-C ; fix, package.json URL refresh. Pulled by platform-native at its pinned commit. ; This env therefore only carries the firmware-side build flags and src filter. +; +; Real LoRa hardware on macOS: +; The same lib_dep `pine64/libch341-spi-userspace` used on Linux works on +; macOS as-is — its `libusb_detach_kernel_driver()` call is `__linux__`- +; guarded, but on macOS the kernel doesn't bind a driver to a CH341A SPI +; bridge (PID 0x5512; bDeviceClass=0xff vendor-specific) by default, so +; no detach is needed. Apple's bundled CH34x driver targets the CH340 +; *UART* variant (PID 0x7523) — different product. libusb opens the device +; and claims interface 0 directly via IOUSBHostInterface. +; +; To use, point `meshtasticd` at any of the existing `bin/config.d/lora-*.yaml` +; files that specify `spidev: ch341` — they're platform-agnostic. Example: +; pio run -e native-macos +; mkdir -p ~/.meshtasticd && cp bin/config-dist.yaml ~/.meshtasticd/config.yaml +; # Edit ~/.meshtasticd/config.yaml: ConfigDirectory: ./config.d/ +; mkdir ~/.meshtasticd/config.d && cp bin/config.d/lora-meshstick-1262.yaml ~/.meshtasticd/config.d/ +; cd ~/.meshtasticd && /path/to/firmware/.pio/build/native-macos/meshtasticd +; +; The MAC address auto-derives from the CH341's USB serial + product string +; (PortduinoGlue.cpp ~497-518); on Linux a BlueZ HCI socket is the fallback +; when that path isn't taken, but BlueZ is `__linux__`-guarded so the +; serial-derivation path is mandatory on macOS. Override with +; `MACAddress: AA:BB:CC:DD:EE:FF` in config.yaml's `General:` section if +; the device's serial isn't 8 hex chars. +; +; Diagnosing CH341 issues on macOS: +; ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512 +; Children should be `IOUSBHostInterface`. If a vendor driver class +; (e.g. `com.wch.CH34xVCPDriver` from a third-party WCH installer) +; claims interface 0, libusb will fail with LIBUSB_ERROR_BUSY. +; Workaround: `sudo kmutil unload -b `. +; LIBUSB_DEBUG=4 .pio/build/native-macos/meshtasticd +; Verbose libusb trace — useful when claim_interface fails. ; --------------------------------------------------------------------------- [env:native-macos] extends = native_base From 8d8ff21e7c4b0735fe72cc537021516932651e8c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 28 Apr 2026 19:03:50 -0500 Subject: [PATCH 104/225] Add clamping logic for milliseconds conversion and unit tests (#10326) * Add clamping logic for milliseconds conversion and unit tests * Simplify comments in secondsToMsClamped function Removed detailed comments about seconds to milliseconds conversion. --- src/mesh/Default.cpp | 26 +++++++++----- test/test_default/test_main.cpp | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/mesh/Default.cpp b/src/mesh/Default.cpp index 3ecd766f109..7a2d9e410d2 100644 --- a/src/mesh/Default.cpp +++ b/src/mesh/Default.cpp @@ -2,18 +2,21 @@ #include "meshUtils.h" +// Convert seconds to ms, clamping at INT32_MAX (~24.86 days) +static inline uint32_t secondsToMsClamped(uint32_t secs) +{ + constexpr uint32_t MAX_MS = static_cast(INT32_MAX); + return (secs > MAX_MS / 1000U) ? MAX_MS : secs * 1000U; +} + uint32_t Default::getConfiguredOrDefaultMs(uint32_t configuredInterval, uint32_t defaultInterval) { - if (configuredInterval > 0) - return configuredInterval * 1000; - return defaultInterval * 1000; + return secondsToMsClamped(configuredInterval > 0 ? configuredInterval : defaultInterval); } uint32_t Default::getConfiguredOrDefaultMs(uint32_t configuredInterval) { - if (configuredInterval > 0) - return configuredInterval * 1000; - return default_broadcast_interval_secs * 1000; + return secondsToMsClamped(configuredInterval > 0 ? configuredInterval : default_broadcast_interval_secs); } uint32_t Default::getConfiguredOrDefault(uint32_t configured, uint32_t defaultValue) @@ -47,7 +50,14 @@ uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t d meshtastic_Config_DeviceConfig_Role_TAK_TRACKER)) return getConfiguredOrDefaultMs(configured, defaultValue); - return getConfiguredOrDefaultMs(configured, defaultValue) * congestionScalingCoefficient(numOnlineNodes); + // Saturate at INT32_MAX to match secondsToMsClamped: float→uint32_t when + // out of range is UB, and the result is consumed as an int32_t downstream. + constexpr uint32_t MAX_MS = static_cast(INT32_MAX); + uint32_t base = getConfiguredOrDefaultMs(configured, defaultValue); + float coef = congestionScalingCoefficient(numOnlineNodes); + if (static_cast(base) * static_cast(coef) >= static_cast(MAX_MS)) + return MAX_MS; + return base * coef; } uint32_t Default::getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue) @@ -66,4 +76,4 @@ uint8_t Default::getConfiguredOrDefaultHopLimit(uint8_t configured) #else return (configured >= HOP_MAX) ? HOP_MAX : config.lora.hop_limit; #endif -} \ No newline at end of file +} diff --git a/test/test_default/test_main.cpp b/test/test_default/test_main.cpp index 9da3678971e..4202d7b8da2 100644 --- a/test/test_default/test_main.cpp +++ b/test/test_default/test_main.cpp @@ -127,6 +127,60 @@ void test_client_uses_public_channel_minimums() TEST_ASSERT_EQUAL_UINT32(60 * 60, position); } +// --- Saturation/clamp tests for getConfiguredOrDefaultMs[Scaled] --- +// These guard the INT32_MAX clamp added to avoid uint32 wrap of secs*1000 and +// to keep results safe to cast to int32_t for OSThread runOnce returns. + +void test_ms_below_threshold() +{ + // Ordinary value passes through unchanged. + TEST_ASSERT_EQUAL_UINT32(60000U, Default::getConfiguredOrDefaultMs(60, 0)); +} + +void test_ms_at_threshold() +{ + // INT32_MAX / 1000 = 2,147,483 — largest secs that does not clamp. + TEST_ASSERT_EQUAL_UINT32(2147483000U, Default::getConfiguredOrDefaultMs(2147483U, 0)); +} + +void test_ms_just_above_threshold() +{ + // One second over the boundary must saturate, not wrap. + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), Default::getConfiguredOrDefaultMs(2147484U, 0)); +} + +void test_ms_uint32_max() +{ + // default_sds_secs == UINT32_MAX on non-routers must not wrap. + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), Default::getConfiguredOrDefaultMs(UINT32_MAX, 0)); +} + +void test_ms_default_clamps() +{ + // Clamp also applies when the default-arg path is taken (configured == 0). + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), Default::getConfiguredOrDefaultMs(0, UINT32_MAX)); +} + +void test_ms_result_is_int32_safe() +{ + // Regression guard for runOnce returns: cast to int32_t must not go negative. + int32_t result = static_cast(Default::getConfiguredOrDefaultMs(UINT32_MAX, 0)); + TEST_ASSERT_GREATER_OR_EQUAL_INT32(0, result); +} + +void test_scaled_overflow_saturates() +{ + // long_fast (SF11/BW250) with a 24h base and heavy congestion overflows + // the uint32 result without the double-precision guard. Must saturate. + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.lora.use_preset = false; + config.lora.spread_factor = 11; + config.lora.bandwidth = 250; + + uint32_t res = Default::getConfiguredOrDefaultMsScaled(0, ONE_DAY, 1000); + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), res); +} + void setup() { // Small delay to match other test mains @@ -140,6 +194,13 @@ void setup() RUN_TEST(test_router_uses_router_minimums); RUN_TEST(test_router_late_uses_router_minimums); RUN_TEST(test_client_uses_public_channel_minimums); + RUN_TEST(test_ms_below_threshold); + RUN_TEST(test_ms_at_threshold); + RUN_TEST(test_ms_just_above_threshold); + RUN_TEST(test_ms_uint32_max); + RUN_TEST(test_ms_default_clamps); + RUN_TEST(test_ms_result_is_int32_safe); + RUN_TEST(test_scaled_overflow_saturates); exit(UNITY_END()); } From 11df30a85fd7860535c8bd45728849efeb649ceb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:04:16 -0500 Subject: [PATCH 105/225] Upgrade trunk (#10324) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 178a1cc9e98..41bb110a9ca 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,7 +9,7 @@ plugins: lint: enabled: - checkov@3.2.525 - - renovate@43.142.0 + - renovate@43.147.0 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 From c0e52e6e1c2a5e5aaa960de853f5f9f917084ec5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:10:17 -0500 Subject: [PATCH 106/225] Update meshtastic/device-ui digest to 1ddcc9d (#10328) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 3f8f77228cf..9348d97d9a4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -126,7 +126,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/728932970996ec91bdb93cb6dae29c2cb70c66e2.zip + https://github.com/meshtastic/device-ui/archive/1ddcc9da2e60c013d6fc515fb73fb63fac75f9fd.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From 22a9346fe03a8c049bf6a9a2377a8b39d6b63bf9 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 12:16:25 -0400 Subject: [PATCH 107/225] Debian: Correctly fail upon failure (#10341) Fake success is BS! We should fail when we fail. Fixes issues with Debian sourcedebs silently failing to build ocassionally (due to github 502s, etc). --- debian/ci_pack_sdeb.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/debian/ci_pack_sdeb.sh b/debian/ci_pack_sdeb.sh index 7b2418ff671..d35aeef24e3 100755 --- a/debian/ci_pack_sdeb.sh +++ b/debian/ci_pack_sdeb.sh @@ -1,4 +1,5 @@ #!/usr/bin/bash +set -e export DEBEMAIL="jbennett@incomsystems.biz" export PLATFORMIO_LIBDEPS_DIR=pio/libdeps export PLATFORMIO_PACKAGES_DIR=pio/packages From 9ec63b5eb2c236457ce307c896e98f00837a82c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:55:48 -0500 Subject: [PATCH 108/225] Upgrade trunk (#10336) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 41bb110a9ca..77ee7ecc327 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,7 +9,7 @@ plugins: lint: enabled: - checkov@3.2.525 - - renovate@43.147.0 + - renovate@43.150.0 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 From 7be5426f343e40272146086830a8686f0f144a58 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 14:00:01 -0400 Subject: [PATCH 109/225] Do not FACTORY_INSTALL on ARCH_PORTDUINO (#10343) --- src/mesh/NodeDB.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 4b79882bddc..b526bc86903 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1205,7 +1205,7 @@ void NodeDB::loadFromDisk() spiLock->unlock(); #endif #ifdef FSCom -#ifdef FACTORY_INSTALL +#if defined(FACTORY_INSTALL) && !defined(ARCH_PORTDUINO) spiLock->lock(); if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) { LOG_WARN("Factory Install Reset!"); From 089af764ec04f715fa3bc60d0fb6e6a30ed4fcb8 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 29 Apr 2026 16:41:21 -0500 Subject: [PATCH 110/225] Replace FSCom.format() with FSCom.rmDir() for directory cleanup in NodeDB::loadFromDisk() --- src/mesh/NodeDB.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index b526bc86903..a0212794f63 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1209,7 +1209,7 @@ void NodeDB::loadFromDisk() spiLock->lock(); if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) { LOG_WARN("Factory Install Reset!"); - FSCom.format(); + FSCom.rmDir("/prefs"); FSCom.mkdir("/prefs"); File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE); if (f2) { From 195f42af826634acf3120252c64e8255de03c7c4 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 29 Apr 2026 16:57:21 -0500 Subject: [PATCH 111/225] Doesn't FSCom --- src/mesh/NodeDB.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index a0212794f63..6d13952e587 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1209,7 +1209,7 @@ void NodeDB::loadFromDisk() spiLock->lock(); if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) { LOG_WARN("Factory Install Reset!"); - FSCom.rmDir("/prefs"); + rmDir("/prefs"); FSCom.mkdir("/prefs"); File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE); if (f2) { From ad23c42fcc9531509f8cf674b738a3b7b193ec07 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:09:21 -0500 Subject: [PATCH 112/225] Update meshtastic/device-ui digest to 4bf593a (#10346) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 9348d97d9a4..71c362a79ed 100644 --- a/platformio.ini +++ b/platformio.ini @@ -126,7 +126,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/1ddcc9da2e60c013d6fc515fb73fb63fac75f9fd.zip + https://github.com/meshtastic/device-ui/archive/4bf593a82100b911ff816dddf7158ffdee2114cd.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From 478444eb02836fd5c6ae0e5887840994c9679289 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 20:31:59 -0400 Subject: [PATCH 113/225] Docker-Alpine: Align version between build/main stages (#10347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FROM python:3.14-alpine3.23 AS builder FROM alpine:3.23 the alpine version needs to match in both stages 😅 --- alpine.Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/alpine.Dockerfile b/alpine.Dockerfile index 75c9aa594d0..40a4990bb8b 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -3,7 +3,8 @@ # trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions -FROM python:3.14-alpine3.22 AS builder +# Ensure the Alpine version is updated in both stages of the container! +FROM python:3.14-alpine3.23 AS builder ARG PIO_ENV=native ENV PIP_ROOT_USER_ACTION=ignore @@ -60,4 +61,4 @@ EXPOSE 4403 CMD [ "sh", "-cx", "meshtasticd --fsdir=/var/lib/meshtasticd" ] -HEALTHCHECK NONE \ No newline at end of file +HEALTHCHECK NONE From 3a87fc82c088b5ab4d06958af762bbd755676f17 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 29 Apr 2026 19:54:05 -0500 Subject: [PATCH 114/225] Add documentation for macOS support in Copilot and Agent instructions --- .github/copilot-instructions.md | 4 +++- AGENTS.md | 24 +++++++++++++----------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2d74571021c..29d5f6b000a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,7 @@ Meshtastic is an open-source LoRa mesh networking project for long-range, low-po - **RP2040/RP2350** - Raspberry Pi Pico variants - **STM32WL** - STM32 with integrated LoRa - **Linux/Portduino** - Native Linux builds (Raspberry Pi, etc.) +- **macOS native** - Headless `meshtasticd` on Apple Silicon / x86_64; see `variants/native/portduino/platformio.ini` for Homebrew prereqs + CH341 LoRa setup ### Supported Radio Chips @@ -369,7 +370,7 @@ To reduce avoidable agent mistakes, assume these tools are available (or install - **Required CLI basics**: `bash`, `git`, `find`, `grep`, `sed`, `awk`, `xargs` - **Strongly recommended**: `rg` (ripgrep) for fast file/text search, `jq` for JSON processing - **Build/test tools**: `python3`, `pip`, virtualenv (`python3 -m venv`), `platformio` (`pio`) -- **Containerized native testing**: `docker` (especially important on macOS / non-Linux hosts) +- **Containerized native testing**: `docker` (fallback for non-Linux hosts; macOS can also build natively via `pio run -e native-macos`) Fallback expectations for agents: @@ -388,6 +389,7 @@ Build commands: pio run -e tbeam # Build specific target pio run -e tbeam -t upload # Build and upload pio run -e native # Build native/Linux version +pio run -e native-macos # Build headless macOS meshtasticd (Homebrew prereqs in variants/native/portduino/platformio.ini) ``` ### Build Manifest diff --git a/AGENTS.md b/AGENTS.md index 8f34746403f..ca6794322ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,17 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don ## Quick command reference -| Action | Command | -| -------------------------------- | ----------------------------------------------------------------------------------- | -| Build a firmware variant | `pio run -e ` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) | -| Clean + rebuild | `pio run -e -t clean && pio run -e ` | -| Flash a device | `pio run -e -t upload --upload-port ` (or use the `pio_flash` MCP tool) | -| Run firmware unit tests (native) | `pio test -e native` | -| Run MCP hardware tests | `./mcp-server/run-tests.sh` | -| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` | -| Format before commit | `trunk fmt` | -| Regenerate protobuf bindings | `bin/regen-protos.sh` | -| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` | +| Action | Command | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Build a firmware variant | `pio run -e ` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) | +| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) | +| Clean + rebuild | `pio run -e -t clean && pio run -e ` | +| Flash a device | `pio run -e -t upload --upload-port ` (or use the `pio_flash` MCP tool) | +| Run firmware unit tests (native) | `pio test -e native` | +| Run MCP hardware tests | `./mcp-server/run-tests.sh` | +| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` | +| Format before commit | `trunk fmt` | +| Regenerate protobuf bindings | `bin/regen-protos.sh` | +| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` | ## MCP server (device + test automation) @@ -121,6 +122,7 @@ Sequence these; don't parallelize on the same port. - **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access. - **Port busy?** `lsof ` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it. - **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned. +- **macOS: `LIBUSB_ERROR_BUSY` on a CH341 LoRa adapter?** A third-party WCH `CH34xVCPDriver` is claiming interface 0. Find the bundle ID with `ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512`, then `sudo kmutil unload -b `. Apple's bundled CH34x kext targets the CH340 UART (PID 0x7523), not the SPI bridge — it's never the culprit. ## Environment variables (test harness) From 24d64a0013b62abdac05c471f58ffec025c8396f Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 22:04:49 -0400 Subject: [PATCH 115/225] Docker: Build for riscv64 (#10345) Upstream support has been added in Debian and Alpine. Only build as part of `docker_manifest` (Beta/Alpha/Daily) releases, because these will take a **while** thanks to qemu. Co-authored-by: Copilot --- .github/workflows/docker_build.yml | 4 +++- .github/workflows/docker_manifest.yml | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index d9b23a7e810..8a3ef0e6cd7 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -73,7 +73,9 @@ jobs: - name: Sanitize platform string id: sanitize_platform # Replace slashes with underscores - run: echo "cleaned_platform=${{ inputs.platform }}" | sed 's/\//_/g' >> $GITHUB_OUTPUT + env: + plat: ${{ inputs.platform }} + run: echo "cleaned_platform=${plat}" | sed 's/\//_/g' >> $GITHUB_OUTPUT - name: Docker login if: ${{ inputs.push }} diff --git a/.github/workflows/docker_manifest.yml b/.github/workflows/docker_manifest.yml index b2fd1259914..4bfdfe37e47 100644 --- a/.github/workflows/docker_manifest.yml +++ b/.github/workflows/docker_manifest.yml @@ -43,6 +43,15 @@ jobs: push: true secrets: inherit + docker-debian-riscv64: + uses: ./.github/workflows/docker_build.yml + with: + distro: debian + platform: linux/riscv64 + runs-on: ubuntu-24.04-arm + push: true + secrets: inherit + docker-alpine-amd64: uses: ./.github/workflows/docker_build.yml with: @@ -70,16 +79,27 @@ jobs: push: true secrets: inherit + docker-alpine-riscv64: + uses: ./.github/workflows/docker_build.yml + with: + distro: alpine + platform: linux/riscv64 + runs-on: ubuntu-24.04-arm + push: true + secrets: inherit + docker-manifest: needs: # Debian - docker-debian-amd64 - docker-debian-arm64 - docker-debian-armv7 + - docker-debian-riscv64 # Alpine - docker-alpine-amd64 - docker-alpine-arm64 - docker-alpine-armv7 + - docker-alpine-riscv64 runs-on: ubuntu-24.04 steps: - name: Checkout code @@ -162,6 +182,7 @@ jobs: meshtastic/meshtasticd@${{ needs.docker-debian-amd64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-debian-arm64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-debian-armv7.outputs.digest }} + meshtastic/meshtasticd@${{ needs.docker-debian-riscv64.outputs.digest }} - name: Docker meta (Alpine) id: meta_alpine @@ -182,3 +203,4 @@ jobs: meshtastic/meshtasticd@${{ needs.docker-alpine-amd64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-alpine-arm64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-alpine-armv7.outputs.digest }} + meshtastic/meshtasticd@${{ needs.docker-alpine-riscv64.outputs.digest }} From e19f531059952e7e9e0bc5e54a163abb384fac20 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:05:16 -0400 Subject: [PATCH 116/225] Update Screen.cpp (#10344) --- src/graphics/Screen.cpp | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index e8a7f685e9c..d02938df9d6 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -524,6 +524,11 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) delay(100); #endif #if !ARCH_PORTDUINO +#if defined(USE_ST7789) && defined(VTFT_CTRL) + // Ensure panel power rail is enabled before sending wake commands. + pinMode(VTFT_CTRL, OUTPUT); + digitalWrite(VTFT_CTRL, LOW); +#endif dispdev->displayOn(); #endif @@ -545,10 +550,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) ui->init(); #endif #if defined(USE_ST7789) && defined(VTFT_LEDA) -#ifdef VTFT_CTRL - pinMode(VTFT_CTRL, OUTPUT); - digitalWrite(VTFT_CTRL, LOW); -#endif ui->init(); #ifdef ESP_PLATFORM analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT); @@ -589,23 +590,22 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) #endif #ifdef USE_ST7789 SPI1.end(); -#if defined(ARCH_ESP32) + // Keep TFT control pins in deterministic states while timed-off. + // Floating/default pin states can corrupt panel edge rows on wake. #ifdef VTFT_LEDA - pinMode(VTFT_LEDA, ANALOG); + pinMode(VTFT_LEDA, OUTPUT); + digitalWrite(VTFT_LEDA, !TFT_BACKLIGHT_ON); #endif #ifdef VTFT_CTRL - pinMode(VTFT_CTRL, ANALOG); -#endif - pinMode(ST7789_RESET, ANALOG); - pinMode(ST7789_RS, ANALOG); - pinMode(ST7789_NSS, ANALOG); -#else - nrf_gpio_cfg_default(VTFT_LEDA); - nrf_gpio_cfg_default(VTFT_CTRL); - nrf_gpio_cfg_default(ST7789_RESET); - nrf_gpio_cfg_default(ST7789_RS); - nrf_gpio_cfg_default(ST7789_NSS); -#endif + pinMode(VTFT_CTRL, OUTPUT); + digitalWrite(VTFT_CTRL, HIGH); +#endif + pinMode(ST7789_RESET, OUTPUT); + digitalWrite(ST7789_RESET, HIGH); + pinMode(ST7789_RS, OUTPUT); + digitalWrite(ST7789_RS, HIGH); + pinMode(ST7789_NSS, OUTPUT); + digitalWrite(ST7789_NSS, HIGH); #endif #ifdef USE_ST7796 SPI1.end(); From a0951f23c3d5fa369d2bb0b5a64b8ed8f408470f Mon Sep 17 00:00:00 2001 From: Joe <85746415+WB3IHY@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:00:50 -0400 Subject: [PATCH 117/225] fix: MQTT connection on Portduino/Linux native nodes (#10330) isConnectedToNetwork() always returned false on ARCH_PORTDUINO because none of HAS_WIFI, HAS_ETHERNET, or USE_WS5500 are defined for Linux native builds. This caused wantsLink() to always return false, preventing the MQTT thread from ever connecting at boot. Fix: return true for ARCH_PORTDUINO since Linux always has network access available. Co-authored-by: Jonathan Bennett --- src/mqtt/MQTT.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 283fcffb16d..97636b60155 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -350,6 +350,8 @@ inline bool isConnectedToNetwork() return WiFi.isConnected(); #elif HAS_ETHERNET return Ethernet.linkStatus() == LinkON; +#elif defined(ARCH_PORTDUINO) + return true; #else return false; #endif From 83adfd417a080503afb092067c5004068a094072 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 06:39:52 -0500 Subject: [PATCH 118/225] Upgrade trunk (#10354) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 77ee7ecc327..1913c6604d8 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -4,7 +4,7 @@ cli: plugins: sources: - id: trunk - ref: v1.7.6 + ref: v1.8.0 uri: https://github.com/trunk-io/plugins lint: enabled: @@ -36,7 +36,7 @@ lint: - bin/** runtimes: enabled: - - python@3.10.8 + - python@3.14.4 - go@1.21.0 - node@22.16.0 actions: From 173ac58ed70ab7dc74fad11686a59246de79ef06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:45:20 -0500 Subject: [PATCH 119/225] Update protobufs (#10357) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/apponly.pb.h | 2 +- src/mesh/generated/meshtastic/config.pb.h | 12 +- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/mesh/generated/meshtastic/localonly.pb.h | 2 +- .../generated/meshtastic/serial_hal.pb.cpp | 19 +++ src/mesh/generated/meshtastic/serial_hal.pb.h | 135 ++++++++++++++++++ 7 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 src/mesh/generated/meshtastic/serial_hal.pb.cpp create mode 100644 src/mesh/generated/meshtastic/serial_hal.pb.h diff --git a/protobufs b/protobufs index 249a80855a2..1d6f1a71ff3 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 249a80855a2adb76fb0904dac8bf6285d45f330f +Subproject commit 1d6f1a71ff329fa52ad8bb7899951e96f8280a1f diff --git a/src/mesh/generated/meshtastic/apponly.pb.h b/src/mesh/generated/meshtastic/apponly.pb.h index ce766878b1e..88cbcb5e67b 100644 --- a/src/mesh/generated/meshtastic/apponly.pb.h +++ b/src/mesh/generated/meshtastic/apponly.pb.h @@ -55,7 +55,7 @@ extern const pb_msgdesc_t meshtastic_ChannelSet_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_APPONLY_PB_H_MAX_SIZE meshtastic_ChannelSet_size -#define meshtastic_ChannelSet_size 682 +#define meshtastic_ChannelSet_size 685 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 0e14334d5a7..d614a6438c3 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -618,6 +618,8 @@ typedef struct _meshtastic_Config_LoRaConfig { bool config_ok_to_mqtt; /* Set where LORA FEM is enabled, disabled, or not present */ meshtastic_Config_LoRaConfig_FEM_LNA_Mode fem_lna_mode; + /* Don't use radiolib to initialize the radio, instead listen for a serialHal connection */ + bool serial_hal_only; } meshtastic_Config_LoRaConfig; typedef struct _meshtastic_Config_BluetoothConfig { @@ -779,7 +781,7 @@ extern "C" { #define meshtastic_Config_NetworkConfig_init_default {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_default, "", 0, 0} #define meshtastic_Config_NetworkConfig_IpV4Config_init_default {0, 0, 0, 0} #define meshtastic_Config_DisplayConfig_init_default {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} -#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN} +#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_default {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} #define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} #define meshtastic_Config_SessionkeyConfig_init_default {0} @@ -790,7 +792,7 @@ extern "C" { #define meshtastic_Config_NetworkConfig_init_zero {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_zero, "", 0, 0} #define meshtastic_Config_NetworkConfig_IpV4Config_init_zero {0, 0, 0, 0} #define meshtastic_Config_DisplayConfig_init_zero {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} -#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN} +#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_zero {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} #define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} #define meshtastic_Config_SessionkeyConfig_init_zero {0} @@ -877,6 +879,7 @@ extern "C" { #define meshtastic_Config_LoRaConfig_ignore_mqtt_tag 104 #define meshtastic_Config_LoRaConfig_config_ok_to_mqtt_tag 105 #define meshtastic_Config_LoRaConfig_fem_lna_mode_tag 106 +#define meshtastic_Config_LoRaConfig_serial_hal_only_tag 107 #define meshtastic_Config_BluetoothConfig_enabled_tag 1 #define meshtastic_Config_BluetoothConfig_mode_tag 2 #define meshtastic_Config_BluetoothConfig_fixed_pin_tag 3 @@ -1029,7 +1032,8 @@ X(a, STATIC, SINGULAR, BOOL, pa_fan_disabled, 15) \ X(a, STATIC, REPEATED, UINT32, ignore_incoming, 103) \ X(a, STATIC, SINGULAR, BOOL, ignore_mqtt, 104) \ X(a, STATIC, SINGULAR, BOOL, config_ok_to_mqtt, 105) \ -X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106) +X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106) \ +X(a, STATIC, SINGULAR, BOOL, serial_hal_only, 107) #define meshtastic_Config_LoRaConfig_CALLBACK NULL #define meshtastic_Config_LoRaConfig_DEFAULT NULL @@ -1086,7 +1090,7 @@ extern const pb_msgdesc_t meshtastic_Config_SessionkeyConfig_msg; #define meshtastic_Config_BluetoothConfig_size 10 #define meshtastic_Config_DeviceConfig_size 100 #define meshtastic_Config_DisplayConfig_size 36 -#define meshtastic_Config_LoRaConfig_size 88 +#define meshtastic_Config_LoRaConfig_size 91 #define meshtastic_Config_NetworkConfig_IpV4Config_size 20 #define meshtastic_Config_NetworkConfig_size 204 #define meshtastic_Config_PositionConfig_size 62 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 1d6cd32f9a7..6d03dc64379 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -361,7 +361,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2429 +#define meshtastic_BackupPreferences_size 2432 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1737 #define meshtastic_NodeInfoLite_size 196 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 8425c122a0c..27f5ad7bfdf 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -205,7 +205,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size -#define meshtastic_LocalConfig_size 754 +#define meshtastic_LocalConfig_size 757 #define meshtastic_LocalModuleConfig_size 820 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/serial_hal.pb.cpp b/src/mesh/generated/meshtastic/serial_hal.pb.cpp new file mode 100644 index 00000000000..183bc48f6e4 --- /dev/null +++ b/src/mesh/generated/meshtastic/serial_hal.pb.cpp @@ -0,0 +1,19 @@ +/* Automatically generated nanopb constant definitions */ +/* Generated by nanopb-0.4.9.1 */ + +#include "meshtastic/serial_hal.pb.h" +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +PB_BIND(meshtastic_SerialHalCommand, meshtastic_SerialHalCommand, 2) + + +PB_BIND(meshtastic_SerialHalResponse, meshtastic_SerialHalResponse, 2) + + + + + + + diff --git a/src/mesh/generated/meshtastic/serial_hal.pb.h b/src/mesh/generated/meshtastic/serial_hal.pb.h new file mode 100644 index 00000000000..5dfcdf1ca1a --- /dev/null +++ b/src/mesh/generated/meshtastic/serial_hal.pb.h @@ -0,0 +1,135 @@ +/* Automatically generated nanopb header */ +/* Generated by nanopb-0.4.9.1 */ + +#ifndef PB_MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_INCLUDED +#define PB_MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_INCLUDED +#include + +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +/* Enum definitions */ +typedef enum _meshtastic_SerialHalCommand_Type { + meshtastic_SerialHalCommand_Type_UNSET = 0, + meshtastic_SerialHalCommand_Type_PIN_MODE = 1, + meshtastic_SerialHalCommand_Type_DIGITAL_WRITE = 2, + meshtastic_SerialHalCommand_Type_DIGITAL_READ = 3, + meshtastic_SerialHalCommand_Type_ATTACH_INTERRUPT = 4, + meshtastic_SerialHalCommand_Type_DETACH_INTERRUPT = 5, + meshtastic_SerialHalCommand_Type_SPI_TRANSFER = 6, + meshtastic_SerialHalCommand_Type_NOOP = 7 +} meshtastic_SerialHalCommand_Type; + +typedef enum _meshtastic_SerialHalResponse_Result { + meshtastic_SerialHalResponse_Result_OK = 0, + meshtastic_SerialHalResponse_Result_ERROR = 1, + meshtastic_SerialHalResponse_Result_BAD_REQUEST = 2, + meshtastic_SerialHalResponse_Result_UNSUPPORTED = 3 +} meshtastic_SerialHalResponse_Result; + +/* Struct definitions */ +typedef PB_BYTES_ARRAY_T(512) meshtastic_SerialHalCommand_data_t; +typedef struct _meshtastic_SerialHalCommand { + /* Host-assigned request id. Replies echo this id back in + SerialHalResponse.transaction_id. */ + uint32_t transaction_id; + meshtastic_SerialHalCommand_Type type; + uint32_t pin; + uint32_t value; + uint32_t mode; + meshtastic_SerialHalCommand_data_t data; +} meshtastic_SerialHalCommand; + +typedef PB_BYTES_ARRAY_T(512) meshtastic_SerialHalResponse_data_t; +typedef struct _meshtastic_SerialHalResponse { + /* Matches the originating SerialHalCommand.transaction_id for normal + request/response traffic. + + A value of 0 indicates an unsolicited interrupt notification generated by + the device. In that case, the host should interpret value as the GPIO pin + that triggered. */ + uint32_t transaction_id; + meshtastic_SerialHalResponse_Result result; + /* Used by DIGITAL_READ replies and interrupt notifications. For interrupt + notifications (transaction_id == 0), this carries the pin number. */ + uint32_t value; + meshtastic_SerialHalResponse_data_t data; + char error[80]; +} meshtastic_SerialHalResponse; + + +#ifdef __cplusplus +extern "C" { +#endif + +/* Helper constants for enums */ +#define _meshtastic_SerialHalCommand_Type_MIN meshtastic_SerialHalCommand_Type_UNSET +#define _meshtastic_SerialHalCommand_Type_MAX meshtastic_SerialHalCommand_Type_NOOP +#define _meshtastic_SerialHalCommand_Type_ARRAYSIZE ((meshtastic_SerialHalCommand_Type)(meshtastic_SerialHalCommand_Type_NOOP+1)) + +#define _meshtastic_SerialHalResponse_Result_MIN meshtastic_SerialHalResponse_Result_OK +#define _meshtastic_SerialHalResponse_Result_MAX meshtastic_SerialHalResponse_Result_UNSUPPORTED +#define _meshtastic_SerialHalResponse_Result_ARRAYSIZE ((meshtastic_SerialHalResponse_Result)(meshtastic_SerialHalResponse_Result_UNSUPPORTED+1)) + +#define meshtastic_SerialHalCommand_type_ENUMTYPE meshtastic_SerialHalCommand_Type + +#define meshtastic_SerialHalResponse_result_ENUMTYPE meshtastic_SerialHalResponse_Result + + +/* Initializer values for message structs */ +#define meshtastic_SerialHalCommand_init_default {0, _meshtastic_SerialHalCommand_Type_MIN, 0, 0, 0, {0, {0}}} +#define meshtastic_SerialHalResponse_init_default {0, _meshtastic_SerialHalResponse_Result_MIN, 0, {0, {0}}, ""} +#define meshtastic_SerialHalCommand_init_zero {0, _meshtastic_SerialHalCommand_Type_MIN, 0, 0, 0, {0, {0}}} +#define meshtastic_SerialHalResponse_init_zero {0, _meshtastic_SerialHalResponse_Result_MIN, 0, {0, {0}}, ""} + +/* Field tags (for use in manual encoding/decoding) */ +#define meshtastic_SerialHalCommand_transaction_id_tag 1 +#define meshtastic_SerialHalCommand_type_tag 2 +#define meshtastic_SerialHalCommand_pin_tag 3 +#define meshtastic_SerialHalCommand_value_tag 4 +#define meshtastic_SerialHalCommand_mode_tag 5 +#define meshtastic_SerialHalCommand_data_tag 6 +#define meshtastic_SerialHalResponse_transaction_id_tag 1 +#define meshtastic_SerialHalResponse_result_tag 2 +#define meshtastic_SerialHalResponse_value_tag 3 +#define meshtastic_SerialHalResponse_data_tag 4 +#define meshtastic_SerialHalResponse_error_tag 5 + +/* Struct field encoding specification for nanopb */ +#define meshtastic_SerialHalCommand_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, transaction_id, 1) \ +X(a, STATIC, SINGULAR, UENUM, type, 2) \ +X(a, STATIC, SINGULAR, UINT32, pin, 3) \ +X(a, STATIC, SINGULAR, UINT32, value, 4) \ +X(a, STATIC, SINGULAR, UINT32, mode, 5) \ +X(a, STATIC, SINGULAR, BYTES, data, 6) +#define meshtastic_SerialHalCommand_CALLBACK NULL +#define meshtastic_SerialHalCommand_DEFAULT NULL + +#define meshtastic_SerialHalResponse_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, transaction_id, 1) \ +X(a, STATIC, SINGULAR, UENUM, result, 2) \ +X(a, STATIC, SINGULAR, UINT32, value, 3) \ +X(a, STATIC, SINGULAR, BYTES, data, 4) \ +X(a, STATIC, SINGULAR, STRING, error, 5) +#define meshtastic_SerialHalResponse_CALLBACK NULL +#define meshtastic_SerialHalResponse_DEFAULT NULL + +extern const pb_msgdesc_t meshtastic_SerialHalCommand_msg; +extern const pb_msgdesc_t meshtastic_SerialHalResponse_msg; + +/* Defines for backwards compatibility with code written before nanopb-0.4.0 */ +#define meshtastic_SerialHalCommand_fields &meshtastic_SerialHalCommand_msg +#define meshtastic_SerialHalResponse_fields &meshtastic_SerialHalResponse_msg + +/* Maximum encoded size of messages (where known) */ +#define MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_MAX_SIZE meshtastic_SerialHalResponse_size +#define meshtastic_SerialHalCommand_size 541 +#define meshtastic_SerialHalResponse_size 610 + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif From 21cef8c2e5d46c31022e3ecedba6a58d84e73045 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 30 Apr 2026 13:51:29 -0500 Subject: [PATCH 120/225] Add TCP support for Meshtastic MCP interface / tests and update docs (#10355) * Add TCP support for Meshtastic MCP interface / tests and update docs * Address TCP endpoint validation and error handling in connection * TCP connection handling and device listing logic * Fix docstring formatting in normalize_tcp_endpoint function --- .github/copilot-instructions.md | 2 + AGENTS.md | 27 +- mcp-server/README.md | 76 +++- mcp-server/src/meshtastic_mcp/connection.py | 171 +++++++- mcp-server/src/meshtastic_mcp/devices.py | 66 ++- mcp-server/src/meshtastic_mcp/flash.py | 19 +- mcp-server/src/meshtastic_mcp/hw_tools.py | 7 +- .../src/meshtastic_mcp/serial_session.py | 4 + mcp-server/tests/unit/test_connection_tcp.py | 383 ++++++++++++++++++ 9 files changed, 715 insertions(+), 40 deletions(-) create mode 100644 mcp-server/tests/unit/test_connection_tcp.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 29d5f6b000a..fe9af4359b5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -575,6 +575,8 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v `confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`. +**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step — use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See `mcp-server/README.md` § "TCP / native-host nodes". + ### Hardware test suite (`mcp-server/run-tests.sh`) The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_`), then invokes pytest. Zero pre-flight config needed from the operator. diff --git a/AGENTS.md b/AGENTS.md index ca6794322ac..cdccda1f48b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,16 +126,17 @@ Sequence these; don't parallelize on the same port. ## Environment variables (test harness) -| Var | Purpose | -| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `MESHTASTIC_MCP_ENV_` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. | -| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp--`. | -| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. | -| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. | -| `MESHTASTIC_UHUBCTL_LOCATION_` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. | -| `MESHTASTIC_UHUBCTL_PORT_` | Pin a role to a specific hub port number. Required alongside `LOCATION_`. | -| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). | -| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. | -| `MESHTASTIC_UI_CAMERA_DEVICE_` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). | -| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). | -| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. | +| Var | Purpose | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MESHTASTIC_MCP_ENV_` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. | +| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp--`. | +| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. | +| `MESHTASTIC_MCP_TCP_HOST` | `host` or `host:port` of a `meshtasticd` daemon (e.g. the `native-macos` build). Surfaces it in `list_devices` as `tcp://host:port` so `connect()`-based tools target it transparently. Default port 4403. | +| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. | +| `MESHTASTIC_UHUBCTL_LOCATION_` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. | +| `MESHTASTIC_UHUBCTL_PORT_` | Pin a role to a specific hub port number. Required alongside `LOCATION_`. | +| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). | +| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. | +| `MESHTASTIC_UI_CAMERA_DEVICE_` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). | +| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). | +| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. | diff --git a/mcp-server/README.md b/mcp-server/README.md index 7a36a6facb0..22ce77fbcb6 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -166,15 +166,73 @@ rather than auto-`sudo`'ing mid-run. ## Environment variables -| Var | Default | Purpose | -| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | -| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo | -| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location | -| `MESHTASTIC_ESPTOOL_BIN` | `/.venv/bin/esptool` → `$PATH` | Override esptool | -| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil | -| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool | -| `MESHTASTIC_MCP_SEED` | `mcp--` | PSK seed for test-harness session (CI override) | -| `MESHTASTIC_MCP_FLASH_LOG` | `/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) | +| Var | Default | Purpose | +| -------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo | +| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location | +| `MESHTASTIC_ESPTOOL_BIN` | `/.venv/bin/esptool` → `$PATH` | Override esptool | +| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil | +| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool | +| `MESHTASTIC_MCP_SEED` | `mcp--` | PSK seed for test-harness session (CI override) | +| `MESHTASTIC_MCP_FLASH_LOG` | `/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) | +| `MESHTASTIC_MCP_TCP_HOST` | unset | `host` or `host:port` of a `meshtasticd` daemon to surface as a TCP device (see "TCP / native-host nodes" below) | + +## TCP / native-host nodes + +The `native-macos` and `native` PlatformIO envs build a headless `meshtasticd` +binary that runs on the host (Apple Silicon / Intel macOS, or Linux Portduino). +The daemon exposes the meshtastic TCP API on port `4403` rather than a USB +serial endpoint — point the MCP server at it via `MESHTASTIC_MCP_TCP_HOST`: + +```bash +# 1. Build + run a daemon on this host (see variants/native/portduino/platformio.ini +# for full Homebrew prereqs and CH341 LoRa-adapter setup). +pio run -e native-macos +~/.meshtasticd/meshtasticd + +# 2. Point the MCP server at it. +export MESHTASTIC_MCP_TCP_HOST=localhost # or host:port, default port 4403 +``` + +**First-run gotcha — MAC address.** `meshtasticd` derives its MAC from the +USB adapter's serial-number / product strings. Many cheap CH341 dongles +(MeshStick included — VID 0x1A86 / PID 0x5512) ship with `iSerialNumber=0` +and `iProduct=0`, so the daemon aborts on boot with `*** Blank MAC Address +not allowed!`. Set the MAC explicitly in `config.yaml`: + +```yaml +# Under General: +MACAddress: 02:CA:FE:BA:BE:01 +``` + +Use a locally-administered address (first byte's second-LSB set, e.g. +`02:*` / `06:*` / `0A:*` / `0E:*`) to avoid colliding with a real OUI. + +There is also a `--hwid AA:BB:CC:DD:EE:FF` CLI flag visible in +`meshtasticd --help`, but it is **currently broken** in +`MAC_from_string()` (`src/platform/portduino/PortduinoGlue.cpp`): the +function strips colons from its parameter but then reads bytes from the +global `portduino_config.mac_address`, so `--hwid` is silently overridden +when `MACAddress:` is also set, and crashes the daemon (uncaught +`std::invalid_argument: stoi: no conversion`) when it isn't. Use the YAML +form until that's fixed upstream. + +`list_devices` will surface the daemon as `tcp://localhost:4403` with +`likely_meshtastic=True`, so `device_info`, `list_nodes`, `get_config`, +`set_config`, `set_owner`, `send_text`, `userprefs_*`, and the admin RPCs +auto-select it when no `port` is passed. Pass `port="tcp://other-host:9999"` +explicitly to target a different daemon. + +**Tools that don't apply to a TCP/native node** (no USB hardware to operate +on) raise a clear `ConnectionError` rather than failing mysteriously: +`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, +`serial_open` (use info/admin tools directly), and the vendor escape hatches +`esptool_*`, `nrfutil_*`, `picotool_*`. `pio_flash` against a `native*` env +similarly raises — there's no upload step; use `build` and run the binary +directly. + +The pytest harness in `tests/` still assumes USB-attached devices per role — +TCP-aware fixtures are not part of this surface yet. ## Hardware Test Suite diff --git a/mcp-server/src/meshtastic_mcp/connection.py b/mcp-server/src/meshtastic_mcp/connection.py index 17a7e2c8985..7dbf847b917 100644 --- a/mcp-server/src/meshtastic_mcp/connection.py +++ b/mcp-server/src/meshtastic_mcp/connection.py @@ -1,4 +1,4 @@ -"""Context manager for meshtastic.SerialInterface connections. +"""Context manager for meshtastic interface connections (serial + TCP). Every info/admin tool goes through `connect(port)` so we have a single place that: @@ -6,8 +6,16 @@ - fails fast if a serial_session is already holding the port, - guarantees `.close()` is called, even on exception. -The `SerialInterface` blocks on construction waiting for the node database; -that's fine for v1 since every tool is a short-lived request. +Two transports: + - Serial: USB-attached firmware on `/dev/cu.*` / `/dev/ttyUSB*` / `COM*`. + - TCP: a `meshtasticd` daemon (e.g. the native macOS / Linux Portduino + headless build) addressed as `tcp://host[:port]` (default port 4403). + Surfaced by `devices.list_devices()` when `MESHTASTIC_MCP_TCP_HOST` is + set, so `resolve_port(None)` auto-selects it like a USB candidate. + +Both `SerialInterface` and `TCPInterface` block on construction waiting for +the node database; that's fine for v1 since every tool is a short-lived +request. """ from __future__ import annotations @@ -17,20 +25,107 @@ from . import devices, registry +DEFAULT_TCP_PORT = 4403 +TCP_SCHEME = "tcp://" +TCP_HOST_ENV = "MESHTASTIC_MCP_TCP_HOST" + class ConnectionError(RuntimeError): pass +def is_tcp_port(port: str | None) -> bool: + return bool(port) and port.startswith(TCP_SCHEME) + + +def parse_tcp_port(port: str) -> tuple[str, int]: + """Parse `tcp://host[:port]` → (host, port). Defaults to 4403. + + Validates host shape (non-empty, no path separators) and port range + (1..65535). Raises `ConnectionError` on malformed input — never lets + a raw `ValueError` bubble up to a tool surface. + """ + if not port.startswith(TCP_SCHEME): + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: expected '{TCP_SCHEME}host[:port]'." + ) + rest = port[len(TCP_SCHEME) :] + if ":" in rest: + host, port_str = rest.rsplit(":", 1) + try: + tcp_port = int(port_str) + except ValueError as e: + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: port {port_str!r} is not an integer." + ) from e + else: + host, tcp_port = rest, DEFAULT_TCP_PORT + if not host: + raise ConnectionError(f"Invalid TCP endpoint {port!r}: empty host.") + if any(c in host for c in ("/", "\\")): + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: host {host!r} contains a path " + "separator. TCP hostnames cannot contain '/' or '\\' — did you " + "pass a serial port path or a Windows drive path by mistake?" + ) + if not (1 <= tcp_port <= 65535): + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: port {tcp_port} out of range " + "(must be 1..65535)." + ) + return host, tcp_port + + +def normalize_tcp_endpoint(endpoint: str) -> str: + r"""Normalize `host`, `host:port`, or `tcp://host[:port]` → canonical + `tcp://host:port` form. One place that owns the lock-key shape. + + Defers all validation to `parse_tcp_port`, so path-like inputs + (`/dev/cu.foo`, `C:\Windows\…`), empty hosts, non-integer ports, + and out-of-range ports raise `ConnectionError` here too. + """ + if endpoint.startswith(TCP_SCHEME): + canonical = endpoint + elif ":" in endpoint: + canonical = f"{TCP_SCHEME}{endpoint}" + else: + canonical = f"{TCP_SCHEME}{endpoint}:{DEFAULT_TCP_PORT}" + host, port = parse_tcp_port(canonical) + return f"{TCP_SCHEME}{host}:{port}" + + +def reject_if_tcp(port: str | None, tool_name: str) -> None: + """Raise if `port` is a TCP endpoint — for tools that need real USB + hardware (flash, bootloader, vendor escape hatches, serial monitor). + + Only checks the explicit arg; auto-selection via env var is the caller's + responsibility to handle if it matters. + """ + if is_tcp_port(port): + raise ConnectionError( + f"{tool_name} is not applicable to TCP/native nodes ({port}). " + "This tool requires USB-attached hardware." + ) + + def resolve_port(port: str | None) -> str: - """Pick a port: explicit > sole likely_meshtastic candidate > error.""" + """Pick a port: explicit > sole likely_meshtastic candidate > error. + + A `tcp://` string passes through (after canonicalization). When `port` + is None and no USB candidates are present, `MESHTASTIC_MCP_TCP_HOST` + is consulted via `devices.list_devices()`. + """ if port: + if is_tcp_port(port): + return normalize_tcp_endpoint(port) return port candidates = [d for d in devices.list_devices() if d["likely_meshtastic"]] if not candidates: raise ConnectionError( - "No Meshtastic devices detected. Plug one in or pass `port` explicitly. " - "Run `list_devices` with include_unknown=True to see all serial ports." + "No Meshtastic devices detected. Plug one in, set " + f"{TCP_HOST_ENV}= for a meshtasticd daemon, " + "or pass `port` explicitly. Run `list_devices` with " + "include_unknown=True to see all serial ports." ) if len(candidates) > 1: ports = ", ".join(c["port"] for c in candidates) @@ -43,17 +138,62 @@ def resolve_port(port: str | None) -> str: @contextmanager def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator: - """Open a `meshtastic.SerialInterface` and always close it. - - Raises `ConnectionError` immediately if another serial session holds the - port (a `pio device monitor` in `serial_sessions/`, for instance). + """Open a meshtastic interface (serial or TCP) and always close it. + + For serial: raises `ConnectionError` immediately if another serial + session holds the port (a `pio device monitor` in `serial_sessions/`). + For TCP: no exclusive-access requirement, so the serial-session check + is skipped — but the `port_lock` still serializes parallel `connect()` + calls to the same daemon endpoint. + + `timeout_s` is plumbed through to both `SerialInterface(timeout=...)` + and `TCPInterface(timeout=...)`. The meshtastic library uses the value + as the reply-wait deadline for `localNode.waitForConfig()` during + construction and for any subsequent admin RPC. `int()`-converted at + the boundary because the upstream API expects whole seconds. """ + resolved = resolve_port(port) + timeout = int(timeout_s) + + if is_tcp_port(resolved): + from meshtastic.tcp_interface import ( + TCPInterface, # type: ignore[import-untyped] + ) + + host, tcp_port = parse_tcp_port(resolved) + lock = registry.port_lock(resolved) + if not lock.acquire(blocking=False): + raise ConnectionError( + f"TCP endpoint {resolved} is busy — another device operation " + "is in flight. Retry shortly." + ) + + iface = None + try: + iface = TCPInterface( + hostname=host, + portNumber=tcp_port, + connectNow=True, + noProto=False, + timeout=timeout, + ) + yield iface + finally: + if iface is not None: + try: + iface.close() + except Exception: + pass + try: + lock.release() + except RuntimeError: + pass + return + from meshtastic.serial_interface import ( SerialInterface, # type: ignore[import-untyped] ) - resolved = resolve_port(port) - active = registry.active_session_for_port(resolved) if active is not None: raise ConnectionError( @@ -70,7 +210,12 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator: iface = None try: - iface = SerialInterface(devPath=resolved, connectNow=True, noProto=False) + iface = SerialInterface( + devPath=resolved, + connectNow=True, + noProto=False, + timeout=timeout, + ) yield iface finally: if iface is not None: diff --git a/mcp-server/src/meshtastic_mcp/devices.py b/mcp-server/src/meshtastic_mcp/devices.py index c4805c1ab2c..976e893a03d 100644 --- a/mcp-server/src/meshtastic_mcp/devices.py +++ b/mcp-server/src/meshtastic_mcp/devices.py @@ -1,13 +1,18 @@ -"""USB/serial device discovery. +"""USB/serial + TCP device discovery. Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with the richer metadata (`serial.tools.list_ports.comports()`) so callers see VID/PID, descriptions, and manufacturer strings alongside the "is this likely a Meshtastic device" signal. + +If `MESHTASTIC_MCP_TCP_HOST=` is set, a synthetic entry for the +`meshtasticd` daemon at that endpoint is prepended to the result, so +`resolve_port(None)` auto-selects it like a USB candidate. """ from __future__ import annotations +import os from typing import Any from serial.tools import list_ports @@ -19,6 +24,45 @@ def _to_hex(value: int | None) -> str | None: return f"0x{value:04x}" +def _tcp_endpoint_from_env() -> dict[str, Any] | None: + """Synthesize a TCP device entry from MESHTASTIC_MCP_TCP_HOST, if set. + + If the env var is malformed (non-integer port, path-like host, etc.), + return an entry with `likely_meshtastic=False` and the parser error in + the description, rather than raising — `list_devices` is the diagnostic + tool a user reaches for when their env var isn't working, so it must + not crash on misconfiguration. + """ + host = os.environ.get("MESHTASTIC_MCP_TCP_HOST") + if not host: + return None + # Lazy import to avoid a circular dependency (connection imports devices). + from . import connection + + try: + port = connection.normalize_tcp_endpoint(host) + description = "meshtasticd (TCP)" + likely = True + except connection.ConnectionError as e: + # Surface the raw env-var value plus the parser's reason so the + # user can see exactly what they set and why it was rejected. + # Don't double the scheme if the user already prefixed `tcp://`. + port = host if host.startswith(connection.TCP_SCHEME) else f"tcp://{host}" + description = f"meshtasticd (TCP) — invalid MESHTASTIC_MCP_TCP_HOST: {e}" + likely = False + return { + "port": port, + "vid": None, + "pid": None, + "description": description, + "manufacturer": None, + "product": None, + "serial_number": None, + "likely_meshtastic": likely, + "blacklisted": False, + } + + def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]: """Return enriched info for serial ports, flagging Meshtastic candidates. @@ -70,6 +114,22 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]: } ) - # Stable ordering: likely_meshtastic first, then by port path - results.sort(key=lambda r: (not r["likely_meshtastic"], r["port"])) + # Append the TCP endpoint (if env var set) and sort everything together. + tcp_entry = _tcp_endpoint_from_env() + if tcp_entry is not None: + results.append(tcp_entry) + + # Stable ordering: likely_meshtastic first; within rank, TCP wins over + # USB (explicit env-var configuration takes precedence over USB + # enumeration); then by port path. A misconfigured TCP entry has + # likely_meshtastic=False and lands among the other ignored entries — + # it does NOT pre-empt real USB devices at the top of the list. + results.sort( + key=lambda r: ( + not r["likely_meshtastic"], + not r["port"].startswith("tcp://"), + r["port"], + ) + ) + return results diff --git a/mcp-server/src/meshtastic_mcp/flash.py b/mcp-server/src/meshtastic_mcp/flash.py index 2c41a7c21e6..e11197d5fac 100644 --- a/mcp-server/src/meshtastic_mcp/flash.py +++ b/mcp-server/src/meshtastic_mcp/flash.py @@ -17,7 +17,7 @@ import serial -from . import boards, config, devices, pio, userprefs +from . import boards, config, connection, devices, pio, userprefs # Meshtastic variants use both `esp32s3` and `esp32-s3` style names across # variants/*/platformio.ini (no consistency enforced). Accept both spellings. @@ -46,6 +46,18 @@ def _require_confirm(confirm: bool, operation: str) -> None: ) +def _reject_native_env(env: str, operation: str) -> None: + """`native*` envs build a host executable, not firmware — there's no + upload step. The user wants `build` (or just runs the binary directly). + """ + if env.startswith("native"): + raise FlashError( + f"{operation} is not applicable for env {env!r}: native envs " + "produce a host executable, not flashable firmware. Use `build` " + "instead, then run the resulting binary directly." + ) + + def _artifacts_for(env: str) -> list[Path]: build_dir = config.firmware_root() / ".pio" / "build" / env if not build_dir.is_dir(): @@ -141,6 +153,8 @@ def flash( that pio performs will pick up the injected values. """ _require_confirm(confirm, "flash") + _reject_native_env(env, "flash") + connection.reject_if_tcp(port, "flash") with userprefs.temporary_overrides(userprefs_overrides) as effective: result = pio.run( ["run", "-e", env, "-t", "upload", "--upload-port", port], @@ -200,6 +214,7 @@ def erase_and_flash( in that case) since a cached factory.bin would not reflect the new prefs. """ _require_confirm(confirm, "erase_and_flash") + connection.reject_if_tcp(port, "erase_and_flash") _check_esp32_env(env) if userprefs_overrides and skip_build: @@ -257,6 +272,7 @@ def update_flash( overrides are provided we always force a rebuild. """ _require_confirm(confirm, "update_flash") + connection.reject_if_tcp(port, "update_flash") _check_esp32_env(env) if userprefs_overrides and skip_build: @@ -391,6 +407,7 @@ def touch_1200bps( Returns `{ok, former_port, new_port, new_port_vid_pid, attempts}`. """ + connection.reject_if_tcp(port, "touch_1200bps") before_list = devices.list_devices(include_unknown=True) before_ports = {d["port"] for d in before_list} diff --git a/mcp-server/src/meshtastic_mcp/hw_tools.py b/mcp-server/src/meshtastic_mcp/hw_tools.py index 4275539baf0..1835f4ef16f 100644 --- a/mcp-server/src/meshtastic_mcp/hw_tools.py +++ b/mcp-server/src/meshtastic_mcp/hw_tools.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any, Sequence -from . import config, pio +from . import config, connection, pio _TIMEOUT_SHORT = 30 _TIMEOUT_LONG = 600 @@ -102,6 +102,7 @@ def _parse_esptool_chip_info(stdout: str) -> dict[str, Any]: def esptool_chip_info(port: str) -> dict[str, Any]: + connection.reject_if_tcp(port, "esptool_chip_info") binary = config.esptool_bin() # `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash. combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT) @@ -116,6 +117,7 @@ def esptool_chip_info(port: str) -> dict[str, Any]: def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]: """Full-chip erase. Leaves the device unbootable until reflashed.""" _require_confirm(confirm, "esptool_erase_flash") + connection.reject_if_tcp(port, "esptool_erase_flash") binary = config.esptool_bin() # esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name # first; if it fails with unknown command, retry old. @@ -134,6 +136,7 @@ def esptool_raw( """Raw esptool passthrough. Destructive subcommands require confirm=True.""" if not args: raise ToolError("args must not be empty") + connection.reject_if_tcp(port, "esptool_raw") # Find the first non-flag arg (the subcommand). subcommand = next((a for a in args if not a.startswith("-")), None) if subcommand and subcommand.replace("-", "_") in { @@ -156,6 +159,7 @@ def esptool_raw( def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]: _require_confirm(confirm, "nrfutil_dfu") + connection.reject_if_tcp(port, "nrfutil_dfu") pkg = Path(package_path).expanduser() if not pkg.is_file(): raise ToolError(f"Package not found: {pkg}") @@ -213,6 +217,7 @@ def _parse_picotool_info(stdout: str) -> dict[str, Any]: def picotool_info(port: str | None = None) -> dict[str, Any]: """Read device info from a Pico in BOOTSEL mode. `port` is informational only — picotool auto-detects.""" + connection.reject_if_tcp(port, "picotool_info") binary = config.picotool_bin() res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT) if res["exit_code"] != 0: diff --git a/mcp-server/src/meshtastic_mcp/serial_session.py b/mcp-server/src/meshtastic_mcp/serial_session.py index b9c71d1d0f0..43537323f71 100644 --- a/mcp-server/src/meshtastic_mcp/serial_session.py +++ b/mcp-server/src/meshtastic_mcp/serial_session.py @@ -71,6 +71,10 @@ def open_session( If `env` is supplied, pio resolves baud and filters from platformio.ini. Otherwise uses the supplied `baud` and `filters` (default `['direct']`). """ + # Lazy import to avoid circular: registry imports serial_session. + from . import connection + + connection.reject_if_tcp(port, "serial_open") args = ["device", "monitor", "--port", port, "--no-reconnect"] effective_filters: list[str] effective_baud: int = baud diff --git a/mcp-server/tests/unit/test_connection_tcp.py b/mcp-server/tests/unit/test_connection_tcp.py new file mode 100644 index 00000000000..54b7e9b477f --- /dev/null +++ b/mcp-server/tests/unit/test_connection_tcp.py @@ -0,0 +1,383 @@ +"""TCP transport plumbing in connection.py + devices.py. + +Pure-Python tests — no real device or daemon required. Mocks `TCPInterface` +when exercising `connect()`. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from meshtastic_mcp import connection, devices + +# ---------- helpers -------------------------------------------------------- + + +class TestIsTcpPort: + def test_tcp_scheme(self) -> None: + assert connection.is_tcp_port("tcp://localhost") is True + assert connection.is_tcp_port("tcp://localhost:4403") is True + assert connection.is_tcp_port("tcp://192.168.1.50:9999") is True + + def test_serial_paths(self) -> None: + assert connection.is_tcp_port("/dev/cu.usbmodem1234") is False + assert connection.is_tcp_port("/dev/ttyUSB0") is False + assert connection.is_tcp_port("COM3") is False + + def test_empty_or_none(self) -> None: + assert connection.is_tcp_port(None) is False + assert connection.is_tcp_port("") is False + + +class TestParseTcpPort: + def test_default_port(self) -> None: + assert connection.parse_tcp_port("tcp://localhost") == ("localhost", 4403) + + def test_explicit_port(self) -> None: + assert connection.parse_tcp_port("tcp://localhost:9999") == ( + "localhost", + 9999, + ) + + def test_ip_with_port(self) -> None: + assert connection.parse_tcp_port("tcp://192.168.1.50:4403") == ( + "192.168.1.50", + 4403, + ) + + +class TestNormalizeTcpEndpoint: + def test_bare_host(self) -> None: + assert connection.normalize_tcp_endpoint("localhost") == "tcp://localhost:4403" + + def test_host_port(self) -> None: + assert ( + connection.normalize_tcp_endpoint("localhost:5000") + == "tcp://localhost:5000" + ) + + def test_full_url(self) -> None: + assert ( + connection.normalize_tcp_endpoint("tcp://1.2.3.4") == "tcp://1.2.3.4:4403" + ) + assert ( + connection.normalize_tcp_endpoint("tcp://1.2.3.4:9999") + == "tcp://1.2.3.4:9999" + ) + + def test_idempotent(self) -> None: + once = connection.normalize_tcp_endpoint("localhost:4403") + twice = connection.normalize_tcp_endpoint(once) + assert once == twice == "tcp://localhost:4403" + + def test_path_like_endpoint_rejected(self) -> None: + # Serial port paths and Windows drive paths are common config typos + # (someone passes a serial path to MESHTASTIC_MCP_TCP_HOST). Reject + # rather than producing a nonsense `tcp:///dev/cu.foo:4403` URL. + with pytest.raises(connection.ConnectionError, match="path separator"): + connection.normalize_tcp_endpoint("/dev/cu.foo") + with pytest.raises(connection.ConnectionError): + connection.normalize_tcp_endpoint("tcp:///dev/cu.foo:4403") + with pytest.raises(connection.ConnectionError): + connection.normalize_tcp_endpoint(r"C:\Windows\System32") + + def test_non_integer_port_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="not an integer"): + connection.normalize_tcp_endpoint("tcp://host:notaport") + with pytest.raises(connection.ConnectionError, match="not an integer"): + connection.normalize_tcp_endpoint("host:notaport") + + def test_empty_host_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="empty host"): + connection.normalize_tcp_endpoint("tcp://:4403") + + def test_port_out_of_range_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.normalize_tcp_endpoint("tcp://host:0") + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.normalize_tcp_endpoint("tcp://host:65536") + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.normalize_tcp_endpoint("host:99999") + + +class TestParseTcpPortValidation: + def test_missing_scheme_rejected(self) -> None: + # parse_tcp_port is a low-level helper that requires the scheme. + # Misuse should fail loudly rather than silently mis-parsing. + with pytest.raises(connection.ConnectionError, match="expected"): + connection.parse_tcp_port("localhost:4403") + + def test_negative_port_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.parse_tcp_port("tcp://host:-1") + + +# ---------- reject_if_tcp -------------------------------------------------- + + +class TestRejectIfTcp: + def test_rejects_tcp(self) -> None: + with pytest.raises(connection.ConnectionError, match="not applicable"): + connection.reject_if_tcp("tcp://localhost", "esptool_chip_info") + + def test_passes_through_serial(self) -> None: + connection.reject_if_tcp("/dev/cu.usbmodem1", "esptool_chip_info") # no raise + + def test_passes_through_none(self) -> None: + # None means "auto-detect"; not the explicit-arg case we guard. + connection.reject_if_tcp(None, "esptool_chip_info") # no raise + + +# ---------- resolve_port --------------------------------------------------- + + +class TestResolvePort: + def test_explicit_serial_passthrough(self) -> None: + assert connection.resolve_port("/dev/cu.usbmodem999") == "/dev/cu.usbmodem999" + + def test_explicit_tcp_normalized(self) -> None: + assert connection.resolve_port("tcp://localhost") == "tcp://localhost:4403" + + def test_no_port_no_devices_errors(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + with patch.object(devices, "list_devices", return_value=[]): + with pytest.raises( + connection.ConnectionError, match="No Meshtastic devices" + ): + connection.resolve_port(None) + + def test_no_port_one_candidate_selected( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + fake = [{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True}] + with patch.object(devices, "list_devices", return_value=fake): + assert connection.resolve_port(None) == "/dev/cu.usbmodem1" + + def test_no_port_multiple_candidates_errors( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + fake = [ + {"port": "/dev/cu.usbmodem1", "likely_meshtastic": True}, + {"port": "/dev/cu.usbmodem2", "likely_meshtastic": True}, + ] + with patch.object(devices, "list_devices", return_value=fake): + with pytest.raises(connection.ConnectionError, match="Multiple"): + connection.resolve_port(None) + + def test_env_var_surfaces_tcp_via_devices( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost") + # Don't patch list_devices — let the real env-var path run, but stub + # the USB enumeration to keep the test hermetic. + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + assert connection.resolve_port(None) == "tcp://localhost:4403" + + +# ---------- devices.list_devices TCP entry -------------------------------- + + +class TestDevicesTcpEntry: + def test_no_env_var_no_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices() + assert all(not d["port"].startswith("tcp://") for d in ds) + + def test_env_var_adds_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "myhost:9999") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices() + tcp = [d for d in ds if d["port"].startswith("tcp://")] + assert len(tcp) == 1 + assert tcp[0]["port"] == "tcp://myhost:9999" + assert tcp[0]["likely_meshtastic"] is True + assert tcp[0]["description"] == "meshtasticd (TCP)" + + def test_tcp_entry_first_in_results(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices() + assert ds, "expected at least the TCP entry" + assert ds[0]["port"].startswith("tcp://") + + def test_invalid_env_var_does_not_break_list_devices( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # `list_devices` is the diagnostic tool reached for when an env var + # isn't working — it must not throw on misconfiguration. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices(include_unknown=True) + tcp = [d for d in ds if "TCP" in (d["description"] or "")] + assert len(tcp) == 1 + assert tcp[0]["likely_meshtastic"] is False + assert "invalid MESHTASTIC_MCP_TCP_HOST" in tcp[0]["description"] + assert "not an integer" in tcp[0]["description"] + + def test_invalid_env_var_excluded_from_resolve_port_autodetect( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # `likely_meshtastic=False` keeps the bad TCP entry out of the + # auto-select path — `resolve_port(None)` should still report + # "no Meshtastic devices" rather than picking a broken endpoint. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + with pytest.raises(connection.ConnectionError, match="No Meshtastic"): + connection.resolve_port(None) + + def test_invalid_env_var_does_not_double_tcp_scheme( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # If a user mistakenly sets `MESHTASTIC_MCP_TCP_HOST=tcp://host:bad`, + # the diagnostic entry must surface the raw value as-is rather than + # producing `tcp://tcp://host:bad`. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "tcp://host:notaport") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices(include_unknown=True) + tcp = [d for d in ds if "TCP" in (d["description"] or "")] + assert len(tcp) == 1 + assert tcp[0]["port"] == "tcp://host:notaport" + assert "tcp://tcp://" not in tcp[0]["port"] + + def test_invalid_env_var_does_not_pre_empt_real_usb_devices( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Sort ordering: a misconfigured TCP env var must NOT take position 0 + # ahead of real USB candidates. Position 0 is reserved for the highest + # rank (likely_meshtastic=True), with TCP-before-USB as a tiebreaker + # within rank. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport") + + # Stub a USB Meshtastic candidate (Espressif VID, port present in + # findPorts). + class FakeInfo: + def __init__(self, device: str, vid: int, pid: int) -> None: + self.device = device + self.vid = vid + self.pid = pid + self.description = "Heltec V3" + self.manufacturer = "Espressif" + self.product = "USB JTAG/serial" + self.serial_number = "abc" + + fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001) + with patch( + "meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port] + ), patch( + "meshtastic.util.findPorts", + return_value=["/dev/cu.usbmodem4201"], + ): + ds = devices.list_devices(include_unknown=True) + + assert ds, "expected at least the USB + TCP entries" + # Real USB candidate must be at position 0 — it's likely_meshtastic. + assert ds[0]["port"] == "/dev/cu.usbmodem4201" + assert ds[0]["likely_meshtastic"] is True + # The malformed TCP entry exists but lands among the unlikely entries. + tcp = [d for d in ds if "TCP" in (d["description"] or "")] + assert len(tcp) == 1 + assert tcp[0]["likely_meshtastic"] is False + assert ds.index(tcp[0]) > 0 + + def test_likely_tcp_entry_wins_tiebreak_over_usb( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Conversely, a *valid* TCP env var should sort ahead of USB + # candidates of equal likely_meshtastic rank — explicit env-var + # configuration is a precedence signal. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost:4403") + + class FakeInfo: + def __init__(self, device: str, vid: int, pid: int) -> None: + self.device = device + self.vid = vid + self.pid = pid + self.description = "Heltec V3" + self.manufacturer = "Espressif" + self.product = "USB JTAG/serial" + self.serial_number = "abc" + + fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001) + with patch( + "meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port] + ), patch( + "meshtastic.util.findPorts", + return_value=["/dev/cu.usbmodem4201"], + ): + ds = devices.list_devices() + + assert ds[0]["port"] == "tcp://localhost:4403" + assert ds[0]["likely_meshtastic"] is True + + +# ---------- connect() routing --------------------------------------------- + + +class TestConnectRoutesTcp: + def test_connect_uses_tcp_interface_for_tcp_port( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify the TCP branch instantiates `TCPInterface(hostname, portNumber)` + and never touches `SerialInterface`.""" + # Make sure the env var doesn't leak in and confuse resolve_port. + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + + with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp, patch( + "meshtastic.serial_interface.SerialInterface" + ) as mock_serial: + mock_tcp.return_value.close.return_value = None + with connection.connect(port="tcp://example.com:1234", timeout_s=12.0): + pass + + mock_tcp.assert_called_once_with( + hostname="example.com", + portNumber=1234, + connectNow=True, + noProto=False, + timeout=12, + ) + mock_serial.assert_not_called() + + def test_connect_plumbs_timeout_to_serial_interface( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify the serial branch also propagates `timeout_s` so callers + passing a custom timeout to `device_info` / `list_nodes` / etc. don't + silently get the library default.""" + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + + with patch("meshtastic.serial_interface.SerialInterface") as mock_serial, patch( + "meshtastic.tcp_interface.TCPInterface" + ) as mock_tcp: + mock_serial.return_value.close.return_value = None + with connection.connect(port="/dev/cu.fake", timeout_s=20.0): + pass + + mock_serial.assert_called_once_with( + devPath="/dev/cu.fake", + connectNow=True, + noProto=False, + timeout=20, + ) + mock_tcp.assert_not_called() + + def test_connect_releases_lock_on_tcp_failure( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp: + mock_tcp.side_effect = RuntimeError("boom") + with pytest.raises(RuntimeError, match="boom"): + with connection.connect(port="tcp://locktest:4403"): + pass + + # Lock should be released — a second connect attempt must not fail + # with "busy". + with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp: + mock_tcp.return_value.close.return_value = None + with connection.connect(port="tcp://locktest:4403"): + pass From 7066abbb86739be7e3a907fb40ed2bd4744f637b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 30 Apr 2026 13:52:42 -0500 Subject: [PATCH 121/225] Fix MAC_from_string to use input parameter instead of global config for MAC address parsing (#10356) * Fix MAC_from_string to use input parameter instead of global config for MAC address parsing * Enhance MAC_from_string validation and error handling * Add missing include for in PortduinoGlue.cpp --- src/platform/portduino/PortduinoGlue.cpp | 33 ++-- test/test_mac_from_string/test_main.cpp | 195 +++++++++++++++++++++++ 2 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 test/test_mac_from_string/test_main.cpp diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index fbaa3c98cb7..eeb56240dae 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -1052,17 +1053,31 @@ static bool ends_with(std::string_view str, std::string_view suffix) bool MAC_from_string(std::string mac_str, uint8_t *dmac) { mac_str.erase(std::remove(mac_str.begin(), mac_str.end(), ':'), mac_str.end()); - if (mac_str.length() == 12) { - dmac[0] = std::stoi(portduino_config.mac_address.substr(0, 2), nullptr, 16); - dmac[1] = std::stoi(portduino_config.mac_address.substr(2, 2), nullptr, 16); - dmac[2] = std::stoi(portduino_config.mac_address.substr(4, 2), nullptr, 16); - dmac[3] = std::stoi(portduino_config.mac_address.substr(6, 2), nullptr, 16); - dmac[4] = std::stoi(portduino_config.mac_address.substr(8, 2), nullptr, 16); - dmac[5] = std::stoi(portduino_config.mac_address.substr(10, 2), nullptr, 16); - return true; - } else { + if (mac_str.length() != 12) { + return false; + } + // Validate every character is a hex digit before parsing. std::stoi + // would otherwise skip leading whitespace and silently truncate at the + // first non-digit, which is too lenient for a MAC address. + for (char c : mac_str) { + if (!isxdigit(static_cast(c))) { + return false; + } + } + // Parse into a temporary so dmac is not partially modified if a later + // byte fails. At least one caller in getMacAddr() ignores the bool + // return, so leaving stale bytes in dmac on failure would silently + // produce a wrong MAC. + uint8_t tmp[6]; + try { + for (int i = 0; i < 6; i++) { + tmp[i] = static_cast(std::stoi(mac_str.substr(i * 2, 2), nullptr, 16)); + } + } catch (const std::exception &) { return false; } + memcpy(dmac, tmp, 6); + return true; } std::string exec(const char *cmd) diff --git a/test/test_mac_from_string/test_main.cpp b/test/test_mac_from_string/test_main.cpp new file mode 100644 index 00000000000..c9d2289cc4f --- /dev/null +++ b/test/test_mac_from_string/test_main.cpp @@ -0,0 +1,195 @@ +// Unit tests for MAC_from_string in src/platform/portduino/PortduinoGlue.cpp. +// +// Regression coverage for when the function stripped colons from +// its mac_str parameter but then read bytes from the global +// portduino_config.mac_address. Symptoms: --hwid silently ignored when +// MACAddress: was also set, and SIGABRT (stoi: no conversion) when --hwid +// was used without MACAddress: in config.yaml. +#include "Arduino.h" +#include "TestUtil.h" +#include +#include +#include +#include + +// Forward-declare instead of including PortduinoGlue.h to avoid pulling in +// LR11x0Interface, USBHal, mesh.pb.h, yaml-cpp, and the full portduino_config +// struct just to test a self-contained string parser. The symbol is defined +// in PortduinoGlue.cpp and resolved at link time. +bool MAC_from_string(std::string mac_str, uint8_t *dmac); + +void setUp(void) {} +void tearDown(void) {} + +// --- Happy-path parsing --- + +void test_colon_separated_uppercase() +{ + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AA:BB:CC:DD:EE:FF", dmac)); + TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xBB, dmac[1]); + TEST_ASSERT_EQUAL_HEX8(0xCC, dmac[2]); + TEST_ASSERT_EQUAL_HEX8(0xDD, dmac[3]); + TEST_ASSERT_EQUAL_HEX8(0xEE, dmac[4]); + TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]); +} + +void test_colon_separated_lowercase() +{ + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("02:ca:fe:ba:be:01", dmac)); + TEST_ASSERT_EQUAL_HEX8(0x02, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xCA, dmac[1]); + TEST_ASSERT_EQUAL_HEX8(0xFE, dmac[2]); + TEST_ASSERT_EQUAL_HEX8(0xBA, dmac[3]); + TEST_ASSERT_EQUAL_HEX8(0xBE, dmac[4]); + TEST_ASSERT_EQUAL_HEX8(0x01, dmac[5]); +} + +void test_no_colons_packed_hex() +{ + // The CLI form produced by some tools — 12 hex chars, no separators. + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AABBCCDDEEFF", dmac)); + TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]); +} + +void test_two_distinct_inputs_yield_distinct_outputs() +{ + // Direct regression for the original bug: parsing two different MAC + // strings in succession must produce two different byte sequences. + // Pre-fix, both calls would have produced identical bytes derived from + // the (untouched) global portduino_config.mac_address. + uint8_t a[6] = {0}; + uint8_t b[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AA:BB:CC:DD:EE:FF", a)); + TEST_ASSERT_TRUE(MAC_from_string("02:CA:FE:BA:BE:01", b)); + TEST_ASSERT_NOT_EQUAL(0, std::memcmp(a, b, 6)); + TEST_ASSERT_EQUAL_HEX8(0xAA, a[0]); + TEST_ASSERT_EQUAL_HEX8(0x02, b[0]); +} + +void test_does_not_read_external_state() +{ + // The function must derive every byte from its parameter, not from any + // global. Provide a unique MAC and verify all six bytes match the input + // exactly — leaves no room for the function to be smuggling bytes from + // elsewhere. + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("12:34:56:78:9A:BC", dmac)); + const uint8_t expected[6] = {0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC}; + TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, dmac, 6); +} + +// --- Rejected inputs --- +// Pre-fix, the empty/short cases either crashed (stoi exception on substr("") +// of the empty global) or silently filled dmac with stale bytes. Post-fix, +// the length guard rejects them cleanly with `false` and dmac is unchanged. + +void test_empty_string_returns_false() +{ + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("", dmac)); + // dmac must be untouched on failure. + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_too_short_returns_false() +{ + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_too_long_returns_false() +{ + uint8_t dmac[6] = {0}; + // 14 hex chars after colon-strip > 12. + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE:FF:00", dmac)); +} + +void test_only_colons_returns_false() +{ + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string(":::::", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_extra_colons_still_parses() +{ + // Colon stripping happens before length check, so an unconventional + // grouping that totals 12 hex chars after stripping is still accepted. + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AABB:CCDD:EEFF", dmac)); + TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]); +} + +void test_non_hex_input_returns_false() +{ + // 12 chars of non-hex would have made std::stoi throw before the + // try/catch wrapper was added, killing the daemon. Now must return false + // and leave dmac untouched. + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_partial_hex_failure_preserves_dmac() +{ + // First five bytes are valid hex; the sixth ("ZZ") is not. Without the + // temp-buffer staging, dmac would be partially overwritten with the five + // good bytes plus stale data in slot 5 — silently producing a wrong MAC + // since the only caller that uses this in getMacAddr() ignores the bool + // return value. + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE:ZZ", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_embedded_non_hex_returns_false() +{ + // std::stoi tolerates leading whitespace and a "0x" prefix, so a stray + // space inside a 2-char window like " F" would silently parse as 0xF. + // The per-character isxdigit() pre-check rejects these. The 14-char + // "0xAABBCCDDEEFF" is also rejected by the length check. + uint8_t dmac[6] = {0}; + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE: F", dmac)); + TEST_ASSERT_FALSE(MAC_from_string("0xAABBCCDDEEFF", dmac)); +} + +// --- Unity lifecycle --- + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_colon_separated_uppercase); + RUN_TEST(test_colon_separated_lowercase); + RUN_TEST(test_no_colons_packed_hex); + RUN_TEST(test_two_distinct_inputs_yield_distinct_outputs); + RUN_TEST(test_does_not_read_external_state); + RUN_TEST(test_empty_string_returns_false); + RUN_TEST(test_too_short_returns_false); + RUN_TEST(test_too_long_returns_false); + RUN_TEST(test_only_colons_returns_false); + RUN_TEST(test_extra_colons_still_parses); + RUN_TEST(test_non_hex_input_returns_false); + RUN_TEST(test_partial_hex_failure_preserves_dmac); + RUN_TEST(test_embedded_non_hex_returns_false); + exit(UNITY_END()); +} + +void loop() {} From 4ee959810795e94b6046c0bc4854f5ec6a859927 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 30 Apr 2026 16:22:11 -0400 Subject: [PATCH 122/225] Docker: Install grpcio-tools from distro (#10358) Use distro provided Python at build time (instead of the `python` images from dockerhub) and install `grpcio-tools` using the distro provided packages. This should speed up build times, ESPECIALLY on riscv64 (where prebuilt `grpcio-tools` wheels are not provided on pip). Co-authored-by: Copilot --- Dockerfile | 4 +++- alpine.Dockerfile | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e00d81658d8..ba013cb1557 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,15 +3,17 @@ # trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions -FROM python:3.14-slim-trixie AS builder +FROM debian:trixie AS builder ARG PIO_ENV=native ENV DEBIAN_FRONTEND=noninteractive ENV TZ=Etc/UTC # Install Dependencies ENV PIP_ROOT_USER_ACTION=ignore +ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN apt-get update && apt-get install --no-install-recommends -y \ curl wget g++ zip git ca-certificates pkg-config \ + python3-pip python3-grpc-tools \ libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \ libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \ libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \ diff --git a/alpine.Dockerfile b/alpine.Dockerfile index 40a4990bb8b..6d1b999e299 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -4,12 +4,18 @@ # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions # Ensure the Alpine version is updated in both stages of the container! -FROM python:3.14-alpine3.23 AS builder +FROM alpine:3.23 AS builder ARG PIO_ENV=native -ENV PIP_ROOT_USER_ACTION=ignore +# Enable Alpine community repository (for 'py3-grpcio-tools') +RUN echo "https://dl-cdn.alpinelinux.org/alpine/v$(cut -d. -f1,2 /etc/alpine-release)/community" >> /etc/apk/repositories + +# Install Dependencies +ENV PIP_ROOT_USER_ACTION=ignore +ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN apk --no-cache add \ bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \ + py3-pip py3-grpcio-tools \ libgpiod-dev yaml-cpp-dev bluez-dev \ libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \ libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \ From 1eb860a3fc3b2654458bfeffdd27ac004b0bf9e2 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Fri, 1 May 2026 21:25:19 +0800 Subject: [PATCH 123/225] fix(stm32wl,nrf52,fs): flash hardening, FS platform unification, write-behind LFS cache (FORMAT BREAK) (#10171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stm32wl: check HAL_FLASH_Unlock() return in _internal_flash_erase _internal_flash_prog already checks HAL_FLASH_Unlock() and returns LFS_ERR_IO on failure. _internal_flash_erase discarded the return value, proceeding to erase even if the flash was not unlocked. Apply the same check for consistency and safety. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl: fix _internal_flash_prog to abort on first write error Previously the programming loop continued to the next doubleword after HAL_FLASH_Program() failed, potentially writing to invalid addresses and returning a misleading error code only at the end (last iteration). HAL_FLASH_Lock() was also skipped on the mid-loop early return path. - Move bounds check before the loop (validate full range at once) - Break on first HAL error so subsequent doublewords are not written - Move HAL_FLASH_Lock() after the loop so it always runs Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl: clear stale flash SR error flags before erase and program Stale error flags in FLASH->SR from a previous failed operation can cause HAL_FLASH_Program() or HAL_FLASHEx_Erase() to return HAL_ERROR immediately without attempting the operation. Add __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS) after each HAL_FLASH_Unlock() in both _internal_flash_prog and _internal_flash_erase to ensure a clean state before each operation. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl: reject flash prog writes not aligned to 8-byte doubleword The STM32WL HAL minimum write unit is one 64-bit doubleword (8 bytes). _internal_flash_prog silently truncated any trailing bytes when size % 8 != 0 because dw_count = size / 8 drops the remainder. Return LFS_ERR_INVAL early so LittleFS sees the error rather than a silent short write. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fix(nrf52,fs): use atomic SafeFile rename instead of direct write NRF52 was bypassing the .tmp/readback/rename path entirely — openFile() deleted the target file and wrote directly to it, and close() returned true without verifying the write or renaming anything. Adafruit_LittleFS::rename() calls lfs_rename() directly (confirmed at Adafruit_LittleFS.cpp:205). Remove both ARCH_NRF52 guards so NRF52 follows the same write-to-.tmp → readback-hash → rename path used by all other platforms. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fix(admin): skip uiconfig.proto save on devices without a screen handleStoreDeviceUIConfig() was writing /prefs/uiconfig.proto unconditionally. MenuHandler.cpp is already gated behind #if HAS_SCREEN, so there is no path that populates UI config on screen-less platforms. Guard the save with #if HAS_SCREEN to avoid wasting a flash block on devices that will never use it. The read path (handleGetDeviceUIConfig) does not touch the filesystem and needs no change. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fs: enable format-on-retry for all platforms in saveToDisk The FSCom.format() call on save failure was guarded to ARCH_NRF52 with a comment that other platforms were not ready (bug #4184). STM32WL was added to the guard in a prior commit. All platforms now expose format semantics and the retry logic is identical — remove the guard. To keep NodeDB.cpp platform-agnostic and fix a CI failure on native-tft (portduino's fs::FS has no format() method), introduce fsFormat() in FSCommon as the single call-site for all callers: - Embedded (ESP32, NRF52, STM32WL, RP2040): delegates to FSCom.format() - Portduino: rmDir("/prefs") + FSBegin() (a no-op on portduino). rmDir("/prefs") is already called unconditionally by factoryReset() (NodeDB.cpp:504), so both primitives are proven on portduino. Replace both direct FSCom.format() calls in NodeDB.cpp with fsFormat(). Note: we do not run portduino locally — portduino/native build testers please verify the format-on-retry path. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * DO NOT MERGE: nrf52(fs): add File() default constructor bound to InternalFS Adds File() to the Adafruit LittleFS File class (in the Meshtastic Adafruit_nRF52_Arduino fork), delegating to File(InternalFS). This matches the default-constructible File API on all other platforms. The constructor is implemented in Adafruit_LittleFS_File.cpp rather than inline in the header to avoid a circular include between Adafruit_LittleFS_File.h and InternalFileSystem.h. FOLLOW-UP REQUIRED: nrf52.ini points to a commit SHA on the mesh-malaysia/Adafruit_nRF52_Arduino fork instead of the upstream meshtastic framework. Once meshtastic/Adafruit_nRF52_Arduino#5 is merged, revert nrf52.ini to point back to the upstream meshtastic framework URL. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl(fs): add File() default constructor and document LFS tunables Adds File() to STM32_LittleFS_Namespace::File, delegating to File(InternalFS). Implemented in the .cpp to avoid a circular include between STM32_LittleFS_File.h (which cannot include LittleFS.h) and the InternalFS extern declaration. This matches the File API on ESP32/RP2040/Portduino and is a prerequisite for removing the ARCH_STM32WL guard in xmodem.h. No behavior change — the constructor leaves the file in the same closed/unattached state as File(InternalFS) would. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fs: remove arch-specific ifdefs from FSCommon, SafeFile, xmodem Now that NRF52 and STM32WL have File() default constructors and NRF52 has working atomic SafeFile rename, the capability gaps are closed. Remove all per-arch guards across the shared FS layer: FSCommon.cpp — renameFile(): Use FSCom.rename() on all platforms. Adafruit_LittleFS::rename() calls lfs_rename() directly (Adafruit_LittleFS.cpp:205). The copy+delete fallback on NRF52/RP2040 was never necessary. FSCommon.cpp — getFiles(): Replace four ARCH_ESP32 guards with a single filepath pointer at the top of the loop (file.path() on ESP32, file.name() elsewhere). Fix strcpy(fileInfo.file_name, filepath): bounded to sizeof(fileInfo.file_name)-1 with explicit NUL termination to prevent overflow of the 228-byte meshtastic_FileInfo::file_name array. FSCommon.cpp — listDir(): Same filepath pointer approach. NRF52/STM32WL were in an else-branch that only logged but never deleted — now all platforms follow the unified del path. 12 guards → 2. Fix three strncpy(buffer, ..., sizeof(buffer)) calls that did not NUL-terminate when source length >= sizeof(buffer) (255 bytes). Add explicit buffer[sizeof(buffer)-1] = '\0' after each. FSCommon.cpp — rmDir(): Use listDir(del=true) everywhere. The ARCH_NRF52 rmdir_r() path and the ARCH_ESP32|RP2040|PORTDUINO listDir() path collapse to one line. SafeFile.cpp: ARCH_NRF52 bypass removed (handled in preceding commit). xmodem.h: File file; now works on all platforms via default constructors added in the two preceding commits. Remaining #ifdef ARCH_ESP32 in FSCommon.cpp: exactly 4, all for the file.path() vs file.name() API difference (ESP32 Arduino LittleFS returns the full path; all others return only the name). That difference lives in the framework and cannot be closed here. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl(fs): add write-behind page cache, reduce virtual block size and FS reservation (FORMAT BREAK) Adds a write-behind (RMW) page cache to the STM32WL LittleFS driver, modelled after the NRF52 Adafruit approach (flash_cache.c). This allows LFS to use 256-byte virtual blocks backed by 2048-byte physical pages: the erase/prog callbacks accumulate changes in a 2 KB RAM buffer; the sync callback (and page eviction on page-change) flushes with a single HAL physical-erase + doubleword-program pass. LFS tunables changed (FORMAT BREAK — superblock parameters): block_size: 2048 B → 256 B (8 virtual blocks per physical page) read_size: 2048 B → 256 B (= block_size) prog_size: 2048 B → 256 B (= block_size; hardware min is 8 B) block_count: 112 → 80 (14 phys pages → 10 phys pages = 20 KiB) Benefits: - Internal fragmentation: max 2047 B/file → max 255 B/file - Heap per open LFS file: ~4 KB → 512 B (prog + read buffers) - Code flash headroom: 6.7 KB → ~14.1 KB (+7.4 KB) - Block budget: 80 virtual blocks, worst-case peak ~20, ~60 free Updates board_upload.maximum_size in wio-e5/platformio.ini from 233472 (256 KB − 28 KB) to 241664 (256 KB − 20 KB) to match the reduced FS reservation. Justification for the format break: the prior STM32WL firmware had several flash write bugs fixed earlier in this series (missing error flag clearing, no abort on first write failure, unaligned write acceptance). These bugs very likely caused silent config corruption on deployed devices. The format break should be treated as an enhancement: it provides a clean, reliably-written starting point. Users will need to reconfigure their device once after this update. Correctness fixes applied to the cache implementation: - alignas(8) on _page_cache: the buffer was uint8_t[] (alignment 1) but _flash_cache_flush casts it to const uint64_t* — undefined behaviour per C++ standard, potential Cortex-M hardfault. alignas(8) guarantees the required alignment for the doubleword cast. - HAL_FLASH_Lock() return value: was discarded. Now assigned to lock_rc and propagated into rc if prior writes succeeded, so LFS sees the error rather than a false success. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl(fs): reduce FS reservation from 10 pages to 7 pages (FORMAT BREAK) Reduces LFS_FLASH_TOTAL_SIZE from 10 × 2 KiB pages (20 KiB) to 7 × 2 KiB pages (14 KiB), freeing 6 KiB for firmware. board_upload.maximum_size updated accordingly across all STM32WL variants: 241664 (256 KiB - 20 KiB) → 247808 (256 KiB - 14 KiB) This is a FORMAT BREAK: existing filesystems must be erased before use. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Andrew Yong * fix(fs): return false in renameFile() when FSCom is not defined Avoids undefined behavior and -Wreturn-type warnings in configurations that compile FSCommon.cpp without a filesystem backend. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 --------- Signed-off-by: Andrew Yong Co-authored-by: Ben Meadors --- src/FSCommon.cpp | 148 +++++------ src/FSCommon.h | 1 + src/SafeFile.cpp | 7 - src/mesh/NodeDB.cpp | 4 +- src/modules/AdminModule.cpp | 2 + src/platform/stm32wl/LittleFS.cpp | 237 +++++++++++------- src/platform/stm32wl/STM32_LittleFS_File.cpp | 6 + src/platform/stm32wl/STM32_LittleFS_File.h | 1 + src/xmodem.h | 4 - variants/stm32/CDEBYTE_E77-MBL/platformio.ini | 2 +- variants/stm32/milesight_gs301/platformio.ini | 2 +- variants/stm32/rak3172/platformio.ini | 2 +- variants/stm32/russell/platformio.ini | 2 +- variants/stm32/wio-e5/platformio.ini | 2 +- 14 files changed, 218 insertions(+), 202 deletions(-) diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index f215be80fb6..8fafc6c52da 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -79,28 +79,46 @@ bool copyFile(const char *from, const char *to) bool renameFile(const char *pathFrom, const char *pathTo) { #ifdef FSCom - -#ifdef ARCH_ESP32 - // take SPI Lock spiLock->lock(); - // rename was fixed for ESP32 IDF LittleFS in April bool result = FSCom.rename(pathFrom, pathTo); spiLock->unlock(); return result; #else - // copyFile does its own locking. - if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) { - return true; - } else { - return false; - } -#endif - + return false; #endif } #include +/** + * @brief Platform-agnostic filesystem format / wipe. + * + * On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the + * native FSCom.format() which erases and reinitialises the LittleFS + * partition. + * + * On Portduino the fs::FS backend has no format() method. We instead + * delete /prefs (the only meshtastic data directory written at runtime) + * and return. rmDir("/prefs") is already called unconditionally by + * factoryReset() so this is a proven primitive on Portduino. + * FSBegin() is a no-op (#define FSBegin() true) on Portduino. + * + * @return true on success, false on failure or if no filesystem is configured. + */ +bool fsFormat() +{ +#ifdef FSCom +#if defined(ARCH_PORTDUINO) + rmDir("/prefs"); + return FSBegin(); +#else + return FSCom.format(); +#endif +#else + return false; +#endif +} + /** * @brief Get the list of files in a directory. * @@ -123,23 +141,21 @@ std::vector getFiles(const char *dirname, uint8_t levels) File file = root.openNextFile(); while (file) { - if (file.isDirectory() && !String(file.name()).endsWith(".")) { - if (levels) { #ifdef ARCH_ESP32 - std::vector subDirFilenames = getFiles(file.path(), levels - 1); + const char *filepath = file.path(); #else - std::vector subDirFilenames = getFiles(file.name(), levels - 1); + const char *filepath = file.name(); #endif + if (file.isDirectory() && !String(file.name()).endsWith(".")) { + if (levels) { + std::vector subDirFilenames = getFiles(filepath, levels - 1); filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end()); file.close(); } } else { meshtastic_FileInfo fileInfo = {"", static_cast(file.size())}; -#ifdef ARCH_ESP32 - strcpy(fileInfo.file_name, file.path()); -#else - strcpy(fileInfo.file_name, file.name()); -#endif + 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); } @@ -163,98 +179,59 @@ std::vector getFiles(const char *dirname, uint8_t levels) void listDir(const char *dirname, uint8_t levels, bool del) { #ifdef FSCom -#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) char buffer[255]; -#endif File root = FSCom.open(dirname, FILE_O_READ); - if (!root) { - return; - } - if (!root.isDirectory()) { + if (!root || !root.isDirectory()) return; - } File file = root.openNextFile(); - while ( - file && - file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395) + while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395 +#ifdef ARCH_ESP32 + const char *filepath = file.path(); +#else + const char *filepath = file.name(); +#endif if (file.isDirectory() && !String(file.name()).endsWith(".")) { if (levels) { -#ifdef ARCH_ESP32 - listDir(file.path(), levels - 1, del); + listDir(filepath, levels - 1, del); if (del) { - LOG_DEBUG("Remove %s", file.path()); - strncpy(buffer, file.path(), sizeof(buffer)); + LOG_DEBUG("Remove %s", filepath); + strncpy(buffer, filepath, sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; file.close(); FSCom.rmdir(buffer); } else { file.close(); } -#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) - listDir(file.name(), levels - 1, del); - if (del) { - LOG_DEBUG("Remove %s", file.name()); - strncpy(buffer, file.name(), sizeof(buffer)); - file.close(); - FSCom.rmdir(buffer); - } else { - file.close(); - } -#else - LOG_DEBUG(" %s (directory)", file.name()); - listDir(file.name(), levels - 1, del); - file.close(); -#endif } } else { -#ifdef ARCH_ESP32 - if (del) { - LOG_DEBUG("Delete %s", file.path()); - strncpy(buffer, file.path(), sizeof(buffer)); - file.close(); - FSCom.remove(buffer); - } else { - LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size()); - file.close(); - } -#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) if (del) { - LOG_DEBUG("Delete %s", file.name()); - strncpy(buffer, file.name(), sizeof(buffer)); + LOG_DEBUG("Delete %s", filepath); + strncpy(buffer, filepath, sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; file.close(); FSCom.remove(buffer); } else { - LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size()); + LOG_DEBUG(" %s (%i Bytes)", filepath, file.size()); file.close(); } -#else - LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size()); - file.close(); -#endif } file = root.openNextFile(); } #ifdef ARCH_ESP32 + const char *rootpath = root.path(); +#else + const char *rootpath = root.name(); +#endif if (del) { - LOG_DEBUG("Remove %s", root.path()); - strncpy(buffer, root.path(), sizeof(buffer)); - root.close(); - FSCom.rmdir(buffer); - } else { - root.close(); - } -#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) - if (del) { - LOG_DEBUG("Remove %s", root.name()); - strncpy(buffer, root.name(), sizeof(buffer)); + LOG_DEBUG("Remove %s", rootpath); + strncpy(buffer, rootpath, sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; root.close(); FSCom.rmdir(buffer); } else { root.close(); } -#else - root.close(); -#endif #endif } @@ -268,14 +245,7 @@ void listDir(const char *dirname, uint8_t levels, bool del) void rmDir(const char *dirname) { #ifdef FSCom - -#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) listDir(dirname, 10, true); -#elif defined(ARCH_NRF52) - // nRF52 implementation of LittleFS has a recursive delete function - FSCom.rmdir_r(dirname); -#endif - #endif } diff --git a/src/FSCommon.h b/src/FSCommon.h index fdc0b76ecd1..9fe71e47b41 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -52,6 +52,7 @@ void fsInit(); void fsListFiles(); bool copyFile(const char *from, const char *to); bool renameFile(const char *pathFrom, const char *pathTo); +bool fsFormat(); std::vector getFiles(const char *dirname, uint8_t levels); void listDir(const char *dirname, uint8_t levels, bool del = false); void rmDir(const char *dirname); diff --git a/src/SafeFile.cpp b/src/SafeFile.cpp index 39436f18e69..0173fde816a 100644 --- a/src/SafeFile.cpp +++ b/src/SafeFile.cpp @@ -7,10 +7,6 @@ static File openFile(const char *filename, bool fullAtomic) { concurrency::LockGuard g(spiLock); LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic); -#ifdef ARCH_NRF52 - FSCom.remove(filename); - return FSCom.open(filename, FILE_O_WRITE); -#endif if (!fullAtomic) { FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists) } @@ -67,9 +63,6 @@ bool SafeFile::close() f.close(); spiLock->unlock(); -#ifdef ARCH_NRF52 - return true; -#endif if (!testReadback()) return false; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 083db6561ca..ac6880adea4 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1611,12 +1611,10 @@ bool NodeDB::saveToDisk(int saveWhat) if (!success) { LOG_ERROR("Failed to save to disk, retrying"); -#ifdef ARCH_NRF52 // @geeksville is not ready yet to say we should do this on other platforms. See bug #4184 discussion spiLock->lock(); - FSCom.format(); + fsFormat(); spiLock->unlock(); -#endif success = saveToDiskNoRetry(saveWhat); RECORD_CRITICALERROR(success ? meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 865ac38f526..7b249f656cd 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1412,7 +1412,9 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot) void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg) { +#if HAS_SCREEN nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uicfg); +#endif } void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) diff --git a/src/platform/stm32wl/LittleFS.cpp b/src/platform/stm32wl/LittleFS.cpp index 40f32eca89d..4b57fb151e5 100644 --- a/src/platform/stm32wl/LittleFS.cpp +++ b/src/platform/stm32wl/LittleFS.cpp @@ -25,23 +25,27 @@ #include "LittleFS.h" #include "stm32wlxx_hal_flash.h" -/********************************************************************************************************************** - * Macro definitions - **********************************************************************************************************************/ -/** This macro is used to suppress compiler messages about a parameter not being used in a function. */ +/** Suppress unused-parameter warnings. */ #define LFS_UNUSED(p) (void)((p)) -#define STM32WL_PAGE_SIZE (FLASH_PAGE_SIZE) +#define STM32WL_PAGE_SIZE (FLASH_PAGE_SIZE) // physical flash erase granularity: 2048 B #define STM32WL_PAGE_COUNT (FLASH_PAGE_NB) #define STM32WL_FLASH_BASE (FLASH_BASE) /* - * FLASH_SIZE from stm32wle5xx.h will read the actual FLASH size from the chip. - * FLASH_END_ADDR is calculated from FLASH_SIZE. - * Use the last 28 KiB of the FLASH + * LFS tunables — all of these are stored in the LFS superblock. + * Changing ANY of them is incompatible with the existing on-disk format; + * the filesystem will be detected as corrupted and reformatted on first boot. + * + * LFS_FLASH_TOTAL_SIZE and LFS_BLOCK_SIZE are the only values to edit here. + * All other parameters are derived. + * + * FLASH_END_ADDR is computed from FLASH_SIZE (read from the chip at link time). */ -#define LFS_FLASH_TOTAL_SIZE (14 * 2048) /* needs to be a multiple of LFS_BLOCK_SIZE */ -#define LFS_BLOCK_SIZE (2048) +#define LFS_FLASH_TOTAL_SIZE \ + (7 * STM32WL_PAGE_SIZE) /* 14 KiB — last 7 physical pages (FORMAT BREAK: reduced from 10 pages / 20 KiB) */ +#define LFS_BLOCK_SIZE (256) /* virtual block size (FORMAT BREAK if changed) */ + #define LFS_FLASH_ADDR_END (FLASH_END_ADDR) #define LFS_FLASH_ADDR_BASE (LFS_FLASH_ADDR_END - LFS_FLASH_TOTAL_SIZE + 1) @@ -51,6 +55,80 @@ #define _LFS_DBG(fmt, ...) printf("%s:%d (%s): " fmt "\n", __FILE__, __LINE__, __func__, __VA_ARGS__) #endif +//--------------------------------------------------------------------+ +// Write-behind page cache +// +// LFS requires block_size == erase granularity, but the STM32WL flash +// erases in 2048-byte pages. To use smaller virtual LFS blocks we +// maintain a single-page RAM cache: the LFS erase/prog callbacks only +// update this buffer; the physical erase+reprogram is deferred to +// _internal_flash_sync() (or triggered automatically when a different +// physical page is addressed). +// +// This mirrors the approach used by the NRF52 Adafruit driver +// (flash_cache.c / flash_nrf5x.c) but adapted for the 2048-byte STM32WL +// page size and HAL doubleword-program requirement. +//--------------------------------------------------------------------+ + +alignas(8) static uint8_t _page_cache[STM32WL_PAGE_SIZE]; +static uint32_t _page_cache_addr = UINT32_MAX; // UINT32_MAX = no page cached +static bool _page_cache_dirty = false; + +/** Flush the cached page to flash (physical erase + doubleword program). */ +static int _flash_cache_flush(void) +{ + if (!_page_cache_dirty) + return LFS_ERR_OK; + + FLASH_EraseInitTypeDef erase = { + .TypeErase = FLASH_TYPEERASE_PAGES, + .Page = (_page_cache_addr - STM32WL_FLASH_BASE) / STM32WL_PAGE_SIZE, + .NbPages = 1, + }; + uint32_t page_error = 0; + + if (HAL_FLASH_Unlock() != HAL_OK) + return LFS_ERR_IO; + __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS); + + HAL_StatusTypeDef rc = HAL_FLASHEx_Erase(&erase, &page_error); + if (rc == HAL_OK) { + const uint64_t *p = (const uint64_t *)_page_cache; + uint32_t addr = _page_cache_addr; + for (size_t i = 0; i < STM32WL_PAGE_SIZE / 8; i++) { + rc = HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, addr, *p++); + if (rc != HAL_OK) + break; + addr += 8; + } + } + HAL_StatusTypeDef lock_rc = HAL_FLASH_Lock(); + if (rc == HAL_OK) + rc = lock_rc; + + if (rc == HAL_OK) + _page_cache_dirty = false; + return rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; +} + +/** + * Ensure the physical page containing `page_addr` is loaded into the cache. + * If a different dirty page is already cached it is flushed first. + */ +static int _flash_cache_load(uint32_t page_addr) +{ + if (_page_cache_addr == page_addr) + return LFS_ERR_OK; // already cached + + int rc = _flash_cache_flush(); + if (rc != LFS_ERR_OK) + return rc; + + memcpy(_page_cache, (const void *)page_addr, STM32WL_PAGE_SIZE); + _page_cache_addr = page_addr; + return LFS_ERR_OK; +} + //--------------------------------------------------------------------+ // LFS Disk IO //--------------------------------------------------------------------+ @@ -59,111 +137,82 @@ static int _internal_flash_read(const struct lfs_config *c, lfs_block_t block, l { LFS_UNUSED(c); - if (!buffer || !size) { - _LFS_DBG("%s Invalid parameter!\r\n", __func__); - return LFS_ERR_INVAL; - } - - lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE + off); + uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE + off; + uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1); + uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1); - memcpy(buffer, (void *)address, size); + if (_page_cache_addr == page_addr) + memcpy(buffer, _page_cache + page_off, size); + else + memcpy(buffer, (const void *)addr, size); return LFS_ERR_OK; } -// Program a region in a block. The block must have previously -// been erased. Negative error codes are propogated to the user. -// May return LFS_ERR_CORRUPT if the block should be considered bad. +// Program a region in a block. The block must have previously been erased. +// Writes are accumulated in the page cache and flushed on sync or page eviction. static int _internal_flash_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { - lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE + off); - HAL_StatusTypeDef hal_rc = HAL_OK; - uint32_t dw_count = size / 8; - uint64_t *bufp = (uint64_t *)buffer; - LFS_UNUSED(c); - _LFS_DBG("Programming %d bytes/%d doublewords at address 0x%08x/block %d, offset %d.", size, dw_count, address, block, off); - if (HAL_FLASH_Unlock() != HAL_OK) { - return LFS_ERR_IO; - } - for (uint32_t i = 0; i < dw_count; i++) { - if ((address < LFS_FLASH_ADDR_BASE) || (address > LFS_FLASH_ADDR_END)) { - _LFS_DBG("Wanted to program out of bound of FLASH: 0x%08x.\n", address); - HAL_FLASH_Lock(); - return LFS_ERR_INVAL; - } - hal_rc = HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, address, *bufp); - if (hal_rc != HAL_OK) { - /* Error occurred while writing data in Flash memory. - * User can add here some code to deal with this error. - */ - _LFS_DBG("Program error at (0x%08x), 0x%X, error: 0x%08x\n", address, hal_rc, HAL_FLASH_GetError()); - } - address += 8; - bufp += 1; - } - if (HAL_FLASH_Lock() != HAL_OK) { - return LFS_ERR_IO; - } + uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE + off; + uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1); + uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1); + + int rc = _flash_cache_load(page_addr); + if (rc != LFS_ERR_OK) + return rc; - return hal_rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; // If HAL_OK, return LFS_ERR_OK, else return LFS_ERR_IO + memcpy(_page_cache + page_off, buffer, size); + _page_cache_dirty = true; + return LFS_ERR_OK; } -// Erase a block. A block must be erased before being programmed. -// The state of an erased block is undefined. Negative error codes -// are propogated to the user. -// May return LFS_ERR_CORRUPT if the block should be considered bad. +// Erase a virtual block. Marks the corresponding region in the page cache as 0xFF. +// Physical erase of the containing page is deferred until sync or page eviction. static int _internal_flash_erase(const struct lfs_config *c, lfs_block_t block) { - lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE); - HAL_StatusTypeDef hal_rc; - FLASH_EraseInitTypeDef EraseInitStruct = {.TypeErase = FLASH_TYPEERASE_PAGES, .Page = 0, .NbPages = 1}; - uint32_t PAGEError = 0; - LFS_UNUSED(c); - if ((address < LFS_FLASH_ADDR_BASE) || (address > LFS_FLASH_ADDR_END)) { - _LFS_DBG("Wanted to erase out of bound of FLASH: 0x%08x.\n", address); - return LFS_ERR_INVAL; - } - /* calculate the absolute page, i.e. what the ST wants */ - EraseInitStruct.Page = (address - STM32WL_FLASH_BASE) / STM32WL_PAGE_SIZE; - _LFS_DBG("Erasing block %d at 0x%08x... ", block, address); - HAL_FLASH_Unlock(); - hal_rc = HAL_FLASHEx_Erase(&EraseInitStruct, &PAGEError); - HAL_FLASH_Lock(); - - return hal_rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; // If HAL_OK, return LFS_ERR_OK, else return LFS_ERR_IO + uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE; + uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1); + uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1); + + int rc = _flash_cache_load(page_addr); + if (rc != LFS_ERR_OK) + return rc; + + memset(_page_cache + page_off, 0xFF, LFS_BLOCK_SIZE); + _page_cache_dirty = true; + return LFS_ERR_OK; } -// Sync the state of the underlying block device. Negative error codes -// are propogated to the user. +// Flush the write-behind cache to flash. static int _internal_flash_sync(const struct lfs_config *c) { LFS_UNUSED(c); - // write function performs no caching. No need for sync. - - return LFS_ERR_OK; + return _flash_cache_flush(); } -static struct lfs_config _InternalFSConfig = {.context = NULL, +static struct lfs_config _InternalFSConfig = { + .context = NULL, - .read = _internal_flash_read, - .prog = _internal_flash_prog, - .erase = _internal_flash_erase, - .sync = _internal_flash_sync, + .read = _internal_flash_read, + .prog = _internal_flash_prog, + .erase = _internal_flash_erase, + .sync = _internal_flash_sync, - .read_size = LFS_BLOCK_SIZE, - .prog_size = LFS_BLOCK_SIZE, - .block_size = LFS_BLOCK_SIZE, - .block_count = LFS_FLASH_TOTAL_SIZE / LFS_BLOCK_SIZE, - .lookahead = 128, + .read_size = LFS_BLOCK_SIZE, + .prog_size = LFS_BLOCK_SIZE, + .block_size = LFS_BLOCK_SIZE, + .block_count = LFS_FLASH_TOTAL_SIZE / LFS_BLOCK_SIZE, + .lookahead = 128, - .read_buffer = NULL, - .prog_buffer = NULL, - .lookahead_buffer = NULL, - .file_buffer = NULL}; + .read_buffer = NULL, + .prog_buffer = NULL, + .lookahead_buffer = NULL, + .file_buffer = NULL, +}; LittleFS InternalFS; @@ -179,17 +228,17 @@ bool LittleFS::begin(void) /* There is not enough space on this device for a filesystem. */ return false; } - // failed to mount, erase all pages then format and mount again + // failed to mount, erase all virtual blocks then format and mount again if (!STM32_LittleFS::begin()) { - // Erase all pages of internal flash region for Filesystem. - for (uint32_t addr = LFS_FLASH_ADDR_BASE; addr < (LFS_FLASH_ADDR_END + 1); addr += STM32WL_PAGE_SIZE) { - _internal_flash_erase(&_InternalFSConfig, (addr - LFS_FLASH_ADDR_BASE) / STM32WL_PAGE_SIZE); + for (lfs_block_t block = 0; block < _InternalFSConfig.block_count; block++) { + _internal_flash_erase(&_InternalFSConfig, block); } + _flash_cache_flush(); // flush the last cached page // lfs format this->format(); - // mount again if still failed, give up + // mount again; if still failed, give up if (!STM32_LittleFS::begin()) return false; } diff --git a/src/platform/stm32wl/STM32_LittleFS_File.cpp b/src/platform/stm32wl/STM32_LittleFS_File.cpp index 349187a02bb..a85a3ee02c7 100644 --- a/src/platform/stm32wl/STM32_LittleFS_File.cpp +++ b/src/platform/stm32wl/STM32_LittleFS_File.cpp @@ -22,6 +22,7 @@ * THE SOFTWARE. */ +#include "LittleFS.h" #include "STM32_LittleFS.h" #include @@ -391,3 +392,8 @@ void File::rewindDirectory(void) } _fs->_unlockFS(); } + +// Default constructor — binds to the global InternalFS instance. +// Allows File to be declared without an explicit filesystem argument, +// matching the API of ESP32/RP2040/Portduino File objects. +File::File() : File(InternalFS) {} diff --git a/src/platform/stm32wl/STM32_LittleFS_File.h b/src/platform/stm32wl/STM32_LittleFS_File.h index 2b48b02e00c..71b98352c24 100644 --- a/src/platform/stm32wl/STM32_LittleFS_File.h +++ b/src/platform/stm32wl/STM32_LittleFS_File.h @@ -44,6 +44,7 @@ class File : public Stream public: explicit File(STM32_LittleFS &fs); File(char const *filename, uint8_t mode, STM32_LittleFS &fs); + File(); // default-constructs against InternalFS; defined in STM32_LittleFS_File.cpp public: bool open(char const *filename, uint8_t mode); diff --git a/src/xmodem.h b/src/xmodem.h index 4cfcb43e18b..7b665e0acdb 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -61,11 +61,7 @@ class XModemAdapter uint16_t packetno = 0; -#if defined(ARCH_NRF52) || defined(ARCH_STM32WL) - File file = File(FSCom); -#else File file; -#endif char filename[sizeof(meshtastic_XModem_buffer_t::bytes)] = {0}; diff --git a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini index b4c0c958ff4..cb980db10f0 100644 --- a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini +++ b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini @@ -1,7 +1,7 @@ [env:CDEBYTE_E77-MBL] extends = stm32_base board = ebyte_e77_dev -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) board_level = extra build_flags = ${stm32_base.build_flags} diff --git a/variants/stm32/milesight_gs301/platformio.ini b/variants/stm32/milesight_gs301/platformio.ini index 73b9cf7ea73..8bc063a9139 100644 --- a/variants/stm32/milesight_gs301/platformio.ini +++ b/variants/stm32/milesight_gs301/platformio.ini @@ -4,7 +4,7 @@ extends = stm32_base board = wiscore_rak3172 ; Convenient choice as the same USART is used for programming/debug board_level = extra -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/milesight_gs301 diff --git a/variants/stm32/rak3172/platformio.ini b/variants/stm32/rak3172/platformio.ini index 4d96e98f93f..de8f2b74b41 100644 --- a/variants/stm32/rak3172/platformio.ini +++ b/variants/stm32/rak3172/platformio.ini @@ -2,7 +2,7 @@ extends = stm32_base board = wiscore_rak3172 board_level = pr -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/rak3172 diff --git a/variants/stm32/russell/platformio.ini b/variants/stm32/russell/platformio.ini index 73cf7f81a2c..57f73f6d05b 100644 --- a/variants/stm32/russell/platformio.ini +++ b/variants/stm32/russell/platformio.ini @@ -8,7 +8,7 @@ extends = stm32_base board = wiscore_rak3172 board_level = extra -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/russell diff --git a/variants/stm32/wio-e5/platformio.ini b/variants/stm32/wio-e5/platformio.ini index c8dbb2b7236..8c7579aa3dc 100644 --- a/variants/stm32/wio-e5/platformio.ini +++ b/variants/stm32/wio-e5/platformio.ini @@ -2,7 +2,7 @@ extends = stm32_base board = lora_e5_dev_board board_level = pr -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/wio-e5 From 90744ee0b767b354b7194f05b570ad4ca154f25d Mon Sep 17 00:00:00 2001 From: Jason P Date: Fri, 1 May 2026 08:46:53 -0500 Subject: [PATCH 124/225] Update PhoneAPI.cpp to reduce chattiness (#10367) --- src/mesh/PhoneAPI.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 2a3a2a26c27..9ff27d33eaa 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -512,11 +512,6 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) // LOG_INFO("nodeinfo: num=0x%x, lastseen=%u, id=%s, name=%s", nodeInfoForPhone.num, nodeInfoForPhone.last_heard, // nodeInfoForPhone.user.id, nodeInfoForPhone.user.long_name); - // Occasional progress logging. (readIndex==2 will be true for the first non-us node) - if (readIndex == 2 || readIndex % 20 == 0) { - LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes()); - } - fromRadioScratch.which_payload_variant = meshtastic_FromRadio_node_info_tag; fromRadioScratch.node_info = infoToSend; prefetchNodeInfos(); @@ -647,9 +642,11 @@ void PhoneAPI::releaseQueueStatusPhonePacket() void PhoneAPI::prefetchNodeInfos() { bool added = false; + bool wasEmpty = false; // Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment. { concurrency::LockGuard guard(&nodeInfoMutex); + wasEmpty = nodeInfoQueue.empty(); while (nodeInfoQueue.size() < kNodePrefetchDepth) { auto nextNode = nodeDB->readNextMeshNode(readIndex); if (!nextNode) @@ -663,11 +660,15 @@ void PhoneAPI::prefetchNodeInfos() info.via_mqtt = isUs ? false : info.via_mqtt; info.is_favorite = info.is_favorite || isUs; nodeInfoQueue.push_back(info); + // Log progress here (at fetch time) so readIndex is accurate and each value logs only once. + if (readIndex == 2 || readIndex % 20 == 0) { + LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes()); + } added = true; } } - if (added) + if (added && wasEmpty) onNowHasData(0); } From 55f40ecdfdd3d41a5a19d7ed4c787efbca86fedb Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 1 May 2026 08:56:49 -0500 Subject: [PATCH 125/225] Add ulfius webserver support to macos native target (#10366) * Add ulfius webserver support to macos native target * fix: update PiWebServer docs for macOS and add explicit cstring include Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/3ce82582-23e0-4afe-b22f-b24f81721488 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: add --cflags to openssl@3 pkg-config and fix apt package name Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/1a6c59aa-4393-4134-8cee-61eeee0e9127 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mesh/raspihttp/PiWebServer.cpp | 35 +++++++++++++++------ src/mesh/raspihttp/PiWebServer.h | 6 +++- src/platform/portduino/PortduinoGlue.cpp | 40 ++++++++++++++++++++++-- variants/native/portduino/platformio.ini | 38 +++++++++++++++++++++- 4 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index 3e9dbe8c20a..5485f8eb2ab 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -1,22 +1,34 @@ /* -Adds a WebServer and WebService callbacks to meshtastic as Linux Version. The WebServer & Webservices -runs in a real linux thread beside the portdunio threading emulation. It replaces the complete ESP32 -Webserver libs including generation of SSL certifcicates, because the use ESP specific details in -the lib that can't be emulated. +Adds a WebServer and WebService callbacks to meshtastic via the Portduino/native target (Linux and +macOS). The WebServer & Webservices run in a real host thread beside the Portduino threading +emulation. It replaces the complete ESP32 Webserver libs including generation of SSL certificates, +because those libs use ESP-specific details that can't be emulated. The WebServices adapt to the two major phoneapi functions "handleAPIv1FromRadio,handleAPIv1ToRadio" -The WebServer just adds basaic support to deliver WebContent, so it can be used to -deliver the WebGui definded by the WebClient Project. +The WebServer just adds basic support to deliver WebContent, so it can be used to +deliver the WebGui defined by the WebClient Project. Steps to get it running: -1.) Add these Linux Libs to the compile and target machine: + +Linux (apt): +1.) Add these libs to the compile and target machine: sudo apt update && \ - apt -y install openssl libssl-dev libopenssl libsdl2-dev \ + apt -y install openssl libssl-dev libsdl2-dev \ libulfius-dev liborcania-dev +macOS (Homebrew): +1.) Install prerequisites via Homebrew: + + brew install ulfius openssl@3 + + The PlatformIO env (native-macos) picks up compiler/linker flags via + `pkg-config`. In particular, OpenSSL needs `pkg-config --cflags --libs openssl@3` + so both the Homebrew include path and linker flags are provided; ulfius and its + dependencies (liborcania, libyder) are also resolved via `pkg-config`. + 2.) Configure the root directory of the web Content in the config.yaml file. - The followinng tags should be included and set at your needs + The following tags should be included and set at your needs Example entry in the config.yaml Webserver: @@ -34,7 +46,10 @@ Author: Marc Philipp Hammermann mail: marchammermann@googlemail.com */ -#ifdef PORTDUINO_LINUX_HARDWARE +// Mirrors the guard in PiWebServer.h — see comment there. macOS Homebrew +// provides ulfius + deps; Linux pulls them via apt. Either way, this +// translation unit only compiles when the headers are present. +#ifdef ARCH_PORTDUINO #if __has_include() #include "PiWebServer.h" #include "NodeDB.h" diff --git a/src/mesh/raspihttp/PiWebServer.h b/src/mesh/raspihttp/PiWebServer.h index 74b094f8cd8..24b7de4b158 100644 --- a/src/mesh/raspihttp/PiWebServer.h +++ b/src/mesh/raspihttp/PiWebServer.h @@ -1,5 +1,9 @@ #pragma once -#ifdef PORTDUINO_LINUX_HARDWARE +// Portduino webserver is built whenever the ulfius headers are reachable, +// not only on Linux. macOS users can `brew install ulfius` to enable it; +// without ulfius the entire body is skipped and main.cpp's matching +// __has_include guard avoids referencing the type. +#ifdef ARCH_PORTDUINO #if __has_include() #include "PhoneAPI.h" #include "ulfius-cfg.h" diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index eeb56240dae..1f59e78b5ab 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -33,6 +33,16 @@ #include #endif +#ifdef __APPLE__ +// Used by getMacAddr()'s macOS fallback to read the en0 link-layer address. +// `getifaddrs()` is the BSD-portable way; `` provides the +// `sockaddr_dl` cast and the `LLADDR()` macro that points at the 6-byte MAC. +#include // strcmp, memcpy +#include +#include +#include +#endif + #include "platform/portduino/USBHal.h" portduino_config_struct portduino_config; @@ -156,9 +166,35 @@ void getMacAddr(uint8_t *dmac) dmac[3] = di.bdaddr.b[2]; dmac[4] = di.bdaddr.b[1]; dmac[5] = di.bdaddr.b[0]; +#elif defined(__APPLE__) + // No BlueZ on macOS, but we can fall back to the host's primary + // network interface MAC. `en0` is Wi-Fi on every shipping Mac + // (Ethernet, when present, is en1 or higher), which gives the user + // the same kind of stable, host-derived identifier that the BlueZ + // path provides on Linux. If en0 isn't found or has no MAC, dmac is + // left untouched and the caller's "Blank MAC Address not allowed!" + // check will still fire — preserving existing behavior for users + // who deliberately rely on --hwid or YAML override. + struct ifaddrs *ifap = nullptr; + if (getifaddrs(&ifap) == 0) { + for (struct ifaddrs *p = ifap; p != nullptr; p = p->ifa_next) { + if (p->ifa_addr == nullptr || p->ifa_addr->sa_family != AF_LINK) { + continue; + } + if (strcmp(p->ifa_name, "en0") != 0) { + continue; + } + auto *sdl = reinterpret_cast(p->ifa_addr); + if (sdl->sdl_alen == 6) { + memcpy(dmac, LLADDR(sdl), 6); + break; + } + } + freeifaddrs(ifap); + } #else - // No BlueZ on non-Linux hosts (e.g. macOS). Leave dmac at its default; - // the caller can override via the --hwid CLI flag or the YAML config. + // No platform-specific MAC source; leave dmac at its default. Caller + // can override via the --hwid CLI flag or the YAML config. (void)dmac; #endif } diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index e493da77b6a..0a47e72839e 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -125,6 +125,8 @@ test_testing_command = ; ; Prerequisites (Homebrew): ; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config +; # Optional: enable the HTTP API (PiWebServer) on macOS: +; brew install ulfius ; ; The macOS-side patches now live upstream: ; * meshtastic/platform-native — `String.h`-shadow shim, `-Wno-enum-constexpr-conversion`, @@ -191,7 +193,16 @@ build_flags = ${portduino_base.build_flags_common} ; style screen-driver hooks scattered through sensor sources. -DHAS_SCREEN=0 -DMESHTASTIC_EXCLUDE_SCREEN=1 - !pkg-config --libs openssl --silence-errors || : + ; openssl@3 is the keg-only Homebrew formula; --cflags is required so the + ; compiler finds in the Homebrew prefix (not just the linker). + !pkg-config --cflags --libs openssl@3 --silence-errors || : + ; PiWebServer (src/mesh/raspihttp/PiWebServer.cpp) auto-engages when ulfius + ; headers are reachable via `#if __has_include()`. The `|| :` + ; tail keeps the build green when the user hasn't run `brew install ulfius` + ; — they just don't get the HTTP API in that case. + !pkg-config --cflags --libs libulfius --silence-errors || : + !pkg-config --cflags --libs liborcania --silence-errors || : + !pkg-config --cflags --libs libyder --silence-errors || : ; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist ; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX ; (which we lib_ignore on macOS for the issue). Neither is needed @@ -206,3 +217,28 @@ build_src_filter = ${native_base.build_src_filter} lib_ignore = ${portduino_base.lib_ignore} LovyanGFX + +; --------------------------------------------------------------------------- +; Same as [env:native-macos] but built with AddressSanitizer for catching +; use-after-free, leaks, and OOB access during local development. Headless +; (no SDL/X11/libinput) so it stays cheap to build. Mirrors the shape of +; [env:native-tft-debug] but without the TFT/X11 dependencies. +; +; pio run -e native-macos-debug +; .pio/build/native-macos-debug/meshtasticd -s +; +; ASan runtime tuning (set in the shell before launching): +; ASAN_OPTIONS=detect_leaks=1:halt_on_error=0:abort_on_error=1 +; MallocStackLogging=1 # macOS: nicer stack traces in malloc reports +; --------------------------------------------------------------------------- +[env:native-macos-debug] +extends = native_base +build_type = debug +build_unflags = ${env:native-macos.build_unflags} +build_flags = ${env:native-macos.build_flags} + -O0 + -g + -fsanitize=address + -fno-omit-frame-pointer +build_src_filter = ${env:native-macos.build_src_filter} +lib_ignore = ${env:native-macos.lib_ignore} From c0fcf807c068dbbdbfd2005b28cb23f8990e71b6 Mon Sep 17 00:00:00 2001 From: Austin Date: Fri, 1 May 2026 10:42:17 -0400 Subject: [PATCH 126/225] MacOS: Correct pkg-config name `openssl` for ulfius. (#10369) --- .github/workflows/build_macos_bin.yml | 2 +- variants/native/portduino/platformio.ini | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_macos_bin.yml b/.github/workflows/build_macos_bin.yml index cde2dd8165b..d0e89d7da6e 100644 --- a/.github/workflows/build_macos_bin.yml +++ b/.github/workflows/build_macos_bin.yml @@ -24,7 +24,7 @@ jobs: shell: bash run: | brew update - brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config + brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius - name: Get release version string run: | diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 0a47e72839e..6d1bd02f353 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -195,14 +195,12 @@ build_flags = ${portduino_base.build_flags_common} -DMESHTASTIC_EXCLUDE_SCREEN=1 ; openssl@3 is the keg-only Homebrew formula; --cflags is required so the ; compiler finds in the Homebrew prefix (not just the linker). - !pkg-config --cflags --libs openssl@3 --silence-errors || : + !pkg-config --cflags --libs openssl --silence-errors || : ; PiWebServer (src/mesh/raspihttp/PiWebServer.cpp) auto-engages when ulfius ; headers are reachable via `#if __has_include()`. The `|| :` ; tail keeps the build green when the user hasn't run `brew install ulfius` ; — they just don't get the HTTP API in that case. !pkg-config --cflags --libs libulfius --silence-errors || : - !pkg-config --cflags --libs liborcania --silence-errors || : - !pkg-config --cflags --libs libyder --silence-errors || : ; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist ; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX ; (which we lib_ignore on macOS for the issue). Neither is needed From 0240a00d0934e021dea8fab56630a67b41676284 Mon Sep 17 00:00:00 2001 From: Austin Lane Date: Fri, 1 May 2026 10:55:32 -0400 Subject: [PATCH 127/225] MacOS: Re-Add Orcania/Yder --- variants/native/portduino/platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 6d1bd02f353..d334a1901d5 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -200,6 +200,8 @@ build_flags = ${portduino_base.build_flags_common} ; headers are reachable via `#if __has_include()`. The `|| :` ; tail keeps the build green when the user hasn't run `brew install ulfius` ; — they just don't get the HTTP API in that case. + !pkg-config --cflags --libs liborcania --silence-errors || : + !pkg-config --cflags --libs libyder --silence-errors || : !pkg-config --cflags --libs libulfius --silence-errors || : ; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist ; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX From b7a9555905cf9bc22ef01028e108ab9fbf5a1a01 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Fri, 1 May 2026 12:50:07 -0500 Subject: [PATCH 128/225] Update RadioLib dependency to a specific commit (#10370) Exploratory PR to test new radiolib change --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 195879bff02..8a312907511 100644 --- a/platformio.ini +++ b/platformio.ini @@ -120,7 +120,7 @@ lib_deps = [radiolib_base] lib_deps = # renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib - https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip + https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip [device-ui_base] lib_deps = From 7cb071c780d7cb4290d7b39ac1a399dc62437901 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 18:25:08 -0400 Subject: [PATCH 129/225] Update platform-native digest to cab4b21 (#10372) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/native/portduino.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 35c8c66972b..6ff3d0686c4 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/4ea5e09ac7d51a593e12ec7c1ebb6cd06745ce53.zip + https://github.com/meshtastic/platform-native/archive/cab4b21d902973e43c938dab3cf4844ba02547ec.zip framework = arduino build_src_filter = From 2d761f645349d7bf9a6a4f22e20569330b1d0a88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 07:23:49 -0500 Subject: [PATCH 130/225] Upgrade trunk (#10364) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 1913c6604d8..d2ccc60a47d 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -8,7 +8,7 @@ plugins: uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.525 + - checkov@3.2.526 - renovate@43.150.0 - prettier@3.8.3 - trufflehog@3.95.2 From 41f53177a12f3370059477ab41fd8ba3f1c17722 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 2 May 2026 08:25:24 -0500 Subject: [PATCH 131/225] Use OBS instead of launchpad (#10375) --- .github/workflows/build_debian_src.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_debian_src.yml b/.github/workflows/build_debian_src.yml index d1bcd889890..8d2076b113f 100644 --- a/.github/workflows/build_debian_src.yml +++ b/.github/workflows/build_debian_src.yml @@ -32,10 +32,15 @@ jobs: shell: bash working-directory: meshtasticd run: | + # Build-tools (notably platformio) come from the Meshtastic project + # on the OpenSUSE Build Service: + # https://build.opensuse.org/project/show/network:Meshtastic:build-tools + echo 'deb http://download.opensuse.org/repositories/network:/Meshtastic:/build-tools/xUbuntu_24.04/ /' \ + | sudo tee /etc/apt/sources.list.d/network:Meshtastic:build-tools.list + curl -fsSL https://download.opensuse.org/repositories/network:Meshtastic:build-tools/xUbuntu_24.04/Release.key \ + | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/network_Meshtastic_build-tools.gpg >/dev/null sudo apt-get update -y --fix-missing - sudo apt-get install -y software-properties-common build-essential devscripts equivs - sudo add-apt-repository ppa:meshtastic/build-tools -y - sudo apt-get update -y --fix-missing + sudo apt-get install -y build-essential devscripts equivs sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control - name: Import GPG key From b2bda3b07ada98a543284abd46a179725b04df5a Mon Sep 17 00:00:00 2001 From: Jord <650645+Jord-JD@users.noreply.github.com> Date: Sat, 2 May 2026 22:38:06 +0100 Subject: [PATCH 132/225] Enable MESHTASTIC_PREHOP_DROP by default (#9924) --- src/configuration.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/configuration.h b/src/configuration.h index e0284e6c982..d263d9ae1c6 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -80,7 +80,7 @@ along with this program. If not, see . // Pre-hop drop handling (compile-time flag). #ifndef MESHTASTIC_PREHOP_DROP -#define MESHTASTIC_PREHOP_DROP 0 +#define MESHTASTIC_PREHOP_DROP 1 #endif /// Convert a preprocessor name into a quoted string From 6ea0d5ebbaa6580b16ab8e5ba2109430901ac162 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 4 May 2026 16:19:48 -0500 Subject: [PATCH 133/225] Add TFT_BACKLIGHT_ON for cardputer to fix builds (#10387) --- variants/esp32s3/m5stack_cardputer_adv/variant.h | 1 + 1 file changed, 1 insertion(+) diff --git a/variants/esp32s3/m5stack_cardputer_adv/variant.h b/variants/esp32s3/m5stack_cardputer_adv/variant.h index 5fdb1436eb7..48437cd1346 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/variant.h +++ b/variants/esp32s3/m5stack_cardputer_adv/variant.h @@ -9,6 +9,7 @@ #define ST7789_BUSY -1 // #define VTFT_CTRL 38 #define VTFT_LEDA 38 +#define TFT_BACKLIGHT_ON HIGH // #define ST7789_BL (32+6) #define ST7789_SPI_HOST SPI2_HOST // #define TFT_BL (32+6) From d559af8477d207da88d8f687fea7d134eaf13deb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 12:29:04 +0200 Subject: [PATCH 134/225] Update LovyanGFX to v1.2.21 (#10373) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32/chatter2/platformio.ini | 2 +- variants/esp32/m5stack_core/platformio.ini | 2 +- variants/esp32/wiphone/platformio.ini | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 2 +- variants/esp32s3/heltec_v4_r8/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini | 2 +- variants/esp32s3/mesh-tab/platformio.ini | 2 +- variants/esp32s3/picomputer-s3/platformio.ini | 2 +- variants/esp32s3/rak_wismesh_tap_v2/platformio.ini | 2 +- variants/esp32s3/t-deck/platformio.ini | 2 +- variants/esp32s3/t-watch-s3/platformio.ini | 2 +- variants/esp32s3/tlora-pager/platformio.ini | 2 +- variants/esp32s3/tracksenger/platformio.ini | 4 ++-- variants/esp32s3/unphone/platformio.ini | 2 +- variants/native/portduino.ini | 2 +- 17 files changed, 18 insertions(+), 18 deletions(-) diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index a14e407a10c..b0adeee4b7e 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -12,4 +12,4 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 diff --git a/variants/esp32/m5stack_core/platformio.ini b/variants/esp32/m5stack_core/platformio.ini index 8fbbae8956b..4f0b556acba 100644 --- a/variants/esp32/m5stack_core/platformio.ini +++ b/variants/esp32/m5stack_core/platformio.ini @@ -35,4 +35,4 @@ lib_ignore = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 diff --git a/variants/esp32/wiphone/platformio.ini b/variants/esp32/wiphone/platformio.ini index fbd77be7542..b1c1f8bf749 100644 --- a/variants/esp32/wiphone/platformio.ini +++ b/variants/esp32/wiphone/platformio.ini @@ -11,7 +11,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 # renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander sparkfun/SX1509 IO Expander@3.0.6 # renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102 diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 103cac94175..790f0292440 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -133,6 +133,6 @@ build_flags = lib_deps = ${heltec_v4_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini index 747cc8c49d2..7799acf437d 100644 --- a/variants/esp32s3/heltec_v4_r8/platformio.ini +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -140,6 +140,6 @@ build_flags = lib_deps = ${heltec_v4_r8_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index 33643c54168..1450bb45ce3 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -24,4 +24,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini index ab6592afb5a..ffcc1fe953f 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index ebf0118bbed..53f698c9ea2 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -21,4 +21,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 diff --git a/variants/esp32s3/mesh-tab/platformio.ini b/variants/esp32s3/mesh-tab/platformio.ini index a153ba9fb3a..fcb58d36ac3 100644 --- a/variants/esp32s3/mesh-tab/platformio.ini +++ b/variants/esp32s3/mesh-tab/platformio.ini @@ -55,7 +55,7 @@ lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 [mesh_tab_xpt2046] extends = mesh_tab_base diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index 6f218a126de..b5a4ff178f2 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -25,7 +25,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 build_src_filter = ${esp32s3_base.build_src_filter} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index 7847410ae99..1d3314aab08 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -37,7 +37,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 [env:rak_wismesh_tap_v2-tft] extends = env:rak_wismesh_tap_v2 diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 1b3599464e4..a7701549ff3 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index 35239681887..6e7db86301f 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -22,7 +22,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index 832f9d7d7f6..15abfadf384 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -33,7 +33,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/tracksenger/platformio.ini b/variants/esp32s3/tracksenger/platformio.ini index c006cf835d7..44d07d9e81b 100644 --- a/variants/esp32s3/tracksenger/platformio.ini +++ b/variants/esp32s3/tracksenger/platformio.ini @@ -22,7 +22,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 [env:tracksenger-lcd] custom_meshtastic_hw_model = 48 @@ -48,7 +48,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 [env:tracksenger-oled] custom_meshtastic_hw_model = 48 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index 3c342e2ac1f..56838b1fc70 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -37,7 +37,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 # TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0 https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 6ff3d0686c4..9ab45d1ab66 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -27,7 +27,7 @@ lib_deps = # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto rweather/Crypto@0.4.0 # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.19 + lovyan03/LovyanGFX@1.2.21 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main https://github.com/pine64/libch341-spi-userspace/archive/23c42319a69cffcb65868e3c72e6bed83974a393.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library From fcef46f4b0591efa1e957a8df15c4c277521a4d7 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Tue, 5 May 2026 12:52:07 +0100 Subject: [PATCH 135/225] dependency swap - INA3221Sensor (#10379) * dependency swap - INA3221Sensor update INA3221 initialization and measurement methods for compatibility with Rob Tillaart's library Co-authored-by: Copilot * Addresses copilot review Co-authored-by: Copilot * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Refine comments on USB detection and INA3221 Updated comments regarding USB detection and INA3221 usage. * Fix static_assert conditions for INA3221 channel definitions Co-authored-by: Copilot * moved macro defines earlier to allow better use. Co-authored-by: Copilot * Add raw register read methods for bus voltage and shunt current in INA3221Sensor Co-authored-by: Copilot --------- Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- platformio.ini | 4 +- .../Telemetry/Sensor/INA3221Sensor.cpp | 67 ++++++++++++++----- src/modules/Telemetry/Sensor/INA3221Sensor.h | 25 +++++-- variants/nrf52840/rak3401_1watt/variant.h | 4 +- variants/nrf52840/rak4631/variant.h | 4 +- 5 files changed, 79 insertions(+), 25 deletions(-) diff --git a/platformio.ini b/platformio.ini index 8a312907511..95c89a3ef14 100644 --- a/platformio.ini +++ b/platformio.ini @@ -170,8 +170,8 @@ lib_deps = https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip # renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614 https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip - # renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow - https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip + # renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT + https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip # renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip # renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU diff --git a/src/modules/Telemetry/Sensor/INA3221Sensor.cpp b/src/modules/Telemetry/Sensor/INA3221Sensor.cpp index 78081132a88..d3b9b16f017 100644 --- a/src/modules/Telemetry/Sensor/INA3221Sensor.cpp +++ b/src/modules/Telemetry/Sensor/INA3221Sensor.cpp @@ -16,10 +16,28 @@ int32_t INA3221Sensor::runOnce() return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; } if (!status) { - ina3221.begin(nodeTelemetrySensorsMap[sensorType].second); - ina3221.setShuntRes(100, 100, 100); // 0.1 Ohm shunt resistors - status = true; + // Re-initialise with the address and Wire bus from the telemetry sensors map. + // (Rob Tillaart INA3221_RT takes address + TwoWire*, unlike sgtwilko which took Wire in begin().) + ina3221 = INA3221(nodeTelemetrySensorsMap[sensorType].first, nodeTelemetrySensorsMap[sensorType].second); + status = ina3221.begin(); + if (status) { + // Default all three channels to a 0.1 Ω shunt resistor. + // Override per-variant by defining INA3221_SHUNT_R_CH1/CH2/CH3 (in Ohms) in variant.h. +#ifndef INA3221_SHUNT_R_CH1 +#define INA3221_SHUNT_R_CH1 0.1f +#endif +#ifndef INA3221_SHUNT_R_CH2 +#define INA3221_SHUNT_R_CH2 0.1f +#endif +#ifndef INA3221_SHUNT_R_CH3 +#define INA3221_SHUNT_R_CH3 0.1f +#endif + ina3221.setShuntR(0, INA3221_SHUNT_R_CH1); + ina3221.setShuntR(1, INA3221_SHUNT_R_CH2); + ina3221.setShuntR(2, INA3221_SHUNT_R_CH3); + } } else { + // Already initialised; status stays true and initI2CSensor() returns next poll interval. status = true; } return initI2CSensor(); @@ -27,12 +45,14 @@ int32_t INA3221Sensor::runOnce() void INA3221Sensor::setup() {} -struct _INA3221Measurement INA3221Sensor::getMeasurement(ina3221_ch_t ch) +struct _INA3221Measurement INA3221Sensor::getMeasurement(uint8_t ch) { struct _INA3221Measurement measurement; - measurement.voltage = ina3221.getVoltage(ch); - measurement.current = ina3221.getCurrent(ch); + measurement.voltage = ina3221.getBusVoltage(ch); // Volts + // getCurrent_mA() is used instead of getCurrent() because Rob Tillaart's getCurrent() + // returns Amperes; the telemetry proto and VoltageSensor/CurrentSensor interfaces expect mA. + measurement.current = ina3221.getCurrent_mA(ch); // milliAmps return measurement; } @@ -43,7 +63,7 @@ struct _INA3221Measurements INA3221Sensor::getMeasurements() // INA3221 has 3 channels starting from 0 for (int i = 0; i < 3; i++) { - measurements.measurements[i] = getMeasurement((ina3221_ch_t)i); + measurements.measurements[i] = getMeasurement((uint8_t)i); } return measurements; @@ -87,24 +107,41 @@ bool INA3221Sensor::getPowerMetrics(meshtastic_Telemetry *measurement) measurement->variant.power_metrics.has_ch3_voltage = true; measurement->variant.power_metrics.has_ch3_current = true; - measurement->variant.power_metrics.ch1_voltage = m.measurements[INA3221_CH1].voltage; - measurement->variant.power_metrics.ch1_current = m.measurements[INA3221_CH1].current; - measurement->variant.power_metrics.ch2_voltage = m.measurements[INA3221_CH2].voltage; - measurement->variant.power_metrics.ch2_current = m.measurements[INA3221_CH2].current; - measurement->variant.power_metrics.ch3_voltage = m.measurements[INA3221_CH3].voltage; - measurement->variant.power_metrics.ch3_current = m.measurements[INA3221_CH3].current; + // INA3221 channel indices are zero-based (0=CH1, 1=CH2, 2=CH3). + measurement->variant.power_metrics.ch1_voltage = m.measurements[0].voltage; + measurement->variant.power_metrics.ch1_current = m.measurements[0].current; + measurement->variant.power_metrics.ch2_voltage = m.measurements[1].voltage; + measurement->variant.power_metrics.ch2_current = m.measurements[1].current; + measurement->variant.power_metrics.ch3_voltage = m.measurements[2].voltage; + measurement->variant.power_metrics.ch3_current = m.measurements[2].current; return true; } uint16_t INA3221Sensor::getBusVoltageMv() { - return lround(ina3221.getVoltage(BAT_CH) * 1000); + return lround(ina3221.getBusVoltage_mV(BAT_CH)); } int16_t INA3221Sensor::getCurrentMa() { - return lround(ina3221.getCurrent(BAT_CH)); + return lround(ina3221.getCurrent_mA(BAT_CH)); +} + +// Bus voltage register (0x02 + ch*2): bits [15:3] unsigned, 1 LSB = 8 mV (datasheet p.6). +// Voltage raw units: 1 count = 8 mV, so V_mV = raw * 8. +int16_t INA3221Sensor::getRawBusVoltage(uint8_t ch) +{ + return (int16_t)(ina3221.getRegister(0x02 + ch * 2) >> 3); +} + +// Shunt voltage register (0x01 + ch*2): bits [15:3] signed two's complement, 1 LSB = 40 µV (datasheet p.6). +// Current raw units are shunt-voltage counts: 1 count = 40 uV, signed. +// I_mA = (raw * 40 uV) / R_mOhm, because uV / mOhm = mA. +// Example for 100 mOhm shunt: I_mA = raw * 40 / 100 = raw * 0.4. +int16_t INA3221Sensor::getRawShuntCurrent(uint8_t ch) +{ + return (int16_t)(ina3221.getRegister(0x01 + ch * 2) >> 3); } #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/INA3221Sensor.h b/src/modules/Telemetry/Sensor/INA3221Sensor.h index 0581f92f6db..bb2dbe7b3cd 100644 --- a/src/modules/Telemetry/Sensor/INA3221Sensor.h +++ b/src/modules/Telemetry/Sensor/INA3221Sensor.h @@ -1,3 +1,9 @@ +// INA3221 channel aliases (zero-based: 0 = CH1, 1 = CH2, 2 = CH3). +// Defined before configuration.h so variant.h can use them in INA3221_ENV_CH / INA3221_BAT_CH. +#define INA3221_CH1 0 +#define INA3221_CH2 1 +#define INA3221_CH3 2 + #include "configuration.h" #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() @@ -9,26 +15,29 @@ #include #ifndef INA3221_ENV_CH -#define INA3221_ENV_CH INA3221_CH1 +#define INA3221_ENV_CH INA3221_CH1 // channel to report in environment metrics (default: CH1) #endif #ifndef INA3221_BAT_CH -#define INA3221_BAT_CH INA3221_CH1 +#define INA3221_BAT_CH INA3221_CH1 // channel for device_battery_ina_address (default: CH1) #endif class INA3221Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor { private: - INA3221 ina3221 = INA3221(INA3221_ADDR42_SDA); + // Placeholder constructor; re-initialised with correct address and Wire in runOnce(). + INA3221 ina3221 = INA3221(INA3221_ADDR); // channel to report voltage/current for environment metrics - static const ina3221_ch_t ENV_CH = INA3221_ENV_CH; + static const uint8_t ENV_CH = INA3221_ENV_CH; + static_assert(INA3221_ENV_CH >= 0 && INA3221_ENV_CH <= 2, "INA3221_ENV_CH must be 0, 1, or 2"); // channel to report battery voltage for device_battery_ina_address - static const ina3221_ch_t BAT_CH = INA3221_BAT_CH; + static const uint8_t BAT_CH = INA3221_BAT_CH; + static_assert(INA3221_BAT_CH >= 0 && INA3221_BAT_CH <= 2, "INA3221_BAT_CH must be 0, 1, or 2"); // get a single measurement for a channel - struct _INA3221Measurement getMeasurement(ina3221_ch_t ch); + struct _INA3221Measurement getMeasurement(uint8_t ch); // get all measurements for all channels struct _INA3221Measurements getMeasurements(); @@ -45,6 +54,10 @@ class INA3221Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor bool getMetrics(meshtastic_Telemetry *measurement) override; virtual uint16_t getBusVoltageMv() override; virtual int16_t getCurrentMa() override; + + // Raw register reads (bits [15:3] right-shifted), no conversion applied. + int16_t getRawBusVoltage(uint8_t ch); + int16_t getRawShuntCurrent(uint8_t ch); }; struct _INA3221Measurement { diff --git a/variants/nrf52840/rak3401_1watt/variant.h b/variants/nrf52840/rak3401_1watt/variant.h index 80b09cf695f..a154e6a41db 100644 --- a/variants/nrf52840/rak3401_1watt/variant.h +++ b/variants/nrf52840/rak3401_1watt/variant.h @@ -166,7 +166,9 @@ static const uint8_t SCK = PIN_SPI_SCK; // Testing USB detection #define NRF_APM // If using a power chip like the INA3221 you can override the default battery voltage channel below -// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging +// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging. +// INA3221Sensor.h provides INA3221_CH1/INA3221_CH2/INA3221_CH3 compatibility aliases, so +// board variants can continue to use the named channel constants here. // #define INA3221_BAT_CH INA3221_CH2 // #define INA3221_ENV_CH INA3221_CH1 diff --git a/variants/nrf52840/rak4631/variant.h b/variants/nrf52840/rak4631/variant.h index 6a6b32f2797..8ea272a1559 100644 --- a/variants/nrf52840/rak4631/variant.h +++ b/variants/nrf52840/rak4631/variant.h @@ -220,7 +220,9 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG // Testing USB detection #define NRF_APM // If using a power chip like the INA3221 you can override the default battery voltage channel below -// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging +// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging. +// INA3221Sensor.h provides compatibility aliases such as INA3221_CH1/INA3221_CH2/INA3221_CH3, +// so board variants can continue to use the channel names below. // #define INA3221_BAT_CH INA3221_CH2 // #define INA3221_ENV_CH INA3221_CH1 From 603cce2988b8640d94199403ad6bd35b23062fd0 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 5 May 2026 10:12:50 -0500 Subject: [PATCH 136/225] Add informSearchFailed method to update GPS power state handling (#10394) --- src/gps/GPS.cpp | 7 +++++-- src/gps/GPSUpdateScheduling.cpp | 10 ++++++++++ src/gps/GPSUpdateScheduling.h | 3 ++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 1260d8b15fb..f4267818230 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -1017,10 +1017,13 @@ void GPS::up() setPowerState(GPS_ACTIVE); } -// We've got a GPS lock. Enter a low power state, potentially. +// We've finished a GPS search cycle (lock or timeout). Enter a low power state, potentially. void GPS::down() { - scheduling.informGotLock(); + if (hasValidLocation) + scheduling.informGotLock(); + else + scheduling.informSearchFailed(); uint32_t predictedSearchDuration = scheduling.predictedSearchDurationMs(); uint32_t sleepTime = scheduling.msUntilNextSearch(); uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval); diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp index 53d6c833f83..45634d2d359 100644 --- a/src/gps/GPSUpdateScheduling.cpp +++ b/src/gps/GPSUpdateScheduling.cpp @@ -17,6 +17,16 @@ void GPSUpdateScheduling::informGotLock() updateLockTimePrediction(); } +// 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 +// down() to fall into GPS_IDLE, leaving the chip awake on subsequent indoor cycles. +void GPSUpdateScheduling::informSearchFailed() +{ + searchEndedMs = millis(); + LOG_DEBUG("GPS search ended without fix after %us", (searchEndedMs - searchStartedMs) / 1000); +} + // Clear old lock-time prediction data. // When re-enabling GPS with user button. void GPSUpdateScheduling::reset() diff --git a/src/gps/GPSUpdateScheduling.h b/src/gps/GPSUpdateScheduling.h index 7e121c9b688..64835a4694a 100644 --- a/src/gps/GPSUpdateScheduling.h +++ b/src/gps/GPSUpdateScheduling.h @@ -8,7 +8,8 @@ class GPSUpdateScheduling public: // Marks the time of these events, for calculation use void informSearching(); - void informGotLock(); // Predicted lock-time is recalculated here + void informGotLock(); // Predicted lock-time is recalculated here + void informSearchFailed(); // Search ended without a fix; prediction is left untouched void reset(); // Reset the prediction - after GPS::disable() / GPS::enable() bool isUpdateDue(); // Is it time to begin searching for a GPS position? From cdc47a2aeaa564732382d9a7d539abeda9da6b49 Mon Sep 17 00:00:00 2001 From: jessm33 <112707725+jessm33@users.noreply.github.com> Date: Tue, 5 May 2026 15:17:04 -0400 Subject: [PATCH 137/225] Fix GPS initialization logic for Portduino configuration (#10395) Co-authored-by: jessm33 --- src/gps/GPS.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index cf5511a333a..814983d2f81 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -1553,7 +1553,12 @@ std::unique_ptr GPS::createGps() _en_gpio = PIN_GPS_EN; #endif #ifdef ARCH_PORTDUINO - if (!portduino_config.has_gps) + if (portduino_config.has_gps) { + // These need to set as flags so later checks will pass on native and GPS will work. + // They are not used for any hardware access. + _rx_gpio = 1; + _tx_gpio = 1; + } else return nullptr; #endif if (!_rx_gpio || !_serial_gps) // Configured to have no GPS at all From 6e810741f329a77011a4941e2a839fad43a2e800 Mon Sep 17 00:00:00 2001 From: jessm33 <112707725+jessm33@users.noreply.github.com> Date: Tue, 5 May 2026 15:17:04 -0400 Subject: [PATCH 138/225] Fix GPS initialization logic for Portduino configuration (#10395) Co-authored-by: jessm33 --- src/gps/GPS.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index f4267818230..3bc047ad806 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -1548,7 +1548,12 @@ std::unique_ptr GPS::createGps() _en_gpio = PIN_GPS_EN; #endif #ifdef ARCH_PORTDUINO - if (!portduino_config.has_gps) + if (portduino_config.has_gps) { + // These need to set as flags so later checks will pass on native and GPS will work. + // They are not used for any hardware access. + _rx_gpio = 1; + _tx_gpio = 1; + } else return nullptr; #endif if (!_rx_gpio || !_serial_gps) // Configured to have no GPS at all From 220bb4d1865ffc7536d387f794e420060317adf2 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 6 May 2026 15:33:59 -0500 Subject: [PATCH 139/225] Smart pointers and memory management cleanup (#10400) * Refactor memory management in Syslog and StoreForwardModule * Implement destructor for Lock * Refactor RotaryEncoder and PacketHistory to use smart pointers for better memory management * CH341 should use unique_ptr for improved memory management * Fix checks in PH * Improve Syslog::vlogf to handle variable argument lists more safely * Fix initOk method to use nullptr for null pointer check --- src/DebugConfiguration.cpp | 37 ++++++++-------- src/concurrency/Lock.cpp | 7 +++ src/concurrency/Lock.h | 1 + src/input/RotaryEncoderImpl.cpp | 11 ++--- src/input/RotaryEncoderImpl.h | 3 +- src/mesh/PacketHistory.cpp | 55 ++++++++++-------------- src/mesh/PacketHistory.h | 8 ++-- src/modules/StoreForwardModule.cpp | 4 +- src/modules/StoreForwardModule.h | 9 +++- src/platform/portduino/PortduinoGlue.cpp | 8 ++-- 10 files changed, 70 insertions(+), 73 deletions(-) diff --git a/src/DebugConfiguration.cpp b/src/DebugConfiguration.cpp index 207feb8c0b9..f83624bbf48 100644 --- a/src/DebugConfiguration.cpp +++ b/src/DebugConfiguration.cpp @@ -26,6 +26,8 @@ SOFTWARE.*/ #include "DebugConfiguration.h" +#include + #ifdef ARCH_PORTDUINO #include "platform/portduino/PortduinoGlue.h" #endif @@ -119,27 +121,22 @@ bool Syslog::vlogf(uint16_t pri, const char *fmt, va_list args) bool Syslog::vlogf(uint16_t pri, const char *appName, const char *fmt, va_list args) { - char *message; - size_t initialLen; - size_t len; - bool result; - - initialLen = strlen(fmt); - - message = new char[initialLen + 1]; - - len = vsnprintf(message, initialLen + 1, fmt, args); - if (len > initialLen) { - delete[] message; - message = new char[len + 1]; - - vsnprintf(message, len + 1, fmt, args); - } - - result = this->_sendLog(pri, appName, message); + // First measure the formatted length using a copy of args; passing args directly + // to vsnprintf consumes it, and reusing a consumed va_list is undefined behavior. + va_list args_measure; + va_copy(args_measure, args); + int needed = vsnprintf(nullptr, 0, fmt, args_measure); + va_end(args_measure); + + if (needed < 0) + return false; // encoding error + + auto message = std::unique_ptr(new char[static_cast(needed) + 1]); + int written = vsnprintf(message.get(), static_cast(needed) + 1, fmt, args); + if (written < 0) + return false; - delete[] message; - return result; + return this->_sendLog(pri, appName, message.get()); } inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *message) diff --git a/src/concurrency/Lock.cpp b/src/concurrency/Lock.cpp index 0fe80e455fe..4596e0edfde 100644 --- a/src/concurrency/Lock.cpp +++ b/src/concurrency/Lock.cpp @@ -14,6 +14,11 @@ Lock::Lock() : handle(xSemaphoreCreateBinary()) } } +Lock::~Lock() +{ + vSemaphoreDelete(handle); +} + void Lock::lock() { if (xSemaphoreTake(handle, portMAX_DELAY) == false) { @@ -30,6 +35,8 @@ void Lock::unlock() #else Lock::Lock() {} +Lock::~Lock() {} + void Lock::lock() {} void Lock::unlock() {} diff --git a/src/concurrency/Lock.h b/src/concurrency/Lock.h index 1b9ea20d580..a5124811780 100644 --- a/src/concurrency/Lock.h +++ b/src/concurrency/Lock.h @@ -12,6 +12,7 @@ class Lock { public: Lock(); + ~Lock(); Lock(const Lock &) = delete; Lock &operator=(const Lock &) = delete; diff --git a/src/input/RotaryEncoderImpl.cpp b/src/input/RotaryEncoderImpl.cpp index cc122259572..dcdbf0d36ba 100644 --- a/src/input/RotaryEncoderImpl.cpp +++ b/src/input/RotaryEncoderImpl.cpp @@ -13,7 +13,6 @@ RotaryEncoderImpl *rotaryEncoderImpl; RotaryEncoderImpl::RotaryEncoderImpl() { - rotary = nullptr; #ifdef ARCH_ESP32 isFirstInit = true; #endif @@ -23,11 +22,6 @@ RotaryEncoderImpl::~RotaryEncoderImpl() { LOG_DEBUG("RotaryEncoderImpl destructor"); detachRotaryEncoderInterrupts(); - - if (rotary != nullptr) { - delete rotary; - rotary = nullptr; - } } bool RotaryEncoderImpl::init() @@ -43,8 +37,9 @@ bool RotaryEncoderImpl::init() eventPressed = static_cast(moduleConfig.canned_message.inputbroker_event_press); if (rotary == nullptr) { - rotary = new RotaryEncoder(moduleConfig.canned_message.inputbroker_pin_a, moduleConfig.canned_message.inputbroker_pin_b, - moduleConfig.canned_message.inputbroker_pin_press); + rotary.reset(new RotaryEncoder(moduleConfig.canned_message.inputbroker_pin_a, + moduleConfig.canned_message.inputbroker_pin_b, + moduleConfig.canned_message.inputbroker_pin_press)); } attachRotaryEncoderInterrupts(); diff --git a/src/input/RotaryEncoderImpl.h b/src/input/RotaryEncoderImpl.h index ec8a064bd33..58b4e3450d4 100644 --- a/src/input/RotaryEncoderImpl.h +++ b/src/input/RotaryEncoderImpl.h @@ -5,6 +5,7 @@ #include "InputBroker.h" #include "concurrency/OSThread.h" #include "mesh/NodeDB.h" +#include class RotaryEncoder; @@ -28,7 +29,7 @@ class RotaryEncoderImpl final : public InputPollable input_broker_event eventCcw = INPUT_BROKER_NONE; input_broker_event eventPressed = INPUT_BROKER_NONE; - RotaryEncoder *rotary; + std::unique_ptr rotary; private: #ifdef ARCH_ESP32 diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index 845a936d4cd..861b5fdfbc0 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -16,7 +16,7 @@ #define VERBOSE_PACKET_HISTORY 0 // Set to 1 for verbose logging, 2 for heavy debugging #define PACKET_HISTORY_TRACE_AGING 1 // Set to 1 to enable logging of the age of re/used history slots -PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPackets(NULL) // Initialize members +PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initialize members { if (size < 4 || size > PACKETHISTORY_MAX) { // Copilot suggested - makes sense LOG_WARN("Packet History - Invalid size %d, using default %d", size, PACKETHISTORY_MAX); @@ -25,7 +25,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPa // Allocate memory for the recent packets array recentPacketsCapacity = size; - recentPackets = new PacketRecord[recentPacketsCapacity]; + recentPackets.reset(new PacketRecord[recentPacketsCapacity]); if (!recentPackets) { // No logging here, console/log probably uninitialized yet. LOG_ERROR("Packet History - Memory allocation failed for size=%d entries / %d Bytes", size, sizeof(PacketRecord) * recentPacketsCapacity); @@ -34,14 +34,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPa } // Initialize the recent packets array to zero - memset(recentPackets, 0, sizeof(PacketRecord) * recentPacketsCapacity); -} - -PacketHistory::~PacketHistory() -{ - recentPacketsCapacity = 0; - delete[] recentPackets; - recentPackets = NULL; + memset(recentPackets.get(), 0, sizeof(PacketRecord) * recentPacketsCapacity); } /** Update recentPackets and return true if we have already seen this packet */ @@ -205,13 +198,14 @@ PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id) return NULL; } + PacketRecord *base = recentPackets.get(); PacketRecord *it = NULL; - for (it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) { + for (it = base; it < (base + recentPacketsCapacity); ++it) { if (it->id == id && it->sender == sender) { #if VERBOSE_PACKET_HISTORY LOG_DEBUG("Packet History - find: s=%08x id=%08x FOUND nh=%02x rby=%02x %02x %02x age=%d slot=%d/%d", it->sender, it->id, it->next_hop, it->relayed_by[0], it->relayed_by[1], it->relayed_by[2], millis() - (it->rxTimeMsec), - it - recentPackets, recentPacketsCapacity); + it - base, recentPacketsCapacity); #endif // only the first match is returned, so be careful not to create duplicate entries return it; // Return pointer to the found record @@ -229,39 +223,38 @@ void PacketHistory::insert(const PacketRecord &r) { uint32_t now_millis = millis(); // Should not jump with time changes uint32_t OldtrxTimeMsec = 0; + PacketRecord *base = recentPackets.get(); PacketRecord *tu = NULL; // Will insert here. PacketRecord *it = NULL; // Find a free, matching or oldest used slot in the recentPackets array - for (it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) { + for (it = base; it < (base + recentPacketsCapacity); ++it) { if (it->id == 0 && it->sender == 0 /*&& rxTimeMsec == 0*/) { // Record is empty tu = it; // Remember the free slot #if VERBOSE_PACKET_HISTORY >= 2 - LOG_DEBUG("Packet History - insert: Free slot@ %d/%d", tu - recentPackets, recentPacketsCapacity); + LOG_DEBUG("Packet History - insert: Free slot@ %d/%d", tu - base, recentPacketsCapacity); #endif // We have that, Exit the loop - it = (recentPackets + recentPacketsCapacity); + it = (base + recentPacketsCapacity); } else if (it->id == r.id && it->sender == r.sender) { // Record matches the packet we want to insert tu = it; // Remember the matching slot OldtrxTimeMsec = now_millis - it->rxTimeMsec; // ..and save current entry's age #if VERBOSE_PACKET_HISTORY >= 2 - LOG_DEBUG("Packet History - insert: Matched slot@ %d/%d age=%d", tu - recentPackets, recentPacketsCapacity, - OldtrxTimeMsec); + LOG_DEBUG("Packet History - insert: Matched slot@ %d/%d age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec); #endif // We have that, Exit the loop - it = (recentPackets + recentPacketsCapacity); + it = (base + recentPacketsCapacity); } else { if (it->rxTimeMsec == 0) { LOG_WARN( "Packet History - insert: Found packet s=%08x id=%08x with rxTimeMsec = 0, slot %d/%d. Should never happen!", - it->sender, it->id, it - recentPackets, recentPacketsCapacity); + it->sender, it->id, it - base, recentPacketsCapacity); } if ((now_millis - it->rxTimeMsec) > OldtrxTimeMsec) { // 49.7 days rollover friendly OldtrxTimeMsec = now_millis - it->rxTimeMsec; tu = it; // remember the oldest packet #if VERBOSE_PACKET_HISTORY >= 2 - LOG_DEBUG("Packet History - insert: Older slot@ %d/%d age=%d", tu - recentPackets, recentPacketsCapacity, - OldtrxTimeMsec); + LOG_DEBUG("Packet History - insert: Older slot@ %d/%d age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec); #endif } // keep looking for oldest till entire array is checked @@ -276,13 +269,11 @@ void PacketHistory::insert(const PacketRecord &r) #if VERBOSE_PACKET_HISTORY if (tu->id == 0 && tu->sender == 0) { - LOG_DEBUG("Packet History - insert: slot@ %d/%d is NEW", tu - recentPackets, recentPacketsCapacity); + LOG_DEBUG("Packet History - insert: slot@ %d/%d is NEW", tu - base, recentPacketsCapacity); } else if (tu->id == r.id && tu->sender == r.sender) { - LOG_DEBUG("Packet History - insert: slot@ %d/%d MATCHED, age=%d", tu - recentPackets, recentPacketsCapacity, - OldtrxTimeMsec); + LOG_DEBUG("Packet History - insert: slot@ %d/%d MATCHED, age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec); } else { - LOG_DEBUG("Packet History - insert: slot@ %d/%d REUSE OLDEST, age=%d", tu - recentPackets, recentPacketsCapacity, - OldtrxTimeMsec); + LOG_DEBUG("Packet History - insert: slot@ %d/%d REUSE OLDEST, age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec); } #endif @@ -315,9 +306,9 @@ void PacketHistory::insert(const PacketRecord &r) #endif #if VERBOSE_PACKET_HISTORY - LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d BEFORE", - tu - recentPackets, recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], - tu->relayed_by[2], tu->rxTimeMsec); + LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d BEFORE", tu - base, + recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], tu->relayed_by[2], + tu->rxTimeMsec); #endif if (r.rxTimeMsec == 0) { @@ -330,9 +321,9 @@ void PacketHistory::insert(const PacketRecord &r) *tu = r; // store the packet #if VERBOSE_PACKET_HISTORY - LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER", - tu - recentPackets, recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], - tu->relayed_by[2], tu->rxTimeMsec); + LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER", tu - base, + recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], tu->relayed_by[2], + tu->rxTimeMsec); #endif } diff --git a/src/mesh/PacketHistory.h b/src/mesh/PacketHistory.h index 9b6a9328099..756fa86c1da 100644 --- a/src/mesh/PacketHistory.h +++ b/src/mesh/PacketHistory.h @@ -1,6 +1,7 @@ #pragma once #include "NodeDB.h" +#include // Number of relayers we keep track of. Use 6 to be efficient with memory alignment of PacketRecord to 20 bytes #define NUM_RELAYERS 6 @@ -26,7 +27,7 @@ class PacketHistory uint32_t recentPacketsCapacity = 0; // Can be set in constructor, no need to recompile. Used to allocate memory for mx_recentPackets. - PacketRecord *recentPackets = NULL; // Simple and fixed in size. Debloat. + std::unique_ptr recentPackets; // Simple and fixed in size. Debloat. /** Find a packet record in history. * @param sender NodeNum @@ -48,11 +49,8 @@ class PacketHistory uint8_t getOurTxHopLimit(const PacketRecord &r); void setOurTxHopLimit(PacketRecord &r, uint8_t hopLimit); - PacketHistory(const PacketHistory &); // non construction-copyable - PacketHistory &operator=(const PacketHistory &); // non copyable public: explicit PacketHistory(uint32_t size = -1); // Constructor with size parameter, default is PACKETHISTORY_MAX - ~PacketHistory(); /** * Update recentBroadcasts and return true if we have already seen this packet @@ -74,5 +72,5 @@ class PacketHistory void removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender); // To check if the PacketHistory was initialized correctly by constructor - bool initOk(void) { return recentPackets != NULL && recentPacketsCapacity != 0; } + bool initOk(void) { return recentPackets != nullptr && recentPacketsCapacity != 0; } }; diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index 6df0e18f0db..0ac70acf865 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -80,9 +80,9 @@ void StoreForwardModule::populatePSRAM() (this->records ? this->records : (((memGet.getFreePsram() / 4) * 3) / sizeof(PacketHistoryStruct))); this->records = numberOfPackets; #if defined(ARCH_ESP32) - this->packetHistory = static_cast(ps_calloc(numberOfPackets, sizeof(PacketHistoryStruct))); + this->packetHistory.reset(static_cast(ps_calloc(numberOfPackets, sizeof(PacketHistoryStruct)))); #elif defined(ARCH_PORTDUINO) - this->packetHistory = static_cast(calloc(numberOfPackets, sizeof(PacketHistoryStruct))); + this->packetHistory.reset(static_cast(calloc(numberOfPackets, sizeof(PacketHistoryStruct)))); #endif diff --git a/src/modules/StoreForwardModule.h b/src/modules/StoreForwardModule.h index 148568e1bad..77565b22c49 100644 --- a/src/modules/StoreForwardModule.h +++ b/src/modules/StoreForwardModule.h @@ -7,6 +7,7 @@ #include "configuration.h" #include #include +#include #include struct PacketHistoryStruct { @@ -29,11 +30,17 @@ struct PacketHistoryStruct { class StoreForwardModule : private concurrency::OSThread, public ProtobufModule { + // packetHistory is allocated with ps_calloc / calloc, so it must be released with free(), + // not delete[]. + struct CFreeDeleter { + void operator()(PacketHistoryStruct *p) const noexcept { free(p); } + }; + bool busy = 0; uint32_t busyTo = 0; char routerMessage[meshtastic_Constants_DATA_PAYLOAD_LEN] = {0}; - PacketHistoryStruct *packetHistory = 0; + std::unique_ptr packetHistory; uint32_t packetHistoryTotalCount = 0; uint32_t last_time = 0; uint32_t requestCount = 0; diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 1f59e78b5ab..bfa2f6efb45 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -297,10 +298,9 @@ void portduinoSetup() // Try CH341 try { std::cout << "autoconf: Looking for CH341 device..." << std::endl; - ch341Hal = new Ch341Hal(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid, - portduino_config.lora_usb_pid); - ch341Hal->getProductString(autoconf_product, 95); - delete ch341Hal; + auto probe = std::unique_ptr(new Ch341Hal(0, portduino_config.lora_usb_serial_num, + portduino_config.lora_usb_vid, portduino_config.lora_usb_pid)); + probe->getProductString(autoconf_product, 95); std::cout << "autoconf: Found CH341 device " << autoconf_product << std::endl; found_ch341 = true; From 33e2bb70e6e3a0014c7a4aeef916c8f81de93506 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 6 May 2026 20:44:04 -0500 Subject: [PATCH 140/225] Enhance GPS search failure handling backoff logic (#10404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enhance GPS search failure handling backoff logic * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Remove stray submodule gitlink for .claude worktree A 160000 (gitlink) entry for .claude/worktrees/naughty-payne-60fdb7 pointing at f2923590bc was accidentally committed in 9db15780f. The path isn't a real submodule — it's a Claude Code agent worktree that shouldn't be tracked. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- src/gps/GPSUpdateScheduling.cpp | 28 +++++++++++++++++++++++++--- src/gps/GPSUpdateScheduling.h | 1 + 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp index 45634d2d359..07920e96791 100644 --- a/src/gps/GPSUpdateScheduling.cpp +++ b/src/gps/GPSUpdateScheduling.cpp @@ -15,6 +15,7 @@ void GPSUpdateScheduling::informGotLock() searchEndedMs = millis(); LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000); updateLockTimePrediction(); + consecutiveFailures = 0; // Drop back to fast cadence as soon as we acquire any fix } // Search finished without obtaining a fix. We still need to mark the end time so @@ -24,7 +25,9 @@ void GPSUpdateScheduling::informGotLock() void GPSUpdateScheduling::informSearchFailed() { searchEndedMs = millis(); - LOG_DEBUG("GPS search ended without fix after %us", (searchEndedMs - searchStartedMs) / 1000); + consecutiveFailures++; + LOG_DEBUG("GPS search ended without fix after %us (consecutive failures: %u)", (searchEndedMs - searchStartedMs) / 1000, + consecutiveFailures); } // Clear old lock-time prediction data. @@ -35,6 +38,7 @@ void GPSUpdateScheduling::reset() searchEndedMs = 0; searchCount = 0; predictedMsToGetLock = 0; + consecutiveFailures = 0; } // How many milliseconds before we should next search for GPS position @@ -46,6 +50,20 @@ uint32_t GPSUpdateScheduling::msUntilNextSearch() // Target interval (seconds), between GPS updates uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval); + // After a failed search, back off: indoors / no-sky environments will keep failing, + // so wake at most once per broadcast interval rather than once per gps_update_interval. + // Capped at 1 hour so a user-configured very-long broadcast interval still retries + // periodically (in case conditions change). Reset on any successful lock. + if (consecutiveFailures > 0) { + constexpr uint32_t failureRetryCapMs = 60UL * 60UL * 1000UL; // 1 hour cap + uint32_t failureSleepMs = + Default::getConfiguredOrDefaultMs(config.position.position_broadcast_secs, default_broadcast_interval_secs); + if (failureSleepMs > failureRetryCapMs) + failureSleepMs = failureRetryCapMs; + if (updateInterval < failureSleepMs) + updateInterval = failureSleepMs; + } + // Check how long until we should start searching, to hopefully hit our target interval uint32_t dueAtMs = searchEndedMs + updateInterval; uint32_t compensatedStart = dueAtMs - predictedMsToGetLock; @@ -81,14 +99,18 @@ bool GPSUpdateScheduling::isUpdateDue() bool GPSUpdateScheduling::searchedTooLong() { constexpr uint32_t oneMinuteMs = 60UL * 1000UL; - constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long + constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long + constexpr uint32_t postFailureSearchMs = 5UL * oneMinuteMs; // Tighter dwell once we know the environment is hostile uint32_t elapsed = elapsedSearchMs(); // Anything over 15 minutes is too long, regardless of the broadcast interval. - // TODO: Make a smarter algorithm that backs off the search dwell time when not getting a lock. if (elapsed > maxSearchClampMs) return true; + // After a prior failed search, shorten the dwell + if (consecutiveFailures > 0 && elapsed > postFailureSearchMs) + return true; + uint32_t minimumOrConfiguredSecs = Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs); uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs); diff --git a/src/gps/GPSUpdateScheduling.h b/src/gps/GPSUpdateScheduling.h index 64835a4694a..120605c4ef8 100644 --- a/src/gps/GPSUpdateScheduling.h +++ b/src/gps/GPSUpdateScheduling.h @@ -25,6 +25,7 @@ class GPSUpdateScheduling uint32_t searchEndedMs = 0; uint32_t searchCount = 0; uint32_t predictedMsToGetLock = 0; + uint32_t consecutiveFailures = 0; // Count of search cycles that ended without a fix; reset on lock const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time". }; \ No newline at end of file From b246bcd72ec6989b803da0d83d217b045303d3c6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 20:52:30 -0500 Subject: [PATCH 141/225] Update libpax digest to df42474 (#10406) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32/esp32-common.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index db826b3f926..91ae0017b94 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -72,7 +72,7 @@ lib_deps = # renovate: datasource=custom.pio depName=NimBLE-Arduino packageName=h2zero/library/NimBLE-Arduino h2zero/NimBLE-Arduino@1.4.3 # renovate: datasource=git-refs depName=libpax packageName=https://github.com/dbinfrago/libpax gitBranch=master - https://github.com/dbinfrago/libpax/archive/3cdc0371c375676a97967547f4065607d4c53fd1.zip + https://github.com/dbinfrago/libpax/archive/df424747f9acb86ab07c5a206ded1e8e3650726a.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib lewisxhe/XPowersLib@0.3.3 # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto From b11d29ff31428d8e8373bf9aae6f2e4fc452587f Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 7 May 2026 11:57:34 +0100 Subject: [PATCH 142/225] fix(mesh): update reconfigure methods to return true instead of RADIOLIB_ERR_NONE (#10407) --- src/mesh/LR11x0Interface.cpp | 2 +- src/mesh/RF95Interface.cpp | 2 +- src/mesh/SX126xInterface.cpp | 2 +- src/mesh/SX128xInterface.cpp | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 7a193f7f395..441cab833d6 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -198,7 +198,7 @@ template bool LR11x0Interface::reconfigure() startReceive(); // restart receiving - return RADIOLIB_ERR_NONE; + return true; } template void LR11x0Interface::disableInterrupt() diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 32c92de93ab..43149ef8b40 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -252,7 +252,7 @@ bool RF95Interface::reconfigure() startReceive(); // restart receiving - return RADIOLIB_ERR_NONE; + return true; } /** diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 44c4a805ac3..3513bbba3f3 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -262,7 +262,7 @@ template bool SX126xInterface::reconfigure() startReceive(); // restart receiving - return RADIOLIB_ERR_NONE; + return true; } template void SX126xInterface::disableInterrupt() diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index cb21c0770d6..64d71921a58 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -153,7 +153,7 @@ template bool SX128xInterface::reconfigure() startReceive(); // restart receiving - return RADIOLIB_ERR_NONE; + return true; } template void SX128xInterface::disableInterrupt() From 4553d1e0b1c86e0ba0c6b1f256fa36be0fe5ed77 Mon Sep 17 00:00:00 2001 From: Clive Blackledge Date: Thu, 7 May 2026 09:51:55 -0700 Subject: [PATCH 143/225] Skip MQTT allocation when disabled (#10411) Co-authored-by: Ben Meadors --- src/mqtt/MQTT.cpp | 3 +++ test/test_mqtt/MQTT.cpp | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 97636b60155..3e477cae558 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -409,6 +409,9 @@ void MQTT::onReceive(char *topic, byte *payload, size_t length) void mqttInit() { + if (!moduleConfig.mqtt.enabled) + return; + new MQTT(); } diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index edf9a39835c..523e1d4acf1 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -800,6 +800,17 @@ void test_disabled(void) TEST_ASSERT_FALSE(mqtt->isEnabled()); } +void test_mqttInitSkipsAllocationWhenDisabled(void) +{ + delete unitTest; + mqtt = unitTest = NULL; + + moduleConfig.mqtt.enabled = false; + mqttInit(); + + TEST_ASSERT_NULL(mqtt); +} + // Subscriptions contain the moduleConfig.mqtt.root prefix. void test_customMqttRoot(void) { @@ -912,6 +923,7 @@ void setup() RUN_TEST(test_usingCustomServer); RUN_TEST(test_enabled); RUN_TEST(test_disabled); + RUN_TEST(test_mqttInitSkipsAllocationWhenDisabled); RUN_TEST(test_customMqttRoot); RUN_TEST(test_configEmptyIsValid); RUN_TEST(test_configEnabledEmptyIsValid); From 0f854862e795bc14ff67f33d94350e8a6190005a Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 7 May 2026 13:17:29 -0500 Subject: [PATCH 144/225] Give ThinkNode-m4 a heartbeat (#10408) --- src/modules/StatusLEDModule.cpp | 13 +++++++++++++ src/modules/StatusLEDModule.h | 3 +++ variants/nrf52840/ELECROW-ThinkNode-M4/variant.cpp | 3 +++ variants/nrf52840/ELECROW-ThinkNode-M4/variant.h | 3 ++- 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/modules/StatusLEDModule.cpp b/src/modules/StatusLEDModule.cpp index f828f4a1691..3ed0585af3f 100644 --- a/src/modules/StatusLEDModule.cpp +++ b/src/modules/StatusLEDModule.cpp @@ -106,6 +106,19 @@ int32_t StatusLEDModule::runOnce() } } +// If we want a LED to be dedicated to the simple hearbeat, we can use that instead of the charge LED +#if defined(LED_HEARTBEAT) + if (!config.device.led_heartbeat_disabled) { + if (HEARTBEAT_LED_state == LED_STATE_ON) { + HEARTBEAT_LED_state = LED_STATE_OFF; + my_interval = 999; + } else { + HEARTBEAT_LED_state = LED_STATE_ON; + my_interval = 1; + } + digitalWrite(LED_HEARTBEAT, HEARTBEAT_LED_state); + } +#endif if (!config.bluetooth.enabled || PAIRING_LED_starttime + 30 * 1000 < millis() || doing_fast_blink) { PAIRING_LED_state = LED_STATE_OFF; } else if (ble_state == unpaired) { diff --git a/src/modules/StatusLEDModule.h b/src/modules/StatusLEDModule.h index 972e26737dd..f66a536f677 100644 --- a/src/modules/StatusLEDModule.h +++ b/src/modules/StatusLEDModule.h @@ -43,6 +43,9 @@ class StatusLEDModule : private concurrency::OSThread private: bool CHARGE_LED_state = LED_STATE_OFF; bool PAIRING_LED_state = LED_STATE_OFF; +#if defined(LED_HEARTBEAT) + bool HEARTBEAT_LED_state = LED_STATE_OFF; +#endif uint32_t PAIRING_LED_starttime = 0; uint32_t lastUserbuttonTime = 0; diff --git a/variants/nrf52840/ELECROW-ThinkNode-M4/variant.cpp b/variants/nrf52840/ELECROW-ThinkNode-M4/variant.cpp index 999f326dbb1..da257a39b70 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M4/variant.cpp +++ b/variants/nrf52840/ELECROW-ThinkNode-M4/variant.cpp @@ -35,6 +35,9 @@ void initVariant() pinMode(LED_PAIRING, OUTPUT); ledOff(LED_PAIRING); + pinMode(LED_HEARTBEAT, OUTPUT); + ledOff(LED_HEARTBEAT); + pinMode(Battery_LED_1, OUTPUT); ledOff(Battery_LED_1); pinMode(Battery_LED_2, OUTPUT); diff --git a/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h b/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h index 2cfe948e3c4..560af284b5a 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h +++ b/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h @@ -41,7 +41,8 @@ extern "C" { // LEDs #define LED_BLUE -1 -#define LED_NOTIFICATION (32 + 9) +// #define LED_NOTIFICATION (32 + 9) +#define LED_HEARTBEAT (32 + 9) #define LED_PAIRING (13) #define Battery_LED_1 (15) From 5e2ca8aed4239f6682dede26485c63fcb2fdd521 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Fri, 8 May 2026 02:24:39 +0100 Subject: [PATCH 145/225] LR2021 radio on NRF_Promicro (#10401) * LR2021 radio on NRF_Promicro Co-authored-by: Copilot * Refactor LR2021 interface includes and conditional compilation for improved clarity Co-authored-by: Copilot * Refactor LR20x0 interface: remove unused includes and update comments for clarity * Fix LR2021 max power definitions and add radio type detection tests * remove potato radio type detection tests * Include placeholder for DCDC - currently requires godmode * Added godmode features - not enabled by default --------- Co-authored-by: Copilot Co-authored-by: Ben Meadors --- src/detect/LoRaRadioType.h | 3 +- src/mesh/InterfacesTemplates.cpp | 7 + src/mesh/LR11x0Interface.cpp | 24 +- src/mesh/LR2021Interface.cpp | 18 + src/mesh/LR2021Interface.h | 15 + src/mesh/LR20x0Interface.cpp | 399 ++++++++++++++++++ src/mesh/LR20x0Interface.h | 75 ++++ src/mesh/RadioInterface.cpp | 15 + .../nrf52_promicro_diy_tcxo/platformio.ini | 1 + .../diy/nrf52_promicro_diy_tcxo/rfswitch.h | 43 +- .../diy/nrf52_promicro_diy_tcxo/variant.h | 10 + 11 files changed, 596 insertions(+), 14 deletions(-) create mode 100644 src/mesh/LR2021Interface.cpp create mode 100644 src/mesh/LR2021Interface.h create mode 100644 src/mesh/LR20x0Interface.cpp create mode 100644 src/mesh/LR20x0Interface.h diff --git a/src/detect/LoRaRadioType.h b/src/detect/LoRaRadioType.h index a059a3668be..f741b929ca3 100644 --- a/src/detect/LoRaRadioType.h +++ b/src/detect/LoRaRadioType.h @@ -11,7 +11,8 @@ enum LoRaRadioType { SX1280_RADIO, LR1110_RADIO, LR1120_RADIO, - LR1121_RADIO + LR1121_RADIO, + LR2021_RADIO }; extern LoRaRadioType radioType; \ No newline at end of file diff --git a/src/mesh/InterfacesTemplates.cpp b/src/mesh/InterfacesTemplates.cpp index 57abbf0ee40..907cc2a4e04 100644 --- a/src/mesh/InterfacesTemplates.cpp +++ b/src/mesh/InterfacesTemplates.cpp @@ -1,5 +1,9 @@ +#include "configuration.h" + #include "LR11x0Interface.cpp" #include "LR11x0Interface.h" +#include "LR20x0Interface.cpp" +#include "LR20x0Interface.h" #include "SX126xInterface.cpp" #include "SX126xInterface.h" #include "SX128xInterface.cpp" @@ -21,6 +25,9 @@ template class LR11x0Interface; template class LR11x0Interface; template class LR11x0Interface; #endif +#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1 +template class LR20x0Interface; +#endif #ifdef ARCH_STM32WL template class SX126xInterface; #endif diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 441cab833d6..1d1616ed673 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -57,16 +57,19 @@ template bool LR11x0Interface::init() #if ARCH_PORTDUINO float tcxoVoltage = (float)portduino_config.dio3_tcxo_voltage / 1000; // FIXME: correct logic to default to not using TCXO if no voltage is specified for LR11x0_DIO3_TCXO_VOLTAGE -#elif !defined(LR11X0_DIO3_TCXO_VOLTAGE) +#elif defined(LR11X0_DIO3_TCXO_VOLTAGE) + float tcxoVoltage = LR11X0_DIO3_TCXO_VOLTAGE; + LOG_DEBUG("LR11X0_DIO3_TCXO_VOLTAGE defined, using DIO3 as TCXO reference voltage at %f V", LR11X0_DIO3_TCXO_VOLTAGE); + // (DIO3 is not free to be used as an IRQ) +#elif defined(TCXO_OPTIONAL) + float tcxoVoltage = 1.6f; // TCXO_OPTIONAL: try default 1.6 V first, fall back to XTAL on failure + LOG_DEBUG("TCXO_OPTIONAL: no LR11X0_DIO3_TCXO_VOLTAGE defined, trying default TCXO Vref 1.6 V first"); +#else float tcxoVoltage = 0; // "TCXO reference voltage to be set on DIO3. Defaults to 1.6 V, set to 0 to skip." per // https://github.com/jgromes/RadioLib/blob/690a050ebb46e6097c5d00c371e961c1caa3b52e/src/modules/LR11x0/LR11x0.h#L471C26-L471C104 // (DIO3 is free to be used as an IRQ) LOG_DEBUG("LR11X0_DIO3_TCXO_VOLTAGE not defined, not using DIO3 as TCXO reference voltage"); -#else - float tcxoVoltage = LR11X0_DIO3_TCXO_VOLTAGE; - LOG_DEBUG("LR11X0_DIO3_TCXO_VOLTAGE defined, using DIO3 as TCXO reference voltage at %f V", LR11X0_DIO3_TCXO_VOLTAGE); - // (DIO3 is not free to be used as an IRQ) #endif RadioLibInterface::init(); @@ -101,6 +104,17 @@ template bool LR11x0Interface::init() res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); } +#if defined(TCXO_OPTIONAL) + // If init failed for any reason other than chip not found, retry without TCXO (XTAL mode) + if (res != RADIOLIB_ERR_NONE && res != RADIOLIB_ERR_CHIP_NOT_FOUND && tcxoVoltage > 0) { + LOG_WARN("LR11x0 init failed with TCXO Vref %f V (err %d), retrying without TCXO", tcxoVoltage, res); + tcxoVoltage = 0; + res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + if (res == RADIOLIB_ERR_NONE) + LOG_INFO("LR11x0 init success without TCXO (XTAL mode)"); + } +#endif + // \todo Display actual typename of the adapter, not just `LR11x0` LOG_INFO("LR11x0 init result %d", res); if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED) diff --git a/src/mesh/LR2021Interface.cpp b/src/mesh/LR2021Interface.cpp new file mode 100644 index 00000000000..9aa4d5f1ab0 --- /dev/null +++ b/src/mesh/LR2021Interface.cpp @@ -0,0 +1,18 @@ +#include "configuration.h" + +#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1 + +#include "LR2021Interface.h" +#include "error.h" + +LR2021Interface::LR2021Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, + RADIOLIB_PIN_TYPE busy) + : LR20x0Interface(hal, cs, irq, rst, busy) +{ +} + +bool LR2021Interface::wideLora() +{ + return true; +} +#endif diff --git a/src/mesh/LR2021Interface.h b/src/mesh/LR2021Interface.h new file mode 100644 index 00000000000..52c04ee9034 --- /dev/null +++ b/src/mesh/LR2021Interface.h @@ -0,0 +1,15 @@ +#pragma once +#if RADIOLIB_EXCLUDE_LR2021 != 1 +#include "LR20x0Interface.h" + +/** + * Our adapter for LR2021 radios + */ +class LR2021Interface : public LR20x0Interface +{ + public: + LR2021Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, + RADIOLIB_PIN_TYPE busy); + bool wideLora() override; +}; +#endif diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp new file mode 100644 index 00000000000..699663cf032 --- /dev/null +++ b/src/mesh/LR20x0Interface.cpp @@ -0,0 +1,399 @@ +#include "configuration.h" + +#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1 +#include "LR20x0Interface.h" +#include "error.h" +#include "mesh/NodeDB.h" + +// Keep LR20x0 naming while RadioLib exposes LR2021 symbols. +#ifndef LR20x0 +#define LR20x0 LR2021 +#endif + +#ifdef LR2021_DIO_AS_RF_SWITCH +#include "rfswitch.h" +#elif ARCH_PORTDUINO +#include "PortduinoGlue.h" +#define lr20x0_rfswitch_dio_pins portduino_config.rfswitch_dio_pins +#define lr20x0_rfswitch_table portduino_config.rfswitch_table +#else +static const uint32_t lr20x0_rfswitch_dio_pins[] = {RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC}; +static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = { + {LR20x0::MODE_STBY, {}}, {LR20x0::MODE_RX, {}}, {LR20x0::MODE_TX, {}}, + {LR20x0::MODE_RX_HF, {}}, {LR20x0::MODE_TX_HF, {}}, END_OF_MODE_TABLE, +}; +#endif + +// Particular boards might define a different max power based on what their hardware can do, default to max power output if not +// specified (may be dangerous if using external PA and LR20x0 power config forgotten) +#if ARCH_PORTDUINO +#define LR2021_MAX_POWER portduino_config.lr2021_max_power +#endif +#ifndef LR2021_MAX_POWER +#define LR2021_MAX_POWER 22 +#endif + +// the 2.4G part maxes at 12dBm + +#if ARCH_PORTDUINO +#define LR2021_MAX_POWER_HF portduino_config.lr2021_max_power_hf +#endif +#ifndef LR2021_MAX_POWER_HF +#define LR2021_MAX_POWER_HF 12 +#endif + +template +LR20x0Interface::LR20x0Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, + RADIOLIB_PIN_TYPE busy) + : RadioLibInterface(hal, cs, irq, rst, busy, &lora), lora(&module) +{ + LOG_WARN("LR20x0Interface(cs=%d, irq=%d, rst=%d, busy=%d)", cs, irq, rst, busy); +} + +/// Initialise the Driver transport hardware and software. +/// Make sure the Driver is properly configured before calling init(). +/// \return true if initialisation succeeded. +template bool LR20x0Interface::init() +{ +#ifdef LR2021_POWER_EN + pinMode(LR2021_POWER_EN, OUTPUT); + digitalWrite(LR2021_POWER_EN, HIGH); +#endif + +#if ARCH_PORTDUINO + float tcxoVoltage = (float)portduino_config.dio3_tcxo_voltage / 1000; +// FIXME: correct logic to default to not using TCXO if no voltage is specified for LR20x0_DIO3_TCXO_VOLTAGE +#elif defined(LR2021_DIO3_TCXO_VOLTAGE) + float tcxoVoltage = LR2021_DIO3_TCXO_VOLTAGE; + LOG_DEBUG("LR2021_DIO3_TCXO_VOLTAGE defined, using DIO3 as TCXO reference voltage at %f V", LR2021_DIO3_TCXO_VOLTAGE); + // (DIO3 is not free to be used as an IRQ) +#elif defined(TCXO_OPTIONAL) + float tcxoVoltage = 1.6f; // TCXO_OPTIONAL: try default 1.6 V first, fall back to XTAL on failure + LOG_DEBUG("TCXO_OPTIONAL: no LR2021_DIO3_TCXO_VOLTAGE defined, trying default TCXO Vref 1.6 V first"); +#else + float tcxoVoltage = + 0; // "TCXO reference voltage to be set on DIO3. Defaults to 1.6 V, set to 0 to skip." per + // https://github.com/jgromes/RadioLib/blob/690a050ebb46e6097c5d00c371e961c1caa3b52e/src/modules/LR11x0/LR11x0.h#L471C26-L471C104 + // (DIO3 is free to be used as an IRQ) + LOG_DEBUG("LR2021_DIO3_TCXO_VOLTAGE not defined, not using DIO3 as TCXO reference voltage"); +#endif + + RadioLibInterface::init(); + +#ifdef LR2021_IRQ_DIO_NUM + lora.irqDioNum = LR2021_IRQ_DIO_NUM; + LOG_DEBUG("Set irqDioNum %d", lora.irqDioNum); +#elif defined(IRQ_DIO_NUM) + lora.irqDioNum = IRQ_DIO_NUM; + LOG_DEBUG("Set irqDioNum %d", lora.irqDioNum); +#else + LOG_DEBUG("Use default irqDioNum %d", lora.irqDioNum); +#endif + + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range + limitPower(LR2021_MAX_POWER_HF); + } else { + limitPower(LR2021_MAX_POWER); // default clamp for non-wide freq range + } + +#ifdef LR2021_RF_SWITCH_SUBGHZ + pinMode(LR2021_RF_SWITCH_SUBGHZ, OUTPUT); + digitalWrite(LR2021_RF_SWITCH_SUBGHZ, getFreq() < 1e9 ? HIGH : LOW); + LOG_DEBUG("Set RF0 switch to %s", getFreq() < 1e9 ? "SubGHz" : "2.4GHz"); +#endif + +#ifdef LR2021_RF_SWITCH_2_4GHZ + pinMode(LR2021_RF_SWITCH_2_4GHZ, OUTPUT); + digitalWrite(LR2021_RF_SWITCH_2_4GHZ, getFreq() < 1e9 ? LOW : HIGH); + LOG_DEBUG("Set RF1 switch to %s", getFreq() < 1e9 ? "SubGHz" : "2.4GHz"); +#endif + + // Allow extra time for TCXO to stabilize after power-on + delay(10); + + int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + + // Retry if we get SPI command failed - some units need extra TCXO stabilization time + if (res == RADIOLIB_ERR_SPI_CMD_FAILED) { + LOG_WARN("LR20x0 init failed with %d (SPI_CMD_FAILED), retrying after delay...", res); + delay(100); + res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + } + +#if defined(TCXO_OPTIONAL) + // If init failed for any reason other than chip not found, retry without TCXO (XTAL mode) + if (res != RADIOLIB_ERR_NONE && res != RADIOLIB_ERR_CHIP_NOT_FOUND && tcxoVoltage > 0) { + LOG_WARN("LR20x0 init failed with TCXO Vref %f V (err %d), retrying without TCXO", tcxoVoltage, res); + tcxoVoltage = 0; + res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + if (res == RADIOLIB_ERR_NONE) + LOG_INFO("LR20x0 init success without TCXO (XTAL mode)"); + } +#endif + + // \todo Display actual typename of the adapter, not just `LR20x0` + LOG_INFO("LR20x0 init result %d", res); + if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED) + return false; + + // Some basic info about the module's explicit firmware version - no other info available + // Currently requires radiolib godmode + +#if RADIOLIB_GODMODE + uint8_t fwMajor = 0; + uint8_t fwMinor = 0; + int versionRes = lora.getVersion(&fwMajor, &fwMinor); + if (versionRes == RADIOLIB_ERR_NONE) + LOG_DEBUG("LR20x0 FW %d.%d", fwMajor, fwMinor); +#endif + + LOG_INFO("Frequency set to %f", getFreq()); + LOG_INFO("Bandwidth set to %f", bw); + LOG_INFO("Power output set to %d", power); + + if (res == RADIOLIB_ERR_NONE) + res = lora.setCRC(2); + + // Standard DCDC ramp timing from RadioLib workarounds (register 0x00F20024) + // Currently requires radiolib godmode +#if RADIOLIB_GODMODE + if (res == RADIOLIB_ERR_NONE) { + uint8_t rampTimes[4] = {15, 15, 15, 15}; // Standard case for all conditions + res = lora.setRegMode(RADIOLIB_LR2021_REG_MODE_SIMO_NORMAL, rampTimes); + if (res != RADIOLIB_ERR_NONE) + LOG_WARN("LR2021 setRegMode failed: %d", res); + } +#endif + +#ifdef LR2021_DIO_AS_RF_SWITCH + bool dioAsRfSwitch = true; +#elif defined(ARCH_PORTDUINO) + bool dioAsRfSwitch = portduino_config.has_rfswitch_table; +#else + bool dioAsRfSwitch = false; +#endif + + if (dioAsRfSwitch) { + lora.setRfSwitchTable(lr20x0_rfswitch_dio_pins, lr20x0_rfswitch_table); + LOG_DEBUG("Set DIO RF switch"); + } + + if (res == RADIOLIB_ERR_NONE) { + if (config.lora.sx126x_rx_boosted_gain) { // the name is unfortunate but historically accurate + res = lora.setRxBoostedGainMode(true); + LOG_INFO("Set RX gain to boosted mode; result: %d", res); + } else { + res = lora.setRxBoostedGainMode(false); + LOG_INFO("Set RX gain to power saving mode (boosted mode off); result: %d", res); + } + } + + if (res == RADIOLIB_ERR_NONE) + startReceive(); // start receiving + + return res == RADIOLIB_ERR_NONE; +} + +template bool LR20x0Interface::reconfigure() +{ + RadioLibInterface::reconfigure(); + + // set mode to standby + setStandby(); + + // configure publicly accessible settings + int err = lora.setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + + err = lora.setBandwidth(bw); // different form than LR11xx + if (err != RADIOLIB_ERR_NONE) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + + err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it + if (err != RADIOLIB_ERR_NONE) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + + err = lora.setSyncWord(syncWord); + assert(err == RADIOLIB_ERR_NONE); + + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range + limitPower(LR2021_MAX_POWER_HF); + } else { + limitPower(LR2021_MAX_POWER); // default clamp for non-wide freq range + } + + err = lora.setPreambleLength(preambleLength); + assert(err == RADIOLIB_ERR_NONE); + + err = lora.setFrequency(getFreq()); + if (err != RADIOLIB_ERR_NONE) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + + err = lora.setOutputPower(power); + assert(err == RADIOLIB_ERR_NONE); + + // 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); + + startReceive(); // restart receiving + + return true; +} + +template void LR20x0Interface::disableInterrupt() +{ + lora.clearIrqAction(); +} + +template void LR20x0Interface::setStandby() +{ + checkNotification(); // handle any pending interrupts before we force standby + + int err = lora.standby(); + + if (err != RADIOLIB_ERR_NONE) { + LOG_DEBUG("LR20x0 standby failed with error %d", err); + } + + assert(err == RADIOLIB_ERR_NONE); + + isReceiving = false; // If we were receiving, not any more + activeReceiveStart = 0; + disableInterrupt(); + completeSending(); // If we were sending, not anymore + RadioLibInterface::setStandby(); +} + +/** + * Add SNR data to received messages + */ +template void LR20x0Interface::addReceiveMetadata(meshtastic_MeshPacket *mp) +{ + // LOG_DEBUG("PacketStatus %x", lora.getPacketStatus()); + mp->rx_snr = lora.getSNR(); + mp->rx_rssi = lround(lora.getRSSI()); + // LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError()); // not implemented for LR20x0, but noop for LR11x0 + // too(!) +} + +/** We override to turn on transmitter power as needed. + */ +template void LR20x0Interface::configHardwareForSend() +{ + RadioLibInterface::configHardwareForSend(); +} + +// For power draw measurements, helpful to force radio to stay sleeping +// #define SLEEP_ONLY + +template void LR20x0Interface::startReceive() +{ +#ifdef SLEEP_ONLY + sleep(); +#else + + setStandby(); + + lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. + + // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. + int err = + lora.startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + if (err) + LOG_ERROR("StartReceive error: %d", err); + assert(err == RADIOLIB_ERR_NONE); + + RadioLibInterface::startReceive(); + + // Must be done AFTER starting receive, because startReceive clears (possibly stale) interrupt pending register bits + enableInterrupt(isrRxLevel0); + checkRxDoneIrqFlag(); +#endif +} + +/** Is the channel currently active? */ +template bool LR20x0Interface::isChannelActive() +{ + // check if we can detect a LoRa preamble on the current channel + ChannelScanConfig_t cfg = {.cad = {.symNum = NUM_SYM_CAD, + .detPeak = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, + .detMin = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, + .exitMode = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, + .timeout = 0, + .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, + .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; + int16_t result; + + setStandby(); + result = lora.scanChannel(cfg); + if (result == RADIOLIB_LORA_DETECTED) + return true; + + assert(result != RADIOLIB_ERR_WRONG_MODEM); + + return false; +} + +/** Could we send right now (i.e. either not actively receiving or transmitting)? */ +template bool LR20x0Interface::isActivelyReceiving() +{ + // The IRQ status will be cleared when we start our read operation. Check if we've started a header, but haven't yet + // received and handled the interrupt for reading the packet/handling errors. + return receiveDetected(lora.getIrqStatus(), RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID, RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED); +} + +#ifdef LR20X0_AGC_RESET +template void LR20x0Interface::resetAGC() +{ + // Safety: don't reset mid-packet + if (sendingPacket != NULL || (isReceiving && isActivelyReceiving())) + return; + + LOG_DEBUG("LR20x0 AGC reset: warm sleep + Calibrate(0x3F)"); + + // 1. Warm sleep — powers down the analog frontend, resetting AGC state + lora.sleep(true, 0); + + // 2. Wake to RC standby for stable calibration + lora.standby(RADIOLIB_LR20X0_STANDBY_RC, true); + + // 3. Calibrate all blocks (PLL, ADC, image, RC oscillators) + // calibrate() is protected on LR20x0, so use raw SPI (same as internal implementation) + uint8_t calData = RADIOLIB_LR20X0_CALIBRATE_ALL; + module.SPIwriteStream(RADIOLIB_LR20X0_CMD_CALIBRATE, &calData, 1, true, true); + + // 4. Re-calibrate image rejection for actual operating frequency + // Calibrate(0x3F) defaults to 902-928 MHz which is wrong for other regions. + lora.calibrateImageRejection(getFreq() - 4.0f, getFreq() + 4.0f); + + // 5. Re-apply RX boosted gain mode + lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + + // 6. Resume receiving + startReceive(); +} +#endif + +template bool LR20x0Interface::sleep() +{ + // \todo Display actual typename of the adapter, not just `LR20x0` + LOG_DEBUG("LR20x0 entering sleep mode"); + setStandby(); // Stop any pending operations + + // turn off TCXO if it was powered + lora.setTCXO(0); + + // put chipset into sleep mode (we've already disabled interrupts by now) + bool keepConfig = false; + lora.sleep(keepConfig, 0); // Note: we do not keep the config, full reinit will be needed + +#ifdef LR2021_POWER_EN + digitalWrite(LR2021_POWER_EN, LOW); +#endif + + return true; +} +#endif diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h new file mode 100644 index 00000000000..6176cd0b8e8 --- /dev/null +++ b/src/mesh/LR20x0Interface.h @@ -0,0 +1,75 @@ +#pragma once +#if RADIOLIB_EXCLUDE_LR2021 != 1 +#include "RadioLibInterface.h" + +/** + * \brief Adapter for LR20x0 radio family. Implements common logic for child classes. + * \tparam T RadioLib module type for LR20x0, e.g. LR2021. + */ +template class LR20x0Interface : public RadioLibInterface +{ + public: + LR20x0Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, + RADIOLIB_PIN_TYPE busy); + + /// Initialise the Driver transport hardware and software. + /// Make sure the Driver is properly configured before calling init(). + /// \return true if initialisation succeeded. + virtual bool init() override; + + /// Apply any radio provisioning changes + /// Make sure the Driver is properly configured before calling init(). + /// \return true if initialisation succeeded. + virtual bool reconfigure() override; + + /// Prepare hardware for sleep. Call this _only_ for deep sleep, not needed for light sleep. + virtual bool sleep() override; + + bool isIRQPending() override { return lora.getIrqFlags() != 0; } + +#ifdef LR20X0_AGC_RESET + void resetAGC() override; +#endif + + protected: + /** + * Specific module instance + */ + T lora; + + /** + * Glue functions called from ISR land + */ + virtual void disableInterrupt() override; + + /** + * Enable a particular ISR callback glue function + */ + virtual void enableInterrupt(void (*callback)()) { lora.setIrqAction(callback); } + + /** can we detect a LoRa preamble on the current channel? */ + virtual bool isChannelActive() override; + + /** are we actively receiving a packet (only called during receiving state) */ + virtual bool isActivelyReceiving() override; + + /** + * Start waiting to receive a message + */ + virtual void startReceive() override; + + /** + * We override to turn on transmitter power as needed. + */ + virtual void configHardwareForSend() override; + + /** + * Add SNR data to received messages + */ + virtual void addReceiveMetadata(meshtastic_MeshPacket *mp) override; + + virtual void setStandby() override; + + uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } +}; +#endif diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index b7b672d8b02..f4f25f80c9b 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -5,6 +5,7 @@ #include "LR1110Interface.h" #include "LR1120Interface.h" #include "LR1121Interface.h" +#include "LR2021Interface.h" #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" @@ -478,6 +479,20 @@ std::unique_ptr initLoRa() } #endif +#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1 + if (!rIf) { + rIf = std::unique_ptr( + new LR2021Interface(loraHal, LR2021_SPI_NSS_PIN, LR2021_IRQ_PIN, LR2021_NRESET_PIN, LR2021_BUSY_PIN)); + if (!rIf->init()) { + LOG_WARN("No LR2021 radio"); + rIf = nullptr; + } else { + LOG_INFO("LR2021 init success"); + radioType = LR2021_RADIO; + } + } +#endif + #if defined(USE_SX1280) && RADIOLIB_EXCLUDE_SX128X != 1 if (!rIf) { rIf = std::unique_ptr(new SX1280Interface(loraHal, SX128X_CS, SX128X_DIO1, SX128X_RESET, SX128X_BUSY)); diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index 82a4e1953ba..d111b011f49 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -15,6 +15,7 @@ board = promicro-nrf52840 build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/diy/nrf52_promicro_diy_tcxo -D NRF52_PROMICRO_DIY + ; -D RADIOLIB_GODMODE=1 ; needed for some LR2021 items, but not enabled by default. build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> debug_tool = jlink diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h index ac7ef57c417..714c58c241f 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h @@ -1,5 +1,11 @@ +#pragma once #include "RadioLib.h" +// Keep LR20x0 naming while RadioLib exposes LR2021 symbols. +#ifndef LR20x0 +#define LR20x0 LR2021 +#endif + // This is rewritten to match the requirements of the E80-900M2213S // The E80 does not conform to the reference Semtech switches(!) and therefore needs a custom matrix. // See footnote #3 in "https://www.cdebyte.com/products/E80-900M2213S/2#Pin" @@ -13,14 +19,35 @@ static const uint32_t rfswitch_dio_pins[] = {RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11 static const Module::RfSwitchMode_t rfswitch_table[] = { // clang-format off - // mode DIO5 DIO6 DIO7 - {LR11x0::MODE_STBY, {LOW, LOW, LOW}}, - {LR11x0::MODE_RX, {LOW, HIGH, LOW}}, - {LR11x0::MODE_TX, {HIGH, HIGH, LOW}}, - {LR11x0::MODE_TX_HP, {HIGH, LOW, LOW}}, - {LR11x0::MODE_TX_HF, {LOW, LOW, LOW}}, - {LR11x0::MODE_GNSS, {LOW, LOW, HIGH}}, - {LR11x0::MODE_WIFI, {LOW, LOW, LOW}}, + // mode DIO5 DIO6 DIO7 + {LR11x0::MODE_STBY, {LOW, LOW, LOW}}, + {LR11x0::MODE_RX, {LOW, HIGH, LOW}}, + {LR11x0::MODE_TX, {HIGH, HIGH, LOW}}, + {LR11x0::MODE_TX_HP, {HIGH, LOW, LOW}}, + {LR11x0::MODE_TX_HF, {LOW, LOW, LOW}}, + {LR11x0::MODE_GNSS, {LOW, LOW, HIGH}}, + {LR11x0::MODE_WIFI, {LOW, LOW, LOW}}, + END_OF_MODE_TABLE, + // clang-format on +}; + +// LR2021 RF switch matrix following the standard Semtech / Seeed T1000-E reference topology. +// DIO5 -> antenna path select (HIGH = sub-GHz LF) +// DIO6 -> TX enable / HP PA select +// DIO7 -> not connected (no GNSS on LR2021) +// DIO8 -> RF front-end power enable + +static const uint32_t lr20x0_rfswitch_dio_pins[] = {RADIOLIB_LR2021_DIO5, RADIOLIB_LR2021_DIO6, RADIOLIB_LR2021_DIO7, + RADIOLIB_LR2021_DIO8, RADIOLIB_NC}; + +static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = { + // clang-format off + // mode DIO5 DIO6 DIO7 DIO8 + {LR20x0::MODE_STBY, {LOW, LOW, LOW, LOW}}, + {LR20x0::MODE_RX, {HIGH, LOW, LOW, HIGH}}, + {LR20x0::MODE_TX, {HIGH, HIGH, LOW, HIGH}}, + {LR20x0::MODE_RX_HF, {LOW, LOW, LOW, LOW}}, + {LR20x0::MODE_TX_HF, {LOW, LOW, LOW, LOW}}, END_OF_MODE_TABLE, // clang-format on }; diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h index 8e10141f5c5..82178d32165 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h @@ -155,6 +155,16 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define LR11X0_DIO_AS_RF_SWITCH #endif +// LR2021 +#define USE_LR2021 +#define LR2021_IRQ_PIN (0 + 10) // P0.10 IRQ +#define LR2021_NRESET_PIN LORA_RESET // P0.09 NRST +#define LR2021_BUSY_PIN (0 + 29) // P0.29 BUSY +#define LR2021_SPI_NSS_PIN LORA_CS // P1.13 +#define LR2021_DIO3_TCXO_VOLTAGE 1.8 +#define LR2021_DIO_AS_RF_SWITCH +#define LR2021_IRQ_DIO_NUM 9 // DIO9 → P0.10 + // #define SX126X_MAX_POWER 8 set this if using a high-power board! /* From a8a785bbb7c19884377fd1dfc38eb02afe278370 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 05:43:02 -0500 Subject: [PATCH 146/225] Upgrade trunk (#10418) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index d2ccc60a47d..558d67d9c39 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -8,7 +8,7 @@ plugins: uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.526 + - checkov@3.2.527 - renovate@43.150.0 - prettier@3.8.3 - trufflehog@3.95.2 From 5512185cfea022bc5f1c3ded0c2d9f69c9af21e0 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Fri, 8 May 2026 16:03:39 -0500 Subject: [PATCH 147/225] Make heartbeat LED play nice with other LEDs (#10423) --- src/modules/StatusLEDModule.cpp | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/modules/StatusLEDModule.cpp b/src/modules/StatusLEDModule.cpp index 3ed0585af3f..4ea34fb529a 100644 --- a/src/modules/StatusLEDModule.cpp +++ b/src/modules/StatusLEDModule.cpp @@ -95,20 +95,9 @@ int32_t StatusLEDModule::runOnce() } } } - - if (power_state != charging && power_state != charged && !doing_fast_blink) { - if (CHARGE_LED_state == LED_STATE_ON) { - CHARGE_LED_state = LED_STATE_OFF; - my_interval = 999; - } else { - CHARGE_LED_state = LED_STATE_ON; - my_interval = 1; - } - } - // If we want a LED to be dedicated to the simple hearbeat, we can use that instead of the charge LED #if defined(LED_HEARTBEAT) - if (!config.device.led_heartbeat_disabled) { + if (power_state != charging && power_state != charged && !doing_fast_blink && !config.device.led_heartbeat_disabled) { if (HEARTBEAT_LED_state == LED_STATE_ON) { HEARTBEAT_LED_state = LED_STATE_OFF; my_interval = 999; @@ -117,6 +106,19 @@ int32_t StatusLEDModule::runOnce() my_interval = 1; } digitalWrite(LED_HEARTBEAT, HEARTBEAT_LED_state); + } else { + HEARTBEAT_LED_state = LED_STATE_OFF; + digitalWrite(LED_HEARTBEAT, HEARTBEAT_LED_state); + } +#else + if (power_state != charging && power_state != charged && !doing_fast_blink && !config.device.led_heartbeat_disabled) { + if (CHARGE_LED_state == LED_STATE_ON) { + CHARGE_LED_state = LED_STATE_OFF; + my_interval = 999; + } else { + CHARGE_LED_state = LED_STATE_ON; + my_interval = 1; + } } #endif if (!config.bluetooth.enabled || PAIRING_LED_starttime + 30 * 1000 < millis() || doing_fast_blink) { From f63716c3229651359d70921c484507cbfd278979 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 19:05:11 -0500 Subject: [PATCH 148/225] Automated version bumps (#10419) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --- bin/org.meshtastic.meshtasticd.metainfo.xml | 3 +++ debian/changelog | 6 ++++++ version.properties | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/bin/org.meshtastic.meshtasticd.metainfo.xml b/bin/org.meshtastic.meshtasticd.metainfo.xml index a1690186b19..ed5338af647 100644 --- a/bin/org.meshtastic.meshtasticd.metainfo.xml +++ b/bin/org.meshtastic.meshtasticd.metainfo.xml @@ -87,6 +87,9 @@ + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.24 + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.23 diff --git a/debian/changelog b/debian/changelog index c3f1424a5d3..6b9d0668efd 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +meshtasticd (2.7.24.0) unstable; urgency=medium + + * Version 2.7.24 + + -- GitHub Actions Fri, 08 May 2026 10:44:12 +0000 + meshtasticd (2.7.23.0) unstable; urgency=medium * Version 2.7.23 diff --git a/version.properties b/version.properties index 4ee342bb89b..56ea393171a 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ [VERSION] major = 2 minor = 7 -build = 23 +build = 24 From b4234b7f116ff93d699bdbc76dff34cb0d8f1417 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 19:05:11 -0500 Subject: [PATCH 149/225] Automated version bumps (#10419) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --- bin/org.meshtastic.meshtasticd.metainfo.xml | 3 +++ debian/changelog | 6 ++++++ version.properties | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/bin/org.meshtastic.meshtasticd.metainfo.xml b/bin/org.meshtastic.meshtasticd.metainfo.xml index a1690186b19..ed5338af647 100644 --- a/bin/org.meshtastic.meshtasticd.metainfo.xml +++ b/bin/org.meshtastic.meshtasticd.metainfo.xml @@ -87,6 +87,9 @@ + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.24 + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.23 diff --git a/debian/changelog b/debian/changelog index c3f1424a5d3..6b9d0668efd 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +meshtasticd (2.7.24.0) unstable; urgency=medium + + * Version 2.7.24 + + -- GitHub Actions Fri, 08 May 2026 10:44:12 +0000 + meshtasticd (2.7.23.0) unstable; urgency=medium * Version 2.7.23 diff --git a/version.properties b/version.properties index 4ee342bb89b..56ea393171a 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ [VERSION] major = 2 minor = 7 -build = 23 +build = 24 From fefd424901763af2c2ec861ed6e875b02a130091 Mon Sep 17 00:00:00 2001 From: BJK <58904384+Bjk8kds@users.noreply.github.com> Date: Sun, 10 May 2026 00:53:07 +0700 Subject: [PATCH 150/225] Fix screen geometry update for SH1107 display (#10444) Added conditional block to update screen geometry for SH1107 128x128. --- src/main.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index 6f78c0b960b..2fb2006d8cc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -731,8 +731,15 @@ void setup() #elif defined(USE_SH1107_128_64) screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // keep dimension of 128x64 #else - if (config.display.oled != meshtastic_Config_DisplayConfig_OledType_OLED_AUTO) + if (config.display.oled != meshtastic_Config_DisplayConfig_OledType_OLED_AUTO) { screen_model = config.display.oled; + + // Fix: update geometry for SH1107 128x128 selected via menu + if (screen_model == meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128) { + screen_geometry = GEOMETRY_128_128; + screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // normalize + } + } #endif #endif From 10a7f1042bb9b04c87e89c7705605dcde96f3815 Mon Sep 17 00:00:00 2001 From: BJK <58904384+Bjk8kds@users.noreply.github.com> Date: Sun, 10 May 2026 00:53:07 +0700 Subject: [PATCH 151/225] Fix screen geometry update for SH1107 display (#10444) Added conditional block to update screen geometry for SH1107 128x128. --- src/main.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index 6f78c0b960b..2fb2006d8cc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -731,8 +731,15 @@ void setup() #elif defined(USE_SH1107_128_64) screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // keep dimension of 128x64 #else - if (config.display.oled != meshtastic_Config_DisplayConfig_OledType_OLED_AUTO) + if (config.display.oled != meshtastic_Config_DisplayConfig_OledType_OLED_AUTO) { screen_model = config.display.oled; + + // Fix: update geometry for SH1107 128x128 selected via menu + if (screen_model == meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128) { + screen_geometry = GEOMETRY_128_128; + screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // normalize + } + } #endif #endif From 1bcabb893b9faa612ecd346ad6df53f39828e4ec Mon Sep 17 00:00:00 2001 From: Sylwester Date: Sat, 9 May 2026 20:32:42 +0200 Subject: [PATCH 152/225] mesh: bound the user-facing notification sprintf calls (#10437) Two sites built ClientNotification messages with sprintf into a fixed-size proto buffer with no length cap. The current format strings fit comfortably, but a future caller editing either format string without rechecking the buffer size would get a silent stack/heap overrun. Switch to snprintf with sizeof so the bound is enforced at the call site. Co-authored-by: Ben Meadors --- src/mesh/NodeDB.cpp | 2 +- src/mesh/Router.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index ac6880adea4..ef07f68fd64 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1881,7 +1881,7 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); cn->level = meshtastic_LogRecord_Level_WARNING; cn->time = getValidTime(RTCQualityFromNet); - sprintf(cn->message, warning, p.long_name); + snprintf(cn->message, sizeof(cn->message), warning, p.long_name); service->sendClientNotification(cn); } return false; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index ffeb7c5393d..19756689910 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -329,7 +329,7 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) cn->reply_id = p->id; cn->level = meshtastic_LogRecord_Level_WARNING; cn->time = getValidTime(RTCQualityFromNet); - sprintf(cn->message, "Duty cycle limit exceeded. You can send again in %d mins", silentMinutes); + snprintf(cn->message, sizeof(cn->message), "Duty cycle limit exceeded. You can send again in %d mins", silentMinutes); service->sendClientNotification(cn); meshtastic_Routing_Error err = meshtastic_Routing_Error_DUTY_CYCLE_LIMIT; From 94bb21ecc70a275185a1f4fea3b7d57822d1e289 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 9 May 2026 15:12:10 -0500 Subject: [PATCH 153/225] 2.8: NodeDB shrink, decoupling, and restructuring (#10413) * 2.8: NodeDB refactor to decouple satellite entries and decrease size * Regen * Refactor node mute handling to use dedicated functions for clarity and consistency * Develop ref * Fix NodeDB review follow-ups Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/6b1d6cf6-ed6b-43b6-95cb-8e141757664e Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Address review validation nits Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/6b1d6cf6-ed6b-43b6-95cb-8e141757664e Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Trunk * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Extract legacy NodeDatabase migration * Fix remaining NodeDB review issues Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/c76b9a5a-7244-4fbc-9ef0-98091d8caaea Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Fixes * Trunk * Fix latest review compile follow-ups Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/5198da01-ec4c-4c16-8a09-68b8e6d5d410 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Fix cppcheck style warnings Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/e60287ba-4ece-46e0-83d8-a6d89664c0bb Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Change pointer type for mesh node in set_favorite function * Change pointer types for mesh node references to const in multiple applets * Add NodeDB layout v25 documentation and migration guidelines * Remove tests for uninitialized PacketHistory state due to undefined behavior * Fix code block formatting in copilot instructions --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 89 +++ protobufs | 2 +- src/GPSStatus.h | 9 +- src/graphics/Screen.cpp | 4 +- src/graphics/draw/MenuHandler.cpp | 40 +- src/graphics/draw/MessageRenderer.cpp | 37 +- src/graphics/draw/NodeListRenderer.cpp | 90 +-- src/graphics/draw/NotificationRenderer.cpp | 10 +- src/graphics/draw/UIRenderer.cpp | 86 ++- src/graphics/niche/InkHUD/Applet.cpp | 4 +- .../InkHUD/Applets/Bases/Map/MapApplet.cpp | 58 +- .../Applets/Bases/NodeList/NodeListApplet.cpp | 26 +- .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 10 +- .../Notification/NotificationApplet.cpp | 4 +- .../User/AllMessage/AllMessageApplet.cpp | 4 +- .../niche/InkHUD/Applets/User/DM/DMApplet.cpp | 4 +- .../User/FavoritesMap/FavoritesMapApplet.cpp | 6 +- .../InkHUD/Applets/User/Heard/HeardApplet.cpp | 24 +- src/mesh/CryptoEngine.cpp | 4 +- src/mesh/CryptoEngine.h | 7 +- src/mesh/MeshService.cpp | 24 +- src/mesh/NodeDB.cpp | 678 ++++++++++++++---- src/mesh/NodeDB.h | 138 +++- src/mesh/NodeDBLegacyMigration.cpp | 129 ++++ src/mesh/PhoneAPI.cpp | 426 ++++++++++- src/mesh/PhoneAPI.h | 45 +- src/mesh/ProtobufModule.h | 4 +- src/mesh/ReliableRouter.cpp | 2 +- src/mesh/Router.cpp | 32 +- src/mesh/TypeConversions.cpp | 120 ++-- src/mesh/TypeConversions.h | 11 +- .../generated/meshtastic/deviceonly.pb.cpp | 12 + src/mesh/generated/meshtastic/deviceonly.pb.h | 171 +++-- .../meshtastic/deviceonly_legacy.pb.cpp | 15 + .../meshtastic/deviceonly_legacy.pb.h | 124 ++++ src/mesh/http/ContentHandler.cpp | 32 +- src/mesh/mesh-pb-constants.h | 40 +- src/modules/AdminModule.cpp | 29 +- src/modules/CannedMessageModule.cpp | 24 +- src/modules/ExternalNotificationModule.cpp | 2 +- src/modules/KeyVerificationModule.cpp | 48 +- src/modules/PositionModule.cpp | 36 +- src/modules/RangeTestModule.cpp | 32 +- src/modules/RoutingModule.cpp | 3 +- src/modules/SerialModule.cpp | 23 +- src/modules/StatusMessageModule.cpp | 13 +- src/modules/StatusMessageModule.h | 21 +- .../Telemetry/EnvironmentTelemetry.cpp | 4 + src/modules/TraceRouteModule.cpp | 10 +- src/modules/TrafficManagementModule.cpp | 4 +- src/modules/WaypointModule.cpp | 8 +- src/mqtt/MQTT.cpp | 2 +- src/serialization/MeshPacketSerializer.cpp | 14 +- .../MeshPacketSerializer_nRF52.cpp | 12 +- test/test_crypto/test_main.cpp | 2 +- test/test_packet_history/test_main.cpp | 32 +- test/test_traffic_management/test_main.cpp | 8 +- test/test_type_conversions/test_main.cpp | 406 +++++++++++ 58 files changed, 2608 insertions(+), 646 deletions(-) create mode 100644 src/mesh/NodeDBLegacyMigration.cpp create mode 100644 src/mesh/generated/meshtastic/deviceonly_legacy.pb.cpp create mode 100644 src/mesh/generated/meshtastic/deviceonly_legacy.pb.h create mode 100644 test/test_type_conversions/test_main.cpp diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fe9af4359b5..c63e2401819 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -135,6 +135,95 @@ On top of authorization, any remote admin message that **mutates** state (not a - **Channel 0 PSK change** → every peer must re-learn the channel hash; cached NodeInfo becomes temporarily unreachable until the next broadcast. - **`security.private_key` blanked via admin** → regenerates both halves (unless in Ham mode) and propagates the new public key via NodeInfo. +## NodeDB Layout (v25) + +`DEVICESTATE_CUR_VER = 25`, `DEVICESTATE_MIN_VER = 24`. The on-device NodeDB was split in v25 into a slim header table plus four optional satellite stores. Older v24 saves auto-migrate at boot. Old training-data instincts (`node->user.long_name`, `node->position.latitude_i`, `node->is_favorite`, `node->device_metrics.battery_level`) are wrong now — the fields aren't there. Read this section before touching anything that walks `nodeDB->meshNodes`. + +### Slim `NodeInfoLite` + +`UserLite` is flattened onto `NodeInfoLite` (no nested sub-message); `position` and `device_metrics` are removed entirely (tags reserved). MAC address is dropped. Long names are capped at 25 chars (`max_size:25` in `deviceonly.options`); `hw_model` and `role` are `int_size:8`. Encoded size dropped from ~166 B → ~105 B per node. + +Booleans are bit-packed into `NodeInfoLite.bitfield`. **Do not read or write the bits directly** — use the inline helpers in `src/mesh/NodeDB.h`: + +```cpp +nodeInfoLiteHasUser(n) // bit 5 — user fields populated +nodeInfoLiteIsFavorite(n) // bit 3 +nodeInfoLiteIsIgnored(n) // bit 4 +nodeInfoLiteIsMuted(n) // bit 1 +nodeInfoLiteIsLicensed(n) // bit 6 — Ham mode peer +nodeInfoLiteIsKeyManuallyVerified(n) // bit 0 +nodeInfoLiteHasIsUnmessagable(n) // bit 8 — "is_unmessagable was sent" +nodeInfoLiteIsUnmessagable(n) // bit 7 +// via_mqtt is bit 2 (mask exposed; predicate uses the mask directly) + +nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); // setter +``` + +### Satellite stores + +Four `std::unordered_map` members on `NodeDB`, each gated by its own build flag: + +| Map | Value type | Build flag | +| ----------------- | ------------------------------- | ---------------------------------- | +| `nodePositions` | `meshtastic_PositionLite` | `MESHTASTIC_EXCLUDE_POSITIONDB` | +| `nodeTelemetry` | `meshtastic_DeviceMetrics` | `MESHTASTIC_EXCLUDE_TELEMETRYDB` | +| `nodeEnvironment` | `meshtastic_EnvironmentMetrics` | `MESHTASTIC_EXCLUDE_ENVIRONMENTDB` | +| `nodeStatus` | `meshtastic_StatusMessage` | `MESHTASTIC_EXCLUDE_STATUSDB` | + +Defaults are ON (i.e., maps **excluded**) for STM32WL only — see `src/mesh/mesh-pb-constants.h`. On every other arch all four maps are present. When excluded, the map member is absent and the corresponding accessors return `false`. + +All four maps are guarded by **`mutable concurrency::Lock satelliteMutex`** — concurrent access from receive threads, the phone API state machine, and the renderer is the rule, not the exception. + +### Accessor convention + +**Never hand out pointers into the maps.** Use the copy-out accessors on `NodeDB`: + +```cpp +bool copyNodePosition(NodeNum, meshtastic_PositionLite &out) const; +bool copyNodeTelemetry(NodeNum, meshtastic_DeviceMetrics &out) const; +bool copyNodeEnvironment(NodeNum, meshtastic_EnvironmentMetrics &out) const; +bool copyNodeStatus(NodeNum, meshtastic_StatusMessage &out) const; +``` + +Each takes the lock, copies the value if present, returns `false` if the entry is absent or the DB is excluded. Pass-by-out-param is deliberate — pointer-style accessors would invite UAF and lock-leak bugs across the renderer. The "has any X" convenience predicates (`hasValidPosition` etc.) are implemented in terms of these. + +Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which dispatches on `which_variant` for device vs environment metrics) — these own the lock and the eviction hooks. + +### Eviction + +Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly. + +### Gradient sync (opt-in via special nonces) + +`client_capabilities` is **not** a thing in this branch. Phone clients opt into the new sync flow by sending one of two values in the `ToRadio.want_config_id`: + +- `SPECIAL_NONCE_GRADIENT_SYNC` (69422) — full config + thin NodeInfo + replay phases. +- `SPECIAL_NONCE_GRADIENT_ONLY_NODES` (69423) — skip config segments, NodeInfo + replay only. + +`PhoneAPI::clientWantsGradientSync()` is the single switch. When true, `STATE_SEND_OTHER_NODEINFOS` is followed by: + +```text +STATE_REPLAY_POSITIONS → STATE_REPLAY_TELEMETRY → STATE_REPLAY_ENVIRONMENT → STATE_REPLAY_STATUS +``` + +Each replay phase walks the corresponding satellite map and emits synthetic `MeshPacket`s on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` for both device + environment variants, `STATUS_MESSAGE_APP`). Legacy clients (no special nonce) get the bundled-NodeInfo path with position/device_metrics joined back in by `ConvertToNodeInfo(lite, pos*, dm*)` — wire bytes are byte-identical to pre-v25 for them. + +`ConvertToNodeInfoThin(lite)` is the gradient-sync emitter (no position/telemetry). + +### v24 → v25 migration + +The legacy migration code lives in **`src/mesh/NodeDBLegacyMigration.cpp`**, not in `NodeDB.cpp`. It owns the `meshtastic_NodeDatabase_Legacy` callback and `NodeDB::migrateLegacyNodeDatabase()`. The legacy proto descriptor is `protobufs/meshtastic/deviceonly_legacy.proto` (only included by the migration TU). The boot path peeks the file's leading version tag, runs the migration if `version < 25`, then re-saves in v25 layout. The legacy descriptor is scheduled for removal once `DEVICESTATE_MIN_VER` is bumped. + +### Read-site rules of thumb + +- Never `node->position.X` / `node->device_metrics.X` — those fields no longer exist. Pull from the satellite map via `copyNodePosition` / `copyNodeTelemetry`. +- Never `node->user.long_name` — `long_name`, `short_name`, `public_key`, `hw_model`, `role`, `macaddr` (gone), `is_licensed`, `is_unmessagable` are flat on `NodeInfoLite`. +- Never `node->is_favorite` / `node->is_ignored` / `node->via_mqtt` / `node->is_key_manually_verified` — use the bitfield helpers. +- Never assume `nodeDB->getMeshNode(num)->position.time` — call `copyNodePosition` and check the return. +- Don't lock `satelliteMutex` yourself in renderer code; the copy-out accessors already do. + +Unit tests for the conversion layer live in `test/test_type_conversions/test_main.cpp` (Unity) — bitfield round-trips, `long_name` truncation, thin-vs-full conversions. Add cases there when extending the schema. + ## Project Structure ``` diff --git a/protobufs b/protobufs index 1d6f1a71ff3..149586802fa 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 1d6f1a71ff329fa52ad8bb7899951e96f8280a1f +Subproject commit 149586802fa59233783d536132d8cf2fa8057193 diff --git a/src/GPSStatus.h b/src/GPSStatus.h index a1a9f2c5694..25c7b10394c 100644 --- a/src/GPSStatus.h +++ b/src/GPSStatus.h @@ -53,8 +53,7 @@ class GPSStatus : public Status int32_t getLatitude() const { if (config.position.fixed_position) { - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); - return node->position.latitude_i; + return localPosition.latitude_i; } else { return p.latitude_i; } @@ -63,8 +62,7 @@ class GPSStatus : public Status int32_t getLongitude() const { if (config.position.fixed_position) { - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); - return node->position.longitude_i; + return localPosition.longitude_i; } else { return p.longitude_i; } @@ -73,8 +71,7 @@ class GPSStatus : public Status int32_t getAltitude() const { if (config.position.fixed_position) { - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); - return node->position.altitude; + return localPosition.altitude; } else { return p.altitude; } diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index d02938df9d6..205e71bcf15 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1285,7 +1285,7 @@ void Screen::setFrames(FrameFocus focus) for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); - if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) { + if (n && n->num != nodeDB->getNodeNum() && nodeInfoLiteIsFavorite(n)) { favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode); } } @@ -1670,7 +1670,7 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from); const meshtastic_Channel channel = channels.getByIndex(packet->channel ? packet->channel : channels.getPrimaryIndex()); - const char *longName = (node && node->has_user) ? node->user.long_name : nullptr; + const char *longName = nodeInfoLiteHasUser(node) ? node->long_name : nullptr; const char *msgRaw = reinterpret_cast(packet->decoded.payload.bytes); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index f31cb405b60..382b89f2ff5 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -845,10 +845,10 @@ void menuHandler::messageViewModeMenu() // Encode peers for (size_t i = 0; i < uniquePeers.size(); ++i) { uint32_t peer = uniquePeers[i]; - auto node = nodeDB->getMeshNode(peer); + const auto *node = nodeDB->getMeshNode(peer); std::string name; - if (node && node->has_user) - name = sanitizeString(node->user.long_name).substr(0, 15); + if (nodeInfoLiteHasUser(node)) + name = sanitizeString(node->long_name).substr(0, 15); else { char buf[20]; snprintf(buf, sizeof(buf), "Node %08X", peer); @@ -1355,14 +1355,14 @@ void menuHandler::manageNodeMenu() static int optionsEnumArray[enumEnd] = {Back}; int options = 1; - if (node->is_favorite) { + if (nodeInfoLiteIsFavorite(node)) { optionsArray[options] = "Unfavorite"; } else { optionsArray[options] = "Favorite"; } optionsEnumArray[options++] = Favorite; - bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; + bool isMuted = nodeInfoLiteIsMuted(node); if (isMuted) { optionsArray[options] = "Unmute Notifications"; } else { @@ -1376,7 +1376,7 @@ void menuHandler::manageNodeMenu() optionsArray[options] = "Key Verification"; optionsEnumArray[options++] = KeyVerification; - if (node->is_ignored) { + if (nodeInfoLiteIsIgnored(node)) { optionsArray[options] = "Unignore Node"; } else { optionsArray[options] = "Ignore Node"; @@ -1386,8 +1386,8 @@ void menuHandler::manageNodeMenu() BannerOverlayOptions bannerOptions; std::string title = ""; - if (node->has_user && node->user.long_name && node->user.long_name[0]) { - title += sanitizeString(node->user.long_name).substr(0, 15); + if (nodeInfoLiteHasUser(node) && node->long_name[0]) { + title += sanitizeString(node->long_name).substr(0, 15); } else { char buf[20]; snprintf(buf, sizeof(buf), "%08X", (unsigned int)node->num); @@ -1409,7 +1409,7 @@ void menuHandler::manageNodeMenu() if (!n) { return; } - if (n->is_favorite) { + if (nodeInfoLiteIsFavorite(n)) { LOG_INFO("Removing node %08X from favorites", menuHandler::pickedNodeNum); nodeDB->set_favorite(false, menuHandler::pickedNodeNum); } else { @@ -1426,13 +1426,9 @@ void menuHandler::manageNodeMenu() return; } - if (n->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) { - n->bitfield &= ~NODEINFO_BITFIELD_IS_MUTED_MASK; - LOG_INFO("Unmuted node %08X", menuHandler::pickedNodeNum); - } else { - n->bitfield |= NODEINFO_BITFIELD_IS_MUTED_MASK; - LOG_INFO("Muted node %08X", menuHandler::pickedNodeNum); - } + const bool wasMuted = nodeInfoLiteIsMuted(n); + nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, !wasMuted); + LOG_INFO(wasMuted ? "Unmuted node %08X" : "Muted node %08X", menuHandler::pickedNodeNum); nodeDB->notifyObservers(true); nodeDB->saveToDisk(); screen->setFrames(graphics::Screen::FOCUS_PRESERVE); @@ -1461,11 +1457,11 @@ void menuHandler::manageNodeMenu() return; } - if (n->is_ignored) { - n->is_ignored = false; + if (nodeInfoLiteIsIgnored(n)) { + nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, false); LOG_INFO("Unignoring node %08X", menuHandler::pickedNodeNum); } else { - n->is_ignored = true; + nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true); LOG_INFO("Ignoring node %08X", menuHandler::pickedNodeNum); } nodeDB->notifyObservers(true); @@ -2079,9 +2075,9 @@ void menuHandler::removeFavoriteMenu() static const char *optionsArray[] = {"Back", "Yes"}; BannerOverlayOptions bannerOptions; std::string message = "Unfavorite This Node?\n"; - auto node = nodeDB->getMeshNode(graphics::UIRenderer::currentFavoriteNodeNum); - if (node && node->has_user) { - message += sanitizeString(node->user.long_name).substr(0, 15); + const auto *node = nodeDB->getMeshNode(graphics::UIRenderer::currentFavoriteNodeNum); + if (nodeInfoLiteHasUser(node)) { + message += sanitizeString(node->long_name).substr(0, 15); } bannerOptions.message = message.c_str(); bannerOptions.optionsArrayPtr = optionsArray; diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 2260c57df14..a42ef1e08ae 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -462,8 +462,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 } case ThreadMode::DIRECT: { meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer); - if (node && node->has_user && node->user.short_name[0]) { - snprintf(titleStr, sizeof(titleStr), "@%s", node->user.short_name); + if (nodeInfoLiteHasUser(node) && node->short_name[0]) { + snprintf(titleStr, sizeof(titleStr), "@%s", node->short_name); } else { snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer); } @@ -585,11 +585,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest); char senderName[64] = ""; - if (node && node->has_user) { - if (node->user.long_name[0]) { - strncpy(senderName, node->user.long_name, sizeof(senderName) - 1); - } else if (node->user.short_name[0]) { - strncpy(senderName, node->user.short_name, sizeof(senderName) - 1); + if (nodeInfoLiteHasUser(node)) { + if (node->long_name[0]) { + strncpy(senderName, node->long_name, sizeof(senderName) - 1); + } else if (node->short_name[0]) { + strncpy(senderName, node->short_name, sizeof(senderName) - 1); } senderName[sizeof(senderName) - 1] = '\0'; } @@ -599,18 +599,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 // If this is *our own* message, override senderName to who the recipient was bool mine = (m.sender == nodeDB->getNodeNum()); - if (mine && node_recipient && node_recipient->has_user) { - if (node_recipient->user.long_name[0]) { - strncpy(senderName, node_recipient->user.long_name, sizeof(senderName) - 1); + if (mine && nodeInfoLiteHasUser(node_recipient)) { + if (node_recipient->long_name[0]) { + strncpy(senderName, node_recipient->long_name, sizeof(senderName) - 1); senderName[sizeof(senderName) - 1] = '\0'; - } else if (node_recipient->user.short_name[0]) { - strncpy(senderName, node_recipient->user.short_name, sizeof(senderName) - 1); + } else if (node_recipient->short_name[0]) { + strncpy(senderName, node_recipient->short_name, sizeof(senderName) - 1); senderName[sizeof(senderName) - 1] = '\0'; } } // If recipient info is missing/empty, prefer a recipient identifier for outbound messages. - if (mine && (!node_recipient || !node_recipient->has_user || - (!node_recipient->user.long_name[0] && !node_recipient->user.short_name[0]))) { + if (mine && (!nodeInfoLiteHasUser(node_recipient) || (!node_recipient->long_name[0] && !node_recipient->short_name[0]))) { snprintf(senderName, sizeof(senderName), "(%08x)", m.dest); } @@ -1073,12 +1072,12 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht // Banner logic const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from); char longName[64] = "?"; - if (node && node->has_user) { - if (node->user.long_name[0]) { - strncpy(longName, node->user.long_name, sizeof(longName) - 1); + if (nodeInfoLiteHasUser(node)) { + if (node->long_name[0]) { + strncpy(longName, node->long_name, sizeof(longName) - 1); longName[sizeof(longName) - 1] = '\0'; - } else if (node->user.short_name[0]) { - strncpy(longName, node->user.short_name, sizeof(longName) - 1); + } else if (node->short_name[0]) { + strncpy(longName, node->short_name, sizeof(longName) - 1); longName[sizeof(longName) - 1] = '\0'; } } diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 00ae74b585d..d0b027c1356 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -98,29 +98,23 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, // 1) Choose target candidate (long vs short) only if present const char *raw = nullptr; -#if !MESHTASTIC_EXCLUDE_STATUS +#if !MESHTASTIC_EXCLUDE_STATUS && !MESHTASTIC_EXCLUDE_STATUSDB // If long-name mode is enabled, and we have a recent status for this node, - // prefer "(short_name) statusText" as the raw candidate. + // prefer "(short_name) statusText" as the raw candidate. Pull straight out + // of NodeDB's per-NodeNum cache instead of scanning a FIFO. std::string composedFromStatus; - if (config.display.use_long_node_name && node && node->has_user && statusMessageModule) { - const auto &recent = statusMessageModule->getRecentReceived(); - const StatusMessageModule::RecentStatus *found = nullptr; - for (auto it = recent.rbegin(); it != recent.rend(); ++it) { - if (it->fromNodeId == node->num && !it->statusText.empty()) { - found = &(*it); - break; - } - } - - if (found) { - const char *shortName = node->user.short_name; - composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + found->statusText.size()); + if (config.display.use_long_node_name && nodeInfoLiteHasUser(node) && nodeDB) { + meshtastic_StatusMessage cachedStatus; + if (nodeDB->copyNodeStatus(node->num, cachedStatus) && cachedStatus.status[0]) { + const char *shortName = node->short_name; + const size_t statusLen = std::strlen(cachedStatus.status); + composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + statusLen); composedFromStatus += "("; if (shortName && *shortName) { composedFromStatus += shortName; } composedFromStatus += ") "; - composedFromStatus += found->statusText; + composedFromStatus += cachedStatus.status; raw = composedFromStatus.c_str(); // safe for now; we'll sanitize immediately into std::string } @@ -129,8 +123,8 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, // If we didn't compose from status, use normal long/short selection if (!raw) { - if (node && node->has_user) { - raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name; + if (nodeInfoLiteHasUser(node)) { + raw = config.display.use_long_node_name ? node->long_name : node->short_name; } } @@ -218,7 +212,7 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, static inline void applyFavoriteNodeNameColor(OLEDDisplay *display, const meshtastic_NodeInfoLite *node, const char *nodeName, int16_t nameX, int16_t y, int nameMaxWidth) { - if (!display || !node || !node->is_favorite || !isTFTColoringEnabled() || !nodeName) { + if (!display || !node || !nodeInfoLiteIsFavorite(node) || !isTFTColoringEnabled() || !nodeName) { return; } @@ -259,7 +253,7 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int #if GRAPHICS_TFT_COLORING_ENABLED applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); #endif - bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; + bool isMuted = nodeInfoLiteIsMuted(node); char timeStr[10]; uint32_t seconds = sinceLastSeen(node); @@ -279,14 +273,14 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); - if (node->is_favorite) { + if (nodeInfoLiteIsFavorite(node)) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); } else { display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); } } - if (node->is_ignored || isMuted) { + if (nodeInfoLiteIsIgnored(node) || isMuted) { if (currentResolution == ScreenResolution::High) { display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8); } else { @@ -321,20 +315,20 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int #if GRAPHICS_TFT_COLORING_ENABLED applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); #endif - bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; + bool isMuted = nodeInfoLiteIsMuted(node); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); - if (node->is_favorite) { + if (nodeInfoLiteIsFavorite(node)) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); } else { display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); } } - if (node->is_ignored || isMuted) { + if (nodeInfoLiteIsIgnored(node) || isMuted) { if (currentResolution == ScreenResolution::High) { display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8); } else { @@ -401,15 +395,19 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 #if GRAPHICS_TFT_COLORING_ENABLED applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); #endif - bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; + bool isMuted = nodeInfoLiteIsMuted(node); char distStr[10] = ""; - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) { - double lat1 = ourNode->position.latitude_i * 1e-7; - double lon1 = ourNode->position.longitude_i * 1e-7; - double lat2 = node->position.latitude_i * 1e-7; - double lon2 = node->position.longitude_i * 1e-7; + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + meshtastic_PositionLite ourPos; + meshtastic_PositionLite theirPos; + const bool haveOurPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ourPos); + const bool haveTheirPos = nodeDB->copyNodePosition(node->num, theirPos); + if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node) && haveOurPos && haveTheirPos) { + double lat1 = ourPos.latitude_i * 1e-7; + double lon1 = ourPos.longitude_i * 1e-7; + double lat2 = theirPos.latitude_i * 1e-7; + double lon2 = theirPos.longitude_i * 1e-7; double earthRadiusKm = 6371.0; double dLat = (lat2 - lat1) * DEG_TO_RAD; @@ -455,14 +453,14 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); - if (node->is_favorite) { + if (nodeInfoLiteIsFavorite(node)) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); } else { display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); } } - if (node->is_ignored || isMuted) { + if (nodeInfoLiteIsIgnored(node) || isMuted) { if (currentResolution == ScreenResolution::High) { display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8); } else { @@ -509,19 +507,19 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 #if GRAPHICS_TFT_COLORING_ENABLED applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); #endif - bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; + bool isMuted = nodeInfoLiteIsMuted(node); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); - if (node->is_favorite) { + if (nodeInfoLiteIsFavorite(node)) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); } else { display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); } } - if (node->is_ignored || isMuted) { + if (nodeInfoLiteIsIgnored(node) || isMuted) { if (currentResolution == ScreenResolution::High) { display->drawLine(x + 8, y + 8, (isLeftCol ? 0 : x - 4) + nameMaxWidth - 17, y + 8); } else { @@ -542,8 +540,11 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 int centerX = x + columnWidth - arrowXOffset; int centerY = y + FONT_HEIGHT_SMALL / 2; - double nodeLat = node->position.latitude_i * 1e-7; - double nodeLon = node->position.longitude_i * 1e-7; + meshtastic_PositionLite nodePos; + if (!nodeDB->copyNodePosition(node->num, nodePos)) + return; + double nodeLat = nodePos.latitude_i * 1e-7; + double nodeLon = nodePos.longitude_i * 1e-7; float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon); float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian); float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing); @@ -652,7 +653,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t continue; if (n->num == nodeDB->getNodeNum()) continue; - if (locationScreen && !n->has_position) + if (locationScreen && !nodeDB->hasNodePosition(n->num)) continue; drawList.push_back(n->num); @@ -883,14 +884,15 @@ void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { float headingRadian = 0.0f; - auto ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - if (!ourNode || !nodeDB->hasValidPosition(ourNode)) { + const auto *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + meshtastic_PositionLite ourSelfPos; + if (!ourNode || !nodeDB->hasValidPosition(ourNode) || !nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) { drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassUnknown, headingRadian, 0.0, 0.0); return; } - double lat = DegD(ourNode->position.latitude_i); - double lon = DegD(ourNode->position.longitude_i); + double lat = DegD(ourSelfPos.latitude_i); + double lon = DegD(ourSelfPos.longitude_i); #if defined(M5STACK_UNITC6L) display->clear(); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index cca60d1e27e..8d031cf734c 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -317,12 +317,12 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) { char tempName[48] = {0}; meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i + 1); - if (node && node->has_user) { + if (nodeInfoLiteHasUser(node)) { const char *rawName = nullptr; - if (node->user.long_name[0]) { - rawName = node->user.long_name; - } else if (node->user.short_name[0]) { - rawName = node->user.short_name; + if (node->long_name[0]) { + rawName = node->long_name; + } else if (node->short_name[0]) { + rawName = node->short_name; } if (rawName) { const int arrowWidth = (currentResolution == ScreenResolution::High) diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index b75bcd17b1e..cfde101247f 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -464,11 +464,11 @@ static bool computeBottomCompassPlacement(OLEDDisplay *display, int16_t xOffset, return true; } -static void drawTruncatedStatusLine(OLEDDisplay *display, int16_t x, int16_t y, const std::string &statusText) +static void drawTruncatedStatusLine(OLEDDisplay *display, int16_t x, int16_t y, const char *statusText) { // Fixed-buffer truncate helper replaces iterative std::string chopping to keep code size down. char rawStatus[96]; - snprintf(rawStatus, sizeof(rawStatus), " Status: %s", statusText.c_str()); + snprintf(rawStatus, sizeof(rawStatus), " Status: %s", statusText ? statusText : ""); char clippedStatus[96]; UIRenderer::truncateStringWithEmotes(display, rawStatus, clippedStatus, sizeof(clippedStatus), display->getWidth()); @@ -495,7 +495,7 @@ void graphics::UIRenderer::rebuildFavoritedNodes() meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); if (!n || n->num == nodeDB->getNodeNum()) continue; - if (n->is_favorite) + if (nodeInfoLiteIsFavorite(n)) favoritedNodes.push_back(n); } @@ -750,7 +750,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat return; meshtastic_NodeInfoLite *node = favoritedNodes[nodeIndex]; - if (!node || node->num == nodeDB->getNodeNum() || !node->is_favorite) + if (!node || node->num == nodeDB->getNodeNum() || !nodeInfoLiteIsFavorite(node)) return; display->clear(); #if defined(M5STACK_UNITC6L) @@ -763,7 +763,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat #endif currentFavoriteNodeNum = node->num; // === Create the shortName and title string === - const char *shortName = (node->has_user && node->user.short_name[0]) ? node->user.short_name : "Node"; + const char *shortName = (nodeInfoLiteHasUser(node) && node->short_name[0]) ? node->short_name : "Node"; char titlestr[40]; snprintf(titlestr, sizeof(titlestr), "*%s*", shortName); @@ -781,9 +781,9 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat // === 1. Long Name (always try to show first) === const char *username; if (currentResolution == ScreenResolution::UltraLow) { - username = (node->has_user && node->user.long_name[0]) ? node->user.short_name : nullptr; + username = (nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->short_name : nullptr; } else { - username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr; + username = (nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->long_name : nullptr; } // Print node's long name (e.g. "Backpack Node") @@ -796,23 +796,14 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false); } -#if !MESHTASTIC_EXCLUDE_STATUS +#if !MESHTASTIC_EXCLUDE_STATUS && !MESHTASTIC_EXCLUDE_STATUSDB // === Optional: Last received StatusMessage line for this node === - // Display it directly under the username line (if we have one). - if (statusMessageModule) { - const auto &recent = statusMessageModule->getRecentReceived(); - const StatusMessageModule::RecentStatus *found = nullptr; - - // Search newest-to-oldest - for (auto it = recent.rbegin(); it != recent.rend(); ++it) { - if (it->fromNodeId == node->num && !it->statusText.empty()) { - found = &(*it); - break; - } - } - - if (found) { - drawTruncatedStatusLine(display, x, getTextPositions(display)[line++], found->statusText); + // Display it directly under the username line (if we have one). The cache + // lives on NodeDB now, keyed by NodeNum, so this is an O(1) lookup. + if (nodeDB) { + meshtastic_StatusMessage cachedStatus; + if (nodeDB->copyNodeStatus(node->num, cachedStatus) && cachedStatus.status[0]) { + drawTruncatedStatusLine(display, x, getTextPositions(display)[line++], cachedStatus.status); } } #endif @@ -971,25 +962,30 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat #if !defined(M5STACK_UNITC6L) // === 4. Uptime (only show if metric is present) === char uptimeStr[32] = ""; - if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) { + meshtastic_DeviceMetrics nodeMetrics; + const bool haveNodeMetrics = nodeDB->copyNodeTelemetry(node->num, nodeMetrics); + if (haveNodeMetrics && nodeMetrics.has_uptime_seconds) { char upPrefix[12]; // enough for leftSideSpacing + "Up:" snprintf(upPrefix, sizeof(upPrefix), "%sUp:", leftSideSpacing); - getUptimeStr(node->device_metrics.uptime_seconds * 1000, upPrefix, uptimeStr, sizeof(uptimeStr)); + getUptimeStr(nodeMetrics.uptime_seconds * 1000, upPrefix, uptimeStr, sizeof(uptimeStr)); } if (uptimeStr[0]) { display->drawString(x, getTextPositions(display)[line++], uptimeStr); } // === 5. Distance (only if both nodes have GPS position) === - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); char distStr[24] = ""; // Make buffer big enough for any string bool haveDistance = false; - if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) { + meshtastic_PositionLite nodePos; + meshtastic_PositionLite ourPos; + const bool haveNodePos = nodeDB->copyNodePosition(node->num, nodePos); + const bool haveOurPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ourPos); + if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node) && haveNodePos && haveOurPos) { // Use shared meter conversion, then format display units with lightweight integer rounding. - const float distanceMeters = - GeoCoord::latLongToMeter(DegD(node->position.latitude_i), DegD(node->position.longitude_i), - DegD(ourNode->position.latitude_i), DegD(ourNode->position.longitude_i)); + const float distanceMeters = GeoCoord::latLongToMeter(DegD(nodePos.latitude_i), DegD(nodePos.longitude_i), + DegD(ourPos.latitude_i), DegD(ourPos.longitude_i)); if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { const int feet = static_cast((distanceMeters * METERS_TO_FEET) + 0.5f); if (feet > 0 && feet < 1000) { @@ -1024,19 +1020,19 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat char batLine[32] = ""; bool haveBatLine = false; - if (node->has_device_metrics) { - bool hasPct = node->device_metrics.has_battery_level; - bool hasVolt = node->device_metrics.has_voltage && node->device_metrics.voltage > 0.001f; + if (haveNodeMetrics) { + bool hasPct = nodeMetrics.has_battery_level; + bool hasVolt = nodeMetrics.has_voltage && nodeMetrics.voltage > 0.001f; int pct = 0; float volt = 0.0f; if (hasPct) { - pct = (int)node->device_metrics.battery_level; + pct = (int)nodeMetrics.battery_level; } if (hasVolt) { - volt = node->device_metrics.voltage; + volt = nodeMetrics.voltage; } if (hasPct && pct > 0 && pct <= 100) { @@ -1076,11 +1072,11 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat const bool hasNodePositionFix = nodeDB->hasValidPosition(node); const char *statusLine1 = nullptr; const char *statusLine2 = nullptr; - if (hasOwnPositionFix && hasNodePositionFix) { - const auto &op = ourNode->position; + if (hasOwnPositionFix && hasNodePositionFix && haveOurPos && haveNodePos) { + const auto &op = ourPos; showCompass = CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading); if (showCompass) { - const auto &p = node->position; + const auto &p = nodePos; bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i)); bearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeading); } else { @@ -1130,7 +1126,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); int line = 1; - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); // === Header === if (currentResolution == ScreenResolution::UltraLow) { @@ -1270,7 +1266,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta int textWidth = 0; int nameX = 0; int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5; - const char *longName = (ourNode && ourNode->has_user && ourNode->user.long_name[0]) ? ourNode->user.long_name : ""; + const char *longName = (nodeInfoLiteHasUser(ourNode) && ourNode->long_name[0]) ? ourNode->long_name : ""; const char *shortName = owner.short_name ? owner.short_name : ""; char combinedName[96]; if (longName[0] && shortName[0]) { @@ -1597,7 +1593,7 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()), int32_t(gpsStatus->getAltitude())); - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode)); const bool hasLiveGpsFix = (gpsStatus && gpsStatus->getHasLock() && (gpsStatus->getLatitude() != 0 || gpsStatus->getLongitude() != 0)); @@ -1613,9 +1609,11 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU headingLat = DegD(gpsStatus->getLatitude()); headingLon = DegD(gpsStatus->getLongitude()); } else if (hasOwnPositionFix) { - const auto &op = ourNode->position; - headingLat = DegD(op.latitude_i); - headingLon = DegD(op.longitude_i); + meshtastic_PositionLite ownPos; + if (nodeDB->copyNodePosition(ourNode->num, ownPos)) { + headingLat = DegD(ownPos.latitude_i); + headingLon = DegD(ownPos.longitude_i); + } } validHeading = CompassRenderer::getHeadingRadians(headingLat, headingLon, heading); } diff --git a/src/graphics/niche/InkHUD/Applet.cpp b/src/graphics/niche/InkHUD/Applet.cpp index 0a9cd3add87..94039d51939 100644 --- a/src/graphics/niche/InkHUD/Applet.cpp +++ b/src/graphics/niche/InkHUD/Applet.cpp @@ -362,8 +362,8 @@ std::string InkHUD::Applet::parseShortName(meshtastic_NodeInfoLite *node) assert(node); // Use the true shortname if known, and doesn't contain any unprintable characters (emoji, etc.) - if (node->has_user) { - std::string parsed = parse(node->user.short_name); + if (nodeInfoLiteHasUser(node)) { + std::string parsed = parse(node->short_name); if (isPrintable(parsed)) return parsed; } diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp index 63ccaa2163c..881371e2d09 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp @@ -136,9 +136,10 @@ 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 - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - if (ourNode && nodeDB->hasValidPosition(ourNode)) { - Marker self = calculateMarker(ourNode->position.latitude_i * 1e-7, ourNode->position.longitude_i * 1e-7, false, 0); + 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); int16_t centerX = X(0.5) + (self.eastMeters * metersToPx); int16_t centerY = Y(0.5) - (self.northMeters * metersToPx); @@ -168,10 +169,11 @@ void InkHUD::MapApplet::onRender(bool full) void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) { // If we have a valid position for our own node, use that as the anchor - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - if (ourNode && nodeDB->hasValidPosition(ourNode)) { - *lat = ourNode->position.latitude_i * 1e-7; - *lng = ourNode->position.longitude_i * 1e-7; + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + meshtastic_PositionLite ourSelfPos; + if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) { + *lat = ourSelfPos.latitude_i * 1e-7; + *lng = ourSelfPos.longitude_i * 1e-7; } else { // Find mean lat long coords // ============================ @@ -201,9 +203,13 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) if (!shouldDrawNode(node)) continue; + meshtastic_PositionLite pos; + if (!nodeDB->copyNodePosition(node->num, pos)) + continue; + // Latitude and Longitude of node, in radians - float latRad = node->position.latitude_i * (1e-7) * DEG_TO_RAD; - float lngRad = node->position.longitude_i * (1e-7) * DEG_TO_RAD; + float latRad = pos.latitude_i * (1e-7) * DEG_TO_RAD; + float lngRad = pos.longitude_i * (1e-7) * DEG_TO_RAD; // Convert to cartesian points, with center of earth at 0, 0, 0 // Exact distance from center is irrelevant, as we're only interested in the vector @@ -300,13 +306,17 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) if (!shouldDrawNode(node)) continue; + meshtastic_PositionLite pos; + if (!nodeDB->copyNodePosition(node->num, pos)) + continue; + // Check for a new top or bottom latitude - float latNode = node->position.latitude_i * 1e-7; + float latNode = pos.latitude_i * 1e-7; northernmost = max(northernmost, latNode); southernmost = min(southernmost, latNode); // Longitude is trickier - float lngNode = node->position.longitude_i * 1e-7; + 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 if (degEastward < degWestward) @@ -372,10 +382,13 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node) { // Find x and y position based on node's position in nodeDB assert(nodeDB->hasValidPosition(node)); - Marker m = calculateMarker(node->position.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style - node->position.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 + 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 ); // Convert to pixel coords @@ -516,13 +529,16 @@ void InkHUD::MapApplet::calculateAllMarkers() if (node->num == nodeDB->getNodeNum()) continue; + meshtastic_PositionLite pos; + if (!nodeDB->copyNodePosition(node->num, pos)) + continue; + // Calculate marker and store it - markers.push_back( - calculateMarker(node->position.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style - node->position.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, // 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 + )); } } diff --git a/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp index 607fd4ef730..3da912a788e 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp @@ -50,21 +50,23 @@ ProcessMessage InkHUD::NodeListApplet::handleReceived(const meshtastic_MeshPacke c.signal = getSignalStrength(mp.rx_snr, mp.rx_rssi); // Assemble info: from nodeDB (needed to detect changes) - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(c.nodeNum); - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(c.nodeNum); + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); if (node) { if (node->has_hops_away) c.hopsAway = node->hops_away; if (nodeDB->hasValidPosition(node) && nodeDB->hasValidPosition(ourNode)) { - // Get lat and long as float - // Meshtastic stores these as integers internally - float ourLat = ourNode->position.latitude_i * 1e-7; - float ourLong = ourNode->position.longitude_i * 1e-7; - float theirLat = node->position.latitude_i * 1e-7; - float theirLong = node->position.longitude_i * 1e-7; - - c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong); + meshtastic_PositionLite ourPos; + meshtastic_PositionLite theirPos; + if (nodeDB->copyNodePosition(ourNode->num, ourPos) && nodeDB->copyNodePosition(node->num, theirPos)) { + float ourLat = ourPos.latitude_i * 1e-7; + float ourLong = ourPos.longitude_i * 1e-7; + float theirLat = theirPos.latitude_i * 1e-7; + float theirLong = theirPos.longitude_i * 1e-7; + + c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong); + } } } @@ -179,8 +181,8 @@ void InkHUD::NodeListApplet::onRender(bool full) // -- Longname -- // Parse special chars in long name // Use node id if unknown - if (node && node->has_user) - longName = parse(node->user.long_name); // Found in nodeDB + if (nodeInfoLiteHasUser(node)) + longName = parse(node->long_name); // Found in nodeDB else { // Not found in nodeDB, show a hex nodeid instead longName = hexifyNodeNum(nodeNum); diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index dfef4d08522..79ac1e701b2 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -2090,7 +2090,7 @@ void InkHUD::MenuApplet::populateRecipientPage() // Count favorites for (uint32_t i = 0; i < nodeCount; i++) { - if (nodeDB->getMeshNodeByIndex(i)->is_favorite) + if (nodeInfoLiteIsFavorite(nodeDB->getMeshNodeByIndex(i))) favoriteCount++; } @@ -2098,10 +2098,10 @@ void InkHUD::MenuApplet::populateRecipientPage() // Don't want some monstrous list that takes 100 clicks to reach exit if (favoriteCount < 20) { for (uint32_t i = 0; i < nodeCount; i++) { - meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); // Skip node if not a favorite - if (!node->is_favorite) + if (!nodeInfoLiteIsFavorite(node)) continue; CannedMessages::RecipientItem r; @@ -2111,8 +2111,8 @@ void InkHUD::MenuApplet::populateRecipientPage() // Set a label for the menu item r.label = "DM: "; - if (node->has_user) - r.label += parse(node->user.long_name); + if (nodeInfoLiteHasUser(node)) + r.label += parse(node->long_name); else r.label += hexifyNodeNum(node->num); // Unsure if it's possible to favorite a node without NodeInfo? diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp index 6c8069c8b9d..44d0a808977 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp @@ -241,7 +241,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila text += msgIsBroadcast ? "From:" : "DM: "; // Sender id - if (node && node->has_user) + if (nodeInfoLiteHasUser(node)) text += parseShortName(node); else text += hexifyNodeNum(message->sender); @@ -255,7 +255,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila text += msgIsBroadcast ? "Msg from " : "DM from "; // Sender id - if (node && node->has_user) + if (nodeInfoLiteHasUser(node)) text += parseShortName(node); else text += hexifyNodeNum(message->sender); diff --git a/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp index 96c519599f0..bbbe826b162 100644 --- a/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp @@ -70,10 +70,10 @@ void InkHUD::AllMessageApplet::onRender(bool full) // - short name and long name, if available, or // - node id meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(message->sender); - if (sender && sender->has_user) { + if (nodeInfoLiteHasUser(sender)) { header += parseShortName(sender); // May be last-four of node if unprintable (emoji, etc) header += " ("; - header += parse(sender->user.long_name); + header += parse(sender->long_name); header += ")"; } else header += hexifyNodeNum(message->sender); diff --git a/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp index 189a56cab5a..c15152d3a17 100644 --- a/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp @@ -66,10 +66,10 @@ void InkHUD::DMApplet::onRender(bool full) // - shortname and long name, if available, or // - node id meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(latestMessage->dm.sender); - if (sender && sender->has_user) { + if (nodeInfoLiteHasUser(sender)) { header += parseShortName(sender); // May be last-four of node if unprintable (emoji, etc) header += " ("; - header += parse(sender->user.long_name); + header += parse(sender->long_name); header += ")"; } else header += hexifyNodeNum(latestMessage->dm.sender); diff --git a/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.cpp index 520070d7282..290546a6f11 100644 --- a/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.cpp @@ -8,7 +8,7 @@ using namespace NicheGraphics; bool InkHUD::FavoritesMapApplet::shouldDrawNode(meshtastic_NodeInfoLite *node) { // Keep our own node available as map anchor/center; all others must be favorited. - return node && (node->num == nodeDB->getNodeNum() || node->is_favorite); + return node && (node->num == nodeDB->getNodeNum() || nodeInfoLiteIsFavorite(node)); } void InkHUD::FavoritesMapApplet::onRender(bool full) @@ -25,7 +25,7 @@ void InkHUD::FavoritesMapApplet::onRender(bool full) // Draw our latest "node of interest" as a special marker. meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(lastFrom); - if (node && node->is_favorite && nodeDB->hasValidPosition(node) && enoughMarkers()) + if (node && nodeInfoLiteIsFavorite(node) && nodeDB->hasValidPosition(node) && enoughMarkers()) drawLabeledMarker(node); } @@ -74,7 +74,7 @@ ProcessMessage InkHUD::FavoritesMapApplet::handleReceived(const meshtastic_MeshP } else { // For non-local packets, this applet only reacts to favorited nodes. const meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(mp.from); - if (!sender || !sender->is_favorite) + if (!nodeInfoLiteIsFavorite(sender)) return ProcessMessage::CONTINUE; // Check if this position is from someone different than our previous position packet. diff --git a/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp index a7fd094e6ca..94a87d23db5 100644 --- a/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp @@ -80,8 +80,8 @@ void InkHUD::HeardApplet::populateFromNodeDB() ordered.resize(maxCards()); // Create card info for these (stale) node observations - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - for (meshtastic_NodeInfoLite *node : ordered) { + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + for (const meshtastic_NodeInfoLite *node : ordered) { CardInfo c; c.nodeNum = node->num; @@ -89,14 +89,16 @@ void InkHUD::HeardApplet::populateFromNodeDB() c.hopsAway = node->hops_away; if (nodeDB->hasValidPosition(node) && nodeDB->hasValidPosition(ourNode)) { - // Get lat and long as float - // Meshtastic stores these as integers internally - float ourLat = ourNode->position.latitude_i * 1e-7; - float ourLong = ourNode->position.longitude_i * 1e-7; - float theirLat = node->position.latitude_i * 1e-7; - float theirLong = node->position.longitude_i * 1e-7; - - c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong); + meshtastic_PositionLite ourPos; + meshtastic_PositionLite theirPos; + if (nodeDB->copyNodePosition(ourNode->num, ourPos) && nodeDB->copyNodePosition(node->num, theirPos)) { + float ourLat = ourPos.latitude_i * 1e-7; + float ourLong = ourPos.longitude_i * 1e-7; + float theirLat = theirPos.latitude_i * 1e-7; + float theirLong = theirPos.longitude_i * 1e-7; + + c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong); + } } // Insert into the card collection (member of base class) @@ -122,4 +124,4 @@ std::string InkHUD::HeardApplet::getHeaderText() return text; } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index daa7a3a75db..59c6701c9d4 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -112,7 +112,7 @@ bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, mes * @param bytes Buffer containing plaintext input. * @param bytesOut Output buffer to be populated with encrypted ciphertext. */ -bool CryptoEngine::encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtastic_UserLite_public_key_t remotePublic, +bool CryptoEngine::encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic, uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut) { uint8_t *auth; @@ -152,7 +152,7 @@ bool CryptoEngine::encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtas * @param bytes Buffer containing ciphertext input. * @param bytesOut Output buffer to be populated with decrypted plaintext. */ -bool CryptoEngine::decryptCurve25519(uint32_t fromNode, meshtastic_UserLite_public_key_t remotePublic, uint64_t packetNum, +bool CryptoEngine::decryptCurve25519(uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic, uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut) { const uint8_t *auth = bytes + numBytes - 12; // set to last 8 bytes of text? diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index f40400331a9..9410b63e754 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -40,9 +40,12 @@ class CryptoEngine #endif void setDHPrivateKey(uint8_t *_private_key); - virtual bool encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtastic_UserLite_public_key_t remotePublic, + // The remotePublic key parameter takes the public_key bytes container from + // a stored node header. NodeInfoLite is the on-device storage type since + // the slim refactor flattened UserLite into it. + virtual bool encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic, uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut); - virtual bool decryptCurve25519(uint32_t fromNode, meshtastic_UserLite_public_key_t remotePublic, uint64_t packetNum, + virtual bool decryptCurve25519(uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic, uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut); virtual bool setDHPublicKey(uint8_t *publicKey); virtual void hash(uint8_t *bytes, size_t numBytes); diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 952a6d2be37..151831b962c 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -94,8 +94,9 @@ int MeshService::handleFromRadio(const meshtastic_MeshPacket *mp) mp->decoded.portnum == meshtastic_PortNum_TELEMETRY_APP && mp->decoded.request_id > 0) { LOG_DEBUG("Received telemetry response. Skip sending our NodeInfo"); // ignore our request for its NodeInfo - } else if (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag && !nodeDB->getMeshNode(mp->from)->has_user && - nodeInfoModule && !isPreferredRebroadcaster && !nodeDB->isFull()) { + } else if (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag && + !nodeInfoLiteHasUser(nodeDB->getMeshNode(mp->from)) && nodeInfoModule && !isPreferredRebroadcaster && + !nodeDB->isFull()) { if (airTime->isTxAllowedChannelUtil(true)) { const int8_t hopsUsed = getHopsAway(*mp, config.lora.hop_limit); if (hopsUsed > (int32_t)(config.lora.hop_limit + 2)) { @@ -392,19 +393,16 @@ meshtastic_NodeInfoLite *MeshService::refreshLocalMeshNode() meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); assert(node); - // We might not have a position yet for our local node, in that case, at least try to send the time - if (!node->has_position) { - memset(&node->position, 0, sizeof(node->position)); - node->has_position = true; - } - - meshtastic_PositionLite &position = node->position; - // Update our local node info with our time (even if we don't decide to update anyone else) node->last_heard = getValidTime(RTCQualityFromNet); // This nodedb timestamp might be stale, so update it if our clock is kinda valid - position.time = getValidTime(RTCQualityFromNet); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + // Make sure our own NodeNum has a slot in the position map so subsequent + // updates (and the bundled NodeInfo emission to the phone) have somewhere + // to read from. Insert a default-zero entry on first call. + nodeDB->touchNodePositionTime(node->num, getValidTime(RTCQualityFromNet)); +#endif if (powerStatus->getHasBattery() == 1) { updateBatteryLevel(powerStatus->getBatteryChargePercent()); @@ -432,7 +430,9 @@ int MeshService::onGPSChanged(const meshtastic::GPSStatus *newStatus) // Used fixed position if configured regardless of GPS lock if (config.position.fixed_position) { LOG_WARN("Use fixed position"); - pos = TypeConversions::ConvertToPosition(node->position); + meshtastic_PositionLite fixedSlot; + if (nodeDB->copyNodePosition(node->num, fixedSlot)) + pos = TypeConversions::ConvertToPosition(fixedSlot); } // Add a fresh timestamp diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index ef07f68fd64..46aa61b28da 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -22,6 +22,7 @@ #include "error.h" #include "main.h" #include "mesh-pb-constants.h" +#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h" #include "meshUtils.h" #include "modules/NeighborInfoModule.h" #include @@ -151,24 +152,103 @@ uint32_t get_st7789_id(uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_ #endif -bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_iter_t *field) +bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field) { - if (ostream) { - std::vector const *vec = (std::vector *)field->pData; - for (auto item : *vec) { - if (!pb_encode_tag_for_field(ostream, field)) - return false; - pb_encode_submessage(ostream, meshtastic_NodeInfoLite_fields, &item); + const auto *iter = reinterpret_cast(field); + switch (iter->tag) { + case meshtastic_NodeDatabase_nodes_tag: { + if (ostream) { + const auto *vec = static_cast *>(iter->pData); + for (auto item : *vec) { + if (!pb_encode_tag_for_field(ostream, iter)) + return false; + if (!pb_encode_submessage(ostream, meshtastic_NodeInfoLite_fields, &item)) + return false; + } + } + if (istream) { + meshtastic_NodeInfoLite node; + auto *vec = static_cast *>(iter->pData); + if (istream->bytes_left && pb_decode(istream, meshtastic_NodeInfoLite_fields, &node)) + vec->push_back(node); + } + return true; + } + case meshtastic_NodeDatabase_positions_tag: { + if (ostream) { + const auto *vec = static_cast *>(iter->pData); + for (auto item : *vec) { + if (!pb_encode_tag_for_field(ostream, iter)) + return false; + if (!pb_encode_submessage(ostream, meshtastic_NodePositionEntry_fields, &item)) + return false; + } + } + if (istream) { + meshtastic_NodePositionEntry entry; + auto *vec = static_cast *>(iter->pData); + if (istream->bytes_left && pb_decode(istream, meshtastic_NodePositionEntry_fields, &entry)) + vec->push_back(entry); + } + return true; + } + case meshtastic_NodeDatabase_telemetry_tag: { + if (ostream) { + const auto *vec = static_cast *>(iter->pData); + for (auto item : *vec) { + if (!pb_encode_tag_for_field(ostream, iter)) + return false; + if (!pb_encode_submessage(ostream, meshtastic_NodeTelemetryEntry_fields, &item)) + return false; + } + } + if (istream) { + meshtastic_NodeTelemetryEntry entry; + auto *vec = static_cast *>(iter->pData); + if (istream->bytes_left && pb_decode(istream, meshtastic_NodeTelemetryEntry_fields, &entry)) + vec->push_back(entry); + } + return true; + } + case meshtastic_NodeDatabase_status_tag: { + if (ostream) { + const auto *vec = static_cast *>(iter->pData); + for (auto item : *vec) { + if (!pb_encode_tag_for_field(ostream, iter)) + return false; + if (!pb_encode_submessage(ostream, meshtastic_NodeStatusEntry_fields, &item)) + return false; + } + } + if (istream) { + meshtastic_NodeStatusEntry entry; + auto *vec = static_cast *>(iter->pData); + if (istream->bytes_left && pb_decode(istream, meshtastic_NodeStatusEntry_fields, &entry)) + vec->push_back(entry); + } + return true; + } + case meshtastic_NodeDatabase_environment_tag: { + if (ostream) { + const auto *vec = static_cast *>(iter->pData); + for (auto item : *vec) { + if (!pb_encode_tag_for_field(ostream, iter)) + return false; + if (!pb_encode_submessage(ostream, meshtastic_NodeEnvironmentEntry_fields, &item)) + return false; + } + } + if (istream) { + meshtastic_NodeEnvironmentEntry entry; + auto *vec = static_cast *>(iter->pData); + if (istream->bytes_left && pb_decode(istream, meshtastic_NodeEnvironmentEntry_fields, &entry)) + vec->push_back(entry); } + return true; } - if (istream) { - meshtastic_NodeInfoLite node; // this gets good data - std::vector *vec = (std::vector *)field->pData; - - if (istream->bytes_left && pb_decode(istream, meshtastic_NodeInfoLite_fields, &node)) - vec->push_back(node); + default: + return true; } - return true; } /** The current change # for radio settings. Starts at 0 on boot and any time the radio settings @@ -298,8 +378,7 @@ NodeDB::NodeDB() #endif // Include our owner in the node db under our nodenum meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum()); - info->user = TypeConversions::ConvertToUserLite(owner); - info->has_user = true; + TypeConversions::CopyUserToNodeInfoLite(info, owner); // If node database has not been saved for the first time, save it now #ifdef FSCom @@ -442,8 +521,12 @@ NodeDB::NodeDB() #if defined(USERPREFS_FIXED_GPS_LAT) && defined(USERPREFS_FIXED_GPS_LON) fixedGPS.location_source = meshtastic_Position_LocSource_LOC_MANUAL; config.has_position = true; - info->has_position = true; - info->position = TypeConversions::ConvertToPositionLite(fixedGPS); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + { + concurrency::LockGuard guard(&satelliteMutex); + nodePositions[info->num] = TypeConversions::ConvertToPositionLite(fixedGPS); + } +#endif nodeDB->setLocalPosition(fixedGPS); config.position.fixed_position = true; #endif @@ -479,6 +562,29 @@ bool isBroadcast(uint32_t dest) return dest == NODENUM_BROADCAST || dest == NODENUM_BROADCAST_NO_LORA; } +namespace +{ +template bool copySatelliteEntry(const Map &map, NodeNum n, Value &out) +{ + auto it = map.find(n); + if (it == map.end()) + return false; + out = it->second; + return true; +} + +template std::vector snapshotSatelliteNodeNums(const Map &map, NodeNum exclude) +{ + std::vector result; + result.reserve(map.size()); + for (const auto &kv : map) { + if (kv.first != exclude) + result.push_back(kv.first); + } + return result; +} +} // namespace + void NodeDB::resetRadioConfig(bool is_fresh_install) { if (is_fresh_install) { @@ -548,6 +654,19 @@ void NodeDB::installDefaultNodeDatabase() nodeDatabase.nodes = std::vector(MAX_NUM_NODES); numMeshNodes = 0; meshNodes = &nodeDatabase.nodes; + concurrency::LockGuard satelliteGuard(&satelliteMutex); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + nodePositions.clear(); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + nodeTelemetry.clear(); +#endif +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + nodeEnvironment.clear(); +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB + nodeStatus.clear(); +#endif } void NodeDB::installDefaultConfig(bool preserveKey = false) @@ -1019,12 +1138,14 @@ void NodeDB::resetNodes(bool keepFavorites) { if (!config.position.fixed_position) clearLocalPosition(); + NodeNum ourNum = getNodeNum(); numMeshNodes = 1; if (keepFavorites) { LOG_INFO("Clearing node database - preserving favorites"); for (size_t i = 0; i < meshNodes->size(); i++) { meshtastic_NodeInfoLite &node = meshNodes->at(i); - if (i > 0 && !node.is_favorite) { + if (i > 0 && !nodeInfoLiteIsFavorite(&node)) { + eraseNodeSatellites(node.num); node = meshtastic_NodeInfoLite(); } else { numMeshNodes += 1; @@ -1032,8 +1153,14 @@ void NodeDB::resetNodes(bool keepFavorites) }; } else { LOG_INFO("Clearing node database - removing favorites"); + for (size_t i = 1; i < meshNodes->size(); i++) { + const NodeNum gone = meshNodes->at(i).num; + if (gone) + eraseNodeSatellites(gone); + } std::fill(nodeDatabase.nodes.begin() + 1, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite()); } + (void)ourNum; devicestate.has_rx_text_message = false; devicestate.has_rx_waypoint = false; saveNodeDatabaseToDisk(); @@ -1054,36 +1181,173 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum) numMeshNodes -= removed; std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.begin() + numMeshNodes + 1, meshtastic_NodeInfoLite()); + if (removed) + eraseNodeSatellites(nodeNum); LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed); saveNodeDatabaseToDisk(); } void NodeDB::clearLocalPosition() { - meshtastic_NodeInfoLite *node = getMeshNode(nodeDB->getNodeNum()); - node->position.latitude_i = 0; - node->position.longitude_i = 0; - node->position.altitude = 0; - node->position.time = 0; +#if !MESHTASTIC_EXCLUDE_POSITIONDB + concurrency::LockGuard guard(&satelliteMutex); + nodePositions.erase(getNodeNum()); +#endif setLocalPosition(meshtastic_Position_init_default); localPositionUpdatedSinceBoot = false; } +bool NodeDB::copyNodePosition(NodeNum n, meshtastic_PositionLite &out) const +{ +#if MESHTASTIC_EXCLUDE_POSITIONDB + (void)n; + (void)out; + return false; +#else + concurrency::LockGuard guard(&satelliteMutex); + return copySatelliteEntry(nodePositions, n, out); +#endif +} + +bool NodeDB::copyNodeTelemetry(NodeNum n, meshtastic_DeviceMetrics &out) const +{ +#if MESHTASTIC_EXCLUDE_TELEMETRYDB + (void)n; + (void)out; + return false; +#else + concurrency::LockGuard guard(&satelliteMutex); + return copySatelliteEntry(nodeTelemetry, n, out); +#endif +} + +bool NodeDB::copyNodeEnvironment(NodeNum n, meshtastic_EnvironmentMetrics &out) const +{ +#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB + (void)n; + (void)out; + return false; +#else + concurrency::LockGuard guard(&satelliteMutex); + return copySatelliteEntry(nodeEnvironment, n, out); +#endif +} + +bool NodeDB::copyNodeStatus(NodeNum n, meshtastic_StatusMessage &out) const +{ +#if MESHTASTIC_EXCLUDE_STATUSDB + (void)n; + (void)out; + return false; +#else + concurrency::LockGuard guard(&satelliteMutex); + return copySatelliteEntry(nodeStatus, n, out); +#endif +} + +std::vector NodeDB::snapshotPositionNodeNums(NodeNum exclude) const +{ +#if MESHTASTIC_EXCLUDE_POSITIONDB + (void)exclude; + return {}; +#else + concurrency::LockGuard guard(&satelliteMutex); + return snapshotSatelliteNodeNums(nodePositions, exclude); +#endif +} + +std::vector NodeDB::snapshotTelemetryNodeNums(NodeNum exclude) const +{ +#if MESHTASTIC_EXCLUDE_TELEMETRYDB + (void)exclude; + return {}; +#else + concurrency::LockGuard guard(&satelliteMutex); + return snapshotSatelliteNodeNums(nodeTelemetry, exclude); +#endif +} + +std::vector NodeDB::snapshotEnvironmentNodeNums(NodeNum exclude) const +{ +#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB + (void)exclude; + return {}; +#else + concurrency::LockGuard guard(&satelliteMutex); + return snapshotSatelliteNodeNums(nodeEnvironment, exclude); +#endif +} + +std::vector NodeDB::snapshotStatusNodeNums(NodeNum exclude) const +{ +#if MESHTASTIC_EXCLUDE_STATUSDB + (void)exclude; + return {}; +#else + concurrency::LockGuard guard(&satelliteMutex); + return snapshotSatelliteNodeNums(nodeStatus, exclude); +#endif +} + +void NodeDB::setNodeStatus(NodeNum n, const meshtastic_StatusMessage &status) +{ +#if MESHTASTIC_EXCLUDE_STATUSDB + (void)n; + (void)status; +#else + concurrency::LockGuard guard(&satelliteMutex); + nodeStatus[n] = status; +#endif +} + +void NodeDB::touchNodePositionTime(NodeNum n, uint32_t time) +{ +#if MESHTASTIC_EXCLUDE_POSITIONDB + (void)n; + (void)time; +#else + concurrency::LockGuard guard(&satelliteMutex); + nodePositions[n].time = time; +#endif +} + +void NodeDB::eraseNodeSatellites(NodeNum n) +{ + concurrency::LockGuard guard(&satelliteMutex); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + nodePositions.erase(n); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + nodeTelemetry.erase(n); +#endif +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + nodeEnvironment.erase(n); +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB + nodeStatus.erase(n); +#endif +} + void NodeDB::cleanupMeshDB() { int newPos = 0, removed = 0; for (int i = 0; i < numMeshNodes; i++) { - if (meshNodes->at(i).has_user) { - if (meshNodes->at(i).user.public_key.size > 0) { - if (memfll(meshNodes->at(i).user.public_key.bytes, 0, meshNodes->at(i).user.public_key.size)) { - meshNodes->at(i).user.public_key.size = 0; + meshtastic_NodeInfoLite &n = meshNodes->at(i); + if (nodeInfoLiteHasUser(&n)) { + if (n.public_key.size > 0) { + if (memfll(n.public_key.bytes, 0, n.public_key.size)) { + n.public_key.size = 0; } } if (newPos != i) - meshNodes->at(newPos++) = meshNodes->at(i); + meshNodes->at(newPos++) = n; else newPos++; } else { + // No user info - drop this node and its satellites + const NodeNum gone = n.num; + if (gone) + eraseNodeSatellites(gone); removed++; } } @@ -1141,14 +1405,22 @@ void NodeDB::pickNewNodeNum() nodeNum = (ourMacAddr[2] << 24) | (ourMacAddr[3] << 16) | (ourMacAddr[4] << 8) | ourMacAddr[5]; } + // Identity check via public key (or "empty slot?" when no keys yet); + // macaddr no longer lives on the slim header. + auto isOurOwnEntry = [&](const meshtastic_NodeInfoLite *n) -> bool { + if (!n) + return false; + if (owner.public_key.size == 32 && n->public_key.size == 32) + return memcmp(n->public_key.bytes, owner.public_key.bytes, 32) == 0; + return !nodeInfoLiteHasUser(n); + }; + meshtastic_NodeInfoLite *found; - while (((found = getMeshNode(nodeNum)) && memcmp(found->user.macaddr, ourMacAddr, sizeof(ourMacAddr)) != 0) || + while (((found = getMeshNode(nodeNum)) && !isOurOwnEntry(found)) || (nodeNum == NODENUM_BROADCAST || nodeNum < NUM_RESERVED)) { NodeNum candidate = random(NUM_RESERVED, LONG_MAX); // try a new random choice if (found) - LOG_WARN("NOTE! Our desired nodenum 0x%x is invalid or in use, by MAC ending in 0x%02x%02x vs our 0x%02x%02x, so " - "trying for 0x%x", - nodeNum, found->user.macaddr[4], found->user.macaddr[5], ourMacAddr[4], ourMacAddr[5], candidate); + LOG_WARN("NOTE! Our desired nodenum 0x%x is invalid or in use, picking 0x%x", nodeNum, candidate); nodeNum = candidate; } LOG_DEBUG("Use nodenum 0x%x ", nodeNum); @@ -1169,7 +1441,8 @@ LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t if (f) { LOG_INFO("Load %s", filename); pb_istream_t stream = {&readcb, &f, protoSize}; - if (fields != &meshtastic_NodeDatabase_msg) // contains a vector object + if (fields != &meshtastic_NodeDatabase_msg && + fields != &meshtastic_NodeDatabase_Legacy_msg) // both NodeDatabase descriptors contain std::vector members memset(dest_struct, 0, objSize); if (!pb_decode(&stream, fields, dest_struct)) { LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream)); @@ -1242,9 +1515,57 @@ void NodeDB::loadFromDisk() if (nodeDatabase.version < DEVICESTATE_MIN_VER) { LOG_WARN("NodeDatabase %d is old, discard", nodeDatabase.version); installDefaultNodeDatabase(); + } else if (nodeDatabase.version < DEVICESTATE_CUR_VER) { + if (migrateLegacyNodeDatabase()) + saveNodeDatabaseToDisk(); + else + installDefaultNodeDatabase(); } else { meshNodes = &nodeDatabase.nodes; numMeshNodes = nodeDatabase.nodes.size(); + // Hydrate the satellite maps; the on-disk vectors stay empty in steady + // state and are repopulated only at save time. + concurrency::LockGuard guard(&satelliteMutex); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + nodePositions.clear(); + nodePositions.reserve(nodeDatabase.positions.size()); + for (const auto &entry : nodeDatabase.positions) { + if (entry.has_position) + nodePositions[entry.num] = entry.position; + } + nodeDatabase.positions.clear(); + nodeDatabase.positions.shrink_to_fit(); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + nodeTelemetry.clear(); + nodeTelemetry.reserve(nodeDatabase.telemetry.size()); + for (const auto &entry : nodeDatabase.telemetry) { + if (entry.has_device_metrics) + nodeTelemetry[entry.num] = entry.device_metrics; + } + nodeDatabase.telemetry.clear(); + nodeDatabase.telemetry.shrink_to_fit(); +#endif +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + nodeEnvironment.clear(); + nodeEnvironment.reserve(nodeDatabase.environment.size()); + for (const auto &entry : nodeDatabase.environment) { + if (entry.has_environment_metrics) + nodeEnvironment[entry.num] = entry.environment_metrics; + } + nodeDatabase.environment.clear(); + nodeDatabase.environment.shrink_to_fit(); +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB + nodeStatus.clear(); + nodeStatus.reserve(nodeDatabase.status.size()); + for (const auto &entry : nodeDatabase.status) { + if (entry.has_status) + nodeStatus[entry.num] = entry.status; + } + nodeDatabase.status.clear(); + nodeDatabase.status.shrink_to_fit(); +#endif LOG_INFO("Loaded saved nodedatabase version %d, with nodes count: %d", nodeDatabase.version, nodeDatabase.nodes.size()); } @@ -1272,16 +1593,16 @@ void NodeDB::loadFromDisk() // Attempt recovery of owner fields from our own NodeDB entry if available. meshtastic_NodeInfoLite *us = getMeshNode(getNodeNum()); - if (us && us->has_user) { + if (nodeInfoLiteHasUser(us)) { LOG_WARN("Restoring owner fields (long_name/short_name/is_licensed/is_unmessagable) from NodeDB for our node 0x%08x", us->num); - memcpy(owner.long_name, us->user.long_name, sizeof(owner.long_name)); + memcpy(owner.long_name, us->long_name, sizeof(owner.long_name)); owner.long_name[sizeof(owner.long_name) - 1] = '\0'; - memcpy(owner.short_name, us->user.short_name, sizeof(owner.short_name)); + memcpy(owner.short_name, us->short_name, sizeof(owner.short_name)); owner.short_name[sizeof(owner.short_name) - 1] = '\0'; - owner.is_licensed = us->user.is_licensed; - owner.has_is_unmessagable = us->user.has_is_unmessagable; - owner.is_unmessagable = us->user.is_unmessagable; + owner.is_licensed = nodeInfoLiteIsLicensed(us); + owner.has_is_unmessagable = nodeInfoLiteHasIsUnmessagable(us); + owner.is_unmessagable = nodeInfoLiteIsUnmessagable(us); // Save the recovered owner to device state on disk saveToDisk(SEGMENT_DEVICESTATE); @@ -1528,9 +1849,75 @@ bool NodeDB::saveNodeDatabaseToDisk() FSCom.mkdir("/prefs"); spiLock->unlock(); #endif + // Project the maps into the on-disk vectors just before encoding; cleared + // again on the way out so we don't carry duplicate state. + concurrency::LockGuard guard(&satelliteMutex); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + nodeDatabase.positions.clear(); + nodeDatabase.positions.reserve(nodePositions.size()); + for (const auto &kv : nodePositions) { + meshtastic_NodePositionEntry entry = meshtastic_NodePositionEntry_init_default; + entry.num = kv.first; + entry.has_position = true; + entry.position = kv.second; + nodeDatabase.positions.push_back(entry); + } +#else + nodeDatabase.positions.clear(); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + nodeDatabase.telemetry.clear(); + nodeDatabase.telemetry.reserve(nodeTelemetry.size()); + for (const auto &kv : nodeTelemetry) { + meshtastic_NodeTelemetryEntry entry = meshtastic_NodeTelemetryEntry_init_default; + entry.num = kv.first; + entry.has_device_metrics = true; + entry.device_metrics = kv.second; + nodeDatabase.telemetry.push_back(entry); + } +#else + nodeDatabase.telemetry.clear(); +#endif +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + nodeDatabase.environment.clear(); + nodeDatabase.environment.reserve(nodeEnvironment.size()); + for (const auto &kv : nodeEnvironment) { + meshtastic_NodeEnvironmentEntry entry = meshtastic_NodeEnvironmentEntry_init_default; + entry.num = kv.first; + entry.has_environment_metrics = true; + entry.environment_metrics = kv.second; + nodeDatabase.environment.push_back(entry); + } +#else + nodeDatabase.environment.clear(); +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB + nodeDatabase.status.clear(); + nodeDatabase.status.reserve(nodeStatus.size()); + for (const auto &kv : nodeStatus) { + meshtastic_NodeStatusEntry entry = meshtastic_NodeStatusEntry_init_default; + entry.num = kv.first; + entry.has_status = true; + entry.status = kv.second; + nodeDatabase.status.push_back(entry); + } +#else + nodeDatabase.status.clear(); +#endif + size_t nodeDatabaseSize; pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &nodeDatabase); - return saveProto(nodeDatabaseFileName, nodeDatabaseSize, &meshtastic_NodeDatabase_msg, &nodeDatabase, false); + bool ok = saveProto(nodeDatabaseFileName, nodeDatabaseSize, &meshtastic_NodeDatabase_msg, &nodeDatabase, false); + + nodeDatabase.positions.clear(); + nodeDatabase.positions.shrink_to_fit(); + nodeDatabase.telemetry.clear(); + nodeDatabase.telemetry.shrink_to_fit(); + nodeDatabase.environment.clear(); + nodeDatabase.environment.shrink_to_fit(); + nodeDatabase.status.clear(); + nodeDatabase.status.shrink_to_fit(); + return ok; } bool NodeDB::saveToDiskNoRetry(int saveWhat) @@ -1699,7 +2086,7 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly) // FIXME this implementation is kinda expensive for (int i = 0; i < numMeshNodes; i++) { - if (localOnly && meshNodes->at(i).via_mqtt) + if (localOnly && nodeInfoLiteViaMqtt(&meshNodes->at(i))) continue; if (sinceLastSeen(&meshNodes->at(i)) < NUM_ONLINE_SECS) numseen++; @@ -1736,62 +2123,94 @@ void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSou return; } +#if MESHTASTIC_EXCLUDE_POSITIONDB + // Build flag opted out: header still tracks last_heard via updateFrom; we + // simply don't cache the position payload anywhere on this device. if (src == RX_SRC_LOCAL) { - // Local packet, fully authoritative - LOG_INFO("updatePosition LOCAL pos@%x time=%u lat=%d lon=%d alt=%d", p.timestamp, p.time, p.latitude_i, p.longitude_i, + LOG_INFO("updatePosition LOCAL (PositionDB excluded) time=%u lat=%d lon=%d alt=%d", p.time, p.latitude_i, p.longitude_i, p.altitude); - setLocalPosition(p); - info->position = TypeConversions::ConvertToPositionLite(p); - } else if ((p.time > 0) && !p.latitude_i && !p.longitude_i && !p.timestamp && !p.location_source) { - // FIXME SPECIAL TIME SETTING PACKET FROM EUD TO RADIO - // (stop-gap fix for issue #900) - LOG_DEBUG("updatePosition SPECIAL time setting time=%u", p.time); - info->position.time = p.time; - } else { - // Be careful to only update fields that have been set by the REMOTE sender - // A lot of position reports don't have time populated. In that case, be careful to not blow away the time we - // recorded based on the packet rxTime - // - // FIXME perhaps handle RX_SRC_USER separately? - LOG_INFO("updatePosition REMOTE node=0x%x time=%u lat=%d lon=%d", nodeId, p.time, p.latitude_i, p.longitude_i); - - // First, back up fields that we want to protect from overwrite - uint32_t tmp_time = info->position.time; - - // Next, update atomically - info->position = TypeConversions::ConvertToPositionLite(p); - - // Last, restore any fields that may have been overwritten - if (!info->position.time) - info->position.time = tmp_time; } - info->has_position = true; + (void)nodeId; + updateGUIforNode = info; + notifyObservers(true); +#else + { + concurrency::LockGuard guard(&satelliteMutex); + meshtastic_PositionLite &slot = nodePositions[nodeId]; // creates default-zero entry if missing + + if (src == RX_SRC_LOCAL) { + // Local packet, fully authoritative + LOG_INFO("updatePosition LOCAL pos@%x time=%u lat=%d lon=%d alt=%d", p.timestamp, p.time, p.latitude_i, p.longitude_i, + p.altitude); + + setLocalPosition(p); + slot = TypeConversions::ConvertToPositionLite(p); + } else if ((p.time > 0) && !p.latitude_i && !p.longitude_i && !p.timestamp && !p.location_source) { + // FIXME SPECIAL TIME SETTING PACKET FROM EUD TO RADIO + // (stop-gap fix for issue #900) + LOG_DEBUG("updatePosition SPECIAL time setting time=%u", p.time); + slot.time = p.time; + } else { + // Be careful to only update fields that have been set by the REMOTE sender + // A lot of position reports don't have time populated. In that case, be careful to not blow away the time we + // recorded based on the packet rxTime + // + // FIXME perhaps handle RX_SRC_USER separately? + LOG_INFO("updatePosition REMOTE node=0x%x time=%u lat=%d lon=%d", nodeId, p.time, p.latitude_i, p.longitude_i); + + // First, back up fields that we want to protect from overwrite + uint32_t tmp_time = slot.time; + + // Next, update atomically + slot = TypeConversions::ConvertToPositionLite(p); + + // Last, restore any fields that may have been overwritten + if (!slot.time) + slot.time = tmp_time; + } + } updateGUIforNode = info; notifyObservers(true); // Force an update whether or not our node counts have changed +#endif } -/** Update telemetry info for this node based on received metrics - * We only care about device telemetry here +/** Update telemetry info for this node based on received metrics. Stores + * device_metrics and environment_metrics into their respective satellite + * maps; other variants (air_quality, power, local_stats, health) are + * intentionally not retained per-node. */ void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxSource src) { meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId); - // Environment metrics should never go to NodeDb but we'll safegaurd anyway - if (!info || t.which_variant != meshtastic_Telemetry_device_metrics_tag) { + if (!info) return; - } - if (src == RX_SRC_LOCAL) { - // Local packet, fully authoritative - LOG_DEBUG("updateTelemetry LOCAL"); + if (t.which_variant == meshtastic_Telemetry_device_metrics_tag) { + if (src == RX_SRC_LOCAL) { + LOG_DEBUG("updateTelemetry LOCAL device"); + } else { + LOG_DEBUG("updateTelemetry REMOTE device node=0x%x", nodeId); + } +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + concurrency::LockGuard guard(&satelliteMutex); + nodeTelemetry[nodeId] = t.variant.device_metrics; +#endif + } else if (t.which_variant == meshtastic_Telemetry_environment_metrics_tag) { + if (src == RX_SRC_LOCAL) { + LOG_DEBUG("updateTelemetry LOCAL env"); + } else { + LOG_DEBUG("updateTelemetry REMOTE env node=0x%x", nodeId); + } +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + concurrency::LockGuard guard(&satelliteMutex); + nodeEnvironment[nodeId] = t.variant.environment_metrics; +#endif } else { - LOG_DEBUG("updateTelemetry REMOTE node=0x%x ", nodeId); + return; // air_quality / power / local_stats / health: not stored per-node } - info->device_metrics = t.variant.device_metrics; - info->has_device_metrics = true; updateGUIforNode = info; - notifyObservers(true); // Force an update whether or not our node counts have changed + notifyObservers(true); } /** @@ -1806,24 +2225,22 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) // If the local node has this node marked as manually verified // and the client does not, do not allow the client to update the // saved public key. - if ((info->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK) && !contact.manually_verified) { - if (contact.user.public_key.size != info->user.public_key.size || - memcmp(contact.user.public_key.bytes, info->user.public_key.bytes, info->user.public_key.size) != 0) { + if (nodeInfoLiteIsKeyManuallyVerified(info) && !contact.manually_verified) { + if (contact.user.public_key.size != info->public_key.size || + memcmp(contact.user.public_key.bytes, info->public_key.bytes, info->public_key.size) != 0) { return; } } info->num = contact.node_num; - info->has_user = true; - info->user = TypeConversions::ConvertToUserLite(contact.user); + TypeConversions::CopyUserToNodeInfoLite(info, contact.user); if (contact.should_ignore) { // If should_ignore is set, // we need to clear the public key and other cruft, in addition to setting the node as ignored - info->is_ignored = true; - info->is_favorite = false; - info->has_device_metrics = false; - info->has_position = false; - info->user.public_key.size = 0; - memset(info->user.public_key.bytes, 0, sizeof(info->user.public_key.bytes)); + nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_IGNORED_MASK, true); + nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false); + eraseNodeSatellites(contact.node_num); + info->public_key.size = 0; + memset(info->public_key.bytes, 0, sizeof(info->public_key.bytes)); } else { /* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with * public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM! @@ -1842,12 +2259,12 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) } else { // Normal case: set is_favorite to prevent expiration. // last_heard will remain as-is (or remain 0 if this entry wasn't in the nodeDB). - info->is_favorite = true; + nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); } // As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified if (contact.manually_verified) { - info->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); } // Mark the node's key as manually verified to indicate trustworthiness. updateGUIforNode = info; @@ -1887,9 +2304,9 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde return false; } } - if (info->user.public_key.size == 32) { // if we have a key for this user already, don't overwrite with a new one + if (info->public_key.size == 32) { // if we have a key for this user already, don't overwrite with a new one // if the key doesn't match, don't update nodeDB at all. - if (p.public_key.size != 32 || (memcmp(p.public_key.bytes, info->user.public_key.bytes, 32) != 0)) { + if (p.public_key.size != 32 || (memcmp(p.public_key.bytes, info->public_key.bytes, 32) != 0)) { LOG_WARN("Public Key mismatch, dropping NodeInfo"); return false; } @@ -1902,19 +2319,22 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde // Always ensure user.id is derived from nodeId, regardless of what was received snprintf(p.id, sizeof(p.id), "!%08x", nodeId); - // Both of info->user and p start as filled with zero so I think this is okay - auto lite = TypeConversions::ConvertToUserLite(p); - bool changed = memcmp(&info->user, &lite, sizeof(info->user)) || (info->channel != channelIndex); + meshtastic_NodeInfoLite before = *info; + TypeConversions::CopyUserToNodeInfoLite(info, p); + bool changed = + (memcmp(before.long_name, info->long_name, sizeof(info->long_name)) != 0) || + (memcmp(before.short_name, info->short_name, sizeof(info->short_name)) != 0) || (before.hw_model != info->hw_model) || + (before.role != info->role) || (before.public_key.size != info->public_key.size) || + (info->public_key.size > 0 && memcmp(before.public_key.bytes, info->public_key.bytes, info->public_key.size) != 0) || + (before.bitfield != info->bitfield) || (info->channel != channelIndex); - info->user = lite; - if (info->user.public_key.size == 32) { - printBytes("Saved Pubkey: ", info->user.public_key.bytes, 32); + if (info->public_key.size == 32) { + printBytes("Saved Pubkey: ", info->public_key.bytes, 32); } if (nodeId != getNodeNum()) info->channel = channelIndex; // Set channel we need to use to reach this node (but don't set our own channel) - LOG_DEBUG("Update changed=%d user %s/%s, id=0x%08x, channel=%d", changed, info->user.long_name, info->user.short_name, nodeId, + LOG_DEBUG("Update changed=%d user %s/%s, id=0x%08x, channel=%d", changed, info->long_name, info->short_name, nodeId, info->channel); - info->has_user = true; if (changed) { updateGUIforNode = info; @@ -1956,7 +2376,8 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) if (mp.rx_snr) info->snr = mp.rx_snr; // keep the most recent SNR we received for this node. - info->via_mqtt = mp.via_mqtt; // Store if we received this packet via MQTT + nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_VIA_MQTT_MASK, + mp.via_mqtt); // Store if we received this packet via MQTT // If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway const int8_t hopsAway = getHopsAway(mp); @@ -1971,8 +2392,8 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) void NodeDB::set_favorite(bool is_favorite, uint32_t nodeId) { meshtastic_NodeInfoLite *lite = getMeshNode(nodeId); - if (lite && lite->is_favorite != is_favorite) { - lite->is_favorite = is_favorite; + if (lite && nodeInfoLiteIsFavorite(lite) != is_favorite) { + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_IS_FAVORITE_MASK, is_favorite); sortMeshDB(); saveNodeDatabaseToDisk(); } @@ -1986,10 +2407,10 @@ bool NodeDB::isFavorite(uint32_t nodeId) if (nodeId == NODENUM_BROADCAST) return false; - meshtastic_NodeInfoLite *lite = getMeshNode(nodeId); + const meshtastic_NodeInfoLite *lite = getMeshNode(nodeId); if (lite) { - return lite->is_favorite; + return nodeInfoLiteIsFavorite(lite); } return false; } @@ -2014,14 +2435,14 @@ bool NodeDB::isFromOrToFavoritedNode(const meshtastic_MeshPacket &p) lite = &meshNodes->at(i); if (lite->num == p.from) { - if (lite->is_favorite) + if (nodeInfoLiteIsFavorite(lite)) return true; seenFrom = true; } if (lite->num == p.to) { - if (lite->is_favorite) + if (nodeInfoLiteIsFavorite(lite)) return true; seenTo = true; @@ -2057,10 +2478,10 @@ void NodeDB::sortMeshDB() // TODO: Look for at(i-1) also matching own node num, and throw the DB in the trash std::swap(meshNodes->at(i), meshNodes->at(i - 1)); changed = true; - } else if (meshNodes->at(i).is_favorite && !meshNodes->at(i - 1).is_favorite) { + } else if (nodeInfoLiteIsFavorite(&meshNodes->at(i)) && !nodeInfoLiteIsFavorite(&meshNodes->at(i - 1))) { std::swap(meshNodes->at(i), meshNodes->at(i - 1)); changed = true; - } else if (!meshNodes->at(i).is_favorite && meshNodes->at(i - 1).is_favorite) { + } else if (!nodeInfoLiteIsFavorite(&meshNodes->at(i)) && nodeInfoLiteIsFavorite(&meshNodes->at(i - 1))) { // noop } else if (meshNodes->at(i).last_heard > meshNodes->at(i - 1).last_heard) { std::swap(meshNodes->at(i), meshNodes->at(i - 1)); @@ -2120,17 +2541,18 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) int oldestIndex = -1; int oldestBoringIndex = -1; for (int i = 1; i < numMeshNodes; i++) { + const meshtastic_NodeInfoLite *cand = &meshNodes->at(i); + const bool isFavoriteNode = nodeInfoLiteIsFavorite(cand); + const bool isIgnored = nodeInfoLiteIsIgnored(cand); + const bool isVerified = nodeInfoLiteIsKeyManuallyVerified(cand); // Simply the oldest non-favorite, non-ignored, non-verified node - if (!meshNodes->at(i).is_favorite && !meshNodes->at(i).is_ignored && - !(meshNodes->at(i).bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK) && - meshNodes->at(i).last_heard < oldest) { - oldest = meshNodes->at(i).last_heard; + if (!isFavoriteNode && !isIgnored && !isVerified && cand->last_heard < oldest) { + oldest = cand->last_heard; oldestIndex = i; } // The oldest "boring" node - if (!meshNodes->at(i).is_favorite && !meshNodes->at(i).is_ignored && meshNodes->at(i).user.public_key.size == 0 && - meshNodes->at(i).last_heard < oldestBoring) { - oldestBoring = meshNodes->at(i).last_heard; + if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 && cand->last_heard < oldestBoring) { + oldestBoring = cand->last_heard; oldestBoringIndex = i; } } @@ -2140,6 +2562,7 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) } if (oldestIndex != -1) { + eraseNodeSatellites(meshNodes->at(oldestIndex).num); // Shove the remaining nodes down the chain for (int i = oldestIndex; i < numMeshNodes - 1; i++) { meshNodes->at(i) = meshNodes->at(i + 1); @@ -2163,18 +2586,23 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) /// valid lat/lon bool NodeDB::hasValidPosition(const meshtastic_NodeInfoLite *n) { - return n->has_position && (n->position.latitude_i != 0 || n->position.longitude_i != 0); + if (!n) + return false; + if (n->num == getNodeNum()) { + return localPosition.latitude_i != 0 || localPosition.longitude_i != 0; + } + meshtastic_PositionLite pos; + return copyNodePosition(n->num, pos) && (pos.latitude_i != 0 || pos.longitude_i != 0); } /// If we have a node / user and they report is_licensed = true /// we consider them licensed UserLicenseStatus NodeDB::getLicenseStatus(uint32_t nodeNum) { - meshtastic_NodeInfoLite *info = getMeshNode(nodeNum); - if (!info || !info->has_user) { + const meshtastic_NodeInfoLite *info = getMeshNode(nodeNum); + if (!nodeInfoLiteHasUser(info)) return UserLicenseStatus::NotKnown; - } - return info->user.is_licensed ? UserLicenseStatus::Licensed : UserLicenseStatus::NotLicensed; + return nodeInfoLiteIsLicensed(info) ? UserLicenseStatus::Licensed : UserLicenseStatus::NotLicensed; } #if !defined(MESHTASTIC_EXCLUDE_PKI) diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index f6be963c184..090f3bb9ae9 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -6,10 +6,12 @@ #include #include #include +#include #include #include "MeshTypes.h" #include "NodeStatus.h" +#include "concurrency/Lock.h" #include "configuration.h" #include "mesh-pb-constants.h" #include "mesh/generated/meshtastic/mesh.pb.h" // For CriticalErrorCode @@ -82,7 +84,9 @@ DeviceState versions used to be defined in the .proto file but really only this #define SEGMENT_CHANNELS 8 #define SEGMENT_NODEDATABASE 16 -#define DEVICESTATE_CUR_VER 24 +#define DEVICESTATE_CUR_VER 25 +// Lowest on-disk version we still know how to load. v24 saves are migrated +// at boot via the parallel deviceonly_legacy descriptor and re-saved as v25. #define DEVICESTATE_MIN_VER 24 extern meshtastic_DeviceState devicestate; @@ -166,6 +170,21 @@ class NodeDB Observable newStatus; pb_size_t numMeshNodes; + // Satellite per-NodeNum maps for data we used to inline into NodeInfoLite, + // gated by MESHTASTIC_EXCLUDE_*DB so STM32WL can omit them. +#if !MESHTASTIC_EXCLUDE_POSITIONDB + std::unordered_map nodePositions; +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + std::unordered_map nodeTelemetry; +#endif +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + std::unordered_map nodeEnvironment; +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB + std::unordered_map nodeStatus; +#endif + bool keyIsLowEntropy = false; bool hasWarned = false; @@ -277,6 +296,43 @@ class NodeDB virtual meshtastic_NodeInfoLite *getMeshNode(NodeNum n); size_t getNumMeshNodes() { return numMeshNodes; } + // Thread-safe satellite-map accessors. Return false if absent or the + // corresponding DB is compiled out. + bool copyNodePosition(NodeNum n, meshtastic_PositionLite &out) const; + bool copyNodeTelemetry(NodeNum n, meshtastic_DeviceMetrics &out) const; + bool copyNodeEnvironment(NodeNum n, meshtastic_EnvironmentMetrics &out) const; + bool copyNodeStatus(NodeNum n, meshtastic_StatusMessage &out) const; + std::vector snapshotPositionNodeNums(NodeNum exclude) const; + std::vector snapshotTelemetryNodeNums(NodeNum exclude) const; + std::vector snapshotEnvironmentNodeNums(NodeNum exclude) const; + std::vector snapshotStatusNodeNums(NodeNum exclude) const; + + void setNodeStatus(NodeNum n, const meshtastic_StatusMessage &status); + void touchNodePositionTime(NodeNum n, uint32_t time); + + bool hasNodePosition(NodeNum n) const + { + meshtastic_PositionLite scratch; + return copyNodePosition(n, scratch); + } + bool hasNodeTelemetry(NodeNum n) const + { + meshtastic_DeviceMetrics scratch; + return copyNodeTelemetry(n, scratch); + } + bool hasNodeEnvironment(NodeNum n) const + { + meshtastic_EnvironmentMetrics scratch; + return copyNodeEnvironment(n, scratch); + } + bool hasNodeStatus(NodeNum n) const + { + meshtastic_StatusMessage scratch; + return copyNodeStatus(n, scratch); + } + + void eraseNodeSatellites(NodeNum n); + UserLicenseStatus getLicenseStatus(uint32_t nodeNum); size_t getMaxNodesAllocatedSize() @@ -285,7 +341,11 @@ class NodeDB emptyNodeDatabase.version = DEVICESTATE_CUR_VER; size_t nodeDatabaseSize; pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &emptyNodeDatabase); - return nodeDatabaseSize + (MAX_NUM_NODES * meshtastic_NodeInfoLite_size); + // 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); } // returns true if the maximum number of nodes is reached or we are running low on memory @@ -329,6 +389,7 @@ class NodeDB } private: + mutable concurrency::Lock satelliteMutex; bool duplicateWarned = false; bool localPositionUpdatedSinceBoot = false; uint32_t lastNodeDbSave = 0; // when we last saved our db to flash @@ -363,6 +424,11 @@ class NodeDB bool saveDeviceStateToDisk(); bool saveNodeDatabaseToDisk(); void sortMeshDB(); + + // Defined in NodeDBLegacyMigration.cpp. Decodes /prefs/nodes.proto via + // the legacy descriptor and copies entries into the v25 layout. Caller + // is responsible for save / install-default on the result. + bool migrateLegacyNodeDatabase(); }; extern NodeDB *nodeDB; @@ -397,10 +463,74 @@ extern meshtastic_CriticalErrorCode error_code; * A numeric error address (nonzero if available) */ extern uint32_t error_address; +// Bit assignments for meshtastic_NodeInfoLite.bitfield. #define NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_SHIFT 0 -#define NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK (1 << NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_SHIFT) +#define NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK (1u << NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_SHIFT) #define NODEINFO_BITFIELD_IS_MUTED_SHIFT 1 -#define NODEINFO_BITFIELD_IS_MUTED_MASK (1 << NODEINFO_BITFIELD_IS_MUTED_SHIFT) +#define NODEINFO_BITFIELD_IS_MUTED_MASK (1u << NODEINFO_BITFIELD_IS_MUTED_SHIFT) +#define NODEINFO_BITFIELD_VIA_MQTT_SHIFT 2 +#define NODEINFO_BITFIELD_VIA_MQTT_MASK (1u << NODEINFO_BITFIELD_VIA_MQTT_SHIFT) +#define NODEINFO_BITFIELD_IS_FAVORITE_SHIFT 3 +#define NODEINFO_BITFIELD_IS_FAVORITE_MASK (1u << NODEINFO_BITFIELD_IS_FAVORITE_SHIFT) +#define NODEINFO_BITFIELD_IS_IGNORED_SHIFT 4 +#define NODEINFO_BITFIELD_IS_IGNORED_MASK (1u << NODEINFO_BITFIELD_IS_IGNORED_SHIFT) +#define NODEINFO_BITFIELD_HAS_USER_SHIFT 5 +#define NODEINFO_BITFIELD_HAS_USER_MASK (1u << NODEINFO_BITFIELD_HAS_USER_SHIFT) +#define NODEINFO_BITFIELD_IS_LICENSED_SHIFT 6 +#define NODEINFO_BITFIELD_IS_LICENSED_MASK (1u << NODEINFO_BITFIELD_IS_LICENSED_SHIFT) +#define NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT 7 +#define NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT) +#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT 8 +#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT) +// Bits 9..31 reserved for future single-bit flags. + +// Convenience accessors so call sites read like the old struct fields. +inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK); +} +inline bool nodeInfoLiteViaMqtt(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_VIA_MQTT_MASK); +} +inline bool nodeInfoLiteIsFavorite(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_IS_FAVORITE_MASK); +} +inline bool nodeInfoLiteIsIgnored(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_IS_IGNORED_MASK); +} +inline bool nodeInfoLiteIsLicensed(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_IS_LICENSED_MASK); +} +inline bool nodeInfoLiteHasIsUnmessagable(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK); +} +inline bool nodeInfoLiteIsUnmessagable(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK); +} +inline bool nodeInfoLiteIsMuted(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK); +} +inline bool nodeInfoLiteIsKeyManuallyVerified(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK); +} + +inline void nodeInfoLiteSetBit(meshtastic_NodeInfoLite *n, uint32_t mask, bool value) +{ + if (!n) + return; + if (value) + n->bitfield |= mask; + else + n->bitfield &= ~mask; +} #define Module_Config_size \ (ModuleConfig_CannedMessageConfig_size + ModuleConfig_ExternalNotificationConfig_size + ModuleConfig_MQTTConfig_size + \ diff --git a/src/mesh/NodeDBLegacyMigration.cpp b/src/mesh/NodeDBLegacyMigration.cpp new file mode 100644 index 00000000000..18d6daa4de6 --- /dev/null +++ b/src/mesh/NodeDBLegacyMigration.cpp @@ -0,0 +1,129 @@ +// Migration from the legacy (pre-v25) NodeDatabase shape to the slim header + +// satellite maps layout. Lives in its own translation unit so the bulk +// fixed-shape decode + field-by-field copy doesn't clutter NodeDB.cpp. +// +// Caller (NodeDB::loadFromDisk) decides what to do with the result: +// - true -> persist via saveNodeDatabaseToDisk() +// - false -> reset via installDefaultNodeDatabase() +// +// This file (and the deviceonly_legacy proto) can be removed once +// DEVICESTATE_MIN_VER advances past 24. + +#include "NodeDB.h" +#include "concurrency/LockGuard.h" +#include "configuration.h" +#include "mesh-pb-constants.h" +#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h" + +#include +#include +#include +#include + +bool meshtastic_NodeDatabase_Legacy_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field) +{ + const auto *iter = reinterpret_cast(field); + if (ostream) { + const auto *vec = static_cast *>(iter->pData); + for (auto item : *vec) { + if (!pb_encode_tag_for_field(ostream, iter)) + return false; + if (!pb_encode_submessage(ostream, meshtastic_NodeInfoLite_Legacy_fields, &item)) + return false; + } + } + if (istream) { + meshtastic_NodeInfoLite_Legacy node; + auto *vec = static_cast *>(iter->pData); + if (istream->bytes_left && pb_decode(istream, meshtastic_NodeInfoLite_Legacy_fields, &node)) + vec->push_back(node); + } + return true; +} + +bool NodeDB::migrateLegacyNodeDatabase() +{ + LOG_WARN("NodeDatabase v%u: migrating to v%u", nodeDatabase.version, DEVICESTATE_CUR_VER); + + // _init_zero brace-inits the embedded std::vector via its explicit + // (size_type, allocator) ctor, so default-construct instead. + meshtastic_NodeDatabase_Legacy legacyDb{}; + legacyDb.version = 0; + meshtastic_NodeDatabase_Legacy legacyEmpty{}; + legacyEmpty.version = DEVICESTATE_CUR_VER; + size_t legacyEmptyEncoded = 0; + pb_get_encoded_size(&legacyEmptyEncoded, meshtastic_NodeDatabase_Legacy_fields, &legacyEmpty); + const size_t legacyBufSize = legacyEmptyEncoded + (MAX_NUM_NODES * meshtastic_NodeInfoLite_Legacy_size); + auto legacyState = loadProto(nodeDatabaseFileName, legacyBufSize, sizeof(meshtastic_NodeDatabase_Legacy), + &meshtastic_NodeDatabase_Legacy_msg, &legacyDb); + if (legacyState != LoadFileResult::LOAD_SUCCESS) { + LOG_ERROR("Failed to load NodeDatabase via legacy descriptor; installing default"); + return false; + } + + nodeDatabase.nodes.clear(); + const size_t maxToMigrate = std::min(legacyDb.nodes.size(), MAX_NUM_NODES); + nodeDatabase.nodes.reserve(maxToMigrate); + size_t posCount = 0, telCount = 0; + { + concurrency::LockGuard guard(&satelliteMutex); + for (size_t i = 0; i < maxToMigrate; ++i) { + const auto &legacy = legacyDb.nodes[i]; + meshtastic_NodeInfoLite slim = meshtastic_NodeInfoLite_init_default; + slim.num = legacy.num; + slim.snr = legacy.snr; + slim.last_heard = legacy.last_heard; + slim.channel = legacy.channel; + slim.has_hops_away = legacy.has_hops_away; + slim.hops_away = legacy.hops_away; + slim.next_hop = legacy.next_hop; + slim.bitfield = legacy.bitfield; + if (legacy.via_mqtt) + slim.bitfield |= NODEINFO_BITFIELD_VIA_MQTT_MASK; + if (legacy.is_favorite) + slim.bitfield |= NODEINFO_BITFIELD_IS_FAVORITE_MASK; + if (legacy.is_ignored) + slim.bitfield |= NODEINFO_BITFIELD_IS_IGNORED_MASK; + if (legacy.has_user) { + 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'; + strncpy(slim.short_name, legacy.user.short_name, sizeof(slim.short_name)); + slim.short_name[sizeof(slim.short_name) - 1] = '\0'; + slim.hw_model = legacy.user.hw_model; + slim.role = legacy.user.role; + if (legacy.user.is_licensed) + slim.bitfield |= NODEINFO_BITFIELD_IS_LICENSED_MASK; + slim.public_key.size = legacy.user.public_key.size; + memcpy(slim.public_key.bytes, legacy.user.public_key.bytes, sizeof(slim.public_key.bytes)); + if (legacy.user.has_is_unmessagable) { + slim.bitfield |= NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK; + if (legacy.user.is_unmessagable) + slim.bitfield |= NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK; + } + // macaddr deprecated since 1.2.11 - dropped from slim header. + } + nodeDatabase.nodes.push_back(slim); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + if (legacy.has_position) + nodePositions[legacy.num] = legacy.position; +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + if (legacy.has_device_metrics) + nodeTelemetry[legacy.num] = legacy.device_metrics; +#endif + } +#if !MESHTASTIC_EXCLUDE_POSITIONDB + posCount = nodePositions.size(); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + telCount = nodeTelemetry.size(); +#endif + } + nodeDatabase.version = DEVICESTATE_CUR_VER; + meshNodes = &nodeDatabase.nodes; + numMeshNodes = nodeDatabase.nodes.size(); + LOG_INFO("Migrated %u nodes from legacy -> v%u (positions: %u, telemetry: %u)", (unsigned)numMeshNodes, DEVICESTATE_CUR_VER, + (unsigned)posCount, (unsigned)telCount); + return true; +} diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 5c3ab486a51..ffe413056b3 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -62,7 +62,7 @@ void PhoneAPI::handleStartConfig() onConfigStart(); // even if we were already connected - restart our state machine - if (config_nonce == SPECIAL_NONCE_ONLY_NODES) { + if (config_nonce == SPECIAL_NONCE_ONLY_NODES || config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES) { // If client only wants node info, jump directly to sending nodes state = STATE_SEND_OWN_NODEINFO; LOG_INFO("Client only wants node info, skipping other config"); @@ -81,6 +81,15 @@ void PhoneAPI::handleStartConfig() concurrency::LockGuard guard(&nodeInfoMutex); nodeInfoForPhone = {}; nodeInfoQueue.clear(); + replayQueue.clear(); + replayPositionOrder.clear(); + replayTelemetryOrder.clear(); + replayEnvironmentOrder.clear(); + replayStatusOrder.clear(); + replayPositionIndex = 0; + replayTelemetryIndex = 0; + replayEnvironmentIndex = 0; + replayStatusIndex = 0; } resetReadIndex(); } @@ -120,6 +129,15 @@ void PhoneAPI::close() concurrency::LockGuard guard(&nodeInfoMutex); nodeInfoForPhone = {}; nodeInfoQueue.clear(); + replayQueue.clear(); + replayPositionOrder.clear(); + replayTelemetryOrder.clear(); + replayEnvironmentOrder.clear(); + replayStatusOrder.clear(); + replayPositionIndex = 0; + replayTelemetryIndex = 0; + replayEnvironmentIndex = 0; + replayStatusIndex = 0; } packetForPhone = NULL; filesManifest.clear(); @@ -302,7 +320,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) nodeInfoForPhone.num = 0; } } - if (config_nonce == SPECIAL_NONCE_ONLY_NODES) { + if (config_nonce == SPECIAL_NONCE_ONLY_NODES || config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES) { // If client only wants node info, jump directly to sending nodes state = STATE_SEND_OTHER_NODEINFOS; onNowHasData(0); @@ -529,8 +547,124 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) LOG_DEBUG("Done sending %d of %d nodeinfos millis=%u", readIndex, nodeDB->getNumMeshNodes(), millis()); concurrency::LockGuard guard(&nodeInfoMutex); nodeInfoQueue.clear(); + // Replay states no-op for legacy clients / excluded DBs. + state = STATE_REPLAY_POSITIONS; + return getFromRadio(buf); + } + break; + } + + case STATE_REPLAY_POSITIONS: { + if (replayPositionOrder.empty() && replayPositionIndex == 0) + beginReplayPositions(); + prefetchReplayPositions(); + + meshtastic_MeshPacket pkt = {}; + bool havePkt = false; + { + concurrency::LockGuard guard(&nodeInfoMutex); + if (!replayQueue.empty()) { + pkt = replayQueue.front(); + replayQueue.pop_front(); + havePkt = true; + } + } + + if (havePkt) { + fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag; + fromRadioScratch.packet = pkt; + } else { + LOG_DEBUG("Done replaying positions count=%u millis=%u", (unsigned)replayPositionIndex, millis()); + state = STATE_REPLAY_TELEMETRY; + return getFromRadio(buf); + } + break; + } + + case STATE_REPLAY_TELEMETRY: { + if (replayTelemetryOrder.empty() && replayTelemetryIndex == 0) + beginReplayTelemetry(); + prefetchReplayTelemetry(); + + meshtastic_MeshPacket pkt = {}; + bool havePkt = false; + { + concurrency::LockGuard guard(&nodeInfoMutex); + if (!replayQueue.empty()) { + pkt = replayQueue.front(); + replayQueue.pop_front(); + havePkt = true; + } + } + + if (havePkt) { + fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag; + fromRadioScratch.packet = pkt; + } else { + LOG_DEBUG("Done replaying telemetry count=%u millis=%u", (unsigned)replayTelemetryIndex, millis()); + state = STATE_REPLAY_ENVIRONMENT; + return getFromRadio(buf); + } + break; + } + + case STATE_REPLAY_ENVIRONMENT: { + if (replayEnvironmentOrder.empty() && replayEnvironmentIndex == 0) + beginReplayEnvironment(); + prefetchReplayEnvironment(); + + meshtastic_MeshPacket pkt = {}; + bool havePkt = false; + { + concurrency::LockGuard guard(&nodeInfoMutex); + if (!replayQueue.empty()) { + pkt = replayQueue.front(); + replayQueue.pop_front(); + havePkt = true; + } + } + + if (havePkt) { + fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag; + fromRadioScratch.packet = pkt; + } else { + LOG_DEBUG("Done replaying environment count=%u millis=%u", (unsigned)replayEnvironmentIndex, millis()); + state = STATE_REPLAY_STATUS; + return getFromRadio(buf); + } + break; + } + + case STATE_REPLAY_STATUS: { + if (replayStatusOrder.empty() && replayStatusIndex == 0) + beginReplayStatus(); + prefetchReplayStatus(); + + meshtastic_MeshPacket pkt = {}; + bool havePkt = false; + { + concurrency::LockGuard guard(&nodeInfoMutex); + if (!replayQueue.empty()) { + pkt = replayQueue.front(); + replayQueue.pop_front(); + havePkt = true; + } + } + + if (havePkt) { + fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag; + fromRadioScratch.packet = pkt; + } else { + LOG_DEBUG("Done replaying status count=%u millis=%u", (unsigned)replayStatusIndex, millis()); + replayPositionOrder.clear(); + replayPositionOrder.shrink_to_fit(); + replayTelemetryOrder.clear(); + replayTelemetryOrder.shrink_to_fit(); + replayEnvironmentOrder.clear(); + replayEnvironmentOrder.shrink_to_fit(); + replayStatusOrder.clear(); + replayStatusOrder.shrink_to_fit(); state = STATE_SEND_FILEMANIFEST; - // Go ahead and send that ID right now return getFromRadio(buf); } break; @@ -538,9 +672,9 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) case STATE_SEND_FILEMANIFEST: { LOG_DEBUG("FromRadio=STATE_SEND_FILEMANIFEST"); - // last element - if (config_state == filesManifest.size() || - config_nonce == SPECIAL_NONCE_ONLY_NODES) { // also handles an empty filesManifest + // ONLY_NODES variants skip the manifest. + if (config_state == filesManifest.size() || config_nonce == SPECIAL_NONCE_ONLY_NODES || + config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES) { config_state = 0; filesManifest.clear(); // Skip to complete packet @@ -653,6 +787,7 @@ void PhoneAPI::prefetchNodeInfos() { bool added = false; bool wasEmpty = false; + const bool gradient = clientWantsGradientSync(); // Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment. { concurrency::LockGuard guard(&nodeInfoMutex); @@ -662,7 +797,8 @@ void PhoneAPI::prefetchNodeInfos() if (!nextNode) break; - auto info = TypeConversions::ConvertToNodeInfo(nextNode); + auto info = + gradient ? TypeConversions::ConvertToNodeInfoThin(nextNode) : TypeConversions::ConvertToNodeInfo(nextNode); bool isUs = info.num == nodeDB->getNodeNum(); info.hops_away = isUs ? 0 : info.hops_away; info.last_heard = isUs ? getValidTime(RTCQualityFromNet) : info.last_heard; @@ -682,6 +818,257 @@ void PhoneAPI::prefetchNodeInfos() onNowHasData(0); } +meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const meshtastic_PositionLite &pos) +{ + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; + pkt.from = num; + pkt.to = nodeDB->getNodeNum(); + pkt.id = generatePacketId(); + pkt.rx_time = pos.time; + pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + pkt.decoded.portnum = meshtastic_PortNum_POSITION_APP; + meshtastic_Position fullPos = TypeConversions::ConvertToPosition(pos); + size_t len = + pb_encode_to_bytes(pkt.decoded.payload.bytes, sizeof(pkt.decoded.payload.bytes), &meshtastic_Position_msg, &fullPos); + pkt.decoded.payload.size = (pb_size_t)len; + return pkt; +} + +meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const meshtastic_DeviceMetrics &metrics) +{ + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; + pkt.from = num; + pkt.to = nodeDB->getNodeNum(); + pkt.id = generatePacketId(); + // No native timestamp on telemetry packets here; use last_heard. + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); + pkt.rx_time = header ? header->last_heard : 0; + pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + pkt.decoded.portnum = meshtastic_PortNum_TELEMETRY_APP; + meshtastic_Telemetry fullTel = meshtastic_Telemetry_init_default; + fullTel.time = pkt.rx_time; + fullTel.which_variant = meshtastic_Telemetry_device_metrics_tag; + fullTel.variant.device_metrics = metrics; + size_t len = + pb_encode_to_bytes(pkt.decoded.payload.bytes, sizeof(pkt.decoded.payload.bytes), &meshtastic_Telemetry_msg, &fullTel); + pkt.decoded.payload.size = (pb_size_t)len; + return pkt; +} + +void PhoneAPI::beginReplayPositions() +{ +#if MESHTASTIC_EXCLUDE_POSITIONDB + // Build excluded entirely - leave the order list empty so the state arm + // immediately drains and advances. + replayPositionOrder.clear(); + replayPositionIndex = 0; +#else + if (!clientWantsGradientSync()) { + replayPositionOrder.clear(); + replayPositionIndex = 0; + return; + } + // Snapshot the keyset at phase start so concurrent inserts/erases on the + // map don't invalidate iteration. Skip our own node - the phone already + // got our position bundled in STATE_SEND_OWN_NODEINFO. + replayPositionOrder = nodeDB->snapshotPositionNodeNums(nodeDB->getNodeNum()); + replayPositionIndex = 0; + LOG_INFO("Begin position replay: %u entries millis=%u", (unsigned)replayPositionOrder.size(), millis()); +#endif +} + +void PhoneAPI::prefetchReplayPositions() +{ +#if MESHTASTIC_EXCLUDE_POSITIONDB + return; +#else + if (!clientWantsGradientSync()) + return; + bool added = false; + bool wasEmpty = false; + { + concurrency::LockGuard guard(&nodeInfoMutex); + wasEmpty = replayQueue.empty(); + while (replayQueue.size() < kReplayPrefetchDepth && replayPositionIndex < replayPositionOrder.size()) { + NodeNum num = replayPositionOrder[replayPositionIndex++]; + meshtastic_PositionLite pos; + if (!nodeDB->copyNodePosition(num, pos)) + continue; // entry was evicted between snapshot and now + replayQueue.push_back(makeReplayPositionPacket(num, pos)); + added = true; + } + } + if (added && wasEmpty) + onNowHasData(0); +#endif +} + +void PhoneAPI::beginReplayTelemetry() +{ +#if MESHTASTIC_EXCLUDE_TELEMETRYDB + replayTelemetryOrder.clear(); + replayTelemetryIndex = 0; +#else + if (!clientWantsGradientSync()) { + replayTelemetryOrder.clear(); + replayTelemetryIndex = 0; + return; + } + replayTelemetryOrder = nodeDB->snapshotTelemetryNodeNums(nodeDB->getNodeNum()); + replayTelemetryIndex = 0; + LOG_INFO("Begin telemetry replay: %u entries millis=%u", (unsigned)replayTelemetryOrder.size(), millis()); +#endif +} + +void PhoneAPI::prefetchReplayTelemetry() +{ +#if MESHTASTIC_EXCLUDE_TELEMETRYDB + return; +#else + if (!clientWantsGradientSync()) + return; + bool added = false; + bool wasEmpty = false; + { + concurrency::LockGuard guard(&nodeInfoMutex); + wasEmpty = replayQueue.empty(); + while (replayQueue.size() < kReplayPrefetchDepth && replayTelemetryIndex < replayTelemetryOrder.size()) { + NodeNum num = replayTelemetryOrder[replayTelemetryIndex++]; + meshtastic_DeviceMetrics dm; + if (!nodeDB->copyNodeTelemetry(num, dm)) + continue; + replayQueue.push_back(makeReplayTelemetryPacket(num, dm)); + added = true; + } + } + if (added && wasEmpty) + onNowHasData(0); +#endif +} + +meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const meshtastic_EnvironmentMetrics &env) +{ + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; + pkt.from = num; + pkt.to = nodeDB->getNodeNum(); + pkt.id = generatePacketId(); + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); + pkt.rx_time = header ? header->last_heard : 0; + pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + pkt.decoded.portnum = meshtastic_PortNum_TELEMETRY_APP; + meshtastic_Telemetry fullTel = meshtastic_Telemetry_init_default; + fullTel.time = pkt.rx_time; + fullTel.which_variant = meshtastic_Telemetry_environment_metrics_tag; + fullTel.variant.environment_metrics = env; + size_t len = + pb_encode_to_bytes(pkt.decoded.payload.bytes, sizeof(pkt.decoded.payload.bytes), &meshtastic_Telemetry_msg, &fullTel); + pkt.decoded.payload.size = (pb_size_t)len; + return pkt; +} + +void PhoneAPI::beginReplayEnvironment() +{ +#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB + replayEnvironmentOrder.clear(); + replayEnvironmentIndex = 0; +#else + if (!clientWantsGradientSync()) { + replayEnvironmentOrder.clear(); + replayEnvironmentIndex = 0; + return; + } + replayEnvironmentOrder = nodeDB->snapshotEnvironmentNodeNums(nodeDB->getNodeNum()); + replayEnvironmentIndex = 0; + LOG_INFO("Begin environment replay: %u entries millis=%u", (unsigned)replayEnvironmentOrder.size(), millis()); +#endif +} + +void PhoneAPI::prefetchReplayEnvironment() +{ +#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB + return; +#else + if (!clientWantsGradientSync()) + return; + bool added = false; + bool wasEmpty = false; + { + concurrency::LockGuard guard(&nodeInfoMutex); + wasEmpty = replayQueue.empty(); + while (replayQueue.size() < kReplayPrefetchDepth && replayEnvironmentIndex < replayEnvironmentOrder.size()) { + NodeNum num = replayEnvironmentOrder[replayEnvironmentIndex++]; + meshtastic_EnvironmentMetrics env; + if (!nodeDB->copyNodeEnvironment(num, env)) + continue; + replayQueue.push_back(makeReplayEnvironmentPacket(num, env)); + added = true; + } + } + if (added && wasEmpty) + onNowHasData(0); +#endif +} + +meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const meshtastic_StatusMessage &status) +{ + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; + pkt.from = num; + pkt.to = nodeDB->getNodeNum(); + pkt.id = generatePacketId(); + // StatusMessage has no native timestamp; use last_heard. + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); + pkt.rx_time = header ? header->last_heard : 0; + pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + pkt.decoded.portnum = meshtastic_PortNum_NODE_STATUS_APP; + size_t len = + pb_encode_to_bytes(pkt.decoded.payload.bytes, sizeof(pkt.decoded.payload.bytes), &meshtastic_StatusMessage_msg, &status); + pkt.decoded.payload.size = (pb_size_t)len; + return pkt; +} + +void PhoneAPI::beginReplayStatus() +{ +#if MESHTASTIC_EXCLUDE_STATUSDB + replayStatusOrder.clear(); + replayStatusIndex = 0; +#else + if (!clientWantsGradientSync()) { + replayStatusOrder.clear(); + replayStatusIndex = 0; + return; + } + replayStatusOrder = nodeDB->snapshotStatusNodeNums(nodeDB->getNodeNum()); + replayStatusIndex = 0; + LOG_INFO("Begin status replay: %u entries millis=%u", (unsigned)replayStatusOrder.size(), millis()); +#endif +} + +void PhoneAPI::prefetchReplayStatus() +{ +#if MESHTASTIC_EXCLUDE_STATUSDB + return; +#else + if (!clientWantsGradientSync()) + return; + bool added = false; + bool wasEmpty = false; + { + concurrency::LockGuard guard(&nodeInfoMutex); + wasEmpty = replayQueue.empty(); + while (replayQueue.size() < kReplayPrefetchDepth && replayStatusIndex < replayStatusOrder.size()) { + NodeNum num = replayStatusOrder[replayStatusIndex++]; + meshtastic_StatusMessage status; + if (!nodeDB->copyNodeStatus(num, status) || status.status[0] == '\0') + continue; + replayQueue.push_back(makeReplayStatusPacket(num, status)); + added = true; + } + } + if (added && wasEmpty) + onNowHasData(0); +#endif +} + void PhoneAPI::releaseMqttClientProxyPhonePacket() { if (mqttClientProxyMessageForPhone) { @@ -728,6 +1115,31 @@ bool PhoneAPI::available() PREFETCH_NODEINFO: prefetchNodeInfos(); return true; + case STATE_REPLAY_POSITIONS: { + // Prime the iterator if we haven't yet, then top up the queue. + if (replayPositionOrder.empty() && replayPositionIndex == 0) + beginReplayPositions(); + prefetchReplayPositions(); + return true; // Always advance state machine; arm itself transitions when drained + } + case STATE_REPLAY_TELEMETRY: { + if (replayTelemetryOrder.empty() && replayTelemetryIndex == 0) + beginReplayTelemetry(); + prefetchReplayTelemetry(); + return true; + } + case STATE_REPLAY_ENVIRONMENT: { + if (replayEnvironmentOrder.empty() && replayEnvironmentIndex == 0) + beginReplayEnvironment(); + prefetchReplayEnvironment(); + return true; + } + case STATE_REPLAY_STATUS: { + if (replayStatusOrder.empty() && replayStatusIndex == 0) + beginReplayStatus(); + prefetchReplayStatus(); + return true; + } case STATE_SEND_PACKETS: { if (!queueStatusPacketForPhone) queueStatusPacketForPhone = service->getQueueStatusForPhone(); diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index 7f79b5792b9..642fdd7e099 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -4,12 +4,16 @@ #include "concurrency/Lock.h" #include "mesh-pb-constants.h" #include "meshtastic/portnums.pb.h" +#include #include #include #include #include #include +// NodeNum stored as raw uint32_t below; including MeshTypes.h here breaks the +// LOG_WARN include order via Arduino.h/MemoryPool.h. + // Make sure that we never let our packets grow too large for one BLE packet #define MAX_TO_FROM_RADIO_SIZE 512 @@ -22,6 +26,9 @@ #define SPECIAL_NONCE_ONLY_CONFIG 69420 #define SPECIAL_NONCE_ONLY_NODES 69421 // ( ͡° ͜ʖ ͡°) +// Gradient sync: phone sends one of these to opt into thin-header + replay. +#define SPECIAL_NONCE_GRADIENT_SYNC 69422 +#define SPECIAL_NONCE_GRADIENT_ONLY_NODES 69423 /** * Provides our protobuf based API which phone/PC clients can use to talk to our device @@ -45,7 +52,13 @@ class PhoneAPI STATE_SEND_CONFIG, // Replacement for the old Radioconfig STATE_SEND_MODULECONFIG, // Send Module specific config STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to to the client - STATE_SEND_FILEMANIFEST, // Send file manifest + // Drain satellite DBs as synthetic POSITION_APP / TELEMETRY_APP / + // NODE_STATUS_APP packets when the phone opted into gradient sync. + STATE_REPLAY_POSITIONS, + STATE_REPLAY_TELEMETRY, + STATE_REPLAY_ENVIRONMENT, + STATE_REPLAY_STATUS, + STATE_SEND_FILEMANIFEST, // Send file manifest STATE_SEND_COMPLETE_ID, STATE_SEND_PACKETS // send packets or debug strings }; @@ -88,6 +101,20 @@ class PhoneAPI // Protect nodeInfoForPhone + nodeInfoQueue because NimBLE callbacks run in a separate FreeRTOS task. concurrency::Lock nodeInfoMutex; + // Synthetic-packet replay queue (paced via prefetch). + std::deque replayQueue; + static constexpr size_t kReplayPrefetchDepth = 4; + // NodeNum snapshots taken at each replay phase start so concurrent + // satellite-map mutations don't invalidate iteration. + std::vector replayPositionOrder; + std::vector replayTelemetryOrder; + std::vector replayEnvironmentOrder; + std::vector replayStatusOrder; + size_t replayPositionIndex = 0; + size_t replayTelemetryIndex = 0; + size_t replayEnvironmentIndex = 0; + size_t replayStatusIndex = 0; + meshtastic_ToRadio toRadioScratch = { 0}; // this is a static scratch object, any data must be copied elsewhere before returning @@ -137,6 +164,10 @@ class PhoneAPI bool isConnected() { return state != STATE_SEND_NOTHING; } bool isSendingPackets() { return state == STATE_SEND_PACKETS; } + bool clientWantsGradientSync() const + { + return config_nonce == SPECIAL_NONCE_GRADIENT_SYNC || config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES; + } protected: /// Our fromradio packet while it is being assembled @@ -185,6 +216,18 @@ class PhoneAPI void releaseQueueStatusPhonePacket(); void prefetchNodeInfos(); + void beginReplayPositions(); + void prefetchReplayPositions(); + void beginReplayTelemetry(); + void prefetchReplayTelemetry(); + void beginReplayEnvironment(); + void prefetchReplayEnvironment(); + void beginReplayStatus(); + void prefetchReplayStatus(); + meshtastic_MeshPacket makeReplayPositionPacket(uint32_t num, const meshtastic_PositionLite &pos); + meshtastic_MeshPacket makeReplayTelemetryPacket(uint32_t num, const meshtastic_DeviceMetrics &metrics); + meshtastic_MeshPacket makeReplayEnvironmentPacket(uint32_t num, const meshtastic_EnvironmentMetrics &env); + meshtastic_MeshPacket makeReplayStatusPacket(uint32_t num, const meshtastic_StatusMessage &status); void releaseMqttClientProxyPhonePacket(); diff --git a/src/mesh/ProtobufModule.h b/src/mesh/ProtobufModule.h index 27e653efe2a..fa2417c4684 100644 --- a/src/mesh/ProtobufModule.h +++ b/src/mesh/ProtobufModule.h @@ -58,7 +58,7 @@ template class ProtobufModule : protected SinglePortModule const char *getSenderShortName(const meshtastic_MeshPacket &mp) { auto node = nodeDB->getMeshNode(getFrom(&mp)); - const char *sender = (node) ? node->user.short_name : "???"; + const char *sender = (node && nodeInfoLiteHasUser(node) && node->short_name[0]) ? node->short_name : "???"; return sender; } @@ -121,4 +121,4 @@ template class ProtobufModule : protected SinglePortModule return alterReceivedProtobuf(mp, decoded); } } -}; \ No newline at end of file +}; diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index 42c24c783f6..a1cfb1200ca 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -115,7 +115,7 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0); } } else if (p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag && p->channel == 0 && - (nodeDB->getMeshNode(p->from) == nullptr || nodeDB->getMeshNode(p->from)->user.public_key.size == 0)) { + (nodeDB->getMeshNode(p->from) == nullptr || nodeDB->getMeshNode(p->from)->public_key.size == 0)) { LOG_INFO("PKI packet from unknown node, send PKI_UNKNOWN_PUBKEY"); sendAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, getFrom(p), p->id, channels.getPrimaryIndex(), routingModule->getHopLimitForResponse(*p)); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 19756689910..980ceb6df6d 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -120,17 +120,17 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p) if (!node) continue; - // Check 1: is_favorite (cheapest - single bool) - if (!node->is_favorite) + // Check 1: is_favorite (cheapest - single bit test) + if (!nodeInfoLiteIsFavorite(node)) continue; - // Check 2: has_user (cheap - single bool) - if (!node->has_user) + // 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->user.role, meshtastic_Config_DeviceConfig_Role_ROUTER, - meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) { + if (!IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, + meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) { continue; } @@ -433,7 +433,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) concurrency::LockGuard g(cryptLock); if (config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY && - (nodeDB->getMeshNode(p->from) == NULL || !nodeDB->getMeshNode(p->from)->has_user)) { + !nodeInfoLiteHasUser(nodeDB->getMeshNode(p->from))) { LOG_DEBUG("Node 0x%x not in nodeDB-> Rebroadcast mode KNOWN_ONLY will ignore packet", p->from); return DecodeState::DECODE_FAILURE; } @@ -451,11 +451,11 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) #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)->user.public_key.size > 0 && nodeDB->getMeshNode(p->to)->user.public_key.size > 0 && - rawSize > MESHTASTIC_PKC_OVERHEAD) { + 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) { LOG_DEBUG("Attempt PKI decryption"); - if (crypto->decryptCurve25519(p->from, nodeDB->getMeshNode(p->from)->user.public_key, p->id, rawSize, p->encrypted.bytes, + if (crypto->decryptCurve25519(p->from, nodeDB->getMeshNode(p->from)->public_key, p->id, rawSize, p->encrypted.bytes, bytes)) { LOG_INFO("PKI Decryption worked!"); @@ -467,7 +467,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)->user.public_key.bytes, 32); + memcpy(&p->public_key.bytes, nodeDB->getMeshNode(p->from)->public_key.bytes, 32); p->public_key.size = 32; p->decoded = decodedtmp; p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded @@ -665,18 +665,18 @@ 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->user.public_key.size != 32) { + if (node == nullptr || node->public_key.size != 32) { 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->user.public_key.bytes, 32) != 0) { + memcmp(p->public_key.bytes, node->public_key.bytes, 32) != 0) { LOG_WARN("Client public key differs from requested: 0x%02x, stored key begins 0x%02x", *p->public_key.bytes, - *node->user.public_key.bytes); + *node->public_key.bytes); return meshtastic_Routing_Error_PKI_FAILED; } - crypto->encryptCurve25519(p->to, getFrom(p), node->user.public_key, p->id, numbytes, bytes, p->encrypted.bytes); + crypto->encryptCurve25519(p->to, getFrom(p), node->public_key, p->id, numbytes, bytes, p->encrypted.bytes); numbytes += MESHTASTIC_PKC_OVERHEAD; p->channel = 0; p->pki_encrypted = true; @@ -860,7 +860,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) } meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from); - if (node != NULL && node->is_ignored) { + if (nodeInfoLiteIsIgnored(node)) { LOG_DEBUG("Ignore msg, 0x%x is ignored", p->from); packetPool.release(p); return; diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index 3798daf28a4..254af613212 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -3,7 +3,9 @@ #include "mesh/generated/meshtastic/mesh.pb.h" #include "meshUtils.h" -meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite) +meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite, + const meshtastic_PositionLite *position, + const meshtastic_DeviceMetrics *deviceMetrics) { meshtastic_NodeInfo info = meshtastic_NodeInfo_init_default; @@ -11,42 +13,56 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo info.snr = lite->snr; info.last_heard = lite->last_heard; info.channel = lite->channel; - info.via_mqtt = lite->via_mqtt; - info.is_favorite = lite->is_favorite; - info.is_ignored = lite->is_ignored; - info.is_key_manually_verified = lite->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; - info.is_muted = lite->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK; + info.via_mqtt = nodeInfoLiteViaMqtt(lite); + info.is_favorite = nodeInfoLiteIsFavorite(lite); + info.is_ignored = nodeInfoLiteIsIgnored(lite); + info.is_key_manually_verified = nodeInfoLiteIsKeyManuallyVerified(lite); + info.is_muted = nodeInfoLiteIsMuted(lite); if (lite->has_hops_away) { info.has_hops_away = true; info.hops_away = lite->hops_away; } - if (lite->has_position) { + if (position) { info.has_position = true; - if (lite->position.latitude_i != 0) + if (position->latitude_i != 0) info.position.has_latitude_i = true; - info.position.latitude_i = lite->position.latitude_i; - if (lite->position.longitude_i != 0) + info.position.latitude_i = position->latitude_i; + if (position->longitude_i != 0) info.position.has_longitude_i = true; - info.position.longitude_i = lite->position.longitude_i; - if (lite->position.altitude != 0) + info.position.longitude_i = position->longitude_i; + if (position->altitude != 0) info.position.has_altitude = true; - info.position.altitude = lite->position.altitude; - info.position.location_source = lite->position.location_source; - info.position.time = lite->position.time; + info.position.altitude = position->altitude; + info.position.location_source = position->location_source; + info.position.time = position->time; } - if (lite->has_user) { + if (nodeInfoLiteHasUser(lite)) { info.has_user = true; - info.user = ConvertToUser(lite->num, lite->user); + info.user = ConvertToUser(lite); } - if (lite->has_device_metrics) { + if (deviceMetrics) { info.has_device_metrics = true; - info.device_metrics = lite->device_metrics; + info.device_metrics = *deviceMetrics; } return info; } +meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite) +{ + meshtastic_PositionLite posScratch; + meshtastic_DeviceMetrics dmScratch; + const meshtastic_PositionLite *pos = (nodeDB && nodeDB->copyNodePosition(lite->num, posScratch)) ? &posScratch : nullptr; + const meshtastic_DeviceMetrics *dm = (nodeDB && nodeDB->copyNodeTelemetry(lite->num, dmScratch)) ? &dmScratch : nullptr; + return ConvertToNodeInfo(lite, pos, dm); +} + +meshtastic_NodeInfo TypeConversions::ConvertToNodeInfoThin(const meshtastic_NodeInfoLite *lite) +{ + return ConvertToNodeInfo(lite, nullptr, nullptr); +} + meshtastic_PositionLite TypeConversions::ConvertToPositionLite(meshtastic_Position position) { meshtastic_PositionLite lite = meshtastic_PositionLite_init_default; @@ -77,46 +93,50 @@ meshtastic_Position TypeConversions::ConvertToPosition(meshtastic_PositionLite l return position; } -meshtastic_UserLite TypeConversions::ConvertToUserLite(meshtastic_User user) +void TypeConversions::CopyUserToNodeInfoLite(meshtastic_NodeInfoLite *lite, const meshtastic_User &user) { - meshtastic_UserLite lite = meshtastic_UserLite_init_default; - - strncpy(lite.long_name, user.long_name, sizeof(lite.long_name)); - lite.long_name[sizeof(lite.long_name) - 1] = '\0'; - sanitizeUtf8(lite.long_name, sizeof(lite.long_name)); - strncpy(lite.short_name, user.short_name, sizeof(lite.short_name)); - lite.short_name[sizeof(lite.short_name) - 1] = '\0'; - sanitizeUtf8(lite.short_name, sizeof(lite.short_name)); - lite.hw_model = user.hw_model; - lite.role = user.role; - lite.is_licensed = user.is_licensed; - memcpy(lite.macaddr, user.macaddr, sizeof(lite.macaddr)); - memcpy(lite.public_key.bytes, user.public_key.bytes, sizeof(lite.public_key.bytes)); - lite.public_key.size = user.public_key.size; - lite.has_is_unmessagable = user.has_is_unmessagable; - lite.is_unmessagable = user.is_unmessagable; - return lite; + if (!lite) + return; + + strncpy(lite->long_name, user.long_name, sizeof(lite->long_name)); + lite->long_name[sizeof(lite->long_name) - 1] = '\0'; + sanitizeUtf8(lite->long_name, sizeof(lite->long_name)); + strncpy(lite->short_name, user.short_name, sizeof(lite->short_name)); + lite->short_name[sizeof(lite->short_name) - 1] = '\0'; + sanitizeUtf8(lite->short_name, sizeof(lite->short_name)); + lite->hw_model = user.hw_model; + lite->role = user.role; + memcpy(lite->public_key.bytes, user.public_key.bytes, sizeof(lite->public_key.bytes)); + lite->public_key.size = user.public_key.size; + + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_IS_LICENSED_MASK, user.is_licensed); + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK, user.has_is_unmessagable); + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK, user.has_is_unmessagable && user.is_unmessagable); + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_USER_MASK, true); } -meshtastic_User TypeConversions::ConvertToUser(uint32_t nodeNum, meshtastic_UserLite lite) +meshtastic_User TypeConversions::ConvertToUser(const meshtastic_NodeInfoLite *lite) { meshtastic_User user = meshtastic_User_init_default; + if (!lite) + return user; - snprintf(user.id, sizeof(user.id), "!%08x", nodeNum); - strncpy(user.long_name, lite.long_name, sizeof(user.long_name)); + snprintf(user.id, sizeof(user.id), "!%08x", lite->num); + strncpy(user.long_name, lite->long_name, sizeof(user.long_name)); user.long_name[sizeof(user.long_name) - 1] = '\0'; sanitizeUtf8(user.long_name, sizeof(user.long_name)); - strncpy(user.short_name, lite.short_name, sizeof(user.short_name)); + strncpy(user.short_name, lite->short_name, sizeof(user.short_name)); user.short_name[sizeof(user.short_name) - 1] = '\0'; sanitizeUtf8(user.short_name, sizeof(user.short_name)); - user.hw_model = lite.hw_model; - user.role = lite.role; - user.is_licensed = lite.is_licensed; - memcpy(user.macaddr, lite.macaddr, sizeof(user.macaddr)); - memcpy(user.public_key.bytes, lite.public_key.bytes, sizeof(user.public_key.bytes)); - user.public_key.size = lite.public_key.size; - user.has_is_unmessagable = lite.has_is_unmessagable; - user.is_unmessagable = lite.is_unmessagable; + user.hw_model = lite->hw_model; + user.role = lite->role; + user.is_licensed = nodeInfoLiteIsLicensed(lite); + memcpy(user.public_key.bytes, lite->public_key.bytes, sizeof(user.public_key.bytes)); + user.public_key.size = lite->public_key.size; + user.has_is_unmessagable = nodeInfoLiteHasIsUnmessagable(lite); + user.is_unmessagable = nodeInfoLiteIsUnmessagable(lite); + // macaddr is gone from the slim header; zero-fill for old clients that read it. + memset(user.macaddr, 0, sizeof(user.macaddr)); return user; -} \ No newline at end of file +} diff --git a/src/mesh/TypeConversions.h b/src/mesh/TypeConversions.h index 19e471f988b..f9792b7c386 100644 --- a/src/mesh/TypeConversions.h +++ b/src/mesh/TypeConversions.h @@ -7,9 +7,16 @@ class TypeConversions { public: + // Either pointer may be null; the corresponding has_* fields stay unset. + static meshtastic_NodeInfo ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite, const meshtastic_PositionLite *position, + const meshtastic_DeviceMetrics *deviceMetrics); static meshtastic_NodeInfo ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite); + // Identity + link-state only; satellite payloads are replayed afterward. + static meshtastic_NodeInfo ConvertToNodeInfoThin(const meshtastic_NodeInfoLite *lite); + static meshtastic_PositionLite ConvertToPositionLite(meshtastic_Position position); static meshtastic_Position ConvertToPosition(meshtastic_PositionLite lite); - static meshtastic_UserLite ConvertToUserLite(meshtastic_User user); - static meshtastic_User ConvertToUser(uint32_t nodeNum, meshtastic_UserLite lite); + + static void CopyUserToNodeInfoLite(meshtastic_NodeInfoLite *lite, const meshtastic_User &user); + static meshtastic_User ConvertToUser(const meshtastic_NodeInfoLite *lite); }; diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.cpp b/src/mesh/generated/meshtastic/deviceonly.pb.cpp index 5a96957027d..5580866379a 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.cpp +++ b/src/mesh/generated/meshtastic/deviceonly.pb.cpp @@ -18,6 +18,18 @@ PB_BIND(meshtastic_NodeInfoLite, meshtastic_NodeInfoLite, AUTO) PB_BIND(meshtastic_DeviceState, meshtastic_DeviceState, 2) +PB_BIND(meshtastic_NodePositionEntry, meshtastic_NodePositionEntry, AUTO) + + +PB_BIND(meshtastic_NodeTelemetryEntry, meshtastic_NodeTelemetryEntry, AUTO) + + +PB_BIND(meshtastic_NodeEnvironmentEntry, meshtastic_NodeEnvironmentEntry, AUTO) + + +PB_BIND(meshtastic_NodeStatusEntry, meshtastic_NodeStatusEntry, AUTO) + + PB_BIND(meshtastic_NodeDatabase, meshtastic_NodeDatabase, AUTO) diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 6d03dc64379..5e0b844f187 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -63,43 +63,35 @@ typedef struct _meshtastic_UserLite { bool is_unmessagable; } meshtastic_UserLite; +typedef PB_BYTES_ARRAY_T(32) meshtastic_NodeInfoLite_public_key_t; typedef struct _meshtastic_NodeInfoLite { /* The node number */ uint32_t num; - /* The user info for this node */ - bool has_user; - meshtastic_UserLite user; - /* This position data. Note: before 1.2.14 we would also store the last time we've heard from this node in position.time, that is no longer true. - Position.time now indicates the last time we received a POSITION from that node. */ - bool has_position; - meshtastic_PositionLite position; /* Returns the Signal-to-noise ratio (SNR) of the last received message, as measured by the receiver. Return SNR of the last received message in dB */ float snr; /* Set to indicate the last time we received a packet from this node */ uint32_t last_heard; - /* The latest device metrics for the node. */ - bool has_device_metrics; - meshtastic_DeviceMetrics device_metrics; /* local channel index we heard that node on. Only populated if its not the default channel. */ uint8_t channel; - /* True if we witnessed the node over MQTT instead of LoRA transport */ - bool via_mqtt; /* Number of hops away from us this node is (0 if direct neighbor) */ bool has_hops_away; uint8_t hops_away; - /* True if node is in our favorites list - Persists between NodeDB internal clean ups */ - bool is_favorite; - /* True if node is in our ignored list - Persists between NodeDB internal clean ups */ - bool is_ignored; /* Last byte of the node number of the node that should be used as the next hop to reach this node. */ uint8_t next_hop; - /* Bitfield for storing booleans. - LSB 0 is_key_manually_verified - LSB 1 is_muted */ + /* Bitfield for storing booleans. See NODEINFO_BITFIELD_* in src/mesh/NodeDB.h. */ uint32_t bitfield; + /* A full name for this user, i.e. "Kevin Hester". */ + char long_name[25]; + /* A VERY short name, ideally two characters or an emoji. + Suitable for a tiny OLED screen. */ + char short_name[5]; + /* Hardware model the user's device is running. */ + meshtastic_HardwareModel hw_model; + /* The user's role in the mesh. */ + meshtastic_Config_DeviceConfig_Role role; + /* The public key of the user's device, for PKI-based encrypted DMs. */ + meshtastic_NodeInfoLite_public_key_t public_key; } meshtastic_NodeInfoLite; /* This message is never sent over the wire, but it is used for serializing DB @@ -143,6 +135,30 @@ typedef struct _meshtastic_DeviceState { meshtastic_NodeRemoteHardwarePin node_remote_hardware_pins[12]; } meshtastic_DeviceState; +typedef struct _meshtastic_NodePositionEntry { + uint32_t num; + bool has_position; + meshtastic_PositionLite position; +} meshtastic_NodePositionEntry; + +typedef struct _meshtastic_NodeTelemetryEntry { + uint32_t num; + bool has_device_metrics; + meshtastic_DeviceMetrics device_metrics; +} meshtastic_NodeTelemetryEntry; + +typedef struct _meshtastic_NodeEnvironmentEntry { + uint32_t num; + bool has_environment_metrics; + meshtastic_EnvironmentMetrics environment_metrics; +} meshtastic_NodeEnvironmentEntry; + +typedef struct _meshtastic_NodeStatusEntry { + uint32_t num; + bool has_status; + meshtastic_StatusMessage status; +} meshtastic_NodeStatusEntry; + typedef struct _meshtastic_NodeDatabase { /* A version integer used to invalidate old save files when we make incompatible changes This integer is set at build time and is private to @@ -150,6 +166,12 @@ typedef struct _meshtastic_NodeDatabase { uint32_t version; /* New lite version of NodeDB to decrease memory footprint */ std::vector nodes; + /* Per-NodeNum satellite arrays. Constrained platforms (e.g. STM32WL) omit + these via MESHTASTIC_EXCLUDE_*DB build flags. */ + std::vector positions; + std::vector telemetry; + std::vector status; + std::vector environment; } meshtastic_NodeDatabase; /* The on-disk saved channels */ @@ -191,16 +213,24 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} #define meshtastic_UserLite_init_default {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} -#define meshtastic_NodeInfoLite_init_default {0, false, meshtastic_UserLite_init_default, false, meshtastic_PositionLite_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} #define meshtastic_DeviceState_init_default {false, meshtastic_MyNodeInfo_init_default, false, meshtastic_User_init_default, 0, {meshtastic_MeshPacket_init_default}, false, meshtastic_MeshPacket_init_default, 0, 0, 0, false, meshtastic_MeshPacket_init_default, 0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} -#define meshtastic_NodeDatabase_init_default {0, {0}} +#define meshtastic_NodePositionEntry_init_default {0, false, meshtastic_PositionLite_init_default} +#define meshtastic_NodeTelemetryEntry_init_default {0, false, meshtastic_DeviceMetrics_init_default} +#define meshtastic_NodeEnvironmentEntry_init_default {0, false, meshtastic_EnvironmentMetrics_init_default} +#define meshtastic_NodeStatusEntry_init_default {0, false, meshtastic_StatusMessage_init_default} +#define meshtastic_NodeDatabase_init_default {0, {0}, {0}, {0}, {0}, {0}} #define meshtastic_ChannelFile_init_default {0, {meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default}, 0} #define meshtastic_BackupPreferences_init_default {0, 0, false, meshtastic_LocalConfig_init_default, false, meshtastic_LocalModuleConfig_init_default, false, meshtastic_ChannelFile_init_default, false, meshtastic_User_init_default} #define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} #define meshtastic_UserLite_init_zero {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} -#define meshtastic_NodeInfoLite_init_zero {0, false, meshtastic_UserLite_init_zero, false, meshtastic_PositionLite_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} #define meshtastic_DeviceState_init_zero {false, meshtastic_MyNodeInfo_init_zero, false, meshtastic_User_init_zero, 0, {meshtastic_MeshPacket_init_zero}, false, meshtastic_MeshPacket_init_zero, 0, 0, 0, false, meshtastic_MeshPacket_init_zero, 0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} -#define meshtastic_NodeDatabase_init_zero {0, {0}} +#define meshtastic_NodePositionEntry_init_zero {0, false, meshtastic_PositionLite_init_zero} +#define meshtastic_NodeTelemetryEntry_init_zero {0, false, meshtastic_DeviceMetrics_init_zero} +#define meshtastic_NodeEnvironmentEntry_init_zero {0, false, meshtastic_EnvironmentMetrics_init_zero} +#define meshtastic_NodeStatusEntry_init_zero {0, false, meshtastic_StatusMessage_init_zero} +#define meshtastic_NodeDatabase_init_zero {0, {0}, {0}, {0}, {0}, {0}} #define meshtastic_ChannelFile_init_zero {0, {meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero}, 0} #define meshtastic_BackupPreferences_init_zero {0, 0, false, meshtastic_LocalConfig_init_zero, false, meshtastic_LocalModuleConfig_init_zero, false, meshtastic_ChannelFile_init_zero, false, meshtastic_User_init_zero} @@ -219,18 +249,17 @@ extern "C" { #define meshtastic_UserLite_public_key_tag 7 #define meshtastic_UserLite_is_unmessagable_tag 9 #define meshtastic_NodeInfoLite_num_tag 1 -#define meshtastic_NodeInfoLite_user_tag 2 -#define meshtastic_NodeInfoLite_position_tag 3 #define meshtastic_NodeInfoLite_snr_tag 4 #define meshtastic_NodeInfoLite_last_heard_tag 5 -#define meshtastic_NodeInfoLite_device_metrics_tag 6 #define meshtastic_NodeInfoLite_channel_tag 7 -#define meshtastic_NodeInfoLite_via_mqtt_tag 8 #define meshtastic_NodeInfoLite_hops_away_tag 9 -#define meshtastic_NodeInfoLite_is_favorite_tag 10 -#define meshtastic_NodeInfoLite_is_ignored_tag 11 #define meshtastic_NodeInfoLite_next_hop_tag 12 #define meshtastic_NodeInfoLite_bitfield_tag 13 +#define meshtastic_NodeInfoLite_long_name_tag 14 +#define meshtastic_NodeInfoLite_short_name_tag 15 +#define meshtastic_NodeInfoLite_hw_model_tag 16 +#define meshtastic_NodeInfoLite_role_tag 17 +#define meshtastic_NodeInfoLite_public_key_tag 18 #define meshtastic_DeviceState_my_node_tag 2 #define meshtastic_DeviceState_owner_tag 3 #define meshtastic_DeviceState_receive_queue_tag 5 @@ -240,8 +269,20 @@ extern "C" { #define meshtastic_DeviceState_did_gps_reset_tag 11 #define meshtastic_DeviceState_rx_waypoint_tag 12 #define meshtastic_DeviceState_node_remote_hardware_pins_tag 13 +#define meshtastic_NodePositionEntry_num_tag 1 +#define meshtastic_NodePositionEntry_position_tag 2 +#define meshtastic_NodeTelemetryEntry_num_tag 1 +#define meshtastic_NodeTelemetryEntry_device_metrics_tag 2 +#define meshtastic_NodeEnvironmentEntry_num_tag 1 +#define meshtastic_NodeEnvironmentEntry_environment_metrics_tag 2 +#define meshtastic_NodeStatusEntry_num_tag 1 +#define meshtastic_NodeStatusEntry_status_tag 2 #define meshtastic_NodeDatabase_version_tag 1 #define meshtastic_NodeDatabase_nodes_tag 2 +#define meshtastic_NodeDatabase_positions_tag 3 +#define meshtastic_NodeDatabase_telemetry_tag 4 +#define meshtastic_NodeDatabase_status_tag 5 +#define meshtastic_NodeDatabase_environment_tag 6 #define meshtastic_ChannelFile_channels_tag 1 #define meshtastic_ChannelFile_version_tag 2 #define meshtastic_BackupPreferences_version_tag 1 @@ -275,23 +316,19 @@ X(a, STATIC, OPTIONAL, BOOL, is_unmessagable, 9) #define meshtastic_NodeInfoLite_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, user, 2) \ -X(a, STATIC, OPTIONAL, MESSAGE, position, 3) \ X(a, STATIC, SINGULAR, FLOAT, snr, 4) \ X(a, STATIC, SINGULAR, FIXED32, last_heard, 5) \ -X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 6) \ X(a, STATIC, SINGULAR, UINT32, channel, 7) \ -X(a, STATIC, SINGULAR, BOOL, via_mqtt, 8) \ X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \ -X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ -X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ X(a, STATIC, SINGULAR, UINT32, next_hop, 12) \ -X(a, STATIC, SINGULAR, UINT32, bitfield, 13) +X(a, STATIC, SINGULAR, UINT32, bitfield, 13) \ +X(a, STATIC, SINGULAR, STRING, long_name, 14) \ +X(a, STATIC, SINGULAR, STRING, short_name, 15) \ +X(a, STATIC, SINGULAR, UENUM, hw_model, 16) \ +X(a, STATIC, SINGULAR, UENUM, role, 17) \ +X(a, STATIC, SINGULAR, BYTES, public_key, 18) #define meshtastic_NodeInfoLite_CALLBACK NULL #define meshtastic_NodeInfoLite_DEFAULT NULL -#define meshtastic_NodeInfoLite_user_MSGTYPE meshtastic_UserLite -#define meshtastic_NodeInfoLite_position_MSGTYPE meshtastic_PositionLite -#define meshtastic_NodeInfoLite_device_metrics_MSGTYPE meshtastic_DeviceMetrics #define meshtastic_DeviceState_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, MESSAGE, my_node, 2) \ @@ -312,13 +349,49 @@ X(a, STATIC, REPEATED, MESSAGE, node_remote_hardware_pins, 13) #define meshtastic_DeviceState_rx_waypoint_MSGTYPE meshtastic_MeshPacket #define meshtastic_DeviceState_node_remote_hardware_pins_MSGTYPE meshtastic_NodeRemoteHardwarePin +#define meshtastic_NodePositionEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, position, 2) +#define meshtastic_NodePositionEntry_CALLBACK NULL +#define meshtastic_NodePositionEntry_DEFAULT NULL +#define meshtastic_NodePositionEntry_position_MSGTYPE meshtastic_PositionLite + +#define meshtastic_NodeTelemetryEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 2) +#define meshtastic_NodeTelemetryEntry_CALLBACK NULL +#define meshtastic_NodeTelemetryEntry_DEFAULT NULL +#define meshtastic_NodeTelemetryEntry_device_metrics_MSGTYPE meshtastic_DeviceMetrics + +#define meshtastic_NodeEnvironmentEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, environment_metrics, 2) +#define meshtastic_NodeEnvironmentEntry_CALLBACK NULL +#define meshtastic_NodeEnvironmentEntry_DEFAULT NULL +#define meshtastic_NodeEnvironmentEntry_environment_metrics_MSGTYPE meshtastic_EnvironmentMetrics + +#define meshtastic_NodeStatusEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, status, 2) +#define meshtastic_NodeStatusEntry_CALLBACK NULL +#define meshtastic_NodeStatusEntry_DEFAULT NULL +#define meshtastic_NodeStatusEntry_status_MSGTYPE meshtastic_StatusMessage + #define meshtastic_NodeDatabase_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, version, 1) \ -X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) +X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) \ +X(a, CALLBACK, REPEATED, MESSAGE, positions, 3) \ +X(a, CALLBACK, REPEATED, MESSAGE, telemetry, 4) \ +X(a, CALLBACK, REPEATED, MESSAGE, status, 5) \ +X(a, CALLBACK, REPEATED, MESSAGE, environment, 6) extern bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field); #define meshtastic_NodeDatabase_CALLBACK meshtastic_NodeDatabase_callback #define meshtastic_NodeDatabase_DEFAULT NULL #define meshtastic_NodeDatabase_nodes_MSGTYPE meshtastic_NodeInfoLite +#define meshtastic_NodeDatabase_positions_MSGTYPE meshtastic_NodePositionEntry +#define meshtastic_NodeDatabase_telemetry_MSGTYPE meshtastic_NodeTelemetryEntry +#define meshtastic_NodeDatabase_status_MSGTYPE meshtastic_NodeStatusEntry +#define meshtastic_NodeDatabase_environment_MSGTYPE meshtastic_NodeEnvironmentEntry #define meshtastic_ChannelFile_FIELDLIST(X, a) \ X(a, STATIC, REPEATED, MESSAGE, channels, 1) \ @@ -345,6 +418,10 @@ extern const pb_msgdesc_t meshtastic_PositionLite_msg; extern const pb_msgdesc_t meshtastic_UserLite_msg; extern const pb_msgdesc_t meshtastic_NodeInfoLite_msg; extern const pb_msgdesc_t meshtastic_DeviceState_msg; +extern const pb_msgdesc_t meshtastic_NodePositionEntry_msg; +extern const pb_msgdesc_t meshtastic_NodeTelemetryEntry_msg; +extern const pb_msgdesc_t meshtastic_NodeEnvironmentEntry_msg; +extern const pb_msgdesc_t meshtastic_NodeStatusEntry_msg; extern const pb_msgdesc_t meshtastic_NodeDatabase_msg; extern const pb_msgdesc_t meshtastic_ChannelFile_msg; extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; @@ -354,6 +431,10 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_UserLite_fields &meshtastic_UserLite_msg #define meshtastic_NodeInfoLite_fields &meshtastic_NodeInfoLite_msg #define meshtastic_DeviceState_fields &meshtastic_DeviceState_msg +#define meshtastic_NodePositionEntry_fields &meshtastic_NodePositionEntry_msg +#define meshtastic_NodeTelemetryEntry_fields &meshtastic_NodeTelemetryEntry_msg +#define meshtastic_NodeEnvironmentEntry_fields &meshtastic_NodeEnvironmentEntry_msg +#define meshtastic_NodeStatusEntry_fields &meshtastic_NodeStatusEntry_msg #define meshtastic_NodeDatabase_fields &meshtastic_NodeDatabase_msg #define meshtastic_ChannelFile_fields &meshtastic_ChannelFile_msg #define meshtastic_BackupPreferences_fields &meshtastic_BackupPreferences_msg @@ -364,7 +445,11 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_BackupPreferences_size 2432 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1737 -#define meshtastic_NodeInfoLite_size 196 +#define meshtastic_NodeEnvironmentEntry_size 170 +#define meshtastic_NodeInfoLite_size 105 +#define meshtastic_NodePositionEntry_size 36 +#define meshtastic_NodeStatusEntry_size 89 +#define meshtastic_NodeTelemetryEntry_size 35 #define meshtastic_PositionLite_size 28 #define meshtastic_UserLite_size 98 diff --git a/src/mesh/generated/meshtastic/deviceonly_legacy.pb.cpp b/src/mesh/generated/meshtastic/deviceonly_legacy.pb.cpp new file mode 100644 index 00000000000..04c3a98d94d --- /dev/null +++ b/src/mesh/generated/meshtastic/deviceonly_legacy.pb.cpp @@ -0,0 +1,15 @@ +/* Automatically generated nanopb constant definitions */ +/* Generated by nanopb-0.4.9.1 */ + +#include "meshtastic/deviceonly_legacy.pb.h" +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +PB_BIND(meshtastic_NodeInfoLite_Legacy, meshtastic_NodeInfoLite_Legacy, AUTO) + + +PB_BIND(meshtastic_NodeDatabase_Legacy, meshtastic_NodeDatabase_Legacy, AUTO) + + + diff --git a/src/mesh/generated/meshtastic/deviceonly_legacy.pb.h b/src/mesh/generated/meshtastic/deviceonly_legacy.pb.h new file mode 100644 index 00000000000..916951419fa --- /dev/null +++ b/src/mesh/generated/meshtastic/deviceonly_legacy.pb.h @@ -0,0 +1,124 @@ +/* Automatically generated nanopb header */ +/* Generated by nanopb-0.4.9.1 */ + +#ifndef PB_MESHTASTIC_MESHTASTIC_DEVICEONLY_LEGACY_PB_H_INCLUDED +#define PB_MESHTASTIC_MESHTASTIC_DEVICEONLY_LEGACY_PB_H_INCLUDED +#include +#include +#include "meshtastic/deviceonly.pb.h" +#include "meshtastic/telemetry.pb.h" + +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +/* Struct definitions */ +/* Legacy NodeInfoLite descriptor used only to decode pre-split + /prefs/nodes.proto saves during the v24 -> v25 migration boot. + This preserves the original NodeInfoLite-compatible field numbers needed + to parse old wire bytes cleanly, including user (2), position (3), + device_metrics (6), and the legacy-only compatibility fields via_mqtt (8), + is_favorite (10), and is_ignored (11). Steady-state code does not use + this struct; it is dropped after migration completes. This file should be + removed once DEVICESTATE_MIN_VER advances past 24. */ +typedef struct _meshtastic_NodeInfoLite_Legacy { + uint32_t num; + bool has_user; + meshtastic_UserLite user; + bool has_position; + meshtastic_PositionLite position; + float snr; + uint32_t last_heard; + bool has_device_metrics; + meshtastic_DeviceMetrics device_metrics; + uint8_t channel; + bool via_mqtt; + bool has_hops_away; + uint8_t hops_away; + bool is_favorite; + bool is_ignored; + uint8_t next_hop; + uint32_t bitfield; +} meshtastic_NodeInfoLite_Legacy; + +/* Legacy NodeDatabase shape: one repeated array of fat NodeInfoLite_Legacy + with no satellite position/telemetry arrays. */ +typedef struct _meshtastic_NodeDatabase_Legacy { + uint32_t version; + std::vector nodes; +} meshtastic_NodeDatabase_Legacy; + + +#ifdef __cplusplus +extern "C" { +#endif + +/* Initializer values for message structs */ +#define meshtastic_NodeInfoLite_Legacy_init_default {0, false, meshtastic_UserLite_init_default, false, meshtastic_PositionLite_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_NodeDatabase_Legacy_init_default {0, {0}} +#define meshtastic_NodeInfoLite_Legacy_init_zero {0, false, meshtastic_UserLite_init_zero, false, meshtastic_PositionLite_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_NodeDatabase_Legacy_init_zero {0, {0}} + +/* Field tags (for use in manual encoding/decoding) */ +#define meshtastic_NodeInfoLite_Legacy_num_tag 1 +#define meshtastic_NodeInfoLite_Legacy_user_tag 2 +#define meshtastic_NodeInfoLite_Legacy_position_tag 3 +#define meshtastic_NodeInfoLite_Legacy_snr_tag 4 +#define meshtastic_NodeInfoLite_Legacy_last_heard_tag 5 +#define meshtastic_NodeInfoLite_Legacy_device_metrics_tag 6 +#define meshtastic_NodeInfoLite_Legacy_channel_tag 7 +#define meshtastic_NodeInfoLite_Legacy_via_mqtt_tag 8 +#define meshtastic_NodeInfoLite_Legacy_hops_away_tag 9 +#define meshtastic_NodeInfoLite_Legacy_is_favorite_tag 10 +#define meshtastic_NodeInfoLite_Legacy_is_ignored_tag 11 +#define meshtastic_NodeInfoLite_Legacy_next_hop_tag 12 +#define meshtastic_NodeInfoLite_Legacy_bitfield_tag 13 +#define meshtastic_NodeDatabase_Legacy_version_tag 1 +#define meshtastic_NodeDatabase_Legacy_nodes_tag 2 + +/* Struct field encoding specification for nanopb */ +#define meshtastic_NodeInfoLite_Legacy_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, user, 2) \ +X(a, STATIC, OPTIONAL, MESSAGE, position, 3) \ +X(a, STATIC, SINGULAR, FLOAT, snr, 4) \ +X(a, STATIC, SINGULAR, FIXED32, last_heard, 5) \ +X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 6) \ +X(a, STATIC, SINGULAR, UINT32, channel, 7) \ +X(a, STATIC, SINGULAR, BOOL, via_mqtt, 8) \ +X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \ +X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ +X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ +X(a, STATIC, SINGULAR, UINT32, next_hop, 12) \ +X(a, STATIC, SINGULAR, UINT32, bitfield, 13) +#define meshtastic_NodeInfoLite_Legacy_CALLBACK NULL +#define meshtastic_NodeInfoLite_Legacy_DEFAULT NULL +#define meshtastic_NodeInfoLite_Legacy_user_MSGTYPE meshtastic_UserLite +#define meshtastic_NodeInfoLite_Legacy_position_MSGTYPE meshtastic_PositionLite +#define meshtastic_NodeInfoLite_Legacy_device_metrics_MSGTYPE meshtastic_DeviceMetrics + +#define meshtastic_NodeDatabase_Legacy_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, version, 1) \ +X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) +extern bool meshtastic_NodeDatabase_Legacy_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field); +#define meshtastic_NodeDatabase_Legacy_CALLBACK meshtastic_NodeDatabase_Legacy_callback +#define meshtastic_NodeDatabase_Legacy_DEFAULT NULL +#define meshtastic_NodeDatabase_Legacy_nodes_MSGTYPE meshtastic_NodeInfoLite_Legacy + +extern const pb_msgdesc_t meshtastic_NodeInfoLite_Legacy_msg; +extern const pb_msgdesc_t meshtastic_NodeDatabase_Legacy_msg; + +/* Defines for backwards compatibility with code written before nanopb-0.4.0 */ +#define meshtastic_NodeInfoLite_Legacy_fields &meshtastic_NodeInfoLite_Legacy_msg +#define meshtastic_NodeDatabase_Legacy_fields &meshtastic_NodeDatabase_Legacy_msg + +/* Maximum encoded size of messages (where known) */ +/* meshtastic_NodeDatabase_Legacy_size depends on runtime parameters */ +#define MESHTASTIC_MESHTASTIC_DEVICEONLY_LEGACY_PB_H_MAX_SIZE meshtastic_NodeInfoLite_Legacy_size +#define meshtastic_NodeInfoLite_Legacy_size 196 + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif diff --git a/src/mesh/http/ContentHandler.cpp b/src/mesh/http/ContentHandler.cpp index 6b3aa4859ff..57bbfe61bb7 100644 --- a/src/mesh/http/ContentHandler.cpp +++ b/src/mesh/http/ContentHandler.cpp @@ -729,7 +729,7 @@ void handleNodes(HTTPRequest *req, HTTPResponse *res) uint32_t readIndex = 0; const meshtastic_NodeInfoLite *tempNodeInfo = nodeDB->readNextMeshNode(readIndex); while (tempNodeInfo != NULL) { - if (tempNodeInfo->has_user) { + if (nodeInfoLiteHasUser(tempNodeInfo)) { JSONObject node; char id[16]; @@ -737,26 +737,26 @@ void handleNodes(HTTPRequest *req, HTTPResponse *res) node["id"] = new JSONValue(id); node["snr"] = new JSONValue(tempNodeInfo->snr); - node["via_mqtt"] = new JSONValue(BoolToString(tempNodeInfo->via_mqtt)); + node["via_mqtt"] = new JSONValue(BoolToString(nodeInfoLiteViaMqtt(tempNodeInfo))); node["last_heard"] = new JSONValue((int)tempNodeInfo->last_heard); node["position"] = new JSONValue(); if (nodeDB->hasValidPosition(tempNodeInfo)) { - JSONObject position; - position["latitude"] = new JSONValue((float)tempNodeInfo->position.latitude_i * 1e-7); - position["longitude"] = new JSONValue((float)tempNodeInfo->position.longitude_i * 1e-7); - position["altitude"] = new JSONValue((int)tempNodeInfo->position.altitude); - node["position"] = new JSONValue(position); + meshtastic_PositionLite posLite; + if (nodeDB->copyNodePosition(tempNodeInfo->num, posLite)) { + JSONObject position; + position["latitude"] = new JSONValue((float)posLite.latitude_i * 1e-7); + position["longitude"] = new JSONValue((float)posLite.longitude_i * 1e-7); + position["altitude"] = new JSONValue((int)posLite.altitude); + node["position"] = new JSONValue(position); + } } - node["long_name"] = new JSONValue(tempNodeInfo->user.long_name); - node["short_name"] = new JSONValue(tempNodeInfo->user.short_name); - char macStr[18]; - snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X", tempNodeInfo->user.macaddr[0], - tempNodeInfo->user.macaddr[1], tempNodeInfo->user.macaddr[2], tempNodeInfo->user.macaddr[3], - tempNodeInfo->user.macaddr[4], tempNodeInfo->user.macaddr[5]); - node["mac_address"] = new JSONValue(macStr); - node["hw_model"] = new JSONValue(tempNodeInfo->user.hw_model); + node["long_name"] = new JSONValue(tempNodeInfo->long_name); + node["short_name"] = new JSONValue(tempNodeInfo->short_name); + // mac_address dropped from NodeInfoLite as part of the slim refactor; emit zeros. + node["mac_address"] = new JSONValue("00:00:00:00:00:00"); + node["hw_model"] = new JSONValue(tempNodeInfo->hw_model); nodesArray.push_back(new JSONValue(node)); } @@ -930,4 +930,4 @@ void handleScanNetworks(HTTPRequest *req, HTTPResponse *res) res->print(jsonString.c_str()); delete value; } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index eea7d4efcdf..2fbb355b424 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -37,9 +37,43 @@ #define MAX_RX_NOTIFICATION_TOPHONE 2 #endif -/// Verify baseline assumption of node size. If it increases, we need to reevaluate -/// the impact of its memory footprint, notably on MAX_NUM_NODES. -static_assert(sizeof(meshtastic_NodeInfoLite) <= 200, "NodeInfoLite size increased. Reconsider impact on MAX_NUM_NODES."); +/// Tighten this when the slim header shrinks; loosen only with deliberate +/// awareness of MAX_NUM_NODES impact per platform. +static_assert(sizeof(meshtastic_NodeInfoLite) <= 130, "NodeInfoLite size increased. Reconsider impact on MAX_NUM_NODES."); + +// Compile satellite NodeDBs out on STM32WL (and the status DB also follows +// MESHTASTIC_EXCLUDE_STATUS). +#ifndef MESHTASTIC_EXCLUDE_POSITIONDB +#if defined(ARCH_STM32WL) +#define MESHTASTIC_EXCLUDE_POSITIONDB 1 +#else +#define MESHTASTIC_EXCLUDE_POSITIONDB 0 +#endif +#endif + +#ifndef MESHTASTIC_EXCLUDE_TELEMETRYDB +#if defined(ARCH_STM32WL) +#define MESHTASTIC_EXCLUDE_TELEMETRYDB 1 +#else +#define MESHTASTIC_EXCLUDE_TELEMETRYDB 0 +#endif +#endif + +#ifndef MESHTASTIC_EXCLUDE_ENVIRONMENTDB +#if defined(ARCH_STM32WL) +#define MESHTASTIC_EXCLUDE_ENVIRONMENTDB 1 +#else +#define MESHTASTIC_EXCLUDE_ENVIRONMENTDB 0 +#endif +#endif + +#ifndef MESHTASTIC_EXCLUDE_STATUSDB +#if defined(ARCH_STM32WL) || defined(MESHTASTIC_EXCLUDE_STATUS) +#define MESHTASTIC_EXCLUDE_STATUSDB 1 +#else +#define MESHTASTIC_EXCLUDE_STATUSDB 0 +#endif +#endif /// max number of nodes allowed in the nodeDB #ifndef MAX_NUM_NODES diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 7b249f656cd..396917693be 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -107,14 +107,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // Automatically favorite the node that is using the admin key auto remoteNode = nodeDB->getMeshNode(mp.from); - if (remoteNode && !remoteNode->is_favorite) { + if (remoteNode && !nodeInfoLiteIsFavorite(remoteNode)) { if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) { // Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it // without the user doing so deliberately. LOG_INFO("PKC admin valid, but not auto-favoriting node %x because role==CLIENT_BASE", mp.from); } else { LOG_INFO("PKC admin valid. Auto-favoriting node %x", mp.from); - remoteNode->is_favorite = true; + nodeInfoLiteSetBit(remoteNode, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); } } } else { @@ -393,7 +393,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_INFO("Client received set_favorite_node command"); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_favorite_node); if (node != NULL) { - node->is_favorite = true; + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); saveChanges(SEGMENT_NODEDATABASE, false); if (screen) screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens @@ -404,7 +404,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_INFO("Client received remove_favorite_node command"); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_favorite_node); if (node != NULL) { - node->is_favorite = false; + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false); saveChanges(SEGMENT_NODEDATABASE, false); if (screen) screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens @@ -415,11 +415,10 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_INFO("Client received set_ignored_node command"); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_ignored_node); if (node != NULL) { - node->is_ignored = true; - node->has_device_metrics = false; - node->has_position = false; - node->user.public_key.size = 0; - memset(node->user.public_key.bytes, 0, sizeof(node->user.public_key.bytes)); + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true); + nodeDB->eraseNodeSatellites(node->num); + node->public_key.size = 0; + memset(node->public_key.bytes, 0, sizeof(node->public_key.bytes)); saveChanges(SEGMENT_NODEDATABASE, false); } break; @@ -428,7 +427,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_INFO("Client received remove_ignored_node command"); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_ignored_node); if (node != NULL) { - node->is_ignored = false; + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, false); saveChanges(SEGMENT_NODEDATABASE, false); } break; @@ -437,7 +436,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_INFO("Client received toggle_muted_node command"); meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->toggle_muted_node); if (node != NULL) { - node->bitfield ^= (1 << NODEINFO_BITFIELD_IS_MUTED_SHIFT); + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_MUTED_MASK, !nodeInfoLiteIsMuted(node)); saveChanges(SEGMENT_NODEDATABASE, false); } break; @@ -445,9 +444,11 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta case meshtastic_AdminMessage_set_fixed_position_tag: { LOG_INFO("Client received set_fixed_position command"); - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); - node->has_position = true; - node->position = TypeConversions::ConvertToPositionLite(r->set_fixed_position); + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); + // Route the fixed position through updatePosition so it lands in the + // satellite map (or, on builds with PositionDB excluded, just sets + // localPosition for the local broadcast path). + nodeDB->updatePosition(node->num, r->set_fixed_position, RX_SRC_LOCAL); nodeDB->setLocalPosition(r->set_fixed_position); config.position.fixed_position = true; saveChanges(SEGMENT_NODEDATABASE | SEGMENT_CONFIG, false); diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 65e90313444..a29b9fa5838 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -141,7 +141,7 @@ void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t static bool returnToCannedList = false; bool hasKeyForNode(const meshtastic_NodeInfoLite *node) { - return node && node->has_user && node->user.public_key.size > 0; + return nodeInfoLiteHasUser(node) && node->public_key.size > 0; } /** * @brief Items in array this->messages will be set to be pointing on the right @@ -262,10 +262,10 @@ void CannedMessageModule::updateDestinationSelectionList() for (size_t i = 0; i < numMeshNodes; ++i) { meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); - if (!node || node->num == myNodeNum || !node->has_user || node->user.public_key.size != 32) + if (!node || node->num == myNodeNum || !nodeInfoLiteHasUser(node) || node->public_key.size != 32) continue; - const String &nodeName = node->user.long_name; + const String &nodeName = node->long_name; if (searchQuery.length() == 0) { this->filteredNodes.push_back({node, sinceLastSeen(node)}); @@ -1054,7 +1054,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha } NodeNum myNodeNum = nodeDB->getNodeNum(); - if (node && node->num != myNodeNum && node->has_user && node->user.public_key.size == 32) { + if (node && node->num != myNodeNum && nodeInfoLiteHasUser(node) && node->public_key.size == 32) { p->pki_encrypted = true; p->channel = 0; // force PKI } @@ -1415,8 +1415,8 @@ const char *CannedMessageModule::getNodeName(NodeNum node) return "Broadcast"; meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node); - if (info && info->has_user && strlen(info->user.long_name) > 0) { - return info->user.long_name; + if (nodeInfoLiteHasUser(info) && strlen(info->long_name) > 0) { + return info->long_name; } static char fallback[12]; @@ -1723,17 +1723,17 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node; if (node) { if (display->getWidth() <= 64) { - entryText = node->user.short_name; - } else if (node->user.long_name[0]) { - entryText = node->user.long_name; + entryText = node->short_name; + } else if (node->long_name[0]) { + entryText = node->long_name; } else { - entryText = node->user.short_name; + entryText = node->short_name; } } int availWidth = display->getWidth() - ((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) - - ((node && node->is_favorite) ? 10 : 0); + ((nodeInfoLiteIsFavorite(node)) ? 10 : 0); if (availWidth < 0) availWidth = 0; char truncatedEntry[96]; @@ -1742,7 +1742,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O entryText = truncatedEntry; // Prepend "* " if this is a favorite - if (node && node->is_favorite) { + if (nodeInfoLiteIsFavorite(node)) { entryText = "* " + entryText; } graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry), diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 16ccdd74498..1a0b699300d 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -372,7 +372,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP // If we receive a direct message and the receipent is us, apply DM mute setting // Else we just handle it as not muted. const bool isDmToUs = !isBroadcast(mp.to) && isToUs(&mp); - bool is_muted = isDmToUs ? (sender && ((sender->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0)) + bool is_muted = isDmToUs ? nodeInfoLiteIsMuted(sender) : (ch.settings.has_module_settings && ch.settings.module_settings.is_muted); const bool buzzerModeIsDirectOnly = diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index 6d0255d5355..67317ac9b71 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -10,6 +10,18 @@ KeyVerificationModule *keyVerificationModule; +namespace +{ +void copyNodeLongNameOrUnknown(char *dest, size_t destSize, const meshtastic_NodeInfoLite *node) +{ + if (!dest || destSize == 0) + return; + const char *name = (node && nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->long_name : "Unknown"; + strncpy(dest, name, destSize - 1); + dest[destSize - 1] = '\0'; +} +} // namespace + KeyVerificationModule::KeyVerificationModule() : ProtobufModule("KeyVerification", meshtastic_PortNum_KEY_VERIFICATION_APP, &meshtastic_KeyVerification_msg) { @@ -37,7 +49,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons } else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_VERIFY && request->key_verification.nonce == currentNonce) { auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + if (remoteNodePtr) + remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; resetToIdle(); } else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_NOT_VERIFY) { resetToIdle(); @@ -72,9 +85,9 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & sprintf(cn->message, "Enter Security Number for Key Verification"); cn->which_payload_variant = meshtastic_ClientNotification_key_verification_number_request_tag; cn->payload_variant.key_verification_number_request.nonce = currentNonce; - strncpy(cn->payload_variant.key_verification_number_request.remote_longname, // should really check for nulls, etc - nodeDB->getMeshNode(currentRemoteNode)->user.long_name, - sizeof(cn->payload_variant.key_verification_number_request.remote_longname)); + copyNodeLongNameOrUnknown(cn->payload_variant.key_verification_number_request.remote_longname, + sizeof(cn->payload_variant.key_verification_number_request.remote_longname), + nodeDB->getMeshNode(currentRemoteNode)); service->sendClientNotification(cn); LOG_INFO("Received hash2"); currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER; @@ -95,7 +108,8 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & [=](int selected) { if (selected == 1) { auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + if (remoteNodePtr) + remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; } }; screen->showOverlayBanner(options);) @@ -104,9 +118,9 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & sprintf(cn->message, "Final confirmation for incoming manual key verification %s", message); cn->which_payload_variant = meshtastic_ClientNotification_key_verification_final_tag; cn->payload_variant.key_verification_final.nonce = currentNonce; - strncpy(cn->payload_variant.key_verification_final.remote_longname, // should really check for nulls, etc - nodeDB->getMeshNode(currentRemoteNode)->user.long_name, - sizeof(cn->payload_variant.key_verification_final.remote_longname)); + copyNodeLongNameOrUnknown(cn->payload_variant.key_verification_final.remote_longname, + sizeof(cn->payload_variant.key_verification_final.remote_longname), + nodeDB->getMeshNode(currentRemoteNode)); cn->payload_variant.key_verification_final.isSender = false; service->sendClientNotification(cn); @@ -202,9 +216,9 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() currentSecurityNumber % 1000); cn->which_payload_variant = meshtastic_ClientNotification_key_verification_number_inform_tag; cn->payload_variant.key_verification_number_inform.nonce = currentNonce; - strncpy(cn->payload_variant.key_verification_number_inform.remote_longname, // should really check for nulls, etc - nodeDB->getMeshNode(currentRemoteNode)->user.long_name, - sizeof(cn->payload_variant.key_verification_number_inform.remote_longname)); + copyNodeLongNameOrUnknown(cn->payload_variant.key_verification_number_inform.remote_longname, + sizeof(cn->payload_variant.key_verification_number_inform.remote_longname), + nodeDB->getMeshNode(currentRemoteNode)); cn->payload_variant.key_verification_number_inform.security_number = currentSecurityNumber; service->sendClientNotification(cn); LOG_WARN("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce); @@ -219,7 +233,7 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) LOG_WARN("received security number: %u", incomingNumber); meshtastic_NodeInfoLite *remoteNodePtr = nullptr; remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - if (remoteNodePtr == nullptr || !remoteNodePtr->has_user || remoteNodePtr->user.public_key.size != 32) { + if (!remoteNodePtr || !nodeInfoLiteHasUser(remoteNodePtr) || remoteNodePtr->public_key.size != 32) { currentState = KEY_VERIFICATION_IDLE; return; // should we throw an error here? } @@ -232,7 +246,7 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) hash.update(¤tRemoteNode, sizeof(currentRemoteNode)); hash.update(owner.public_key.bytes, owner.public_key.size); - hash.update(remoteNodePtr->user.public_key.bytes, remoteNodePtr->user.public_key.size); + hash.update(remoteNodePtr->public_key.bytes, remoteNodePtr->public_key.size); hash.finalize(hash1, 32); hash.reset(); @@ -265,9 +279,9 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) sprintf(cn->message, "Final confirmation for outgoing manual key verification %s", message); cn->which_payload_variant = meshtastic_ClientNotification_key_verification_final_tag; cn->payload_variant.key_verification_final.nonce = currentNonce; - strncpy(cn->payload_variant.key_verification_final.remote_longname, // should really check for nulls, etc - nodeDB->getMeshNode(currentRemoteNode)->user.long_name, - sizeof(cn->payload_variant.key_verification_final.remote_longname)); + copyNodeLongNameOrUnknown(cn->payload_variant.key_verification_final.remote_longname, + sizeof(cn->payload_variant.key_verification_final.remote_longname), + nodeDB->getMeshNode(currentRemoteNode)); cn->payload_variant.key_verification_final.isSender = true; service->sendClientNotification(cn); LOG_INFO(message); @@ -310,4 +324,4 @@ void KeyVerificationModule::generateVerificationCode(char *readableCode) readableCode[i] = (hash1[i] >> 2) + 48; // not a standardized base64, but workable and avoids having a dictionary. } } -#endif \ No newline at end of file +#endif diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index ac81e9c577b..c13904c311b 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -183,8 +183,7 @@ meshtastic_MeshPacket *PositionModule::allocPositionPacket() return nullptr; } - meshtastic_NodeInfoLite *node = service->refreshLocalMeshNode(); // should guarantee there is now a position - assert(node->has_position); + const meshtastic_NodeInfoLite *node = service->refreshLocalMeshNode(); // should guarantee there is now a position // configuration of POSITION packet // consider making this a function argument? @@ -194,7 +193,9 @@ meshtastic_MeshPacket *PositionModule::allocPositionPacket() meshtastic_Position p = meshtastic_Position_init_default; // Start with an empty structure // if localPosition is totally empty, put our last saved position (lite) in there if (localPosition.latitude_i == 0 && localPosition.longitude_i == 0) { - nodeDB->setLocalPosition(TypeConversions::ConvertToPosition(node->position)); + meshtastic_PositionLite cachedSelf; + if (nodeDB->copyNodePosition(node->num, cachedSelf)) + nodeDB->setLocalPosition(TypeConversions::ConvertToPosition(cachedSelf)); } localPosition.seq_number++; @@ -420,7 +421,7 @@ int32_t PositionModule::runOnce() doDeepSleep(nightyNightMs, false, false); } - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); if (node == nullptr) return RUNONCE_INTERVAL; @@ -445,8 +446,11 @@ int32_t PositionModule::runOnce() } else if (nodeDB->hasValidPosition(node)) { lastGpsSend = now; - lastGpsLatitude = node->position.latitude_i; - lastGpsLongitude = node->position.longitude_i; + meshtastic_PositionLite selfPos; + if (nodeDB->copyNodePosition(node->num, selfPos)) { + lastGpsLatitude = selfPos.latitude_i; + lastGpsLongitude = selfPos.longitude_i; + } if (transmitHistory) transmitHistory->setLastSentToMesh(meshtastic_PortNum_POSITION_APP); @@ -461,7 +465,10 @@ int32_t PositionModule::runOnce() if (nodeDB->hasValidPosition(node2)) { // The minimum time (in seconds) that would pass before we are able to send a new position packet. - auto smartPosition = getDistanceTraveledSinceLastSend(node->position); + meshtastic_PositionLite selfPos; + if (!nodeDB->copyNodePosition(node->num, selfPos)) + return RUNONCE_INTERVAL; // Defensive: hasValidPosition should imply this is non-null + auto smartPosition = getDistanceTraveledSinceLastSend(selfPos); msSinceLastSend = now - lastGpsSend; if (smartPosition.hasTraveledOverThreshold && @@ -479,8 +486,8 @@ int32_t PositionModule::runOnce() msSinceLastSend, minimumTimeThreshold); // Set the current coords as our last ones, after we've compared distance with current and decided to send - lastGpsLatitude = node->position.latitude_i; - lastGpsLongitude = node->position.longitude_i; + lastGpsLatitude = selfPos.latitude_i; + lastGpsLongitude = selfPos.longitude_i; } } } @@ -563,11 +570,14 @@ struct SmartPosition PositionModule::getDistanceTraveledSinceLastSend(meshtastic void PositionModule::handleNewPosition() { - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); const meshtastic_NodeInfoLite *node2 = service->refreshLocalMeshNode(); // should guarantee there is now a position // We limit our GPS broadcasts to a max rate if (nodeDB->hasValidPosition(node2)) { - auto smartPosition = getDistanceTraveledSinceLastSend(node->position); + meshtastic_PositionLite selfPos; + if (!nodeDB->copyNodePosition(node->num, selfPos)) + return; + auto smartPosition = getDistanceTraveledSinceLastSend(selfPos); uint32_t msSinceLastSend = millis() - lastGpsSend; if (smartPosition.hasTraveledOverThreshold && Throttle::execute( @@ -583,8 +593,8 @@ void PositionModule::handleNewPosition() minimumTimeThreshold); // Set the current coords as our last ones, after we've compared distance with current and decided to send - lastGpsLatitude = node->position.latitude_i; - lastGpsLongitude = node->position.longitude_i; + lastGpsLatitude = selfPos.latitude_i; + lastGpsLongitude = selfPos.longitude_i; } } } diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp index 026b3028db7..9d3c3ffba15 100644 --- a/src/modules/RangeTestModule.cpp +++ b/src/modules/RangeTestModule.cpp @@ -268,26 +268,36 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) fileToAppend.printf("??:??:??,"); // Time } - fileToAppend.printf("%d,", getFrom(&mp)); // From - fileToAppend.printf("%s,", n->user.long_name); // Long Name - fileToAppend.printf("%f,", n->position.latitude_i * 1e-7); // Sender Lat - fileToAppend.printf("%f,", n->position.longitude_i * 1e-7); // Sender Long + fileToAppend.printf("%d,", getFrom(&mp)); // From + fileToAppend.printf("%s,", n ? n->long_name : ""); // Long Name + meshtastic_PositionLite senderPos; + const bool haveSenderPos = nodeDB->copyNodePosition(getFrom(&mp), senderPos); + if (haveSenderPos) { + fileToAppend.printf("%f,", senderPos.latitude_i * 1e-7); // Sender Lat + fileToAppend.printf("%f,", senderPos.longitude_i * 1e-7); // Sender Long + } else { + fileToAppend.printf("0.0,0.0,"); + } if (gpsStatus->getIsConnected() || config.position.fixed_position) { fileToAppend.printf("%f,", gpsStatus->getLatitude() * 1e-7); // RX Lat fileToAppend.printf("%f,", gpsStatus->getLongitude() * 1e-7); // RX Long fileToAppend.printf("%d,", gpsStatus->getAltitude()); // RX Altitude } else { // When the phone API is in use, the node info will be updated with position - meshtastic_NodeInfoLite *us = nodeDB->getMeshNode(nodeDB->getNodeNum()); - fileToAppend.printf("%f,", us->position.latitude_i * 1e-7); // RX Lat - fileToAppend.printf("%f,", us->position.longitude_i * 1e-7); // RX Long - fileToAppend.printf("%d,", us->position.altitude); // RX Altitude + meshtastic_PositionLite usPos; + if (nodeDB->copyNodePosition(nodeDB->getNodeNum(), usPos)) { + fileToAppend.printf("%f,", usPos.latitude_i * 1e-7); // RX Lat + fileToAppend.printf("%f,", usPos.longitude_i * 1e-7); // RX Long + fileToAppend.printf("%d,", usPos.altitude); // RX Altitude + } else { + fileToAppend.printf("0.0,0.0,0,"); + } } fileToAppend.printf("%f,", mp.rx_snr); // RX SNR - if (n->position.latitude_i && n->position.longitude_i && gpsStatus->getLatitude() && gpsStatus->getLongitude()) { - float distance = GeoCoord::latLongToMeter(n->position.latitude_i * 1e-7, n->position.longitude_i * 1e-7, + if (haveSenderPos && senderPos.latitude_i && senderPos.longitude_i && gpsStatus->getLatitude() && gpsStatus->getLongitude()) { + float distance = GeoCoord::latLongToMeter(senderPos.latitude_i * 1e-7, senderPos.longitude_i * 1e-7, gpsStatus->getLatitude() * 1e-7, gpsStatus->getLongitude() * 1e-7); fileToAppend.printf("%f,", distance); // Distance in meters } else { @@ -340,4 +350,4 @@ bool RangeTestModuleRadio::removeFile() return 0; #endif -} \ No newline at end of file +} diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp index 85e7f8c064a..a46323ac601 100644 --- a/src/modules/RoutingModule.cpp +++ b/src/modules/RoutingModule.cpp @@ -17,8 +17,7 @@ bool RoutingModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mesh config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY)) { if (!maybePKI) return false; - if ((nodeDB->getMeshNode(mp.from) == NULL || !nodeDB->getMeshNode(mp.from)->has_user) && - (nodeDB->getMeshNode(mp.to) == NULL || !nodeDB->getMeshNode(mp.to)->has_user)) + if (!nodeInfoLiteHasUser(nodeDB->getMeshNode(mp.from)) && !nodeInfoLiteHasUser(nodeDB->getMeshNode(mp.to))) return false; } else if (owner.is_licensed && ((nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) || (nodeDB->getLicenseStatus(mp.to) == UserLicenseStatus::NotLicensed))) { diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index 20d4d7d8c7e..0266bcec508 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -250,9 +250,12 @@ int32_t SerialModule::runOnce() uint32_t readIndex = 0; const meshtastic_NodeInfoLite *tempNodeInfo = nodeDB->readNextMeshNode(readIndex); while (tempNodeInfo != NULL) { - if (tempNodeInfo->has_user && nodeDB->hasValidPosition(tempNodeInfo)) { - printWPL(outbuf, sizeof(outbuf), tempNodeInfo->position, tempNodeInfo->user.long_name, true); - serialPrint->printf("%s", outbuf); + if (nodeInfoLiteHasUser(tempNodeInfo) && nodeDB->hasValidPosition(tempNodeInfo)) { + meshtastic_PositionLite pos; + if (nodeDB->copyNodePosition(tempNodeInfo->num, pos)) { + printWPL(outbuf, sizeof(outbuf), pos, tempNodeInfo->long_name, true); + serialPrint->printf("%s", outbuf); + } } tempNodeInfo = nodeDB->readNextMeshNode(readIndex); } @@ -401,7 +404,7 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp serialPrint->write(p.payload.bytes, p.payload.size); } else if (moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_TEXTMSG) { meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp)); - const char *sender = (node && node->has_user) ? node->user.short_name : "???"; + const char *sender = nodeInfoLiteHasUser(node) ? node->short_name : "???"; serialPrint->println(); serialPrint->printf("%s: %s", sender, p.payload.bytes); serialPrint->println(); @@ -417,9 +420,13 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp decoded = &scratch; } // send position packet as WPL to the serial port - printWPL(outbuf, sizeof(outbuf), *decoded, nodeDB->getMeshNode(getFrom(&mp))->user.long_name, - moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO); - serialPrint->printf("%s", outbuf); + { + meshtastic_NodeInfoLite *senderNode = nodeDB->getMeshNode(getFrom(&mp)); + const char *senderName = senderNode ? senderNode->long_name : ""; + printWPL(outbuf, sizeof(outbuf), *decoded, senderName, + moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO); + serialPrint->printf("%s", outbuf); + } } } } @@ -714,4 +721,4 @@ void SerialModule::processWXSerial() #endif return; } -#endif \ No newline at end of file +#endif diff --git a/src/modules/StatusMessageModule.cpp b/src/modules/StatusMessageModule.cpp index 0707a4f7d67..2f05517f331 100644 --- a/src/modules/StatusMessageModule.cpp +++ b/src/modules/StatusMessageModule.cpp @@ -2,6 +2,7 @@ #include "StatusMessageModule.h" #include "MeshService.h" +#include "NodeDB.h" #include "ProtobufModule.h" StatusMessageModule *statusMessageModule; @@ -36,16 +37,8 @@ ProcessMessage StatusMessageModule::handleReceived(const meshtastic_MeshPacket & LOG_INFO("Received a NodeStatus message %s", incomingMessage.status); - RecentStatus entry; - entry.fromNodeId = mp.from; - entry.statusText = incomingMessage.status; - - recentReceived.push_back(std::move(entry)); - - // Keep only last MAX_RECENT_STATUSMESSAGES - if (recentReceived.size() > MAX_RECENT_STATUSMESSAGES) { - recentReceived.erase(recentReceived.begin()); // drop oldest - } + if (nodeDB) + nodeDB->setNodeStatus(mp.from, incomingMessage); } } return ProcessMessage::CONTINUE; diff --git a/src/modules/StatusMessageModule.h b/src/modules/StatusMessageModule.h index 5090066e6dc..2238a8437a8 100644 --- a/src/modules/StatusMessageModule.h +++ b/src/modules/StatusMessageModule.h @@ -2,8 +2,6 @@ #if !MESHTASTIC_EXCLUDE_STATUS #include "SinglePortModule.h" #include "configuration.h" -#include -#include class StatusMessageModule : public SinglePortModule, private concurrency::OSThread { @@ -20,29 +18,16 @@ class StatusMessageModule : public SinglePortModule, private concurrency::OSThre this->setInterval(1000 * 12 * 60 * 60); } // TODO: If we have a string, set the initial delay (15 minutes maybe) - - // Keep vector from reallocating as we fill up to MAX_RECENT_STATUSMESSAGES - recentReceived.reserve(MAX_RECENT_STATUSMESSAGES); } virtual int32_t runOnce() override; - struct RecentStatus { - uint32_t fromNodeId; // mp.from - std::string statusText; // incomingMessage.status - }; - - const std::vector &getRecentReceived() const { return recentReceived; } - protected: - /** Called to handle a particular incoming message + /** Called to handle a particular incoming message. The cached most-recent + * status for each node lives on NodeDB; use nodeDB->copyNodeStatus(num, ...). */ virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; - - private: - static constexpr size_t MAX_RECENT_STATUSMESSAGES = 5; - std::vector recentReceived; }; extern StatusMessageModule *statusMessageModule; -#endif \ No newline at end of file +#endif diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 684d408a1cc..04a5370de4d 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -515,6 +515,10 @@ bool EnvironmentTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPac packetPool.release(lastMeasurementPacket); lastMeasurementPacket = packetPool.allocCopy(mp); + + // Cache the latest env metrics per node on NodeDB so the phone can + // pull last-known values across reboots and replays. + nodeDB->updateTelemetry(getFrom(&mp), *t, RX_SRC_RADIO); } return false; // Let others look at this message also if they want diff --git a/src/modules/TraceRouteModule.cpp b/src/modules/TraceRouteModule.cpp index 3371c405dd5..b6cb5d04181 100644 --- a/src/modules/TraceRouteModule.cpp +++ b/src/modules/TraceRouteModule.cpp @@ -492,12 +492,12 @@ TraceRouteModule::TraceRouteModule() const char *TraceRouteModule::getNodeName(NodeNum node) { meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node); - if (info && info->has_user) { - if (strlen(info->user.short_name) > 0) { - return info->user.short_name; + if (nodeInfoLiteHasUser(info)) { + if (strlen(info->short_name) > 0) { + return info->short_name; } - if (strlen(info->user.long_name) > 0) { - return info->user.long_name; + if (strlen(info->long_name) > 0) { + return info->long_name; } } diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 1ecb68c4b49..4c73c2947c5 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -1226,9 +1226,9 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // Fallback only when PSRAM cache is unavailable on this target. // In this mode we use the node-wide table maintained by NodeInfoModule. const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); - if (!node || !node->has_user) + if (!nodeInfoLiteHasUser(node)) return false; - cachedUser = TypeConversions::ConvertToUser(node->num, node->user); + cachedUser = TypeConversions::ConvertToUser(node); } if (!sendResponse) diff --git a/src/modules/WaypointModule.cpp b/src/modules/WaypointModule.cpp index 632727b9240..ca96eaa7ad1 100644 --- a/src/modules/WaypointModule.cpp +++ b/src/modules/WaypointModule.cpp @@ -100,7 +100,7 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, char distStr[20] = ""; // Get our node, to use our own position - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); // Match compass sizing/placement to favorite node screen logic. const int w = display->getWidth(); @@ -147,8 +147,10 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, const char *statusLine2 = nullptr; // Distance only needs our own position fix; compass/bearing additionally needs heading. - if (hasOwnPositionFix) { - const meshtastic_PositionLite &op = ourNode->position; + meshtastic_PositionLite ownPos; + const bool haveOwnPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ownPos); + if (hasOwnPositionFix && haveOwnPos) { + const meshtastic_PositionLite &op = ownPos; const float d = GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 3e477cae558..bd02ac0458b 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -141,7 +141,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) const meshtastic_NodeInfoLite *rx = nodeDB->getMeshNode(p->to); // Only accept PKI messages to us, or if we have both the sender and receiver in our nodeDB, as then it's // likely they discovered each other via a channel we have downlink enabled for - if (isToUs(p.get()) || (tx && tx->has_user && rx && rx->has_user)) + if (isToUs(p.get()) || (nodeInfoLiteHasUser(tx) && nodeInfoLiteHasUser(rx))) router->enqueueReceivedMessage(p.release()); } else if (router && perhapsDecode(p.get()) == DecodeState::DECODE_SUCCESS) // ignore messages if we don't have the channel key diff --git a/src/serialization/MeshPacketSerializer.cpp b/src/serialization/MeshPacketSerializer.cpp index 819ba3da513..36ed4b507b7 100644 --- a/src/serialization/MeshPacketSerializer.cpp +++ b/src/serialization/MeshPacketSerializer.cpp @@ -319,10 +319,14 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, // Lambda function for adding a long name to the route auto addToRoute = [](JSONArray *route, NodeNum num) { char long_name[40] = "Unknown"; - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num); - bool name_known = node ? node->has_user : false; - if (name_known) - memcpy(long_name, node->user.long_name, sizeof(long_name)); + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num); + bool name_known = nodeInfoLiteHasUser(node); + if (name_known) { + const size_t copy_len = + (sizeof(node->long_name) < sizeof(long_name)) ? sizeof(node->long_name) : sizeof(long_name) - 1; + memcpy(long_name, node->long_name, copy_len); + long_name[copy_len] = '\0'; + } route->push_back(new JSONValue(long_name)); }; addToRoute(&route, mp->to); // Started at the original transmitter (destination of response) @@ -473,4 +477,4 @@ std::string MeshPacketSerializer::JsonSerializeEncrypted(const meshtastic_MeshPa delete value; return jsonStr; } -#endif \ No newline at end of file +#endif diff --git a/src/serialization/MeshPacketSerializer_nRF52.cpp b/src/serialization/MeshPacketSerializer_nRF52.cpp index bd0a29c51b0..c79b3d26931 100644 --- a/src/serialization/MeshPacketSerializer_nRF52.cpp +++ b/src/serialization/MeshPacketSerializer_nRF52.cpp @@ -292,9 +292,13 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, auto addToRoute = [](JsonArray *route, NodeNum num) { char long_name[40] = "Unknown"; meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num); - bool name_known = node ? node->has_user : false; - if (name_known) - memcpy(long_name, node->user.long_name, sizeof(long_name)); + bool name_known = nodeInfoLiteHasUser(node); + if (name_known) { + const size_t copy_len = + (sizeof(node->long_name) < sizeof(long_name)) ? sizeof(node->long_name) : sizeof(long_name) - 1; + memcpy(long_name, node->long_name, copy_len); + long_name[copy_len] = '\0'; + } route->add(long_name); }; @@ -418,4 +422,4 @@ std::string MeshPacketSerializer::JsonSerializeEncrypted(const meshtastic_MeshPa return jsonStr; } -#endif \ No newline at end of file +#endif diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index 36dc37b9dd1..15f372398d8 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -113,7 +113,7 @@ void test_DH25519(void) void test_PKC(void) { uint8_t private_key[32]; - meshtastic_UserLite_public_key_t public_key; + meshtastic_NodeInfoLite_public_key_t public_key; uint8_t expected_shared[32]; uint8_t expected_decrypted[32]; uint8_t radioBytes[128] __attribute__((__aligned__)); diff --git a/test/test_packet_history/test_main.cpp b/test/test_packet_history/test_main.cpp index 2453956c5f9..4f4782ab6be 100644 --- a/test/test_packet_history/test_main.cpp +++ b/test/test_packet_history/test_main.cpp @@ -641,30 +641,12 @@ void test_sender_zero_substituted(void) TEST_ASSERT_TRUE(ph->wasSeenRecently(&p2, false)); } -void test_uninitialized_wasSeenRecently(void) -{ - // Simulate uninitialized state — create a PacketHistory that looks uninitialized - // We can't easily make allocation fail, but we can test the initOk guard with a destructed one - PacketHistory h(4); - TEST_ASSERT_TRUE(h.initOk()); // sanity check - h.~PacketHistory(); - - auto p = makePacket(0x1111, 100); - TEST_ASSERT_FALSE(h.wasSeenRecently(&p)); - - // Reconstruct in place to allow proper destruction - new (&h) PacketHistory(4); -} - -void test_uninitialized_wasRelayer(void) -{ - PacketHistory h(4); - h.~PacketHistory(); - - TEST_ASSERT_FALSE(h.wasRelayer(0xAA, 100, 0x1111)); - - new (&h) PacketHistory(4); -} +// NOTE: Tests for the !initOk() short-circuit in wasSeenRecently / wasRelayer +// were removed: the only way to drive that branch from a test is to destruct +// the object and then call methods on it, which is UB and trips ASAN under +// `pio test -e coverage`. In practice the guard can only fire if `new[]` throws +// (in which case the constructor doesn't return), so the lost coverage is +// purely defensive. void test_multiple_instances_independent(void) { @@ -819,8 +801,6 @@ void setup() // Group 10 — Edge Cases RUN_TEST(test_packet_id_zero_not_stored); RUN_TEST(test_sender_zero_substituted); - RUN_TEST(test_uninitialized_wasSeenRecently); - RUN_TEST(test_uninitialized_wasRelayer); RUN_TEST(test_multiple_instances_independent); // Group 11 — Hash Table Stress diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index ec54f23122a..734b4a9702a 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -50,7 +50,7 @@ class MockNodeDB : public NodeDB hasCachedNode = true; cachedNodeNum = n; cachedNode.num = n; - cachedNode.has_user = true; + cachedNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; } private: @@ -494,9 +494,9 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); TEST_ASSERT_NOT_NULL(requestor); - TEST_ASSERT_TRUE(requestor->has_user); - TEST_ASSERT_EQUAL_STRING("requester-long", requestor->user.long_name); - TEST_ASSERT_EQUAL_STRING("rq", requestor->user.short_name); + TEST_ASSERT_TRUE((requestor->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK) != 0); + TEST_ASSERT_EQUAL_STRING("requester-long", requestor->long_name); + TEST_ASSERT_EQUAL_STRING("rq", requestor->short_name); TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel); } diff --git a/test/test_type_conversions/test_main.cpp b/test/test_type_conversions/test_main.cpp new file mode 100644 index 00000000000..a02d9bdf818 --- /dev/null +++ b/test/test_type_conversions/test_main.cpp @@ -0,0 +1,406 @@ +// Tests for src/mesh/TypeConversions.cpp covering: +// - bitfield bit collapse on store + extraction round-trip +// - long_name / short_name truncation at the new max_size:25 / 5 boundaries +// - public_key / hw_model / role pass-through +// - thin vs bundled NodeInfo emission +// +// All exercised via the explicit-args overload of ConvertToNodeInfo so we don't +// touch the global nodeDB pointer (which isn't initialized in this test env). + +#include "NodeDB.h" +#include "TestUtil.h" +#include "TypeConversions.h" +#include +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +// ---------- helpers ----------------------------------------------------------- + +static meshtastic_User makeUser(const char *longName, const char *shortName) +{ + meshtastic_User u = meshtastic_User_init_zero; + if (longName) + strncpy(u.long_name, longName, sizeof(u.long_name) - 1); + if (shortName) + strncpy(u.short_name, shortName, sizeof(u.short_name) - 1); + u.hw_model = meshtastic_HardwareModel_TBEAM; + u.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + return u; +} + +// ---------- has_user / id / macaddr ------------------------------------------ + +void test_copy_user_sets_has_user_bit(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User u = makeUser("Kevin Hester", "kh"); + TEST_ASSERT_FALSE((lite.bitfield & NODEINFO_BITFIELD_HAS_USER_MASK) != 0); + TypeConversions::CopyUserToNodeInfoLite(&lite, u); + TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_HAS_USER_MASK) != 0); +} + +void test_convert_to_user_id_derived_from_nodenum(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + lite.num = 0x12345678; + meshtastic_User u = TypeConversions::ConvertToUser(&lite); + TEST_ASSERT_EQUAL_STRING("!12345678", u.id); +} + +void test_convert_to_user_zero_fills_macaddr(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + lite.num = 0xCAFEBABE; + meshtastic_User u = TypeConversions::ConvertToUser(&lite); + uint8_t zeros[sizeof(u.macaddr)] = {0}; + TEST_ASSERT_EQUAL_MEMORY(zeros, u.macaddr, sizeof(u.macaddr)); +} + +// ---------- long_name truncation --------------------------------------------- + +void test_long_name_short_passes_through(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User u = makeUser("Kevin Hester", "kh"); // 12 chars, fits + TypeConversions::CopyUserToNodeInfoLite(&lite, u); + TEST_ASSERT_EQUAL_STRING("Kevin Hester", lite.long_name); +} + +void test_long_name_exact_24_fits(void) +{ + // 24 chars -> stored as 24 chars + NUL inside char[25]. + const char *exact24 = "abcdefghijklmnopqrstuvwx"; + TEST_ASSERT_EQUAL_INT(24, (int)strlen(exact24)); + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User u = makeUser(exact24, "ex"); + TypeConversions::CopyUserToNodeInfoLite(&lite, u); + TEST_ASSERT_EQUAL_STRING(exact24, lite.long_name); + TEST_ASSERT_EQUAL_INT(24, (int)strlen(lite.long_name)); +} + +void test_long_name_truncates_when_too_long(void) +{ + // 33 chars in, must fit in 24 + NUL. + const char *tooLong = "North-County Search & Rescue Base"; + TEST_ASSERT_EQUAL_INT(33, (int)strlen(tooLong)); + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User u = makeUser(tooLong, "nc"); + TypeConversions::CopyUserToNodeInfoLite(&lite, u); + TEST_ASSERT_EQUAL_INT(24, (int)strlen(lite.long_name)); + TEST_ASSERT_EQUAL_STRING_LEN(tooLong, lite.long_name, 24); + TEST_ASSERT_EQUAL_INT('\0', lite.long_name[24]); +} + +void test_long_name_round_trip_to_wire(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User in = makeUser("Mountain Repeater Site E", "mr"); // exactly 24 + TypeConversions::CopyUserToNodeInfoLite(&lite, in); + meshtastic_User out = TypeConversions::ConvertToUser(&lite); + TEST_ASSERT_EQUAL_STRING(in.long_name, out.long_name); +} + +void test_long_name_truncated_utf8_boundary_sanitized(void) +{ + // Suffix the 24th byte with the start of a 4-byte emoji; truncation should + // leave the dangling bytes for sanitizeUtf8 to replace with '?'. + char input[40] = {0}; + memset(input, 'a', 22); // 22 ASCII + input[22] = static_cast(0xF0); // emoji lead byte at position 22 + input[23] = static_cast(0x9F); // continuation + input[24] = static_cast(0xA4); // continuation - past the cap + input[25] = static_cast(0x96); // continuation - past the cap + input[26] = '\0'; + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User u = makeUser(input, "u8"); + TypeConversions::CopyUserToNodeInfoLite(&lite, u); + // 24 bytes survive plus NUL. sanitizeUtf8 should have turned the dangling + // multi-byte head into '?' since its continuation bytes were chopped. + TEST_ASSERT_EQUAL_INT(24, (int)strlen(lite.long_name)); + for (int i = 0; i < 22; ++i) + TEST_ASSERT_EQUAL_INT('a', lite.long_name[i]); + // The 4-byte sequence got truncated mid-codepoint; sanitizeUtf8 replaces + // any invalid lead/continuation byte with '?'. + TEST_ASSERT_EQUAL_INT('?', lite.long_name[22]); + TEST_ASSERT_EQUAL_INT('?', lite.long_name[23]); +} + +// ---------- short_name truncation -------------------------------------------- + +void test_short_name_passes_through(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User u = makeUser("Test", "abcd"); // 4 chars, fits short_name[5] + TypeConversions::CopyUserToNodeInfoLite(&lite, u); + TEST_ASSERT_EQUAL_STRING("abcd", lite.short_name); +} + +void test_short_name_truncates_when_too_long(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User u = makeUser("Test", "abcdefgh"); + TypeConversions::CopyUserToNodeInfoLite(&lite, u); + TEST_ASSERT_EQUAL_INT(4, (int)strlen(lite.short_name)); + TEST_ASSERT_EQUAL_INT('\0', lite.short_name[4]); +} + +// ---------- bitfield collapse + round-trip per bool -------------------------- + +void test_bitfield_is_licensed_round_trip(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User in = makeUser("a", "a"); + in.is_licensed = true; + TypeConversions::CopyUserToNodeInfoLite(&lite, in); + TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_LICENSED_MASK) != 0); + meshtastic_User out = TypeConversions::ConvertToUser(&lite); + TEST_ASSERT_TRUE(out.is_licensed); + + in.is_licensed = false; + meshtastic_NodeInfoLite lite2 = meshtastic_NodeInfoLite_init_default; + TypeConversions::CopyUserToNodeInfoLite(&lite2, in); + TEST_ASSERT_FALSE((lite2.bitfield & NODEINFO_BITFIELD_IS_LICENSED_MASK) != 0); + meshtastic_User out2 = TypeConversions::ConvertToUser(&lite2); + TEST_ASSERT_FALSE(out2.is_licensed); +} + +void test_bitfield_unmessagable_present_and_true(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User in = makeUser("a", "a"); + in.has_is_unmessagable = true; + in.is_unmessagable = true; + TypeConversions::CopyUserToNodeInfoLite(&lite, in); + TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK) != 0); + TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK) != 0); + meshtastic_User out = TypeConversions::ConvertToUser(&lite); + TEST_ASSERT_TRUE(out.has_is_unmessagable); + TEST_ASSERT_TRUE(out.is_unmessagable); +} + +void test_bitfield_unmessagable_present_but_false(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User in = makeUser("a", "a"); + in.has_is_unmessagable = true; + in.is_unmessagable = false; + TypeConversions::CopyUserToNodeInfoLite(&lite, in); + TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK) != 0); + TEST_ASSERT_FALSE((lite.bitfield & NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK) != 0); + meshtastic_User out = TypeConversions::ConvertToUser(&lite); + TEST_ASSERT_TRUE(out.has_is_unmessagable); + TEST_ASSERT_FALSE(out.is_unmessagable); +} + +void test_bitfield_unmessagable_absent(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User in = makeUser("a", "a"); + in.has_is_unmessagable = false; + in.is_unmessagable = true; // explicitly true to make sure absence still wins + TypeConversions::CopyUserToNodeInfoLite(&lite, in); + TEST_ASSERT_FALSE((lite.bitfield & NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK) != 0); + TEST_ASSERT_FALSE((lite.bitfield & NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK) != 0); + meshtastic_User out = TypeConversions::ConvertToUser(&lite); + TEST_ASSERT_FALSE(out.has_is_unmessagable); + TEST_ASSERT_FALSE(out.is_unmessagable); +} + +void test_copy_user_preserves_unrelated_bits(void) +{ + // Pre-set is_muted and is_key_manually_verified - CopyUserToNodeInfoLite + // must not stomp them. + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + lite.bitfield = NODEINFO_BITFIELD_IS_MUTED_MASK | NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK | + NODEINFO_BITFIELD_VIA_MQTT_MASK | NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK; + meshtastic_User in = makeUser("a", "a"); + TypeConversions::CopyUserToNodeInfoLite(&lite, in); + TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0); + TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK) != 0); + TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_VIA_MQTT_MASK) != 0); + TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_FAVORITE_MASK) != 0); + TEST_ASSERT_TRUE((lite.bitfield & NODEINFO_BITFIELD_IS_IGNORED_MASK) != 0); +} + +void test_bitfield_bits_are_independent(void) +{ + // Set just is_licensed, verify only that bit (and HAS_USER) is set. + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User in = makeUser("a", "a"); + in.is_licensed = true; + TypeConversions::CopyUserToNodeInfoLite(&lite, in); + const uint32_t expected = NODEINFO_BITFIELD_HAS_USER_MASK | NODEINFO_BITFIELD_IS_LICENSED_MASK; + TEST_ASSERT_EQUAL_HEX32(expected, lite.bitfield); +} + +// ---------- public_key / hw_model / role pass-through ------------------------ + +void test_public_key_round_trip(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User in = makeUser("a", "a"); + in.public_key.size = 32; + for (int i = 0; i < 32; ++i) + in.public_key.bytes[i] = (uint8_t)(i ^ 0xA5); + TypeConversions::CopyUserToNodeInfoLite(&lite, in); + TEST_ASSERT_EQUAL_INT(32, lite.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(in.public_key.bytes, lite.public_key.bytes, 32); + meshtastic_User out = TypeConversions::ConvertToUser(&lite); + TEST_ASSERT_EQUAL_INT(32, out.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(in.public_key.bytes, out.public_key.bytes, 32); +} + +void test_hw_model_and_role_round_trip(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + meshtastic_User in = makeUser("a", "a"); + in.hw_model = meshtastic_HardwareModel_HELTEC_V3; + in.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + TypeConversions::CopyUserToNodeInfoLite(&lite, in); + TEST_ASSERT_EQUAL_INT(meshtastic_HardwareModel_HELTEC_V3, lite.hw_model); + TEST_ASSERT_EQUAL_INT(meshtastic_Config_DeviceConfig_Role_ROUTER, lite.role); + meshtastic_User out = TypeConversions::ConvertToUser(&lite); + TEST_ASSERT_EQUAL_INT(meshtastic_HardwareModel_HELTEC_V3, out.hw_model); + TEST_ASSERT_EQUAL_INT(meshtastic_Config_DeviceConfig_Role_ROUTER, out.role); +} + +// ---------- ConvertToNodeInfo (3-arg) ---------------------------------------- + +void test_convert_to_node_info_thin_omits_position_and_metrics(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + lite.num = 0xAA; + meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfoThin(&lite); + TEST_ASSERT_FALSE(info.has_position); + TEST_ASSERT_FALSE(info.has_device_metrics); +} + +void test_convert_to_node_info_3arg_with_position(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + lite.num = 0xAA; + meshtastic_PositionLite pos = meshtastic_PositionLite_init_default; + pos.latitude_i = 374200000; + pos.longitude_i = -1221000000; + pos.altitude = 30; + pos.time = 12345; + meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, &pos, nullptr); + TEST_ASSERT_TRUE(info.has_position); + TEST_ASSERT_FALSE(info.has_device_metrics); + TEST_ASSERT_EQUAL_INT32(374200000, info.position.latitude_i); + TEST_ASSERT_EQUAL_INT32(-1221000000, info.position.longitude_i); + TEST_ASSERT_EQUAL_INT32(30, info.position.altitude); + TEST_ASSERT_EQUAL_UINT32(12345, info.position.time); +} + +void test_convert_to_node_info_3arg_with_metrics(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + lite.num = 0xAA; + meshtastic_DeviceMetrics dm = meshtastic_DeviceMetrics_init_default; + dm.battery_level = 88; + dm.has_battery_level = true; + dm.voltage = 3.71f; + dm.has_voltage = true; + meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, nullptr, &dm); + TEST_ASSERT_FALSE(info.has_position); + TEST_ASSERT_TRUE(info.has_device_metrics); + TEST_ASSERT_EQUAL_INT(88, info.device_metrics.battery_level); + TEST_ASSERT_TRUE(info.device_metrics.has_battery_level); + TEST_ASSERT_EQUAL_FLOAT(3.71f, info.device_metrics.voltage); +} + +void test_convert_to_node_info_3arg_null_inputs(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + lite.num = 0xAA; + meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, nullptr, nullptr); + TEST_ASSERT_FALSE(info.has_position); + TEST_ASSERT_FALSE(info.has_device_metrics); +} + +void test_convert_to_node_info_extracts_bitfield_bools(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + lite.num = 0xBB; + lite.bitfield = NODEINFO_BITFIELD_VIA_MQTT_MASK | NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK | + NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK | NODEINFO_BITFIELD_IS_MUTED_MASK; + meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, nullptr, nullptr); + TEST_ASSERT_TRUE(info.via_mqtt); + TEST_ASSERT_TRUE(info.is_favorite); + TEST_ASSERT_TRUE(info.is_ignored); + TEST_ASSERT_TRUE(info.is_key_manually_verified); + TEST_ASSERT_TRUE(info.is_muted); +} + +void test_convert_to_node_info_extracts_bitfield_bools_none_set(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + lite.num = 0xBB; + lite.bitfield = 0; + meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, nullptr, nullptr); + TEST_ASSERT_FALSE(info.via_mqtt); + TEST_ASSERT_FALSE(info.is_favorite); + TEST_ASSERT_FALSE(info.is_ignored); + TEST_ASSERT_FALSE(info.is_key_manually_verified); + TEST_ASSERT_FALSE(info.is_muted); +} + +void test_convert_to_node_info_user_only_when_has_user_bit_set(void) +{ + meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default; + lite.num = 0x01; + strcpy(lite.long_name, "Alpha"); + strcpy(lite.short_name, "A"); + // No HAS_USER bit -> user fields ignored on emit. + meshtastic_NodeInfo info = TypeConversions::ConvertToNodeInfo(&lite, nullptr, nullptr); + TEST_ASSERT_FALSE(info.has_user); + + lite.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; + meshtastic_NodeInfo info2 = TypeConversions::ConvertToNodeInfo(&lite, nullptr, nullptr); + TEST_ASSERT_TRUE(info2.has_user); + TEST_ASSERT_EQUAL_STRING("Alpha", info2.user.long_name); + TEST_ASSERT_EQUAL_STRING("A", info2.user.short_name); + TEST_ASSERT_EQUAL_STRING("!00000001", info2.user.id); +} + +// ---------- entry point ------------------------------------------------------- + +void setup() +{ + delay(10); + delay(2000); + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_copy_user_sets_has_user_bit); + RUN_TEST(test_convert_to_user_id_derived_from_nodenum); + RUN_TEST(test_convert_to_user_zero_fills_macaddr); + RUN_TEST(test_long_name_short_passes_through); + RUN_TEST(test_long_name_exact_24_fits); + RUN_TEST(test_long_name_truncates_when_too_long); + RUN_TEST(test_long_name_round_trip_to_wire); + RUN_TEST(test_long_name_truncated_utf8_boundary_sanitized); + RUN_TEST(test_short_name_passes_through); + RUN_TEST(test_short_name_truncates_when_too_long); + RUN_TEST(test_bitfield_is_licensed_round_trip); + RUN_TEST(test_bitfield_unmessagable_present_and_true); + RUN_TEST(test_bitfield_unmessagable_present_but_false); + RUN_TEST(test_bitfield_unmessagable_absent); + RUN_TEST(test_copy_user_preserves_unrelated_bits); + RUN_TEST(test_bitfield_bits_are_independent); + RUN_TEST(test_public_key_round_trip); + RUN_TEST(test_hw_model_and_role_round_trip); + RUN_TEST(test_convert_to_node_info_thin_omits_position_and_metrics); + RUN_TEST(test_convert_to_node_info_3arg_with_position); + RUN_TEST(test_convert_to_node_info_3arg_with_metrics); + RUN_TEST(test_convert_to_node_info_3arg_null_inputs); + RUN_TEST(test_convert_to_node_info_extracts_bitfield_bools); + RUN_TEST(test_convert_to_node_info_extracts_bitfield_bools_none_set); + RUN_TEST(test_convert_to_node_info_user_only_when_has_user_bit_set); + exit(UNITY_END()); +} + +void loop() {} From f6a954b97e8bd45887267a56366f4418050bd484 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 10 May 2026 09:22:40 -0500 Subject: [PATCH 154/225] Implement rotating JSONL recorder for persistent logging (#10428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement rotating JSONL recorder for persistent logging * Fixes * Update documentation and clean up imports in command files * Address remaining recorder review feedback Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/2541773c-869a-463f-9fae-8505272c06ff Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * recorder: fix lock re-entry deadlock on start() and force_rotate_all() The previous "Fixes" commit added `_files_snapshot()` which acquires `self._lock` so handlers don't race with `stop()` clearing `_files`. But two callers were already holding `self._lock` when they invoked methods that go through the snapshot: - `start()` writes the `recorder_start` event from inside its `with self._lock:` block. `_write_event` -> `_files_snapshot` re-acquires the same non-reentrant `threading.Lock`, freezing process startup. - `force_rotate_all()` calls `self.status()` (which also acquires `self._lock`) while still holding the lock from rotating each file. Both fixes release the lock before the call. The recorder_start marker still lands in events.jsonl because the started/started_at flags are already set when we write it. Verified end-to-end against the standalone /tmp/verify_pr_fixes.py harness — all 9 PR review-comment fixes pass, including pause/resume event ordering and concurrent start/stop without KeyError. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix markdown linting issues in leakhunt.md and repro.md * Handle recorder startup and query review fixes Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Tighten recorder follow-up tests Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Stabilize recorder startup tests Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Remove brittle recorder startup test Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Polish recorder follow-up errors Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Refine recorder startup and regex errors Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Clean up recorder follow-up nits Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Trunk --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- .claude/commands/diagnose.md | 8 +- .claude/commands/leakhunt.md | 103 ++++ .claude/commands/repro.md | 4 + mcp-server/.gitignore | 6 + mcp-server/scripts/datadog-dashboard.json | 217 +++++++ mcp-server/scripts/mtlog_to_datadog.py | 389 +++++++++++++ mcp-server/src/meshtastic_mcp/flash.py | 45 +- mcp-server/src/meshtastic_mcp/log_query.py | 410 +++++++++++++ mcp-server/src/meshtastic_mcp/pio.py | 15 + .../src/meshtastic_mcp/recorder/__init__.py | 19 + .../src/meshtastic_mcp/recorder/parsers.py | 309 ++++++++++ .../src/meshtastic_mcp/recorder/recorder.py | 467 +++++++++++++++ .../src/meshtastic_mcp/recorder/rotating.py | 163 ++++++ .../src/meshtastic_mcp/serial_session.py | 28 +- mcp-server/src/meshtastic_mcp/server.py | 237 +++++++- mcp-server/tests/unit/test_build_flags.py | 88 +++ mcp-server/tests/unit/test_recorder.py | 548 ++++++++++++++++++ 17 files changed, 3049 insertions(+), 7 deletions(-) create mode 100644 .claude/commands/leakhunt.md create mode 100644 mcp-server/scripts/datadog-dashboard.json create mode 100755 mcp-server/scripts/mtlog_to_datadog.py create mode 100644 mcp-server/src/meshtastic_mcp/log_query.py create mode 100644 mcp-server/src/meshtastic_mcp/recorder/__init__.py create mode 100644 mcp-server/src/meshtastic_mcp/recorder/parsers.py create mode 100644 mcp-server/src/meshtastic_mcp/recorder/recorder.py create mode 100644 mcp-server/src/meshtastic_mcp/recorder/rotating.py create mode 100644 mcp-server/tests/unit/test_build_flags.py create mode 100644 mcp-server/tests/unit/test_recorder.py diff --git a/.claude/commands/diagnose.md b/.claude/commands/diagnose.md index 749668956b6..d664f631294 100644 --- a/.claude/commands/diagnose.md +++ b/.claude/commands/diagnose.md @@ -49,11 +49,17 @@ Call the meshtastic MCP tool bundle and format a structured health report for on - Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh) - Do the primary channel NAMES match? Mismatch = different PSK = no decode. -7. **Suggest next actions only for specific, recognisable failure modes**: +7. **Recorder slice (cheap, always available).** The mcp-server runs an autouse log recorder that's been collecting from every connected device. Pull two short slices to surface anything weird that's already happened: + - `mcp__meshtastic__logs_window(start="-2m", level="WARN|ERROR|CRIT", max_lines=20)` — recent firmware errors. If empty, say "no recent errors"; don't manufacture concern. + - `mcp__meshtastic__telemetry_timeline(window="1h", field="free_heap", max_points=60)` — heap trend. If `slope_per_min < -50`, flag it and recommend `/leakhunt window=6h` for a deeper read; otherwise just note the current free heap. + - If `recorder_status` shows `running:false` or `files.telemetry.last_ts` is null, note "recorder has no telemetry yet — enable `set_debug_log_api(True)` to populate" and skip this step gracefully. + +8. **Suggest next actions only for specific, recognisable failure modes**: - Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path." - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." - Device unreachable, reachable via DFU → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds AND the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`. - CP2102-wedged-driver on macOS → see the note in `run-tests.sh`. + - Heap slope strongly negative → "run `/leakhunt window=6h` for a full timeline + classification." ## What NOT to do diff --git a/.claude/commands/leakhunt.md b/.claude/commands/leakhunt.md new file mode 100644 index 00000000000..ef90b133e24 --- /dev/null +++ b/.claude/commands/leakhunt.md @@ -0,0 +1,103 @@ +--- +description: Hunt for memory leaks (and other slow degradations) by reading the persistent recorder's heap timeline + log slice over a window +argument-hint: [window=1h] [field=free_heap] [variant=local] +--- + + + +# `/leakhunt` — read the recorder, classify a memory leak + +Use the always-on recorder (`mcp-server/.mtlog/`) to read a heap timeline plus the matching log slice and produce a one-page verdict: **steady / slow leak / fragmentation / OOM-imminent**. No firmware changes, no special build flags — the LocalStats telemetry packet that the firmware already broadcasts every ~60 s carries `heap_free_bytes` and `heap_total_bytes`. + +## Two signal paths — pick the right one + +| Path | Build flag | Cadence | Per-thread attribution | Cost | +| --------------------- | ---------------- | -------------- | ---------------------- | ------------------------- | +| LocalStats packet | (default) | ~60 s | No | Free — always on | +| `[heap N]` log prefix | `-DDEBUG_HEAP=1` | every log line | Yes (Thread X leaked) | Bigger flash + log volume | + +Both feed the same `telemetry_timeline(field="free_heap")` query — when DEBUG_HEAP is on, the recorder synthesizes telemetry rows from log prefixes (tagged `source: debug_heap`), so a single timeline call gets whichever signal is available. **For a slow leak diagnosis, the default path is plenty** (60 s cadence over 6 h = 360 points; linear regression over that nails sub-100-byte/min slopes). **DEBUG_HEAP is for attribution** — when the slope is real and you need to know which thread is leaking. + +## What to do + +1. **Parse `$ARGUMENTS`**: optional `window` (default `1h`, accepts `30m`/`6h`/`-3d`/etc.), optional `field` (default `free_heap`; alternates: `total_heap`, `battery_level`, anything in the LocalStats variant), optional `variant` (default `local`; alternates: `device`, `environment`, `power`, `airQuality`, `health`). + +2. **Verify the recorder is alive** — call `mcp__meshtastic__recorder_status`. Check: + - `running == True` + - `files.telemetry.lines > 0` (at least one telemetry packet recorded — if zero, the device hasn't broadcast LocalStats yet OR `set_debug_log_api` has never been on; tell the operator to run `mcp__meshtastic__set_debug_log_api(enabled=True)` and wait one device-update interval) + - `files.telemetry.last_ts` within the last 5 minutes (if older, the device is silent — log that, not "leak detected") + +3. **Detect whether DEBUG_HEAP is active** — `mcp__meshtastic__logs_window(start="-2m", grep=r"\\[heap \\d+\\]", max_lines=3)`. If any line matches, the firmware has the prefix → DEBUG_HEAP is on, expect higher-cadence data and `heap_event` rows. If zero matches over the last 2 minutes, you're on the LocalStats-only path. + +4. **Pull the timeline** — `mcp__meshtastic__telemetry_timeline(window=$window, variant=$variant, field=$field, max_points=200)`. Read: + - `samples` — how many raw points contributed + - `min`, `max` — total swing + - `slope_per_min` — units per minute (linear regression over the whole window) + +5. **Pull the log context for the same window** — `mcp__meshtastic__logs_window(start="-${window}", grep="Heap status|leaked heap|freed heap|out of memory|Alloc an err|panic|abort", max_lines=200)`. These are the strings the firmware emits when something memory-related happens (`DEBUG_HEAP` builds emit `"Heap status:"` and `"leaked heap"` lines; production builds emit `"Alloc an err"` on failure and `"out of memory"` on OOM). + +6. **Pull marker events** so we know if the operator labeled phases — `mcp__meshtastic__events_window(start="-${window}", kind="mark|connection_lost|connection_established")`. If a `connection_lost` overlaps a sharp drop, that's not a leak; that's a reboot. + +6a. **(DEBUG_HEAP only) Per-thread attribution** — `mcp__meshtastic__logs_window(start="-${window}", grep="leaked heap", max_lines=200)`. Each row has a structured `heap_event` field with `{kind, thread, before, after, delta}`. Aggregate by thread: sum the `delta` over the window per thread name. The thread with the largest cumulative negative delta is your suspect. Note the count too — a thread with 50× small leaks is different from 1× big leak. + +7. **Classify** based on what the data says, NOT on what you wish it said. Use these rules in order: + - **Insufficient data** (< 5 samples): say so. Suggest a longer window or longer wait. Stop. + - **Reboot mid-window**: if any `connection_lost` event is present AND `free_heap` jumped UP at that timestamp, the device rebooted. Note it; pre-reboot trend may be a leak but you only have part of the curve. + - **OOM-imminent**: any `Alloc an err=` or `out of memory` line in the log slice. This trumps everything; flag urgently. + - **Slow leak**: `slope_per_min < -50` AND `max - min > 1000` AND no reboot. The heap is monotonically (or near-monotonically) declining. Estimate time-to-zero: `min / -slope_per_min` minutes. Surface it. + - **Fragmentation suspect**: `slope_per_min` close to zero (|x| < 50) BUT min trends down across the window AND the log slice shows `Alloc an err` warnings WITHOUT total OOM. Means free total is OK but largest contiguous block is shrinking. Recommend a `DEBUG_HEAP` build to confirm. + - **Steady**: |slope_per_min| < 50, no error lines. Heap is fine. + - **Recovery curve**: slope is POSITIVE — heap recovered. Either a workload completed or GC fired. Note it; not a leak. + +8. **Report**: + + ```text + /leakhunt window=6h field=free_heap variant=local + ──────────────────────────────────────────────────── + recorder : running, telem last_ts 8s ago + build : DEBUG_HEAP=ON (per-line prefix detected) + samples : 14,200 over 6h (cadence ~1.5s, log-line synth) + free_heap : min 92,344 / max 124,008 / range 31,664 + slope : -82 bytes/min (negative — heap declining) + reboots : none in window + OOM events : none + error lines : 3× "Alloc an err=ESP_ERR_NO_MEM" at +4h12m, +5h08m, +5h44m + thread leaks : (DEBUG_HEAP) MeshPacket -3,124 B over 18 events + Router -1,408 B over 4 events + others -240 B + verdict : SLOW LEAK — primary suspect MeshPacket thread + est. time-to-OOM: ~1,127 min (~18.8 h) at current slope + evidence : (3 log line citations with uptimes) + ``` + + Then: **what to do next.** + - SLOW LEAK, **DEBUG_HEAP off** → recommend rebuilding with the flag and re-running this skill. Concrete one-liner the operator can copy: + ```text + mcp__meshtastic__build(env="", build_flags={"DEBUG_HEAP": 1}) + mcp__meshtastic__pio_flash(env="", port="", confirm=True) + ``` + After flash, set debug_log_api back on and wait one window; re-run `/leakhunt`. + - SLOW LEAK, **DEBUG_HEAP on** → cite the top-leaking thread name from step 6a. Point at the corresponding source file (`grep -rn "ThreadName(\"\")" src/`); the operator decides what to fix. + - FRAGMENTATION SUSPECT → propose pre-allocating any per-packet buffers; or rebuilding with `CONFIG_HEAP_TASK_TRACKING=y` on ESP32 to see who's holding the largest blocks. + - OOM-IMMINENT → flag for immediate attention; don't wait for the next telemetry interval. + - STEADY → say so; stop. Don't invent problems. + +## What NOT to do + +- Don't assume a leak from a single dip. LocalStats fires every ~60 s and the firmware naturally allocates+frees on each broadcast cycle; one packet sees the trough. Look at the slope, not the deltas. +- Don't recommend code changes. This skill diagnoses; the operator decides what to fix. +- Don't enable `set_debug_log_api` automatically — if it's off, telemetry isn't reaching pubsub anyway, and the recorder will be empty. Tell the operator to flip it on and wait, then re-run. +- Don't run heavy workloads to "trigger the leak." The recorder is passive; we read what's there. + +## Companion: `mark_event` for stress runs + +If the operator wants to test under stimulus (e.g. blast 50 broadcasts and see what the heap does), they can frame the experiment with markers: + +```text +mark_event("burst-start") +… run the workload … +mark_event("burst-end") +/leakhunt window=15m +``` + +The markers land in both `events.jsonl` and `logs.jsonl`, so the report can show "free_heap dipped 8 KB during the burst window, recovered to baseline within 2 LocalStats cycles" → not a leak. diff --git a/.claude/commands/repro.md b/.claude/commands/repro.md index c5f466ce6d7..84513e45b18 100644 --- a/.claude/commands/repro.md +++ b/.claude/commands/repro.md @@ -3,6 +3,8 @@ description: Re-run a specific test N times in isolation to triage flakes, diff argument-hint: [count=5] --- + + # `/repro` — flakiness triage for one test Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y." @@ -40,6 +42,8 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs — pull specific lines with uptime timestamps. +5a. **Archive recorder slices per attempt** (no extra device interaction; the recorder runs autouse). Right after each attempt finishes, capture its `(start_ts, end_ts)` and call `mcp__meshtastic__recorder_export(start=, end=, dest_dir="mcp-server/tests/repro_artifacts//attempt_/")`. This drops a `logs.jsonl`, `telemetry.jsonl`, `packets.jsonl`, and `events.jsonl` snapshot scoped to the attempt window. Use these for cross-attempt diffs in step 5: `jq '.line' logs.jsonl` is faster than re-running the test, and the telemetry slice lets you compare heap behavior across attempts. + 6. **Classify the flake** into one of: - **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body. - **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this. diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore index 4cc892b2aca..744a4401de0 100644 --- a/mcp-server/.gitignore +++ b/mcp-server/.gitignore @@ -7,6 +7,12 @@ __pycache__/ dist/ build/ +# Persistent device-log capture (recorder + Datadog cursor). +# Cross-session JSONL streams written by the autouse Recorder singleton +# (see src/meshtastic_mcp/recorder/). Lives outside tests/ so the pytest +# fixture truncate doesn't touch it. +.mtlog/ + # Test harness artifacts tests/report.html tests/junit.xml diff --git a/mcp-server/scripts/datadog-dashboard.json b/mcp-server/scripts/datadog-dashboard.json new file mode 100644 index 00000000000..73aa3520132 --- /dev/null +++ b/mcp-server/scripts/datadog-dashboard.json @@ -0,0 +1,217 @@ +{ + "title": "Meshtastic Firmware — Recorder Stream", + "description": "Live view of `.mtlog/` streams shipped by `mtlog_to_datadog.py`. Heap, packet volume, log levels, errors. One row per port.", + "widgets": [ + { + "definition": { + "title": "Free heap (bytes)", + "type": "timeseries", + "show_legend": true, + "requests": [ + { + "queries": [ + { + "name": "free_heap", + "data_source": "metrics", + "query": "avg:mesh.local.heap_free_bytes{service:meshtastic-firmware} by {port}" + } + ], + "response_format": "timeseries", + "display_type": "line" + } + ], + "yaxis": { "label": "bytes" } + } + }, + { + "definition": { + "title": "Heap slope (bytes/min) — last 1h", + "type": "query_value", + "precision": 0, + "requests": [ + { + "queries": [ + { + "name": "slope", + "data_source": "metrics", + "query": "derivative(avg:mesh.local.heap_free_bytes{service:meshtastic-firmware})", + "aggregator": "avg" + } + ], + "response_format": "scalar" + } + ], + "conditional_formats": [ + { "comparator": "<", "value": -100, "palette": "white_on_red" }, + { "comparator": "<", "value": 0, "palette": "white_on_yellow" }, + { "comparator": ">=", "value": 0, "palette": "white_on_green" } + ] + } + }, + { + "definition": { + "title": "Total heap (bytes)", + "type": "timeseries", + "requests": [ + { + "queries": [ + { + "name": "total_heap", + "data_source": "metrics", + "query": "avg:mesh.local.heap_total_bytes{service:meshtastic-firmware} by {port}" + } + ], + "response_format": "timeseries", + "display_type": "line" + } + ] + } + }, + { + "definition": { + "title": "Battery level (%)", + "type": "timeseries", + "requests": [ + { + "queries": [ + { + "name": "battery", + "data_source": "metrics", + "query": "avg:mesh.device.battery_level{service:meshtastic-firmware} by {port}" + } + ], + "response_format": "timeseries", + "display_type": "line" + } + ], + "yaxis": { "min": "0", "max": "105" } + } + }, + { + "definition": { + "title": "Air utilization (TX %)", + "type": "timeseries", + "requests": [ + { + "queries": [ + { + "name": "airutil", + "data_source": "metrics", + "query": "avg:mesh.device.air_util_tx{service:meshtastic-firmware} by {port}" + } + ], + "response_format": "timeseries", + "display_type": "line" + } + ] + } + }, + { + "definition": { + "title": "Channel utilization (%)", + "type": "timeseries", + "requests": [ + { + "queries": [ + { + "name": "chutil", + "data_source": "metrics", + "query": "avg:mesh.device.channel_utilization{service:meshtastic-firmware} by {port}" + } + ], + "response_format": "timeseries", + "display_type": "line" + } + ] + } + }, + { + "definition": { + "title": "Log volume by level", + "type": "timeseries", + "show_legend": true, + "requests": [ + { + "response_format": "timeseries", + "display_type": "bars", + "queries": [ + { + "name": "log_count", + "data_source": "logs", + "indexes": ["*"], + "compute": { "aggregation": "count" }, + "search": { "query": "service:meshtastic-firmware" }, + "group_by": [ + { + "facet": "@level", + "limit": 10, + "sort": { "order": "desc", "aggregation": "count" } + } + ] + } + ] + } + ] + } + }, + { + "definition": { + "title": "Recent ERROR / CRIT firmware logs", + "type": "list_stream", + "requests": [ + { + "response_format": "event_list", + "query": { + "data_source": "logs_stream", + "query_string": "service:meshtastic-firmware (status:error OR @level:ERROR OR @level:CRIT)", + "indexes": [], + "sort": { "column": "timestamp", "order": "desc" } + }, + "columns": [ + { "field": "timestamp", "width": "auto" }, + { "field": "host", "width": "auto" }, + { "field": "@port", "width": "auto" }, + { "field": "@level", "width": "auto" }, + { "field": "@thread", "width": "auto" }, + { "field": "message", "width": "stretch" } + ] + } + ] + } + }, + { + "definition": { + "title": "Recorder marker events", + "type": "list_stream", + "requests": [ + { + "response_format": "event_list", + "query": { + "data_source": "logs_stream", + "query_string": "service:meshtastic-firmware @level:MARK", + "indexes": [], + "sort": { "column": "timestamp", "order": "desc" } + }, + "columns": [ + { "field": "timestamp", "width": "auto" }, + { "field": "host", "width": "auto" }, + { "field": "message", "width": "stretch" } + ] + } + ] + } + } + ], + "template_variables": [ + { + "name": "port", + "prefix": "port", + "available_values": [], + "default": "*" + }, + { "name": "host", "prefix": "host", "available_values": [], "default": "*" } + ], + "layout_type": "ordered", + "notify_list": [], + "reflow_type": "auto" +} diff --git a/mcp-server/scripts/mtlog_to_datadog.py b/mcp-server/scripts/mtlog_to_datadog.py new file mode 100755 index 00000000000..51496adc439 --- /dev/null +++ b/mcp-server/scripts/mtlog_to_datadog.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Forward selected recorder JSONL streams to Datadog. + +Reads `.mtlog/logs.jsonl` and `.mtlog/telemetry.jsonl`, ships logs to the +Logs Intake API and telemetry numerics to the Metrics v2 series API. +Resumes from `.mtlog/.dd-cursor.json` so a daemon restart doesn't +duplicate rows already shipped from the current live files. + +This forwarder does not currently backfill rotated `.jsonl.gz` archives. +If the recorder rotates before this process drains the live file, or the +forwarder is down across a rotation, those older rows are skipped. + +Usage: + DD_API_KEY=... ./scripts/mtlog_to_datadog.py --tail + ./scripts/mtlog_to_datadog.py --once # catch up + exit + ./scripts/mtlog_to_datadog.py --since 3600 # backfill last hour from start + +Default `DD_SITE` is `us5.datadoghq.com` — the team's Datadog instance. +Override via `DD_SITE=...` env var or `--site` flag for one-offs. + +The forwarder is a separate process by design — a Datadog outage or +auth failure must not backpressure the recorder. We exit non-zero on +fatal config errors (missing API key) and keep retrying on transient +network/HTTP errors. +""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +import time +from pathlib import Path +from typing import Any, Iterator + +try: + import requests +except ImportError: + print( + "requests is required. Install it in the mcp-server venv: " + "uv pip install requests", + file=sys.stderr, + ) + sys.exit(2) + + +_DEFAULT_LOG_DIR = Path(__file__).resolve().parents[1] / ".mtlog" +_LOG_INTAKE_TPL = "https://http-intake.logs.{site}/api/v2/logs" +_METRICS_TPL = "https://api.{site}/api/v2/series" +_LOG_BATCH = 50 +_METRICS_BATCH = 100 +_MAX_RETRIES = 5 +_RETRY_BASE_S = 1.5 + + +# --- streaming JSONL with byte-position cursor ------------------------- + + +class _StreamReader: + """Reads a single rotating JSONL with cursor-based resume. + + This tails only the live `.jsonl` file. The recorder rotates files + (live `.jsonl` → `.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`), which means + the live file shrinks abruptly. We detect that via inode change OR live + size < cursor position, and reset the live-file cursor to 0. + """ + + def __init__(self, path: Path, cursor: dict[str, Any]): + self.path = path + self.cursor = cursor + + def _state(self) -> tuple[int, int]: + """Return (inode, size) for the live file. (0, 0) if missing.""" + try: + st = self.path.stat() + return (st.st_ino, st.st_size) + except FileNotFoundError: + return (0, 0) + + def iter_new_records(self) -> Iterator[dict[str, Any]]: + ino, size = self._state() + last_ino = self.cursor.get("ino") + last_pos = int(self.cursor.get("pos") or 0) + if ino == 0: + return + if last_ino is not None and last_ino != ino: + # Rotation happened. Start over. + last_pos = 0 + if last_pos > size: + # Live file truncated/shrunk under us — recorder rotated. + last_pos = 0 + try: + with self.path.open("r", encoding="utf-8") as fh: + fh.seek(last_pos) + for line in fh: + line = line.rstrip("\n") + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + last_pos = fh.tell() + except FileNotFoundError: + return + self.cursor["ino"] = ino + self.cursor["pos"] = last_pos + + +def _load_cursor(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return {} + + +def _save_cursor(path: Path, data: dict[str, Any]) -> None: + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, separators=(",", ":"))) + tmp.replace(path) + + +# --- Datadog clients --------------------------------------------------- + + +class _DDSession: + """Pool one HTTPS session, share retry logic.""" + + def __init__(self, api_key: str, site: str, hostname: str) -> None: + self.api_key = api_key + self.site = site + self.hostname = hostname + self.session = requests.Session() + self.session.headers.update( + { + "DD-API-KEY": api_key, + "Content-Type": "application/json", + } + ) + + def _post(self, url: str, payload: Any) -> bool: + for attempt in range(_MAX_RETRIES): + try: + resp = self.session.post(url, json=payload, timeout=30) + except requests.RequestException as e: + _wait_retry(attempt, f"network error: {e}") + continue + if 200 <= resp.status_code < 300: + return True + if resp.status_code in (408, 429, 500, 502, 503, 504): + _wait_retry( + attempt, + f"HTTP {resp.status_code} retrying", + ) + continue + print( + f"datadog refused: {resp.status_code} {resp.text[:200]}", + file=sys.stderr, + ) + return False + return False + + def send_logs(self, records: list[dict[str, Any]]) -> int: + if not records: + return 0 + url = _LOG_INTAKE_TPL.format(site=self.site) + sent = 0 + for i in range(0, len(records), _LOG_BATCH): + batch = records[i : i + _LOG_BATCH] + if self._post(url, batch): + sent += len(batch) + return sent + + def send_metrics(self, series: list[dict[str, Any]]) -> int: + if not series: + return 0 + url = _METRICS_TPL.format(site=self.site) + sent = 0 + for i in range(0, len(series), _METRICS_BATCH): + batch = series[i : i + _METRICS_BATCH] + if self._post(url, {"series": batch}): + sent += len(batch) + return sent + + +def _wait_retry(attempt: int, reason: str) -> None: + wait = _RETRY_BASE_S * (2**attempt) + print( + f" retry {attempt + 1}/{_MAX_RETRIES} in {wait:.1f}s ({reason})", + file=sys.stderr, + ) + time.sleep(wait) + + +# --- record → datadog payload ------------------------------------------ + + +def _log_record_to_dd(rec: dict[str, Any], host: str) -> dict[str, Any]: + line = rec.get("line") or "" + tags = [ + f"role:{rec.get('role')}", + f"port:{rec.get('port')}", + ] + level = rec.get("level") + if level: + tags.append(f"level:{level}") + tag = rec.get("tag") + if tag: + tags.append(f"thread:{tag}") + return { + "ddsource": "meshtastic-firmware", + "service": "meshtastic-firmware", + "hostname": host, + "message": line, + "ddtags": ",".join(t for t in tags if t and "None" not in t), + "timestamp": int((rec.get("ts") or time.time()) * 1000), + "level": level, + } + + +def _telemetry_record_to_metrics( + rec: dict[str, Any], host: str +) -> list[dict[str, Any]]: + fields = rec.get("fields") or {} + if not isinstance(fields, dict): + return [] + variant = rec.get("variant") or "unknown" + ts = int(rec.get("ts") or time.time()) + out: list[dict[str, Any]] = [] + tags = [] + if rec.get("port"): + tags.append(f"port:{rec['port']}") + if rec.get("role"): + tags.append(f"role:{rec['role']}") + if rec.get("from_node"): + tags.append(f"from_node:{rec['from_node']}") + tags.append(f"variant:{variant}") + for field, value in fields.items(): + if not isinstance(value, (int, float)) or isinstance(value, bool): + continue + metric = f"mesh.{variant}.{_metric_safe(field)}" + out.append( + { + "metric": metric, + "type": 3, # GAUGE + "points": [{"timestamp": ts, "value": float(value)}], + "tags": tags, + "resources": [{"type": "host", "name": host}], + } + ) + return out + + +def _metric_safe(name: str) -> str: + # Lowercase, replace non-alnum with underscore for safe metric names. + return "".join(c.lower() if c.isalnum() else "_" for c in name) + + +# --- main loop --------------------------------------------------------- + + +def run( + log_dir: Path, + *, + once: bool, + since_seconds: float | None, + poll_interval: float, + dd: _DDSession, +) -> int: + cursor_path = log_dir / ".dd-cursor.json" + cursors = _load_cursor(cursor_path) + + # `--since` overrides cursor: rewind to (now-since) timestamp. + # We can't seek by timestamp directly (cursor is byte position), so + # we just reset cursors to 0 and let the time filter in iter_new + # drop older records. + cutoff_ts: float | None = None + if since_seconds is not None: + cursors = {} + cutoff_ts = time.time() - since_seconds + + sent_total = {"logs": 0, "telemetry": 0} + + while True: + # logs.jsonl → DD logs + log_cursor = cursors.setdefault("logs", {}) + log_batch: list[dict[str, Any]] = [] + for rec in _StreamReader(log_dir / "logs.jsonl", log_cursor).iter_new_records(): + if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts: + continue + log_batch.append(_log_record_to_dd(rec, dd.hostname)) + if log_batch: + n = dd.send_logs(log_batch) + sent_total["logs"] += n + print(f"logs: sent {n}/{len(log_batch)}") + + # telemetry.jsonl → DD metrics + telem_cursor = cursors.setdefault("telemetry", {}) + metric_series: list[dict[str, Any]] = [] + for rec in _StreamReader( + log_dir / "telemetry.jsonl", telem_cursor + ).iter_new_records(): + if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts: + continue + metric_series.extend(_telemetry_record_to_metrics(rec, dd.hostname)) + if metric_series: + n = dd.send_metrics(metric_series) + sent_total["telemetry"] += n + print(f"telemetry: sent {n}/{len(metric_series)} metric points") + + _save_cursor(cursor_path, cursors) + + if once: + print(f"done. logs={sent_total['logs']} metrics={sent_total['telemetry']}") + return 0 + time.sleep(poll_interval) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--log-dir", + default=str(_DEFAULT_LOG_DIR), + help="Path to .mtlog/ (default: mcp-server/.mtlog)", + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--once", action="store_true", help="Catch up then exit") + mode.add_argument( + "--tail", + action="store_true", + help="Daemon: poll forever (default)", + ) + parser.add_argument( + "--since", + type=float, + default=None, + help="Backfill last N seconds. Resets cursor.", + ) + parser.add_argument( + "--poll-interval", + type=float, + default=5.0, + help="Seconds between tail polls (default 5)", + ) + parser.add_argument( + "--site", + default=os.environ.get("DD_SITE", "us5.datadoghq.com"), + help=( + "Datadog site. Default is the team's instance (us5.datadoghq.com). " + "Override via DD_SITE env var or this flag." + ), + ) + parser.add_argument( + "--host", + default=socket.gethostname(), + help="Hostname tag (default: socket.gethostname())", + ) + args = parser.parse_args(argv) + + api_key = os.environ.get("DD_API_KEY") + if not api_key: + print("DD_API_KEY env var required.", file=sys.stderr) + return 2 + + log_dir = Path(args.log_dir) + if not log_dir.exists(): + print( + f"log dir {log_dir} does not exist — start the mcp-server first.", + file=sys.stderr, + ) + return 2 + + dd = _DDSession(api_key=api_key, site=args.site, hostname=args.host) + once = args.once and not args.tail + return run( + log_dir, + once=once, + since_seconds=args.since, + poll_interval=args.poll_interval, + dd=dd, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mcp-server/src/meshtastic_mcp/flash.py b/mcp-server/src/meshtastic_mcp/flash.py index e11197d5fac..debc2cf9120 100644 --- a/mcp-server/src/meshtastic_mcp/flash.py +++ b/mcp-server/src/meshtastic_mcp/flash.py @@ -108,18 +108,33 @@ def build( env: str, with_manifest: bool = True, userprefs_overrides: dict[str, Any] | None = None, + build_flags: dict[str, Any] | None = None, ) -> dict[str, Any]: """Run `pio run -e ` and return artifact paths. `userprefs_overrides` (optional): dict of `USERPREFS_: value` to inject into userPrefs.jsonc for this build only. File is restored byte-for-byte on exit. Use `userprefs_set()` for persistent changes. + + `build_flags` (optional): dict of `-D=` macros to set for + this build only via `PLATFORMIO_BUILD_FLAGS`. Common useful flag: + `{"DEBUG_HEAP": 1}` enables per-thread leak detection + `[heap N]` + prefix on every log line. Combines with the recorder so heap shows + up at log cadence (much higher resolution than the ~60 s LocalStats + packet) — see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values + expand to bare `-D` (presence-only flags). """ args = ["run", "-e", env] if with_manifest: args.extend(["-t", "mtjson"]) + extra_env = _build_flags_env(build_flags) if build_flags else None with userprefs.temporary_overrides(userprefs_overrides) as effective: - result = pio.run(args, timeout=pio.TIMEOUT_BUILD, check=False) + result = pio.run( + args, + timeout=pio.TIMEOUT_BUILD, + check=False, + extra_env=extra_env, + ) return { "exit_code": result.returncode, "artifacts": [str(p) for p in _artifacts_for(env)], @@ -127,9 +142,27 @@ def build( "stderr_tail": pio.tail_lines(result.stderr, 200), "duration_s": round(result.duration_s, 2), "userprefs": _userprefs_summary(effective), + "build_flags": dict(build_flags) if build_flags else None, } +def _build_flags_env(build_flags: dict[str, Any]) -> dict[str, str]: + """Translate `{"DEBUG_HEAP": 1, "FOO": "bar"}` → `{"PLATFORMIO_BUILD_FLAGS": + "-DDEBUG_HEAP=1 -DFOO=bar"}`. Bool True → bare `-D`; False/None drop + the flag entirely. Other types stringify.""" + parts: list[str] = [] + for key, value in build_flags.items(): + if value is False or value is None: + continue + if value is True: + parts.append(f"-D{key}") + else: + parts.append(f"-D{key}={value}") + if not parts: + return {} + return {"PLATFORMIO_BUILD_FLAGS": " ".join(parts)} + + def clean(env: str) -> dict[str, Any]: """Run `pio run -e -t clean`.""" result = pio.run(["run", "-e", env, "-t", "clean"], timeout=120, check=False) @@ -146,20 +179,29 @@ def flash( port: str, confirm: bool = False, userprefs_overrides: dict[str, Any] | None = None, + build_flags: dict[str, Any] | None = None, ) -> dict[str, Any]: """`pio run -e -t upload --upload-port `. All architectures. `userprefs_overrides` (optional): see `build()` — the rebuild-before-upload that pio performs will pick up the injected values. + + `build_flags` (optional): same shape as `build()` — `PLATFORMIO_BUILD_FLAGS` + is exported for the rebuild-before-upload, so the uploaded firmware + actually carries the flags. Without this propagation, `pio run -t upload` + would relink without the env var and silently drop them. Common use: + `build_flags={"DEBUG_HEAP": 1}` for the leak-hunt path. """ _require_confirm(confirm, "flash") _reject_native_env(env, "flash") connection.reject_if_tcp(port, "flash") + extra_env = _build_flags_env(build_flags) if build_flags else None with userprefs.temporary_overrides(userprefs_overrides) as effective: result = pio.run( ["run", "-e", env, "-t", "upload", "--upload-port", port], timeout=pio.TIMEOUT_UPLOAD, check=False, + extra_env=extra_env, ) return { "exit_code": result.returncode, @@ -167,6 +209,7 @@ def flash( "stderr_tail": pio.tail_lines(result.stderr, 200), "duration_s": round(result.duration_s, 2), "userprefs": _userprefs_summary(effective), + "build_flags": dict(build_flags) if build_flags else None, } diff --git a/mcp-server/src/meshtastic_mcp/log_query.py b/mcp-server/src/meshtastic_mcp/log_query.py new file mode 100644 index 00000000000..0f3ad7b6905 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/log_query.py @@ -0,0 +1,410 @@ +"""Read-side queries over the recorder's JSONL streams. + +Pure functions over `mcp-server/.mtlog/`. Streaming JSONL reader: never +loads a whole file. Time-bound queries short-circuit as soon as `ts` +exceeds the requested end. The recorder writes monotonically, so a +forward scan is cheap; we don't need an index. + +All time arguments accept: + - epoch seconds (int/float) + - relative strings: "-15m", "-2h", "-3d", "now" + - ISO-ish absolute strings: "2026-05-07T14:30:00" (naive timestamps are + treated as UTC) + +Tools that return data ALWAYS cap their output (max_lines / max_points +/ max), and report whether more matched than was returned. +""" + +from __future__ import annotations + +import gzip +import json +import re +import statistics +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator + +from .recorder.recorder import get_recorder + +_REL_RE = re.compile(r"^\s*-\s*(\d+(?:\.\d+)?)\s*([smhd])\s*$") +_REGEX_PREVIEW_MAX = 100 +_REGEX_PREVIEW_TRUNCATE = 97 + + +def _parse_time(value: Any, *, now: float | None = None) -> float: + """Coerce to epoch seconds. Defaults `now` to `time.time()`.""" + if value is None: + return time.time() + if isinstance(value, (int, float)): + return float(value) + if not isinstance(value, str): + raise ValueError(f"invalid time: {value!r}") + s = value.strip().lower() + if s in ("", "now"): + return time.time() if now is None else now + m = _REL_RE.match(s) + if m: + n = float(m.group(1)) + unit = m.group(2) + secs = n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit] + base = time.time() if now is None else now + return base - secs + # Try ISO 8601. Accept naive (assume UTC) and Z-suffixed. + try: + if s.endswith("z"): + s = s[:-1] + "+00:00" + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except ValueError as e: + raise ValueError(f"unparseable time: {value!r}") from e + + +def _iter_jsonl(path: Path, *, since: float, until: float) -> Iterator[dict[str, Any]]: + """Stream records in chronological order: rotated archives first + (oldest → newest by lex sort, which is chronological for our + `YYYYMMDD-HHMMSS-uuuuuu-NNNNN` archive naming), then the live file + last. The "keep last N" pop-front logic in the window queries + relies on records arriving in time order across files. + """ + files: list[Path] = [] + # Gzipped archives are named ".YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz". + for archive in sorted(path.parent.glob(f"{path.stem}.*.jsonl.gz")): + files.append(archive) + if path.exists(): + files.append(path) + for f in files: + opener = gzip.open if f.suffix == ".gz" else open + try: + with opener(f, "rt", encoding="utf-8") as fh: # type: ignore[arg-type] + for line in fh: + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + ts = rec.get("ts") + if not isinstance(ts, (int, float)): + continue + if ts < since: + continue + if ts > until: + # Records are append-monotonic within a file, so + # the rest of this file is also past `until`. + # Archives can still overlap each other, so only + # short-circuit this file, not the whole scan. + break + yield rec + except (FileNotFoundError, OSError): + continue + + +# -- queries ------------------------------------------------------------ + + +def logs_window( + start: Any = "-15m", + end: Any = "now", + *, + grep: str | None = None, + level: str | None = None, + tag: str | None = None, + port: str | None = None, + max_lines: int = 200, +) -> dict[str, Any]: + """Recent firmware log lines, filtered. + + `level` accepts a single level name or pipe-separated set + ("WARN|ERROR|CRIT"). `grep` is a regex (Python re) over the raw + `line` field. Returns the last `max_lines` matches. + """ + s = _parse_time(start) + e = _parse_time(end) + levels = _split_set(level) + if grep: + try: + grep_re = re.compile(grep) + except re.error as exc: + preview = ( + grep + if len(grep) <= _REGEX_PREVIEW_MAX + else f"{grep[:_REGEX_PREVIEW_TRUNCATE]}..." + ) + raise ValueError(f"invalid grep regex {preview!r}: {exc}") from exc + else: + grep_re = None + + base = get_recorder().base_dir + matched = 0 + out: list[dict[str, Any]] = [] + for rec in _iter_jsonl(base / "logs.jsonl", since=s, until=e): + if levels and rec.get("level") not in levels: + continue + if tag and rec.get("tag") != tag: + continue + if port and rec.get("port") != port: + continue + if grep_re and not grep_re.search(rec.get("line") or ""): + continue + matched += 1 + out.append(rec) + if len(out) > max_lines: + out.pop(0) # keep the most recent N + return { + "lines": out, + "total_matched": matched, + "dropped": max(0, matched - max_lines), + "window": {"start": s, "end": e}, + } + + +def telemetry_timeline( + window: Any = "1h", + *, + variant: str = "local", + field: str = "free_heap", + port: str | None = None, + max_points: int = 200, +) -> dict[str, Any]: + """Timeseries of one telemetry field, downsampled. + + `field` matches both the protobuf snake_case name (`free_heap`, + `heap_free_bytes`, `battery_level`) and camelCase (`freeHeap`). + Server-side bucket-mean downsamples to ≤ `max_points`. Returns + `slope_per_min` (linear regression slope, units/min) so a leak + detector can read one number. + """ + end = time.time() + if isinstance(window, (int, float)): + # Numeric `window` is a duration in seconds — "last N seconds". + # Without this branch, `_parse_time(-N)` would treat -N as an + # absolute epoch timestamp (i.e., Jan 1 1970 minus N seconds), + # producing a wildly negative `start` and matching nothing. + start = end - float(window) + elif isinstance(window, str) and not window.startswith("-"): + # Bare string like "1h" is sugar for "-1h". + start = _parse_time(f"-{window}", now=end) + else: + start = _parse_time(window, now=end) + + base = get_recorder().base_dir + raw: list[tuple[float, float]] = [] + field_aliases = _field_aliases(field) + for rec in _iter_jsonl(base / "telemetry.jsonl", since=start, until=end): + if rec.get("variant") != variant: + continue + if port and rec.get("port") != port: + continue + fields = rec.get("fields") or {} + value: Any = None + for alias in field_aliases: + if alias in fields: + value = fields[alias] + break + if not isinstance(value, (int, float)): + continue + raw.append((float(rec["ts"]), float(value))) + + if not raw: + return { + "points": [], + "samples": 0, + "min": None, + "max": None, + "slope_per_min": None, + "window": {"start": start, "end": end, "variant": variant, "field": field}, + } + + points = _downsample(raw, max_points=max_points) + values = [v for _, v in raw] + return { + "points": [{"ts": ts, "value": v} for ts, v in points], + "samples": len(raw), + "min": min(values), + "max": max(values), + "slope_per_min": _slope_per_min(raw), + "window": {"start": start, "end": end, "variant": variant, "field": field}, + } + + +def packets_window( + start: Any = "-5m", + end: Any = "now", + *, + portnum: str | None = None, + from_node: str | None = None, + to_node: str | None = None, + max: int = 200, +) -> dict[str, Any]: + s = _parse_time(start) + e = _parse_time(end) + portnums = _split_set(portnum) + base = get_recorder().base_dir + matched = 0 + out: list[dict[str, Any]] = [] + for rec in _iter_jsonl(base / "packets.jsonl", since=s, until=e): + if portnums and rec.get("portnum") not in portnums: + continue + if from_node and str(rec.get("from_node")) != str(from_node): + continue + if to_node and str(rec.get("to_node")) != str(to_node): + continue + matched += 1 + out.append(rec) + if len(out) > max: + out.pop(0) + return { + "packets": out, + "total_matched": matched, + "dropped": matched - max if matched > max else 0, + "window": {"start": s, "end": e}, + } + + +def events_window( + start: Any = "-1h", + end: Any = "now", + *, + kind: str | None = None, + max: int = 200, +) -> dict[str, Any]: + s = _parse_time(start) + e = _parse_time(end) + kinds = _split_set(kind) + base = get_recorder().base_dir + matched = 0 + out: list[dict[str, Any]] = [] + for rec in _iter_jsonl(base / "events.jsonl", since=s, until=e): + if kinds and rec.get("kind") not in kinds: + continue + matched += 1 + out.append(rec) + if len(out) > max: + out.pop(0) + return { + "events": out, + "total_matched": matched, + "dropped": matched - max if matched > max else 0, + "window": {"start": s, "end": e}, + } + + +def export( + start: Any, + end: Any, + dest_dir: str, + *, + streams: list[str] | None = None, +) -> dict[str, Any]: + """Bundle a slice of each requested stream into `dest_dir`. + + For a notebook, a bug report, or a Datadog backfill. Output files + are uncompressed JSONL (callers gzip themselves if they want to). + """ + s = _parse_time(start) + e = _parse_time(end) + selected = streams or ["logs", "telemetry", "packets", "events"] + dest = Path(dest_dir) + dest.mkdir(parents=True, exist_ok=True) + + base = get_recorder().base_dir + paths: dict[str, str] = {} + for stream in selected: + src = base / f"{stream}.jsonl" + if not src.exists() and not list(base.glob(f"{stream}.*.jsonl.gz")): + continue + out_path = dest / f"{stream}.jsonl" + n = 0 + with out_path.open("w", encoding="utf-8") as fh: + for rec in _iter_jsonl(src, since=s, until=e): + fh.write(json.dumps(rec, separators=(",", ":")) + "\n") + n += 1 + paths[stream] = str(out_path) + paths[f"{stream}_count"] = str(n) + return {"dest_dir": str(dest), "paths": paths, "window": {"start": s, "end": e}} + + +# -- helpers ------------------------------------------------------------ + + +def _split_set(value: str | None) -> set[str] | None: + if not value: + return None + return {v.strip() for v in value.split("|") if v.strip()} + + +def _field_aliases(field: str) -> list[str]: + """Accept snake_case OR camelCase, plus a few legacy aliases.""" + snake = field + camel = _snake_to_camel(field) + aliases = {snake, camel} + # Old protobuf fields (pre-LocalStats) used different names + legacy = { + "free_heap": ["free_heap", "freeHeap", "heap_free_bytes", "heapFreeBytes"], + "heap_free_bytes": [ + "heap_free_bytes", + "heapFreeBytes", + "free_heap", + "freeHeap", + ], + "total_heap": ["total_heap", "totalHeap", "heap_total_bytes", "heapTotalBytes"], + "heap_total_bytes": [ + "heap_total_bytes", + "heapTotalBytes", + "total_heap", + "totalHeap", + ], + } + if field in legacy: + aliases.update(legacy[field]) + return list(aliases) + + +def _snake_to_camel(name: str) -> str: + parts = name.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + +def _downsample( + points: list[tuple[float, float]], *, max_points: int +) -> list[tuple[float, float]]: + if len(points) <= max_points: + return points + # Even-bucket mean. Preserves shape better than nth-sample picking. + n = len(points) + bucket = n / max_points + out: list[tuple[float, float]] = [] + i = 0 + for k in range(max_points): + end = int((k + 1) * bucket) + end = min(end, n) + if end <= i: + continue + chunk = points[i:end] + ts = chunk[len(chunk) // 2][0] + val = statistics.fmean(v for _, v in chunk) + out.append((ts, val)) + i = end + return out + + +def _slope_per_min(points: list[tuple[float, float]]) -> float | None: + """Least-squares slope (units per minute). None if too few points.""" + if len(points) < 2: + return None + xs = [t for t, _ in points] + ys = [v for _, v in points] + n = len(xs) + mean_x = sum(xs) / n + mean_y = sum(ys) / n + num = sum((xs[i] - mean_x) * (ys[i] - mean_y) for i in range(n)) + den = sum((x - mean_x) ** 2 for x in xs) + if den == 0: + return None + slope_per_sec = num / den + return slope_per_sec * 60.0 diff --git a/mcp-server/src/meshtastic_mcp/pio.py b/mcp-server/src/meshtastic_mcp/pio.py index c0c23f9bba4..c984d7a42d0 100644 --- a/mcp-server/src/meshtastic_mcp/pio.py +++ b/mcp-server/src/meshtastic_mcp/pio.py @@ -92,6 +92,7 @@ def _run_capturing( cwd: Path | None = None, timeout: float | None = None, tee_header: str | None = None, + extra_env: dict[str, str] | None = None, ) -> tuple[int, str, str, float]: """Run a subprocess, capture stdout+stderr, optionally tee to the flash log. @@ -99,6 +100,9 @@ def _run_capturing( `subprocess.TimeoutExpired` on timeout (callers map this to their own domain-specific error). + `extra_env` merges into the subprocess environment (parent env stays + intact). Used for `PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar. + Fast path: `subprocess.run(capture_output=True)` when no flash log is configured (unchanged behavior). @@ -110,6 +114,9 @@ def _run_capturing( """ log_path = _flash_log_path() t0 = time.monotonic() + env = None + if extra_env: + env = {**os.environ, **extra_env} if log_path is None: # Fast path — unchanged. @@ -119,6 +126,7 @@ def _run_capturing( capture_output=True, text=True, timeout=timeout, + env=env, ) return ( proc.returncode, @@ -145,6 +153,7 @@ def _run_capturing( stderr=subprocess.PIPE, text=True, bufsize=1, # line-buffered + env=env, ) stdout_chunks: list[str] = [] stderr_chunks: list[str] = [] @@ -232,12 +241,17 @@ def run( cwd: Path | None = None, timeout: float | None = TIMEOUT_DEFAULT, check: bool = True, + extra_env: dict[str, str] | None = None, ) -> PioResult: """Invoke `pio ` and return captured output. `cwd` defaults to the firmware root. `check=True` raises `PioError` on non-zero exit; set `check=False` to inspect `returncode` manually. + `extra_env` merges into the subprocess environment — used for + `PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar build-time + toggles that can't be expressed as command-line args. + If `MESHTASTIC_MCP_FLASH_LOG` is set, output is also tee'd to that file line-by-line as it arrives (for live flash progress in the TUI). """ @@ -250,6 +264,7 @@ def run( cwd=work_dir, timeout=timeout, tee_header=f"pio {' '.join(args)}", + extra_env=extra_env, ) except subprocess.TimeoutExpired as exc: raise PioTimeout(f"pio {' '.join(args)} timed out after {timeout}s") from exc diff --git a/mcp-server/src/meshtastic_mcp/recorder/__init__.py b/mcp-server/src/meshtastic_mcp/recorder/__init__.py new file mode 100644 index 00000000000..874d59d0e04 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/recorder/__init__.py @@ -0,0 +1,19 @@ +"""Persistent device-log capture. + +Singleton `Recorder` subscribes once to the meshtastic pubsub fan-out +(`meshtastic.log.line`, `meshtastic.receive.*`, `meshtastic.connection.*`) +and appends to four JSONL files under `mcp-server/.mtlog/`. Pubsub is +process-global so a single subscription captures every active interface +(serial / TCP / BLE) without any per-connection bookkeeping. + +The recorder is opt-in-by-import: importing this package is a no-op; call +`get_recorder().start()` (which `server.py` does at FastMCP app init) to +begin writing. `pause()` / `resume()` exist for the rare case the user +wants a clean stretch of file (e.g. capturing a known-good baseline). +""" + +from __future__ import annotations + +from .recorder import Recorder, get_recorder + +__all__ = ["Recorder", "get_recorder"] diff --git a/mcp-server/src/meshtastic_mcp/recorder/parsers.py b/mcp-server/src/meshtastic_mcp/recorder/parsers.py new file mode 100644 index 00000000000..1936ccbdaa6 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/recorder/parsers.py @@ -0,0 +1,309 @@ +"""Best-effort parsers for log lines and telemetry packets. + +Two flavors of log line cross our pubsub subscription: + 1. Text-mode path (debug_log_api disabled): the meshtastic Python lib + accumulates bytes between protobuf frames and emits the full + firmware-formatted line, e.g. + "INFO | 12:34:56 12345 [Main] Booting" + — level, HH:MM:SS, uptime seconds, thread bracket, then message. + 2. LogRecord protobuf path (debug_log_api enabled): the lib calls + `_handleLogLine(record.message)` with ONLY the message body. The + level/source/time fields on the LogRecord are dropped before + pubsub fan-out. We get e.g. just "Booting". + +Both arrive on `meshtastic.log.line`. The parser tries to recover a +level + thread when the prefix is present and falls back to level=None +otherwise. Consumers who want level filtering on protobuf-mode hosts +should grep the raw `line` field instead. + +Telemetry: `meshtastic.receive.telemetry` packets carry one of several +metric variants in `packet["decoded"]["telemetry"]`. We flatten the +chosen variant into a {field: value} dict so callers don't have to +know the protobuf shape. +""" + +from __future__ import annotations + +import re +from typing import Any + +# Match: LEVEL | HH:MM:SS UPTIME [Thread] message +# HH:MM:SS may be ??:??:?? when RTC isn't valid. The level alternation +# below is the canonical list — DebugConfiguration.h's MESHTASTIC_LOG_LEVEL_* +# macros must stay in sync with these strings. +_LINE_RE = re.compile( + r""" + ^ + (?PDEBUG|INFO\ |WARN\ |ERROR|CRIT\ |TRACE|HEAP\ ) + \s*\|\s* + (?P(?:\d{2}:\d{2}:\d{2})|(?:\?{2}:\?{2}:\?{2})) + \s+ + (?P\d+) + \s+ + (?:\[(?P[^\]]+)\]\s+)? + (?P.*) + $ + """, + re.VERBOSE, +) + +# DEBUG_HEAP build prepends `[heap N] ` to every message body, AFTER the +# thread bracket. See src/RedirectablePrint.cpp:175. +_HEAP_PREFIX_RE = re.compile(r"^\[heap\s+(?P\d+)\]\s+(?P.*)$") + +# OSThread leak/free detection. See src/concurrency/OSThread.cpp:89-91. +# Format: "------ Thread NAME leaked heap A -> B (delta) ------" +# "++++++ Thread NAME freed heap A -> B (delta) ++++++" +_THREAD_HEAP_RE = re.compile( + r""" + ^[\-+]+\s* + Thread\s+(?P\S+)\s+ + (?Pleaked|freed)\s+heap\s+ + (?P-?\d+)\s*->\s*(?P-?\d+)\s+ + \((?P-?\d+)\) + """, + re.VERBOSE, +) + +# Power.cpp:908 periodic heap status (DEBUG_HEAP only). +# Format: "Heap status: FREE/TOTAL bytes free (DELTA), running R/N threads" +_HEAP_STATUS_RE = re.compile( + r""" + Heap\s+status:\s+ + (?P\d+)\s*/\s*(?P\d+)\s+bytes\s+free + (?:\s+\((?P-?\d+)\))? + """, + re.VERBOSE, +) + + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +_HEAP_BRACKET_RE = re.compile(r"^heap\s+(?P\d+)$") + + +def parse_log_line(line: str) -> dict[str, Any]: + """Best-effort decompose a raw firmware log line. + + Returns a dict with at least `line` (the original, unmodified — ANSI + codes preserved for fidelity). Adds `level`, `tag`, `clock`, + `uptime_s`, and `msg` when the full prefix is present. + + Handles two firmware quirks: + - LogRecord.message can carry ANSI color escapes from RedirectablePrint + (the BLE/StreamAPI path inherited the colored body in some builds). + We strip ANSI before regex matching so the prefix survives. + - DEBUG_HEAP injects `[heap N]` after the thread bracket. When NO + thread name is set, the heap takes the thread bracket position — + looks like `[heap 12345] msg`. We detect that shape and move it + out of `tag` and into `heap_free`. + + DEBUG_HEAP-build extras (when `[heap N]` is injected): `heap_free` + (bytes), and when a `Thread X leaked|freed heap` line is recognized, + `heap_event` = {kind, thread, before, after, delta}. + + Never raises. + """ + out: dict[str, Any] = {"line": line} + if not line: + return out + + # Strip ANSI escapes BEFORE any regex matching. The original `line` + # stays in `out["line"]` for fidelity / future grep. + clean = _ANSI_RE.sub("", line) + + m = _LINE_RE.match(clean) + msg: str | None = None + if m: + level = m.group("level").rstrip() + out["level"] = level + out["clock"] = m.group("clock") + try: + out["uptime_s"] = int(m.group("uptime")) + except (TypeError, ValueError): + out["uptime_s"] = None + thread = m.group("thread") + if thread: + # If "thread" is actually the heap prefix taking the bracket + # position (DEBUG_HEAP build, no thread set), capture heap + # and leave tag unset. + hb = _HEAP_BRACKET_RE.match(thread.strip()) + if hb: + try: + out["heap_free"] = int(hb.group("heap")) + except (TypeError, ValueError): + pass + else: + out["tag"] = thread + msg = m.group("msg") + out["msg"] = msg + else: + # No prefix — bare LogRecord.message body. Inspect the whole + # line for DEBUG_HEAP-style content; the heap-prefix and + # thread-leak patterns can survive on either path. + msg = clean + + # DEBUG_HEAP per-line heap prefix: `[heap 92344] message`. + # Sits AFTER the thread bracket and BEFORE the message body, but + # for bare LogRecord lines it's at the start. Match it at the + # head of `msg`. + if msg: + hp = _HEAP_PREFIX_RE.match(msg) + if hp: + try: + out["heap_free"] = int(hp.group("heap")) + except (TypeError, ValueError): + pass + else: + # Strip the prefix from `msg` so a grep on the message + # body doesn't have to know about it. + out["msg"] = hp.group("rest") + msg = hp.group("rest") + + # Thread-level leak/free detection. + thr = _THREAD_HEAP_RE.search(msg) + if thr: + try: + out["heap_event"] = { + "kind": thr.group("kind"), + "thread": thr.group("thread"), + "before": int(thr.group("before")), + "after": int(thr.group("after")), + "delta": int(thr.group("delta")), + } + except (TypeError, ValueError): + pass + + # Power.cpp periodic "Heap status: F/T bytes free (D), running ..." + hs = _HEAP_STATUS_RE.search(msg) + if hs: + try: + out["heap_free"] = int(hs.group("free")) + out["heap_total"] = int(hs.group("total")) + if hs.group("delta") is not None: + out["heap_delta"] = int(hs.group("delta")) + except (TypeError, ValueError): + pass + + return out + + +# -- Telemetry ---------------------------------------------------------- + +# Order matters: meshtastic-python decoded packets use the protobuf +# `oneof variant` field name (snake_case) as the dict key. +_TELEMETRY_VARIANTS = ( + ("device_metrics", "device"), + ("local_stats", "local"), + ("environment_metrics", "environment"), + ("power_metrics", "power"), + ("air_quality_metrics", "airQuality"), + ("health_metrics", "health"), + ("host_metrics", "host"), +) + + +def extract_telemetry(packet: dict[str, Any]) -> dict[str, Any] | None: + """Pull the telemetry variant + flat fields out of a `meshtastic.receive.telemetry` + packet. Returns None when the shape isn't what we expect — so the + caller can fall back to a generic packets.jsonl row. + """ + if not isinstance(packet, dict): + return None + decoded = packet.get("decoded") + if not isinstance(decoded, dict): + return None + telem = decoded.get("telemetry") + if not isinstance(telem, dict): + return None + # The Python lib produces dict-of-camelCase keys via MessageToDict. + # Try both camelCase and snake_case to be robust to lib version drift. + for snake, label in _TELEMETRY_VARIANTS: + camel = _snake_to_camel(snake) + for key in (snake, camel): + value = telem.get(key) + if isinstance(value, dict): + return { + "variant": label, + "fields": {k: _scalarize(v) for k, v in value.items()}, + "time": telem.get("time"), + } + return None + + +def _snake_to_camel(name: str) -> str: + parts = name.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + +def _scalarize(value: Any) -> Any: + """Keep telemetry fields JSON-friendly. Lists/dicts pass through + untouched; bytes -> hex string; protobuf enums occasionally arrive + as ints (fine) or strings (also fine).""" + if isinstance(value, (bytes, bytearray, memoryview)): + return bytes(value).hex() + return value + + +# -- Generic packet summary --------------------------------------------- + + +def summarize_packet( + packet: dict[str, Any], *, payload_hex_len: int = 64 +) -> dict[str, Any]: + """Reduce a packet dict to a stable, queryable summary. Drops the + full payload bytes — the recorder records summaries, not pcaps. + """ + if not isinstance(packet, dict): + return {"raw_type": type(packet).__name__} + decoded = packet.get("decoded") if isinstance(packet.get("decoded"), dict) else {} + portnum = decoded.get("portnum") if isinstance(decoded, dict) else None + payload = decoded.get("payload") if isinstance(decoded, dict) else None + payload_hex = None + payload_size = None + if isinstance(payload, (bytes, bytearray, memoryview)): + b = bytes(payload) + payload_size = len(b) + payload_hex = b[:payload_hex_len].hex() if b else "" + elif isinstance(payload, str): + # Some decoded payloads (text messages) come as decoded strings. + payload_size = len(payload) + payload_hex = None # not bytes + return { + "from_node": packet.get("fromId") or packet.get("from"), + "to_node": packet.get("toId") or packet.get("to"), + "portnum": portnum, + "hop_limit": packet.get("hopLimit"), + "want_ack": packet.get("wantAck"), + "rx_rssi": packet.get("rxRssi"), + "rx_snr": packet.get("rxSnr"), + "channel": packet.get("channel"), + "id": packet.get("id"), + "payload_size": payload_size, + "payload_hex_prefix": payload_hex, + } + + +# -- Interface identification ------------------------------------------ + + +def interface_label(interface: Any) -> dict[str, Any]: + """Stable identifier for the meshtastic interface that emitted an event. + + Used as the `port`/`role` tag on every recorded row. SerialInterface + has `devPath`; TCPInterface has `hostname`+`portNumber`; BLEInterface + has `address`. Falls back to the class name when none of those exist. + """ + if interface is None: + return {"port": None, "role": None} + dev_path = getattr(interface, "devPath", None) + if dev_path: + return {"port": str(dev_path), "role": "serial"} + hostname = getattr(interface, "hostname", None) + if hostname: + port_num = getattr(interface, "portNumber", None) + endpoint = f"tcp://{hostname}:{port_num}" if port_num else f"tcp://{hostname}" + return {"port": endpoint, "role": "tcp"} + address = getattr(interface, "address", None) + if address: + return {"port": str(address), "role": "ble"} + return {"port": type(interface).__name__, "role": None} diff --git a/mcp-server/src/meshtastic_mcp/recorder/recorder.py b/mcp-server/src/meshtastic_mcp/recorder/recorder.py new file mode 100644 index 00000000000..2b8a5b48147 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/recorder/recorder.py @@ -0,0 +1,467 @@ +"""Process-global recorder singleton. + +Subscribes once to the meshtastic pubsub fan-out and writes four append-only +JSONL streams under `mcp-server/.mtlog/`. The pubsub fan-out is +process-global — a single subscription captures every active interface +without per-connection bookkeeping. + +Files: + logs.jsonl — every `meshtastic.log.line` event (best-effort prefix + parsed for level/tag/uptime; raw `line` always preserved) + telemetry.jsonl — `meshtastic.receive.telemetry` packets, flattened by + variant (device / local / environment / power / etc.) + packets.jsonl — every other `meshtastic.receive.*` packet, summarized + (portnum, hops, RSSI/SNR, payload size + 64-byte hex) + events.jsonl — connection lifecycle, node-DB updates, and manual + `mark_event` rows. Lower volume; useful for aligning + timelines. + +Pause/resume: `pause()` flips a flag; subscriptions stay registered. The +write methods short-circuit when paused, so we don't lose ordering when +resumed (we just have a gap). No queueing. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from pathlib import Path +from typing import Any + +from . import parsers +from .rotating import _RotatingJsonl + +_DEFAULT_DIR = Path(__file__).resolve().parents[3] / ".mtlog" +log = logging.getLogger(__name__) + + +class Recorder: + """Singleton write-side of the persistent log capture system.""" + + def __init__(self, base_dir: Path | None = None) -> None: + self.base_dir = Path(base_dir) if base_dir else _DEFAULT_DIR + self._lock = threading.RLock() + self._started = False + self._paused = False + self._pause_reason: str | None = None + self._started_at: float | None = None + self._handlers: list[tuple[str, Any]] = [] + self._files: dict[str, _RotatingJsonl] = {} + + # -- lifecycle ---------------------------------------------------- + + def start(self) -> None: + """Idempotent. Safe to call from FastMCP app startup.""" + with self._lock: + if self._started: + return + self.base_dir.mkdir(parents=True, exist_ok=True) + self._files = { + "logs": _RotatingJsonl(self.base_dir / "logs.jsonl"), + "telemetry": _RotatingJsonl(self.base_dir / "telemetry.jsonl"), + "packets": _RotatingJsonl(self.base_dir / "packets.jsonl"), + "events": _RotatingJsonl(self.base_dir / "events.jsonl"), + } + self._wire_pubsub() + self._started = True + self._started_at = time.time() + # Write the recorder_start marker after the initialization block. + # `_write_event()` re-checks recorder state via `_files_snapshot()`, + # so keeping this out of the setup block avoids nested lifecycle work. + self._write_event(kind="recorder_start", label="recorder_started") + + def stop(self) -> None: + with self._lock: + if not self._started: + return + self._unwire_pubsub() + for f in self._files.values(): + f.close() + self._files = {} + self._started = False + + def pause(self, reason: str | None = None) -> None: + # Write the pause marker BEFORE flipping the flag — `_write_event` + # short-circuits when paused, so the order matters for this event + # to actually land in events.jsonl. + self._write_event( + kind="recorder_pause", + label="paused", + note=reason, + ) + with self._lock: + self._paused = True + self._pause_reason = reason + + def resume(self) -> None: + # Mirror of `pause()`: clear the flag first, then write the marker + # so it isn't suppressed by the still-paused short-circuit. + with self._lock: + self._paused = False + self._pause_reason = None + self._write_event(kind="recorder_resume", label="resumed") + + # -- pubsub wiring ------------------------------------------------ + + def _wire_pubsub(self) -> None: + from pubsub import pub # type: ignore[import-untyped] + + # Subscribers — one per topic. Each pubsub publisher sends + # keyword args matching its handler's signature; pubsub + # introspects the function signature to route args. + bindings = [ + ("meshtastic.log.line", self._on_log_line), + ("meshtastic.serial.line", self._on_serial_line), + ("meshtastic.receive", self._on_receive), + ("meshtastic.receive.telemetry", self._on_telemetry), + ("meshtastic.connection.established", self._on_connection_established), + ("meshtastic.connection.lost", self._on_connection_lost), + ("meshtastic.node.updated", self._on_node_updated), + ] + for topic, handler in bindings: + try: + pub.subscribe(handler, topic) + self._handlers.append((topic, handler)) + except Exception as exc: + # If pubsub refuses one binding (signature mismatch on + # an old lib version), log it and keep the rest. + log.warning("Recorder failed to subscribe to %s: %s", topic, exc) + + def _unwire_pubsub(self) -> None: + from pubsub import pub # type: ignore[import-untyped] + + for topic, handler in self._handlers: + try: + pub.unsubscribe(handler, topic) + except Exception: + pass + self._handlers.clear() + + # -- handlers ----------------------------------------------------- + # + # Pubsub callbacks must never raise. Every handler is wrapped in a + # try/except that swallows so a bug here can't take down the + # SerialInterface receive thread. + # + # Threading: handlers fire on whatever thread the meshtastic library + # dispatches from (varies by interface), while `stop()` clears + # `self._files` under `self._lock`. We snapshot `_files` under the + # lock at the top of each handler so a concurrent stop can't + # KeyError us mid-write. The actual file write goes through + # `_RotatingJsonl` which has its own lock. + + def _files_snapshot(self) -> dict[str, _RotatingJsonl] | None: + """Atomic-ish view of `self._files`. Returns None when the recorder + is paused or stopped, so handlers can early-exit cleanly without + racing `stop()`'s clear.""" + with self._lock: + if not self._started or self._paused: + return None + return dict(self._files) + + def _on_log_line(self, line: str, interface: Any = None) -> None: + files = self._files_snapshot() + if files is None: + return + try: + tags = parsers.interface_label(interface) + parsed = parsers.parse_log_line(str(line)) + ts = time.time() + record: dict[str, Any] = { + "ts": ts, + "port": tags["port"], + "role": tags["role"], + "level": parsed.get("level"), + "tag": parsed.get("tag"), + "uptime_s": parsed.get("uptime_s"), + "line": parsed["line"], + } + # DEBUG_HEAP enrichments (only present when the firmware + # was built with -DDEBUG_HEAP=1). Surface as first-class + # fields so logs_window can grep/filter on them and so + # heap_free synthesizes a telemetry point below. + if "heap_free" in parsed: + record["heap_free"] = parsed["heap_free"] + if "heap_total" in parsed: + record["heap_total"] = parsed["heap_total"] + if "heap_delta" in parsed: + record["heap_delta"] = parsed["heap_delta"] + heap_event = parsed.get("heap_event") + if heap_event: + record["heap_event"] = heap_event + files["logs"].write(record) + + # If the line carried a heap snapshot, also write it as a + # synthesized LocalStats-shaped row so telemetry_timeline + # picks it up at log cadence (much higher resolution than + # the ~60 s LocalStats packet). Tagged source=debug_heap so + # consumers can filter if mixing scales is unwanted. + heap_free = parsed.get("heap_free") + if isinstance(heap_free, int): + fields: dict[str, Any] = {"heap_free_bytes": heap_free} + heap_total = parsed.get("heap_total") + if isinstance(heap_total, int): + fields["heap_total_bytes"] = heap_total + files["telemetry"].write( + { + "ts": ts, + "port": tags["port"], + "role": tags["role"], + "from_node": None, + "variant": "local", + "fields": fields, + "source": "debug_heap", + } + ) + except Exception: + pass + + def _on_serial_line(self, line: str, port: str | None = None) -> None: + """Text-mode passive tap. Fired from `serial_session._drain` when a + `pio device monitor` subprocess is running. + + Same parse + heap-synthesis path as `_on_log_line`, but receives + the raw text-formatted line (full level/clock/uptime/thread/`[heap N]`/ + body). On DEBUG_HEAP builds in text mode this gives us per-log-line + heap data — far higher cadence than LocalStats, and works without + protobuf API mode (no SerialInterface required). + """ + files = self._files_snapshot() + if files is None: + return + try: + parsed = parsers.parse_log_line(str(line)) + ts = time.time() + record: dict[str, Any] = { + "ts": ts, + "port": port, + "role": "serial_session", + "level": parsed.get("level"), + "tag": parsed.get("tag"), + "uptime_s": parsed.get("uptime_s"), + "line": parsed["line"], + } + if "heap_free" in parsed: + record["heap_free"] = parsed["heap_free"] + if "heap_total" in parsed: + record["heap_total"] = parsed["heap_total"] + if "heap_delta" in parsed: + record["heap_delta"] = parsed["heap_delta"] + heap_event = parsed.get("heap_event") + if heap_event: + record["heap_event"] = heap_event + files["logs"].write(record) + + # Synthesize a heap_free telemetry sample whenever the line + # carries one — same logic as _on_log_line, tagged source so + # consumers can distinguish text-mode tap from protobuf path. + heap_free = parsed.get("heap_free") + if isinstance(heap_free, int): + fields: dict[str, Any] = {"heap_free_bytes": heap_free} + heap_total = parsed.get("heap_total") + if isinstance(heap_total, int): + fields["heap_total_bytes"] = heap_total + files["telemetry"].write( + { + "ts": ts, + "port": port, + "role": "serial_session", + "from_node": None, + "variant": "local", + "fields": fields, + "source": "debug_heap_serial", + } + ) + except Exception: + pass + + def _on_telemetry(self, packet: dict[str, Any], interface: Any = None) -> None: + files = self._files_snapshot() + if files is None: + return + try: + tags = parsers.interface_label(interface) + extracted = parsers.extract_telemetry(packet) + if extracted is None: + # Couldn't extract a known variant — fall through to the + # generic `_on_receive` path, which will still fire for + # this packet via the parent topic. + return + record = { + "ts": time.time(), + "port": tags["port"], + "role": tags["role"], + "from_node": packet.get("fromId") or packet.get("from"), + "variant": extracted["variant"], + "fields": extracted["fields"], + "device_time": extracted.get("time"), + } + files["telemetry"].write(record) + except Exception: + pass + + def _on_receive(self, packet: dict[str, Any], interface: Any = None) -> None: + # Generic-receive fires for EVERY packet. Telemetry packets get + # recorded twice (here and in _on_telemetry) — that's intentional: + # packets.jsonl is the universal record, telemetry.jsonl is the + # structured timeseries view. + files = self._files_snapshot() + if files is None: + return + try: + tags = parsers.interface_label(interface) + summary = parsers.summarize_packet(packet) + record = { + "ts": time.time(), + "port": tags["port"], + "role": tags["role"], + **summary, + } + files["packets"].write(record) + except Exception: + pass + + def _on_connection_established(self, interface: Any = None) -> None: + self._write_event( + kind="connection_established", + interface=interface, + ) + + def _on_connection_lost(self, interface: Any = None) -> None: + self._write_event( + kind="connection_lost", + interface=interface, + ) + + def _on_node_updated( + self, node: dict[str, Any] | None = None, interface: Any = None + ) -> None: + # Lower-volume than packets but informative — node ID, hops away, + # last heard. Skip the user dict if absent. + try: + user = (node or {}).get("user") if isinstance(node, dict) else None + self._write_event( + kind="node_updated", + interface=interface, + data={ + "num": (node or {}).get("num"), + "id": (user or {}).get("id"), + "short": (user or {}).get("shortName"), + "long": (user or {}).get("longName"), + "hops_away": (node or {}).get("hopsAway"), + "snr": (node or {}).get("snr"), + "last_heard": (node or {}).get("lastHeard"), + }, + ) + except Exception: + pass + + # -- public write helpers ----------------------------------------- + + def mark_event( + self, + label: str, + note: str | None = None, + data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """User-facing marker. Writes to events.jsonl AND emits a + synthetic logs.jsonl row tagged level=MARK so timelines align. + """ + ts = self._write_event(kind="mark", label=label, note=note, data=data) + # Mirror into logs so a single logs_window grep finds it. + files = self._files_snapshot() + if files is not None: + try: + files["logs"].write( + { + "ts": ts, + "port": None, + "role": "marker", + "level": "MARK", + "tag": "mark_event", + "line": f"[mark] {label}" + (f" — {note}" if note else ""), + } + ) + except Exception: + pass + return {"ts": ts, "label": label} + + def _write_event( + self, + *, + kind: str, + label: str | None = None, + note: str | None = None, + interface: Any = None, + data: dict[str, Any] | None = None, + ) -> float: + ts = time.time() + # Lifecycle markers (recorder_start, recorder_pause, recorder_resume) + # arrive at choreographed moments — `pause()` writes BEFORE flipping + # the flag and `resume()` writes AFTER clearing it, so those calls + # see _paused=False here. Other event kinds short-circuit when + # paused via the snapshot guard below. + files = self._files_snapshot() + if files is None: + return ts + try: + tags = parsers.interface_label(interface) + files["events"].write( + { + "ts": ts, + "kind": kind, + "label": label, + "note": note, + "port": tags["port"], + "role": tags["role"], + "data": data, + } + ) + except Exception: + pass + return ts + + # -- introspection ------------------------------------------------ + + def status(self) -> dict[str, Any]: + with self._lock: + return { + "running": self._started, + "paused": self._paused, + "pause_reason": self._pause_reason, + "started_at": self._started_at, + "base_dir": str(self.base_dir), + "files": {name: f.status() for name, f in self._files.items()}, + } + + def force_rotate_all(self) -> dict[str, Any]: + """Test/admin hook: rotate every stream right now.""" + with self._lock: + files = list(self._files.values()) + for f in files: + f.force_rotate() + # `status()` re-acquires `self._lock`; release before calling it. + return self.status() + + +# -- module-level singleton accessor ------------------------------------ + +_INSTANCE_LOCK = threading.Lock() +_INSTANCE: Recorder | None = None + + +def get_recorder() -> Recorder: + """Return the process-global Recorder. Created on first call. + + Honors `MESHTASTIC_MCP_LOG_DIR` env var for the base directory + (used by tests to redirect to a tmpdir). + """ + global _INSTANCE + with _INSTANCE_LOCK: + if _INSTANCE is None: + override = os.environ.get("MESHTASTIC_MCP_LOG_DIR") + base = Path(override) if override else None + _INSTANCE = Recorder(base_dir=base) + return _INSTANCE diff --git a/mcp-server/src/meshtastic_mcp/recorder/rotating.py b/mcp-server/src/meshtastic_mcp/recorder/rotating.py new file mode 100644 index 00000000000..631317790c1 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/recorder/rotating.py @@ -0,0 +1,163 @@ +"""Append-only JSONL writer with size-capped rotation. + +A `_RotatingJsonl` owns one live `.jsonl` file. Writes are line-delimited +JSON objects (one row per call). When the live file exceeds `max_bytes`, +it is closed, gzipped to `.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`, +and the live file resets to empty. Old archives past `keep_archives` are +unlinked oldest-first. + +Size check is amortized — `os.fstat` runs every `check_every` writes, +not per-write, so the hot path stays at one `fh.write` + one `fh.flush`. + +Threading: every public method acquires `self._lock`. The recorder runs +several pubsub handlers on whatever thread the meshtastic library +dispatches from (varies by interface), and queries from MCP tool calls +arrive on the FastMCP request thread, so this lock is not optional. +""" + +from __future__ import annotations + +import gzip +import json +import os +import shutil +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +class _RotatingJsonl: + """Append-only JSONL with size rotation. Thread-safe.""" + + def __init__( + self, + path: Path, + *, + max_bytes: int = 100 * 1024 * 1024, + keep_archives: int = 5, + check_every: int = 1000, + ) -> None: + self.path = path + self.max_bytes = max_bytes + self.keep_archives = keep_archives + self.check_every = check_every + self._lock = threading.Lock() + self._fh: Any = None + self._writes_since_check = 0 + self._rotations = 0 + self._lines_written = 0 + self._last_ts: float | None = None + self._open() + + # -- lifecycle ---------------------------------------------------- + + def _open(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self._fh = self.path.open("a", encoding="utf-8") + + def close(self) -> None: + with self._lock: + if self._fh is not None: + try: + self._fh.close() + finally: + self._fh = None + + # -- write -------------------------------------------------------- + + def write(self, record: dict[str, Any]) -> None: + """Append one JSON object as a line. Triggers rotation if oversized.""" + line = json.dumps(record, separators=(",", ":"), default=str) + "\n" + with self._lock: + if self._fh is None: + return + try: + self._fh.write(line) + self._fh.flush() + except Exception: + # Best-effort: a failed write must not crash the pubsub + # handler. Caller has no way to react anyway. + return + self._lines_written += 1 + ts = record.get("ts") + if isinstance(ts, (int, float)): + self._last_ts = float(ts) + self._writes_since_check += 1 + if self._writes_since_check >= self.check_every: + self._writes_since_check = 0 + self._maybe_rotate() + + # -- rotation ----------------------------------------------------- + + def _maybe_rotate(self) -> None: + # Caller holds self._lock. + try: + size = os.fstat(self._fh.fileno()).st_size + except OSError: + return + if size < self.max_bytes: + return + self._rotate_locked() + + def _rotate_locked(self) -> None: + # Close, gzip-rename, reopen empty, prune oldest archives. + try: + self._fh.close() + except Exception: + pass + self._fh = None + # Microsecond-resolution timestamp + per-instance counter so back- + # to-back rotations (small max_bytes, repeated `force_rotate()`, + # or chatty test loops) get unique archive filenames. The lex + # sort order of `YYYYMMDD-HHMMSS-uuuuuu-NNNNN` is chronological, + # which `_prune_archives()` and `log_query._iter_jsonl()` both + # rely on. + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f") + archive = self.path.with_suffix(f".{stamp}-{self._rotations:05d}.jsonl.gz") + try: + with self.path.open("rb") as src, gzip.open(archive, "wb") as dst: + shutil.copyfileobj(src, dst, length=1024 * 1024) + self.path.unlink() + except Exception: + # Rotation is best-effort. If gzip fails, leave the file + # in place and re-open it; we'll try again next check. + pass + self._open() + self._rotations += 1 + self._prune_archives() + + def _prune_archives(self) -> None: + # Match siblings of self.path.name with `.jsonl.gz` suffix. + prefix = self.path.stem # "logs" for "logs.jsonl" + # Archive filenames are already lexicographically chronological. + # Prune by name, not mtime, so copied/restored files don't reorder. + archives = sorted(self.path.parent.glob(f"{prefix}.*.jsonl.gz")) + excess = len(archives) - self.keep_archives + for old in archives[: max(0, excess)]: + try: + old.unlink() + except OSError: + pass + + def force_rotate(self) -> None: + """Test/admin hook: rotate immediately regardless of size.""" + with self._lock: + if self._fh is not None: + self._rotate_locked() + + # -- introspection ------------------------------------------------ + + def status(self) -> dict[str, Any]: + with self._lock: + try: + size = os.fstat(self._fh.fileno()).st_size if self._fh else 0 + except OSError: + size = 0 + return { + "path": str(self.path), + "size": size, + "lines": self._lines_written, + "last_ts": self._last_ts, + "rotations": self._rotations, + } diff --git a/mcp-server/src/meshtastic_mcp/serial_session.py b/mcp-server/src/meshtastic_mcp/serial_session.py index 43537323f71..fe1a452d0eb 100644 --- a/mcp-server/src/meshtastic_mcp/serial_session.py +++ b/mcp-server/src/meshtastic_mcp/serial_session.py @@ -46,7 +46,23 @@ class SerialSession: def _drain(session: SerialSession) -> None: - """Reader thread: line-by-line pull stdout into buffer.""" + """Reader thread: line-by-line pull stdout into buffer. + + Each line is also published to the `meshtastic.serial.line` pubsub + topic so the persistent recorder can capture it without holding its + own port. This is the text-mode tap path: when no SerialInterface is + open, the firmware emits full formatted lines (level + clock + uptime + + thread + `[heap N]` prefix on DEBUG_HEAP builds + body), and we + fan them out to whoever is listening. Pubsub is best-effort — + publish failures must never block the reader. + """ + # Lazy import: pubsub isn't required just to import this module + # (e.g., during static analysis), and we want a clean test surface. + try: + from pubsub import pub # type: ignore[import-untyped] + except Exception: # pragma: no cover - defensive + pub = None + assert session.proc.stdout is not None try: for line in session.proc.stdout: @@ -54,6 +70,16 @@ def _drain(session: SerialSession) -> None: with session.lock: session.buffer.append(line_stripped) session.total_lines += 1 + if pub is not None: + try: + pub.sendMessage( + "meshtastic.serial.line", + line=line_stripped, + port=session.port, + ) + except Exception: + # A subscriber raising must not break the reader. + pass except Exception: # pragma: no cover - defensive pass finally: diff --git a/mcp-server/src/meshtastic_mcp/server.py b/mcp-server/src/meshtastic_mcp/server.py index 83aa80c457f..573765e26ae 100644 --- a/mcp-server/src/meshtastic_mcp/server.py +++ b/mcp-server/src/meshtastic_mcp/server.py @@ -6,6 +6,7 @@ from __future__ import annotations +import logging from typing import Any from mcp.server.fastmcp import FastMCP @@ -17,14 +18,34 @@ flash, hw_tools, info, + log_query, registry, serial_session, ) from . import userprefs as userprefs_mod +from .recorder import get_recorder + +log = logging.getLogger(__name__) app = FastMCP("meshtastic-mcp") +def _start_recorder() -> None: + # Persistent device-log capture. Starts on first import — pubsub fan-out + # is process-global, so subscribing here captures every active interface + # (whether opened by an MCP tool, a pytest fixture, or a serial_session). + # Files land in mcp-server/.mtlog/ (gitignored). See recorder/recorder.py + # for the full design. Recorder startup is best-effort: an unwritable + # log dir or pubsub mismatch should not take the MCP server down. + try: + get_recorder().start() + except Exception as exc: + log.warning("Failed to start persistent recorder: %s", exc) + + +_start_recorder() + + # ---------- Discovery & metadata ------------------------------------------ @@ -75,6 +96,7 @@ def build( env: str, with_manifest: bool = True, userprefs: dict[str, Any] | None = None, + build_flags: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build firmware for one env via `pio run -e `. @@ -86,8 +108,21 @@ def build( build via userPrefs.jsonc injection. The file is restored after the build completes. Use `userprefs_manifest` to discover available keys. Use `userprefs_set` for persistent changes. - """ - return flash.build(env, with_manifest=with_manifest, userprefs_overrides=userprefs) + + `build_flags` (optional): dict of `-D=` macros for this build + only, injected via `PLATFORMIO_BUILD_FLAGS`. Common pattern: + `build_flags={"DEBUG_HEAP": 1}` enables per-thread leak detection + a + `[heap N]` prefix on every log line. The recorder picks the prefix up + automatically and synthesizes a high-resolution heap timeline that + `telemetry_timeline(field="free_heap")` can read alongside the normal + ~60 s LocalStats packets. Pair with `/leakhunt` for classification. + """ + return flash.build( + env, + with_manifest=with_manifest, + userprefs_overrides=userprefs, + build_flags=build_flags, + ) @app.tool() @@ -105,6 +140,7 @@ def pio_flash( port: str, confirm: bool = False, userprefs: dict[str, Any] | None = None, + build_flags: dict[str, Any] | None = None, ) -> dict[str, Any]: """Flash firmware via `pio run -e -t upload --upload-port `. @@ -114,8 +150,19 @@ def pio_flash( `userprefs` (optional): dict of `USERPREFS_: value` baked into this build via userPrefs.jsonc injection; restored after upload. - """ - return flash.flash(env, port, confirm=confirm, userprefs_overrides=userprefs) + + `build_flags` (optional): dict of `-D=` macros for the + rebuild-before-upload, e.g. `{"DEBUG_HEAP": 1}`. Required for the flags + to actually land in the uploaded firmware — without it, the implicit + rebuild relinks without the env var and silently drops them. + """ + return flash.flash( + env, + port, + confirm=confirm, + userprefs_overrides=userprefs, + build_flags=build_flags, + ) @app.tool() @@ -734,3 +781,185 @@ def picotool_load(uf2_path: str, confirm: bool = False) -> dict[str, Any]: def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]: """Pass-through to `picotool`. load/reboot/save/erase require confirm=True.""" return hw_tools.picotool_raw(args, confirm=confirm) + + +# ---------- Persistent device-log capture (recorder) ---------------------- +# +# The recorder is autouse — it starts at server import and continuously +# writes every meshtastic pubsub event to JSONL files under .mtlog/. These +# tools are query-only over those files, plus a few lifecycle controls. + + +@app.tool() +def logs_window( + start: str = "-15m", + end: str = "now", + grep: str | None = None, + level: str | None = None, + tag: str | None = None, + port: str | None = None, + max_lines: int = 200, +) -> dict[str, Any]: + """Recent firmware log lines from the persistent recorder. + + Filters by time window, regex over the line, level (single or + pipe-separated set like "WARN|ERROR|CRIT"), thread-name tag, and + interface port. Returns up to max_lines most-recent matches. + + Time strings: "-15m", "-2h", "-3d", "now", or ISO 8601. + + Note: lines arriving via the LogRecord protobuf path (when + set_debug_log_api(True) is on) come without level prefix — the + meshtastic Python lib drops record.level before fan-out. For those, + `level` filter won't match; use `grep` instead. + """ + return log_query.logs_window( + start=start, + end=end, + grep=grep, + level=level, + tag=tag, + port=port, + max_lines=max_lines, + ) + + +@app.tool() +def telemetry_timeline( + window: str = "1h", + variant: str = "local", + field: str = "free_heap", + port: str | None = None, + max_points: int = 200, +) -> dict[str, Any]: + """Time series of one telemetry field, downsampled to <= max_points. + + `variant` ∈ device, local, environment, power, airQuality, health, host. + `field` accepts snake_case or camelCase; common aliases (free_heap ↔ + heap_free_bytes) are normalized. + + Returns slope_per_min (linear-regression slope, units/minute) so a + leak detector can read one number — negative slope on free_heap over + a long window indicates a real leak. + + LocalStats variant ("local") cadence is ~60 s (whatever the device's + `device_update_interval` is set to), so a 1 h window gives ~60 raw + points. Bucket-mean downsampling preserves shape. + """ + return log_query.telemetry_timeline( + window=window, + variant=variant, + field=field, + port=port, + max_points=max_points, + ) + + +@app.tool() +def packets_window( + start: str = "-5m", + end: str = "now", + portnum: str | None = None, + from_node: str | None = None, + to_node: str | None = None, + max: int = 200, +) -> dict[str, Any]: + """Recent mesh packets recorded by the recorder. + + Each row is a summary (portnum, from/to, hop_limit, RSSI/SNR, payload + size + first 64 bytes hex) — full payload bytes are not stored. + `portnum` accepts a pipe-separated set like "TEXT_MESSAGE_APP|POSITION_APP". + """ + return log_query.packets_window( + start=start, + end=end, + portnum=portnum, + from_node=from_node, + to_node=to_node, + max=max, + ) + + +@app.tool() +def events_window( + start: str = "-1h", + end: str = "now", + kind: str | None = None, + max: int = 200, +) -> dict[str, Any]: + """Return recorder events: connection lifecycle, node updates, and `mark_event` markers. + + `kind` ∈ recorder_start, recorder_pause, recorder_resume, + connection_established, connection_lost, node_updated, mark. + Pipe-separated sets ("connection_lost|connection_established") work. + """ + return log_query.events_window(start=start, end=end, kind=kind, max=max) + + +@app.tool() +def mark_event( + label: str, + note: str | None = None, + data: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Drop a named marker into events.jsonl AND logs.jsonl. + + Useful for aligning a timeline around a known stimulus: call before + and after a stress workload, then query telemetry_timeline / + logs_window with the markers' timestamps as bounds. + + The marker also lands in logs.jsonl with level=MARK so a single + grep over logs picks it up. + """ + return get_recorder().mark_event(label=label, note=note, data=data) + + +@app.tool() +def recorder_status() -> dict[str, Any]: + """Return recorder runtime info: running, paused, file sizes, last_ts per stream. + + Use this to sanity-check that capture is working before you trust a + `logs_window` / `telemetry_timeline` result. + """ + return get_recorder().status() + + +@app.tool() +def recorder_pause(reason: str | None = None) -> dict[str, Any]: + """Pause writes to all four streams. Pubsub subscriptions stay active — + we just drop events on the floor while paused. Resume with `recorder_resume`. + + Use when capturing a known-good baseline that you don't want to + pollute with pre-test noise. Default state is recording; this is + rarely needed. + """ + get_recorder().pause(reason=reason) + return {"ok": True, "paused": True, "reason": reason} + + +@app.tool() +def recorder_resume() -> dict[str, Any]: + """Resume writes after `recorder_pause`. No-op if already running.""" + get_recorder().resume() + return {"ok": True, "paused": False} + + +@app.tool() +def recorder_export( + start: str, + end: str, + dest_dir: str, + streams: list[str] | None = None, +) -> dict[str, Any]: + """Bundle a slice of the recorder's streams into `dest_dir`. + + Writes one uncompressed JSONL per requested stream (logs / telemetry / + packets / events). Useful for: attaching to a bug report, feeding a + notebook, or backfilling Datadog after the fact. + """ + return log_query.export( + start=start, + end=end, + dest_dir=dest_dir, + streams=streams, + ) diff --git a/mcp-server/tests/unit/test_build_flags.py b/mcp-server/tests/unit/test_build_flags.py new file mode 100644 index 00000000000..bace81fae3a --- /dev/null +++ b/mcp-server/tests/unit/test_build_flags.py @@ -0,0 +1,88 @@ +"""Unit tests for the `build_flags` injection on `flash.build()`. + +We don't actually run pio here — too slow, requires hardware-aware envs. +We test the translation layer (`_build_flags_env`) and that the env vars +are threaded through pio.run correctly via mock. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from meshtastic_mcp import flash, pio + + +class TestBuildFlagsEnv: + def test_simple_value(self) -> None: + out = flash._build_flags_env({"DEBUG_HEAP": 1}) + assert out == {"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP=1"} + + def test_string_value(self) -> None: + out = flash._build_flags_env({"FOO": "bar"}) + assert out == {"PLATFORMIO_BUILD_FLAGS": "-DFOO=bar"} + + def test_bool_true_is_bare_flag(self) -> None: + out = flash._build_flags_env({"DEBUG_HEAP": True}) + assert out == {"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP"} + + def test_bool_false_dropped(self) -> None: + out = flash._build_flags_env({"DEBUG_HEAP": False, "OTHER": 1}) + assert out == {"PLATFORMIO_BUILD_FLAGS": "-DOTHER=1"} + + def test_none_dropped(self) -> None: + out = flash._build_flags_env({"DEBUG_HEAP": None}) + assert out == {} + + def test_multiple_combined(self) -> None: + out = flash._build_flags_env({"DEBUG_HEAP": 1, "FOO": "x", "BAR": True}) + # Order isn't guaranteed in dict iteration, so check membership. + flags = out["PLATFORMIO_BUILD_FLAGS"].split() + assert set(flags) == {"-DDEBUG_HEAP=1", "-DFOO=x", "-DBAR"} + + +class TestBuildPropagatesFlags: + def test_extra_env_passed_to_pio_run(self) -> None: + # Mock pio.run so we don't actually invoke pio. Capture extra_env. + captured = {} + + class _StubResult: + returncode = 0 + stdout = "" + stderr = "" + duration_s = 0.1 + + def _stub(args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return _StubResult() + + with patch.object(pio, "run", side_effect=_stub): + with patch.object(flash, "_artifacts_for", return_value=[]): + out = flash.build( + "fake-env", + with_manifest=False, + build_flags={"DEBUG_HEAP": 1}, + ) + assert captured["args"] == ["run", "-e", "fake-env"] + assert captured["kwargs"]["extra_env"] == { + "PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP=1" + } + assert out["build_flags"] == {"DEBUG_HEAP": 1} + + def test_no_flags_means_no_extra_env(self) -> None: + captured = {} + + class _StubResult: + returncode = 0 + stdout = "" + stderr = "" + duration_s = 0.1 + + def _stub(args, **kwargs): + captured["kwargs"] = kwargs + return _StubResult() + + with patch.object(pio, "run", side_effect=_stub): + with patch.object(flash, "_artifacts_for", return_value=[]): + flash.build("fake-env", with_manifest=False) + assert captured["kwargs"]["extra_env"] is None diff --git a/mcp-server/tests/unit/test_recorder.py b/mcp-server/tests/unit/test_recorder.py new file mode 100644 index 00000000000..a7d93d78f52 --- /dev/null +++ b/mcp-server/tests/unit/test_recorder.py @@ -0,0 +1,548 @@ +"""Unit tests for the persistent device-log recorder. + +Hardware-free: drives the Recorder through its `_on_*` handlers with +synthetic packet/line dicts, then queries via log_query. Validates +prefix parsing, telemetry variant dispatch, marker round-trip, time +window filtering, downsampling, slope estimation, and gzip rotation ++ archive pruning. +""" + +from __future__ import annotations + +import gzip +import json +import logging +import os +import time +from pathlib import Path + +import pubsub +import pytest +from meshtastic_mcp import log_query +from meshtastic_mcp.recorder.parsers import ( + extract_telemetry, + interface_label, + parse_log_line, + summarize_packet, +) +from meshtastic_mcp.recorder.recorder import Recorder +from meshtastic_mcp.recorder.rotating import _RotatingJsonl + +# -- isolation: every test gets a fresh Recorder + tmp dir ----------- + + +@pytest.fixture +def recorder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Recorder: + # Redirect both the Recorder and the module-level singleton lookup + # to the same tmp dir so log_query queries the same files we write. + monkeypatch.setenv("MESHTASTIC_MCP_LOG_DIR", str(tmp_path)) + monkeypatch.setattr( + "meshtastic_mcp.recorder.recorder._INSTANCE", None, raising=False + ) + r = Recorder(base_dir=tmp_path) + r.start() + monkeypatch.setattr("meshtastic_mcp.recorder.recorder._INSTANCE", r, raising=False) + yield r + r.stop() + + +class _FakeIface: + devPath = "/dev/cu.fake" + + +# -- parsers --------------------------------------------------------- + + +class TestParseLogLine: + def test_full_prefix(self) -> None: + out = parse_log_line("INFO | 12:34:56 12345 [Main] Booting") + assert out["level"] == "INFO" + assert out["tag"] == "Main" + assert out["uptime_s"] == 12345 + assert out["msg"] == "Booting" + assert out["clock"] == "12:34:56" + + def test_invalid_clock(self) -> None: + out = parse_log_line("WARN | ??:??:?? 7 [SerialConsole] Boot") + assert out["level"] == "WARN" + assert out["clock"] == "??:??:??" + assert out["uptime_s"] == 7 + + def test_no_thread_bracket(self) -> None: + out = parse_log_line("DEBUG | 00:00:00 0 raw message body") + assert out["level"] == "DEBUG" + assert out.get("tag") is None + assert out["msg"] == "raw message body" + + def test_bare_message(self) -> None: + # LogRecord.message path — no level prefix at all. + out = parse_log_line("just a bare message") + assert "level" not in out or out.get("level") is None + assert out["line"] == "just a bare message" + + def test_empty(self) -> None: + assert parse_log_line("") == {"line": ""} + + def test_debug_heap_prefix_extracted(self) -> None: + out = parse_log_line("INFO | 12:34:56 12345 [Main] [heap 92344] Booting") + assert out["level"] == "INFO" + assert out["tag"] == "Main" + assert out["heap_free"] == 92344 + assert out["msg"] == "Booting" + + def test_debug_heap_prefix_on_bare_line(self) -> None: + # LogRecord.message path: no level prefix but still has [heap N]. + out = parse_log_line("[heap 12345] some message") + assert out["heap_free"] == 12345 + assert out["msg"] == "some message" + + def test_thread_leak_event(self) -> None: + out = parse_log_line( + "HEAP | 00:00:01 100 [Power] [heap 90000] " + "------ Thread MeshPacket leaked heap 92344 -> 90000 (-2344) ------" + ) + assert out["level"] == "HEAP" + assert out["heap_free"] == 90000 + ev = out["heap_event"] + assert ev["kind"] == "leaked" + assert ev["thread"] == "MeshPacket" + assert ev["before"] == 92344 + assert ev["after"] == 90000 + assert ev["delta"] == -2344 + + def test_thread_freed_event(self) -> None: + out = parse_log_line( + "++++++ Thread Router freed heap 1000 -> 1500 (500) ++++++" + ) + ev = out["heap_event"] + assert ev["kind"] == "freed" + assert ev["thread"] == "Router" + assert ev["delta"] == 500 + + def test_heap_status_periodic(self) -> None: + out = parse_log_line( + "HEAP | 00:00:30 30 [Power] " + "Heap status: 92344/200000 bytes free (-128), running 8/12 threads" + ) + assert out["heap_free"] == 92344 + assert out["heap_total"] == 200000 + assert out["heap_delta"] == -128 + + +class TestRecorderDebugHeapSynthesis: + def test_log_with_heap_writes_telemetry(self, recorder: "Recorder") -> None: + # When a log line carries [heap N], the recorder should also + # emit a synthesized telemetry row tagged source=debug_heap. + recorder._on_log_line( + "INFO | 00:00:00 1 [Main] [heap 88888] hello", + _FakeIface(), + ) + telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines() + synth = [json.loads(r) for r in telem if '"source":"debug_heap"' in r] + assert len(synth) == 1 + assert synth[0]["fields"]["heap_free_bytes"] == 88888 + assert synth[0]["variant"] == "local" + + def test_heap_status_writes_total_too(self, recorder: "Recorder") -> None: + recorder._on_log_line( + "HEAP | 00:00:30 30 [Power] " + "Heap status: 50000/200000 bytes free (-100), running 8/12 threads", + _FakeIface(), + ) + telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines() + synth = [json.loads(r) for r in telem if '"source":"debug_heap"' in r] + assert synth[-1]["fields"]["heap_free_bytes"] == 50000 + assert synth[-1]["fields"]["heap_total_bytes"] == 200000 + + def test_no_heap_no_synthesis(self, recorder: "Recorder") -> None: + # Plain log line (no [heap N], no Heap status) — telemetry.jsonl + # should NOT gain a synth row. + before = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n") + recorder._on_log_line("INFO | 00:00:00 1 [Main] just a message", _FakeIface()) + after = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n") + assert after == before + + def test_thread_leak_event_persists_on_log_row(self, recorder: "Recorder") -> None: + recorder._on_log_line( + "HEAP | 00:00:01 100 [Power] [heap 90000] " + "------ Thread MeshPacket leaked heap 92344 -> 90000 (-2344) ------", + _FakeIface(), + ) + rows = [ + json.loads(r) + for r in (recorder.base_dir / "logs.jsonl").read_text().splitlines() + if r + ] + evt_rows = [r for r in rows if r.get("heap_event")] + assert len(evt_rows) == 1 + assert evt_rows[0]["heap_event"]["thread"] == "MeshPacket" + assert evt_rows[0]["heap_event"]["delta"] == -2344 + + +class TestSerialTap: + def test_serial_line_records_log_and_synthesizes_heap( + self, recorder: "Recorder" + ) -> None: + recorder._on_serial_line( + "INFO | 00:00:00 5 [Main] [heap 88888] tap-line", + port="/dev/cu.tap", + ) + logs = (recorder.base_dir / "logs.jsonl").read_text().splitlines() + telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines() + log_rows = [json.loads(r) for r in logs if r] + # Find the row from this call (port=/dev/cu.tap, role=serial_session) + tap_rows = [r for r in log_rows if r.get("port") == "/dev/cu.tap"] + assert len(tap_rows) == 1 + assert tap_rows[0]["role"] == "serial_session" + assert tap_rows[0]["level"] == "INFO" + assert tap_rows[0]["tag"] == "Main" + assert tap_rows[0]["heap_free"] == 88888 + synth = [json.loads(r) for r in telem if '"source":"debug_heap_serial"' in r] + assert len(synth) == 1 + assert synth[0]["fields"]["heap_free_bytes"] == 88888 + assert synth[0]["role"] == "serial_session" + + def test_serial_line_thread_leak_event(self, recorder: "Recorder") -> None: + recorder._on_serial_line( + "HEAP | 00:00:30 30 [Power] [heap 53484] " + "------ Thread Router leaked heap 53612 -> 53484 (-128) ------", + port="/dev/cu.tap", + ) + rows = [ + json.loads(r) + for r in (recorder.base_dir / "logs.jsonl").read_text().splitlines() + if r + ] + evt = [r for r in rows if r.get("heap_event")] + assert len(evt) == 1 + assert evt[0]["heap_event"]["thread"] == "Router" + assert evt[0]["heap_event"]["delta"] == -128 + # Heap also synthesized. + telem = (recorder.base_dir / "telemetry.jsonl").read_text() + assert '"source":"debug_heap_serial"' in telem + + def test_serial_line_pause(self, recorder: "Recorder") -> None: + recorder.pause("baseline") + recorder._on_serial_line( + "INFO | 00:00:00 1 [t] [heap 1000] dropped", + port="/dev/cu.tap", + ) + # Only the pause event row should exist; no tap row. + logs = (recorder.base_dir / "logs.jsonl").read_text() + assert "dropped" not in logs + + def test_serial_line_handler_swallows_exceptions( + self, recorder: "Recorder" + ) -> None: + # Hostile input — should not raise. + recorder._on_serial_line(None, port="/dev/cu.tap") # type: ignore[arg-type] + recorder._on_serial_line(b"\x00\x01\x02\x03", port="/dev/cu.tap") # type: ignore[arg-type] + # Survived. + + +class TestExtractTelemetry: + def test_local_stats_camel(self) -> None: + pkt = { + "decoded": { + "telemetry": { + "localStats": {"heap_total_bytes": 1000, "heap_free_bytes": 600} + } + } + } + out = extract_telemetry(pkt) + assert out is not None + assert out["variant"] == "local" + assert out["fields"]["heap_free_bytes"] == 600 + + def test_device_metrics_snake(self) -> None: + pkt = { + "decoded": { + "telemetry": {"device_metrics": {"battery_level": 88, "voltage": 4.1}} + } + } + out = extract_telemetry(pkt) + assert out is not None + assert out["variant"] == "device" + assert out["fields"]["battery_level"] == 88 + + def test_unknown_variant_returns_none(self) -> None: + assert extract_telemetry({"decoded": {"telemetry": {"weird": {}}}}) is None + assert extract_telemetry({}) is None + assert extract_telemetry({"decoded": "not-a-dict"}) is None + + +class TestSummarizePacket: + def test_text_with_payload(self) -> None: + pkt = { + "fromId": "!abc", + "toId": "!def", + "decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"}, + "hopLimit": 3, + } + out = summarize_packet(pkt) + assert out["from_node"] == "!abc" + assert out["portnum"] == "TEXT_MESSAGE_APP" + assert out["payload_size"] == 5 + assert out["payload_hex_prefix"] == "68656c6c6f" + + def test_no_decoded(self) -> None: + out = summarize_packet({"fromId": "!abc"}) + assert out["from_node"] == "!abc" + assert out["portnum"] is None + + +class TestInterfaceLabel: + def test_serial(self) -> None: + assert interface_label(_FakeIface()) == { + "port": "/dev/cu.fake", + "role": "serial", + } + + def test_tcp(self) -> None: + class T: + hostname = "node.lan" + portNumber = 4403 + + assert interface_label(T()) == {"port": "tcp://node.lan:4403", "role": "tcp"} + + def test_unknown(self) -> None: + assert interface_label(object()) == {"port": "object", "role": None} + + def test_none(self) -> None: + assert interface_label(None) == {"port": None, "role": None} + + +# -- recorder write side --------------------------------------------- + + +class TestRecorderWrites: + def test_log_line_is_recorded(self, recorder: Recorder) -> None: + recorder._on_log_line("INFO | 12:34:56 99 [T] hi", _FakeIface()) + path = recorder.base_dir / "logs.jsonl" + rows = [json.loads(line) for line in path.read_text().splitlines() if line] + # First row is recorder_start_event mirror? No — that's events.jsonl only. + assert any(r.get("level") == "INFO" and r.get("tag") == "T" for r in rows) + + def test_telemetry_recorded_and_packet_double(self, recorder: Recorder) -> None: + # _on_telemetry alone — only telemetry.jsonl + recorder._on_telemetry( + { + "fromId": "!abc", + "decoded": {"telemetry": {"localStats": {"heap_free_bytes": 600}}}, + }, + _FakeIface(), + ) + telem_rows = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines() + assert any('"variant":"local"' in r for r in telem_rows) + + def test_packets_summary(self, recorder: Recorder) -> None: + recorder._on_receive( + { + "fromId": "!abc", + "toId": "!def", + "decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hi"}, + }, + _FakeIface(), + ) + rows = (recorder.base_dir / "packets.jsonl").read_text().splitlines() + assert any('"portnum":"TEXT_MESSAGE_APP"' in r for r in rows) + + def test_mark_event_round_trip(self, recorder: Recorder) -> None: + out = recorder.mark_event("checkpoint", note="midpoint") + assert "ts" in out + events = (recorder.base_dir / "events.jsonl").read_text().splitlines() + logs = (recorder.base_dir / "logs.jsonl").read_text().splitlines() + assert any('"label":"checkpoint"' in r and '"kind":"mark"' in r for r in events) + assert any('"level":"MARK"' in r and "checkpoint" in r for r in logs) + + def test_pause_drops_writes(self, recorder: Recorder) -> None: + before = len((recorder.base_dir / "logs.jsonl").read_text().splitlines()) + recorder.pause(reason="baseline") + recorder._on_log_line("INFO | 00:00:00 1 [t] swallowed", _FakeIface()) + after = len((recorder.base_dir / "logs.jsonl").read_text().splitlines()) + assert after == before + recorder.resume() + recorder._on_log_line("INFO | 00:00:00 2 [t] kept", _FakeIface()) + post_resume = (recorder.base_dir / "logs.jsonl").read_text() + assert "kept" in post_resume + + def test_pubsub_handler_swallows_exceptions(self, recorder: Recorder) -> None: + # If the writer dies, the pubsub callback must NOT raise — that + # would crash the meshtastic receive thread. + bad_packet = object() # not a dict + recorder._on_receive(bad_packet, _FakeIface()) # type: ignore[arg-type] + recorder._on_telemetry(bad_packet, _FakeIface()) # type: ignore[arg-type] + recorder._on_log_line(None, _FakeIface()) # type: ignore[arg-type] + # No assertion needed — survival is the test. + + +# -- log_query read side --------------------------------------------- + + +class TestLogQuery: + def test_logs_window_grep_and_level(self, recorder: Recorder) -> None: + recorder._on_log_line("INFO | 12:00:00 1 [A] alpha", _FakeIface()) + recorder._on_log_line("WARN | 12:00:01 2 [B] bravo failed", _FakeIface()) + recorder._on_log_line("ERROR | 12:00:02 3 [C] charlie failed", _FakeIface()) + + out = log_query.logs_window(start="-1m", level="WARN|ERROR", max_lines=10) + assert out["total_matched"] == 2 + levels = {r["level"] for r in out["lines"]} + assert levels == {"WARN", "ERROR"} + + out2 = log_query.logs_window(start="-1m", grep=r"failed$", max_lines=10) + assert out2["total_matched"] == 2 + + def test_logs_window_invalid_regex(self, recorder: Recorder) -> None: + recorder._on_log_line("INFO | 12:00:00 1 [A] alpha", _FakeIface()) + with pytest.raises(ValueError, match="invalid grep regex"): + log_query.logs_window(start="-1m", grep="(") + + def test_telemetry_timeline_slope_and_downsample(self, recorder: Recorder) -> None: + # Synthesize a downward leak: 100 points, free_heap drops 1 byte/sample. + base_ts = time.time() - 60 + for i in range(100): + recorder._files["telemetry"].write( + { + "ts": base_ts + i * 0.5, + "port": "/dev/cu.fake", + "role": "serial", + "from_node": "!abc", + "variant": "local", + "fields": {"heap_free_bytes": 10000 - i}, + } + ) + + out = log_query.telemetry_timeline( + window="2m", variant="local", field="free_heap", max_points=10 + ) + assert out["samples"] == 100 + assert len(out["points"]) <= 10 + # Negative slope (heap dropping). Magnitude: 1 byte every 0.5s = 120/min. + assert out["slope_per_min"] is not None + assert out["slope_per_min"] < -100 + + def test_export_bundles_slice(self, recorder: Recorder, tmp_path: Path) -> None: + recorder._on_log_line("INFO | 00:00:00 1 [t] one", _FakeIface()) + recorder._on_log_line("INFO | 00:00:00 2 [t] two", _FakeIface()) + dest = tmp_path / "bundle" + out = log_query.export(start="-1m", end="now", dest_dir=str(dest)) + assert (dest / "logs.jsonl").exists() + assert "logs" in out["paths"] + + +# -- time parser ----------------------------------------------------- + + +class TestParseTime: + def test_relative(self) -> None: + now = 1_000_000.0 + assert log_query._parse_time("-15m", now=now) == now - 900 + assert log_query._parse_time("-2h", now=now) == now - 7200 + assert log_query._parse_time("-1d", now=now) == now - 86400 + + def test_now_and_epoch(self) -> None: + now = 1_000_000.0 + assert log_query._parse_time("now", now=now) == now + assert log_query._parse_time(now) == now + + def test_iso(self) -> None: + ts = log_query._parse_time("2026-01-01T00:00:00Z") + assert isinstance(ts, float) and ts > 1_700_000_000 + + def test_naive_iso_assumes_utc(self) -> None: + assert log_query._parse_time("2026-01-01T00:00:00") == log_query._parse_time( + "2026-01-01T00:00:00Z" + ) + + def test_invalid(self) -> None: + with pytest.raises(ValueError): + log_query._parse_time("not a time") + + +# -- rotation -------------------------------------------------------- + + +class TestRotation: + def test_size_cap_rotates_and_gzips(self, tmp_path: Path) -> None: + path = tmp_path / "rot.jsonl" + r = _RotatingJsonl(path, max_bytes=512, keep_archives=5, check_every=1) + for i in range(100): + r.write({"ts": float(i), "i": i, "pad": "x" * 40}) + r.close() + archives = sorted(tmp_path.glob("rot.*.jsonl.gz")) + assert archives, "expected at least one rotation" + # Archive content is valid gzip + valid JSONL + with gzip.open(archives[0], "rt") as fh: + first = json.loads(fh.readline()) + assert "ts" in first + + def test_archive_pruning(self, tmp_path: Path) -> None: + path = tmp_path / "rot.jsonl" + r = _RotatingJsonl(path, max_bytes=200, keep_archives=2, check_every=1) + # Force several rotations. + for _ in range(8): + for i in range(20): + r.write({"ts": float(i), "pad": "x" * 30}) + r.force_rotate() + r.close() + archives = sorted(tmp_path.glob("rot.*.jsonl.gz")) + assert len(archives) <= 2, f"expected ≤2 kept archives, got {len(archives)}" + + def test_archive_pruning_uses_filename_order(self, tmp_path: Path) -> None: + path = tmp_path / "rot.jsonl" + r = _RotatingJsonl(path, keep_archives=2) + old = tmp_path / "rot.20260101-000000-000000-00000.jsonl.gz" + mid = tmp_path / "rot.20260101-000001-000000-00000.jsonl.gz" + new = tmp_path / "rot.20260101-000002-000000-00000.jsonl.gz" + for archive in (old, mid, new): + with gzip.open(archive, "wt", encoding="utf-8") as fh: + fh.write('{"ts":1}\n') + # Deliberately scramble mtimes so lexicographic filename order is + # the only stable chronological signal. + os.utime(old, (300, 300)) + os.utime(mid, (100, 100)) + os.utime(new, (200, 200)) + + r._prune_archives() + r.close() + + archives = sorted(p.name for p in tmp_path.glob("rot.*.jsonl.gz")) + assert archives == [mid.name, new.name] + + def test_force_rotate_when_below_threshold(self, tmp_path: Path) -> None: + path = tmp_path / "rot.jsonl" + r = _RotatingJsonl(path, max_bytes=10_000_000, check_every=999_999) + r.write({"ts": 1.0, "msg": "tiny"}) + r.force_rotate() + r.write({"ts": 2.0, "msg": "after-rotate"}) + r.close() + archives = sorted(tmp_path.glob("rot.*.jsonl.gz")) + assert len(archives) == 1 + assert path.exists() + assert "after-rotate" in path.read_text() + + +class TestRecorderLocks: + def test_force_rotate_all_returns_status(self, recorder: Recorder) -> None: + out = recorder.force_rotate_all() + assert out["running"] is True + assert out["files"] + + def test_wire_pubsub_logs_subscription_failure( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + class FailingPubSubMock: + def subscribe(self, callback: object, topic: str) -> None: + raise RuntimeError("boom") + + monkeypatch.setattr(pubsub, "pub", FailingPubSubMock()) + recorder = Recorder(base_dir=tmp_path) + with caplog.at_level(logging.WARNING): + recorder._wire_pubsub() + assert ( + "Recorder failed to subscribe to meshtastic.log.line: boom" in caplog.text + ) From d79e62fd2a65db64cea1fe18dffc4caac9a40be2 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 10 May 2026 10:20:10 -0500 Subject: [PATCH 155/225] Chatty LLMs should pipe down --- .github/copilot-instructions.md | 1 + AGENTS.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fe9af4359b5..8980aca1a1c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -196,6 +196,7 @@ firmware/ - Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging - Use `assert()` for invariants that should never fail - C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.) +- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. ### Naming Conventions diff --git a/AGENTS.md b/AGENTS.md index cdccda1f48b..113f12591e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor - **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate. - **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. - **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings. +- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. ## Typical agent workflows From 33319aa4e25c1078bcff3c4cd5747d4d2d803394 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 07:30:03 -0500 Subject: [PATCH 156/225] Upgrade trunk (#10451) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 558d67d9c39..00649f7ff78 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -4,11 +4,11 @@ cli: plugins: sources: - id: trunk - ref: v1.8.0 + ref: v1.9.0 uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.527 + - checkov@3.2.528 - renovate@43.150.0 - prettier@3.8.3 - trufflehog@3.95.2 From 9bc25b34fd98f70e65a86d64ba4d9789cdc51660 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 11 May 2026 07:42:04 -0500 Subject: [PATCH 157/225] Add guidance to use Throttle for time-based rate limiting in agent instructions --- .github/copilot-instructions.md | 1 + AGENTS.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8980aca1a1c..8573b5f4512 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -197,6 +197,7 @@ firmware/ - Use `assert()` for invariants that should never fail - C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.) - **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. +- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly. ### Naming Conventions diff --git a/AGENTS.md b/AGENTS.md index 113f12591e7..82912f252f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor - **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. - **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings. - **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. +- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly. ## Typical agent workflows From dfcb6859637d034a5af782eea58d75deece8b976 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 11 May 2026 08:08:15 -0500 Subject: [PATCH 158/225] Update protos --- protobufs | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 2 ++ src/mesh/generated/meshtastic/telemetry.pb.h | 10 +++++++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/protobufs b/protobufs index 1d6f1a71ff3..03eb5347de9 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 1d6f1a71ff329fa52ad8bb7899951e96f8280a1f +Subproject commit 03eb5347de9fbabc3bb57423236def3cc8b9481d diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index f228250303a..facf00a7fa9 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -317,6 +317,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_THINKNODE_M9 = 131, /* The Heltec-V4-R8 uses an ESP32S3R8 chip, plus an SX1262. */ meshtastic_HardwareModel_HELTEC_V4_R8 = 132, + /* The HELTEC_MESH_NODE_T1 uses an NRF52840 chip, plus an SX1262. */ + meshtastic_HardwareModel_HELTEC_MESH_NODE_T1 = 133, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index 8c0fdd56314..e8d337049df 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -115,7 +115,11 @@ typedef enum _meshtastic_TelemetrySensorType { /* SHT family of sensors for temperature and humidity */ meshtastic_TelemetrySensorType_SHTXX = 50, /* DS248X Bridge for one-wire temperature sensors */ - meshtastic_TelemetrySensorType_DS248X = 51 + meshtastic_TelemetrySensorType_DS248X = 51, + /* MMC5983MA 3-Axis Digital Magnetic Sensor */ + meshtastic_TelemetrySensorType_MMC5983MA = 52, + /* ICM-42607-P 6‑Axis IMU */ + meshtastic_TelemetrySensorType_ICM42607P = 53 } meshtastic_TelemetrySensorType; /* Struct definitions */ @@ -496,8 +500,8 @@ extern "C" { /* Helper constants for enums */ #define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET -#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_DS248X -#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_DS248X+1)) +#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_ICM42607P +#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_ICM42607P+1)) From a23f923e64eaf34ddfaf694eb8215a600c2854b1 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 11 May 2026 08:09:39 -0500 Subject: [PATCH 159/225] Update subproject commit reference in protobufs --- protobufs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protobufs b/protobufs index 03eb5347de9..559f3c12dd2 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 03eb5347de9fbabc3bb57423236def3cc8b9481d +Subproject commit 559f3c12dd2cfdb9a80153546d1755d6dac3005b From 88776088583aa213494ced1de23e7d279b955a2e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 11 May 2026 09:32:55 -0500 Subject: [PATCH 160/225] Protos --- protobufs | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/protobufs b/protobufs index 03eb5347de9..b302d923327 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 03eb5347de9fbabc3bb57423236def3cc8b9481d +Subproject commit b302d923327402fbe49efcf15ff1b6ef2361b22b diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index facf00a7fa9..41ef2798c77 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -319,6 +319,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_HELTEC_V4_R8 = 132, /* The HELTEC_MESH_NODE_T1 uses an NRF52840 chip, plus an SX1262. */ meshtastic_HardwareModel_HELTEC_MESH_NODE_T1 = 133, + /* B&Q Consulting Station G3: TBD */ + meshtastic_HardwareModel_STATION_G3 = 134, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ From 8e99ffbe7e7da97484644318a036729a40c7de2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 11 May 2026 18:33:13 +0200 Subject: [PATCH 161/225] ThinkNode M7 (#8077) * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * rename variant and add guard macros * older G3 operational. M7 next. * Split out G3 and M7 to different variants. Completely new PCB design. The G3 stays on 'PRIVATE_HW' * Define button behaviour and use all of the device flash --------- Co-authored-by: Ben Meadors Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> Co-authored-by: Jonathan Bennett --- boards/ThinkNode-M7.json | 42 +++++++++ src/DebugConfiguration.cpp | 2 +- src/DebugConfiguration.h | 9 +- src/input/InputBroker.cpp | 6 ++ src/input/InputBroker.h | 1 + src/main.cpp | 6 +- src/mesh/InterfacesTemplates.cpp | 2 +- src/mesh/api/ethServerAPI.cpp | 2 +- src/mesh/api/ethServerAPI.h | 2 +- src/mesh/eth/ethClient.cpp | 2 +- src/mesh/wifi/WiFiAPClient.cpp | 87 +++++++++++++++++-- src/mesh/wifi/WiFiAPClient.h | 2 +- src/modules/AdminModule.cpp | 2 +- src/modules/SystemCommandsModule.cpp | 11 +++ src/mqtt/MQTT.cpp | 6 ++ src/mqtt/MQTT.h | 2 +- src/platform/esp32/architecture.h | 2 + src/platform/esp32/main-esp32.cpp | 2 +- .../ELECROW-ThinkNode-G3/pins_arduino.h | 26 ++++++ .../ELECROW-ThinkNode-G3/platformio.ini | 20 +++++ .../esp32s3/ELECROW-ThinkNode-G3/variant.h | 36 ++++++++ .../ELECROW-ThinkNode-M7/pins_arduino.h | 19 ++++ .../ELECROW-ThinkNode-M7/platformio.ini | 19 ++++ .../esp32s3/ELECROW-ThinkNode-M7/rfswitch.h | 11 +++ .../esp32s3/ELECROW-ThinkNode-M7/variant.h | 43 +++++++++ 25 files changed, 340 insertions(+), 22 deletions(-) create mode 100644 boards/ThinkNode-M7.json create mode 100644 variants/esp32s3/ELECROW-ThinkNode-G3/pins_arduino.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini create mode 100644 variants/esp32s3/ELECROW-ThinkNode-G3/variant.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M7/pins_arduino.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M7/rfswitch.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M7/variant.h diff --git a/boards/ThinkNode-M7.json b/boards/ThinkNode-M7.json new file mode 100644 index 00000000000..2a0c5e5838a --- /dev/null +++ b/boards/ThinkNode-M7.json @@ -0,0 +1,42 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-D BOARD_HAS_PSRAM", + "-D ARDUINO_USB_CDC_ON_BOOT=0", + "-D ARDUINO_USB_MODE=0", + "-D ARDUINO_RUNNING_CORE=1", + "-D ARDUINO_EVENT_RUNNING_CORE=0" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "qio_opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "ELECROW-ThinkNode-M7" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "ELECROW ThinkNode M7", + "upload": { + "flash_size": "8MB", + "maximum_ram_size": 524288, + "maximum_size": 8388608, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.elecrow.com", + "vendor": "ELECROW" +} diff --git a/src/DebugConfiguration.cpp b/src/DebugConfiguration.cpp index f83624bbf48..b96d0a9f1b8 100644 --- a/src/DebugConfiguration.cpp +++ b/src/DebugConfiguration.cpp @@ -151,7 +151,7 @@ inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *mess if (!this->_enabled) return false; - if ((this->_server == NULL && this->_ip == INADDR_NONE) || this->_port == 0) + if ((this->_server == NULL && this->_ip == IPAddress(0, 0, 0, 0)) || this->_port == 0) return false; // Check priority against priMask values. diff --git a/src/DebugConfiguration.h b/src/DebugConfiguration.h index eac6260fcee..a78dde78e31 100644 --- a/src/DebugConfiguration.h +++ b/src/DebugConfiguration.h @@ -13,6 +13,11 @@ extern MemGet memGet; #define LED_STATE_ON 1 #endif +// WIFI LED +#ifndef WIFI_STATE_ON +#define WIFI_STATE_ON 1 +#endif + // ----------------------------------------------------------------------------- // DEBUG // ----------------------------------------------------------------------------- @@ -147,7 +152,9 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...); // Default Bluetooth PIN #define defaultBLEPin 123456 -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && defined(USE_CH390D) +#include +#elif HAS_ETHERNET && !defined(USE_WS5500) #include #endif // HAS_ETHERNET diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index 393cbc0ec22..42ab7f70d1e 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -333,6 +333,12 @@ void InputBroker::Init() BaseType_t higherWake = 0; concurrency::mainDelay.interruptFromISR(&higherWake); }; +#if defined(ELECROW_ThinkNode_M7) + userConfigNoScreen.longLongPressTime = 15 * 1000; + userConfigNoScreen.longLongPress = INPUT_BROKER_FACTORY_RST; +#else + userConfigNoScreen.longLongPress = INPUT_BROKER_SHUTDOWN; +#endif userConfigNoScreen.singlePress = INPUT_BROKER_USER_PRESS; userConfigNoScreen.longPress = INPUT_BROKER_NONE; userConfigNoScreen.longPressTime = 500; diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index 847604011d0..975c9d9f4a2 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -25,6 +25,7 @@ enum input_broker_event { INPUT_BROKER_USER_PRESS, INPUT_BROKER_ALT_PRESS, INPUT_BROKER_ALT_LONG, + INPUT_BROKER_FACTORY_RST = 0x9a, INPUT_BROKER_SHUTDOWN = 0x9b, INPUT_BROKER_GPS_TOGGLE = 0x9e, INPUT_BROKER_SEND_PING = 0xaf, diff --git a/src/main.cpp b/src/main.cpp index 2fb2006d8cc..2f4b12437c6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -59,12 +59,12 @@ NimbleBluetooth *nimbleBluetooth = nullptr; NRF52Bluetooth *nrf52Bluetooth = nullptr; #endif -#if HAS_WIFI || defined(USE_WS5500) +#if HAS_WIFI || defined(USE_WS5500) || defined(USE_CH390D) #include "mesh/api/WiFiServerAPI.h" #include "mesh/wifi/WiFiAPClient.h" #endif -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D) #include "mesh/api/ethServerAPI.h" #include "mesh/eth/ethClient.h" #endif @@ -335,7 +335,7 @@ void setup() #ifdef WIFI_LED pinMode(WIFI_LED, OUTPUT); - digitalWrite(WIFI_LED, LOW); + digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON); #endif #ifdef BLE_LED diff --git a/src/mesh/InterfacesTemplates.cpp b/src/mesh/InterfacesTemplates.cpp index 907cc2a4e04..6f6b2d2a14d 100644 --- a/src/mesh/InterfacesTemplates.cpp +++ b/src/mesh/InterfacesTemplates.cpp @@ -32,7 +32,7 @@ template class LR20x0Interface; template class SX126xInterface; #endif -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D) #include "api/ethServerAPI.h" template class ServerAPI; template class APIServerPort; diff --git a/src/mesh/api/ethServerAPI.cpp b/src/mesh/api/ethServerAPI.cpp index 43ed74cf811..c75d53ff7c2 100644 --- a/src/mesh/api/ethServerAPI.cpp +++ b/src/mesh/api/ethServerAPI.cpp @@ -1,7 +1,7 @@ #include "configuration.h" #include -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D) #include "ethServerAPI.h" diff --git a/src/mesh/api/ethServerAPI.h b/src/mesh/api/ethServerAPI.h index 8f81ee6ffff..07de392cf1f 100644 --- a/src/mesh/api/ethServerAPI.h +++ b/src/mesh/api/ethServerAPI.h @@ -1,7 +1,7 @@ #pragma once #include "ServerAPI.h" -#ifndef USE_WS5500 +#if !defined(USE_WS5500) && !defined(USE_CH390D) #include /** diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index 440f7b76a88..bb62327272f 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -9,7 +9,7 @@ #include #include -#if HAS_NETWORKING +#if HAS_NETWORKING && !defined(USE_WS5500) && !defined(USE_CH390D) #ifndef DISABLE_NTP #include diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index 5d05c7fc6df..be25e6865b7 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -15,6 +15,12 @@ #define ETH ETH2 #endif // HAS_ETHERNET +#if HAS_ETHERNET && defined(USE_CH390D) +#include "ESP32_CH390.h" +#include "hal/spi_types.h" +#define ETH CH390 +#endif // HAS_ETHERNET + #include #ifdef ARCH_ESP32 #if !MESHTASTIC_EXCLUDE_WEBSERVER @@ -56,12 +62,43 @@ unsigned long lastrun_ntp = 0; bool needReconnect = true; // If we create our reconnector, run it once at the beginning bool isReconnecting = false; // If we are currently reconnecting +#if defined(USE_WS5500) || defined(USE_CH390D) +static volatile bool ethNetworkConnectedPending = false; +#endif WiFiUDP syslogClient; meshtastic::Syslog syslog(syslogClient); Periodic *wifiReconnect; +#if defined(USE_WS5500) || defined(USE_CH390D) +static void onNetworkConnected(); +static uint32_t lastEthIP = 0; +static int32_t ethNetworkConnectedPoll() +{ + if (ethNetworkConnectedPending) { + ethNetworkConnectedPending = false; + uint32_t ip = (uint32_t)ETH.localIP(); + bool ipChanged = APStartupComplete && ip != 0 && ip != lastEthIP; + onNetworkConnected(); + if (ipChanged) { + LOG_INFO("Ethernet IP changed (%u.%u.%u.%u), restarting mDNS", ip & 0xff, (ip >> 8) & 0xff, (ip >> 16) & 0xff, + (ip >> 24) & 0xff); + MDNS.end(); + if (MDNS.begin("Meshtastic")) { + MDNS.addService("meshtastic", "tcp", SERVER_API_DEFAULT_PORT); + MDNS.addServiceTxt("meshtastic", "tcp", "shortname", String(owner.short_name)); + MDNS.addServiceTxt("meshtastic", "tcp", "id", String(nodeDB->getNodeId().c_str())); + MDNS.addServiceTxt("meshtastic", "tcp", "pio_env", optstr(APP_ENV)); + } + } + if (ip != 0) + lastEthIP = ip; + } + return 500; +} +#endif + #ifdef USE_WS5500 // Startup Ethernet bool initEthernet() @@ -72,6 +109,38 @@ bool initEthernet() #if !MESHTASTIC_EXCLUDE_WEBSERVER createSSLCert(); // For WebServer #endif + new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); + return true; + } + + return false; +} +#endif + +#ifdef USE_CH390D +// Startup Ethernet +bool initEthernet() +{ + // Configure CH390 + ch390_config_t ch390_conf = CH390_DEFAULT_CONFIG(); + ch390_conf.spi_host = SPI3_HOST; + ch390_conf.spi_cs_gpio = ETH_CS_PIN; + ch390_conf.spi_sck_gpio = ETH_SCLK_PIN; + ch390_conf.spi_mosi_gpio = ETH_MOSI_PIN; + ch390_conf.spi_miso_gpio = ETH_MISO_PIN; + ch390_conf.int_gpio = ETH_INT_PIN; +#ifdef ETH_RST_PIN + ch390_conf.reset_gpio = ETH_RST_PIN; +#else + ch390_conf.reset_gpio = -1; +#endif + ch390_conf.spi_clock_mhz = 20; + if ((config.network.eth_enabled) && (ETH.begin(ch390_conf))) { + WiFi.onEvent(WiFiEvent); +#if !MESHTASTIC_EXCLUDE_WEBSERVER + createSSLCert(); // For WebServer +#endif + new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); return true; } @@ -234,7 +303,7 @@ bool isWifiAvailable() if (config.network.wifi_enabled && (config.network.wifi_ssid[0])) { return true; -#ifdef USE_WS5500 +#if defined(USE_WS5500) || defined(USE_CH390D) } else if (config.network.eth_enabled) { return true; #endif @@ -384,13 +453,13 @@ static void WiFiEvent(WiFiEvent_t event) #endif } #ifdef WIFI_LED - digitalWrite(WIFI_LED, HIGH); + digitalWrite(WIFI_LED, LOW ^ WIFI_STATE_ON); #endif break; case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: LOG_INFO("Disconnected from WiFi access point"); #ifdef WIFI_LED - digitalWrite(WIFI_LED, LOW); + digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON); #endif #if HAS_UDP_MULTICAST if (udpHandler) { @@ -452,13 +521,13 @@ static void WiFiEvent(WiFiEvent_t event) case ARDUINO_EVENT_WIFI_AP_START: LOG_INFO("WiFi access point started"); #ifdef WIFI_LED - digitalWrite(WIFI_LED, HIGH); + digitalWrite(WIFI_LED, LOW ^ WIFI_STATE_ON); #endif break; case ARDUINO_EVENT_WIFI_AP_STOP: LOG_INFO("WiFi access point stopped"); #ifdef WIFI_LED - digitalWrite(WIFI_LED, LOW); + digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON); #endif break; case ARDUINO_EVENT_WIFI_AP_STACONNECTED: @@ -494,18 +563,18 @@ static void WiFiEvent(WiFiEvent_t event) LOG_INFO("Ethernet disconnected"); break; case ARDUINO_EVENT_ETH_GOT_IP: -#ifdef USE_WS5500 +#if defined(USE_WS5500) || defined(USE_CH390D) LOG_INFO("Obtained IP address: %s, %u Mbps, %s", ETH.localIP().toString().c_str(), ETH.linkSpeed(), ETH.fullDuplex() ? "FULL_DUPLEX" : "HALF_DUPLEX"); - onNetworkConnected(); + ethNetworkConnectedPending = true; #endif break; case ARDUINO_EVENT_ETH_GOT_IP6: -#ifdef USE_WS5500 +#if defined(USE_WS5500) || defined(USE_CH390D) #if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0) LOG_INFO("Obtained Local IP6 address: %s", ETH.linkLocalIPv6().toString().c_str()); LOG_INFO("Obtained GlobalIP6 address: %s", ETH.globalIPv6().toString().c_str()); -#else +#elif defined(USE_WS5500) LOG_INFO("Obtained IP6 address: %s", ETH.localIPv6().toString().c_str()); #endif #endif diff --git a/src/mesh/wifi/WiFiAPClient.h b/src/mesh/wifi/WiFiAPClient.h index 078c4019321..1de897d7a58 100644 --- a/src/mesh/wifi/WiFiAPClient.h +++ b/src/mesh/wifi/WiFiAPClient.h @@ -26,7 +26,7 @@ bool isWifiAvailable(); uint8_t getWifiDisconnectReason(); -#ifdef USE_WS5500 +#if defined(USE_WS5500) || defined(USE_CH390D) // Startup Ethernet bool initEthernet(); #endif \ No newline at end of file diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 396917693be..ba1cfdb97e0 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1318,7 +1318,7 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r } #endif -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D) conn.has_ethernet = true; conn.ethernet.has_status = true; if (Ethernet.linkStatus() == LinkON) { diff --git a/src/modules/SystemCommandsModule.cpp b/src/modules/SystemCommandsModule.cpp index 1da75636605..7e07414cd02 100644 --- a/src/modules/SystemCommandsModule.cpp +++ b/src/modules/SystemCommandsModule.cpp @@ -115,6 +115,17 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) case INPUT_BROKER_SHUTDOWN: shutdownAtMsec = millis(); return true; + // factory reset + case INPUT_BROKER_FACTORY_RST: + disableBluetooth(); + LOG_INFO("Initiate full factory reset"); + nodeDB->factoryReset(true); + // reboot(DEFAULT_REBOOT_SECONDS); + LOG_INFO("Reboot in %d seconds", DEFAULT_REBOOT_SECONDS); + if (screen) + screen->showSimpleBanner("Rebooting...", 0); // stays on screen + rebootAtMsec = (DEFAULT_REBOOT_SECONDS < 0) ? 0 : (millis() + DEFAULT_REBOOT_SECONDS * 1000); + return true; default: // No other input events handled here diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index bd02ac0458b..a6ae79614e4 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -22,6 +22,9 @@ #if HAS_ETHERNET && defined(USE_WS5500) #include #define ETH ETH2 +#elif HAS_ETHERNET && defined(USE_CH390D) +#include "ESP32_CH390.h" +#define ETH CH390 #endif // HAS_ETHERNET #include "Default.h" #if !defined(ARCH_NRF52) || NRF52_USE_JSON @@ -344,6 +347,9 @@ inline bool isConnectedToNetwork() #ifdef USE_WS5500 if (ETH.connected()) return true; +#elif defined(USE_CH390D) + if (ETH.isConnected()) + return true; #endif #if HAS_WIFI diff --git a/src/mqtt/MQTT.h b/src/mqtt/MQTT.h index 7d571560263..b1b143f04cc 100644 --- a/src/mqtt/MQTT.h +++ b/src/mqtt/MQTT.h @@ -15,7 +15,7 @@ #include #endif #endif -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D) #include #endif diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 30398a675f8..cd3ac1f9db9 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -146,6 +146,8 @@ #define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M2 #elif defined(ELECROW_ThinkNode_M5) #define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M5 +#elif defined(ELECROW_ThinkNode_M7) +#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M7 #elif defined(ESP32_S3_PICO) #define HW_VENDOR meshtastic_HardwareModel_ESP32_S3_PICO #elif defined(SENSELORA_S3) diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index 25cb30e962e..dbc573c95e8 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -32,7 +32,7 @@ void variant_shutdown() {} #if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH void setBluetoothEnable(bool enable) { -#ifdef USE_WS5500 +#if defined(USE_WS5500) || defined(USE_CH390D) if ((config.bluetooth.enabled == true) && (config.network.wifi_enabled == false)) #elif HAS_WIFI if (!isWifiAvailable() && config.bluetooth.enabled == true) diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/pins_arduino.h b/variants/esp32s3/ELECROW-ThinkNode-G3/pins_arduino.h new file mode 100644 index 00000000000..35935577246 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/pins_arduino.h @@ -0,0 +1,26 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// The default Wire will be mapped to PMU and RTC +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +// Default SPI will be mapped to Radio +static const uint8_t SS = 39; +static const uint8_t MOSI = 40; +static const uint8_t MISO = 41; +static const uint8_t SCK = 42; + +// #define SPI_MOSI (11) +// #define SPI_SCK (10) +// #define SPI_MISO (9) +// #define SPI_CS (12) + +// #define SDCARD_CS SPI_CS + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini new file mode 100644 index 00000000000..e2c83efe5a4 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini @@ -0,0 +1,20 @@ +[env:thinknode_g3] +extends = esp32s3_base +board = ESP32-S3-WROOM-1-N4 +board_build.psram_type = opi + +build_flags = + ${esp32s3_base.build_flags} + -D HAS_UDP_MULTICAST=1 + -D BOARD_HAS_PSRAM + -D PRIVATE_HW + -I variants/esp32s3/ELECROW-ThinkNode-G3 + -mfix-esp32-psram-cache-issue + +lib_ignore = + Ethernet + +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 + https://github.com/meshtastic/ESP32-CH390/archive/refs/tags/v1.0.1.zip diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/variant.h b/variants/esp32s3/ELECROW-ThinkNode-G3/variant.h new file mode 100644 index 00000000000..c5afd574a6c --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/variant.h @@ -0,0 +1,36 @@ +#define HAS_GPS 0 +#define HAS_WIRE 0 +#define I2C_NO_RESCAN + +#define WIFI_LED 5 +#define WIFI_STATE_ON 0 + +#define LED_PIN 6 +#define LED_STATE_ON 0 +#define BUTTON_PIN 4 + +#define LORA_SCK 42 +#define LORA_MISO 41 +#define LORA_MOSI 40 +#define LORA_CS 39 +#define LORA_RESET 21 + +#define USE_SX1262 +#define SX126X_CS LORA_CS +#define SX126X_DIO1 15 +#define SX126X_BUSY 47 +#define SX126X_RESET LORA_RESET +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 +#define PIN_POWER_EN 45 + +#define HAS_ETHERNET 1 +#define USE_CH390D 1 + +#define ETH_MISO_PIN 12 +#define ETH_MOSI_PIN 11 +#define ETH_SCLK_PIN 13 +#define ETH_CS_PIN 14 +#define ETH_INT_PIN 10 +#define ETH_RST_PIN 9 +// #define ETH_ADDR 1 diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/pins_arduino.h b/variants/esp32s3/ELECROW-ThinkNode-M7/pins_arduino.h new file mode 100644 index 00000000000..bcc9cd1d5e6 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/pins_arduino.h @@ -0,0 +1,19 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// The default Wire will be mapped to PMU and RTC +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +// Default SPI is the LR1110 radio bus +static const uint8_t SS = 12; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 9; +static const uint8_t SCK = 11; + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini new file mode 100644 index 00000000000..f2ddbf8d268 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini @@ -0,0 +1,19 @@ +[env:thinknode_m7] +extends = esp32s3_base +board = ThinkNode-M7 + +build_flags = + ${esp32s3_base.build_flags} + -D ELECROW_ThinkNode_M7 + -D HAS_UDP_MULTICAST=1 + -D BOARD_HAS_PSRAM + -I variants/esp32s3/ELECROW-ThinkNode-M7 + -mfix-esp32-psram-cache-issue + +lib_ignore = + Ethernet + +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 + https://github.com/meshtastic/ESP32-CH390/archive/refs/tags/v1.0.1.zip \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/rfswitch.h b/variants/esp32s3/ELECROW-ThinkNode-M7/rfswitch.h new file mode 100644 index 00000000000..e5fe182c4f6 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/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-M7/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.h new file mode 100644 index 00000000000..9724b20fab3 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.h @@ -0,0 +1,43 @@ +#define HAS_GPS 0 +#define HAS_WIRE 0 +#define HAS_SCREEN 0 +#define I2C_NO_RESCAN + +#define UART_TX 43 +#define UART_RX 44 + +#define WIFI_LED 3 +#define WIFI_STATE_ON 0 + +#define LED_PIN 46 +#define LED_STATE_ON 0 +#define BUTTON_PIN 4 +#define BUTTON_ACTIVE_LOW true +#define BUTTON_ACTIVE_PULLUP true + +#define LORA_SCK 11 +#define LORA_MISO 9 +#define LORA_MOSI 10 +#define LORA_CS 12 +#define LORA_RESET 39 + +#define USE_LR1110 +#define LR1110_SPI_SCK_PIN LORA_SCK +#define LR1110_SPI_MISO_PIN LORA_MISO +#define LR1110_SPI_MOSI_PIN LORA_MOSI +#define LR1110_SPI_NSS_PIN LORA_CS +#define LR1110_IRQ_PIN 38 +#define LR1110_BUSY_PIN 13 +#define LR1110_NRESET_PIN LORA_RESET +#define LR11X0_DIO3_TCXO_VOLTAGE 1.8 +#define LR11X0_DIO_AS_RF_SWITCH + +#define HAS_ETHERNET 1 +#define USE_CH390D 1 + +#define ETH_MISO_PIN 14 +#define ETH_MOSI_PIN 48 +#define ETH_SCLK_PIN 47 +#define ETH_CS_PIN 21 +#define ETH_INT_PIN 45 +// #define ETH_ADDR 1 From da61a0db7d18e23e4c05832d8bd7598668b78def Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 11 May 2026 11:45:14 -0500 Subject: [PATCH 162/225] Refactor mutex handling in PhoneAPI.cpp Replace LockGuard with explicit lock and unlock calls to avoid deadlock --- src/mesh/PhoneAPI.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index ffe413056b3..720743907ca 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -545,8 +545,9 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) prefetchNodeInfos(); } else { LOG_DEBUG("Done sending %d of %d nodeinfos millis=%u", readIndex, nodeDB->getNumMeshNodes(), millis()); - concurrency::LockGuard guard(&nodeInfoMutex); + nodeInfoMutex.lock(); nodeInfoQueue.clear(); + nodeInfoMutex.unlock(); // Replay states no-op for legacy clients / excluded DBs. state = STATE_REPLAY_POSITIONS; return getFromRadio(buf); From 64fd61706ddb55583dc948e874d435d2f3219555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 11 May 2026 18:33:13 +0200 Subject: [PATCH 163/225] ThinkNode M7 (#8077) * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * ThinkNode G3, ETH support WIP * rename variant and add guard macros * older G3 operational. M7 next. * Split out G3 and M7 to different variants. Completely new PCB design. The G3 stays on 'PRIVATE_HW' * Define button behaviour and use all of the device flash --------- Co-authored-by: Ben Meadors Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> Co-authored-by: Jonathan Bennett --- boards/ThinkNode-M7.json | 42 +++++++++ src/DebugConfiguration.cpp | 2 +- src/DebugConfiguration.h | 9 +- src/input/InputBroker.cpp | 6 ++ src/input/InputBroker.h | 1 + src/main.cpp | 6 +- src/mesh/InterfacesTemplates.cpp | 2 +- src/mesh/api/ethServerAPI.cpp | 2 +- src/mesh/api/ethServerAPI.h | 2 +- src/mesh/eth/ethClient.cpp | 2 +- src/mesh/wifi/WiFiAPClient.cpp | 87 +++++++++++++++++-- src/mesh/wifi/WiFiAPClient.h | 2 +- src/modules/AdminModule.cpp | 2 +- src/modules/SystemCommandsModule.cpp | 11 +++ src/mqtt/MQTT.cpp | 6 ++ src/mqtt/MQTT.h | 2 +- src/platform/esp32/architecture.h | 2 + src/platform/esp32/main-esp32.cpp | 2 +- .../ELECROW-ThinkNode-G3/pins_arduino.h | 26 ++++++ .../ELECROW-ThinkNode-G3/platformio.ini | 20 +++++ .../esp32s3/ELECROW-ThinkNode-G3/variant.h | 36 ++++++++ .../ELECROW-ThinkNode-M7/pins_arduino.h | 19 ++++ .../ELECROW-ThinkNode-M7/platformio.ini | 19 ++++ .../esp32s3/ELECROW-ThinkNode-M7/rfswitch.h | 11 +++ .../esp32s3/ELECROW-ThinkNode-M7/variant.h | 43 +++++++++ 25 files changed, 340 insertions(+), 22 deletions(-) create mode 100644 boards/ThinkNode-M7.json create mode 100644 variants/esp32s3/ELECROW-ThinkNode-G3/pins_arduino.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini create mode 100644 variants/esp32s3/ELECROW-ThinkNode-G3/variant.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M7/pins_arduino.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M7/rfswitch.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M7/variant.h diff --git a/boards/ThinkNode-M7.json b/boards/ThinkNode-M7.json new file mode 100644 index 00000000000..2a0c5e5838a --- /dev/null +++ b/boards/ThinkNode-M7.json @@ -0,0 +1,42 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-D BOARD_HAS_PSRAM", + "-D ARDUINO_USB_CDC_ON_BOOT=0", + "-D ARDUINO_USB_MODE=0", + "-D ARDUINO_RUNNING_CORE=1", + "-D ARDUINO_EVENT_RUNNING_CORE=0" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "qio_opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "ELECROW-ThinkNode-M7" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "ELECROW ThinkNode M7", + "upload": { + "flash_size": "8MB", + "maximum_ram_size": 524288, + "maximum_size": 8388608, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.elecrow.com", + "vendor": "ELECROW" +} diff --git a/src/DebugConfiguration.cpp b/src/DebugConfiguration.cpp index f83624bbf48..b96d0a9f1b8 100644 --- a/src/DebugConfiguration.cpp +++ b/src/DebugConfiguration.cpp @@ -151,7 +151,7 @@ inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *mess if (!this->_enabled) return false; - if ((this->_server == NULL && this->_ip == INADDR_NONE) || this->_port == 0) + if ((this->_server == NULL && this->_ip == IPAddress(0, 0, 0, 0)) || this->_port == 0) return false; // Check priority against priMask values. diff --git a/src/DebugConfiguration.h b/src/DebugConfiguration.h index eac6260fcee..a78dde78e31 100644 --- a/src/DebugConfiguration.h +++ b/src/DebugConfiguration.h @@ -13,6 +13,11 @@ extern MemGet memGet; #define LED_STATE_ON 1 #endif +// WIFI LED +#ifndef WIFI_STATE_ON +#define WIFI_STATE_ON 1 +#endif + // ----------------------------------------------------------------------------- // DEBUG // ----------------------------------------------------------------------------- @@ -147,7 +152,9 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...); // Default Bluetooth PIN #define defaultBLEPin 123456 -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && defined(USE_CH390D) +#include +#elif HAS_ETHERNET && !defined(USE_WS5500) #include #endif // HAS_ETHERNET diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index 393cbc0ec22..42ab7f70d1e 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -333,6 +333,12 @@ void InputBroker::Init() BaseType_t higherWake = 0; concurrency::mainDelay.interruptFromISR(&higherWake); }; +#if defined(ELECROW_ThinkNode_M7) + userConfigNoScreen.longLongPressTime = 15 * 1000; + userConfigNoScreen.longLongPress = INPUT_BROKER_FACTORY_RST; +#else + userConfigNoScreen.longLongPress = INPUT_BROKER_SHUTDOWN; +#endif userConfigNoScreen.singlePress = INPUT_BROKER_USER_PRESS; userConfigNoScreen.longPress = INPUT_BROKER_NONE; userConfigNoScreen.longPressTime = 500; diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index 847604011d0..975c9d9f4a2 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -25,6 +25,7 @@ enum input_broker_event { INPUT_BROKER_USER_PRESS, INPUT_BROKER_ALT_PRESS, INPUT_BROKER_ALT_LONG, + INPUT_BROKER_FACTORY_RST = 0x9a, INPUT_BROKER_SHUTDOWN = 0x9b, INPUT_BROKER_GPS_TOGGLE = 0x9e, INPUT_BROKER_SEND_PING = 0xaf, diff --git a/src/main.cpp b/src/main.cpp index 2fb2006d8cc..2f4b12437c6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -59,12 +59,12 @@ NimbleBluetooth *nimbleBluetooth = nullptr; NRF52Bluetooth *nrf52Bluetooth = nullptr; #endif -#if HAS_WIFI || defined(USE_WS5500) +#if HAS_WIFI || defined(USE_WS5500) || defined(USE_CH390D) #include "mesh/api/WiFiServerAPI.h" #include "mesh/wifi/WiFiAPClient.h" #endif -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D) #include "mesh/api/ethServerAPI.h" #include "mesh/eth/ethClient.h" #endif @@ -335,7 +335,7 @@ void setup() #ifdef WIFI_LED pinMode(WIFI_LED, OUTPUT); - digitalWrite(WIFI_LED, LOW); + digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON); #endif #ifdef BLE_LED diff --git a/src/mesh/InterfacesTemplates.cpp b/src/mesh/InterfacesTemplates.cpp index 57abbf0ee40..c246141dcef 100644 --- a/src/mesh/InterfacesTemplates.cpp +++ b/src/mesh/InterfacesTemplates.cpp @@ -25,7 +25,7 @@ template class LR11x0Interface; template class SX126xInterface; #endif -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D) #include "api/ethServerAPI.h" template class ServerAPI; template class APIServerPort; diff --git a/src/mesh/api/ethServerAPI.cpp b/src/mesh/api/ethServerAPI.cpp index 43ed74cf811..c75d53ff7c2 100644 --- a/src/mesh/api/ethServerAPI.cpp +++ b/src/mesh/api/ethServerAPI.cpp @@ -1,7 +1,7 @@ #include "configuration.h" #include -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D) #include "ethServerAPI.h" diff --git a/src/mesh/api/ethServerAPI.h b/src/mesh/api/ethServerAPI.h index 8f81ee6ffff..07de392cf1f 100644 --- a/src/mesh/api/ethServerAPI.h +++ b/src/mesh/api/ethServerAPI.h @@ -1,7 +1,7 @@ #pragma once #include "ServerAPI.h" -#ifndef USE_WS5500 +#if !defined(USE_WS5500) && !defined(USE_CH390D) #include /** diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index 440f7b76a88..bb62327272f 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -9,7 +9,7 @@ #include #include -#if HAS_NETWORKING +#if HAS_NETWORKING && !defined(USE_WS5500) && !defined(USE_CH390D) #ifndef DISABLE_NTP #include diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index 5d05c7fc6df..be25e6865b7 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -15,6 +15,12 @@ #define ETH ETH2 #endif // HAS_ETHERNET +#if HAS_ETHERNET && defined(USE_CH390D) +#include "ESP32_CH390.h" +#include "hal/spi_types.h" +#define ETH CH390 +#endif // HAS_ETHERNET + #include #ifdef ARCH_ESP32 #if !MESHTASTIC_EXCLUDE_WEBSERVER @@ -56,12 +62,43 @@ unsigned long lastrun_ntp = 0; bool needReconnect = true; // If we create our reconnector, run it once at the beginning bool isReconnecting = false; // If we are currently reconnecting +#if defined(USE_WS5500) || defined(USE_CH390D) +static volatile bool ethNetworkConnectedPending = false; +#endif WiFiUDP syslogClient; meshtastic::Syslog syslog(syslogClient); Periodic *wifiReconnect; +#if defined(USE_WS5500) || defined(USE_CH390D) +static void onNetworkConnected(); +static uint32_t lastEthIP = 0; +static int32_t ethNetworkConnectedPoll() +{ + if (ethNetworkConnectedPending) { + ethNetworkConnectedPending = false; + uint32_t ip = (uint32_t)ETH.localIP(); + bool ipChanged = APStartupComplete && ip != 0 && ip != lastEthIP; + onNetworkConnected(); + if (ipChanged) { + LOG_INFO("Ethernet IP changed (%u.%u.%u.%u), restarting mDNS", ip & 0xff, (ip >> 8) & 0xff, (ip >> 16) & 0xff, + (ip >> 24) & 0xff); + MDNS.end(); + if (MDNS.begin("Meshtastic")) { + MDNS.addService("meshtastic", "tcp", SERVER_API_DEFAULT_PORT); + MDNS.addServiceTxt("meshtastic", "tcp", "shortname", String(owner.short_name)); + MDNS.addServiceTxt("meshtastic", "tcp", "id", String(nodeDB->getNodeId().c_str())); + MDNS.addServiceTxt("meshtastic", "tcp", "pio_env", optstr(APP_ENV)); + } + } + if (ip != 0) + lastEthIP = ip; + } + return 500; +} +#endif + #ifdef USE_WS5500 // Startup Ethernet bool initEthernet() @@ -72,6 +109,38 @@ bool initEthernet() #if !MESHTASTIC_EXCLUDE_WEBSERVER createSSLCert(); // For WebServer #endif + new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); + return true; + } + + return false; +} +#endif + +#ifdef USE_CH390D +// Startup Ethernet +bool initEthernet() +{ + // Configure CH390 + ch390_config_t ch390_conf = CH390_DEFAULT_CONFIG(); + ch390_conf.spi_host = SPI3_HOST; + ch390_conf.spi_cs_gpio = ETH_CS_PIN; + ch390_conf.spi_sck_gpio = ETH_SCLK_PIN; + ch390_conf.spi_mosi_gpio = ETH_MOSI_PIN; + ch390_conf.spi_miso_gpio = ETH_MISO_PIN; + ch390_conf.int_gpio = ETH_INT_PIN; +#ifdef ETH_RST_PIN + ch390_conf.reset_gpio = ETH_RST_PIN; +#else + ch390_conf.reset_gpio = -1; +#endif + ch390_conf.spi_clock_mhz = 20; + if ((config.network.eth_enabled) && (ETH.begin(ch390_conf))) { + WiFi.onEvent(WiFiEvent); +#if !MESHTASTIC_EXCLUDE_WEBSERVER + createSSLCert(); // For WebServer +#endif + new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); return true; } @@ -234,7 +303,7 @@ bool isWifiAvailable() if (config.network.wifi_enabled && (config.network.wifi_ssid[0])) { return true; -#ifdef USE_WS5500 +#if defined(USE_WS5500) || defined(USE_CH390D) } else if (config.network.eth_enabled) { return true; #endif @@ -384,13 +453,13 @@ static void WiFiEvent(WiFiEvent_t event) #endif } #ifdef WIFI_LED - digitalWrite(WIFI_LED, HIGH); + digitalWrite(WIFI_LED, LOW ^ WIFI_STATE_ON); #endif break; case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: LOG_INFO("Disconnected from WiFi access point"); #ifdef WIFI_LED - digitalWrite(WIFI_LED, LOW); + digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON); #endif #if HAS_UDP_MULTICAST if (udpHandler) { @@ -452,13 +521,13 @@ static void WiFiEvent(WiFiEvent_t event) case ARDUINO_EVENT_WIFI_AP_START: LOG_INFO("WiFi access point started"); #ifdef WIFI_LED - digitalWrite(WIFI_LED, HIGH); + digitalWrite(WIFI_LED, LOW ^ WIFI_STATE_ON); #endif break; case ARDUINO_EVENT_WIFI_AP_STOP: LOG_INFO("WiFi access point stopped"); #ifdef WIFI_LED - digitalWrite(WIFI_LED, LOW); + digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON); #endif break; case ARDUINO_EVENT_WIFI_AP_STACONNECTED: @@ -494,18 +563,18 @@ static void WiFiEvent(WiFiEvent_t event) LOG_INFO("Ethernet disconnected"); break; case ARDUINO_EVENT_ETH_GOT_IP: -#ifdef USE_WS5500 +#if defined(USE_WS5500) || defined(USE_CH390D) LOG_INFO("Obtained IP address: %s, %u Mbps, %s", ETH.localIP().toString().c_str(), ETH.linkSpeed(), ETH.fullDuplex() ? "FULL_DUPLEX" : "HALF_DUPLEX"); - onNetworkConnected(); + ethNetworkConnectedPending = true; #endif break; case ARDUINO_EVENT_ETH_GOT_IP6: -#ifdef USE_WS5500 +#if defined(USE_WS5500) || defined(USE_CH390D) #if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0) LOG_INFO("Obtained Local IP6 address: %s", ETH.linkLocalIPv6().toString().c_str()); LOG_INFO("Obtained GlobalIP6 address: %s", ETH.globalIPv6().toString().c_str()); -#else +#elif defined(USE_WS5500) LOG_INFO("Obtained IP6 address: %s", ETH.localIPv6().toString().c_str()); #endif #endif diff --git a/src/mesh/wifi/WiFiAPClient.h b/src/mesh/wifi/WiFiAPClient.h index 078c4019321..1de897d7a58 100644 --- a/src/mesh/wifi/WiFiAPClient.h +++ b/src/mesh/wifi/WiFiAPClient.h @@ -26,7 +26,7 @@ bool isWifiAvailable(); uint8_t getWifiDisconnectReason(); -#ifdef USE_WS5500 +#if defined(USE_WS5500) || defined(USE_CH390D) // Startup Ethernet bool initEthernet(); #endif \ No newline at end of file diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 468e8d91e96..c6b17d5b060 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1281,7 +1281,7 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r } #endif -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D) conn.has_ethernet = true; conn.ethernet.has_status = true; if (Ethernet.linkStatus() == LinkON) { diff --git a/src/modules/SystemCommandsModule.cpp b/src/modules/SystemCommandsModule.cpp index 1da75636605..7e07414cd02 100644 --- a/src/modules/SystemCommandsModule.cpp +++ b/src/modules/SystemCommandsModule.cpp @@ -115,6 +115,17 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) case INPUT_BROKER_SHUTDOWN: shutdownAtMsec = millis(); return true; + // factory reset + case INPUT_BROKER_FACTORY_RST: + disableBluetooth(); + LOG_INFO("Initiate full factory reset"); + nodeDB->factoryReset(true); + // reboot(DEFAULT_REBOOT_SECONDS); + LOG_INFO("Reboot in %d seconds", DEFAULT_REBOOT_SECONDS); + if (screen) + screen->showSimpleBanner("Rebooting...", 0); // stays on screen + rebootAtMsec = (DEFAULT_REBOOT_SECONDS < 0) ? 0 : (millis() + DEFAULT_REBOOT_SECONDS * 1000); + return true; default: // No other input events handled here diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 902fd1c2b49..ad1368c5d66 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -22,6 +22,9 @@ #if HAS_ETHERNET && defined(USE_WS5500) #include #define ETH ETH2 +#elif HAS_ETHERNET && defined(USE_CH390D) +#include "ESP32_CH390.h" +#define ETH CH390 #endif // HAS_ETHERNET #include "Default.h" #if !defined(ARCH_NRF52) || NRF52_USE_JSON @@ -344,6 +347,9 @@ inline bool isConnectedToNetwork() #ifdef USE_WS5500 if (ETH.connected()) return true; +#elif defined(USE_CH390D) + if (ETH.isConnected()) + return true; #endif #if HAS_WIFI diff --git a/src/mqtt/MQTT.h b/src/mqtt/MQTT.h index 7d571560263..b1b143f04cc 100644 --- a/src/mqtt/MQTT.h +++ b/src/mqtt/MQTT.h @@ -15,7 +15,7 @@ #include #endif #endif -#if HAS_ETHERNET && !defined(USE_WS5500) +#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D) #include #endif diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 30398a675f8..cd3ac1f9db9 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -146,6 +146,8 @@ #define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M2 #elif defined(ELECROW_ThinkNode_M5) #define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M5 +#elif defined(ELECROW_ThinkNode_M7) +#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M7 #elif defined(ESP32_S3_PICO) #define HW_VENDOR meshtastic_HardwareModel_ESP32_S3_PICO #elif defined(SENSELORA_S3) diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index 25cb30e962e..dbc573c95e8 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -32,7 +32,7 @@ void variant_shutdown() {} #if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH void setBluetoothEnable(bool enable) { -#ifdef USE_WS5500 +#if defined(USE_WS5500) || defined(USE_CH390D) if ((config.bluetooth.enabled == true) && (config.network.wifi_enabled == false)) #elif HAS_WIFI if (!isWifiAvailable() && config.bluetooth.enabled == true) diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/pins_arduino.h b/variants/esp32s3/ELECROW-ThinkNode-G3/pins_arduino.h new file mode 100644 index 00000000000..35935577246 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/pins_arduino.h @@ -0,0 +1,26 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// The default Wire will be mapped to PMU and RTC +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +// Default SPI will be mapped to Radio +static const uint8_t SS = 39; +static const uint8_t MOSI = 40; +static const uint8_t MISO = 41; +static const uint8_t SCK = 42; + +// #define SPI_MOSI (11) +// #define SPI_SCK (10) +// #define SPI_MISO (9) +// #define SPI_CS (12) + +// #define SDCARD_CS SPI_CS + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini new file mode 100644 index 00000000000..e2c83efe5a4 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini @@ -0,0 +1,20 @@ +[env:thinknode_g3] +extends = esp32s3_base +board = ESP32-S3-WROOM-1-N4 +board_build.psram_type = opi + +build_flags = + ${esp32s3_base.build_flags} + -D HAS_UDP_MULTICAST=1 + -D BOARD_HAS_PSRAM + -D PRIVATE_HW + -I variants/esp32s3/ELECROW-ThinkNode-G3 + -mfix-esp32-psram-cache-issue + +lib_ignore = + Ethernet + +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 + https://github.com/meshtastic/ESP32-CH390/archive/refs/tags/v1.0.1.zip diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/variant.h b/variants/esp32s3/ELECROW-ThinkNode-G3/variant.h new file mode 100644 index 00000000000..c5afd574a6c --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/variant.h @@ -0,0 +1,36 @@ +#define HAS_GPS 0 +#define HAS_WIRE 0 +#define I2C_NO_RESCAN + +#define WIFI_LED 5 +#define WIFI_STATE_ON 0 + +#define LED_PIN 6 +#define LED_STATE_ON 0 +#define BUTTON_PIN 4 + +#define LORA_SCK 42 +#define LORA_MISO 41 +#define LORA_MOSI 40 +#define LORA_CS 39 +#define LORA_RESET 21 + +#define USE_SX1262 +#define SX126X_CS LORA_CS +#define SX126X_DIO1 15 +#define SX126X_BUSY 47 +#define SX126X_RESET LORA_RESET +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 +#define PIN_POWER_EN 45 + +#define HAS_ETHERNET 1 +#define USE_CH390D 1 + +#define ETH_MISO_PIN 12 +#define ETH_MOSI_PIN 11 +#define ETH_SCLK_PIN 13 +#define ETH_CS_PIN 14 +#define ETH_INT_PIN 10 +#define ETH_RST_PIN 9 +// #define ETH_ADDR 1 diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/pins_arduino.h b/variants/esp32s3/ELECROW-ThinkNode-M7/pins_arduino.h new file mode 100644 index 00000000000..bcc9cd1d5e6 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/pins_arduino.h @@ -0,0 +1,19 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// The default Wire will be mapped to PMU and RTC +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +// Default SPI is the LR1110 radio bus +static const uint8_t SS = 12; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 9; +static const uint8_t SCK = 11; + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini new file mode 100644 index 00000000000..f2ddbf8d268 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini @@ -0,0 +1,19 @@ +[env:thinknode_m7] +extends = esp32s3_base +board = ThinkNode-M7 + +build_flags = + ${esp32s3_base.build_flags} + -D ELECROW_ThinkNode_M7 + -D HAS_UDP_MULTICAST=1 + -D BOARD_HAS_PSRAM + -I variants/esp32s3/ELECROW-ThinkNode-M7 + -mfix-esp32-psram-cache-issue + +lib_ignore = + Ethernet + +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 + https://github.com/meshtastic/ESP32-CH390/archive/refs/tags/v1.0.1.zip \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/rfswitch.h b/variants/esp32s3/ELECROW-ThinkNode-M7/rfswitch.h new file mode 100644 index 00000000000..e5fe182c4f6 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/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-M7/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.h new file mode 100644 index 00000000000..9724b20fab3 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.h @@ -0,0 +1,43 @@ +#define HAS_GPS 0 +#define HAS_WIRE 0 +#define HAS_SCREEN 0 +#define I2C_NO_RESCAN + +#define UART_TX 43 +#define UART_RX 44 + +#define WIFI_LED 3 +#define WIFI_STATE_ON 0 + +#define LED_PIN 46 +#define LED_STATE_ON 0 +#define BUTTON_PIN 4 +#define BUTTON_ACTIVE_LOW true +#define BUTTON_ACTIVE_PULLUP true + +#define LORA_SCK 11 +#define LORA_MISO 9 +#define LORA_MOSI 10 +#define LORA_CS 12 +#define LORA_RESET 39 + +#define USE_LR1110 +#define LR1110_SPI_SCK_PIN LORA_SCK +#define LR1110_SPI_MISO_PIN LORA_MISO +#define LR1110_SPI_MOSI_PIN LORA_MOSI +#define LR1110_SPI_NSS_PIN LORA_CS +#define LR1110_IRQ_PIN 38 +#define LR1110_BUSY_PIN 13 +#define LR1110_NRESET_PIN LORA_RESET +#define LR11X0_DIO3_TCXO_VOLTAGE 1.8 +#define LR11X0_DIO_AS_RF_SWITCH + +#define HAS_ETHERNET 1 +#define USE_CH390D 1 + +#define ETH_MISO_PIN 14 +#define ETH_MOSI_PIN 48 +#define ETH_SCLK_PIN 47 +#define ETH_CS_PIN 21 +#define ETH_INT_PIN 45 +// #define ETH_ADDR 1 From 4446b0f1a271d2958ed5f93f932395f7e3392930 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 11 May 2026 14:51:21 -0500 Subject: [PATCH 164/225] Add variantDefaultConfig and set eth_enabled to default true (#10454) --- src/mesh/NodeDB.cpp | 10 ++++++++++ variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini | 6 +++++- variants/esp32s3/ELECROW-ThinkNode-G3/variant.cpp | 6 ++++++ variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini | 6 +++++- variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp | 6 ++++++ 5 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 variants/esp32s3/ELECROW-ThinkNode-G3/variant.cpp create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 6d13952e587..d35e0a38ae4 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -80,6 +80,14 @@ static unsigned char userprefs_admin_key_1[] = USERPREFS_USE_ADMIN_KEY_1; static unsigned char userprefs_admin_key_2[] = USERPREFS_USE_ADMIN_KEY_2; #endif +// Weak empty variant initialization function. +// May be redefined by variant files. +void variantDefaultConfig() __attribute__((weak)); +void variantDefaultConfig() {} + +void variantDefaultModuleConfig() __attribute__((weak)); +void variantDefaultModuleConfig() {} + #ifdef HELTEC_MESH_NODE_T114 uint32_t read8(uint8_t bits, uint8_t dummy, uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst) @@ -785,6 +793,8 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #endif initConfigIntervals(); + variantDefaultConfig(); + variantDefaultModuleConfig(); } void NodeDB::initConfigIntervals() diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini index e2c83efe5a4..4f94f5d3927 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini @@ -12,7 +12,11 @@ build_flags = -mfix-esp32-psram-cache-issue lib_ignore = - Ethernet + Ethernet + +build_src_filter = + ${esp32s3_base.build_src_filter} + +<../variants/esp32s3/ELECROW-ThinkNode-G3/*> lib_deps = ${esp32s3_base.lib_deps} diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/variant.cpp b/variants/esp32s3/ELECROW-ThinkNode-G3/variant.cpp new file mode 100644 index 00000000000..c6ff6b8d8a8 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/variant.cpp @@ -0,0 +1,6 @@ +#include "mesh/NodeDB.h" + +void variantDefaultConfig() +{ + config.network.eth_enabled = true; +} diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini index f2ddbf8d268..68fe6818249 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini @@ -11,7 +11,11 @@ build_flags = -mfix-esp32-psram-cache-issue lib_ignore = - Ethernet + Ethernet + +build_src_filter = + ${esp32s3_base.build_src_filter} + +<../variants/esp32s3/ELECROW-ThinkNode-M7/*> lib_deps = ${esp32s3_base.lib_deps} diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp new file mode 100644 index 00000000000..c6ff6b8d8a8 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp @@ -0,0 +1,6 @@ +#include "mesh/NodeDB.h" + +void variantDefaultConfig() +{ + config.network.eth_enabled = true; +} From 59b4993861124c79ad6822194f368cc0a8a9c273 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 15:06:37 -0500 Subject: [PATCH 165/225] Update protobufs (#10456) Co-authored-by: jp-bennett <5630967+jp-bennett@users.noreply.github.com> --- protobufs | 2 +- .../generated/meshtastic/deviceonly.pb.cpp | 12 -- src/mesh/generated/meshtastic/deviceonly.pb.h | 171 +++++------------- src/mesh/generated/meshtastic/mesh.pb.h | 2 + 4 files changed, 46 insertions(+), 141 deletions(-) diff --git a/protobufs b/protobufs index 559f3c12dd2..b302d923327 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 559f3c12dd2cfdb9a80153546d1755d6dac3005b +Subproject commit b302d923327402fbe49efcf15ff1b6ef2361b22b diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.cpp b/src/mesh/generated/meshtastic/deviceonly.pb.cpp index 5580866379a..5a96957027d 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.cpp +++ b/src/mesh/generated/meshtastic/deviceonly.pb.cpp @@ -18,18 +18,6 @@ PB_BIND(meshtastic_NodeInfoLite, meshtastic_NodeInfoLite, AUTO) PB_BIND(meshtastic_DeviceState, meshtastic_DeviceState, 2) -PB_BIND(meshtastic_NodePositionEntry, meshtastic_NodePositionEntry, AUTO) - - -PB_BIND(meshtastic_NodeTelemetryEntry, meshtastic_NodeTelemetryEntry, AUTO) - - -PB_BIND(meshtastic_NodeEnvironmentEntry, meshtastic_NodeEnvironmentEntry, AUTO) - - -PB_BIND(meshtastic_NodeStatusEntry, meshtastic_NodeStatusEntry, AUTO) - - PB_BIND(meshtastic_NodeDatabase, meshtastic_NodeDatabase, AUTO) diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 5e0b844f187..6d03dc64379 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -63,35 +63,43 @@ typedef struct _meshtastic_UserLite { bool is_unmessagable; } meshtastic_UserLite; -typedef PB_BYTES_ARRAY_T(32) meshtastic_NodeInfoLite_public_key_t; typedef struct _meshtastic_NodeInfoLite { /* The node number */ uint32_t num; + /* The user info for this node */ + bool has_user; + meshtastic_UserLite user; + /* This position data. Note: before 1.2.14 we would also store the last time we've heard from this node in position.time, that is no longer true. + Position.time now indicates the last time we received a POSITION from that node. */ + bool has_position; + meshtastic_PositionLite position; /* Returns the Signal-to-noise ratio (SNR) of the last received message, as measured by the receiver. Return SNR of the last received message in dB */ float snr; /* Set to indicate the last time we received a packet from this node */ uint32_t last_heard; + /* The latest device metrics for the node. */ + bool has_device_metrics; + meshtastic_DeviceMetrics device_metrics; /* local channel index we heard that node on. Only populated if its not the default channel. */ uint8_t channel; + /* True if we witnessed the node over MQTT instead of LoRA transport */ + bool via_mqtt; /* Number of hops away from us this node is (0 if direct neighbor) */ bool has_hops_away; uint8_t hops_away; + /* True if node is in our favorites list + Persists between NodeDB internal clean ups */ + bool is_favorite; + /* True if node is in our ignored list + Persists between NodeDB internal clean ups */ + bool is_ignored; /* Last byte of the node number of the node that should be used as the next hop to reach this node. */ uint8_t next_hop; - /* Bitfield for storing booleans. See NODEINFO_BITFIELD_* in src/mesh/NodeDB.h. */ + /* Bitfield for storing booleans. + LSB 0 is_key_manually_verified + LSB 1 is_muted */ uint32_t bitfield; - /* A full name for this user, i.e. "Kevin Hester". */ - char long_name[25]; - /* A VERY short name, ideally two characters or an emoji. - Suitable for a tiny OLED screen. */ - char short_name[5]; - /* Hardware model the user's device is running. */ - meshtastic_HardwareModel hw_model; - /* The user's role in the mesh. */ - meshtastic_Config_DeviceConfig_Role role; - /* The public key of the user's device, for PKI-based encrypted DMs. */ - meshtastic_NodeInfoLite_public_key_t public_key; } meshtastic_NodeInfoLite; /* This message is never sent over the wire, but it is used for serializing DB @@ -135,30 +143,6 @@ typedef struct _meshtastic_DeviceState { meshtastic_NodeRemoteHardwarePin node_remote_hardware_pins[12]; } meshtastic_DeviceState; -typedef struct _meshtastic_NodePositionEntry { - uint32_t num; - bool has_position; - meshtastic_PositionLite position; -} meshtastic_NodePositionEntry; - -typedef struct _meshtastic_NodeTelemetryEntry { - uint32_t num; - bool has_device_metrics; - meshtastic_DeviceMetrics device_metrics; -} meshtastic_NodeTelemetryEntry; - -typedef struct _meshtastic_NodeEnvironmentEntry { - uint32_t num; - bool has_environment_metrics; - meshtastic_EnvironmentMetrics environment_metrics; -} meshtastic_NodeEnvironmentEntry; - -typedef struct _meshtastic_NodeStatusEntry { - uint32_t num; - bool has_status; - meshtastic_StatusMessage status; -} meshtastic_NodeStatusEntry; - typedef struct _meshtastic_NodeDatabase { /* A version integer used to invalidate old save files when we make incompatible changes This integer is set at build time and is private to @@ -166,12 +150,6 @@ typedef struct _meshtastic_NodeDatabase { uint32_t version; /* New lite version of NodeDB to decrease memory footprint */ std::vector nodes; - /* Per-NodeNum satellite arrays. Constrained platforms (e.g. STM32WL) omit - these via MESHTASTIC_EXCLUDE_*DB build flags. */ - std::vector positions; - std::vector telemetry; - std::vector status; - std::vector environment; } meshtastic_NodeDatabase; /* The on-disk saved channels */ @@ -213,24 +191,16 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} #define meshtastic_UserLite_init_default {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} -#define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} +#define meshtastic_NodeInfoLite_init_default {0, false, meshtastic_UserLite_init_default, false, meshtastic_PositionLite_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0} #define meshtastic_DeviceState_init_default {false, meshtastic_MyNodeInfo_init_default, false, meshtastic_User_init_default, 0, {meshtastic_MeshPacket_init_default}, false, meshtastic_MeshPacket_init_default, 0, 0, 0, false, meshtastic_MeshPacket_init_default, 0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} -#define meshtastic_NodePositionEntry_init_default {0, false, meshtastic_PositionLite_init_default} -#define meshtastic_NodeTelemetryEntry_init_default {0, false, meshtastic_DeviceMetrics_init_default} -#define meshtastic_NodeEnvironmentEntry_init_default {0, false, meshtastic_EnvironmentMetrics_init_default} -#define meshtastic_NodeStatusEntry_init_default {0, false, meshtastic_StatusMessage_init_default} -#define meshtastic_NodeDatabase_init_default {0, {0}, {0}, {0}, {0}, {0}} +#define meshtastic_NodeDatabase_init_default {0, {0}} #define meshtastic_ChannelFile_init_default {0, {meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default}, 0} #define meshtastic_BackupPreferences_init_default {0, 0, false, meshtastic_LocalConfig_init_default, false, meshtastic_LocalModuleConfig_init_default, false, meshtastic_ChannelFile_init_default, false, meshtastic_User_init_default} #define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} #define meshtastic_UserLite_init_zero {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} -#define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} +#define meshtastic_NodeInfoLite_init_zero {0, false, meshtastic_UserLite_init_zero, false, meshtastic_PositionLite_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0} #define meshtastic_DeviceState_init_zero {false, meshtastic_MyNodeInfo_init_zero, false, meshtastic_User_init_zero, 0, {meshtastic_MeshPacket_init_zero}, false, meshtastic_MeshPacket_init_zero, 0, 0, 0, false, meshtastic_MeshPacket_init_zero, 0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} -#define meshtastic_NodePositionEntry_init_zero {0, false, meshtastic_PositionLite_init_zero} -#define meshtastic_NodeTelemetryEntry_init_zero {0, false, meshtastic_DeviceMetrics_init_zero} -#define meshtastic_NodeEnvironmentEntry_init_zero {0, false, meshtastic_EnvironmentMetrics_init_zero} -#define meshtastic_NodeStatusEntry_init_zero {0, false, meshtastic_StatusMessage_init_zero} -#define meshtastic_NodeDatabase_init_zero {0, {0}, {0}, {0}, {0}, {0}} +#define meshtastic_NodeDatabase_init_zero {0, {0}} #define meshtastic_ChannelFile_init_zero {0, {meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero}, 0} #define meshtastic_BackupPreferences_init_zero {0, 0, false, meshtastic_LocalConfig_init_zero, false, meshtastic_LocalModuleConfig_init_zero, false, meshtastic_ChannelFile_init_zero, false, meshtastic_User_init_zero} @@ -249,17 +219,18 @@ extern "C" { #define meshtastic_UserLite_public_key_tag 7 #define meshtastic_UserLite_is_unmessagable_tag 9 #define meshtastic_NodeInfoLite_num_tag 1 +#define meshtastic_NodeInfoLite_user_tag 2 +#define meshtastic_NodeInfoLite_position_tag 3 #define meshtastic_NodeInfoLite_snr_tag 4 #define meshtastic_NodeInfoLite_last_heard_tag 5 +#define meshtastic_NodeInfoLite_device_metrics_tag 6 #define meshtastic_NodeInfoLite_channel_tag 7 +#define meshtastic_NodeInfoLite_via_mqtt_tag 8 #define meshtastic_NodeInfoLite_hops_away_tag 9 +#define meshtastic_NodeInfoLite_is_favorite_tag 10 +#define meshtastic_NodeInfoLite_is_ignored_tag 11 #define meshtastic_NodeInfoLite_next_hop_tag 12 #define meshtastic_NodeInfoLite_bitfield_tag 13 -#define meshtastic_NodeInfoLite_long_name_tag 14 -#define meshtastic_NodeInfoLite_short_name_tag 15 -#define meshtastic_NodeInfoLite_hw_model_tag 16 -#define meshtastic_NodeInfoLite_role_tag 17 -#define meshtastic_NodeInfoLite_public_key_tag 18 #define meshtastic_DeviceState_my_node_tag 2 #define meshtastic_DeviceState_owner_tag 3 #define meshtastic_DeviceState_receive_queue_tag 5 @@ -269,20 +240,8 @@ extern "C" { #define meshtastic_DeviceState_did_gps_reset_tag 11 #define meshtastic_DeviceState_rx_waypoint_tag 12 #define meshtastic_DeviceState_node_remote_hardware_pins_tag 13 -#define meshtastic_NodePositionEntry_num_tag 1 -#define meshtastic_NodePositionEntry_position_tag 2 -#define meshtastic_NodeTelemetryEntry_num_tag 1 -#define meshtastic_NodeTelemetryEntry_device_metrics_tag 2 -#define meshtastic_NodeEnvironmentEntry_num_tag 1 -#define meshtastic_NodeEnvironmentEntry_environment_metrics_tag 2 -#define meshtastic_NodeStatusEntry_num_tag 1 -#define meshtastic_NodeStatusEntry_status_tag 2 #define meshtastic_NodeDatabase_version_tag 1 #define meshtastic_NodeDatabase_nodes_tag 2 -#define meshtastic_NodeDatabase_positions_tag 3 -#define meshtastic_NodeDatabase_telemetry_tag 4 -#define meshtastic_NodeDatabase_status_tag 5 -#define meshtastic_NodeDatabase_environment_tag 6 #define meshtastic_ChannelFile_channels_tag 1 #define meshtastic_ChannelFile_version_tag 2 #define meshtastic_BackupPreferences_version_tag 1 @@ -316,19 +275,23 @@ X(a, STATIC, OPTIONAL, BOOL, is_unmessagable, 9) #define meshtastic_NodeInfoLite_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, user, 2) \ +X(a, STATIC, OPTIONAL, MESSAGE, position, 3) \ X(a, STATIC, SINGULAR, FLOAT, snr, 4) \ X(a, STATIC, SINGULAR, FIXED32, last_heard, 5) \ +X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 6) \ X(a, STATIC, SINGULAR, UINT32, channel, 7) \ +X(a, STATIC, SINGULAR, BOOL, via_mqtt, 8) \ X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \ +X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ +X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ X(a, STATIC, SINGULAR, UINT32, next_hop, 12) \ -X(a, STATIC, SINGULAR, UINT32, bitfield, 13) \ -X(a, STATIC, SINGULAR, STRING, long_name, 14) \ -X(a, STATIC, SINGULAR, STRING, short_name, 15) \ -X(a, STATIC, SINGULAR, UENUM, hw_model, 16) \ -X(a, STATIC, SINGULAR, UENUM, role, 17) \ -X(a, STATIC, SINGULAR, BYTES, public_key, 18) +X(a, STATIC, SINGULAR, UINT32, bitfield, 13) #define meshtastic_NodeInfoLite_CALLBACK NULL #define meshtastic_NodeInfoLite_DEFAULT NULL +#define meshtastic_NodeInfoLite_user_MSGTYPE meshtastic_UserLite +#define meshtastic_NodeInfoLite_position_MSGTYPE meshtastic_PositionLite +#define meshtastic_NodeInfoLite_device_metrics_MSGTYPE meshtastic_DeviceMetrics #define meshtastic_DeviceState_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, MESSAGE, my_node, 2) \ @@ -349,49 +312,13 @@ X(a, STATIC, REPEATED, MESSAGE, node_remote_hardware_pins, 13) #define meshtastic_DeviceState_rx_waypoint_MSGTYPE meshtastic_MeshPacket #define meshtastic_DeviceState_node_remote_hardware_pins_MSGTYPE meshtastic_NodeRemoteHardwarePin -#define meshtastic_NodePositionEntry_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, position, 2) -#define meshtastic_NodePositionEntry_CALLBACK NULL -#define meshtastic_NodePositionEntry_DEFAULT NULL -#define meshtastic_NodePositionEntry_position_MSGTYPE meshtastic_PositionLite - -#define meshtastic_NodeTelemetryEntry_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 2) -#define meshtastic_NodeTelemetryEntry_CALLBACK NULL -#define meshtastic_NodeTelemetryEntry_DEFAULT NULL -#define meshtastic_NodeTelemetryEntry_device_metrics_MSGTYPE meshtastic_DeviceMetrics - -#define meshtastic_NodeEnvironmentEntry_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, environment_metrics, 2) -#define meshtastic_NodeEnvironmentEntry_CALLBACK NULL -#define meshtastic_NodeEnvironmentEntry_DEFAULT NULL -#define meshtastic_NodeEnvironmentEntry_environment_metrics_MSGTYPE meshtastic_EnvironmentMetrics - -#define meshtastic_NodeStatusEntry_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, status, 2) -#define meshtastic_NodeStatusEntry_CALLBACK NULL -#define meshtastic_NodeStatusEntry_DEFAULT NULL -#define meshtastic_NodeStatusEntry_status_MSGTYPE meshtastic_StatusMessage - #define meshtastic_NodeDatabase_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, version, 1) \ -X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) \ -X(a, CALLBACK, REPEATED, MESSAGE, positions, 3) \ -X(a, CALLBACK, REPEATED, MESSAGE, telemetry, 4) \ -X(a, CALLBACK, REPEATED, MESSAGE, status, 5) \ -X(a, CALLBACK, REPEATED, MESSAGE, environment, 6) +X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) extern bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field); #define meshtastic_NodeDatabase_CALLBACK meshtastic_NodeDatabase_callback #define meshtastic_NodeDatabase_DEFAULT NULL #define meshtastic_NodeDatabase_nodes_MSGTYPE meshtastic_NodeInfoLite -#define meshtastic_NodeDatabase_positions_MSGTYPE meshtastic_NodePositionEntry -#define meshtastic_NodeDatabase_telemetry_MSGTYPE meshtastic_NodeTelemetryEntry -#define meshtastic_NodeDatabase_status_MSGTYPE meshtastic_NodeStatusEntry -#define meshtastic_NodeDatabase_environment_MSGTYPE meshtastic_NodeEnvironmentEntry #define meshtastic_ChannelFile_FIELDLIST(X, a) \ X(a, STATIC, REPEATED, MESSAGE, channels, 1) \ @@ -418,10 +345,6 @@ extern const pb_msgdesc_t meshtastic_PositionLite_msg; extern const pb_msgdesc_t meshtastic_UserLite_msg; extern const pb_msgdesc_t meshtastic_NodeInfoLite_msg; extern const pb_msgdesc_t meshtastic_DeviceState_msg; -extern const pb_msgdesc_t meshtastic_NodePositionEntry_msg; -extern const pb_msgdesc_t meshtastic_NodeTelemetryEntry_msg; -extern const pb_msgdesc_t meshtastic_NodeEnvironmentEntry_msg; -extern const pb_msgdesc_t meshtastic_NodeStatusEntry_msg; extern const pb_msgdesc_t meshtastic_NodeDatabase_msg; extern const pb_msgdesc_t meshtastic_ChannelFile_msg; extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; @@ -431,10 +354,6 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_UserLite_fields &meshtastic_UserLite_msg #define meshtastic_NodeInfoLite_fields &meshtastic_NodeInfoLite_msg #define meshtastic_DeviceState_fields &meshtastic_DeviceState_msg -#define meshtastic_NodePositionEntry_fields &meshtastic_NodePositionEntry_msg -#define meshtastic_NodeTelemetryEntry_fields &meshtastic_NodeTelemetryEntry_msg -#define meshtastic_NodeEnvironmentEntry_fields &meshtastic_NodeEnvironmentEntry_msg -#define meshtastic_NodeStatusEntry_fields &meshtastic_NodeStatusEntry_msg #define meshtastic_NodeDatabase_fields &meshtastic_NodeDatabase_msg #define meshtastic_ChannelFile_fields &meshtastic_ChannelFile_msg #define meshtastic_BackupPreferences_fields &meshtastic_BackupPreferences_msg @@ -445,11 +364,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_BackupPreferences_size 2432 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1737 -#define meshtastic_NodeEnvironmentEntry_size 170 -#define meshtastic_NodeInfoLite_size 105 -#define meshtastic_NodePositionEntry_size 36 -#define meshtastic_NodeStatusEntry_size 89 -#define meshtastic_NodeTelemetryEntry_size 35 +#define meshtastic_NodeInfoLite_size 196 #define meshtastic_PositionLite_size 28 #define meshtastic_UserLite_size 98 diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index facf00a7fa9..41ef2798c77 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -319,6 +319,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_HELTEC_V4_R8 = 132, /* The HELTEC_MESH_NODE_T1 uses an NRF52840 chip, plus an SX1262. */ meshtastic_HardwareModel_HELTEC_MESH_NODE_T1 = 133, + /* B&Q Consulting Station G3: TBD */ + meshtastic_HardwareModel_STATION_G3 = 134, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ From 7f5184281d6d8a48161b664d3b053ae2da7a20c6 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 11 May 2026 16:09:33 -0500 Subject: [PATCH 166/225] Make power status logging less chatty and track battery presence transitions (#10453) --- src/Power.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Power.cpp b/src/Power.cpp index 1ea3a64c293..f752e9461df 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -878,7 +878,16 @@ void Power::readPowerStatus() // Notify any status instances that are observing us const PowerStatus powerStatus2 = PowerStatus(hasBattery, usbPowered, isChargingNow, batteryVoltageMv, batteryChargePercent); - if (millis() > lastLogTime + 50 * 1000) { + + // Log battery-presence transitions once; skip OptUnknown so we don't lie before the first probe. + static OptionalBool prevHasBattery = OptUnknown; + if (hasBattery != OptUnknown && hasBattery != prevHasBattery) { + LOG_INFO("Power: battery hardware %s", hasBattery == OptTrue ? "detected" : "absent (USB-only)"); + prevHasBattery = hasBattery; + } + + // Periodic telemetry only emits when a battery is actually present (otherwise values are constant -1/0). + if (hasBattery == OptTrue && !Throttle::isWithinTimespanMs(lastLogTime, 50 * 1000)) { LOG_DEBUG("Battery: usbPower=%d, isCharging=%d, batMv=%d, batPct=%d", powerStatus2.getHasUSB(), powerStatus2.getIsCharging(), powerStatus2.getBatteryVoltageMv(), powerStatus2.getBatteryChargePercent()); lastLogTime = millis(); From 811dd427ddf33618a30aefa1f966b4e8568cd42e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 11 May 2026 16:36:35 -0500 Subject: [PATCH 167/225] Update protobufs --- protobufs | 2 +- src/mesh/generated/meshtastic/admin.pb.cpp | 3 + src/mesh/generated/meshtastic/admin.pb.h | 67 ++++++- .../generated/meshtastic/deviceonly.pb.cpp | 12 ++ src/mesh/generated/meshtastic/deviceonly.pb.h | 171 +++++++++++++----- src/mesh/generated/meshtastic/mesh.pb.cpp | 5 + src/mesh/generated/meshtastic/mesh.pb.h | 87 ++++++++- 7 files changed, 301 insertions(+), 46 deletions(-) diff --git a/protobufs b/protobufs index b302d923327..f899d38422a 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit b302d923327402fbe49efcf15ff1b6ef2361b22b +Subproject commit f899d38422ab07e7a973ee1e99fcd5fa1acd8dbd diff --git a/src/mesh/generated/meshtastic/admin.pb.cpp b/src/mesh/generated/meshtastic/admin.pb.cpp index 3dcc241d9b8..945840c0f4d 100644 --- a/src/mesh/generated/meshtastic/admin.pb.cpp +++ b/src/mesh/generated/meshtastic/admin.pb.cpp @@ -15,6 +15,9 @@ PB_BIND(meshtastic_AdminMessage_InputEvent, meshtastic_AdminMessage_InputEvent, PB_BIND(meshtastic_AdminMessage_OTAEvent, meshtastic_AdminMessage_OTAEvent, AUTO) +PB_BIND(meshtastic_LockdownAuth, meshtastic_LockdownAuth, AUTO) + + PB_BIND(meshtastic_HamParameters, meshtastic_HamParameters, AUTO) diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index 58e0356ca39..e6f5110ad30 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -130,6 +130,41 @@ typedef struct _meshtastic_AdminMessage_OTAEvent { meshtastic_AdminMessage_OTAEvent_ota_hash_t ota_hash; } meshtastic_AdminMessage_OTAEvent; +typedef PB_BYTES_ARRAY_T(32) meshtastic_LockdownAuth_passphrase_t; +/* Lockdown passphrase delivery payload. + + One message handles three operations distinguished by content: + - Provision (first-time): passphrase set, lock_now=false. Firmware + generates DEK, wraps with passphrase-derived KEK, persists. + - Unlock: passphrase set, lock_now=false. Firmware verifies + passphrase against stored DEK, unlocks storage, authorizes the + connection that delivered this packet. + - Lock now: lock_now=true, passphrase ignored. Firmware revokes + all client auth and reboots into the locked state. + + Firmware decides between provision and unlock based on its own state + (whether a DEK file already exists). Clients do not need to track + which case applies. */ +typedef struct _meshtastic_LockdownAuth { + /* Passphrase bytes (1-32). Empty when lock_now is true. + Capped to 32 to match the proto cap on related security fields. */ + meshtastic_LockdownAuth_passphrase_t passphrase; + /* Optional override of the boot-count token TTL granted on success. + 0 = use firmware default (TOKEN_DEFAULT_BOOTS). + On reboot the firmware decrements this; when it reaches 0 the + device boots fully locked and requires a fresh passphrase. */ + uint32_t boots_remaining; + /* Optional wall-clock expiry for the unlock token, as absolute + Unix-epoch seconds. 0 = no time limit (only the boot-count TTL + applies). On boot, if the device RTC is set and now > this value, + the token is treated as expired. */ + uint32_t valid_until_epoch; + /* If true, ignore passphrase fields, immediately revoke all + connection-level admin authorization, and reboot the device into + the locked state. Always honoured regardless of current lock state. */ + bool lock_now; +} meshtastic_LockdownAuth; + /* Parameters for setting up Meshtastic for ameteur radio usage */ typedef struct _meshtastic_HamParameters { /* Amateur radio call sign, eg. KD2ABC */ @@ -384,6 +419,15 @@ typedef struct _meshtastic_AdminMessage { meshtastic_AdminMessage_OTAEvent ota_request; /* Parameters and sensor configuration */ meshtastic_SensorConfig sensor_config; + /* Lockdown passphrase delivery / unlock / lock-now command for hardened + firmware builds (see MESHTASTIC_LOCKDOWN). Used to provision the + passphrase on first boot, unlock encrypted storage on subsequent + reboots, re-verify on already-unlocked devices to authorize a new + client connection, or immediately re-lock the device. + + Replaces the earlier scheme that repurposed SecurityConfig.private_key + to carry passphrase bytes; that hack is retired. */ + meshtastic_LockdownAuth lockdown_auth; }; /* The node generates this key and sends it with any get_x_response packets. The client MUST include the same key with any set_x commands. Key expires after 300 seconds. @@ -429,6 +473,7 @@ extern "C" { + #define meshtastic_KeyVerificationAdmin_message_type_ENUMTYPE meshtastic_KeyVerificationAdmin_MessageType @@ -441,6 +486,7 @@ extern "C" { #define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}} +#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0} #define meshtastic_HamParameters_init_default {"", 0, 0, ""} #define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} #define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0} @@ -453,6 +499,7 @@ extern "C" { #define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}} +#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0} #define meshtastic_HamParameters_init_zero {"", 0, 0, ""} #define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} #define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0} @@ -470,6 +517,10 @@ extern "C" { #define meshtastic_AdminMessage_InputEvent_touch_y_tag 4 #define meshtastic_AdminMessage_OTAEvent_reboot_ota_mode_tag 1 #define meshtastic_AdminMessage_OTAEvent_ota_hash_tag 2 +#define meshtastic_LockdownAuth_passphrase_tag 1 +#define meshtastic_LockdownAuth_boots_remaining_tag 2 +#define meshtastic_LockdownAuth_valid_until_epoch_tag 3 +#define meshtastic_LockdownAuth_lock_now_tag 4 #define meshtastic_HamParameters_call_sign_tag 1 #define meshtastic_HamParameters_tx_power_tag 2 #define meshtastic_HamParameters_frequency_tag 3 @@ -560,6 +611,7 @@ extern "C" { #define meshtastic_AdminMessage_nodedb_reset_tag 100 #define meshtastic_AdminMessage_ota_request_tag 102 #define meshtastic_AdminMessage_sensor_config_tag 103 +#define meshtastic_AdminMessage_lockdown_auth_tag 104 #define meshtastic_AdminMessage_session_passkey_tag 101 /* Struct field encoding specification for nanopb */ @@ -621,7 +673,8 @@ X(a, STATIC, ONEOF, INT32, (payload_variant,factory_reset_config,factory X(a, STATIC, ONEOF, BOOL, (payload_variant,nodedb_reset,nodedb_reset), 100) \ X(a, STATIC, SINGULAR, BYTES, session_passkey, 101) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,ota_request,ota_request), 102) \ -X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config), 103) +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config), 103) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_auth,lockdown_auth), 104) #define meshtastic_AdminMessage_CALLBACK NULL #define meshtastic_AdminMessage_DEFAULT NULL #define meshtastic_AdminMessage_payload_variant_get_channel_response_MSGTYPE meshtastic_Channel @@ -644,6 +697,7 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config) #define meshtastic_AdminMessage_payload_variant_key_verification_MSGTYPE meshtastic_KeyVerificationAdmin #define meshtastic_AdminMessage_payload_variant_ota_request_MSGTYPE meshtastic_AdminMessage_OTAEvent #define meshtastic_AdminMessage_payload_variant_sensor_config_MSGTYPE meshtastic_SensorConfig +#define meshtastic_AdminMessage_payload_variant_lockdown_auth_MSGTYPE meshtastic_LockdownAuth #define meshtastic_AdminMessage_InputEvent_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, event_code, 1) \ @@ -659,6 +713,14 @@ X(a, STATIC, SINGULAR, BYTES, ota_hash, 2) #define meshtastic_AdminMessage_OTAEvent_CALLBACK NULL #define meshtastic_AdminMessage_OTAEvent_DEFAULT NULL +#define meshtastic_LockdownAuth_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \ +X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \ +X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \ +X(a, STATIC, SINGULAR, BOOL, lock_now, 4) +#define meshtastic_LockdownAuth_CALLBACK NULL +#define meshtastic_LockdownAuth_DEFAULT NULL + #define meshtastic_HamParameters_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, STRING, call_sign, 1) \ X(a, STATIC, SINGULAR, INT32, tx_power, 2) \ @@ -737,6 +799,7 @@ X(a, STATIC, OPTIONAL, UINT32, set_accuracy, 1) extern const pb_msgdesc_t meshtastic_AdminMessage_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_InputEvent_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_OTAEvent_msg; +extern const pb_msgdesc_t meshtastic_LockdownAuth_msg; extern const pb_msgdesc_t meshtastic_HamParameters_msg; extern const pb_msgdesc_t meshtastic_NodeRemoteHardwarePinsResponse_msg; extern const pb_msgdesc_t meshtastic_SharedContact_msg; @@ -751,6 +814,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_AdminMessage_fields &meshtastic_AdminMessage_msg #define meshtastic_AdminMessage_InputEvent_fields &meshtastic_AdminMessage_InputEvent_msg #define meshtastic_AdminMessage_OTAEvent_fields &meshtastic_AdminMessage_OTAEvent_msg +#define meshtastic_LockdownAuth_fields &meshtastic_LockdownAuth_msg #define meshtastic_HamParameters_fields &meshtastic_HamParameters_msg #define meshtastic_NodeRemoteHardwarePinsResponse_fields &meshtastic_NodeRemoteHardwarePinsResponse_msg #define meshtastic_SharedContact_fields &meshtastic_SharedContact_msg @@ -768,6 +832,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_AdminMessage_size 511 #define meshtastic_HamParameters_size 31 #define meshtastic_KeyVerificationAdmin_size 25 +#define meshtastic_LockdownAuth_size 48 #define meshtastic_NodeRemoteHardwarePinsResponse_size 496 #define meshtastic_SCD30_config_size 27 #define meshtastic_SCD4X_config_size 29 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.cpp b/src/mesh/generated/meshtastic/deviceonly.pb.cpp index 5a96957027d..5580866379a 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.cpp +++ b/src/mesh/generated/meshtastic/deviceonly.pb.cpp @@ -18,6 +18,18 @@ PB_BIND(meshtastic_NodeInfoLite, meshtastic_NodeInfoLite, AUTO) PB_BIND(meshtastic_DeviceState, meshtastic_DeviceState, 2) +PB_BIND(meshtastic_NodePositionEntry, meshtastic_NodePositionEntry, AUTO) + + +PB_BIND(meshtastic_NodeTelemetryEntry, meshtastic_NodeTelemetryEntry, AUTO) + + +PB_BIND(meshtastic_NodeEnvironmentEntry, meshtastic_NodeEnvironmentEntry, AUTO) + + +PB_BIND(meshtastic_NodeStatusEntry, meshtastic_NodeStatusEntry, AUTO) + + PB_BIND(meshtastic_NodeDatabase, meshtastic_NodeDatabase, AUTO) diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 6d03dc64379..5e0b844f187 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -63,43 +63,35 @@ typedef struct _meshtastic_UserLite { bool is_unmessagable; } meshtastic_UserLite; +typedef PB_BYTES_ARRAY_T(32) meshtastic_NodeInfoLite_public_key_t; typedef struct _meshtastic_NodeInfoLite { /* The node number */ uint32_t num; - /* The user info for this node */ - bool has_user; - meshtastic_UserLite user; - /* This position data. Note: before 1.2.14 we would also store the last time we've heard from this node in position.time, that is no longer true. - Position.time now indicates the last time we received a POSITION from that node. */ - bool has_position; - meshtastic_PositionLite position; /* Returns the Signal-to-noise ratio (SNR) of the last received message, as measured by the receiver. Return SNR of the last received message in dB */ float snr; /* Set to indicate the last time we received a packet from this node */ uint32_t last_heard; - /* The latest device metrics for the node. */ - bool has_device_metrics; - meshtastic_DeviceMetrics device_metrics; /* local channel index we heard that node on. Only populated if its not the default channel. */ uint8_t channel; - /* True if we witnessed the node over MQTT instead of LoRA transport */ - bool via_mqtt; /* Number of hops away from us this node is (0 if direct neighbor) */ bool has_hops_away; uint8_t hops_away; - /* True if node is in our favorites list - Persists between NodeDB internal clean ups */ - bool is_favorite; - /* True if node is in our ignored list - Persists between NodeDB internal clean ups */ - bool is_ignored; /* Last byte of the node number of the node that should be used as the next hop to reach this node. */ uint8_t next_hop; - /* Bitfield for storing booleans. - LSB 0 is_key_manually_verified - LSB 1 is_muted */ + /* Bitfield for storing booleans. See NODEINFO_BITFIELD_* in src/mesh/NodeDB.h. */ uint32_t bitfield; + /* A full name for this user, i.e. "Kevin Hester". */ + char long_name[25]; + /* A VERY short name, ideally two characters or an emoji. + Suitable for a tiny OLED screen. */ + char short_name[5]; + /* Hardware model the user's device is running. */ + meshtastic_HardwareModel hw_model; + /* The user's role in the mesh. */ + meshtastic_Config_DeviceConfig_Role role; + /* The public key of the user's device, for PKI-based encrypted DMs. */ + meshtastic_NodeInfoLite_public_key_t public_key; } meshtastic_NodeInfoLite; /* This message is never sent over the wire, but it is used for serializing DB @@ -143,6 +135,30 @@ typedef struct _meshtastic_DeviceState { meshtastic_NodeRemoteHardwarePin node_remote_hardware_pins[12]; } meshtastic_DeviceState; +typedef struct _meshtastic_NodePositionEntry { + uint32_t num; + bool has_position; + meshtastic_PositionLite position; +} meshtastic_NodePositionEntry; + +typedef struct _meshtastic_NodeTelemetryEntry { + uint32_t num; + bool has_device_metrics; + meshtastic_DeviceMetrics device_metrics; +} meshtastic_NodeTelemetryEntry; + +typedef struct _meshtastic_NodeEnvironmentEntry { + uint32_t num; + bool has_environment_metrics; + meshtastic_EnvironmentMetrics environment_metrics; +} meshtastic_NodeEnvironmentEntry; + +typedef struct _meshtastic_NodeStatusEntry { + uint32_t num; + bool has_status; + meshtastic_StatusMessage status; +} meshtastic_NodeStatusEntry; + typedef struct _meshtastic_NodeDatabase { /* A version integer used to invalidate old save files when we make incompatible changes This integer is set at build time and is private to @@ -150,6 +166,12 @@ typedef struct _meshtastic_NodeDatabase { uint32_t version; /* New lite version of NodeDB to decrease memory footprint */ std::vector nodes; + /* Per-NodeNum satellite arrays. Constrained platforms (e.g. STM32WL) omit + these via MESHTASTIC_EXCLUDE_*DB build flags. */ + std::vector positions; + std::vector telemetry; + std::vector status; + std::vector environment; } meshtastic_NodeDatabase; /* The on-disk saved channels */ @@ -191,16 +213,24 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} #define meshtastic_UserLite_init_default {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} -#define meshtastic_NodeInfoLite_init_default {0, false, meshtastic_UserLite_init_default, false, meshtastic_PositionLite_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} #define meshtastic_DeviceState_init_default {false, meshtastic_MyNodeInfo_init_default, false, meshtastic_User_init_default, 0, {meshtastic_MeshPacket_init_default}, false, meshtastic_MeshPacket_init_default, 0, 0, 0, false, meshtastic_MeshPacket_init_default, 0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} -#define meshtastic_NodeDatabase_init_default {0, {0}} +#define meshtastic_NodePositionEntry_init_default {0, false, meshtastic_PositionLite_init_default} +#define meshtastic_NodeTelemetryEntry_init_default {0, false, meshtastic_DeviceMetrics_init_default} +#define meshtastic_NodeEnvironmentEntry_init_default {0, false, meshtastic_EnvironmentMetrics_init_default} +#define meshtastic_NodeStatusEntry_init_default {0, false, meshtastic_StatusMessage_init_default} +#define meshtastic_NodeDatabase_init_default {0, {0}, {0}, {0}, {0}, {0}} #define meshtastic_ChannelFile_init_default {0, {meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default}, 0} #define meshtastic_BackupPreferences_init_default {0, 0, false, meshtastic_LocalConfig_init_default, false, meshtastic_LocalModuleConfig_init_default, false, meshtastic_ChannelFile_init_default, false, meshtastic_User_init_default} #define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} #define meshtastic_UserLite_init_zero {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} -#define meshtastic_NodeInfoLite_init_zero {0, false, meshtastic_UserLite_init_zero, false, meshtastic_PositionLite_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} #define meshtastic_DeviceState_init_zero {false, meshtastic_MyNodeInfo_init_zero, false, meshtastic_User_init_zero, 0, {meshtastic_MeshPacket_init_zero}, false, meshtastic_MeshPacket_init_zero, 0, 0, 0, false, meshtastic_MeshPacket_init_zero, 0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} -#define meshtastic_NodeDatabase_init_zero {0, {0}} +#define meshtastic_NodePositionEntry_init_zero {0, false, meshtastic_PositionLite_init_zero} +#define meshtastic_NodeTelemetryEntry_init_zero {0, false, meshtastic_DeviceMetrics_init_zero} +#define meshtastic_NodeEnvironmentEntry_init_zero {0, false, meshtastic_EnvironmentMetrics_init_zero} +#define meshtastic_NodeStatusEntry_init_zero {0, false, meshtastic_StatusMessage_init_zero} +#define meshtastic_NodeDatabase_init_zero {0, {0}, {0}, {0}, {0}, {0}} #define meshtastic_ChannelFile_init_zero {0, {meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero}, 0} #define meshtastic_BackupPreferences_init_zero {0, 0, false, meshtastic_LocalConfig_init_zero, false, meshtastic_LocalModuleConfig_init_zero, false, meshtastic_ChannelFile_init_zero, false, meshtastic_User_init_zero} @@ -219,18 +249,17 @@ extern "C" { #define meshtastic_UserLite_public_key_tag 7 #define meshtastic_UserLite_is_unmessagable_tag 9 #define meshtastic_NodeInfoLite_num_tag 1 -#define meshtastic_NodeInfoLite_user_tag 2 -#define meshtastic_NodeInfoLite_position_tag 3 #define meshtastic_NodeInfoLite_snr_tag 4 #define meshtastic_NodeInfoLite_last_heard_tag 5 -#define meshtastic_NodeInfoLite_device_metrics_tag 6 #define meshtastic_NodeInfoLite_channel_tag 7 -#define meshtastic_NodeInfoLite_via_mqtt_tag 8 #define meshtastic_NodeInfoLite_hops_away_tag 9 -#define meshtastic_NodeInfoLite_is_favorite_tag 10 -#define meshtastic_NodeInfoLite_is_ignored_tag 11 #define meshtastic_NodeInfoLite_next_hop_tag 12 #define meshtastic_NodeInfoLite_bitfield_tag 13 +#define meshtastic_NodeInfoLite_long_name_tag 14 +#define meshtastic_NodeInfoLite_short_name_tag 15 +#define meshtastic_NodeInfoLite_hw_model_tag 16 +#define meshtastic_NodeInfoLite_role_tag 17 +#define meshtastic_NodeInfoLite_public_key_tag 18 #define meshtastic_DeviceState_my_node_tag 2 #define meshtastic_DeviceState_owner_tag 3 #define meshtastic_DeviceState_receive_queue_tag 5 @@ -240,8 +269,20 @@ extern "C" { #define meshtastic_DeviceState_did_gps_reset_tag 11 #define meshtastic_DeviceState_rx_waypoint_tag 12 #define meshtastic_DeviceState_node_remote_hardware_pins_tag 13 +#define meshtastic_NodePositionEntry_num_tag 1 +#define meshtastic_NodePositionEntry_position_tag 2 +#define meshtastic_NodeTelemetryEntry_num_tag 1 +#define meshtastic_NodeTelemetryEntry_device_metrics_tag 2 +#define meshtastic_NodeEnvironmentEntry_num_tag 1 +#define meshtastic_NodeEnvironmentEntry_environment_metrics_tag 2 +#define meshtastic_NodeStatusEntry_num_tag 1 +#define meshtastic_NodeStatusEntry_status_tag 2 #define meshtastic_NodeDatabase_version_tag 1 #define meshtastic_NodeDatabase_nodes_tag 2 +#define meshtastic_NodeDatabase_positions_tag 3 +#define meshtastic_NodeDatabase_telemetry_tag 4 +#define meshtastic_NodeDatabase_status_tag 5 +#define meshtastic_NodeDatabase_environment_tag 6 #define meshtastic_ChannelFile_channels_tag 1 #define meshtastic_ChannelFile_version_tag 2 #define meshtastic_BackupPreferences_version_tag 1 @@ -275,23 +316,19 @@ X(a, STATIC, OPTIONAL, BOOL, is_unmessagable, 9) #define meshtastic_NodeInfoLite_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, user, 2) \ -X(a, STATIC, OPTIONAL, MESSAGE, position, 3) \ X(a, STATIC, SINGULAR, FLOAT, snr, 4) \ X(a, STATIC, SINGULAR, FIXED32, last_heard, 5) \ -X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 6) \ X(a, STATIC, SINGULAR, UINT32, channel, 7) \ -X(a, STATIC, SINGULAR, BOOL, via_mqtt, 8) \ X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \ -X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ -X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ X(a, STATIC, SINGULAR, UINT32, next_hop, 12) \ -X(a, STATIC, SINGULAR, UINT32, bitfield, 13) +X(a, STATIC, SINGULAR, UINT32, bitfield, 13) \ +X(a, STATIC, SINGULAR, STRING, long_name, 14) \ +X(a, STATIC, SINGULAR, STRING, short_name, 15) \ +X(a, STATIC, SINGULAR, UENUM, hw_model, 16) \ +X(a, STATIC, SINGULAR, UENUM, role, 17) \ +X(a, STATIC, SINGULAR, BYTES, public_key, 18) #define meshtastic_NodeInfoLite_CALLBACK NULL #define meshtastic_NodeInfoLite_DEFAULT NULL -#define meshtastic_NodeInfoLite_user_MSGTYPE meshtastic_UserLite -#define meshtastic_NodeInfoLite_position_MSGTYPE meshtastic_PositionLite -#define meshtastic_NodeInfoLite_device_metrics_MSGTYPE meshtastic_DeviceMetrics #define meshtastic_DeviceState_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, MESSAGE, my_node, 2) \ @@ -312,13 +349,49 @@ X(a, STATIC, REPEATED, MESSAGE, node_remote_hardware_pins, 13) #define meshtastic_DeviceState_rx_waypoint_MSGTYPE meshtastic_MeshPacket #define meshtastic_DeviceState_node_remote_hardware_pins_MSGTYPE meshtastic_NodeRemoteHardwarePin +#define meshtastic_NodePositionEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, position, 2) +#define meshtastic_NodePositionEntry_CALLBACK NULL +#define meshtastic_NodePositionEntry_DEFAULT NULL +#define meshtastic_NodePositionEntry_position_MSGTYPE meshtastic_PositionLite + +#define meshtastic_NodeTelemetryEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 2) +#define meshtastic_NodeTelemetryEntry_CALLBACK NULL +#define meshtastic_NodeTelemetryEntry_DEFAULT NULL +#define meshtastic_NodeTelemetryEntry_device_metrics_MSGTYPE meshtastic_DeviceMetrics + +#define meshtastic_NodeEnvironmentEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, environment_metrics, 2) +#define meshtastic_NodeEnvironmentEntry_CALLBACK NULL +#define meshtastic_NodeEnvironmentEntry_DEFAULT NULL +#define meshtastic_NodeEnvironmentEntry_environment_metrics_MSGTYPE meshtastic_EnvironmentMetrics + +#define meshtastic_NodeStatusEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, status, 2) +#define meshtastic_NodeStatusEntry_CALLBACK NULL +#define meshtastic_NodeStatusEntry_DEFAULT NULL +#define meshtastic_NodeStatusEntry_status_MSGTYPE meshtastic_StatusMessage + #define meshtastic_NodeDatabase_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, version, 1) \ -X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) +X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) \ +X(a, CALLBACK, REPEATED, MESSAGE, positions, 3) \ +X(a, CALLBACK, REPEATED, MESSAGE, telemetry, 4) \ +X(a, CALLBACK, REPEATED, MESSAGE, status, 5) \ +X(a, CALLBACK, REPEATED, MESSAGE, environment, 6) extern bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field); #define meshtastic_NodeDatabase_CALLBACK meshtastic_NodeDatabase_callback #define meshtastic_NodeDatabase_DEFAULT NULL #define meshtastic_NodeDatabase_nodes_MSGTYPE meshtastic_NodeInfoLite +#define meshtastic_NodeDatabase_positions_MSGTYPE meshtastic_NodePositionEntry +#define meshtastic_NodeDatabase_telemetry_MSGTYPE meshtastic_NodeTelemetryEntry +#define meshtastic_NodeDatabase_status_MSGTYPE meshtastic_NodeStatusEntry +#define meshtastic_NodeDatabase_environment_MSGTYPE meshtastic_NodeEnvironmentEntry #define meshtastic_ChannelFile_FIELDLIST(X, a) \ X(a, STATIC, REPEATED, MESSAGE, channels, 1) \ @@ -345,6 +418,10 @@ extern const pb_msgdesc_t meshtastic_PositionLite_msg; extern const pb_msgdesc_t meshtastic_UserLite_msg; extern const pb_msgdesc_t meshtastic_NodeInfoLite_msg; extern const pb_msgdesc_t meshtastic_DeviceState_msg; +extern const pb_msgdesc_t meshtastic_NodePositionEntry_msg; +extern const pb_msgdesc_t meshtastic_NodeTelemetryEntry_msg; +extern const pb_msgdesc_t meshtastic_NodeEnvironmentEntry_msg; +extern const pb_msgdesc_t meshtastic_NodeStatusEntry_msg; extern const pb_msgdesc_t meshtastic_NodeDatabase_msg; extern const pb_msgdesc_t meshtastic_ChannelFile_msg; extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; @@ -354,6 +431,10 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_UserLite_fields &meshtastic_UserLite_msg #define meshtastic_NodeInfoLite_fields &meshtastic_NodeInfoLite_msg #define meshtastic_DeviceState_fields &meshtastic_DeviceState_msg +#define meshtastic_NodePositionEntry_fields &meshtastic_NodePositionEntry_msg +#define meshtastic_NodeTelemetryEntry_fields &meshtastic_NodeTelemetryEntry_msg +#define meshtastic_NodeEnvironmentEntry_fields &meshtastic_NodeEnvironmentEntry_msg +#define meshtastic_NodeStatusEntry_fields &meshtastic_NodeStatusEntry_msg #define meshtastic_NodeDatabase_fields &meshtastic_NodeDatabase_msg #define meshtastic_ChannelFile_fields &meshtastic_ChannelFile_msg #define meshtastic_BackupPreferences_fields &meshtastic_BackupPreferences_msg @@ -364,7 +445,11 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_BackupPreferences_size 2432 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1737 -#define meshtastic_NodeInfoLite_size 196 +#define meshtastic_NodeEnvironmentEntry_size 170 +#define meshtastic_NodeInfoLite_size 105 +#define meshtastic_NodePositionEntry_size 36 +#define meshtastic_NodeStatusEntry_size 89 +#define meshtastic_NodeTelemetryEntry_size 35 #define meshtastic_PositionLite_size 28 #define meshtastic_UserLite_size 98 diff --git a/src/mesh/generated/meshtastic/mesh.pb.cpp b/src/mesh/generated/meshtastic/mesh.pb.cpp index 3648d88502a..a68ffabacda 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.cpp +++ b/src/mesh/generated/meshtastic/mesh.pb.cpp @@ -57,6 +57,9 @@ PB_BIND(meshtastic_QueueStatus, meshtastic_QueueStatus, AUTO) PB_BIND(meshtastic_FromRadio, meshtastic_FromRadio, 2) +PB_BIND(meshtastic_LockdownStatus, meshtastic_LockdownStatus, AUTO) + + PB_BIND(meshtastic_ClientNotification, meshtastic_ClientNotification, 2) @@ -134,6 +137,8 @@ PB_BIND(meshtastic_ChunkedPayloadResponse, meshtastic_ChunkedPayloadResponse, AU + + diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 41ef2798c77..f6fe88019a5 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -638,6 +638,25 @@ typedef enum _meshtastic_LogRecord_Level { meshtastic_LogRecord_Level_TRACE = 5 } meshtastic_LogRecord_Level; +typedef enum _meshtastic_LockdownStatus_State { + /* Default; should not be sent. */ + meshtastic_LockdownStatus_State_STATE_UNSPECIFIED = 0, + /* No passphrase has ever been provisioned on this device. + Client should prompt the operator to set one. */ + meshtastic_LockdownStatus_State_NEEDS_PROVISION = 1, + /* Storage is locked or this client has not authenticated yet. + lock_reason carries a machine-readable detail string. + Client should present (or auto-replay) a passphrase via + AdminMessage.lockdown_auth. */ + meshtastic_LockdownStatus_State_LOCKED = 2, + /* Passphrase accepted; client is now authorized for this connection. + boots_remaining and valid_until_epoch describe the active session + token's TTL. */ + meshtastic_LockdownStatus_State_UNLOCKED = 3, + /* Passphrase rejected. backoff_seconds is non-zero when rate-limited. */ + meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4 +} meshtastic_LockdownStatus_State; + /* Struct definitions */ /* A GPS Position */ typedef struct _meshtastic_Position { @@ -1148,6 +1167,38 @@ typedef struct _meshtastic_QueueStatus { uint32_t mesh_packet_id; } meshtastic_QueueStatus; +/* Lockdown state report from firmware to client (for hardened builds + with MESHTASTIC_LOCKDOWN). Sent immediately after config_complete_id + to inform a freshly-connected unauthorized client what it must do, + and again in response to each LockdownAuth admin command. */ +typedef struct _meshtastic_LockdownStatus { + /* Current lockdown state being reported. */ + meshtastic_LockdownStatus_State state; + /* For LOCKED: machine-readable reason. Known values: + "needs_auth" — storage already unlocked, client must auth + "token_missing" — no boot token on flash + "token_expired" — boot token wall-clock TTL elapsed + "token_boots_zero" — boot token boot-count TTL exhausted + "token_hmac_fail" — token tampered or wrong device + "token_dek_fail" — token DEK decrypt failed + "token_wrong_size" — token file corrupted + "token_bad_magic" — token file corrupted + "not_provisioned" — should generally use NEEDS_PROVISION state instead + Other values may be added; clients should treat unknown values as + "locked, ask for passphrase". */ + char lock_reason[32]; + /* For UNLOCKED: remaining boots on the issued session token. + Decrements by 1 on each subsequent boot. */ + uint32_t boots_remaining; + /* For UNLOCKED: wall-clock expiry of the issued session token, + absolute Unix-epoch seconds. 0 = no time limit. */ + uint32_t valid_until_epoch; + /* For UNLOCK_FAILED: seconds the client must wait before another + passphrase attempt will be accepted. 0 = wrong passphrase, no + backoff (immediate retry allowed but advisable to prompt user). */ + uint32_t backoff_seconds; +} meshtastic_LockdownStatus; + typedef struct _meshtastic_KeyVerificationNumberInform { uint64_t nonce; char remote_longname[40]; @@ -1321,6 +1372,12 @@ typedef struct _meshtastic_FromRadio { meshtastic_ClientNotification clientNotification; /* Persistent data for device-ui */ meshtastic_DeviceUIConfig deviceuiConfig; + /* Lockdown state notification for hardened firmware builds. + Sent post-config (so unauthorized clients learn they must + provision/unlock) and after each LockdownAuth admin command + to report success or failure. Replaces the earlier scheme of + encoding state as magic-string prefixes inside ClientNotification. */ + meshtastic_LockdownStatus lockdown_status; }; } meshtastic_FromRadio; @@ -1462,6 +1519,10 @@ extern "C" { #define _meshtastic_LogRecord_Level_MAX meshtastic_LogRecord_Level_CRITICAL #define _meshtastic_LogRecord_Level_ARRAYSIZE ((meshtastic_LogRecord_Level)(meshtastic_LogRecord_Level_CRITICAL+1)) +#define _meshtastic_LockdownStatus_State_MIN meshtastic_LockdownStatus_State_STATE_UNSPECIFIED +#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_UNLOCK_FAILED +#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_UNLOCK_FAILED+1)) + #define meshtastic_Position_location_source_ENUMTYPE meshtastic_Position_LocSource #define meshtastic_Position_altitude_source_ENUMTYPE meshtastic_Position_AltSource @@ -1492,6 +1553,8 @@ extern "C" { +#define meshtastic_LockdownStatus_state_ENUMTYPE meshtastic_LockdownStatus_State + #define meshtastic_ClientNotification_level_ENUMTYPE meshtastic_LogRecord_Level @@ -1532,6 +1595,7 @@ extern "C" { #define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_default {0, 0, 0, 0} #define meshtastic_FromRadio_init_default {0, 0, {meshtastic_MeshPacket_init_default}} +#define meshtastic_LockdownStatus_init_default {_meshtastic_LockdownStatus_State_MIN, "", 0, 0, 0} #define meshtastic_ClientNotification_init_default {false, 0, 0, _meshtastic_LogRecord_Level_MIN, "", 0, {meshtastic_KeyVerificationNumberInform_init_default}} #define meshtastic_KeyVerificationNumberInform_init_default {0, "", 0} #define meshtastic_KeyVerificationNumberRequest_init_default {0, ""} @@ -1566,6 +1630,7 @@ extern "C" { #define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_zero {0, 0, 0, 0} #define meshtastic_FromRadio_init_zero {0, 0, {meshtastic_MeshPacket_init_zero}} +#define meshtastic_LockdownStatus_init_zero {_meshtastic_LockdownStatus_State_MIN, "", 0, 0, 0} #define meshtastic_ClientNotification_init_zero {false, 0, 0, _meshtastic_LogRecord_Level_MIN, "", 0, {meshtastic_KeyVerificationNumberInform_init_zero}} #define meshtastic_KeyVerificationNumberInform_init_zero {0, "", 0} #define meshtastic_KeyVerificationNumberRequest_init_zero {0, ""} @@ -1718,6 +1783,11 @@ extern "C" { #define meshtastic_QueueStatus_free_tag 2 #define meshtastic_QueueStatus_maxlen_tag 3 #define meshtastic_QueueStatus_mesh_packet_id_tag 4 +#define meshtastic_LockdownStatus_state_tag 1 +#define meshtastic_LockdownStatus_lock_reason_tag 2 +#define meshtastic_LockdownStatus_boots_remaining_tag 3 +#define meshtastic_LockdownStatus_valid_until_epoch_tag 4 +#define meshtastic_LockdownStatus_backoff_seconds_tag 5 #define meshtastic_KeyVerificationNumberInform_nonce_tag 1 #define meshtastic_KeyVerificationNumberInform_remote_longname_tag 2 #define meshtastic_KeyVerificationNumberInform_security_number_tag 3 @@ -1777,6 +1847,7 @@ extern "C" { #define meshtastic_FromRadio_fileInfo_tag 15 #define meshtastic_FromRadio_clientNotification_tag 16 #define meshtastic_FromRadio_deviceuiConfig_tag 17 +#define meshtastic_FromRadio_lockdown_status_tag 18 #define meshtastic_Heartbeat_nonce_tag 1 #define meshtastic_ToRadio_packet_tag 1 #define meshtastic_ToRadio_want_config_id_tag 3 @@ -2017,7 +2088,8 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,metadata,metadata), 13) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,mqttClientProxyMessage,mqttClientProxyMessage), 14) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,fileInfo,fileInfo), 15) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,clientNotification,clientNotification), 16) \ -X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfig), 17) +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfig), 17) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_status), 18) #define meshtastic_FromRadio_CALLBACK NULL #define meshtastic_FromRadio_DEFAULT NULL #define meshtastic_FromRadio_payload_variant_packet_MSGTYPE meshtastic_MeshPacket @@ -2034,6 +2106,16 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfi #define meshtastic_FromRadio_payload_variant_fileInfo_MSGTYPE meshtastic_FileInfo #define meshtastic_FromRadio_payload_variant_clientNotification_MSGTYPE meshtastic_ClientNotification #define meshtastic_FromRadio_payload_variant_deviceuiConfig_MSGTYPE meshtastic_DeviceUIConfig +#define meshtastic_FromRadio_payload_variant_lockdown_status_MSGTYPE meshtastic_LockdownStatus + +#define meshtastic_LockdownStatus_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, state, 1) \ +X(a, STATIC, SINGULAR, STRING, lock_reason, 2) \ +X(a, STATIC, SINGULAR, UINT32, boots_remaining, 3) \ +X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 4) \ +X(a, STATIC, SINGULAR, UINT32, backoff_seconds, 5) +#define meshtastic_LockdownStatus_CALLBACK NULL +#define meshtastic_LockdownStatus_DEFAULT NULL #define meshtastic_ClientNotification_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, UINT32, reply_id, 1) \ @@ -2194,6 +2276,7 @@ extern const pb_msgdesc_t meshtastic_MyNodeInfo_msg; extern const pb_msgdesc_t meshtastic_LogRecord_msg; extern const pb_msgdesc_t meshtastic_QueueStatus_msg; extern const pb_msgdesc_t meshtastic_FromRadio_msg; +extern const pb_msgdesc_t meshtastic_LockdownStatus_msg; extern const pb_msgdesc_t meshtastic_ClientNotification_msg; extern const pb_msgdesc_t meshtastic_KeyVerificationNumberInform_msg; extern const pb_msgdesc_t meshtastic_KeyVerificationNumberRequest_msg; @@ -2230,6 +2313,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_LogRecord_fields &meshtastic_LogRecord_msg #define meshtastic_QueueStatus_fields &meshtastic_QueueStatus_msg #define meshtastic_FromRadio_fields &meshtastic_FromRadio_msg +#define meshtastic_LockdownStatus_fields &meshtastic_LockdownStatus_msg #define meshtastic_ClientNotification_fields &meshtastic_ClientNotification_msg #define meshtastic_KeyVerificationNumberInform_fields &meshtastic_KeyVerificationNumberInform_msg #define meshtastic_KeyVerificationNumberRequest_fields &meshtastic_KeyVerificationNumberRequest_msg @@ -2265,6 +2349,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_KeyVerificationNumberInform_size 58 #define meshtastic_KeyVerificationNumberRequest_size 52 #define meshtastic_KeyVerification_size 79 +#define meshtastic_LockdownStatus_size 53 #define meshtastic_LogRecord_size 426 #define meshtastic_LowEntropyKey_size 0 #define meshtastic_MeshPacket_size 381 From b074645586b36fc1a8e05775990087f6a6558019 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 11 May 2026 17:16:32 -0500 Subject: [PATCH 168/225] Change node pointer to const in JsonSerialize function --- src/serialization/MeshPacketSerializer_nRF52.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serialization/MeshPacketSerializer_nRF52.cpp b/src/serialization/MeshPacketSerializer_nRF52.cpp index c79b3d26931..371d1478d29 100644 --- a/src/serialization/MeshPacketSerializer_nRF52.cpp +++ b/src/serialization/MeshPacketSerializer_nRF52.cpp @@ -291,7 +291,7 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, auto addToRoute = [](JsonArray *route, NodeNum num) { char long_name[40] = "Unknown"; - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num); + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(num); bool name_known = nodeInfoLiteHasUser(node); if (name_known) { const size_t copy_len = From b96012146408387952b7a7abc938ae7905a4984f Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Mon, 11 May 2026 21:53:41 -0400 Subject: [PATCH 169/225] BaseUI: remove legacy single-message runtime path and keep multimessage flow (#10450) * cleanup * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/MessageStore.cpp | 57 ++++---- src/MessageStore.h | 3 - src/graphics/Screen.cpp | 133 ------------------ src/graphics/Screen.h | 1 - src/graphics/draw/MenuHandler.cpp | 97 ++++++++++--- .../User/AllMessage/AllMessageApplet.h | 5 +- .../niche/InkHUD/Applets/User/DM/DMApplet.h | 7 +- src/graphics/niche/InkHUD/Events.cpp | 2 +- src/graphics/niche/InkHUD/Persistence.h | 3 +- src/graphics/niche/InkHUD/docs/README.md | 2 +- src/mesh/NodeDB.cpp | 2 - src/modules/TextMessageModule.cpp | 5 +- src/modules/TextMessageModule.h | 6 +- 13 files changed, 112 insertions(+), 211 deletions(-) diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp index 22da418f524..0bd82c40bd3 100644 --- a/src/MessageStore.cpp +++ b/src/MessageStore.cpp @@ -6,7 +6,6 @@ #include "SPILock.h" #include "SafeFile.h" #include "gps/RTC.h" -#include "graphics/draw/MessageRenderer.h" #include // memcpy #ifndef MESSAGE_TEXT_POOL_SIZE @@ -181,13 +180,8 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa bool isDM = (sm.dest != 0 && sm.dest != NODENUM_BROADCAST); - if (packet.from == 0) { - sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST; - sm.ackStatus = AckStatus::NONE; - } else { - sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST; - sm.ackStatus = AckStatus::ACKED; - } + sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST; + sm.ackStatus = (packet.from == 0) ? AckStatus::NONE : AckStatus::ACKED; addLiveMessage(sm); @@ -372,26 +366,25 @@ void MessageStore::clearAllMessages() #endif } -// Internal helper: erase first or last message matching a predicate -template static void eraseIf(std::deque &deque, Predicate pred, bool fromBack = false) +// Internal helpers for targeted erasure. +template static bool eraseFirstMatch(std::deque &deque, Predicate pred) { - if (fromBack) { - // Iterate from the back and erase all matches from the end - for (auto it = deque.rbegin(); it != deque.rend();) { - if (pred(*it)) { - it = std::deque::reverse_iterator(deque.erase(std::next(it).base())); - } else { - ++it; - } + for (auto it = deque.begin(); it != deque.end(); ++it) { + if (pred(*it)) { + deque.erase(it); + return true; } - } else { - // Manual forward search to erase all matches - for (auto it = deque.begin(); it != deque.end();) { - if (pred(*it)) { - it = deque.erase(it); - } else { - ++it; - } + } + return false; +} + +template static void eraseAllMatches(std::deque &deque, Predicate pred) +{ + for (auto it = deque.begin(); it != deque.end();) { + if (pred(*it)) { + it = deque.erase(it); + } else { + ++it; } } } @@ -399,7 +392,9 @@ template static void eraseIf(std::deque &deq // Delete oldest message (RAM + persisted queue) void MessageStore::deleteOldestMessage() { - eraseIf(liveMessages, [](StoredMessage &) { return true; }); + if (!liveMessages.empty()) { + liveMessages.pop_front(); + } saveToFlash(); } @@ -407,14 +402,14 @@ void MessageStore::deleteOldestMessage() void MessageStore::deleteOldestMessageInChannel(uint8_t channel) { auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; }; - eraseIf(liveMessages, pred); + eraseFirstMatch(liveMessages, pred); saveToFlash(); } void MessageStore::deleteAllMessagesInChannel(uint8_t channel) { auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; }; - eraseIf(liveMessages, pred, false /* delete ALL, not just first */); + eraseAllMatches(liveMessages, pred); saveToFlash(); } @@ -427,7 +422,7 @@ void MessageStore::deleteAllMessagesWithPeer(uint32_t peer) uint32_t other = (m.sender == local) ? m.dest : m.sender; return other == peer; }; - eraseIf(liveMessages, pred, false); + eraseAllMatches(liveMessages, pred); saveToFlash(); } @@ -440,7 +435,7 @@ void MessageStore::deleteOldestMessageWithPeer(uint32_t peer) uint32_t other = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender; return other == peer; }; - eraseIf(liveMessages, pred); + eraseFirstMatch(liveMessages, pred); saveToFlash(); } diff --git a/src/MessageStore.h b/src/MessageStore.h index 77271f1c9d9..8f25c84f78a 100644 --- a/src/MessageStore.h +++ b/src/MessageStore.h @@ -124,9 +124,6 @@ class MessageStore // Allocate text into pool (used by sender-side code) static uint16_t storeText(const char *src, size_t len); - // Used when loading from flash to rebuild the text pool - static uint16_t rebuildTextFromFlash(const char *src, size_t len); - private: std::deque liveMessages; // Single in-RAM message buffer (also used for persistence) std::string filename; // Flash filename for persistence diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 205e71bcf15..80e00ed692c 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -65,7 +65,6 @@ along with this program. If not, see . #include "mesh/Default.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" #include "modules/ExternalNotificationModule.h" -#include "modules/TextMessageModule.h" #include "modules/WaypointModule.h" #include "sleep.h" #include "target_specific.h" @@ -1643,138 +1642,6 @@ int Screen::handleStatusUpdate(const meshtastic::Status *arg) return 0; } -// Handles when message is received; will jump to text message frame. -int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) -{ - if (showingNormalScreen) { - if (packet->from == 0) { - // Outgoing message (likely sent from phone) - devicestate.has_rx_text_message = false; - memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message)); - hiddenFrames.textMessage = true; - hasUnreadMessage = false; // Clear unread state when user replies - - setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list - } else { - // Incoming message - devicestate.has_rx_text_message = true; // Needed to include the message frame - hasUnreadMessage = true; // Enables mail icon in the header - setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view (no-op during text_input) - - // Only wake/force display if the configuration allows it - if (shouldWakeOnReceivedMessage()) { - setOn(true); // Wake up the screen first - forceDisplay(); // Forces screen redraw - } - // === Prepare banner/popup content === - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from); - const meshtastic_Channel channel = - channels.getByIndex(packet->channel ? packet->channel : channels.getPrimaryIndex()); - const char *longName = nodeInfoLiteHasUser(node) ? node->long_name : nullptr; - - const char *msgRaw = reinterpret_cast(packet->decoded.payload.bytes); - - char banner[256]; - - bool isAlert = false; - - if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_bell_vibra || - moduleConfig.external_notification.alert_bell_buzzer) - // Check for bell character to determine if this message is an alert - for (size_t i = 0; i < packet->decoded.payload.size && i < 100; i++) { - if (msgRaw[i] == ASCII_BELL) { - isAlert = true; - break; - } - } - - // Unlike generic messages, alerts (when enabled via the ext notif module) ignore any - // 'mute' preferences set to any specific node or channel. - // If on-screen keyboard is active, show a transient popup over keyboard instead of interrupting it - if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { - // Wake and force redraw so popup is visible immediately - if (shouldWakeOnReceivedMessage()) { - setOn(true); - forceDisplay(); - } - - // Build popup: title = message source name, content = message text (sanitized) - // Title - char titleBuf[64] = {0}; - if (longName && longName[0]) { - // Sanitize sender name - std::string t = sanitizeString(longName); - strncpy(titleBuf, t.c_str(), sizeof(titleBuf) - 1); - } else { - strncpy(titleBuf, "Message", sizeof(titleBuf) - 1); - } - - // Content: payload bytes may not be null-terminated, remove ASCII_BELL and sanitize - char content[256] = {0}; - { - std::string raw; - raw.reserve(packet->decoded.payload.size); - for (size_t i = 0; i < packet->decoded.payload.size; ++i) { - char c = msgRaw[i]; - if (c == ASCII_BELL) - continue; // strip bell - raw.push_back(c); - } - std::string sanitized = sanitizeString(raw); - strncpy(content, sanitized.c_str(), sizeof(content) - 1); - } - - NotificationRenderer::showKeyboardMessagePopupWithTitle(titleBuf, content, 3000); - -// Maintain existing buzzer behavior on M5 if applicable -#if defined(M5STACK_UNITC6L) - if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY || - (isAlert && moduleConfig.external_notification.alert_bell_buzzer) || - (!isBroadcast(packet->to) && isToUs(packet))) { - playLongBeep(); - } -#endif - } else { - // No keyboard active: use regular banner flow, respecting mute settings - if (isAlert) { - if (longName && longName[0]) { - snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName); - } else { - strcpy(banner, "Alert Received"); - } - screen->showSimpleBanner(banner, 3000); - } else if (!channel.settings.has_module_settings || !channel.settings.module_settings.is_muted) { - if (longName && longName[0]) { - if (currentResolution == ScreenResolution::UltraLow) { - strcpy(banner, "New Message"); - } else { - snprintf(banner, sizeof(banner), "New Message from\n%s", longName); - } - } else { - strcpy(banner, "New Message"); - } -#if defined(M5STACK_UNITC6L) - screen->setOn(true); - screen->showSimpleBanner(banner, 1500); - if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY || - (isAlert && moduleConfig.external_notification.alert_bell_buzzer) || - (!isBroadcast(packet->to) && isToUs(packet))) { - // Beep if not in DIRECT_MSG_ONLY mode or if in DIRECT_MSG_ONLY mode and either - // - packet contains an alert and alert bell buzzer is enabled - // - packet is a non-broadcast that is addressed to this node - playLongBeep(); - } -#else - screen->showSimpleBanner(banner, 3000); -#endif - } - } - } - } - - return 0; -} - // Triggered by MeshModules int Screen::handleUIFrameEvent(const UIFrameEvent *event) { diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 401cba59ef7..c2d64e50dff 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -609,7 +609,6 @@ class Screen : public concurrency::OSThread // Handle observer events int handleStatusUpdate(const meshtastic::Status *arg); - int handleTextMessage(const meshtastic_MeshPacket *packet); int handleUIFrameEvent(const UIFrameEvent *arg); int handleInputEvent(const InputEvent *arg); int handleAdminMessage(AdminModule_ObserverData *arg); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 382b89f2ff5..386a4c077de 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -57,6 +57,70 @@ BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOp return bannerOptions; } +const StoredMessage *getNewestMessageForActiveThread() +{ + const auto &messages = messageStore.getMessages(); + if (messages.empty()) { + return nullptr; + } + + const auto mode = graphics::MessageRenderer::getThreadMode(); + const int channel = graphics::MessageRenderer::getThreadChannel(); + const uint32_t peer = graphics::MessageRenderer::getThreadPeer(); + const uint32_t localNode = nodeDB->getNodeNum(); + + if (mode == graphics::MessageRenderer::ThreadMode::ALL) { + return &messages.back(); + } + + for (auto it = messages.rbegin(); it != messages.rend(); ++it) { + const StoredMessage &m = *it; + + if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { + if (m.type == MessageType::BROADCAST && static_cast(m.channelIndex) == channel) { + return &m; + } + continue; + } + + if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { + if (m.type != MessageType::DM_TO_US) { + continue; + } + const uint32_t other = (m.sender == localNode) ? m.dest : m.sender; + if (other == peer) { + return &m; + } + } + } + + return nullptr; +} + +void launchReplyForMessage(const StoredMessage &message, bool freetext) +{ + if (message.type == MessageType::BROADCAST || message.dest == NODENUM_BROADCAST) { + if (freetext) { + cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, message.channelIndex); + } else { + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, message.channelIndex); + } + return; + } + + const uint32_t localNode = nodeDB->getNodeNum(); + const uint32_t peer = (message.sender == localNode) ? message.dest : message.sender; + if (peer == 0 || peer == NODENUM_BROADCAST) { + return; + } + + if (freetext) { + cannedMessageModule->LaunchFreetextWithDestination(peer); + } else { + cannedMessageModule->LaunchWithDestination(peer); + } +} + } // namespace menuHandler::screenMenus menuHandler::menuQueue = MenuNone; @@ -594,9 +658,12 @@ void menuHandler::messageResponseMenu() #ifdef HAS_I2S } else if (selected == Aloud) { - const meshtastic_MeshPacket &mp = devicestate.rx_text_message; - const char *msg = reinterpret_cast(mp.decoded.payload.bytes); - audioThread->readAloud(msg); + if (const StoredMessage *latest = getNewestMessageForActiveThread()) { + const char *msg = MessageStore::getText(*latest); + if (msg && msg[0]) { + audioThread->readAloud(msg); + } + } #endif } }; @@ -656,20 +723,12 @@ void menuHandler::replyMenu() // Preset reply if (selected == ReplyPreset) { - if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, ch); - } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { cannedMessageModule->LaunchWithDestination(peer); - - } else { - // Fallback for last received message - if (devicestate.rx_text_message.to == NODENUM_BROADCAST) { - cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel); - } else { - cannedMessageModule->LaunchWithDestination(devicestate.rx_text_message.from); - } + } else if (const StoredMessage *latest = getNewestMessageForActiveThread()) { + launchReplyForMessage(*latest, false); } return; @@ -677,20 +736,12 @@ void menuHandler::replyMenu() // Freetext reply if (selected == ReplyFreetext) { - if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, ch); - } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { cannedMessageModule->LaunchFreetextWithDestination(peer); - - } else { - // Fallback for last received message - if (devicestate.rx_text_message.to == NODENUM_BROADCAST) { - cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel); - } else { - cannedMessageModule->LaunchFreetextWithDestination(devicestate.rx_text_message.from); - } + } else if (const StoredMessage *latest = getNewestMessageForActiveThread()) { + launchReplyForMessage(*latest, true); } return; diff --git a/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h index 4aa97e4f11b..c96b3208c43 100644 --- a/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h +++ b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h @@ -5,9 +5,8 @@ Shows the latest incoming text message, as well as sender. Both broadcast and direct messages will be shown here, from all channels. -This module doesn't doesn't use the devicestate.rx_text_message,' as this is overwritten to contain outgoing messages This module doesn't collect its own text message. Instead, the WindowManager stores the most recent incoming text message. -This is available to any interested modules (SingeMessageApplet, NotificationApplet etc.) via InkHUD::latestMessage +This is available to any interested modules (SingleMessageApplet, NotificationApplet etc.) via InkHUD::latestMessage We do still receive notifications from the text message module though, to know when a new message has arrived, and trigger the update. @@ -46,4 +45,4 @@ class AllMessageApplet : public Applet } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.h b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.h index 4eb0ec704f9..f7ddbb4dfde 100644 --- a/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.h +++ b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.h @@ -3,11 +3,10 @@ /* Shows the latest incoming *Direct Message* (DM), as well as sender. -This compliments the threaded message applets +This complements the threaded message applets -This module doesn't doesn't use the devicestate.rx_text_message,' as this is overwritten to contain outgoing messages This module doesn't collect its own text message. Instead, the WindowManager stores the most recent incoming text message. -This is available to any interested modules (SingeMessageApplet, NotificationApplet etc.) via InkHUD::latestMessage +This is available to any interested modules (SingleMessageApplet, NotificationApplet etc.) via InkHUD::latestMessage We do still receive notifications from the text message module though, to know when a new message has arrived, and trigger the update. @@ -46,4 +45,4 @@ class DMApplet : public Applet } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index ddcc6781b8d..d40cc2ca759 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -525,7 +525,7 @@ int InkHUD::Events::beforeReboot(void *unused) // Callback when a new text message is received // Caches the most recently received message, for use by applets // Rx does not trigger a save to flash, however the data *will* be saved alongside other during shutdown, etc. -// Note: this is different from devicestate.rx_text_message, which may contain an *outgoing* message +// Note: this is intentionally separate from device-state message fields. int InkHUD::Events::onReceiveTextMessage(const meshtastic_MeshPacket *packet) { // Short circuit: don't store outgoing messages diff --git a/src/graphics/niche/InkHUD/Persistence.h b/src/graphics/niche/InkHUD/Persistence.h index 187af2129c7..6f783cb1c5c 100644 --- a/src/graphics/niche/InkHUD/Persistence.h +++ b/src/graphics/niche/InkHUD/Persistence.h @@ -121,8 +121,7 @@ class Persistence // Most recently received text message // Value is updated by InkHUD::WindowManager, as a courtesy to applets - // Note: different from devicestate.rx_text_message, - // which may contain an *outgoing message* to broadcast + // InkHUD keeps its own latest-message cache for applets. struct LatestMessage { MessageStore::Message broadcast; // Most recent message received broadcast MessageStore::Message dm; // Most recent received DM diff --git a/src/graphics/niche/InkHUD/docs/README.md b/src/graphics/niche/InkHUD/docs/README.md index 7cd468d73de..94af04fb2a1 100644 --- a/src/graphics/niche/InkHUD/docs/README.md +++ b/src/graphics/niche/InkHUD/docs/README.md @@ -464,7 +464,7 @@ Most recently received text message Collected here, so various user applets don't all have to store their own copy of this info. -We are unable to use `devicestate.rx_text_message` for this purpose, because: +We keep this separate latest-message cache for this purpose, because: - it is cleared by an outgoing text message - we want to store both a recent broadcast and a recent DM diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 4f514682e6c..f41800abe5e 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1171,7 +1171,6 @@ void NodeDB::resetNodes(bool keepFavorites) std::fill(nodeDatabase.nodes.begin() + 1, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite()); } (void)ourNum; - devicestate.has_rx_text_message = false; devicestate.has_rx_waypoint = false; saveNodeDatabaseToDisk(); saveDeviceStateToDisk(); @@ -1378,7 +1377,6 @@ void NodeDB::installDefaultDeviceState() devicestate.version = DEVICESTATE_CUR_VER; devicestate.receive_queue_count = 0; // Not yet implemented FIXME devicestate.has_rx_waypoint = false; - devicestate.has_rx_text_message = false; generatePacketId(); // FIXME - ugly way to init current_packet_id; diff --git a/src/modules/TextMessageModule.cpp b/src/modules/TextMessageModule.cpp index d94701c6b8a..275dec63dfb 100644 --- a/src/modules/TextMessageModule.cpp +++ b/src/modules/TextMessageModule.cpp @@ -21,9 +21,6 @@ ProcessMessage TextMessageModule::handleReceived(const meshtastic_MeshPacket &mp textPacketList[textPacketListIndex] = mp.id; textPacketListIndex = (textPacketListIndex + 1) % TEXT_PACKET_LIST_SIZE; - // We only store/display messages destined for us. - devicestate.rx_text_message = mp; - devicestate.has_rx_text_message = true; IF_SCREEN( // Guard against running in MeshtasticUI or with no screen if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { @@ -59,4 +56,4 @@ bool TextMessageModule::recentlySeen(uint32_t id) } } return false; -} \ No newline at end of file +} diff --git a/src/modules/TextMessageModule.h b/src/modules/TextMessageModule.h index 42900a78e6e..ea708315818 100644 --- a/src/modules/TextMessageModule.h +++ b/src/modules/TextMessageModule.h @@ -7,8 +7,8 @@ * Text message handling for Meshtastic. * * This module is responsible for receiving and storing incoming text messages - * from the mesh. It updates device state and notifies observers so that other - * components (such as the MessageRenderer) can later display or process them. + * from the mesh. It notifies observers so that other components (such as the + * MessageRenderer) can later display or process them. * * Rendering of messages on screen is no longer done here. */ @@ -36,4 +36,4 @@ class TextMessageModule : public SinglePortModule, public Observable Date: Mon, 11 May 2026 21:42:07 -0500 Subject: [PATCH 170/225] Remove gradient sync nonce and simplify replay handling (#10459) * Remove gradient sync nonce and simplify replay handling * Fix ONLY_CONFIG replay gating and stale gradient-sync comments Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/cfa93978-e2e0-4dc2-ba5f-b82b5b43cef8 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Add transport mechanism to replay packets for client filtering * Comments * Update protobuf definitions to include precision_bits in PositionLite * Propagate position precision_bits and remove verbose NodeInfo sync log Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/41572cbc-408e-499d-b59e-00f330b5789f Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 24 +- protobufs | 2 +- src/mesh/PhoneAPI.cpp | 344 ++++++++---------- src/mesh/PhoneAPI.h | 37 +- src/mesh/TypeConversions.cpp | 7 + src/mesh/generated/meshtastic/deviceonly.pb.h | 14 +- .../meshtastic/deviceonly_legacy.pb.h | 2 +- 7 files changed, 210 insertions(+), 220 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bac47853d08..d165f2cdb61 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -193,22 +193,26 @@ Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which d Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly. -### Gradient sync (opt-in via special nonces) +### Sync flow: thin NodeInfo + post-COMPLETE_ID replay (no opt-in) -`client_capabilities` is **not** a thing in this branch. Phone clients opt into the new sync flow by sending one of two values in the `ToRadio.want_config_id`: +There is no capability flag and no special "gradient" nonce. The **default** sync flow is: -- `SPECIAL_NONCE_GRADIENT_SYNC` (69422) — full config + thin NodeInfo + replay phases. -- `SPECIAL_NONCE_GRADIENT_ONLY_NODES` (69423) — skip config segments, NodeInfo + replay only. +1. Config / module-config / channel / metadata segments (same as before). +2. `STATE_SEND_OWN_NODEINFO` — **our own** NodeInfo, still bundled with our position and device_metrics (because the replay snapshot excludes our own NodeNum). Emitted via `ConvertToNodeInfo(lite)`. +3. `STATE_SEND_OTHER_NODEINFOS` — every other peer's NodeInfo, **always thin** (no `position`, no `device_metrics`). Emitted via `ConvertToNodeInfoThin(lite)`. +4. `STATE_SEND_FILEMANIFEST` → `STATE_SEND_COMPLETE_ID` — the phone sees `config_complete_id` and treats sync as done. +5. `STATE_SEND_PACKETS` — live mesh packets, with a trailing replay drain interleaved. The replay drain walks four cached satellite stores in order (positions → telemetry → environment → status) and emits each cached entry as an ordinary `MeshPacket` on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` device + environment variants, `NODE_STATUS_APP`). These are indistinguishable on the wire from live mesh traffic, so clients need no special handling — any code that already updates UI on `POSITION_APP` etc. works. -`PhoneAPI::clientWantsGradientSync()` is the single switch. When true, `STATE_SEND_OTHER_NODEINFOS` is followed by: +`PhoneAPI::sendConfigComplete()` arms `replayPhase = REPLAY_PHASE_POSITIONS` for default/full sync and `SPECIAL_NONCE_ONLY_NODES`, while `SPECIAL_NONCE_ONLY_CONFIG` skips replay. The drain runs inside `STATE_SEND_PACKETS` via `popReplayPacket()`, lower priority than live traffic. When all four phases drain, `replayPhase` flips back to `REPLAY_PHASE_IDLE` and the snapshot vectors get `shrink_to_fit`ed. -```text -STATE_REPLAY_POSITIONS → STATE_REPLAY_TELEMETRY → STATE_REPLAY_ENVIRONMENT → STATE_REPLAY_STATUS -``` +STM32WL and any other build with all four `MESHTASTIC_EXCLUDE_*DB` flags set produces zero replay packets — `popReplayPacket` advances through each phase in microseconds without emitting anything. + +Special nonces that still mean something: -Each replay phase walks the corresponding satellite map and emits synthetic `MeshPacket`s on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` for both device + environment variants, `STATUS_MESSAGE_APP`). Legacy clients (no special nonce) get the bundled-NodeInfo path with position/device_metrics joined back in by `ConvertToNodeInfo(lite, pos*, dm*)` — wire bytes are byte-identical to pre-v25 for them. +- `SPECIAL_NONCE_ONLY_CONFIG` (69420) — skip node sync entirely, just config. +- `SPECIAL_NONCE_ONLY_NODES` (69421) — skip config segments, jump straight to `STATE_SEND_OWN_NODEINFO`. Still gets the post-COMPLETE_ID replay drain. -`ConvertToNodeInfoThin(lite)` is the gradient-sync emitter (no position/telemetry). +There are no other reserved nonces; everything else is a fresh random `want_config_id` from the client. ### v24 → v25 migration diff --git a/protobufs b/protobufs index f899d38422a..ff5b3925037 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit f899d38422ab07e7a973ee1e99fcd5fa1acd8dbd +Subproject commit ff5b392503776bf13073034070543d5c5aa1acf7 diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 720743907ca..ecf6ff809d4 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -62,7 +62,7 @@ void PhoneAPI::handleStartConfig() onConfigStart(); // even if we were already connected - restart our state machine - if (config_nonce == SPECIAL_NONCE_ONLY_NODES || config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES) { + if (config_nonce == SPECIAL_NONCE_ONLY_NODES) { // If client only wants node info, jump directly to sending nodes state = STATE_SEND_OWN_NODEINFO; LOG_INFO("Client only wants node info, skipping other config"); @@ -138,6 +138,7 @@ void PhoneAPI::close() replayTelemetryIndex = 0; replayEnvironmentIndex = 0; replayStatusIndex = 0; + replayPhase = REPLAY_PHASE_IDLE; } packetForPhone = NULL; filesManifest.clear(); @@ -320,7 +321,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) nodeInfoForPhone.num = 0; } } - if (config_nonce == SPECIAL_NONCE_ONLY_NODES || config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES) { + if (config_nonce == SPECIAL_NONCE_ONLY_NODES) { // If client only wants node info, jump directly to sending nodes state = STATE_SEND_OTHER_NODEINFOS; onNowHasData(0); @@ -535,11 +536,6 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) // Just in case we stored a different user.id in the past, but should never happen going forward sprintf(infoToSend.user.id, "!%08x", infoToSend.num); - // Logging this really slows down sending nodes on initial connection because the serial console is so slow, so only - // uncomment if you really need to: - // LOG_INFO("nodeinfo: num=0x%x, lastseen=%u, id=%s, name=%s", nodeInfoForPhone.num, nodeInfoForPhone.last_heard, - // nodeInfoForPhone.user.id, nodeInfoForPhone.user.long_name); - fromRadioScratch.which_payload_variant = meshtastic_FromRadio_node_info_tag; fromRadioScratch.node_info = infoToSend; prefetchNodeInfos(); @@ -548,123 +544,8 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) nodeInfoMutex.lock(); nodeInfoQueue.clear(); nodeInfoMutex.unlock(); - // Replay states no-op for legacy clients / excluded DBs. - state = STATE_REPLAY_POSITIONS; - return getFromRadio(buf); - } - break; - } - - case STATE_REPLAY_POSITIONS: { - if (replayPositionOrder.empty() && replayPositionIndex == 0) - beginReplayPositions(); - prefetchReplayPositions(); - - meshtastic_MeshPacket pkt = {}; - bool havePkt = false; - { - concurrency::LockGuard guard(&nodeInfoMutex); - if (!replayQueue.empty()) { - pkt = replayQueue.front(); - replayQueue.pop_front(); - havePkt = true; - } - } - - if (havePkt) { - fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag; - fromRadioScratch.packet = pkt; - } else { - LOG_DEBUG("Done replaying positions count=%u millis=%u", (unsigned)replayPositionIndex, millis()); - state = STATE_REPLAY_TELEMETRY; - return getFromRadio(buf); - } - break; - } - - case STATE_REPLAY_TELEMETRY: { - if (replayTelemetryOrder.empty() && replayTelemetryIndex == 0) - beginReplayTelemetry(); - prefetchReplayTelemetry(); - - meshtastic_MeshPacket pkt = {}; - bool havePkt = false; - { - concurrency::LockGuard guard(&nodeInfoMutex); - if (!replayQueue.empty()) { - pkt = replayQueue.front(); - replayQueue.pop_front(); - havePkt = true; - } - } - - if (havePkt) { - fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag; - fromRadioScratch.packet = pkt; - } else { - LOG_DEBUG("Done replaying telemetry count=%u millis=%u", (unsigned)replayTelemetryIndex, millis()); - state = STATE_REPLAY_ENVIRONMENT; - return getFromRadio(buf); - } - break; - } - - case STATE_REPLAY_ENVIRONMENT: { - if (replayEnvironmentOrder.empty() && replayEnvironmentIndex == 0) - beginReplayEnvironment(); - prefetchReplayEnvironment(); - - meshtastic_MeshPacket pkt = {}; - bool havePkt = false; - { - concurrency::LockGuard guard(&nodeInfoMutex); - if (!replayQueue.empty()) { - pkt = replayQueue.front(); - replayQueue.pop_front(); - havePkt = true; - } - } - - if (havePkt) { - fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag; - fromRadioScratch.packet = pkt; - } else { - LOG_DEBUG("Done replaying environment count=%u millis=%u", (unsigned)replayEnvironmentIndex, millis()); - state = STATE_REPLAY_STATUS; - return getFromRadio(buf); - } - break; - } - - case STATE_REPLAY_STATUS: { - if (replayStatusOrder.empty() && replayStatusIndex == 0) - beginReplayStatus(); - prefetchReplayStatus(); - - meshtastic_MeshPacket pkt = {}; - bool havePkt = false; - { - concurrency::LockGuard guard(&nodeInfoMutex); - if (!replayQueue.empty()) { - pkt = replayQueue.front(); - replayQueue.pop_front(); - havePkt = true; - } - } - - if (havePkt) { - fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag; - fromRadioScratch.packet = pkt; - } else { - LOG_DEBUG("Done replaying status count=%u millis=%u", (unsigned)replayStatusIndex, millis()); - replayPositionOrder.clear(); - replayPositionOrder.shrink_to_fit(); - replayTelemetryOrder.clear(); - replayTelemetryOrder.shrink_to_fit(); - replayEnvironmentOrder.clear(); - replayEnvironmentOrder.shrink_to_fit(); - replayStatusOrder.clear(); - replayStatusOrder.shrink_to_fit(); + // Satellite-DB replay (positions/telemetry/environment/status) now happens + // *after* config_complete_id, interleaved with live traffic in STATE_SEND_PACKETS. state = STATE_SEND_FILEMANIFEST; return getFromRadio(buf); } @@ -674,8 +555,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) case STATE_SEND_FILEMANIFEST: { LOG_DEBUG("FromRadio=STATE_SEND_FILEMANIFEST"); // ONLY_NODES variants skip the manifest. - if (config_state == filesManifest.size() || config_nonce == SPECIAL_NONCE_ONLY_NODES || - config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES) { + if (config_state == filesManifest.size() || config_nonce == SPECIAL_NONCE_ONLY_NODES) { config_state = 0; filesManifest.clear(); // Skip to complete packet @@ -720,6 +600,16 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag; fromRadioScratch.packet = *packetForPhone; releasePhonePacket(); + } else if (replayPending()) { + // 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; + if (popReplayPacket(replayPkt)) { + printPacket("replay packet to phone", &replayPkt); + fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag; + fromRadioScratch.packet = replayPkt; + } } break; @@ -744,10 +634,19 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) void PhoneAPI::sendConfigComplete() { LOG_INFO("Config Send Complete millis=%u", millis()); + const bool shouldReplaySatellites = (config_nonce != SPECIAL_NONCE_ONLY_CONFIG); + // The phone sees config_complete_id first (treats sync as done), then the cached + // satellite-DB packets (positions / telemetry / environment / status) trickle in + // afterward as ordinary mesh packets (except SPECIAL_NONCE_ONLY_CONFIG, which + // skips node/satellite sync entirely). Any client that handles live POSITION_APP / + // TELEMETRY_APP / NODE_STATUS_APP packets handles these identically. STM32WL and + // other builds that compile the satellite DBs out produce no replay packets and + // the phase advances to IDLE in microseconds. fromRadioScratch.which_payload_variant = meshtastic_FromRadio_config_complete_id_tag; fromRadioScratch.config_complete_id = config_nonce; config_nonce = 0; state = STATE_SEND_PACKETS; + replayPhase = shouldReplaySatellites ? REPLAY_PHASE_POSITIONS : REPLAY_PHASE_IDLE; if (api_type == TYPE_BLE) { service->api_state = service->STATE_BLE; } else if (api_type == TYPE_WIFI) { @@ -788,7 +687,8 @@ void PhoneAPI::prefetchNodeInfos() { bool added = false; bool wasEmpty = false; - const bool gradient = clientWantsGradientSync(); + // Other-node NodeInfos always go out thin (no bundled position/device_metrics). + // The post-config_complete_id replay drain delivers those as ordinary mesh packets. // Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment. { concurrency::LockGuard guard(&nodeInfoMutex); @@ -798,8 +698,7 @@ void PhoneAPI::prefetchNodeInfos() if (!nextNode) break; - auto info = - gradient ? TypeConversions::ConvertToNodeInfoThin(nextNode) : TypeConversions::ConvertToNodeInfo(nextNode); + auto info = TypeConversions::ConvertToNodeInfoThin(nextNode); bool isUs = info.num == nodeDB->getNodeNum(); info.hops_away = isUs ? 0 : info.hops_away; info.last_heard = isUs ? getValidTime(RTCQualityFromNet) : info.last_heard; @@ -821,11 +720,20 @@ void PhoneAPI::prefetchNodeInfos() meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const meshtastic_PositionLite &pos) { + // Shape this exactly like a fresh live broadcast Position from the peer so the + // phone runs it through its normal "live position broadcast" handler path. + // to=ourNum would read as a DM-from-peer and never lands in node detail UI. meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; pkt.from = num; - pkt.to = nodeDB->getNodeNum(); + pkt.to = NODENUM_BROADCAST; pkt.id = generatePacketId(); pkt.rx_time = pos.time; + pkt.channel = 0; + 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 + pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag; pkt.decoded.portnum = meshtastic_PortNum_POSITION_APP; meshtastic_Position fullPos = TypeConversions::ConvertToPosition(pos); @@ -839,11 +747,18 @@ meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const mes { meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; pkt.from = num; - pkt.to = nodeDB->getNodeNum(); + pkt.to = NODENUM_BROADCAST; pkt.id = generatePacketId(); // No native timestamp on telemetry packets here; use last_heard. const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + pkt.channel = 0; + 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 + // 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; pkt.decoded.portnum = meshtastic_PortNum_TELEMETRY_APP; meshtastic_Telemetry fullTel = meshtastic_Telemetry_init_default; @@ -859,16 +774,12 @@ meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const mes void PhoneAPI::beginReplayPositions() { #if MESHTASTIC_EXCLUDE_POSITIONDB - // Build excluded entirely - leave the order list empty so the state arm + // Build excluded entirely - leave the order list empty so the phase // immediately drains and advances. replayPositionOrder.clear(); replayPositionIndex = 0; #else - if (!clientWantsGradientSync()) { - replayPositionOrder.clear(); - replayPositionIndex = 0; - return; - } + // Caller (popReplayPacket) only invokes us when replayPhase is armed. // Snapshot the keyset at phase start so concurrent inserts/erases on the // map don't invalidate iteration. Skip our own node - the phone already // got our position bundled in STATE_SEND_OWN_NODEINFO. @@ -883,8 +794,6 @@ void PhoneAPI::prefetchReplayPositions() #if MESHTASTIC_EXCLUDE_POSITIONDB return; #else - if (!clientWantsGradientSync()) - return; bool added = false; bool wasEmpty = false; { @@ -910,11 +819,6 @@ void PhoneAPI::beginReplayTelemetry() replayTelemetryOrder.clear(); replayTelemetryIndex = 0; #else - if (!clientWantsGradientSync()) { - replayTelemetryOrder.clear(); - replayTelemetryIndex = 0; - return; - } replayTelemetryOrder = nodeDB->snapshotTelemetryNodeNums(nodeDB->getNodeNum()); replayTelemetryIndex = 0; LOG_INFO("Begin telemetry replay: %u entries millis=%u", (unsigned)replayTelemetryOrder.size(), millis()); @@ -926,8 +830,6 @@ void PhoneAPI::prefetchReplayTelemetry() #if MESHTASTIC_EXCLUDE_TELEMETRYDB return; #else - if (!clientWantsGradientSync()) - return; bool added = false; bool wasEmpty = false; { @@ -951,10 +853,17 @@ meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const { meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; pkt.from = num; - pkt.to = nodeDB->getNodeNum(); + pkt.to = NODENUM_BROADCAST; pkt.id = generatePacketId(); const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + pkt.channel = 0; + 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 + // 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; pkt.decoded.portnum = meshtastic_PortNum_TELEMETRY_APP; meshtastic_Telemetry fullTel = meshtastic_Telemetry_init_default; @@ -973,11 +882,6 @@ void PhoneAPI::beginReplayEnvironment() replayEnvironmentOrder.clear(); replayEnvironmentIndex = 0; #else - if (!clientWantsGradientSync()) { - replayEnvironmentOrder.clear(); - replayEnvironmentIndex = 0; - return; - } replayEnvironmentOrder = nodeDB->snapshotEnvironmentNodeNums(nodeDB->getNodeNum()); replayEnvironmentIndex = 0; LOG_INFO("Begin environment replay: %u entries millis=%u", (unsigned)replayEnvironmentOrder.size(), millis()); @@ -989,8 +893,6 @@ void PhoneAPI::prefetchReplayEnvironment() #if MESHTASTIC_EXCLUDE_ENVIRONMENTDB return; #else - if (!clientWantsGradientSync()) - return; bool added = false; bool wasEmpty = false; { @@ -1014,11 +916,17 @@ meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const mesht { meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; pkt.from = num; - pkt.to = nodeDB->getNodeNum(); + pkt.to = NODENUM_BROADCAST; pkt.id = generatePacketId(); // StatusMessage has no native timestamp; use last_heard. const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + pkt.channel = 0; + 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 + pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; pkt.which_payload_variant = meshtastic_MeshPacket_decoded_tag; pkt.decoded.portnum = meshtastic_PortNum_NODE_STATUS_APP; size_t len = @@ -1033,11 +941,6 @@ void PhoneAPI::beginReplayStatus() replayStatusOrder.clear(); replayStatusIndex = 0; #else - if (!clientWantsGradientSync()) { - replayStatusOrder.clear(); - replayStatusIndex = 0; - return; - } replayStatusOrder = nodeDB->snapshotStatusNodeNums(nodeDB->getNodeNum()); replayStatusIndex = 0; LOG_INFO("Begin status replay: %u entries millis=%u", (unsigned)replayStatusOrder.size(), millis()); @@ -1049,8 +952,6 @@ void PhoneAPI::prefetchReplayStatus() #if MESHTASTIC_EXCLUDE_STATUSDB return; #else - if (!clientWantsGradientSync()) - return; bool added = false; bool wasEmpty = false; { @@ -1070,6 +971,94 @@ void PhoneAPI::prefetchReplayStatus() #endif } +// Pop one cached satellite-DB packet from the active replay phase. +// Phases drain in order: positions -> telemetry -> environment -> status. +// When the current phase's cursor is exhausted (queue empty AND no more entries +// to snapshot), advance to the next phase. When all four phases are done, +// flip replayPhase back to IDLE and release the snapshot vectors. +// +// Returns true if a packet was placed in `out`; false if everything is drained. +bool PhoneAPI::popReplayPacket(meshtastic_MeshPacket &out) +{ + while (replayPhase != REPLAY_PHASE_IDLE) { + // Prime the active phase: seed the snapshot vector on first entry, + // top up replayQueue from the snapshot up to kReplayPrefetchDepth. + switch (replayPhase) { + case REPLAY_PHASE_POSITIONS: + if (replayPositionOrder.empty() && replayPositionIndex == 0) + beginReplayPositions(); + prefetchReplayPositions(); + break; + case REPLAY_PHASE_TELEMETRY: + if (replayTelemetryOrder.empty() && replayTelemetryIndex == 0) + beginReplayTelemetry(); + prefetchReplayTelemetry(); + break; + case REPLAY_PHASE_ENVIRONMENT: + if (replayEnvironmentOrder.empty() && replayEnvironmentIndex == 0) + beginReplayEnvironment(); + prefetchReplayEnvironment(); + break; + case REPLAY_PHASE_STATUS: + if (replayStatusOrder.empty() && replayStatusIndex == 0) + beginReplayStatus(); + prefetchReplayStatus(); + break; + default: + break; + } + + { + concurrency::LockGuard guard(&nodeInfoMutex); + if (!replayQueue.empty()) { + out = replayQueue.front(); + replayQueue.pop_front(); + return true; + } + } + + // Queue empty AND no more entries to feed it — phase is exhausted. + advanceReplayPhase(); + } + return false; +} + +void PhoneAPI::advanceReplayPhase() +{ + switch (replayPhase) { + case REPLAY_PHASE_POSITIONS: + LOG_DEBUG("Replay drain: positions done (count=%u) millis=%u", (unsigned)replayPositionIndex, millis()); + replayPhase = REPLAY_PHASE_TELEMETRY; + break; + case REPLAY_PHASE_TELEMETRY: + LOG_DEBUG("Replay drain: telemetry done (count=%u) millis=%u", (unsigned)replayTelemetryIndex, millis()); + replayPhase = REPLAY_PHASE_ENVIRONMENT; + break; + case REPLAY_PHASE_ENVIRONMENT: + LOG_DEBUG("Replay drain: environment done (count=%u) millis=%u", (unsigned)replayEnvironmentIndex, millis()); + replayPhase = REPLAY_PHASE_STATUS; + break; + case REPLAY_PHASE_STATUS: + LOG_INFO("Replay drain complete (status count=%u) millis=%u", (unsigned)replayStatusIndex, millis()); + replayPositionOrder.clear(); + replayPositionOrder.shrink_to_fit(); + replayTelemetryOrder.clear(); + replayTelemetryOrder.shrink_to_fit(); + replayEnvironmentOrder.clear(); + replayEnvironmentOrder.shrink_to_fit(); + replayStatusOrder.clear(); + replayStatusOrder.shrink_to_fit(); + replayPositionIndex = 0; + replayTelemetryIndex = 0; + replayEnvironmentIndex = 0; + replayStatusIndex = 0; + replayPhase = REPLAY_PHASE_IDLE; + break; + default: + break; + } +} + void PhoneAPI::releaseMqttClientProxyPhonePacket() { if (mqttClientProxyMessageForPhone) { @@ -1116,31 +1105,6 @@ bool PhoneAPI::available() PREFETCH_NODEINFO: prefetchNodeInfos(); return true; - case STATE_REPLAY_POSITIONS: { - // Prime the iterator if we haven't yet, then top up the queue. - if (replayPositionOrder.empty() && replayPositionIndex == 0) - beginReplayPositions(); - prefetchReplayPositions(); - return true; // Always advance state machine; arm itself transitions when drained - } - case STATE_REPLAY_TELEMETRY: { - if (replayTelemetryOrder.empty() && replayTelemetryIndex == 0) - beginReplayTelemetry(); - prefetchReplayTelemetry(); - return true; - } - case STATE_REPLAY_ENVIRONMENT: { - if (replayEnvironmentOrder.empty() && replayEnvironmentIndex == 0) - beginReplayEnvironment(); - prefetchReplayEnvironment(); - return true; - } - case STATE_REPLAY_STATUS: { - if (replayStatusOrder.empty() && replayStatusIndex == 0) - beginReplayStatus(); - prefetchReplayStatus(); - return true; - } case STATE_SEND_PACKETS: { if (!queueStatusPacketForPhone) queueStatusPacketForPhone = service->getQueueStatusForPhone(); @@ -1172,7 +1136,11 @@ bool PhoneAPI::available() if (!packetForPhone) packetForPhone = service->getForPhone(); hasPacket = !!packetForPhone; - return hasPacket; + if (hasPacket) + return true; + // Trailing replay drain — feeds cached satellite-DB packets alongside + // (lower priority than) live traffic. + return replayPending(); } default: LOG_ERROR("PhoneAPI::available unexpected state %d", state); diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index 642fdd7e099..b1bd1fd23fc 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -26,9 +26,6 @@ #define SPECIAL_NONCE_ONLY_CONFIG 69420 #define SPECIAL_NONCE_ONLY_NODES 69421 // ( ͡° ͜ʖ ͡°) -// Gradient sync: phone sends one of these to opt into thin-header + replay. -#define SPECIAL_NONCE_GRADIENT_SYNC 69422 -#define SPECIAL_NONCE_GRADIENT_ONLY_NODES 69423 /** * Provides our protobuf based API which phone/PC clients can use to talk to our device @@ -52,15 +49,22 @@ class PhoneAPI STATE_SEND_CONFIG, // Replacement for the old Radioconfig STATE_SEND_MODULECONFIG, // Send Module specific config STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to to the client - // Drain satellite DBs as synthetic POSITION_APP / TELEMETRY_APP / - // NODE_STATUS_APP packets when the phone opted into gradient sync. - STATE_REPLAY_POSITIONS, - STATE_REPLAY_TELEMETRY, - STATE_REPLAY_ENVIRONMENT, - STATE_REPLAY_STATUS, - STATE_SEND_FILEMANIFEST, // Send file manifest + STATE_SEND_FILEMANIFEST, // Send file manifest STATE_SEND_COMPLETE_ID, - STATE_SEND_PACKETS // send packets or debug strings + STATE_SEND_PACKETS // live mesh packets + any cached satellite-DB replay that trails sync completion + }; + + // Satellite-DB replay (positions / telemetry / environment / status) used to live + // as four top-level states between STATE_SEND_OTHER_NODEINFOS and STATE_SEND_FILEMANIFEST. + // It now drains *after* config_complete_id has been emitted: the phone considers the + // initial sync done as soon as headers + manifest are delivered, and the cached + // position/telemetry/etc. trickle in alongside live mesh traffic inside STATE_SEND_PACKETS. + enum ReplayPhase : uint8_t { + REPLAY_PHASE_IDLE = 0, // not replaying (legacy clients, no-op DBs, or replay finished) + REPLAY_PHASE_POSITIONS, + REPLAY_PHASE_TELEMETRY, + REPLAY_PHASE_ENVIRONMENT, + REPLAY_PHASE_STATUS, }; State state = STATE_SEND_NOTHING; @@ -114,6 +118,7 @@ class PhoneAPI size_t replayTelemetryIndex = 0; size_t replayEnvironmentIndex = 0; size_t replayStatusIndex = 0; + ReplayPhase replayPhase = REPLAY_PHASE_IDLE; // armed by sendConfigComplete() for full/default sync meshtastic_ToRadio toRadioScratch = { 0}; // this is a static scratch object, any data must be copied elsewhere before returning @@ -164,10 +169,6 @@ class PhoneAPI bool isConnected() { return state != STATE_SEND_NOTHING; } bool isSendingPackets() { return state == STATE_SEND_PACKETS; } - bool clientWantsGradientSync() const - { - return config_nonce == SPECIAL_NONCE_GRADIENT_SYNC || config_nonce == SPECIAL_NONCE_GRADIENT_ONLY_NODES; - } protected: /// Our fromradio packet while it is being assembled @@ -229,6 +230,12 @@ class PhoneAPI meshtastic_MeshPacket makeReplayEnvironmentPacket(uint32_t num, const meshtastic_EnvironmentMetrics &env); meshtastic_MeshPacket makeReplayStatusPacket(uint32_t num, const meshtastic_StatusMessage &status); + // Post-sync replay drain: pop one cached packet from the active phase, advancing + // through positions -> telemetry -> environment -> status until everything is drained. + bool popReplayPacket(meshtastic_MeshPacket &out); + void advanceReplayPhase(); + bool replayPending() const { return replayPhase != REPLAY_PHASE_IDLE; } + void releaseMqttClientProxyPhonePacket(); void releaseClientNotification(); diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index 254af613212..cdcf0b328b1 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -37,6 +37,7 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo info.position.altitude = position->altitude; info.position.location_source = position->location_source; info.position.time = position->time; + info.position.precision_bits = position->precision_bits; } if (nodeInfoLiteHasUser(lite)) { info.has_user = true; @@ -71,6 +72,7 @@ meshtastic_PositionLite TypeConversions::ConvertToPositionLite(meshtastic_Positi lite.altitude = position.altitude; lite.location_source = position.location_source; lite.time = position.time; + lite.precision_bits = position.precision_bits; return lite; } @@ -89,6 +91,11 @@ meshtastic_Position TypeConversions::ConvertToPosition(meshtastic_PositionLite l position.altitude = lite.altitude; position.location_source = lite.location_source; 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 + // 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; return position; } diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 5e0b844f187..17bec9b3a32 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -33,6 +33,8 @@ typedef struct _meshtastic_PositionLite { uint32_t time; /* TODO: REPLACE */ meshtastic_Position_LocSource location_source; + /* Indicates the bits of precision set by the sending node */ + uint32_t precision_bits; } meshtastic_PositionLite; typedef PB_BYTES_ARRAY_T(32) meshtastic_UserLite_public_key_t; @@ -211,7 +213,7 @@ extern "C" { #endif /* Initializer values for message structs */ -#define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} +#define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0} #define meshtastic_UserLite_init_default {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} #define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} #define meshtastic_DeviceState_init_default {false, meshtastic_MyNodeInfo_init_default, false, meshtastic_User_init_default, 0, {meshtastic_MeshPacket_init_default}, false, meshtastic_MeshPacket_init_default, 0, 0, 0, false, meshtastic_MeshPacket_init_default, 0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} @@ -222,7 +224,7 @@ extern "C" { #define meshtastic_NodeDatabase_init_default {0, {0}, {0}, {0}, {0}, {0}} #define meshtastic_ChannelFile_init_default {0, {meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default}, 0} #define meshtastic_BackupPreferences_init_default {0, 0, false, meshtastic_LocalConfig_init_default, false, meshtastic_LocalModuleConfig_init_default, false, meshtastic_ChannelFile_init_default, false, meshtastic_User_init_default} -#define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} +#define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0} #define meshtastic_UserLite_init_zero {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} #define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} #define meshtastic_DeviceState_init_zero {false, meshtastic_MyNodeInfo_init_zero, false, meshtastic_User_init_zero, 0, {meshtastic_MeshPacket_init_zero}, false, meshtastic_MeshPacket_init_zero, 0, 0, 0, false, meshtastic_MeshPacket_init_zero, 0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} @@ -240,6 +242,7 @@ extern "C" { #define meshtastic_PositionLite_altitude_tag 3 #define meshtastic_PositionLite_time_tag 4 #define meshtastic_PositionLite_location_source_tag 5 +#define meshtastic_PositionLite_precision_bits_tag 6 #define meshtastic_UserLite_macaddr_tag 1 #define meshtastic_UserLite_long_name_tag 2 #define meshtastic_UserLite_short_name_tag 3 @@ -298,7 +301,8 @@ X(a, STATIC, SINGULAR, SFIXED32, latitude_i, 1) \ X(a, STATIC, SINGULAR, SFIXED32, longitude_i, 2) \ X(a, STATIC, SINGULAR, INT32, altitude, 3) \ X(a, STATIC, SINGULAR, FIXED32, time, 4) \ -X(a, STATIC, SINGULAR, UENUM, location_source, 5) +X(a, STATIC, SINGULAR, UENUM, location_source, 5) \ +X(a, STATIC, SINGULAR, UINT32, precision_bits, 6) #define meshtastic_PositionLite_CALLBACK NULL #define meshtastic_PositionLite_DEFAULT NULL @@ -447,10 +451,10 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_DeviceState_size 1737 #define meshtastic_NodeEnvironmentEntry_size 170 #define meshtastic_NodeInfoLite_size 105 -#define meshtastic_NodePositionEntry_size 36 +#define meshtastic_NodePositionEntry_size 42 #define meshtastic_NodeStatusEntry_size 89 #define meshtastic_NodeTelemetryEntry_size 35 -#define meshtastic_PositionLite_size 28 +#define meshtastic_PositionLite_size 34 #define meshtastic_UserLite_size 98 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/deviceonly_legacy.pb.h b/src/mesh/generated/meshtastic/deviceonly_legacy.pb.h index 916951419fa..6beee080923 100644 --- a/src/mesh/generated/meshtastic/deviceonly_legacy.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly_legacy.pb.h @@ -115,7 +115,7 @@ extern const pb_msgdesc_t meshtastic_NodeDatabase_Legacy_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_Legacy_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_LEGACY_PB_H_MAX_SIZE meshtastic_NodeInfoLite_Legacy_size -#define meshtastic_NodeInfoLite_Legacy_size 196 +#define meshtastic_NodeInfoLite_Legacy_size 202 #ifdef __cplusplus } /* extern "C" */ From cd5d608e8dd9122bf5336ee07e13e38aa1825d23 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 06:07:09 -0500 Subject: [PATCH 171/225] Upgrade trunk (#10461) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 00649f7ff78..ad264bd76e3 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -11,7 +11,7 @@ lint: - checkov@3.2.528 - renovate@43.150.0 - prettier@3.8.3 - - trufflehog@3.95.2 + - trufflehog@3.95.3 - yamllint@1.38.0 - bandit@1.9.4 - trivy@0.70.0 From 7ff6641f97d060ed548d5dc8b0f6e6f74207207a Mon Sep 17 00:00:00 2001 From: Dirk Mueller Date: Tue, 12 May 2026 13:26:13 +0200 Subject: [PATCH 172/225] Fix missing potential null termination in xmodem filename handling (#10308) * Fix missing potential null termination in xmodem filename handling The packet size max is 128 bytes, and the filename is 128 bytes, so potentially there is no NUL at the end. use strlcpy() as that takes care of null termination even if buffer size is exceeded. * Protect against theoretical buffer overflows in BLE logging --------- Co-authored-by: Ben Meadors --- src/RedirectablePrint.cpp | 4 ++-- src/xmodem.cpp | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 9450f899012..2d6cc13ec40 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -230,9 +230,9 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_ auto thread = concurrency::OSThread::currentThread; meshtastic_LogRecord logRecord = meshtastic_LogRecord_init_zero; logRecord.level = getLogLevel(logLevel); - vsprintf(logRecord.message, format, arg); + vsnprintf(logRecord.message, sizeof(logRecord.message), format, arg); if (thread) - strcpy(logRecord.source, thread->ThreadName.c_str()); + strlcpy(logRecord.source, thread->ThreadName.c_str(), sizeof(logRecord.source)); logRecord.time = getValidTime(RTCQuality::RTCQualityDevice, true); auto buffer = std::unique_ptr(new uint8_t[meshtastic_LogRecord_size]); diff --git a/src/xmodem.cpp b/src/xmodem.cpp index 1d8c777600c..5967329756f 100644 --- a/src/xmodem.cpp +++ b/src/xmodem.cpp @@ -119,7 +119,8 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket) case meshtastic_XModem_Control_STX: if ((xmodemPacket.seq == 0) && !isReceiving && !isTransmitting) { // NULL packet has the destination filename - memcpy(filename, &xmodemPacket.buffer.bytes, xmodemPacket.buffer.size); + strncpy(filename, (const char *)xmodemPacket.buffer.bytes, sizeof(filename) - 1); + filename[sizeof(filename) - 1] = '\0'; if (xmodemPacket.control == meshtastic_XModem_Control_SOH) { // Receive this file and put to Flash spiLock->lock(); From d9cb74e4dd3fd2b638ccb4e09d5705e638f4829e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 12 May 2026 15:38:04 -0500 Subject: [PATCH 173/225] XModemAdapter: ensure file truncation before receiving and add isBusy() method to prevent concurrent writes --- src/xmodem.cpp | 15 ++++++++++++++- src/xmodem.h | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/xmodem.cpp b/src/xmodem.cpp index 5967329756f..b885e47e5af 100644 --- a/src/xmodem.cpp +++ b/src/xmodem.cpp @@ -123,15 +123,24 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket) filename[sizeof(filename) - 1] = '\0'; if (xmodemPacket.control == meshtastic_XModem_Control_SOH) { // Receive this file and put to Flash + // Truncate the destination before opening. On Adafruit_LittleFS, + // `open(path, FILE_O_WRITE)` is *append* semantics (O_RDWR|O_CREAT + + // seek-to-EOF), so without a prior remove, our XModem-streamed + // payload would be concatenated onto whatever was there, producing + // a corrupt mixed file. spiLock->lock(); + if (FSCom.exists(filename)) + FSCom.remove(filename); file = FSCom.open(filename, FILE_O_WRITE); spiLock->unlock(); if (file) { + LOG_INFO("XModem: receiving %s", filename); sendControl(meshtastic_XModem_Control_ACK); isReceiving = true; packetno = 1; break; } + LOG_WARN("XModem: open(%s, WRITE) failed", filename); sendControl(meshtastic_XModem_Control_NAK); isReceiving = false; break; @@ -169,8 +178,12 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket) check(xmodemPacket.buffer.bytes, xmodemPacket.buffer.size, xmodemPacket.crc16)) { // valid packet spiLock->lock(); - file.write(xmodemPacket.buffer.bytes, xmodemPacket.buffer.size); + size_t written = file.write(xmodemPacket.buffer.bytes, xmodemPacket.buffer.size); spiLock->unlock(); + if (written != xmodemPacket.buffer.size) { + LOG_WARN("XModem: short write seq=%d expected=%d wrote=%d (LittleFS partition full?)", + (int)xmodemPacket.seq, (int)xmodemPacket.buffer.size, (int)written); + } sendControl(meshtastic_XModem_Control_ACK); packetno++; break; diff --git a/src/xmodem.h b/src/xmodem.h index 7b665e0acdb..3a1183df157 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -52,6 +52,12 @@ class XModemAdapter meshtastic_XModem getForPhone(); void resetForPhone(); + // True while a file is being received from or transmitted to the phone. + // Callers (e.g. NodeDB::saveNodeDatabaseToDisk) consult this to avoid + // opening the same on-disk file for write in parallel, which races our + // long-lived xmodem `file` handle and ends up producing a 0-byte file. + bool isBusy() const { return isReceiving || isTransmitting; } + private: bool isReceiving = false; bool isTransmitting = false; From f3ae02c425835602c9b01fdfb2c70c514a2edfb7 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 12 May 2026 16:32:00 -0500 Subject: [PATCH 174/225] Cleanup comments --- src/xmodem.cpp | 6 +----- src/xmodem.h | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/xmodem.cpp b/src/xmodem.cpp index b885e47e5af..735d933fc8e 100644 --- a/src/xmodem.cpp +++ b/src/xmodem.cpp @@ -123,11 +123,7 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket) filename[sizeof(filename) - 1] = '\0'; if (xmodemPacket.control == meshtastic_XModem_Control_SOH) { // Receive this file and put to Flash - // Truncate the destination before opening. On Adafruit_LittleFS, - // `open(path, FILE_O_WRITE)` is *append* semantics (O_RDWR|O_CREAT + - // seek-to-EOF), so without a prior remove, our XModem-streamed - // payload would be concatenated onto whatever was there, producing - // a corrupt mixed file. + // FILE_O_WRITE on Adafruit_LittleFS is append, not truncate — remove first. spiLock->lock(); if (FSCom.exists(filename)) FSCom.remove(filename); diff --git a/src/xmodem.h b/src/xmodem.h index 3a1183df157..97418782032 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -52,10 +52,7 @@ class XModemAdapter meshtastic_XModem getForPhone(); void resetForPhone(); - // True while a file is being received from or transmitted to the phone. - // Callers (e.g. NodeDB::saveNodeDatabaseToDisk) consult this to avoid - // opening the same on-disk file for write in parallel, which races our - // long-lived xmodem `file` handle and ends up producing a 0-byte file. + // True while a file transfer is in flight; lets callers avoid racing our `file` handle. bool isBusy() const { return isReceiving || isTransmitting; } private: From eead467ce6340175e622b61d14df6f746c701d10 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 12 May 2026 17:23:29 -0500 Subject: [PATCH 175/225] Added NodeDB fixtures and refactored to use std maps for better memory efficiency (#10464) * Added NodeDB fixtures and refactored to use std maps for better efficiency * Defer NodeDB save during xmodem transfer to prevent mid-transfer fsFormat --- .gitignore | 6 + .trunk/trunk.yaml | 7 + bin/_rewrite_proto_namespace.py | 64 + bin/gen-fake-nodedb-seed.py | 439 ++++ bin/regen-fake-nodedbs.sh | 73 + bin/regen-py-protos.sh | 51 + bin/seed-json-to-proto.py | 342 +++ mcp-server/src/meshtastic_mcp/fixtures.py | 382 ++++ mcp-server/src/meshtastic_mcp/server.py | 43 + .../tests/unit/test_fake_nodedb_generator.py | 364 +++ src/mesh/NodeDB.cpp | 203 +- src/mesh/NodeDB.h | 19 +- src/mesh/mesh-pb-constants.h | 2 +- test/fixtures/nodedb/README.md | 153 ++ test/fixtures/nodedb/seed_v25_0250.jsonl | 251 +++ test/fixtures/nodedb/seed_v25_0500.jsonl | 501 +++++ test/fixtures/nodedb/seed_v25_1000.jsonl | 1001 +++++++++ test/fixtures/nodedb/seed_v25_2000.jsonl | 2001 +++++++++++++++++ 18 files changed, 5839 insertions(+), 63 deletions(-) create mode 100755 bin/_rewrite_proto_namespace.py create mode 100755 bin/gen-fake-nodedb-seed.py create mode 100755 bin/regen-fake-nodedbs.sh create mode 100755 bin/regen-py-protos.sh create mode 100755 bin/seed-json-to-proto.py create mode 100644 mcp-server/src/meshtastic_mcp/fixtures.py create mode 100644 mcp-server/tests/unit/test_fake_nodedb_generator.py create mode 100644 test/fixtures/nodedb/README.md create mode 100644 test/fixtures/nodedb/seed_v25_0250.jsonl create mode 100644 test/fixtures/nodedb/seed_v25_0500.jsonl create mode 100644 test/fixtures/nodedb/seed_v25_1000.jsonl create mode 100644 test/fixtures/nodedb/seed_v25_2000.jsonl diff --git a/.gitignore b/.gitignore index f1eb9d852d7..eebd94ef9c2 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,9 @@ CMakeLists.txt .python3 .claude/scheduled_tasks.lock userPrefs.jsonc.mcp-session-bak + +# Fake-NodeDB fixture pipeline (bin/regen-fake-nodedbs.sh) +# JSONL seeds are committed (test/fixtures/nodedb/seed_v25_*.jsonl); +# compiled .proto outputs are ephemeral build artifacts. +build/fixtures/ +bin/_generated/ diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 00649f7ff78..7af7a52d108 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -34,6 +34,13 @@ lint: - linters: [ALL] paths: - bin/** + # Fake-NodeDB fixture JSONL files contain deterministic synthetic + # public_key_hex (64-char hex) values that gitleaks misidentifies as + # generic-api-key. These are not secrets — they're test fixtures + # produced by bin/gen-fake-nodedb-seed.py with a fixed RNG seed. + - linters: [gitleaks] + paths: + - test/fixtures/nodedb/seed_v25_*.jsonl runtimes: enabled: - python@3.14.4 diff --git a/bin/_rewrite_proto_namespace.py b/bin/_rewrite_proto_namespace.py new file mode 100755 index 00000000000..53c0115957f --- /dev/null +++ b/bin/_rewrite_proto_namespace.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Post-process protoc-generated Python files to live under a local namespace. + +Called by bin/regen-py-protos.sh. Walks the generated *_pb2.py files in the +target directory and rewrites every `meshtastic` reference (imports, dotted +attribute access) to use the new namespace (e.g., `meshtastic_v25`). + +Why: the .proto files declare `package meshtastic;`, so protoc emits +`from meshtastic import mesh_pb2 as ...` lines. That would shadow the PyPI +`meshtastic` package which other parts of the mcp-server depend on. Renaming +to a local namespace keeps both available. + +Usage: + _rewrite_proto_namespace.py +""" + +from __future__ import annotations + +import pathlib +import re +import sys + + +def rewrite(dir_path: pathlib.Path, new_ns: str) -> int: + # Standard protoc import forms: + # from meshtastic.X_pb2 import ... (rare, for direct symbol pulls) + # from meshtastic import X_pb2 as ... (common, the cross-file ref) + # import meshtastic.X_pb2 (also possible) + pattern_dotted_from = re.compile(r"^from meshtastic\.", re.MULTILINE) + pattern_bare_from = re.compile(r"^from meshtastic import ", re.MULTILINE) + pattern_dotted_import = re.compile(r"^import meshtastic\.", re.MULTILINE) + + count = 0 + for p in dir_path.glob("*.py"): + text = p.read_text(encoding="utf-8") + new = pattern_dotted_from.sub(f"from {new_ns}.", text) + new = pattern_bare_from.sub(f"from {new_ns} import ", new) + new = pattern_dotted_import.sub(f"import {new_ns}.", new) + # NOTE: we deliberately leave `meshtastic/X.proto` source-filename + # references inside descriptor strings alone. The descriptor pool is + # keyed by source filename (independent of Python package layout), so + # those don't collide with the PyPI package's descriptors. + if new != text: + p.write_text(new, encoding="utf-8") + count += 1 + return count + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print("usage: _rewrite_proto_namespace.py ", file=sys.stderr) + return 2 + dir_path = pathlib.Path(argv[0]) + new_ns = argv[1] + if not dir_path.is_dir(): + print(f"directory not found: {dir_path}", file=sys.stderr) + return 2 + n = rewrite(dir_path, new_ns) + print(f"rewrote {n} file(s) in {dir_path} → namespace {new_ns}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/bin/gen-fake-nodedb-seed.py b/bin/gen-fake-nodedb-seed.py new file mode 100755 index 00000000000..d8cf3f4b96e --- /dev/null +++ b/bin/gen-fake-nodedb-seed.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +"""Deterministic seed-data generator for the fake NodeDB fixture pipeline. + +Writes a JSONL file describing N fake-but-realistic Meshtastic peers. +The output is hand-editable and committed; a sibling compile step +(bin/seed-json-to-proto.py) turns it into a binary `meshtastic_NodeDatabase` +v25 protobuf with fresh "now-relative" timestamps. + +Determinism contract: + Same --seed -> byte-identical JSONL output, regardless of wall clock. + All timestamps are stored as `*_offset_sec` (seconds before "now"); the + compile step resolves them to absolute epochs at compile time. + +Structural fields covered: + * NodeInfoLite header: num, long_name, short_name, hw_model, role, + public_key, snr, channel, hops_away, next_hop, bitfield flags + * PositionLite: lat/long Gaussian around --centroid, altitude, source + * DeviceMetrics: battery/voltage/util/uptime + * EnvironmentMetrics: temp/humidity/pressure/iaq + * StatusMessage: error_code (usually zero) + +Active-board allow-list: + hw_model values are restricted to the intersection of + (a) variants with `custom_meshtastic_support_level = 1` in + variants/*/*/platformio.ini, AND + (b) values present in the `HardwareModel` enum in mesh.proto. + See HW_MODEL_WEIGHTS below. Deprecated boards (legacy TLORA / Heltec V1-2 / + classic TBEAM / TBEAM_V0P7 / Nano G1 / etc.) and fuzzer-only sentinels + (PORTDUINO, ANDROID_SIM, DIY_V1, ...) are excluded. + +Active-role allow-list: + Excludes ROUTER_CLIENT (deprecated v2.3.15) and REPEATER (deprecated v2.7.11). +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import math +import pathlib +import random +import sys + +# -------------------------------------------------------------------------- +# Active-board allow-list (intersection of tier-1 variants + HardwareModel enum). +# Refresh by running: +# for f in $(find variants -name 'platformio.ini' | xargs grep -lE 'custom_meshtastic_support_level = 1'); do +# grep custom_meshtastic_hw_model_slug $f | awk -F= '{print $2}' | tr -d ' '; +# done | sort -u | comm -12 - <(python3 -c "from meshtastic.protobuf.mesh_pb2 import HardwareModel; print('\\n'.join(HardwareModel.keys()))" | sort) +# -------------------------------------------------------------------------- +HW_MODEL_WEIGHTS: dict[str, float] = { + "HELTEC_V3": 14.0, + "T_DECK": 9.0, + "HELTEC_V4": 8.0, + "RAK4631": 8.0, + "HELTEC_MESH_POCKET": 6.0, + "TRACKER_T1000_E": 5.0, + "HELTEC_MESH_NODE_T114": 5.0, + "T_DECK_PRO": 5.0, + "LILYGO_TBEAM_S3_CORE": 4.0, + "HELTEC_WIRELESS_PAPER": 4.0, + "HELTEC_WSL_V3": 3.0, + "T_ECHO": 3.0, + "HELTEC_WIRELESS_TRACKER": 3.0, + "HELTEC_WIRELESS_TRACKER_V2": 2.0, + "HELTEC_VISION_MASTER_E290": 2.0, + "HELTEC_MESH_SOLAR": 2.0, + "SEEED_WIO_TRACKER_L1": 2.0, + "T_LORA_PAGER": 1.5, + "HELTEC_VISION_MASTER_E213": 1.5, + "T_ECHO_PLUS": 1.0, + "MUZI_BASE": 1.0, + "WISMESH_TAP_V2": 1.0, + "THINKNODE_M2": 1.0, + "THINKNODE_M5": 1.0, + "TLORA_T3_S3": 1.0, + # Long tail (uniform low weight across remaining tier-1 boards): + "HELTEC_V4_R8": 0.3, + "HELTEC_VISION_MASTER_T190": 0.3, + "HELTEC_HT62": 0.3, + "HELTEC_MESH_NODE_T096": 0.3, + "M5STACK_C6L": 0.3, + "MINI_EPAPER_S3": 0.3, + "MUZI_R1_NEO": 0.3, + "NOMADSTAR_METEOR_PRO": 0.3, + "RAK3312": 0.3, + "RAK3401": 0.3, + "SEEED_SOLAR_NODE": 0.3, + "SEEED_WIO_TRACKER_L1_EINK": 0.3, + "SENSECAP_INDICATOR": 0.3, + "TBEAM_1_WATT": 0.3, + "THINKNODE_M1": 0.3, + "THINKNODE_M3": 0.3, + "THINKNODE_M6": 0.3, + "T_ECHO_LITE": 0.3, + "WISMESH_TAG": 0.3, + "WISMESH_TAP": 0.3, + "XIAO_NRF52_KIT": 0.3, + "CROWPANEL": 0.3, +} + +# Non-deprecated roles only. +ROLE_WEIGHTS: dict[str, float] = { + "CLIENT": 75.0, + "CLIENT_MUTE": 5.0, + "ROUTER": 7.0, + "TRACKER": 3.0, + "SENSOR": 2.0, + "CLIENT_HIDDEN": 2.0, + "ROUTER_LATE": 2.0, + "CLIENT_BASE": 2.0, + "TAK": 1.0, + "TAK_TRACKER": 0.5, + "LOST_AND_FOUND": 0.5, +} + +# Name pools — 60 firsts × 60 lasts = 3600 combinations. +FIRSTS = [ + "Quick", "Brave", "Silent", "Wild", "Lone", "Bright", "Red", "Blue", + "Green", "Black", "White", "Iron", "Steel", "Copper", "Silver", "Gold", + "Stone", "River", "Forest", "Mountain", "Canyon", "Desert", "Storm", "Sky", + "Solar", "Lunar", "Dawn", "Dusk", "Misty", "Frosty", "Sunny", "Shady", + "Happy", "Sleepy", "Drowsy", "Sneaky", "Sharp", "Smooth", "Rough", "Loud", + "Soft", "Slow", "Fast", "Tall", "Short", "Old", "New", "Tiny", + "Giant", "Hidden", "Lost", "Found", "Wandering", "Roving", "Drifting", "Floating", + "Burning", "Frozen", "Whispering", "Howling", +] +LASTS = [ + "Phoenix", "Lion", "Bear", "Wolf", "Hawk", "Eagle", "Fox", "Lynx", + "Cougar", "Coyote", "Raven", "Owl", "Crow", "Falcon", "Heron", "Crane", + "Otter", "Badger", "Bison", "Elk", "Moose", "Stag", "Doe", "Hare", + "Marmot", "Mole", "Beaver", "Squirrel", "Mustang", "Bronco", "Pony", "Colt", + "Cobra", "Viper", "Mamba", "Adder", "Gecko", "Iguana", "Tortoise", "Turtle", + "Salmon", "Trout", "Bass", "Pike", "Shark", "Whale", "Dolphin", "Seal", + "Cactus", "Yucca", "Sage", "Juniper", "Pine", "Cedar", "Aspen", "Oak", + "Bluff", "Mesa", "Arroyo", "Ridge", +] + +# Brief callsign pool for licensed-looking suffixes. +CALLSIGN_PREFIXES = ["KX", "WD", "N5", "KE", "AB", "W5", "K1", "KQ", "AE", "NM"] + +# Only emojis that fit in 4 UTF-8 bytes (no variation selectors). short_name's +# nanopb max_size:5 (incl. NUL) limits content to 4 bytes. ❄️ / ☀️ would be +# 6 bytes due to U+FE0F variation selector — explicitly excluded. +EMOJI_SHORTNAMES = ["🦊", "🐺", "🦅", "🐢", "🌵", "🔥", "🌙", + "🌊", "🗻", "🌲", "🦌", "🐝", "🦂", "🦉", + "🦇", "🦋"] + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + +NUM_RESERVED = 4 # firmware reserves 0..3 (per NodeDB constants) +NUM_MAX_EXCLUSIVE = 0x80000000 # restrict to positive int32 range for readability + + +def _weighted_choice(rng: random.Random, weights: dict[str, float]) -> str: + """Deterministic weighted pick. Uses sorted keys so dict order is fixed.""" + keys = sorted(weights.keys()) + totals = [weights[k] for k in keys] + return rng.choices(keys, weights=totals, k=1)[0] + + +def _gen_long_name(rng: random.Random, is_licensed: bool) -> str: + base = f"{rng.choice(FIRSTS)} {rng.choice(LASTS)}" + if is_licensed: + prefix = rng.choice(CALLSIGN_PREFIXES) + # Two trailing alpha chars after the digit; keep within 25 - len(base) - 1 + suffix = f" {prefix}{rng.randint(0,9)}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}" + # nanopb max_size:25 means C string fits 24 bytes + NUL. + if len(base) + len(suffix) <= 24: + base = base + suffix + # Hard cap to 24 chars (nanopb max_size:25 minus NUL). + return base[:24] + + +def _gen_short_name(rng: random.Random, long_name: str) -> str: + # 10% emoji-only short_name + if rng.random() < 0.10: + return rng.choice(EMOJI_SHORTNAMES) + first_char = long_name[0].upper() if long_name else "X" + alphanums = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return first_char + "".join(rng.choices(alphanums, k=3)) + + +def _gen_hops_away(rng: random.Random) -> int: + # Geometric-ish: 0→55%, 1→25%, 2→12%, 3→5%, 4→2%, 5+→1% + r = rng.random() + if r < 0.55: + return 0 + if r < 0.80: + return 1 + if r < 0.92: + return 2 + if r < 0.97: + return 3 + if r < 0.99: + return 4 + return rng.randint(5, 7) + + +def _gen_position( + rng: random.Random, + centroid_lat: float, + centroid_lon: float, + spread_km: float, + last_heard_offset_sec: int, +) -> dict: + # 1 deg ≈ 111 km at the equator; we use this as a flat approximation. + lat = centroid_lat + rng.gauss(0.0, spread_km / 111.0) + lon = centroid_lon + rng.gauss(0.0, spread_km / 111.0) + altitude = max(0, round(rng.gauss(1376.0, 250.0))) # T or C valley floor + relief + # Position was reported up to 300s before last_heard. + time_offset_sec = last_heard_offset_sec + rng.randint(0, 300) + return { + "latitude": round(lat, 6), + "longitude": round(lon, 6), + "altitude": altitude, + "time_offset_sec": time_offset_sec, + "location_source": "LOC_INTERNAL", + } + + +def _gen_telemetry(rng: random.Random) -> dict: + # 5% plugged-in (battery_level == 101); rest uniform [10..100]. + if rng.random() < 0.05: + battery_level = 101 + voltage = 4.20 + else: + battery_level = rng.randint(10, 100) + voltage = round(3.3 + (battery_level / 100.0) * 0.9, 3) + # Beta distributions for low/right-skewed metrics; randomly draw via gammavariate. + def _beta(a: float, b: float) -> float: + x = rng.gammavariate(a, 1.0) + y = rng.gammavariate(b, 1.0) + return x / (x + y) + channel_utilization = round(_beta(2.0, 15.0) * 100.0, 2) + air_util_tx = round(_beta(1.5, 20.0) * 10.0, 3) + uptime_seconds = int(rng.expovariate(1.0 / 86400.0)) + return { + "battery_level": battery_level, + "voltage": voltage, + "channel_utilization": channel_utilization, + "air_util_tx": air_util_tx, + "uptime_seconds": uptime_seconds, + } + + +def _gen_environment(rng: random.Random) -> dict: + return { + "temperature": round(rng.gauss(22.0, 8.0), 2), + "relative_humidity": round(min(100.0, max(0.0, rng.gauss(55.0, 20.0))), 2), + "barometric_pressure": round(rng.gauss(1013.0, 8.0), 2), + "iaq": int(min(500, max(0, round(rng.gauss(50.0, 30.0))))), + } + + +def _gen_status(rng: random.Random) -> dict: + # `StatusMessage` (mesh.proto:1445) has a single free-form `string status`. + # Most peers report a healthy short status; occasional alert string. + healthy = ["OK", "online", "active", "running", "ready", "nominal"] + alert = ["low-batt", "no-gps", "weak-signal", "rebooted", "offline-soon"] + if rng.random() < 0.92: + return {"status": rng.choice(healthy)} + return {"status": rng.choice(alert)} + + +def _gen_node( + rng: random.Random, + num: int, + centroid_lat: float, + centroid_lon: float, + spread_km: float, + coverage: dict[str, float], + last_heard_mean_sec: int, + last_heard_max_sec: int, +) -> dict: + is_licensed = rng.random() < 0.05 + long_name = _gen_long_name(rng, is_licensed) + short_name = _gen_short_name(rng, long_name) + hw_model = _weighted_choice(rng, HW_MODEL_WEIGHTS) + role = _weighted_choice(rng, ROLE_WEIGHTS) + has_public_key = rng.random() < 0.92 + public_key_hex = ( + "".join(f"{rng.randint(0,255):02x}" for _ in range(32)) if has_public_key else "" + ) + snr = round(max(-20.0, min(12.0, rng.gauss(6.0, 4.0))), 2) + channel = 0 if rng.random() < 0.90 else rng.randint(1, 7) + hops_away = _gen_hops_away(rng) + next_hop = rng.randint(0, 255) if hops_away > 0 else 0 + last_heard_offset_sec = int(min(rng.expovariate(1.0 / last_heard_mean_sec), last_heard_max_sec)) + + bitfield = { + "has_user": True, + "is_favorite": rng.random() < 0.08, + "is_muted": rng.random() < 0.03, + "via_mqtt": rng.random() < 0.12, + "is_ignored": rng.random() < 0.01, + "is_licensed": is_licensed, + "has_is_unmessagable": True, + "is_unmessagable": rng.random() < 0.02, + "is_key_manually_verified": rng.random() < 0.04, + } + + node: dict = { + "num": f"0x{num:08x}", + "long_name": long_name, + "short_name": short_name, + "hw_model": hw_model, + "role": role, + "public_key_hex": public_key_hex, + "snr": snr, + "channel": channel, + "hops_away": hops_away, + "next_hop": next_hop, + "last_heard_offset_sec": last_heard_offset_sec, + "bitfield": bitfield, + "position": ( + _gen_position(rng, centroid_lat, centroid_lon, spread_km, last_heard_offset_sec) + if rng.random() < coverage["position"] + else None + ), + "telemetry": _gen_telemetry(rng) if rng.random() < coverage["telemetry"] else None, + "environment": _gen_environment(rng) if rng.random() < coverage["environment"] else None, + "status": _gen_status(rng) if rng.random() < coverage["status"] else None, + } + return node + + +def _parse_my_node_num(s: str | None) -> int | None: + if s is None: + return None + s = s.strip() + if s.startswith("0x") or s.startswith("0X"): + return int(s, 16) + return int(s) + + +def main(argv: list[str]) -> int: + p = argparse.ArgumentParser( + description="Deterministic JSONL seed for the fake NodeDB fixture.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + p.add_argument("--count", type=int, required=True, help="Number of fake nodes to emit.") + p.add_argument("--seed", type=int, required=True, help="Deterministic seed.") + p.add_argument("--out", required=True, help="Output JSONL path.") + p.add_argument( + "--centroid", + default="33.1284,-107.2528", + help="LAT,LON centroid (default: Truth or Consequences, NM).", + ) + p.add_argument("--spread-km", type=float, default=60.0, help="Gaussian std-dev in km.") + p.add_argument("--position-coverage", type=float, default=0.85) + p.add_argument("--telemetry-coverage", type=float, default=0.70) + p.add_argument("--environment-coverage", type=float, default=0.25) + p.add_argument("--status-coverage", type=float, default=0.40) + p.add_argument("--my-node-num", default=None, help="Exclude this NodeNum from generated set (hex or dec).") + p.add_argument("--last-heard-mean-sec", type=int, default=3600) + p.add_argument("--last-heard-max-sec", type=int, default=7 * 86400) + args = p.parse_args(argv) + + if args.count <= 0: + print("--count must be positive", file=sys.stderr) + return 2 + + try: + centroid_lat, centroid_lon = (float(s) for s in args.centroid.split(",")) + except ValueError: + print(f"--centroid must be LAT,LON; got {args.centroid!r}", file=sys.stderr) + return 2 + + my_node_num = _parse_my_node_num(args.my_node_num) + + rng = random.Random(args.seed) + + # 1) Generate a unique deterministic set of NodeNums. + nums: set[int] = set() + while len(nums) < args.count: + n = rng.randrange(NUM_RESERVED, NUM_MAX_EXCLUSIVE) + if my_node_num is not None and n == my_node_num: + continue + nums.add(n) + ordered_nums = sorted(nums) # sort to fix output order independent of set hash + + # 2) Per-node generation (in num order, single RNG continues). + coverage = { + "position": args.position_coverage, + "telemetry": args.telemetry_coverage, + "environment": args.environment_coverage, + "status": args.status_coverage, + } + nodes = [ + _gen_node( + rng, + n, + centroid_lat, + centroid_lon, + args.spread_km, + coverage, + args.last_heard_mean_sec, + args.last_heard_max_sec, + ) + for n in ordered_nums + ] + + # 3) Write JSONL. + out_path = pathlib.Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + # `generated_at_iso` is informational; it does NOT affect determinism because + # we derive it from the seed, not from wall clock. (Same seed -> same string.) + generated_at = _dt.datetime.fromtimestamp(args.seed, tz=_dt.timezone.utc).isoformat().replace("+00:00", "Z") + meta = { + "_meta": { + "version": 25, + "seed": args.seed, + "count": args.count, + "centroid": [centroid_lat, centroid_lon], + "spread_km": args.spread_km, + "generated_at_iso": generated_at, + "my_node_num_excluded": (None if my_node_num is None else f"0x{my_node_num:08x}"), + "coverage": coverage, + "last_heard_mean_sec": args.last_heard_mean_sec, + "last_heard_max_sec": args.last_heard_max_sec, + } + } + with out_path.open("w", encoding="utf-8") as f: + # `ensure_ascii=False` so emoji short_names survive. `sort_keys=True` for + # determinism (insertion order varies by Python version otherwise). + f.write(json.dumps(meta, ensure_ascii=False, sort_keys=True) + "\n") + for node in nodes: + f.write(json.dumps(node, ensure_ascii=False, sort_keys=True) + "\n") + + print(f"wrote {args.count} nodes to {out_path} ({out_path.stat().st_size} bytes)", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/bin/regen-fake-nodedbs.sh b/bin/regen-fake-nodedbs.sh new file mode 100755 index 00000000000..fd92daa0247 --- /dev/null +++ b/bin/regen-fake-nodedbs.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Regenerate the fake-NodeDB fixtures: produces 250 / 500 / 1000 / 2000-node +# JSONL seed files + their compiled v25 protobufs. +# +# Layout: +# test/fixtures/nodedb/seed_v25_.jsonl — COMMITTED, hand-editable. +# build/fixtures/nodedb/nodes_v25_.proto — .gitignored, build artifact. +# Drop into /prefs/nodes.proto. +# +# Daily use: ./bin/regen-fake-nodedbs.sh +# - Recompiles protos from committed seeds (fresh wall-clock timestamps). +# Intentional seed bump: REGEN_SEEDS=yes ./bin/regen-fake-nodedbs.sh +# - Overwrites the committed JSONL files with freshly-seeded data. + +set -euo pipefail +cd "$(dirname "$0")/.." + +# 1) Make sure the Python protobuf bindings exist (in-tree generation; .gitignored). +if [[ ! -d bin/_generated/meshtastic ]]; then + echo "regenerating Python protobuf bindings (one-time)..." + ./bin/regen-py-protos.sh +fi + +# 2) Pick a Python interpreter that has the meshtastic deps installed. +# Prefer the mcp-server venv (most likely to be set up by the operator). +PY="python3" +for cand in mcp-server/.venv/bin/python3 .venv/bin/python3; do + if [[ -x "$cand" ]]; then + PY="$cand" + break + fi +done + +# 3) Pinned seeds per size — bump only when you intentionally want different +# structural data committed. Parallel arrays so the script works on +# macOS bash 3.2 (no `declare -A`). +SIZES=(250 500 1000 2000) +SEEDS=(20260511 20260512 20260513 20260514) + +REGEN_SEEDS="${REGEN_SEEDS:-no}" + +mkdir -p build/fixtures/nodedb test/fixtures/nodedb + +for i in 0 1 2 3; do + n="${SIZES[$i]}" + seed="${SEEDS[$i]}" + jsonl=$(printf "test/fixtures/nodedb/seed_v25_%04d.jsonl" "$n") + proto=$(printf "build/fixtures/nodedb/nodes_v25_%04d.proto" "$n") + + if [[ "$REGEN_SEEDS" == "yes" || ! -f "$jsonl" ]]; then + $PY bin/gen-fake-nodedb-seed.py \ + --count "$n" \ + --seed "$seed" \ + --out "$jsonl" \ + --centroid 33.1284,-107.2528 \ + --spread-km 60 \ + --position-coverage 0.85 \ + --telemetry-coverage 0.70 \ + --environment-coverage 0.25 \ + --status-coverage 0.40 + echo " seed: $jsonl ($(wc -c < "$jsonl") bytes)" + fi + + $PY bin/seed-json-to-proto.py --in "$jsonl" --out "$proto" + echo " proto: $proto ($(wc -c < "$proto") bytes)" +done + +echo "" +echo "Done. To load on Portduino native:" +echo " cp build/fixtures/nodedb/nodes_v25_1000.proto ~/.portduino/default/prefs/nodes.proto" +echo "" +echo "To push to a hardware device:" +echo " Use the mcp-server tool: push_fake_nodedb(size=1000, target=\"hardware\", port=\"/dev/cu.usbmodemXXXX\", confirm=True)" diff --git a/bin/regen-py-protos.sh b/bin/regen-py-protos.sh new file mode 100755 index 00000000000..5edad232513 --- /dev/null +++ b/bin/regen-py-protos.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Regenerate Python protobuf bindings from the in-tree `protobufs/` submodule +# into `bin/_generated/`. Called by bin/regen-fake-nodedbs.sh; also useful as +# a standalone refresh after any change to a .proto file. +# +# Output is .gitignored — bindings are a build artifact. +# +# Namespace rewrite: +# The .proto files declare `package meshtastic;`, which makes protoc emit +# imports like `from meshtastic import mesh_pb2`. That conflicts with the +# PyPI `meshtastic` package (which the mcp-server relies on for its +# SerialInterface/BLEInterface transport). We post-process the generated +# files to live under `meshtastic_v25` instead — both the directory layout +# and all internal imports — so they coexist cleanly with the PyPI package. + +set -euo pipefail +cd "$(dirname "$0")/.." + +if ! command -v protoc >/dev/null 2>&1; then + echo "ERROR: protoc not found in PATH." >&2 + echo " macOS: brew install protobuf" >&2 + echo " Ubuntu/Debian: apt install protobuf-compiler" >&2 + exit 1 +fi + +OUT=bin/_generated +LOCAL_NS=meshtastic_v25 + +rm -rf "$OUT" +mkdir -p "$OUT" + +# 1) Generate from the in-tree protos. nanopb.proto first so its descriptor +# is available for the [(nanopb).*] options on other messages. +protoc \ + --proto_path=protobufs \ + --python_out="$OUT" \ + protobufs/nanopb.proto \ + protobufs/meshtastic/*.proto + +# 2) Move the generated `meshtastic/` directory to `meshtastic_v25/`. +mv "$OUT/meshtastic" "$OUT/$LOCAL_NS" + +# 3) Rewrite internal imports: any reference to `meshtastic.X_pb2` or +# `from meshtastic import X_pb2` becomes `meshtastic_v25.*`. +python3 bin/_rewrite_proto_namespace.py "$OUT/$LOCAL_NS" "$LOCAL_NS" + +# 4) Make the package importable. +touch "$OUT/__init__.py" +touch "$OUT/$LOCAL_NS/__init__.py" + +echo "regenerated Python protobuf bindings -> $OUT/$LOCAL_NS/ (namespace: $LOCAL_NS)" >&2 diff --git a/bin/seed-json-to-proto.py b/bin/seed-json-to-proto.py new file mode 100755 index 00000000000..80eb34d63b5 --- /dev/null +++ b/bin/seed-json-to-proto.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Compile a committed seed JSONL into a binary meshtastic_NodeDatabase v25 proto. + +The input is produced by `bin/gen-fake-nodedb-seed.py`. Timestamps in the JSONL +are stored as `*_offset_sec` (seconds before "now"); this script resolves them +to absolute epochs using `--now-epoch` (default: current wall clock). + +Output is a raw `pb_encode`-compatible binary that can be dropped at +`/prefs/nodes.proto` on the device (Portduino prefs dir or hardware via +XModem) and loaded by `NodeDB::loadFromDisk` at boot. + +Wire format reference: + protobufs/meshtastic/deviceonly.proto (NodeDatabase, NodeInfoLite, sat entries) + src/mesh/NodeDB.h:467-484 (bitfield bit positions) + src/mesh/NodeDB.cpp:1523-1524 (pb_decode entry point) +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import sys +import time +from typing import Any + +# Prefer the in-tree generated Python protobuf bindings (bin/_generated/meshtastic_v25/) +# because the firmware branch's protos (v25 NodeDatabase satellite arrays, slim +# NodeInfoLite) are typically newer than what the PyPI `meshtastic` package +# ships. Run `bin/regen-py-protos.sh` to (re)generate. +# +# Namespace note: the local bindings live under `meshtastic_v25` (NOT `meshtastic`) +# to avoid shadowing the PyPI `meshtastic` package — bin/regen-py-protos.sh +# post-processes the protoc output to rename the package. +_HERE = pathlib.Path(__file__).resolve().parent +_LOCAL_PROTO_DIR = _HERE / "_generated" +if _LOCAL_PROTO_DIR.is_dir(): + sys.path.insert(0, str(_LOCAL_PROTO_DIR)) + +try: + from meshtastic_v25.deviceonly_pb2 import ( # type: ignore[import-not-found] + NodeDatabase, + NodeInfoLite, + NodePositionEntry, + NodeTelemetryEntry, + NodeEnvironmentEntry, + NodeStatusEntry, + PositionLite, + ) + from meshtastic_v25.mesh_pb2 import HardwareModel, Position, StatusMessage # type: ignore[import-not-found] + from meshtastic_v25.config_pb2 import Config # type: ignore[import-not-found] + from meshtastic_v25.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics # type: ignore[import-not-found] +except ImportError as local_err: + # Fall back to the PyPI package if in-tree bindings haven't been generated. + # Will fail the v25 assertion below if the PyPI package predates the + # satellite-DB schema, but at least gives a clear "run regen-py-protos.sh" + # error message instead of an opaque ImportError. + try: + from meshtastic.protobuf.deviceonly_pb2 import ( + NodeDatabase, + NodeInfoLite, + NodePositionEntry, + NodeTelemetryEntry, + NodeEnvironmentEntry, + NodeStatusEntry, + PositionLite, + ) + from meshtastic.protobuf.mesh_pb2 import HardwareModel, Position, StatusMessage + from meshtastic.protobuf.config_pb2 import Config + from meshtastic.protobuf.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics + except ImportError as pypi_err: + print( + "ERROR: could not import meshtastic protobuf bindings.\n" + " In-tree generation: run `bin/regen-py-protos.sh` (requires protoc).\n" + " PyPI fallback: `pip install meshtastic` (may lag firmware branch).\n" + f" local error (meshtastic_v25): {local_err}\n" + f" pypi error (meshtastic.protobuf): {pypi_err}", + file=sys.stderr, + ) + sys.exit(1) + +# Fail loudly if bindings predate v25 (no satellite arrays). +assert ( + hasattr(NodeDatabase, "DESCRIPTOR") + and "positions" in NodeDatabase.DESCRIPTOR.fields_by_name +), ( + "Loaded meshtastic bindings are older than v25 (NodeDatabase.positions missing). " + "Run `bin/regen-py-protos.sh` against the in-tree protobufs/ submodule." +) + +# --------------------------------------------------------------------------- +# Bitfield bit positions (mirror src/mesh/NodeDB.h:467-484). +# --------------------------------------------------------------------------- +BIT_IS_KEY_MANUALLY_VERIFIED = 0 +BIT_IS_MUTED = 1 +BIT_VIA_MQTT = 2 +BIT_IS_FAVORITE = 3 +BIT_IS_IGNORED = 4 +BIT_HAS_USER = 5 +BIT_IS_LICENSED = 6 +BIT_IS_UNMESSAGABLE = 7 +BIT_HAS_IS_UNMESSAGABLE = 8 + +BITFIELD_LAYOUT = ( + # JSON key bit position + ("is_key_manually_verified", BIT_IS_KEY_MANUALLY_VERIFIED), + ("is_muted", BIT_IS_MUTED), + ("via_mqtt", BIT_VIA_MQTT), + ("is_favorite", BIT_IS_FAVORITE), + ("is_ignored", BIT_IS_IGNORED), + ("has_user", BIT_HAS_USER), + ("is_licensed", BIT_IS_LICENSED), + ("is_unmessagable", BIT_IS_UNMESSAGABLE), + ("has_is_unmessagable", BIT_HAS_IS_UNMESSAGABLE), +) + + +def _pack_bitfield(bf: dict[str, bool]) -> int: + out = 0 + for key, shift in BITFIELD_LAYOUT: + if bf.get(key, False): + out |= (1 << shift) + return out + + +def _validate_node(node: dict[str, Any]) -> None: + """Friendly errors so hand-editors get clear feedback.""" + if "num" not in node or not isinstance(node["num"], str): + raise ValueError(f"node missing/invalid 'num' (must be hex string): {node!r}") + if "long_name" not in node: + raise ValueError(f"node {node['num']}: missing 'long_name'") + if len(node["long_name"]) > 24: + raise ValueError( + f"node {node['num']}: long_name {node['long_name']!r} is " + f"{len(node['long_name'])} chars; max 24 (nanopb max_size:25 minus NUL)" + ) + if "short_name" in node: + # short_name max_size:5 (incl. NUL) → 4 bytes of content. + # Char count is irrelevant — emojis with variation selectors (e.g. ❄️ = 6 B) + # would slip past a `len(str) > 4` check. Always measure bytes. + b = node["short_name"].encode("utf-8") + if len(b) > 4: + raise ValueError( + f"node {node['num']}: short_name {node['short_name']!r} is " + f"{len(b)} bytes UTF-8; max 4 (nanopb max_size:5 minus NUL)" + ) + pk = node.get("public_key_hex", "") + if pk and len(pk) != 64: + raise ValueError( + f"node {node['num']}: public_key_hex must be 64 hex chars or empty; " + f"got {len(pk)} chars" + ) + if pk: + try: + bytes.fromhex(pk) + except ValueError as e: + raise ValueError(f"node {node['num']}: public_key_hex is not valid hex: {e}") + + +def _resolve_time( + node: dict[str, Any], + field_absolute: str, + field_offset: str, + now_epoch: int, +) -> int: + """If `field_absolute` is set, use it; else compute `now_epoch - offset`.""" + if field_absolute in node and node[field_absolute] is not None: + return int(node[field_absolute]) + offset = node.get(field_offset, 0) + return max(0, int(now_epoch) - int(offset)) + + +def _build_node_info_lite(node: dict[str, Any], now_epoch: int) -> NodeInfoLite: + _validate_node(node) + info = NodeInfoLite() + info.num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"]) + info.long_name = node.get("long_name", "") + info.short_name = node.get("short_name", "") + # Enum lookups will raise ValueError on unknown names — that's exactly what we want. + info.hw_model = HardwareModel.Value(node.get("hw_model", "UNSET")) + info.role = Config.DeviceConfig.Role.Value(node.get("role", "CLIENT")) + pk_hex = node.get("public_key_hex", "") + if pk_hex: + info.public_key = bytes.fromhex(pk_hex) + info.snr = float(node.get("snr", 0.0)) + info.channel = int(node.get("channel", 0)) + if "hops_away" in node: + # `optional uint32 hops_away = 9;` — in Python protobuf, assigning the + # field implicitly sets HasField("hops_away") to True. No has_hops_away + # setter exists (unlike the C++ nanopb-generated header). + info.hops_away = int(node["hops_away"]) + info.next_hop = int(node.get("next_hop", 0)) + info.last_heard = _resolve_time(node, "last_heard", "last_heard_offset_sec", now_epoch) + info.bitfield = _pack_bitfield(node.get("bitfield", {})) + return info + + +def _build_position_entry(num: int, pos: dict[str, Any], now_epoch: int) -> NodePositionEntry: + entry = NodePositionEntry() + entry.num = num + pl = PositionLite() + # Firmware stores lat/long as int32 in 1e-7 degrees. + pl.latitude_i = int(round(float(pos["latitude"]) * 1e7)) + pl.longitude_i = int(round(float(pos["longitude"]) * 1e7)) + pl.altitude = int(pos.get("altitude", 0)) + pl.time = _resolve_time(pos, "time", "time_offset_sec", now_epoch) + pl.location_source = Position.LocSource.Value(pos.get("location_source", "LOC_UNSET")) + entry.position.CopyFrom(pl) + return entry + + +def _build_telemetry_entry(num: int, tel: dict[str, Any]) -> NodeTelemetryEntry: + entry = NodeTelemetryEntry() + entry.num = num + dm = DeviceMetrics() + if "battery_level" in tel: + dm.battery_level = int(tel["battery_level"]) + if "voltage" in tel: + dm.voltage = float(tel["voltage"]) + if "channel_utilization" in tel: + dm.channel_utilization = float(tel["channel_utilization"]) + if "air_util_tx" in tel: + dm.air_util_tx = float(tel["air_util_tx"]) + if "uptime_seconds" in tel: + dm.uptime_seconds = int(tel["uptime_seconds"]) + entry.device_metrics.CopyFrom(dm) + return entry + + +def _build_environment_entry(num: int, env: dict[str, Any]) -> NodeEnvironmentEntry: + entry = NodeEnvironmentEntry() + entry.num = num + em = EnvironmentMetrics() + if "temperature" in env: + em.temperature = float(env["temperature"]) + if "relative_humidity" in env: + em.relative_humidity = float(env["relative_humidity"]) + if "barometric_pressure" in env: + em.barometric_pressure = float(env["barometric_pressure"]) + if "iaq" in env: + em.iaq = int(env["iaq"]) + entry.environment_metrics.CopyFrom(em) + return entry + + +def _build_status_entry(num: int, status: dict[str, Any]) -> NodeStatusEntry: + # `StatusMessage` (mesh.proto:1445) has a single `string status` field. + entry = NodeStatusEntry() + entry.num = num + sm = StatusMessage() + if "status" in status: + sm.status = str(status["status"]) + entry.status.CopyFrom(sm) + return entry + + +def compile_jsonl_to_proto(jsonl_path: pathlib.Path, now_epoch: int) -> bytes: + """Read a seed JSONL and return the encoded NodeDatabase bytes.""" + lines = jsonl_path.read_text(encoding="utf-8").splitlines() + if not lines: + raise ValueError(f"{jsonl_path} is empty") + meta_line = lines[0] + meta_obj = json.loads(meta_line) + meta = meta_obj.get("_meta", {}) + version = meta.get("version") + if version != 25: + raise ValueError( + f"{jsonl_path}: meta version is {version!r}; this compiler " + f"requires version=25. Regenerate the seed with the matching tooling." + ) + + db = NodeDatabase() + db.version = 25 + + for ln, raw in enumerate(lines[1:], start=2): + raw = raw.strip() + if not raw: + continue + try: + node = json.loads(raw) + except json.JSONDecodeError as e: + raise ValueError(f"{jsonl_path}:{ln} JSON parse error: {e}") + + num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"]) + + # Header + info = _build_node_info_lite(node, now_epoch) + db.nodes.append(info) + + # Satellites (nullable) + if node.get("position"): + db.positions.append(_build_position_entry(num, node["position"], now_epoch)) + if node.get("telemetry"): + db.telemetry.append(_build_telemetry_entry(num, node["telemetry"])) + if node.get("environment"): + db.environment.append(_build_environment_entry(num, node["environment"])) + if node.get("status"): + db.status.append(_build_status_entry(num, node["status"])) + + return db.SerializeToString() + + +def main(argv: list[str]) -> int: + p = argparse.ArgumentParser( + description="Compile a seed JSONL into a binary v25 NodeDatabase proto.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + p.add_argument("--in", dest="in_path", required=True, help="Input seed JSONL.") + p.add_argument("--out", required=True, help="Output binary .proto path.") + p.add_argument( + "--now-epoch", + type=int, + default=None, + help="Pin 'now' to this Unix epoch (for byte-identical CI). Default: time.time().", + ) + args = p.parse_args(argv) + + in_path = pathlib.Path(args.in_path) + if not in_path.is_file(): + print(f"input not found: {in_path}", file=sys.stderr) + return 2 + + now_epoch = args.now_epoch if args.now_epoch is not None else int(time.time()) + + try: + encoded = compile_jsonl_to_proto(in_path, now_epoch) + except ValueError as e: + print(f"ERROR: {e}", file=sys.stderr) + return 3 + + out_path = pathlib.Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(encoded) + print( + f"compiled {in_path} -> {out_path} ({len(encoded)} bytes, now_epoch={now_epoch})", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/mcp-server/src/meshtastic_mcp/fixtures.py b/mcp-server/src/meshtastic_mcp/fixtures.py new file mode 100644 index 00000000000..4af4c9a28a1 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/fixtures.py @@ -0,0 +1,382 @@ +"""Fake NodeDB fixture push — Portduino file copy + hardware XModem upload. + +The fixture pipeline is two-stage: + 1. `bin/gen-fake-nodedb-seed.py` produces a deterministic JSONL describing N + fake-but-realistic peers. Committed under `test/fixtures/nodedb/`. + 2. `bin/seed-json-to-proto.py` compiles JSONL → binary v25 NodeDatabase + protobuf with fresh wall-clock timestamps. + +This module exposes `push_fake_nodedb(...)`, the MCP tool that: + - target="portduino": compiles the JSONL into the device's prefs dir on + the local filesystem (`~/.portduino//prefs/nodes.proto`). + - target="hardware": compiles to a temp file, then streams it over the + XModem protocol (via the meshtastic SerialInterface/BLEInterface + + `meshtastic.xmodempacket` pubsub topic) to `/prefs/nodes.proto` on the + device. Triggers a reboot so the firmware loads the new state on next + boot. + +XModem wire details (mirrors firmware impl at src/xmodem.cpp:115-260): + * 128-byte chunks; final chunk padded to 128 B with 0x1A (SUB) bytes. + * CRC16-CCITT (poly 0x1021, init 0x0000). + * SOH/seq=0 carries the destination filename in `buffer.bytes`. ACK if + `FSCom.open(filename, FILE_O_WRITE)` succeeds; NAK otherwise. + * SOH/seq≥1 carries a 128-byte chunk. ACK = advance; NAK = retransmit. + * EOT after the last chunk flushes + closes the file on-device. + +Hardware push requires `confirm=True` (mirrors factory_reset / erase_and_flash +in the .github/copilot-instructions.md "never do these without asking" list). +""" + +from __future__ import annotations + +import dataclasses +import hashlib +import pathlib +import queue +import shutil +import subprocess +import sys +import tempfile +import time +from typing import Any, Literal + +from .connection import connect, is_tcp_port + +# Resolve repo root so the tool works regardless of mcp-server cwd. +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +_SEED_DIR = _REPO_ROOT / "test" / "fixtures" / "nodedb" +_COMPILE_SCRIPT = _REPO_ROOT / "bin" / "seed-json-to-proto.py" + +_DEFAULT_NODES_FILENAME = "/prefs/nodes.proto" +_XMODEM_CHUNK = 128 +_XMODEM_SUB = 0x1A +_ACK_TIMEOUT_INIT_S = 5.0 +_ACK_TIMEOUT_CHUNK_S = 2.0 +_MAX_CHUNK_RETRIES = 5 + +_VALID_SIZES = (250, 500, 1000, 2000) + + +class FixtureError(RuntimeError): + """Raised for any fixture-push failure (compile, transport, ack timeout, …).""" + + +# --------------------------------------------------------------------------- +# CRC16-CCITT (poly 0x1021, init 0x0000). Matches the firmware's `crc16_ccitt`. +# Hand-rolled to avoid the optional `crcmod` dep. +# --------------------------------------------------------------------------- +def _crc16_ccitt(data: bytes, *, init: int = 0x0000) -> int: + crc = init + for b in data: + crc ^= b << 8 + for _ in range(8): + if crc & 0x8000: + crc = ((crc << 1) ^ 0x1021) & 0xFFFF + else: + crc = (crc << 1) & 0xFFFF + return crc + + +# --------------------------------------------------------------------------- +# Compile step — shells out to bin/seed-json-to-proto.py so the MCP module +# doesn't have to duplicate the proto-encoding logic. +# --------------------------------------------------------------------------- +def _compile_proto(jsonl_path: pathlib.Path, out_path: pathlib.Path) -> None: + if not _COMPILE_SCRIPT.is_file(): + raise FixtureError(f"compile script missing at {_COMPILE_SCRIPT}") + cmd = [ + sys.executable, + str(_COMPILE_SCRIPT), + "--in", + str(jsonl_path), + "--out", + str(out_path), + ] + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as exc: + raise FixtureError( + f"seed-json-to-proto.py failed (exit {exc.returncode}):\n" + f" stdout: {exc.stdout}\n stderr: {exc.stderr}" + ) from exc + + +def _resolve_seed_jsonl(size: int, custom: str | None) -> pathlib.Path: + if custom is not None: + p = pathlib.Path(custom).expanduser().resolve() + if not p.is_file(): + raise FixtureError(f"custom_seed_jsonl not found: {p}") + return p + p = _SEED_DIR / f"seed_v25_{size:04d}.jsonl" + if not p.is_file(): + raise FixtureError( + f"missing committed seed at {p}. " + f"Run `./bin/regen-fake-nodedbs.sh` to generate it." + ) + return p + + +# --------------------------------------------------------------------------- +# Portduino push — file copy into ~/.portduino//prefs/ +# --------------------------------------------------------------------------- +def _portduino_prefs_dir(config_name: str) -> pathlib.Path: + home = pathlib.Path.home() + return home / ".portduino" / config_name / "prefs" + + +def _push_portduino( + size: int, + jsonl: pathlib.Path, + portduino_config: str, + backup_existing: bool, +) -> dict[str, Any]: + prefs = _portduino_prefs_dir(portduino_config) + prefs.mkdir(parents=True, exist_ok=True) + target = prefs / "nodes.proto" + backed_up_to: str | None = None + if backup_existing and target.is_file(): + ts = int(time.time()) + backup = prefs / f"nodes.proto.bak.{ts}" + shutil.move(str(target), str(backup)) + backed_up_to = str(backup) + _compile_proto(jsonl, target) + raw = target.read_bytes() + return { + "transport": "portduino", + "path": str(target), + "bytes": len(raw), + "sha256": hashlib.sha256(raw).hexdigest(), + "jsonl_source": str(jsonl), + "backed_up_to": backed_up_to, + } + + +# --------------------------------------------------------------------------- +# Hardware push — XModem over BLE/serial via the meshtastic Python interface. +# --------------------------------------------------------------------------- +@dataclasses.dataclass +class _AckEvent: + control: int + seq: int + + +def _wait_for_response(q: "queue.Queue[_AckEvent]", timeout_s: float) -> _AckEvent: + try: + return q.get(timeout=timeout_s) + except queue.Empty as exc: + raise FixtureError( + f"XModem response timeout after {timeout_s:.1f}s — device not responding" + ) from exc + + +def _push_hardware( + size: int, + jsonl: pathlib.Path, + port: str | None, + reboot_after: bool, +) -> dict[str, Any]: + # Lazy imports so the module loads even when the meshtastic deps aren't + # available (e.g. CI in a Python env without the package installed). + try: + from meshtastic.protobuf import mesh_pb2, xmodem_pb2 + from pubsub import pub + except ImportError as exc: # pragma: no cover — dep missing + raise FixtureError( + f"hardware push requires the meshtastic + pypubsub packages: {exc}" + ) from exc + + if is_tcp_port(port): + raise FixtureError( + "hardware push over TCP/portduino is not supported — use " + "target='portduino' to drop the fixture directly into the prefs dir." + ) + + # Compile the fixture to a temp file with fresh timestamps. + with tempfile.NamedTemporaryFile(suffix=".proto", delete=False) as tf: + proto_path = pathlib.Path(tf.name) + try: + _compile_proto(jsonl, proto_path) + payload = proto_path.read_bytes() + finally: + proto_path.unlink(missing_ok=True) + + sha256 = hashlib.sha256(payload).hexdigest() + total_bytes = len(payload) + + # Subscribe to XModem responses BEFORE we open the interface, so we don't + # race the first ACK that arrives during the SOH/seq=0 handshake. + # + # NB: the signature MUST declare every kwarg pypubsub will see for this + # topic, or pubsub locks the topic spec to a smaller set (whichever + # subscribe arrives first) and then *rejects* the meshtastic library's + # publish call with `SenderUnknownMsgDataError: unknown ... interface`. + # The meshtastic lib publishes both `packet=` and `interface=` + # (mesh_interface.py:1389-1395), so both must appear here. + response_q: "queue.Queue[_AckEvent]" = queue.Queue() + + def _on_xmodem(packet: Any = None, interface: Any = None, **_kw: Any) -> None: + if packet is None: + return + response_q.put(_AckEvent(control=int(packet.control), seq=int(packet.seq))) + + pub.subscribe(_on_xmodem, "meshtastic.xmodempacket") + + chunks_sent = 0 + retried = 0 + rebooted = False + + XMC = xmodem_pb2.XModem.Control + try: + with connect(port=port) as iface: + # 1) Send the filename (SOH, seq=0). + init_pkt = xmodem_pb2.XModem( + control=XMC.Value("SOH"), + seq=0, + buffer=_DEFAULT_NODES_FILENAME.encode("utf-8"), + ) + iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=init_pkt)) + ack = _wait_for_response(response_q, _ACK_TIMEOUT_INIT_S) + if ack.control != XMC.Value("ACK"): + raise FixtureError( + f"device refused filename {_DEFAULT_NODES_FILENAME!r} " + f"(got control={ack.control}, expected ACK). " + f"Filesystem full or permissions issue?" + ) + + # 2) Stream the payload in 128 B chunks. + for offset in range(0, total_bytes, _XMODEM_CHUNK): + chunk = payload[offset : offset + _XMODEM_CHUNK] + if len(chunk) < _XMODEM_CHUNK: + # Pad final chunk to 128 B with SUB. The trailing 0x1A bytes + # become part of the file on-device, but nanopb ignores + # bytes past the end of the top-level message. + chunk = chunk + bytes([_XMODEM_SUB] * (_XMODEM_CHUNK - len(chunk))) + seq = ((offset // _XMODEM_CHUNK) + 1) % 256 + # Retry loop on NAK / timeout. + attempts = 0 + while True: + pkt = xmodem_pb2.XModem( + control=XMC.Value("SOH"), + seq=seq, + buffer=chunk, + crc16=_crc16_ccitt(chunk), + ) + iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=pkt)) + ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S) + if ack.control == XMC.Value("ACK"): + chunks_sent += 1 + break + if ack.control == XMC.Value("NAK"): + attempts += 1 + retried += 1 + if attempts >= _MAX_CHUNK_RETRIES: + # Abort: send CAN so the firmware removes the half- + # written file via FSCom.remove(filename). + iface._sendToRadio( + mesh_pb2.ToRadio( + xmodemPacket=xmodem_pb2.XModem( + control=XMC.Value("CAN") + ) + ) + ) + raise FixtureError( + f"chunk seq={seq} NAK'd {attempts} times; " + f"aborted transfer (file removed on-device)." + ) + continue # retry the same chunk + raise FixtureError( + f"unexpected XModem control={ack.control} on seq={seq}" + ) + + # 3) Tell the device we're done. + iface._sendToRadio( + mesh_pb2.ToRadio( + xmodemPacket=xmodem_pb2.XModem(control=XMC.Value("EOT")) + ) + ) + ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S) + if ack.control != XMC.Value("ACK"): + raise FixtureError(f"EOT not ACKed (got control={ack.control})") + + # 4) Reboot so loadFromDisk picks up the new file. + if reboot_after: + iface.localNode.reboot(secs=1) + rebooted = True + finally: + try: + pub.unsubscribe(_on_xmodem, "meshtastic.xmodempacket") + except Exception: + pass + + return { + "transport": "hardware", + "port": port, + "filename_on_device": _DEFAULT_NODES_FILENAME, + "bytes": total_bytes, + "chunks_sent": chunks_sent, + "retried": retried, + "sha256": sha256, + "jsonl_source": str(jsonl), + "rebooted": rebooted, + } + + +# --------------------------------------------------------------------------- +# Public entry point — registered as an MCP tool in server.py. +# --------------------------------------------------------------------------- +def push_fake_nodedb( + size: int, + target: Literal["portduino", "hardware"] = "portduino", + *, + port: str | None = None, + portduino_config: str = "default", + backup_existing: bool = True, + confirm: bool = False, + reboot_after: bool = True, + custom_seed_jsonl: str | None = None, +) -> dict[str, Any]: + """Compile a fresh-timestamp NodeDatabase fixture and push it to a device. + + Args: + size: 250, 500, 1000, or 2000 — selects which committed seed JSONL to use. + target: "portduino" (file copy to ~/.portduino//prefs/) or + "hardware" (XModem upload to /prefs/nodes.proto + reboot). + port: required for target="hardware". Serial path (e.g. /dev/cu.usbmodemXXXX) + or BLE identifier. TCP endpoints are rejected — use target="portduino" + instead. + portduino_config: which Portduino instance dir under ~/.portduino/. Default "default". + backup_existing: portduino only. Move nodes.proto -> nodes.proto.bak. + if present, so you can roll back. + confirm: required True for target="hardware" (writes flash + reboots). + reboot_after: hardware only. If True, send a 1-second reboot after the + final ACK so loadFromDisk picks up the new file at next boot. + custom_seed_jsonl: override the committed JSONL. Use to push a hand-edited + test scenario. + + Returns: + dict with transport, bytes, sha256, etc. — depends on target. + + """ + if size not in _VALID_SIZES: + raise FixtureError( + f"size must be one of {_VALID_SIZES}; got {size!r}. " + f"Add a new committed seed if you need a different cardinality." + ) + + jsonl = _resolve_seed_jsonl(size, custom_seed_jsonl) + + if target == "portduino": + return _push_portduino(size, jsonl, portduino_config, backup_existing) + + if target == "hardware": + if not confirm: + raise FixtureError( + "hardware push writes flash and triggers a reboot — pass confirm=True." + ) + if not port: + raise FixtureError( + "target='hardware' requires a port (e.g. /dev/cu.usbmodemXXXX)." + ) + return _push_hardware(size, jsonl, port, reboot_after) + + raise FixtureError(f"unknown target {target!r}; expected 'portduino' or 'hardware'") diff --git a/mcp-server/src/meshtastic_mcp/server.py b/mcp-server/src/meshtastic_mcp/server.py index 573765e26ae..b0de301bbb2 100644 --- a/mcp-server/src/meshtastic_mcp/server.py +++ b/mcp-server/src/meshtastic_mcp/server.py @@ -15,6 +15,7 @@ admin, boards, devices, + fixtures, flash, hw_tools, info, @@ -963,3 +964,45 @@ def recorder_export( dest_dir=dest_dir, streams=streams, ) + + +# ---------- Fixture / test-data push -------------------------------------- + + +@app.tool() +def push_fake_nodedb( + size: int, + target: str = "portduino", + port: str | None = None, + portduino_config: str = "default", + backup_existing: bool = True, + confirm: bool = False, + reboot_after: bool = True, + custom_seed_jsonl: str | None = None, +) -> dict[str, Any]: + """Push a fake-NodeDB v25 fixture (250/500/1000/2000 nodes) onto a device. + + Two transports: + target="portduino" — file copy to ~/.portduino//prefs/nodes.proto. + Fast, no device connection needed. + target="hardware" — XModem upload over serial/BLE to /prefs/nodes.proto. + Requires `port` + `confirm=True`. Triggers a reboot + so loadFromDisk picks up the new file at next boot. + + Compiles a fresh-timestamp proto from the committed JSONL seed under + test/fixtures/nodedb/seed_v25_.jsonl each invocation, so the loaded + NodeDB always looks "recent" to the connecting phone. Structural data + (names, IDs, positions, telemetries) is deterministic per the seed. + + Override the JSONL via `custom_seed_jsonl` to push a hand-edited scenario. + """ + return fixtures.push_fake_nodedb( + size=size, + target=target, # type: ignore[arg-type] + port=port, + portduino_config=portduino_config, + backup_existing=backup_existing, + confirm=confirm, + reboot_after=reboot_after, + custom_seed_jsonl=custom_seed_jsonl, + ) diff --git a/mcp-server/tests/unit/test_fake_nodedb_generator.py b/mcp-server/tests/unit/test_fake_nodedb_generator.py new file mode 100644 index 00000000000..b6cb2aae98b --- /dev/null +++ b/mcp-server/tests/unit/test_fake_nodedb_generator.py @@ -0,0 +1,364 @@ +"""Tests for the fake-NodeDB fixture pipeline (bin/gen-fake-nodedb-seed.py ++ bin/seed-json-to-proto.py + mcp-server fixtures.push_fake_nodedb). + +Lives under tests/unit/ because none of these touch real hardware — they +shell out to the bin/ scripts and decode the resulting protobufs in-process. +""" + +from __future__ import annotations + +import json +import pathlib +import subprocess +import sys +import time + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +SEED_GEN = REPO_ROOT / "bin" / "gen-fake-nodedb-seed.py" +COMPILE = REPO_ROOT / "bin" / "seed-json-to-proto.py" +FIXTURES_DIR = REPO_ROOT / "test" / "fixtures" / "nodedb" + +# Ensure the locally-generated Python protobuf bindings are importable. +# These live under `meshtastic_v25` (not `meshtastic`) so they don't shadow +# the PyPI `meshtastic` package that the rest of the mcp-server depends on. +_BINDINGS_DIR = REPO_ROOT / "bin" / "_generated" +if _BINDINGS_DIR.is_dir() and str(_BINDINGS_DIR) not in sys.path: + sys.path.insert(0, str(_BINDINGS_DIR)) + +try: + from meshtastic_v25.deviceonly_pb2 import ( + NodeDatabase, # type: ignore[import-not-found] + ) +except ImportError: + NodeDatabase = None # type: ignore[assignment] + + +def _require_v25_bindings() -> None: + if NodeDatabase is None: + pytest.skip( + "v25 Python protobuf bindings missing; run `./bin/regen-py-protos.sh`." + ) + if "positions" not in NodeDatabase.DESCRIPTOR.fields_by_name: + pytest.skip( + "Loaded NodeDatabase predates v25 — run `./bin/regen-py-protos.sh`." + ) + + +def _run(cmd: list[str]) -> None: + subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + + +# --------------------------------------------------------------------------- +# Seed generator: deterministic for given --seed (no wall-clock dependence). +# --------------------------------------------------------------------------- +def test_seed_generator_is_deterministic(tmp_path: pathlib.Path) -> None: + a = tmp_path / "a.jsonl" + b = tmp_path / "b.jsonl" + _run( + [ + sys.executable, + str(SEED_GEN), + "--count", + "100", + "--seed", + "42", + "--out", + str(a), + ] + ) + # Sleep so any sneaky wall-clock leak in the generator would surface as + # a byte diff between the two runs. + time.sleep(0.8) + _run( + [ + sys.executable, + str(SEED_GEN), + "--count", + "100", + "--seed", + "42", + "--out", + str(b), + ] + ) + assert a.read_bytes() == b.read_bytes() + + +def test_seed_generator_meta_line(tmp_path: pathlib.Path) -> None: + out = tmp_path / "seed.jsonl" + _run( + [ + sys.executable, + str(SEED_GEN), + "--count", + "50", + "--seed", + "1", + "--out", + str(out), + ] + ) + lines = out.read_text(encoding="utf-8").splitlines() + assert len(lines) == 51 # 1 meta + 50 nodes + meta = json.loads(lines[0]) + assert "_meta" in meta + assert meta["_meta"]["version"] == 25 + assert meta["_meta"]["count"] == 50 + assert meta["_meta"]["seed"] == 1 + + +def test_seed_only_uses_active_hardware_and_roles(tmp_path: pathlib.Path) -> None: + """Confirm no deprecated roles + no off-list HW models leak through.""" + out = tmp_path / "seed.jsonl" + _run( + [ + sys.executable, + str(SEED_GEN), + "--count", + "500", + "--seed", + "7", + "--out", + str(out), + ] + ) + forbidden_roles = {"ROUTER_CLIENT", "REPEATER"} + forbidden_hw = { + "TLORA_V1", + "TLORA_V2", + "TLORA_V1_1P3", + "TLORA_V2_1_1P6", + "TLORA_V2_1_1P8", + "HELTEC_V1", + "HELTEC_V2_0", + "HELTEC_V2_1", + "TBEAM", + "TBEAM_V0P7", + "NANO_G1", + "NANO_G1_EXPLORER", + "NANO_G2_ULTRA", + "STATION_G1", + "STATION_G2", + "PORTDUINO", + "ANDROID_SIM", + "DIY_V1", + "LORA_RELAY_V1", + "NRF52840_PCA10059", + "NRF52_UNKNOWN", + "DR_DEV", + "GENIEBLOCKS", + "M5STACK", + "RP2040_LORA", + "PPR", + } + for raw in out.read_text(encoding="utf-8").splitlines()[1:]: + node = json.loads(raw) + assert node["role"] not in forbidden_roles, f"deprecated role: {node['role']}" + assert ( + node["hw_model"] not in forbidden_hw + ), f"non-tier-1 HW: {node['hw_model']}" + + +# --------------------------------------------------------------------------- +# Compile step + committed seeds. +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("size", [250, 500, 1000, 2000]) +def test_committed_seed_compiles_and_decodes(size: int, tmp_path: pathlib.Path) -> None: + _require_v25_bindings() + proto = tmp_path / "out.proto" + jsonl = FIXTURES_DIR / f"seed_v25_{size:04d}.jsonl" + if not jsonl.is_file(): + pytest.skip(f"{jsonl} not present — run ./bin/regen-fake-nodedbs.sh") + _run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(proto)]) + + db = NodeDatabase() + db.ParseFromString(proto.read_bytes()) + assert db.version == 25 + assert len(db.nodes) == size + nums = {n.num for n in db.nodes} + assert len(nums) == size, "node numbers must be unique" + assert all(n.long_name and n.short_name for n in db.nodes) + assert all(len(n.long_name) <= 24 for n in db.nodes) # max_size:25 - NUL + + # Coverage sanity (±10pp tolerance for binomial fluctuation). + def in_range(actual: int, expected_ratio: float, tol_pp: float = 0.10) -> bool: + lo = max(0, int((expected_ratio - tol_pp) * size)) + hi = min(size, int((expected_ratio + tol_pp) * size)) + return lo <= actual <= hi + + assert in_range(len(db.positions), 0.85) + assert in_range(len(db.telemetry), 0.70) + assert in_range(len(db.environment), 0.25) + assert in_range(len(db.status), 0.40) + + +def test_compile_freshens_timestamps(tmp_path: pathlib.Path) -> None: + """Same JSONL compiled twice → identical structure, different timestamps.""" + _require_v25_bindings() + jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl" + if not jsonl.is_file(): + pytest.skip("250-node seed not present — run ./bin/regen-fake-nodedbs.sh") + a = tmp_path / "a.proto" + b = tmp_path / "b.proto" + _run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(a)]) + time.sleep(1.2) + _run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(b)]) + + da = NodeDatabase() + db_ = NodeDatabase() + da.ParseFromString(a.read_bytes()) + db_.ParseFromString(b.read_bytes()) + + # Zero out timestamp fields and confirm everything else is byte-identical. + for d in (da, db_): + for n in d.nodes: + n.last_heard = 0 + for p in d.positions: + p.position.time = 0 + assert da.SerializeToString() == db_.SerializeToString() + + # Re-load fresh copies to confirm timestamps actually moved. + aa = NodeDatabase() + bb = NodeDatabase() + aa.ParseFromString(a.read_bytes()) + bb.ParseFromString(b.read_bytes()) + aa_max = max(n.last_heard for n in aa.nodes if n.last_heard) + bb_max = max(n.last_heard for n in bb.nodes if n.last_heard) + assert bb_max >= aa_max + assert bb_max - aa_max < 5 # within a few seconds + + +def test_compile_pinned_now_epoch_is_byte_identical(tmp_path: pathlib.Path) -> None: + """With --now-epoch pinned, two compiles produce identical bytes.""" + _require_v25_bindings() + jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl" + if not jsonl.is_file(): + pytest.skip("250-node seed not present") + a = tmp_path / "a.proto" + b = tmp_path / "b.proto" + for o in (a, b): + _run( + [ + sys.executable, + str(COMPILE), + "--in", + str(jsonl), + "--now-epoch", + "1700000000", + "--out", + str(o), + ] + ) + assert a.read_bytes() == b.read_bytes() + + +def test_compile_timestamps_are_recent(tmp_path: pathlib.Path) -> None: + _require_v25_bindings() + jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl" + if not jsonl.is_file(): + pytest.skip("250-node seed not present") + out = tmp_path / "out.proto" + _run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(out)]) + db = NodeDatabase() + db.ParseFromString(out.read_bytes()) + now = int(time.time()) + # No timestamp older than 7 days, none in the future. + for n in db.nodes: + if n.last_heard: + assert now - 7 * 86400 <= n.last_heard <= now + # At least half should be within the last hour + # (matches expovariate(mean=3600s)). + recent = sum(1 for n in db.nodes if n.last_heard and n.last_heard >= now - 3600) + assert recent >= 0.4 * len(db.nodes) + + +def test_compile_hand_edit_round_trip(tmp_path: pathlib.Path) -> None: + """Edit one JSONL line, recompile, confirm edit appears in the proto.""" + _require_v25_bindings() + src = FIXTURES_DIR / "seed_v25_0250.jsonl" + if not src.is_file(): + pytest.skip("250-node seed not present") + dst = tmp_path / "edited.jsonl" + lines = src.read_text(encoding="utf-8").splitlines() + + # Find a node that already has telemetry so the index relationship is + # easy to assert on the other side. + edit_idx = None + for i, raw in enumerate(lines[1:], start=1): + node = json.loads(raw) + if node.get("telemetry") is not None: + edit_idx = i + break + assert edit_idx is not None, "expected at least one node with telemetry" + + node = json.loads(lines[edit_idx]) + target_num = int(node["num"], 16) + node["long_name"] = "Hand Edited Node" + node["telemetry"] = { + "battery_level": 42, + "voltage": 3.71, + "channel_utilization": 0.0, + "air_util_tx": 0.0, + "uptime_seconds": 1, + } + lines[edit_idx] = json.dumps(node, ensure_ascii=False, sort_keys=True) + dst.write_text("\n".join(lines) + "\n", encoding="utf-8") + + out = tmp_path / "out.proto" + _run([sys.executable, str(COMPILE), "--in", str(dst), "--out", str(out)]) + db = NodeDatabase() + db.ParseFromString(out.read_bytes()) + edited = next((n for n in db.nodes if n.num == target_num), None) + assert edited is not None + assert edited.long_name == "Hand Edited Node" + tel = next((t for t in db.telemetry if t.num == target_num), None) + assert tel is not None + assert tel.device_metrics.battery_level == 42 + + +# --------------------------------------------------------------------------- +# Misc smoke checks on the module surface. +# --------------------------------------------------------------------------- +def test_crc16_ccitt_matches_known_vectors() -> None: + """Sanity-check the hand-rolled CRC16-CCITT matches well-known vectors. + + Test vectors from the XModem-CRC spec (init=0, poly=0x1021): + crc16("123456789") = 0x31C3 + crc16("") = 0x0000 + """ + from meshtastic_mcp.fixtures import _crc16_ccitt + + assert _crc16_ccitt(b"") == 0x0000 + assert _crc16_ccitt(b"123456789") == 0x31C3 + + +def test_push_fake_nodedb_rejects_invalid_size() -> None: + from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb + + with pytest.raises(FixtureError, match="size must be one of"): + push_fake_nodedb(size=999, target="portduino") # type: ignore[arg-type] + + +def test_push_fake_nodedb_hardware_requires_confirm() -> None: + from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb + + with pytest.raises(FixtureError, match="confirm=True"): + push_fake_nodedb(size=250, target="hardware", port="/dev/cu.fake") + + +def test_push_fake_nodedb_hardware_requires_port() -> None: + from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb + + with pytest.raises(FixtureError, match="requires a port"): + push_fake_nodedb(size=250, target="hardware", confirm=True) + + +def test_push_fake_nodedb_hardware_rejects_tcp_port() -> None: + from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb + + with pytest.raises(FixtureError, match="not supported"): + push_fake_nodedb( + size=250, target="hardware", confirm=True, port="tcp://localhost:4403" + ) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index f41800abe5e..8660aefaec2 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -25,6 +25,7 @@ #include "mesh/generated/meshtastic/deviceonly_legacy.pb.h" #include "meshUtils.h" #include "modules/NeighborInfoModule.h" +#include "xmodem.h" #include #include #include @@ -160,6 +161,25 @@ uint32_t get_st7789_id(uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_ #endif +// When armed by loadFromDisk, the decode callback writes satellite entries +// straight into these maps instead of the temp vectors. Nullptr = legacy +// push_back-to-vector path for backup/restore and other decoders. +namespace +{ +#if !MESHTASTIC_EXCLUDE_POSITIONDB +std::map *s_decodePositionsTarget = nullptr; +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB +std::map *s_decodeTelemetryTarget = nullptr; +#endif +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB +std::map *s_decodeEnvironmentTarget = nullptr; +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB +std::map *s_decodeStatusTarget = nullptr; +#endif +} // namespace + bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field) { const auto *iter = reinterpret_cast(field); @@ -174,10 +194,10 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre return false; } } - if (istream) { - meshtastic_NodeInfoLite node; + if (istream && istream->bytes_left) { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; auto *vec = static_cast *>(iter->pData); - if (istream->bytes_left && pb_decode(istream, meshtastic_NodeInfoLite_fields, &node)) + if (pb_decode(istream, meshtastic_NodeInfoLite_fields, &node)) vec->push_back(node); } return true; @@ -192,11 +212,19 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre return false; } } - if (istream) { - meshtastic_NodePositionEntry entry; - auto *vec = static_cast *>(iter->pData); - if (istream->bytes_left && pb_decode(istream, meshtastic_NodePositionEntry_fields, &entry)) + if (istream && istream->bytes_left) { + meshtastic_NodePositionEntry entry = meshtastic_NodePositionEntry_init_zero; + if (pb_decode(istream, meshtastic_NodePositionEntry_fields, &entry)) { +#if !MESHTASTIC_EXCLUDE_POSITIONDB + if (s_decodePositionsTarget) { + if (entry.has_position) + (*s_decodePositionsTarget)[entry.num] = entry.position; + return true; + } +#endif + auto *vec = static_cast *>(iter->pData); vec->push_back(entry); + } } return true; } @@ -210,11 +238,19 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre return false; } } - if (istream) { - meshtastic_NodeTelemetryEntry entry; - auto *vec = static_cast *>(iter->pData); - if (istream->bytes_left && pb_decode(istream, meshtastic_NodeTelemetryEntry_fields, &entry)) + if (istream && istream->bytes_left) { + meshtastic_NodeTelemetryEntry entry = meshtastic_NodeTelemetryEntry_init_zero; + if (pb_decode(istream, meshtastic_NodeTelemetryEntry_fields, &entry)) { +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + if (s_decodeTelemetryTarget) { + if (entry.has_device_metrics) + (*s_decodeTelemetryTarget)[entry.num] = entry.device_metrics; + return true; + } +#endif + auto *vec = static_cast *>(iter->pData); vec->push_back(entry); + } } return true; } @@ -228,11 +264,19 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre return false; } } - if (istream) { - meshtastic_NodeStatusEntry entry; - auto *vec = static_cast *>(iter->pData); - if (istream->bytes_left && pb_decode(istream, meshtastic_NodeStatusEntry_fields, &entry)) + if (istream && istream->bytes_left) { + meshtastic_NodeStatusEntry entry = meshtastic_NodeStatusEntry_init_zero; + if (pb_decode(istream, meshtastic_NodeStatusEntry_fields, &entry)) { +#if !MESHTASTIC_EXCLUDE_STATUSDB + if (s_decodeStatusTarget) { + if (entry.has_status) + (*s_decodeStatusTarget)[entry.num] = entry.status; + return true; + } +#endif + auto *vec = static_cast *>(iter->pData); vec->push_back(entry); + } } return true; } @@ -246,11 +290,19 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre return false; } } - if (istream) { - meshtastic_NodeEnvironmentEntry entry; - auto *vec = static_cast *>(iter->pData); - if (istream->bytes_left && pb_decode(istream, meshtastic_NodeEnvironmentEntry_fields, &entry)) + if (istream && istream->bytes_left) { + meshtastic_NodeEnvironmentEntry entry = meshtastic_NodeEnvironmentEntry_init_zero; + if (pb_decode(istream, meshtastic_NodeEnvironmentEntry_fields, &entry)) { +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + if (s_decodeEnvironmentTarget) { + if (entry.has_environment_metrics) + (*s_decodeEnvironmentTarget)[entry.num] = entry.environment_metrics; + return true; + } +#endif + auto *vec = static_cast *>(iter->pData); vec->push_back(entry); + } } return true; } @@ -259,6 +311,42 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre } } +void NodeDB::armNodeDatabaseDecodeTargets() +{ +#if !MESHTASTIC_EXCLUDE_POSITIONDB + nodePositions.clear(); + s_decodePositionsTarget = &nodePositions; +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + nodeTelemetry.clear(); + s_decodeTelemetryTarget = &nodeTelemetry; +#endif +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + nodeEnvironment.clear(); + s_decodeEnvironmentTarget = &nodeEnvironment; +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB + nodeStatus.clear(); + s_decodeStatusTarget = &nodeStatus; +#endif +} + +void NodeDB::disarmNodeDatabaseDecodeTargets() +{ +#if !MESHTASTIC_EXCLUDE_POSITIONDB + s_decodePositionsTarget = nullptr; +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + s_decodeTelemetryTarget = nullptr; +#endif +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + s_decodeEnvironmentTarget = nullptr; +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB + s_decodeStatusTarget = nullptr; +#endif +} + /** The current change # for radio settings. Starts at 0 on boot and any time the radio settings * might have changed is incremented. Allows others to detect they might now be on a new channel. */ @@ -1518,6 +1606,19 @@ void NodeDB::loadFromDisk() } #endif + // Arm the direct-into-map decode so satellite entries skip the temp vectors. + { + concurrency::LockGuard guard(&satelliteMutex); + armNodeDatabaseDecodeTargets(); + } + struct Disarm { + NodeDB &self; + ~Disarm() { self.disarmNodeDatabaseDecodeTargets(); } + } disarm{*this}; + + // Avoid push_back's power-of-2 capacity growth wasting RAM at small N. + nodeDatabase.nodes.reserve(MAX_NUM_NODES); + auto state = loadProto(nodeDatabaseFileName, getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), &meshtastic_NodeDatabase_msg, &nodeDatabase); if (nodeDatabase.version < DEVICESTATE_MIN_VER) { @@ -1531,50 +1632,33 @@ void NodeDB::loadFromDisk() } else { meshNodes = &nodeDatabase.nodes; numMeshNodes = nodeDatabase.nodes.size(); - // Hydrate the satellite maps; the on-disk vectors stay empty in steady - // state and are repopulated only at save time. - concurrency::LockGuard guard(&satelliteMutex); + // Counts computed outside LOG_INFO() so cppcheck doesn't choke on #if in macro args. + const unsigned posCount = #if !MESHTASTIC_EXCLUDE_POSITIONDB - nodePositions.clear(); - nodePositions.reserve(nodeDatabase.positions.size()); - for (const auto &entry : nodeDatabase.positions) { - if (entry.has_position) - nodePositions[entry.num] = entry.position; - } - nodeDatabase.positions.clear(); - nodeDatabase.positions.shrink_to_fit(); + (unsigned)nodePositions.size(); +#else + 0u; #endif + const unsigned telCount = #if !MESHTASTIC_EXCLUDE_TELEMETRYDB - nodeTelemetry.clear(); - nodeTelemetry.reserve(nodeDatabase.telemetry.size()); - for (const auto &entry : nodeDatabase.telemetry) { - if (entry.has_device_metrics) - nodeTelemetry[entry.num] = entry.device_metrics; - } - nodeDatabase.telemetry.clear(); - nodeDatabase.telemetry.shrink_to_fit(); + (unsigned)nodeTelemetry.size(); +#else + 0u; #endif + const unsigned envCount = #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB - nodeEnvironment.clear(); - nodeEnvironment.reserve(nodeDatabase.environment.size()); - for (const auto &entry : nodeDatabase.environment) { - if (entry.has_environment_metrics) - nodeEnvironment[entry.num] = entry.environment_metrics; - } - nodeDatabase.environment.clear(); - nodeDatabase.environment.shrink_to_fit(); + (unsigned)nodeEnvironment.size(); +#else + 0u; #endif + const unsigned statusCount = #if !MESHTASTIC_EXCLUDE_STATUSDB - nodeStatus.clear(); - nodeStatus.reserve(nodeDatabase.status.size()); - for (const auto &entry : nodeDatabase.status) { - if (entry.has_status) - nodeStatus[entry.num] = entry.status; - } - nodeDatabase.status.clear(); - nodeDatabase.status.shrink_to_fit(); + (unsigned)nodeStatus.size(); +#else + 0u; #endif - LOG_INFO("Loaded saved nodedatabase version %d, with nodes count: %d", nodeDatabase.version, nodeDatabase.nodes.size()); + LOG_INFO("Loaded saved nodedatabase v%d: %d nodes, %u pos, %u tel, %u env, %u status", nodeDatabase.version, + nodeDatabase.nodes.size(), posCount, telCount, envCount, statusCount); } if (numMeshNodes > MAX_NUM_NODES) { @@ -1852,6 +1936,15 @@ bool NodeDB::saveNodeDatabaseToDisk() return false; } + // Defer (don't fail) while xmodem holds the prefs file handle. Returning false + // would propagate through saveToDisk() and trigger fsFormat() mid-transfer. +#ifdef FSCom + if (xModem.isBusy()) { + LOG_DEBUG("Deferring NodeDB save: xmodem transfer in progress"); + return true; + } +#endif + #ifdef FSCom spiLock->lock(); FSCom.mkdir("/prefs"); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 090f3bb9ae9..8b250f09bf1 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -4,9 +4,9 @@ #include #include #include +#include #include #include -#include #include #include "MeshTypes.h" @@ -170,19 +170,19 @@ class NodeDB Observable newStatus; pb_size_t numMeshNodes; - // Satellite per-NodeNum maps for data we used to inline into NodeInfoLite, - // gated by MESHTASTIC_EXCLUDE_*DB so STM32WL can omit them. + // Satellite per-NodeNum maps. std::map avoids unordered_map's bucket-array + // preallocation; O(log N) lookup is fine at these sizes. #if !MESHTASTIC_EXCLUDE_POSITIONDB - std::unordered_map nodePositions; + std::map nodePositions; #endif #if !MESHTASTIC_EXCLUDE_TELEMETRYDB - std::unordered_map nodeTelemetry; + std::map nodeTelemetry; #endif #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB - std::unordered_map nodeEnvironment; + std::map nodeEnvironment; #endif #if !MESHTASTIC_EXCLUDE_STATUSDB - std::unordered_map nodeStatus; + std::map nodeStatus; #endif bool keyIsLowEntropy = false; @@ -429,6 +429,11 @@ class NodeDB // the legacy descriptor and copies entries into the v25 layout. Caller // is responsible for save / install-default on the result. bool migrateLegacyNodeDatabase(); + + // Route satellite-store decode entries straight into our maps instead of + // temp vectors. Must be paired — disarm before any other NodeDatabase decode. + void armNodeDatabaseDecodeTargets(); + void disarmNodeDatabaseDecodeTargets(); }; extern NodeDB *nodeDB; diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index 2fbb355b424..30cea93831b 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -80,7 +80,7 @@ static_assert(sizeof(meshtastic_NodeInfoLite) <= 130, "NodeInfoLite size increas #if defined(ARCH_STM32WL) #define MAX_NUM_NODES 10 #elif defined(ARCH_NRF52) -#define MAX_NUM_NODES 80 +#define MAX_NUM_NODES 150 #elif defined(CONFIG_IDF_TARGET_ESP32S3) #include "Esp.h" static inline int get_max_num_nodes() diff --git a/test/fixtures/nodedb/README.md b/test/fixtures/nodedb/README.md new file mode 100644 index 00000000000..5f594b7f354 --- /dev/null +++ b/test/fixtures/nodedb/README.md @@ -0,0 +1,153 @@ +# Fake NodeDB Fixtures + +Deterministic JSONL seed files for the v25 `meshtastic_NodeDatabase` format +plus tooling that compiles them to binary `.proto` files and pushes them +onto a device for testing. + +Centered on Truth or Consequences, NM (33.1284°N, 107.2528°W) with a 60 km +spread — change via `--centroid` and `--spread-km` if you want a different +geography. + +## Pipeline + +```text + bin/gen-fake-nodedb-seed.py + ↓ (single Random(seed); no wall-clock dependence) + test/fixtures/nodedb/seed_v25_.jsonl ← committed, hand-editable + ↓ + bin/seed-json-to-proto.py + ↓ (resolves *_offset_sec → now-relative epochs at compile time) + build/fixtures/nodedb/nodes_v25_.proto ← .gitignored, fresh timestamps + ↓ + - Portduino: cp to ~/.portduino//prefs/nodes.proto + - Hardware: XModem upload via mcp-server's push_fake_nodedb tool +``` + +## What's committed + +| File | Size | Purpose | +| --------------------- | ------- | ------------------------------------------------------- | +| `seed_v25_0250.jsonl` | ~200 KB | Matches ESP32-S3 high-flash MAX_NUM_NODES cap | +| `seed_v25_0500.jsonl` | ~400 KB | Stress between caps | +| `seed_v25_1000.jsonl` | ~800 KB | Large mesh stress | +| `seed_v25_2000.jsonl` | ~1.6 MB | Truncation/eviction stress (exceeds every platform cap) | + +## Determinism contract + +**Structural fields are deterministic** given a fixed `--seed`: NodeNum, +long_name, short_name, hw_model, role, public_key, snr, channel, hops_away, +next_hop, bitfield flags, latitude/longitude/altitude, all +DeviceMetrics/EnvironmentMetrics/StatusMessage values. + +**Timestamps are intentionally non-deterministic** at compile time. The JSONL +stores `*_offset_sec` (seconds before "now"); the compile step subtracts these +from current wall clock so the loaded NodeDB shows fresh "recently heard" +peers regardless of when the fixture was generated. Pass `--now-epoch T` to +the compile step to pin it for byte-identical CI artifacts. + +## Active-board allow-list + +`hw_model` values are restricted to the intersection of: + +1. Variants with `custom_meshtastic_support_level = 1` in `variants/*/*/platformio.ini` +2. Values present in the `HardwareModel` enum in `mesh.proto` + +This excludes legacy/deprecated boards (Heltec V1–V2, TLORA V1–V2, classic +TBEAM (4) and TBEAM_V0P7 (6), Nano G1, Station G1/G2, etc.) and fuzzer-only +sentinels (PORTDUINO, ANDROID_SIM, DIY_V1, LORA_RELAY_V1, etc.). + +Refresh the allow-list in `bin/gen-fake-nodedb-seed.py:HW_MODEL_WEIGHTS` when +boards graduate to tier-1 (or retire). One-liner to print the current +intersection: + +```bash +for f in $(find variants -name 'platformio.ini' | xargs grep -lE 'custom_meshtastic_support_level = 1'); do + grep custom_meshtastic_hw_model_slug "$f" | awk -F= '{print $2}' | tr -d ' ' +done | sort -u | comm -12 - <( + bin/_generated/meshtastic_v25/__init__.py >/dev/null 2>&1 || ./bin/regen-py-protos.sh >&2 + python3 -c "import sys; sys.path.insert(0,'bin/_generated'); \ + from meshtastic_v25.mesh_pb2 import HardwareModel; \ + print('\n'.join(HardwareModel.keys()))" | sort +) +``` + +## Role allow-list + +`role` is drawn from non-deprecated `Config.DeviceConfig.Role` values: + +- Excluded: `ROUTER_CLIENT` (deprecated v2.3.15), `REPEATER` (deprecated v2.7.11) +- Active: CLIENT, CLIENT_MUTE, ROUTER, TRACKER, SENSOR, TAK, CLIENT_HIDDEN, + LOST_AND_FOUND, TAK_TRACKER, ROUTER_LATE, CLIENT_BASE + +## Quickstart + +### Regenerate fixtures with fresh timestamps + +```bash +./bin/regen-fake-nodedbs.sh +``` + +This recompiles all four `.proto` outputs into `build/fixtures/nodedb/` from +the committed JSONL seeds, using current wall clock for timestamps. Re-run +whenever you want "recent-looking" cached state on a freshly-booted device. + +### Bump the seed (regenerate JSONL structure) + +```bash +REGEN_SEEDS=yes ./bin/regen-fake-nodedbs.sh +``` + +This overwrites the committed JSONL files. Commit the result. + +### Hand-edit a specific scenario + +```bash +# Find the node you want to tweak, edit the line in place. +$EDITOR test/fixtures/nodedb/seed_v25_0250.jsonl + +# Recompile and push. +./bin/regen-fake-nodedbs.sh +``` + +Each line of the JSONL is one node + metadata as the first line. Field schema +documented inline in `bin/gen-fake-nodedb-seed.py`. To override a specific +timestamp, replace the `last_heard_offset_sec` field with `last_heard` (an +absolute epoch); the compile step honors absolute values. + +### Load onto Portduino (native macOS / linux) + +```bash +cp build/fixtures/nodedb/nodes_v25_1000.proto ~/.portduino/default/prefs/nodes.proto +# Run the native binary; loadFromDisk picks it up at boot. +``` + +### Push to USB-attached hardware via mcp-server + +```python +# From within the mcp-server tool surface: +push_fake_nodedb( + size=500, + target="hardware", + port="/dev/cu.usbmodem21301", # discover via list_devices + confirm=True, # gates the destructive write + reboot +) +``` + +Streams the proto over XModem to `/prefs/nodes.proto`, then issues a 1-second +reboot so `loadFromDisk` picks it up on next boot. CRC16-CCITT-validated +chunks; retries each chunk up to 5× on NAK before aborting with `CAN`. + +## Schema reference + +See `bin/gen-fake-nodedb-seed.py` for the JSONL field reference. Key points: + +- `num` is a hex string (`"0xa1b2c3d4"`) +- `public_key_hex` is 64 hex chars (32 bytes), empty for keyless nodes +- `hw_model` and `role` are enum **names**; the compile step resolves them + via `HardwareModel.Value(name)` / `Config.DeviceConfig.Role.Value(name)` +- `bitfield` is a struct of named booleans; the compile step packs them + per the bit positions in `src/mesh/NodeDB.h:467-484` +- `position` / `telemetry` / `environment` / `status` are nullable; + coverage ratios at seed time decide which nodes get which +- `latitude` / `longitude` are floats in degrees (compiled to `latitude_i = +int(lat * 1e7)` matching `meshtastic_PositionLite`) diff --git a/test/fixtures/nodedb/seed_v25_0250.jsonl b/test/fixtures/nodedb/seed_v25_0250.jsonl new file mode 100644 index 00000000000..0938ebef6bc --- /dev/null +++ b/test/fixtures/nodedb/seed_v25_0250.jsonl @@ -0,0 +1,251 @@ +{"_meta": {"centroid": [33.1284, -107.2528], "count": 250, "coverage": {"environment": 0.25, "position": 0.85, "status": 0.4, "telemetry": 0.7}, "generated_at_iso": "1970-08-23T11:55:11Z", "last_heard_max_sec": 604800, "last_heard_mean_sec": 3600, "my_node_num_excluded": null, "seed": 20260511, "spread_km": 60.0, "version": 25}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.74, "iaq": 31, "relative_humidity": 46.72, "temperature": 24.16}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 4809, "long_name": "Drifting Phoenix", "next_hop": 253, "num": "0x0005e869", "position": {"altitude": 1338, "latitude": 33.690292, "location_source": "LOC_INTERNAL", "longitude": -106.436201, "time_offset_sec": 4996}, "public_key_hex": "056060d6ceae374c7ee39ffb5fb6c2503d238610f2277c47e7cc008a9a096dc5", "role": "CLIENT", "short_name": "DB5I", "snr": 6.64, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.84, "iaq": 38, "relative_humidity": 21.78, "temperature": 30.58}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 1304, "long_name": "Hidden Aspen", "next_hop": 0, "num": "0x00511844", "position": {"altitude": 1535, "latitude": 32.558935, "location_source": "LOC_INTERNAL", "longitude": -107.91722, "time_offset_sec": 1442}, "public_key_hex": "3cae2af9a3e9e2d04829c53ad6cac0f87d8cc81ef4b3ac26a20548113a0d8280", "role": "CLIENT", "short_name": "H4WA", "snr": -0.68, "status": null, "telemetry": {"air_util_tx": 0.075, "battery_level": 75, "channel_utilization": 11.57, "uptime_seconds": 162386, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1836, "long_name": "Iron Otter", "next_hop": 0, "num": "0x02396342", "position": {"altitude": 1379, "latitude": 32.240662, "location_source": "LOC_INTERNAL", "longitude": -107.329117, "time_offset_sec": 2132}, "public_key_hex": "12e9fbc814fd38f3cb2f87dff7de3e9d16d456ee4c68c35cbba37c34e69182f4", "role": "CLIENT", "short_name": "IAJQ", "snr": 10.63, "status": null, "telemetry": {"air_util_tx": 0.304, "battery_level": 56, "channel_utilization": 36.83, "uptime_seconds": 103925, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.88, "iaq": 72, "relative_humidity": 36.6, "temperature": 17.87}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1507, "long_name": "Tall Cougar", "next_hop": 0, "num": "0x02b15d65", "position": {"altitude": 1087, "latitude": 32.881111, "location_source": "LOC_INTERNAL", "longitude": -106.606342, "time_offset_sec": 1695}, "public_key_hex": "78f0eb2208a2d578e455169e63f635a2d7d5229e6482453317a1971256a93a44", "role": "CLIENT", "short_name": "TYD8", "snr": 6.84, "status": null, "telemetry": {"air_util_tx": 0.978, "battery_level": 43, "channel_utilization": 10.6, "uptime_seconds": 133906, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.27, "iaq": 67, "relative_humidity": 60.2, "temperature": 23.87}, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 4638, "long_name": "Quick Yucca", "next_hop": 0, "num": "0x03829eb5", "position": {"altitude": 1526, "latitude": 32.816327, "location_source": "LOC_INTERNAL", "longitude": -107.795843, "time_offset_sec": 4912}, "public_key_hex": "502fc9984eefe0f5022e7c3b1baac8c2701d24e5e9a64a08791026f85ca17d3b", "role": "CLIENT", "short_name": "Q9YW", "snr": 8.1, "status": null, "telemetry": {"air_util_tx": 0.102, "battery_level": 100, "channel_utilization": 31.04, "uptime_seconds": 739545, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4946, "long_name": "Wandering Doe", "next_hop": 0, "num": "0x03f1d514", "position": {"altitude": 1509, "latitude": 33.566253, "location_source": "LOC_INTERNAL", "longitude": -107.554414, "time_offset_sec": 4982}, "public_key_hex": "6bbae52865c1305682f3ac1ef3de4e3d39ec219cab928731137f2131883ef397", "role": "TRACKER", "short_name": "WR0J", "snr": 10.58, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4827, "long_name": "Lone Hare", "next_hop": 0, "num": "0x04ca3beb", "position": {"altitude": 1092, "latitude": 33.004522, "location_source": "LOC_INTERNAL", "longitude": -107.283169, "time_offset_sec": 4829}, "public_key_hex": "", "role": "SENSOR", "short_name": "LO87", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.037, "battery_level": 27, "channel_utilization": 2.08, "uptime_seconds": 49520, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 2448, "long_name": "Dusk Cougar", "next_hop": 0, "num": "0x0535c6ea", "position": {"altitude": 856, "latitude": 31.937803, "location_source": "LOC_INTERNAL", "longitude": -106.516028, "time_offset_sec": 2539}, "public_key_hex": "461adb831f736b11725a4c1a4574c6a74c88753d8046435f641a0fea108e9962", "role": "CLIENT_BASE", "short_name": "DFI5", "snr": 9.58, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 160, "long_name": "Sneaky Hawk", "next_hop": 0, "num": "0x061c8543", "position": {"altitude": 1543, "latitude": 33.531176, "location_source": "LOC_INTERNAL", "longitude": -107.114202, "time_offset_sec": 177}, "public_key_hex": "e70785c21cb865614a71a43f5bfb6d1139fc672e80fd598e3c1a15bfefe9a2af", "role": "CLIENT", "short_name": "SWXS", "snr": 8.12, "status": null, "telemetry": {"air_util_tx": 1.227, "battery_level": 101, "channel_utilization": 14.58, "uptime_seconds": 3338, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1717, "long_name": "Gold Adder", "next_hop": 0, "num": "0x0672aa7b", "position": {"altitude": 926, "latitude": 32.997975, "location_source": "LOC_INTERNAL", "longitude": -108.310088, "time_offset_sec": 1985}, "public_key_hex": "", "role": "CLIENT", "short_name": "GKVX", "snr": 1.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": {"barometric_pressure": 1015.21, "iaq": 55, "relative_humidity": 34.4, "temperature": 38.58}, "hops_away": 0, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 1257, "long_name": "Silent Dolphin", "next_hop": 0, "num": "0x070e1b66", "position": {"altitude": 1591, "latitude": 33.299113, "location_source": "LOC_INTERNAL", "longitude": -107.818463, "time_offset_sec": 1434}, "public_key_hex": "bc40ef1f82acda597f9259c27880e996eaf5d31db18a08daf4b92c333364f823", "role": "CLIENT", "short_name": "🗻", "snr": 0.08, "status": null, "telemetry": {"air_util_tx": 1.092, "battery_level": 57, "channel_utilization": 3.38, "uptime_seconds": 68787, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 948, "long_name": "Rough Doe", "next_hop": 0, "num": "0x07adbb88", "position": {"altitude": 1658, "latitude": 33.262723, "location_source": "LOC_INTERNAL", "longitude": -108.220474, "time_offset_sec": 1019}, "public_key_hex": "4a8bd22cc5910d64d9d1cbb9cfac6ba12e15d5a3eaf432a25914e282afdf6ad8", "role": "CLIENT", "short_name": "RP6F", "snr": 7.14, "status": null, "telemetry": {"air_util_tx": 0.718, "battery_level": 38, "channel_utilization": 5.75, "uptime_seconds": 52963, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4100, "long_name": "Wandering Crow", "next_hop": 0, "num": "0x07bc9340", "position": {"altitude": 1555, "latitude": 33.465538, "location_source": "LOC_INTERNAL", "longitude": -108.0528, "time_offset_sec": 4267}, "public_key_hex": "8c38b26d1f0494c5e8ae1e8f259b2127f7a4ed7193820e2b5142196b4a5ca72e", "role": "CLIENT", "short_name": "🌵", "snr": 7.37, "status": null, "telemetry": {"air_util_tx": 0.128, "battery_level": 43, "channel_utilization": 13.24, "uptime_seconds": 3455, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 135, "long_name": "Lone Hawk", "next_hop": 0, "num": "0x083ddc7d", "position": {"altitude": 1727, "latitude": 33.142932, "location_source": "LOC_INTERNAL", "longitude": -107.69923, "time_offset_sec": 190}, "public_key_hex": "633bbaccd26d91e50d7482dfb72d7caa0c3d3ea2ac4537035af5dec53d703135", "role": "CLIENT", "short_name": "LPH4", "snr": 8.17, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1028.3, "iaq": 19, "relative_humidity": 65.94, "temperature": 2.42}, "hops_away": 3, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6576, "long_name": "Sleepy Doe", "next_hop": 171, "num": "0x08a9fb83", "position": null, "public_key_hex": "5e98de33af5bf7584fa18bc37877e763892d8564bd3878a360631810d5b347ea", "role": "CLIENT", "short_name": "🐺", "snr": 9.09, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.54, "iaq": 58, "relative_humidity": 42.81, "temperature": 3.35}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2645, "long_name": "Howling Elk", "next_hop": 0, "num": "0x08d5357a", "position": {"altitude": 1113, "latitude": 33.563203, "location_source": "LOC_INTERNAL", "longitude": -107.077155, "time_offset_sec": 2657}, "public_key_hex": "04c61243842c533fad9ecf8d25f76123d1626f074b5e0f27251eb78a7b8e7e2c", "role": "CLIENT", "short_name": "H8L8", "snr": 8.67, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.756, "battery_level": 84, "channel_utilization": 18.15, "uptime_seconds": 172126, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 587, "long_name": "Drifting Mamba", "next_hop": 64, "num": "0x08d602a7", "position": {"altitude": 1280, "latitude": 32.981947, "location_source": "LOC_INTERNAL", "longitude": -107.511111, "time_offset_sec": 885}, "public_key_hex": "6394f02f41a0e243c6ca8a8caf75c1e0b417e213446bf2713d9938fd759cb410", "role": "CLIENT", "short_name": "DLUF", "snr": 9.46, "status": null, "telemetry": {"air_util_tx": 0.411, "battery_level": 101, "channel_utilization": 16.4, "uptime_seconds": 75514, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2735, "long_name": "Smooth Badger", "next_hop": 0, "num": "0x0923d80c", "position": {"altitude": 1401, "latitude": 33.18486, "location_source": "LOC_INTERNAL", "longitude": -107.615573, "time_offset_sec": 2892}, "public_key_hex": "c09db88982c59eac6204dd0dddb9adbe05e778349f78c2f62fce90dadd433f05", "role": "CLIENT", "short_name": "SANX", "snr": 7.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.17, "iaq": 20, "relative_humidity": 68.57, "temperature": 12.23}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 10427, "long_name": "Silver Wolf", "next_hop": 23, "num": "0x0a277969", "position": {"altitude": 1432, "latitude": 33.347268, "location_source": "LOC_INTERNAL", "longitude": -107.752326, "time_offset_sec": 10447}, "public_key_hex": "", "role": "CLIENT", "short_name": "SB1N", "snr": 2.51, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1485, "long_name": "Silent Falcon", "next_hop": 100, "num": "0x0a526ec8", "position": {"altitude": 1404, "latitude": 32.852863, "location_source": "LOC_INTERNAL", "longitude": -105.833072, "time_offset_sec": 1677}, "public_key_hex": "1b3b314eca34b5eda9bfee5edef66f954a24378efc0ada41cb6865818a18627d", "role": "CLIENT_MUTE", "short_name": "SCXD", "snr": 6.59, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.855, "battery_level": 28, "channel_utilization": 8.74, "uptime_seconds": 162069, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 6351, "long_name": "Sneaky Viper", "next_hop": 0, "num": "0x0a87420a", "position": {"altitude": 2097, "latitude": 33.46946, "location_source": "LOC_INTERNAL", "longitude": -107.854801, "time_offset_sec": 6351}, "public_key_hex": "", "role": "CLIENT", "short_name": "SXGT", "snr": 9.66, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2668, "long_name": "Slow Raven", "next_hop": 0, "num": "0x0a9785bb", "position": {"altitude": 1168, "latitude": 33.399476, "location_source": "LOC_INTERNAL", "longitude": -107.002506, "time_offset_sec": 2827}, "public_key_hex": "c8f36e0258e1c3c3223010df3176b2ea4e4c4479c49ca206aa7e9e4a7acf6d95", "role": "CLIENT_MUTE", "short_name": "S53U", "snr": 11.62, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.144, "battery_level": 61, "channel_utilization": 2.7, "uptime_seconds": 31838, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 74, "long_name": "Wild Phoenix", "next_hop": 0, "num": "0x0b377d65", "position": {"altitude": 1535, "latitude": 32.619246, "location_source": "LOC_INTERNAL", "longitude": -107.303937, "time_offset_sec": 216}, "public_key_hex": "8ed086ac62317421d7a9a962c1f79458c7b1ce9c00da0c45037b26dfeb1cd070", "role": "CLIENT", "short_name": "🗻", "snr": 8.49, "status": null, "telemetry": {"air_util_tx": 0.959, "battery_level": 40, "channel_utilization": 16.45, "uptime_seconds": 46826, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 639, "long_name": "Lone Yucca", "next_hop": 0, "num": "0x0bbac3e3", "position": {"altitude": 1418, "latitude": 32.838025, "location_source": "LOC_INTERNAL", "longitude": -106.952382, "time_offset_sec": 755}, "public_key_hex": "565e3acd1523d5efd9db6717454eb11ad7832e6306d8c769fb6df4f1914ecb11", "role": "TAK_TRACKER", "short_name": "🌵", "snr": 9.57, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.085, "battery_level": 38, "channel_utilization": 13.02, "uptime_seconds": 46809, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4396, "long_name": "Solar Raven", "next_hop": 0, "num": "0x0cad1877", "position": {"altitude": 1442, "latitude": 33.838306, "location_source": "LOC_INTERNAL", "longitude": -107.31537, "time_offset_sec": 4451}, "public_key_hex": "", "role": "ROUTER_LATE", "short_name": "ST9H", "snr": 10.64, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 6007, "long_name": "Smooth Bison", "next_hop": 225, "num": "0x0cb3920e", "position": {"altitude": 1473, "latitude": 32.799514, "location_source": "LOC_INTERNAL", "longitude": -107.318775, "time_offset_sec": 6032}, "public_key_hex": "135c23e5ef3009668b333313a4ec57f58b74148d90135fddc983d31774ad4c00", "role": "CLIENT", "short_name": "SDEG", "snr": 6.49, "status": null, "telemetry": {"air_util_tx": 0.252, "battery_level": 76, "channel_utilization": 12.26, "uptime_seconds": 1707, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 3075, "long_name": "Red Adder", "next_hop": 0, "num": "0x0cd3c9b8", "position": {"altitude": 1746, "latitude": 32.398996, "location_source": "LOC_INTERNAL", "longitude": -107.079664, "time_offset_sec": 3102}, "public_key_hex": "d6e909ad875b1afe4e2acca344f4b77a75eb0ac6eab0231ba27cf97a41a6a2e6", "role": "ROUTER_LATE", "short_name": "RKHV", "snr": 9.3, "status": null, "telemetry": {"air_util_tx": 0.092, "battery_level": 54, "channel_utilization": 6.94, "uptime_seconds": 46741, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 11846, "long_name": "Blue Turtle", "next_hop": 42, "num": "0x0cf6f075", "position": {"altitude": 1599, "latitude": 33.292365, "location_source": "LOC_INTERNAL", "longitude": -106.642067, "time_offset_sec": 11851}, "public_key_hex": "29f0d88c9858804290119564279b259728e09815ba7f953bfbbc0235a3b7ca59", "role": "ROUTER", "short_name": "BE2J", "snr": 3.22, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.103, "battery_level": 30, "channel_utilization": 9.29, "uptime_seconds": 119323, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 12334, "long_name": "Sleepy Badger", "next_hop": 0, "num": "0x0d3ce504", "position": {"altitude": 1333, "latitude": 33.571284, "location_source": "LOC_INTERNAL", "longitude": -107.427147, "time_offset_sec": 12617}, "public_key_hex": "ef30355e2fd16b456d44a21f9b96bc848eed29615dc26abc43e4968a50aae461", "role": "CLIENT", "short_name": "SPED", "snr": 5.13, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2432, "long_name": "Burning Coyote", "next_hop": 0, "num": "0x0d8ca8ba", "position": null, "public_key_hex": "aa735974126186912e6affeb580aeb2cdd7eaefdbae2844c4df102f11b316e71", "role": "CLIENT", "short_name": "BG4J", "snr": 9.6, "status": null, "telemetry": {"air_util_tx": 0.585, "battery_level": 89, "channel_utilization": 33.07, "uptime_seconds": 48, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 697, "long_name": "Gold Pine", "next_hop": 0, "num": "0x0dc79902", "position": {"altitude": 1547, "latitude": 33.325688, "location_source": "LOC_INTERNAL", "longitude": -106.36506, "time_offset_sec": 948}, "public_key_hex": "2a23f899fd519cfc08c957b9e8ba17540d56edeba480530a48947799fef05839", "role": "CLIENT", "short_name": "🌵", "snr": 5.6, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 5174, "long_name": "Black Salmon", "next_hop": 0, "num": "0x0df39b49", "position": {"altitude": 1507, "latitude": 33.799625, "location_source": "LOC_INTERNAL", "longitude": -107.081248, "time_offset_sec": 5294}, "public_key_hex": "67046c933ed949ce758a1b4dbe62e1465acdbd08f6610b21ab98606c2ebea11b", "role": "CLIENT", "short_name": "BC24", "snr": 4.36, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.75, "iaq": 52, "relative_humidity": 53.83, "temperature": 22.67}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2507, "long_name": "Silver Ridge NM2PJ", "next_hop": 0, "num": "0x0e466af3", "position": {"altitude": 1161, "latitude": 32.844045, "location_source": "LOC_INTERNAL", "longitude": -106.934913, "time_offset_sec": 2662}, "public_key_hex": "3c7c399e35477b4b00daa1a6fbe53a9f867b0d2911654098069329b3651c3f01", "role": "ROUTER", "short_name": "SLVY", "snr": 4.23, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.005, "battery_level": 87, "channel_utilization": 15.46, "uptime_seconds": 63106, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1105, "long_name": "Red Adder", "next_hop": 142, "num": "0x0e73347a", "position": {"altitude": 1713, "latitude": 32.128782, "location_source": "LOC_INTERNAL", "longitude": -107.197526, "time_offset_sec": 1241}, "public_key_hex": "fb59e2604d8f8506f13a00e620f3ac7213794c0ec66b9cca1ff18487eba8c3f5", "role": "CLIENT", "short_name": "RZM6", "snr": 11.91, "status": null, "telemetry": {"air_util_tx": 0.899, "battery_level": 34, "channel_utilization": 30.04, "uptime_seconds": 348824, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5269, "long_name": "Smooth Squirrel", "next_hop": 0, "num": "0x0e74c170", "position": {"altitude": 1173, "latitude": 33.375622, "location_source": "LOC_INTERNAL", "longitude": -107.383683, "time_offset_sec": 5279}, "public_key_hex": "5d76465b574f6a8605a021ad5297582546fa352876f250cb0e6a43c4f3d99263", "role": "CLIENT", "short_name": "🦌", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.626, "battery_level": 101, "channel_utilization": 12.18, "uptime_seconds": 192410, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 7, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 195, "long_name": "Sharp Mustang", "next_hop": 189, "num": "0x0f969f92", "position": {"altitude": 1326, "latitude": 33.071155, "location_source": "LOC_INTERNAL", "longitude": -107.28847, "time_offset_sec": 275}, "public_key_hex": "203b997abe373c62563240a3f88aa604ec0b7f226eaec9d44687c05ee3e099ca", "role": "CLIENT", "short_name": "SCYS", "snr": 3.98, "status": null, "telemetry": {"air_util_tx": 0.509, "battery_level": 88, "channel_utilization": 22.37, "uptime_seconds": 94713, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 556, "long_name": "Lost Falcon", "next_hop": 10, "num": "0x1068b9c3", "position": {"altitude": 1639, "latitude": 32.08854, "location_source": "LOC_INTERNAL", "longitude": -107.420525, "time_offset_sec": 835}, "public_key_hex": "403a60ad42e3e05848cda956b3572c2b6d9716487ee0c8ea9f29b03516d095f0", "role": "CLIENT", "short_name": "L2J5", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.19, "iaq": 94, "relative_humidity": 74.04, "temperature": 9.55}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2179, "long_name": "Brave Shark", "next_hop": 0, "num": "0x106e1aa9", "position": {"altitude": 1516, "latitude": 33.052381, "location_source": "LOC_INTERNAL", "longitude": -106.368485, "time_offset_sec": 2181}, "public_key_hex": "fe2b739b59a8e775f040b609fddd7c541e43f6e02b7b752e1dc6e4fb5481da32", "role": "CLIENT", "short_name": "BQFR", "snr": 8.56, "status": null, "telemetry": {"air_util_tx": 0.393, "battery_level": 55, "channel_utilization": 12.42, "uptime_seconds": 125558, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1770, "long_name": "Black Turtle", "next_hop": 0, "num": "0x10fdbb7c", "position": {"altitude": 1120, "latitude": 32.111339, "location_source": "LOC_INTERNAL", "longitude": -107.662349, "time_offset_sec": 2036}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦂", "snr": 7.83, "status": null, "telemetry": {"air_util_tx": 0.72, "battery_level": 79, "channel_utilization": 19.07, "uptime_seconds": 53503, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.23, "iaq": 37, "relative_humidity": 78.84, "temperature": 19.85}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 903, "long_name": "Howling Iguana", "next_hop": 26, "num": "0x114c8d0c", "position": {"altitude": 1474, "latitude": 33.869401, "location_source": "LOC_INTERNAL", "longitude": -108.620133, "time_offset_sec": 1079}, "public_key_hex": "953c0a6d8bf0ac6e8db0c8fd701f51841183ef8fb1ba16b109dc8290411d4549", "role": "CLIENT", "short_name": "HDW4", "snr": 10.98, "status": {"status": "running"}, "telemetry": {"air_util_tx": 2.014, "battery_level": 98, "channel_utilization": 17.04, "uptime_seconds": 38009, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1017.71, "iaq": 33, "relative_humidity": 62.97, "temperature": 21.51}, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 1820, "long_name": "Wild Eagle", "next_hop": 0, "num": "0x120b19a3", "position": {"altitude": 1391, "latitude": 33.611799, "location_source": "LOC_INTERNAL", "longitude": -107.006216, "time_offset_sec": 1904}, "public_key_hex": "4f7cac84044ada634f60e87e9a63e56ca1cb8a73f76306f0627c8078063c568e", "role": "ROUTER", "short_name": "WPE2", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.756, "battery_level": 97, "channel_utilization": 3.27, "uptime_seconds": 12783, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 3752, "long_name": "Red Mole K17PL", "next_hop": 126, "num": "0x12384425", "position": null, "public_key_hex": "a590327c39a8811f4b495c11c1303361da64452685d896d3b6ed9f1adfc060f8", "role": "CLIENT", "short_name": "🐢", "snr": 5.84, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.333, "battery_level": 12, "channel_utilization": 18.99, "uptime_seconds": 53482, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 6541, "long_name": "Tall Squirrel", "next_hop": 79, "num": "0x126ceb2c", "position": {"altitude": 1375, "latitude": 32.841802, "location_source": "LOC_INTERNAL", "longitude": -107.427944, "time_offset_sec": 6724}, "public_key_hex": "", "role": "CLIENT", "short_name": "TQM3", "snr": 2.55, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.98, "iaq": 111, "relative_humidity": 89.08, "temperature": 33.48}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4162, "long_name": "Steel Turtle", "next_hop": 0, "num": "0x12f76d86", "position": {"altitude": 1208, "latitude": 32.892447, "location_source": "LOC_INTERNAL", "longitude": -107.118326, "time_offset_sec": 4430}, "public_key_hex": "", "role": "CLIENT", "short_name": "S5F4", "snr": 10.89, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.292, "battery_level": 53, "channel_utilization": 12.83, "uptime_seconds": 2678, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 224, "long_name": "Silent Raven", "next_hop": 201, "num": "0x130f2fb7", "position": {"altitude": 1413, "latitude": 32.938113, "location_source": "LOC_INTERNAL", "longitude": -107.179085, "time_offset_sec": 225}, "public_key_hex": "a7efa3ea6ddaa0a40bcc0e12b957c55331369acee2899c0a0d2b8028d2f1e170", "role": "CLIENT", "short_name": "SZWZ", "snr": 6.34, "status": null, "telemetry": {"air_util_tx": 0.329, "battery_level": 79, "channel_utilization": 19.84, "uptime_seconds": 40527, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAG", "last_heard_offset_sec": 3447, "long_name": "Whispering Adder", "next_hop": 23, "num": "0x13293edd", "position": {"altitude": 1269, "latitude": 33.452132, "location_source": "LOC_INTERNAL", "longitude": -106.69832, "time_offset_sec": 3648}, "public_key_hex": "f52d28036520d2bcaa71bd4c3cf8705bb41aec203e95eb97240a7d90ff224378", "role": "CLIENT", "short_name": "W53C", "snr": 6.71, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.102, "battery_level": 25, "channel_utilization": 9.98, "uptime_seconds": 14375, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 5126, "long_name": "Shady Crane", "next_hop": 4, "num": "0x13b2ba9f", "position": {"altitude": 1406, "latitude": 32.818484, "location_source": "LOC_INTERNAL", "longitude": -107.401426, "time_offset_sec": 5424}, "public_key_hex": "b9881b3aa54bb216618fcd9667532add5f2de8020a7c9143e7a160145af4f103", "role": "CLIENT", "short_name": "SP0D", "snr": 6.85, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2395, "long_name": "River Viper", "next_hop": 0, "num": "0x14758ac9", "position": {"altitude": 1254, "latitude": 32.825863, "location_source": "LOC_INTERNAL", "longitude": -107.703222, "time_offset_sec": 2430}, "public_key_hex": "430af60798f22e409d7c4f8fc662539c25cd09c16b5a3e3eb987fc46c0469167", "role": "CLIENT", "short_name": "R3ZD", "snr": 11.9, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 580, "long_name": "Blue Mesa", "next_hop": 155, "num": "0x14a5204d", "position": {"altitude": 1187, "latitude": 33.093378, "location_source": "LOC_INTERNAL", "longitude": -106.854458, "time_offset_sec": 613}, "public_key_hex": "6358368d5865bb7758b9834d3512457be607092ed2e299be69da713780342f33", "role": "CLIENT", "short_name": "BTTY", "snr": 6.51, "status": null, "telemetry": {"air_util_tx": 2.628, "battery_level": 40, "channel_utilization": 22.99, "uptime_seconds": 87527, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 1003, "long_name": "Old Mamba", "next_hop": 0, "num": "0x1564261a", "position": {"altitude": 1666, "latitude": 33.943138, "location_source": "LOC_INTERNAL", "longitude": -107.171092, "time_offset_sec": 1202}, "public_key_hex": "54240722fd0126e61a3882ec9c403788f496ef6ba21ddabd23d8252013b10059", "role": "CLIENT", "short_name": "OZKL", "snr": 8.48, "status": {"status": "low-batt"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 12975, "long_name": "Whispering Bear", "next_hop": 205, "num": "0x1615bf36", "position": {"altitude": 738, "latitude": 33.554574, "location_source": "LOC_INTERNAL", "longitude": -106.641352, "time_offset_sec": 13150}, "public_key_hex": "7a01f1d9fc0dc8be20d7f267aa9c9abe5d2ed23c5173372b9536c2b8b2397827", "role": "CLIENT", "short_name": "WBL0", "snr": 9.7, "status": null, "telemetry": {"air_util_tx": 0.222, "battery_level": 50, "channel_utilization": 7.5, "uptime_seconds": 23756, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.22, "iaq": 28, "relative_humidity": 73.84, "temperature": 37.27}, "hops_away": 3, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 2565, "long_name": "Gold Yucca", "next_hop": 248, "num": "0x1644d8bc", "position": {"altitude": 1764, "latitude": 32.887885, "location_source": "LOC_INTERNAL", "longitude": -106.298752, "time_offset_sec": 2844}, "public_key_hex": "247e5e5b436da4f5db45db12f0c373deb057d05621ca70d77233b4a279d3b89a", "role": "CLIENT", "short_name": "GL20", "snr": 0.4, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2941, "long_name": "Tiny Gecko", "next_hop": 0, "num": "0x16b3cb8d", "position": {"altitude": 1348, "latitude": 33.724181, "location_source": "LOC_INTERNAL", "longitude": -107.174284, "time_offset_sec": 2941}, "public_key_hex": "941fd5f96cf769a08fbff7f3215ca75ad0a9b18fdf8cb78dc29cd40bb82d2150", "role": "CLIENT", "short_name": "TO7I", "snr": 7.42, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 871, "long_name": "Steel Trout", "next_hop": 175, "num": "0x1711f35f", "position": null, "public_key_hex": "139c29b1aa2cb64879370ed42b478b94a9af417661a7cc02f8cdec0ad17845fc", "role": "TRACKER", "short_name": "S7DX", "snr": 4.27, "status": null, "telemetry": {"air_util_tx": 0.645, "battery_level": 44, "channel_utilization": 3.4, "uptime_seconds": 160815, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3326, "long_name": "Red Wolf", "next_hop": 233, "num": "0x177be91b", "position": null, "public_key_hex": "55adf85e42585ebe03f0e32cb630e12472993b9fd0e7052572d843e0eba6d52f", "role": "CLIENT", "short_name": "RHM4", "snr": 0.38, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 1462, "long_name": "New Aspen", "next_hop": 0, "num": "0x17d34d2f", "position": null, "public_key_hex": "bbf436b42ee2abed84219c04516f33a62e5393f50b6b849495bc8c0bbb053cf3", "role": "CLIENT", "short_name": "NHXL", "snr": 4.01, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4776, "long_name": "River Seal", "next_hop": 33, "num": "0x17f3a94a", "position": {"altitude": 1664, "latitude": 32.860698, "location_source": "LOC_INTERNAL", "longitude": -106.353502, "time_offset_sec": 5072}, "public_key_hex": "316b919aac604507432d927ff4bc7458e3852b85105a918fd3ddfc41549f488f", "role": "CLIENT", "short_name": "R7R3", "snr": 5.17, "status": null, "telemetry": {"air_util_tx": 1.346, "battery_level": 17, "channel_utilization": 4.74, "uptime_seconds": 30218, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": {"barometric_pressure": 1021.6, "iaq": 17, "relative_humidity": 65.68, "temperature": 24.24}, "hops_away": 3, "hw_model": "RAK3401", "last_heard_offset_sec": 350, "long_name": "Hidden Pine", "next_hop": 251, "num": "0x180443f5", "position": {"altitude": 1319, "latitude": 32.404289, "location_source": "LOC_INTERNAL", "longitude": -106.934617, "time_offset_sec": 508}, "public_key_hex": "9fadbb08623c9913b28bd7a458638bdbc6f8f7e31181b9a121ffcde8b20bbc1e", "role": "CLIENT", "short_name": "🦅", "snr": 0.66, "status": null, "telemetry": {"air_util_tx": 0.806, "battery_level": 33, "channel_utilization": 12.19, "uptime_seconds": 95281, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 142, "long_name": "Sleepy Bison", "next_hop": 0, "num": "0x18dd2de3", "position": {"altitude": 1129, "latitude": 33.62816, "location_source": "LOC_INTERNAL", "longitude": -107.024573, "time_offset_sec": 313}, "public_key_hex": "", "role": "ROUTER", "short_name": "SS7V", "snr": 6.53, "status": null, "telemetry": {"air_util_tx": 0.689, "battery_level": 44, "channel_utilization": 2.6, "uptime_seconds": 2877, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.99, "iaq": 73, "relative_humidity": 78.79, "temperature": 28.95}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 567, "long_name": "Fast Turtle", "next_hop": 0, "num": "0x18ef7a96", "position": null, "public_key_hex": "0ec2598567e0e8cd092b276487781df7399307ff10d12964f08b6f99ee76e68a", "role": "CLIENT", "short_name": "FUHT", "snr": 1.18, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1075, "long_name": "Canyon Coyote", "next_hop": 0, "num": "0x1939a174", "position": {"altitude": 1239, "latitude": 32.965874, "location_source": "LOC_INTERNAL", "longitude": -106.002274, "time_offset_sec": 1084}, "public_key_hex": "bea2d386101b64e5fe16904ee43db809e962b2edc92d583d3bce4cf469c9c770", "role": "CLIENT", "short_name": "C42Q", "snr": 4.07, "status": null, "telemetry": {"air_util_tx": 0.587, "battery_level": 36, "channel_utilization": 29.37, "uptime_seconds": 161621, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7533, "long_name": "Brave Phoenix", "next_hop": 161, "num": "0x19606048", "position": {"altitude": 1207, "latitude": 32.520481, "location_source": "LOC_INTERNAL", "longitude": -107.707377, "time_offset_sec": 7611}, "public_key_hex": "", "role": "CLIENT", "short_name": "B2EE", "snr": 5.18, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.51, "iaq": 81, "relative_humidity": 41.63, "temperature": 18.29}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5429, "long_name": "Found Ridge", "next_hop": 95, "num": "0x1967fd5f", "position": {"altitude": 1367, "latitude": 32.374835, "location_source": "LOC_INTERNAL", "longitude": -106.844832, "time_offset_sec": 5572}, "public_key_hex": "1a0dbcfd50865ed35a464d947bebb1364c0ede8ae6fabca77ee2ee5bc7a22db2", "role": "CLIENT", "short_name": "FMOK", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.306, "battery_level": 71, "channel_utilization": 22.2, "uptime_seconds": 125119, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1001.64, "iaq": 86, "relative_humidity": 7.71, "temperature": 31.37}, "hops_away": 2, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 1873, "long_name": "Loud Marmot KX8YO", "next_hop": 148, "num": "0x19c898a0", "position": {"altitude": 1783, "latitude": 32.405528, "location_source": "LOC_INTERNAL", "longitude": -107.106416, "time_offset_sec": 2160}, "public_key_hex": "954f20cebbcc2abe846c90d8fd01106873d22cd7aa47dc862c5cc75981a81729", "role": "CLIENT", "short_name": "L7SZ", "snr": 6.87, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.397, "battery_level": 90, "channel_utilization": 14.17, "uptime_seconds": 6055, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7996, "long_name": "Hidden Mesa", "next_hop": 0, "num": "0x1a39a908", "position": {"altitude": 1371, "latitude": 32.42213, "location_source": "LOC_INTERNAL", "longitude": -107.093866, "time_offset_sec": 8025}, "public_key_hex": "443b5cca642cee1c46ca97cbcbe87b55a133c2d547d62c5487bc7c3af01bb1a1", "role": "CLIENT", "short_name": "HSZR", "snr": 8.5, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5421, "long_name": "Brave Shark", "next_hop": 90, "num": "0x1b56b90e", "position": {"altitude": 1441, "latitude": 32.889426, "location_source": "LOC_INTERNAL", "longitude": -106.537159, "time_offset_sec": 5704}, "public_key_hex": "27480efc8066b57abc0b7b949808894010ef10c105a2e88d289f5d040df21976", "role": "CLIENT", "short_name": "🌊", "snr": 9.72, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.202, "battery_level": 59, "channel_utilization": 23.59, "uptime_seconds": 101999, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2302, "long_name": "Happy Gecko", "next_hop": 0, "num": "0x1b6bf44c", "position": null, "public_key_hex": "7206286d9afd3155730837948ec0038876c333330cbd131ef96209e7b277d647", "role": "CLIENT", "short_name": "H15N", "snr": 0.62, "status": null, "telemetry": {"air_util_tx": 2.207, "battery_level": 93, "channel_utilization": 1.79, "uptime_seconds": 122358, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 7, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3119, "long_name": "Floating Lion", "next_hop": 202, "num": "0x1b713583", "position": null, "public_key_hex": "1d6bd24a1414da9f59d73703954239bb4b8cc827bae76dbaa09d01ba52452f85", "role": "CLIENT", "short_name": "FTPZ", "snr": 5.03, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.397, "battery_level": 68, "channel_utilization": 8.09, "uptime_seconds": 241505, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 2382, "long_name": "Short Raven", "next_hop": 0, "num": "0x1c0560ca", "position": {"altitude": 1464, "latitude": 34.21151, "location_source": "LOC_INTERNAL", "longitude": -107.210931, "time_offset_sec": 2445}, "public_key_hex": "dc645a4e861a9fc93aa0bf8005e0684ab7cf2f24058726eb2a214c97d68ca2fb", "role": "CLIENT", "short_name": "🐺", "snr": 11.22, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.24, "battery_level": 38, "channel_utilization": 15.58, "uptime_seconds": 50239, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 5657, "long_name": "Wandering Wolf", "next_hop": 0, "num": "0x1c36dc90", "position": {"altitude": 1147, "latitude": 32.250805, "location_source": "LOC_INTERNAL", "longitude": -106.884952, "time_offset_sec": 5947}, "public_key_hex": "f1d21755ecb8465c3c00f424d40aace7520544dc1102bf2f6ced1eae30989314", "role": "CLIENT", "short_name": "W2SJ", "snr": 8.52, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.01, "battery_level": 45, "channel_utilization": 20.36, "uptime_seconds": 18959, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.6, "iaq": 83, "relative_humidity": 52.43, "temperature": 23.21}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 133, "long_name": "Desert Mesa", "next_hop": 0, "num": "0x1cefe3cb", "position": {"altitude": 1311, "latitude": 33.656763, "location_source": "LOC_INTERNAL", "longitude": -107.626614, "time_offset_sec": 301}, "public_key_hex": "69dc1000761b943914f461621060293f761303807ce4a9178729eda65e9cdcff", "role": "CLIENT", "short_name": "DTI2", "snr": 0.02, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.621, "battery_level": 16, "channel_utilization": 9.62, "uptime_seconds": 45293, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1807, "long_name": "Sunny Hawk", "next_hop": 0, "num": "0x1d722068", "position": {"altitude": 812, "latitude": 32.577717, "location_source": "LOC_INTERNAL", "longitude": -107.408054, "time_offset_sec": 2034}, "public_key_hex": "beabbf8bc9b06cea1c5c4a08c7888084eb3929c37760a670764fd6cadd7295a6", "role": "CLIENT", "short_name": "🦉", "snr": 9.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1012.02, "iaq": 63, "relative_humidity": 96.05, "temperature": 20.13}, "hops_away": 1, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 161, "long_name": "Frozen Cedar", "next_hop": 195, "num": "0x1d8163c2", "position": {"altitude": 1238, "latitude": 32.869186, "location_source": "LOC_INTERNAL", "longitude": -107.651904, "time_offset_sec": 416}, "public_key_hex": "91f0e28d288761bdb1f304ebe33f2d5fd7d9df9d44cb6a2f065b8e47e5fc7ac3", "role": "CLIENT", "short_name": "FKIT", "snr": 6.32, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.164, "battery_level": 41, "channel_utilization": 13.01, "uptime_seconds": 43618, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 1639, "long_name": "Canyon Owl", "next_hop": 27, "num": "0x1d82b94f", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "C7ZK", "snr": 9.18, "status": null, "telemetry": {"air_util_tx": 1.023, "battery_level": 48, "channel_utilization": 14.43, "uptime_seconds": 38354, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4184, "long_name": "Smooth Mole", "next_hop": 0, "num": "0x1daca15d", "position": {"altitude": 1185, "latitude": 33.384658, "location_source": "LOC_INTERNAL", "longitude": -107.289712, "time_offset_sec": 4264}, "public_key_hex": "46921f68b88565974b589cac24f86cfe93ea100dbe7022089f71df082bb333cd", "role": "CLIENT_MUTE", "short_name": "🌙", "snr": 2.51, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.77, "iaq": 7, "relative_humidity": 69.95, "temperature": 30.02}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 9674, "long_name": "Storm Mesa", "next_hop": 225, "num": "0x1de48b4f", "position": {"altitude": 1496, "latitude": 33.354591, "location_source": "LOC_INTERNAL", "longitude": -106.570413, "time_offset_sec": 9772}, "public_key_hex": "8a3610408b8070eb103d779cf5e9d2900d4cb30d84598400cd21a9943b9d7df9", "role": "CLIENT", "short_name": "SY02", "snr": 3.07, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 1.042, "battery_level": 35, "channel_utilization": 43.64, "uptime_seconds": 199707, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 480, "long_name": "Brave Pike", "next_hop": 0, "num": "0x1e7ed5d4", "position": {"altitude": 798, "latitude": 33.470996, "location_source": "LOC_INTERNAL", "longitude": -107.416067, "time_offset_sec": 550}, "public_key_hex": "bf0171d6339e8eb928b8ba93385a22027a661d1a8b29f7e5984f28e309fd6d34", "role": "CLIENT", "short_name": "B2ZX", "snr": 6.14, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.361, "battery_level": 92, "channel_utilization": 8.47, "uptime_seconds": 190763, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2556, "long_name": "Frosty Pine", "next_hop": 0, "num": "0x1ea77801", "position": {"altitude": 1528, "latitude": 32.418991, "location_source": "LOC_INTERNAL", "longitude": -106.093586, "time_offset_sec": 2772}, "public_key_hex": "0dea045571181b0214c5f3e0b31d33f7052cd424ecd71ffe705af811d3b25bcb", "role": "ROUTER", "short_name": "FOSC", "snr": 6.35, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "CROWPANEL", "last_heard_offset_sec": 2235, "long_name": "Lunar Whale", "next_hop": 0, "num": "0x1f6cc1bc", "position": {"altitude": 899, "latitude": 33.411689, "location_source": "LOC_INTERNAL", "longitude": -107.405901, "time_offset_sec": 2430}, "public_key_hex": "2736e197f4f6da0a2ee69c1e3a51ee35629d2867e1d3c34b4227d235b686a81f", "role": "CLIENT", "short_name": "LDSY", "snr": 10.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 8864, "long_name": "Loud Marmot", "next_hop": 0, "num": "0x1fdf7664", "position": {"altitude": 1536, "latitude": 33.654204, "location_source": "LOC_INTERNAL", "longitude": -107.187343, "time_offset_sec": 8957}, "public_key_hex": "f010cbc4e81263a439bc4f2716cb167d1714c92abb0a23c491b3602c772d383a", "role": "TAK", "short_name": "🌲", "snr": 6.38, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.972, "battery_level": 58, "channel_utilization": 19.6, "uptime_seconds": 52550, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 9543, "long_name": "Smooth Pike", "next_hop": 0, "num": "0x201cbee0", "position": {"altitude": 1348, "latitude": 33.759616, "location_source": "LOC_INTERNAL", "longitude": -106.64923, "time_offset_sec": 9837}, "public_key_hex": "c32890238040eeb56215ef4b8e9e5c101015db7ca36b004f121a43c7ff899822", "role": "CLIENT_HIDDEN", "short_name": "SQK3", "snr": 5.33, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 4229, "long_name": "Shady Colt", "next_hop": 168, "num": "0x206963c3", "position": {"altitude": 1118, "latitude": 33.832162, "location_source": "LOC_INTERNAL", "longitude": -107.073608, "time_offset_sec": 4391}, "public_key_hex": "e1315e6691a0d99979d84e54f6500c2317aaae905fb491f4dc0222f8703aca42", "role": "CLIENT", "short_name": "S3EW", "snr": 8.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 12150, "long_name": "Forest Phoenix", "next_hop": 0, "num": "0x218f5088", "position": {"altitude": 1106, "latitude": 32.897549, "location_source": "LOC_INTERNAL", "longitude": -107.531896, "time_offset_sec": 12292}, "public_key_hex": "2d66dd4145234b2b57ea024df39279c239e97431db054756de54678b20f5da93", "role": "SENSOR", "short_name": "FW9Z", "snr": 9.15, "status": null, "telemetry": {"air_util_tx": 0.139, "battery_level": 79, "channel_utilization": 17.85, "uptime_seconds": 124412, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1604, "long_name": "Sunny Mustang", "next_hop": 0, "num": "0x237435df", "position": {"altitude": 1720, "latitude": 33.689395, "location_source": "LOC_INTERNAL", "longitude": -106.620763, "time_offset_sec": 1795}, "public_key_hex": "de8c3a94d4068daa81f917c544b401537ede4f41ee908cfcfab3b8a7cbb077cf", "role": "CLIENT", "short_name": "SXDZ", "snr": 3.47, "status": null, "telemetry": {"air_util_tx": 0.434, "battery_level": 26, "channel_utilization": 7.32, "uptime_seconds": 78190, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1284, "long_name": "Silver Dolphin", "next_hop": 113, "num": "0x23f54207", "position": {"altitude": 866, "latitude": 33.871469, "location_source": "LOC_INTERNAL", "longitude": -107.534801, "time_offset_sec": 1437}, "public_key_hex": "306a6a50b4b716250b255ea086314fc4ce70eb71e959107baa0284f4920f6822", "role": "ROUTER", "short_name": "S2F3", "snr": 8.89, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 759, "long_name": "Mountain Cactus", "next_hop": 0, "num": "0x23f8dd43", "position": {"altitude": 1548, "latitude": 33.293569, "location_source": "LOC_INTERNAL", "longitude": -107.692603, "time_offset_sec": 954}, "public_key_hex": "ad1317a5f42db4e6d4adc9505d7116df9f9a2f3063134627c22e8c42d8d0b861", "role": "CLIENT", "short_name": "MWI3", "snr": 0.4, "status": null, "telemetry": {"air_util_tx": 0.832, "battery_level": 85, "channel_utilization": 3.39, "uptime_seconds": 19402, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 3147, "long_name": "Floating Cougar", "next_hop": 42, "num": "0x2483da22", "position": {"altitude": 1574, "latitude": 34.618557, "location_source": "LOC_INTERNAL", "longitude": -106.795298, "time_offset_sec": 3366}, "public_key_hex": "70743e0a888a095156f73537ad455490bbd8c3f542ed955a9cb39333730d0a7d", "role": "CLIENT", "short_name": "FXM3", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.507, "battery_level": 63, "channel_utilization": 5.56, "uptime_seconds": 102994, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1063, "long_name": "Silent Seal", "next_hop": 0, "num": "0x2543fee0", "position": null, "public_key_hex": "437a8bf578d601494b638780ceb552c36172d1286d5a73133983be72837575f7", "role": "CLIENT", "short_name": "SO11", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.017, "battery_level": 28, "channel_utilization": 6.46, "uptime_seconds": 17298, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 796, "long_name": "Sunny Crow", "next_hop": 0, "num": "0x2563d10d", "position": {"altitude": 1406, "latitude": 32.882114, "location_source": "LOC_INTERNAL", "longitude": -107.561773, "time_offset_sec": 860}, "public_key_hex": "9239b3f0c406543fd79d7fc60add1f54c6b1dcf6f439976148be084955f2e6cf", "role": "ROUTER", "short_name": "S4BI", "snr": 9.54, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 5393, "long_name": "Sky Cobra", "next_hop": 38, "num": "0x256cfdf9", "position": {"altitude": 1230, "latitude": 33.35275, "location_source": "LOC_INTERNAL", "longitude": -106.720733, "time_offset_sec": 5559}, "public_key_hex": "66abd534d8050bd3a9b5e673958b42dd8be93dcc7b7bb3df3cddd55c54e6ddff", "role": "CLIENT", "short_name": "🦉", "snr": 1.45, "status": null, "telemetry": {"air_util_tx": 0.593, "battery_level": 79, "channel_utilization": 14.33, "uptime_seconds": 30967, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.13, "iaq": 0, "relative_humidity": 66.62, "temperature": 28.28}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 7681, "long_name": "Found Sage", "next_hop": 8, "num": "0x2947046c", "position": {"altitude": 995, "latitude": 32.620757, "location_source": "LOC_INTERNAL", "longitude": -107.727416, "time_offset_sec": 7747}, "public_key_hex": "", "role": "CLIENT", "short_name": "FX3S", "snr": 9.15, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.41, "iaq": 37, "relative_humidity": 57.37, "temperature": 9.65}, "hops_away": 0, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 2786, "long_name": "Stone Cobra", "next_hop": 0, "num": "0x294c2dba", "position": {"altitude": 1400, "latitude": 32.83756, "location_source": "LOC_INTERNAL", "longitude": -107.65565, "time_offset_sec": 2822}, "public_key_hex": "7c0192e6eb7bad02522f3afaeec7e41451816fb5d3c3c96eabc35c66dba69015", "role": "CLIENT_MUTE", "short_name": "SM0Q", "snr": 5.47, "status": null, "telemetry": {"air_util_tx": 0.835, "battery_level": 33, "channel_utilization": 8.37, "uptime_seconds": 48228, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 3855, "long_name": "Wandering Bronco", "next_hop": 0, "num": "0x29ca8824", "position": {"altitude": 1308, "latitude": 32.768325, "location_source": "LOC_INTERNAL", "longitude": -107.304989, "time_offset_sec": 4107}, "public_key_hex": "0e293c8e21f1632490b3bf622b3034f74bb3f48300640d0f085c41e49cdee87f", "role": "CLIENT", "short_name": "WFFI", "snr": 4.79, "status": null, "telemetry": {"air_util_tx": 0.462, "battery_level": 39, "channel_utilization": 2.79, "uptime_seconds": 143329, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 7594, "long_name": "Happy Juniper", "next_hop": 0, "num": "0x2a38022d", "position": {"altitude": 1602, "latitude": 33.250885, "location_source": "LOC_INTERNAL", "longitude": -107.23156, "time_offset_sec": 7779}, "public_key_hex": "3e1c289337c793db000bbde022cf406494f9d412b35394570ac89f57652fd58d", "role": "CLIENT", "short_name": "HXCF", "snr": 6.13, "status": null, "telemetry": {"air_util_tx": 0.093, "battery_level": 101, "channel_utilization": 11.18, "uptime_seconds": 158807, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 370, "long_name": "Steel Hawk", "next_hop": 83, "num": "0x2a45d990", "position": {"altitude": 1416, "latitude": 32.684826, "location_source": "LOC_INTERNAL", "longitude": -107.454352, "time_offset_sec": 538}, "public_key_hex": "41e079053e96130355138dce101fb501f67a2371b6392ccd446936d4982687cb", "role": "CLIENT", "short_name": "S9DH", "snr": 3.49, "status": null, "telemetry": {"air_util_tx": 1.05, "battery_level": 60, "channel_utilization": 11.14, "uptime_seconds": 103663, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 5107, "long_name": "Blue Bison", "next_hop": 0, "num": "0x2af49b14", "position": {"altitude": 1731, "latitude": 32.352734, "location_source": "LOC_INTERNAL", "longitude": -107.333148, "time_offset_sec": 5235}, "public_key_hex": "4c6ce116048de8bce3727736799275a78ae798d97a2ac3f8af06c121c05a1681", "role": "TRACKER", "short_name": "B3XZ", "snr": 9.17, "status": null, "telemetry": {"air_util_tx": 0.319, "battery_level": 66, "channel_utilization": 11.87, "uptime_seconds": 25973, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 8236, "long_name": "Happy Hawk", "next_hop": 0, "num": "0x2b03792b", "position": null, "public_key_hex": "3868199e2f9abe0163cb67380ce20d0c601cbe73763ad6f223a4bfff063cab38", "role": "CLIENT", "short_name": "🌙", "snr": 5.17, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.609, "battery_level": 95, "channel_utilization": 20.61, "uptime_seconds": 72195, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2541, "long_name": "Smooth Adder", "next_hop": 185, "num": "0x2c7d0593", "position": {"altitude": 1178, "latitude": 32.166593, "location_source": "LOC_INTERNAL", "longitude": -106.773143, "time_offset_sec": 2622}, "public_key_hex": "9a0cb934580a5f33cbcd3de39a9f6ab5012c1889e33fc226dc9f91326d26fd14", "role": "CLIENT", "short_name": "🌲", "snr": 9.33, "status": null, "telemetry": {"air_util_tx": 1.019, "battery_level": 93, "channel_utilization": 6.5, "uptime_seconds": 63099, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 961, "long_name": "Drifting Pike", "next_hop": 0, "num": "0x2d0e8c42", "position": {"altitude": 1532, "latitude": 34.734724, "location_source": "LOC_INTERNAL", "longitude": -107.084259, "time_offset_sec": 1026}, "public_key_hex": "ad3f772db064508f43eb82bb8e958d4d8d4fab3308c5a5d194a696ba2fc12052", "role": "CLIENT", "short_name": "DI45", "snr": 5.8, "status": null, "telemetry": {"air_util_tx": 0.122, "battery_level": 58, "channel_utilization": 14.53, "uptime_seconds": 227656, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.68, "iaq": 14, "relative_humidity": 85.82, "temperature": 22.23}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 1964, "long_name": "Loud Shark", "next_hop": 0, "num": "0x2e4cc1e6", "position": {"altitude": 1213, "latitude": 34.237167, "location_source": "LOC_INTERNAL", "longitude": -107.199683, "time_offset_sec": 2091}, "public_key_hex": "5c833a6036236e58f63965ce3ab3256b3bc170f31928512f7e1cfabca03a4cbd", "role": "CLIENT", "short_name": "LDFT", "snr": 10.85, "status": null, "telemetry": {"air_util_tx": 0.153, "battery_level": 73, "channel_utilization": 1.07, "uptime_seconds": 132926, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 126, "long_name": "Red Iguana", "next_hop": 116, "num": "0x2e68f619", "position": {"altitude": 1832, "latitude": 32.923994, "location_source": "LOC_INTERNAL", "longitude": -106.657373, "time_offset_sec": 329}, "public_key_hex": "", "role": "CLIENT", "short_name": "R7ZB", "snr": 4.76, "status": null, "telemetry": {"air_util_tx": 0.338, "battery_level": 24, "channel_utilization": 3.24, "uptime_seconds": 12781, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.38, "iaq": 30, "relative_humidity": 39.21, "temperature": 27.25}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 12488, "long_name": "Misty Stag", "next_hop": 113, "num": "0x2ed4a8e4", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "MS2B", "snr": 0.46, "status": null, "telemetry": {"air_util_tx": 0.209, "battery_level": 73, "channel_utilization": 4.95, "uptime_seconds": 21300, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 706, "long_name": "Happy Crane", "next_hop": 0, "num": "0x2f190630", "position": {"altitude": 1326, "latitude": 32.193645, "location_source": "LOC_INTERNAL", "longitude": -108.049345, "time_offset_sec": 819}, "public_key_hex": "a3fbfff1a8985ff8dfc323d60115bfa7cf7b900797769c2108ee2aedafaa1280", "role": "CLIENT", "short_name": "H726", "snr": 0.66, "status": null, "telemetry": {"air_util_tx": 0.209, "battery_level": 80, "channel_utilization": 20.01, "uptime_seconds": 393994, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 358, "long_name": "Silent Mustang K14IJ", "next_hop": 0, "num": "0x2f57c805", "position": {"altitude": 1410, "latitude": 33.695552, "location_source": "LOC_INTERNAL", "longitude": -107.067123, "time_offset_sec": 485}, "public_key_hex": "16d1f52621f47df16b03a20e6523299f5efe1f4f95b7aeb10457858966ed2a82", "role": "CLIENT", "short_name": "SONZ", "snr": 2.78, "status": null, "telemetry": {"air_util_tx": 0.535, "battery_level": 10, "channel_utilization": 16.78, "uptime_seconds": 11456, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.38, "iaq": 29, "relative_humidity": 35.93, "temperature": 35.08}, "hops_away": 1, "hw_model": "XIAO_NRF52_KIT", "last_heard_offset_sec": 228, "long_name": "Wild Bear", "next_hop": 190, "num": "0x2f8120b8", "position": {"altitude": 1569, "latitude": 32.915073, "location_source": "LOC_INTERNAL", "longitude": -107.459333, "time_offset_sec": 339}, "public_key_hex": "1501e8e5e651653894574d21e3af92c8cc152d5f5f2b361a57f1566a27743f4a", "role": "CLIENT", "short_name": "🐢", "snr": 8.88, "status": null, "telemetry": {"air_util_tx": 0.378, "battery_level": 40, "channel_utilization": 19.12, "uptime_seconds": 32255, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1841, "long_name": "Sneaky Owl", "next_hop": 155, "num": "0x2fb30c88", "position": null, "public_key_hex": "ed99fa218967e31fed4ff0766c016c3449d50055d1106ad503e8b9e2cdfc2b41", "role": "CLIENT", "short_name": "SGO4", "snr": 0.44, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.131, "battery_level": 96, "channel_utilization": 16.88, "uptime_seconds": 13908, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4212, "long_name": "Roving Squirrel", "next_hop": 62, "num": "0x2fe8f471", "position": {"altitude": 1083, "latitude": 33.046297, "location_source": "LOC_INTERNAL", "longitude": -107.497098, "time_offset_sec": 4479}, "public_key_hex": "fc5ac53f01bbc0951caf461a2cbf92eee367800881041f132b0d9f5623877728", "role": "CLIENT", "short_name": "RK7N", "snr": 3.45, "status": null, "telemetry": {"air_util_tx": 0.622, "battery_level": 89, "channel_utilization": 5.45, "uptime_seconds": 150733, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 4401, "long_name": "Sharp Cougar", "next_hop": 83, "num": "0x3117ad36", "position": null, "public_key_hex": "bcfa30fdae164084e2b1cfc1d6e17125c37a50480e57a3679c892fcce5a6168e", "role": "CLIENT", "short_name": "SNGJ", "snr": 9.12, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.231, "battery_level": 52, "channel_utilization": 21.0, "uptime_seconds": 23937, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 415, "long_name": "Gold Viper", "next_hop": 127, "num": "0x31bef7f0", "position": {"altitude": 1377, "latitude": 32.021334, "location_source": "LOC_INTERNAL", "longitude": -107.433857, "time_offset_sec": 697}, "public_key_hex": "5823cdb30133a932c4a81c9b454e608d811974d97cb709c70fb10fe5ab8345b9", "role": "CLIENT", "short_name": "G2ZX", "snr": 8.05, "status": null, "telemetry": {"air_util_tx": 0.521, "battery_level": 79, "channel_utilization": 3.1, "uptime_seconds": 216248, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3129, "long_name": "Iron Lynx", "next_hop": 100, "num": "0x3208623d", "position": {"altitude": 1701, "latitude": 33.189569, "location_source": "LOC_INTERNAL", "longitude": -107.539966, "time_offset_sec": 3383}, "public_key_hex": "892dea76c661963c99ed1f1195d4bea9c6fa5163475b5b35ce9b3a5fda420ec0", "role": "CLIENT_HIDDEN", "short_name": "IWY3", "snr": 9.49, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1096, "long_name": "Steel Doe", "next_hop": 0, "num": "0x322f653a", "position": {"altitude": 1245, "latitude": 32.930805, "location_source": "LOC_INTERNAL", "longitude": -105.812392, "time_offset_sec": 1218}, "public_key_hex": "d75ceb23a3e896da98903cdeb81a6c3b946cfe56199eee9556f836f52c857815", "role": "CLIENT", "short_name": "S78M", "snr": 4.93, "status": null, "telemetry": {"air_util_tx": 0.211, "battery_level": 33, "channel_utilization": 39.3, "uptime_seconds": 98053, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 2228, "long_name": "Brave Salmon", "next_hop": 0, "num": "0x3245221d", "position": {"altitude": 938, "latitude": 32.657217, "location_source": "LOC_INTERNAL", "longitude": -107.920171, "time_offset_sec": 2525}, "public_key_hex": "54d85c0b0ddf309d95b3acdf2d203c2fa39f500256cd6362250d4061d48c3b07", "role": "SENSOR", "short_name": "B4DI", "snr": 1.54, "status": null, "telemetry": {"air_util_tx": 0.182, "battery_level": 19, "channel_utilization": 8.93, "uptime_seconds": 87158, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2151, "long_name": "Storm Raven", "next_hop": 0, "num": "0x341f4692", "position": {"altitude": 1618, "latitude": 33.195702, "location_source": "LOC_INTERNAL", "longitude": -108.073506, "time_offset_sec": 2318}, "public_key_hex": "2bbc851a951cefd1b2b48a8b5b55e3263efd3ee5e6a33d131a19740950d46e16", "role": "CLIENT", "short_name": "SOEX", "snr": 12.0, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2915, "long_name": "Frosty Juniper", "next_hop": 0, "num": "0x345796f1", "position": {"altitude": 1374, "latitude": 33.249679, "location_source": "LOC_INTERNAL", "longitude": -107.230585, "time_offset_sec": 2978}, "public_key_hex": "97c9b6de4acbacfc11b88b0f29c6a37e85bbf4e5c1c0e225b0865d83a2992c16", "role": "CLIENT", "short_name": "🌙", "snr": 6.38, "status": null, "telemetry": {"air_util_tx": 0.502, "battery_level": 71, "channel_utilization": 19.85, "uptime_seconds": 117857, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 9084, "long_name": "Storm Hawk", "next_hop": 0, "num": "0x34eb1977", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "S0JA", "snr": 1.68, "status": null, "telemetry": {"air_util_tx": 1.081, "battery_level": 50, "channel_utilization": 7.61, "uptime_seconds": 38562, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 1346, "long_name": "Forest Trout", "next_hop": 0, "num": "0x34f751c6", "position": {"altitude": 1326, "latitude": 32.338295, "location_source": "LOC_INTERNAL", "longitude": -107.444579, "time_offset_sec": 1349}, "public_key_hex": "3b2b981eeb23c8feb07890fc4a130978cc01042f49838735eeda90536da89afc", "role": "CLIENT", "short_name": "FTSG", "snr": 5.76, "status": null, "telemetry": {"air_util_tx": 0.426, "battery_level": 27, "channel_utilization": 11.69, "uptime_seconds": 8654, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3630, "long_name": "Tiny Beaver", "next_hop": 0, "num": "0x350928d2", "position": {"altitude": 1394, "latitude": 33.767341, "location_source": "LOC_INTERNAL", "longitude": -107.072889, "time_offset_sec": 3829}, "public_key_hex": "38ebb2ccc72aa736e8dc5f4d9775bffc50dcb99766b48a972525e0ae84985b1d", "role": "ROUTER", "short_name": "TA4T", "snr": 6.23, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1205, "long_name": "Storm Crane", "next_hop": 124, "num": "0x35280f4d", "position": {"altitude": 1226, "latitude": 32.43356, "location_source": "LOC_INTERNAL", "longitude": -106.613189, "time_offset_sec": 1366}, "public_key_hex": "587cd2b9f47a6d59abf22ca05d92d521900ce70b27f1054f913d4498b434222e", "role": "CLIENT", "short_name": "SMH0", "snr": 3.07, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M6", "last_heard_offset_sec": 822, "long_name": "Roving Otter", "next_hop": 208, "num": "0x35281377", "position": {"altitude": 1321, "latitude": 32.536876, "location_source": "LOC_INTERNAL", "longitude": -108.039779, "time_offset_sec": 1027}, "public_key_hex": "ba0d4fb69c240d9a29dc357e3f5fd9479bd61da30e785c66722de8ef2fcc8c2d", "role": "CLIENT", "short_name": "RS7Q", "snr": -5.92, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.742, "battery_level": 101, "channel_utilization": 7.82, "uptime_seconds": 179764, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1449, "long_name": "Howling Falcon", "next_hop": 198, "num": "0x35eaa098", "position": {"altitude": 1552, "latitude": 33.404432, "location_source": "LOC_INTERNAL", "longitude": -107.924439, "time_offset_sec": 1581}, "public_key_hex": "a1fc3b0d9893f1242bea61d384ac9165cc0766cbd3a48f3ccd6a18ee6f812319", "role": "CLIENT_HIDDEN", "short_name": "HJ3F", "snr": 5.04, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 372, "long_name": "Tiny Elk", "next_hop": 0, "num": "0x35f0cc39", "position": {"altitude": 1427, "latitude": 33.290895, "location_source": "LOC_INTERNAL", "longitude": -107.201106, "time_offset_sec": 499}, "public_key_hex": "330a4052ea490328a3956becda9a08acdcfbc16bc0478a07cef222b5a30daafd", "role": "CLIENT", "short_name": "TE54", "snr": 9.56, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.597, "battery_level": 28, "channel_utilization": 4.76, "uptime_seconds": 31339, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1101, "long_name": "Sunny Gecko", "next_hop": 116, "num": "0x3619ed0e", "position": {"altitude": 1985, "latitude": 33.257677, "location_source": "LOC_INTERNAL", "longitude": -107.385914, "time_offset_sec": 1362}, "public_key_hex": "757f839679e19f0949429e4df3ac104d6868cb752fe529ea4723c49d2e3f0845", "role": "CLIENT", "short_name": "🐢", "snr": 7.04, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1366, "long_name": "Sleepy Colt", "next_hop": 0, "num": "0x379a7f4b", "position": {"altitude": 1482, "latitude": 33.010008, "location_source": "LOC_INTERNAL", "longitude": -108.104341, "time_offset_sec": 1645}, "public_key_hex": "d7e6c8d58e00e128529228bca8331472cefe3b6ade499a4229579e44a06a9909", "role": "CLIENT", "short_name": "SBJW", "snr": 6.5, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.41, "iaq": 52, "relative_humidity": 47.13, "temperature": 29.47}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4284, "long_name": "Sky Falcon", "next_hop": 0, "num": "0x37b90b2e", "position": {"altitude": 1498, "latitude": 32.135808, "location_source": "LOC_INTERNAL", "longitude": -106.657873, "time_offset_sec": 4402}, "public_key_hex": "9ee39d5af7fec89de02a3ce7d48b9ad82bb218d75d9324ff74eb317cab8aaf60", "role": "CLIENT", "short_name": "S9D7", "snr": 7.46, "status": null, "telemetry": {"air_util_tx": 0.463, "battery_level": 33, "channel_utilization": 16.68, "uptime_seconds": 196876, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.65, "iaq": 36, "relative_humidity": 46.5, "temperature": 23.13}, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1643, "long_name": "Brave Owl", "next_hop": 85, "num": "0x37dccaab", "position": null, "public_key_hex": "ce6a097bf64b3b2f149276d389a0aaf54c3c62b3822b4dd57cad98049d85f645", "role": "CLIENT", "short_name": "B8MT", "snr": 5.19, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.454, "battery_level": 85, "channel_utilization": 22.66, "uptime_seconds": 261777, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1240, "long_name": "Silver Badger", "next_hop": 0, "num": "0x38ba7d6c", "position": null, "public_key_hex": "b735a5154885ba6bef03bd47d559b3d20e3c3a0ae4e3fa77fd70ffd623bd7a4b", "role": "CLIENT", "short_name": "SZTG", "snr": 1.95, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 4, "hw_model": "RAK4631", "last_heard_offset_sec": 1066, "long_name": "Wandering Bronco", "next_hop": 145, "num": "0x38e31b4f", "position": {"altitude": 1172, "latitude": 32.504572, "location_source": "LOC_INTERNAL", "longitude": -106.301649, "time_offset_sec": 1177}, "public_key_hex": "70d05e317b534b7d3818add161f43ca366cdee3abee2f7587393217af8e3e7da", "role": "CLIENT", "short_name": "WICA", "snr": 6.52, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 1995, "long_name": "Lunar Bronco", "next_hop": 130, "num": "0x3a48853f", "position": {"altitude": 1400, "latitude": 33.976144, "location_source": "LOC_INTERNAL", "longitude": -107.015442, "time_offset_sec": 1996}, "public_key_hex": "e7b6c54f0d2b8d129baad7ad2370629dd0a8a969799b4edad53b28c5d70a137a", "role": "CLIENT", "short_name": "LW2P", "snr": 2.96, "status": null, "telemetry": {"air_util_tx": 1.042, "battery_level": 101, "channel_utilization": 8.36, "uptime_seconds": 170554, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3738, "long_name": "Drifting Mamba", "next_hop": 0, "num": "0x3a7a9d31", "position": {"altitude": 1324, "latitude": 32.805193, "location_source": "LOC_INTERNAL", "longitude": -107.892675, "time_offset_sec": 3975}, "public_key_hex": "8792509c722d7ce22641ad6eeaf4d49c0990d572a660c8a9b4866ad818e99518", "role": "CLIENT_MUTE", "short_name": "DXZL", "snr": 5.37, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.616, "battery_level": 52, "channel_utilization": 13.15, "uptime_seconds": 19131, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 694, "long_name": "Bright Pony", "next_hop": 0, "num": "0x3a9ebc7a", "position": {"altitude": 1679, "latitude": 33.197556, "location_source": "LOC_INTERNAL", "longitude": -107.739604, "time_offset_sec": 705}, "public_key_hex": "bcf5471fc71cf14cda41b23fb2bdbe2a0ad0d9545130faabcb8868094d58ed6d", "role": "TAK_TRACKER", "short_name": "BPWE", "snr": 10.59, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.494, "battery_level": 86, "channel_utilization": 15.79, "uptime_seconds": 5144, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6627, "long_name": "Roving Shark", "next_hop": 0, "num": "0x3c626203", "position": {"altitude": 1389, "latitude": 32.858857, "location_source": "LOC_INTERNAL", "longitude": -107.351624, "time_offset_sec": 6770}, "public_key_hex": "425316a371780a5d7cd4c0f286e8c2ca392ed59eb2ee7164282b4f000fd8c98d", "role": "CLIENT", "short_name": "RBC5", "snr": 7.03, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3404, "long_name": "Quick Falcon", "next_hop": 0, "num": "0x3cd1fc8b", "position": {"altitude": 1465, "latitude": 32.944934, "location_source": "LOC_INTERNAL", "longitude": -107.49285, "time_offset_sec": 3444}, "public_key_hex": "", "role": "CLIENT", "short_name": "QLVF", "snr": 12.0, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.122, "battery_level": 33, "channel_utilization": 5.63, "uptime_seconds": 104974, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3961, "long_name": "Frozen Bronco", "next_hop": 0, "num": "0x3d841286", "position": null, "public_key_hex": "4808b539f3d118bcf2ed4b9b90d28b11ef7b8840e0e9edd5cc7706b51c906125", "role": "CLIENT", "short_name": "FFER", "snr": 6.24, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.634, "battery_level": 20, "channel_utilization": 17.12, "uptime_seconds": 7949, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4991, "long_name": "Lone Coyote", "next_hop": 0, "num": "0x3f08ef02", "position": {"altitude": 1232, "latitude": 33.103679, "location_source": "LOC_INTERNAL", "longitude": -106.900331, "time_offset_sec": 5175}, "public_key_hex": "6525d25dc21d4b76bd54da497bad80a83f0e5491756a1a0344a479f39eb0ba71", "role": "CLIENT", "short_name": "LLVT", "snr": 7.79, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 3286, "long_name": "Iron Viper", "next_hop": 0, "num": "0x3f677662", "position": {"altitude": 1848, "latitude": 33.278539, "location_source": "LOC_INTERNAL", "longitude": -108.310349, "time_offset_sec": 3409}, "public_key_hex": "c413d2c51a6764b0589503e1bda72c9840f3fd0d5687e0b5df6dc04640b662c7", "role": "CLIENT", "short_name": "I0D3", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.179, "battery_level": 22, "channel_utilization": 8.54, "uptime_seconds": 220137, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAG", "last_heard_offset_sec": 5466, "long_name": "Black Lynx AB8ET", "next_hop": 0, "num": "0x3f7ea7eb", "position": {"altitude": 1676, "latitude": 32.76055, "location_source": "LOC_INTERNAL", "longitude": -106.901877, "time_offset_sec": 5692}, "public_key_hex": "f7e96bc6a9e0214c7e3fda88b27f4969e291adce95396869c42ec77126587cd9", "role": "CLIENT", "short_name": "BBUC", "snr": 9.53, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.233, "battery_level": 101, "channel_utilization": 6.48, "uptime_seconds": 36311, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.36, "iaq": 44, "relative_humidity": 72.23, "temperature": 20.48}, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 2354, "long_name": "Dawn Tortoise", "next_hop": 153, "num": "0x3fc5bdc2", "position": null, "public_key_hex": "fda1f9f1e8ed7a0c5b37bff3d18c5d15aaf72b17e03e2248c7565785671247eb", "role": "CLIENT", "short_name": "DETB", "snr": 6.68, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 231, "long_name": "Silver Bass", "next_hop": 7, "num": "0x3ff95e93", "position": {"altitude": 1203, "latitude": 32.849007, "location_source": "LOC_INTERNAL", "longitude": -108.197204, "time_offset_sec": 521}, "public_key_hex": "849a63f901ce989581c4939d4fc3ad466203b6a02c1638a8fa8a5e504b21f96e", "role": "CLIENT", "short_name": "SENN", "snr": 4.24, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.876, "battery_level": 20, "channel_utilization": 2.39, "uptime_seconds": 179024, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.48, "iaq": 0, "relative_humidity": 16.24, "temperature": 34.19}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 8035, "long_name": "Mountain Trout", "next_hop": 184, "num": "0x417a5306", "position": {"altitude": 1503, "latitude": 33.624464, "location_source": "LOC_INTERNAL", "longitude": -106.72661, "time_offset_sec": 8119}, "public_key_hex": "35de117ffe9f5589301f410d8b721a21e7fc4e3a2893eaac77fdf74d3afd98c9", "role": "CLIENT", "short_name": "M97I", "snr": 9.69, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 0.781, "battery_level": 85, "channel_utilization": 7.93, "uptime_seconds": 84523, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.45, "iaq": 97, "relative_humidity": 43.82, "temperature": 7.08}, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 1671, "long_name": "Howling Falcon", "next_hop": 0, "num": "0x41840415", "position": {"altitude": 1497, "latitude": 32.148268, "location_source": "LOC_INTERNAL", "longitude": -107.216316, "time_offset_sec": 1867}, "public_key_hex": "c6dd6006e0a5c8161af6a399b638f234a6a9a1aa4cc49f88a86a05598d6f0b82", "role": "CLIENT", "short_name": "HKBX", "snr": 12.0, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2561, "long_name": "Happy Iguana", "next_hop": 112, "num": "0x41e5b63a", "position": {"altitude": 1296, "latitude": 32.452598, "location_source": "LOC_INTERNAL", "longitude": -106.65747, "time_offset_sec": 2656}, "public_key_hex": "e40bef49256a922269b637c229b4cbfec4bcb93111fc93df36d58c28235c60d0", "role": "CLIENT", "short_name": "HCQ1", "snr": 6.42, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 378, "long_name": "Sleepy Pony", "next_hop": 110, "num": "0x42827046", "position": {"altitude": 1571, "latitude": 33.761147, "location_source": "LOC_INTERNAL", "longitude": -108.038104, "time_offset_sec": 493}, "public_key_hex": "8675ad01e7bef086d31a9de38cabe2990be1c1fbe6c9070b1114f22fc1c3ab9b", "role": "CLIENT", "short_name": "SZQ4", "snr": 8.86, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.245, "battery_level": 30, "channel_utilization": 2.76, "uptime_seconds": 32001, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.27, "iaq": 64, "relative_humidity": 67.28, "temperature": 23.25}, "hops_away": 1, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 7780, "long_name": "Smooth Heron", "next_hop": 149, "num": "0x43098402", "position": {"altitude": 1709, "latitude": 33.548948, "location_source": "LOC_INTERNAL", "longitude": -107.227654, "time_offset_sec": 7782}, "public_key_hex": "30bbdb755f48cb79b58ab77c854d4ee8ba5d74bb8be93c3abfe8f2a0cb765ec6", "role": "CLIENT", "short_name": "SJ03", "snr": 7.38, "status": null, "telemetry": {"air_util_tx": 0.942, "battery_level": 66, "channel_utilization": 9.03, "uptime_seconds": 51036, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 396, "long_name": "Steel Bronco", "next_hop": 0, "num": "0x435d2911", "position": {"altitude": 1673, "latitude": 32.580277, "location_source": "LOC_INTERNAL", "longitude": -106.975968, "time_offset_sec": 564}, "public_key_hex": "72d1139e64da66ab88fe4509ef2fafd974b1ffaca6011c2e3bd45001adf39435", "role": "CLIENT", "short_name": "S1YB", "snr": 2.23, "status": null, "telemetry": {"air_util_tx": 2.157, "battery_level": 74, "channel_utilization": 5.14, "uptime_seconds": 164568, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 835, "long_name": "Howling Dolphin", "next_hop": 0, "num": "0x43eb37a7", "position": {"altitude": 1020, "latitude": 32.758911, "location_source": "LOC_INTERNAL", "longitude": -107.320715, "time_offset_sec": 1086}, "public_key_hex": "095341f06bac766786c813771f272925637b520d163e44d0683a156ef98722b2", "role": "CLIENT", "short_name": "HT3M", "snr": 2.25, "status": null, "telemetry": {"air_util_tx": 1.207, "battery_level": 30, "channel_utilization": 23.12, "uptime_seconds": 49884, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 362, "long_name": "Copper Falcon", "next_hop": 0, "num": "0x447c1421", "position": {"altitude": 1398, "latitude": 33.482628, "location_source": "LOC_INTERNAL", "longitude": -106.782732, "time_offset_sec": 501}, "public_key_hex": "b51c82595a0ba524f559479ce0ca39f6a7b5dbcd9f1c8342021a6b666d4bb598", "role": "CLIENT", "short_name": "🌙", "snr": 9.3, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.389, "battery_level": 100, "channel_utilization": 13.31, "uptime_seconds": 164327, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2034, "long_name": "Storm Hare", "next_hop": 212, "num": "0x455852df", "position": {"altitude": 1411, "latitude": 33.160165, "location_source": "LOC_INTERNAL", "longitude": -106.978518, "time_offset_sec": 2200}, "public_key_hex": "663033cb89273d29f0fd905dc9133b81196d6ae87bac26f5753bfb131854db42", "role": "CLIENT", "short_name": "S7JT", "snr": 5.06, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1006.1, "iaq": 59, "relative_humidity": 46.21, "temperature": 20.36}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1003, "long_name": "Green Dolphin", "next_hop": 0, "num": "0x465af007", "position": null, "public_key_hex": "e78e261f75aafb55d6bbba0e9edd7cc11badfae3eb4186beb779c5dcb3255cd8", "role": "CLIENT", "short_name": "GI2G", "snr": 1.93, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 418, "long_name": "Brave Heron", "next_hop": 0, "num": "0x48134994", "position": {"altitude": 1581, "latitude": 33.071357, "location_source": "LOC_INTERNAL", "longitude": -108.099084, "time_offset_sec": 565}, "public_key_hex": "76a6ad472a5cff57ed41eb49c1816811c240fad47b9af833300228d122aa18f5", "role": "CLIENT_HIDDEN", "short_name": "BWKH", "snr": 6.59, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.187, "battery_level": 23, "channel_utilization": 2.78, "uptime_seconds": 34162, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.42, "iaq": 61, "relative_humidity": 55.86, "temperature": 1.38}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 4712, "long_name": "Tall Bear", "next_hop": 0, "num": "0x48bda4f9", "position": {"altitude": 1327, "latitude": 31.889102, "location_source": "LOC_INTERNAL", "longitude": -107.933255, "time_offset_sec": 4967}, "public_key_hex": "3aa01e4ffd3b8b7e47642303a170636cb7de191e004e122ad7e7675eef2fda6f", "role": "CLIENT", "short_name": "TFGL", "snr": -1.71, "status": null, "telemetry": {"air_util_tx": 0.461, "battery_level": 83, "channel_utilization": 18.67, "uptime_seconds": 100258, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 258, "long_name": "White Bison", "next_hop": 0, "num": "0x48d0b194", "position": {"altitude": 1459, "latitude": 33.782262, "location_source": "LOC_INTERNAL", "longitude": -107.509689, "time_offset_sec": 295}, "public_key_hex": "49e0f1b4d280f33198462cc127e555a1b22c6114145d18e9a14299511b230d9c", "role": "CLIENT_MUTE", "short_name": "WG10", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.751, "battery_level": 100, "channel_utilization": 26.99, "uptime_seconds": 7542, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.81, "iaq": 124, "relative_humidity": 93.64, "temperature": 27.01}, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 868, "long_name": "Found Bluff", "next_hop": 0, "num": "0x49e59f8b", "position": {"altitude": 1285, "latitude": 32.324249, "location_source": "LOC_INTERNAL", "longitude": -106.922861, "time_offset_sec": 1022}, "public_key_hex": "7d26af47afa14a475a43c0d3729ae78c06b25eb4c510e7a9b2a20a0480531dc5", "role": "CLIENT", "short_name": "F62Q", "snr": 2.03, "status": null, "telemetry": {"air_util_tx": 0.093, "battery_level": 36, "channel_utilization": 11.36, "uptime_seconds": 18990, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.75, "iaq": 34, "relative_humidity": 36.1, "temperature": 27.18}, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 5593, "long_name": "Blue Bronco", "next_hop": 203, "num": "0x4a07175b", "position": null, "public_key_hex": "ffcf68b0815970081d634563ff83bde989bcb1f9cd5d61f6d6cc3d2ff1fad152", "role": "CLIENT", "short_name": "🌙", "snr": 10.77, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.56, "battery_level": 66, "channel_utilization": 11.41, "uptime_seconds": 19830, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 1260, "long_name": "Frozen Cobra", "next_hop": 23, "num": "0x4a440efd", "position": {"altitude": 1458, "latitude": 33.287147, "location_source": "LOC_INTERNAL", "longitude": -107.423088, "time_offset_sec": 1271}, "public_key_hex": "d2b9da3fa597cad62300e868e5f3f9f5bcbf470d4de800b67d21d752cca5b033", "role": "CLIENT", "short_name": "FJJK", "snr": 4.15, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3201, "long_name": "Frosty Whale", "next_hop": 30, "num": "0x4a65ed60", "position": {"altitude": 1440, "latitude": 33.617218, "location_source": "LOC_INTERNAL", "longitude": -107.490683, "time_offset_sec": 3323}, "public_key_hex": "2a54ca7e0ca678ad6c80cf14e49ea9d04fe0d7197e281125800c6d8a78dcfb15", "role": "CLIENT", "short_name": "🌙", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4077, "long_name": "White Oak KQ6JZ", "next_hop": 0, "num": "0x4ab70ba8", "position": {"altitude": 1469, "latitude": 33.384405, "location_source": "LOC_INTERNAL", "longitude": -107.365826, "time_offset_sec": 4124}, "public_key_hex": "95d3ff7660fc0f84136aa457178490c7a80c8a442696b77490b14ce708bf6343", "role": "ROUTER", "short_name": "WCCR", "snr": 2.33, "status": null, "telemetry": {"air_util_tx": 0.23, "battery_level": 27, "channel_utilization": 13.09, "uptime_seconds": 58027, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1016.03, "iaq": 54, "relative_humidity": 67.96, "temperature": 16.03}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2928, "long_name": "Tiny Mamba", "next_hop": 235, "num": "0x4aedcfb7", "position": {"altitude": 1754, "latitude": 34.012849, "location_source": "LOC_INTERNAL", "longitude": -106.947321, "time_offset_sec": 3160}, "public_key_hex": "12890fb733850d94ded99326d5675cfddc5d9a133082a749fc56db0de5ec6fac", "role": "SENSOR", "short_name": "TQNJ", "snr": 4.78, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.222, "battery_level": 97, "channel_utilization": 13.6, "uptime_seconds": 47867, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": {"barometric_pressure": 1012.81, "iaq": 28, "relative_humidity": 40.75, "temperature": 8.84}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1916, "long_name": "Dawn Trout", "next_hop": 52, "num": "0x4d187e0c", "position": {"altitude": 1222, "latitude": 33.05008, "location_source": "LOC_INTERNAL", "longitude": -108.01885, "time_offset_sec": 1924}, "public_key_hex": "813f81fd4523b85fc3cf532d515c7c26d17cec31ffac8c79ffb47f4acc1e4974", "role": "CLIENT", "short_name": "D9JM", "snr": 2.33, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.038, "battery_level": 73, "channel_utilization": 8.21, "uptime_seconds": 49948, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2519, "long_name": "Quick Viper", "next_hop": 53, "num": "0x4d58a77a", "position": {"altitude": 1692, "latitude": 33.217413, "location_source": "LOC_INTERNAL", "longitude": -105.91945, "time_offset_sec": 2662}, "public_key_hex": "", "role": "CLIENT", "short_name": "QVG4", "snr": 9.9, "status": null, "telemetry": {"air_util_tx": 1.169, "battery_level": 80, "channel_utilization": 7.86, "uptime_seconds": 8886, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 956, "long_name": "Forest Elk", "next_hop": 0, "num": "0x4de415a9", "position": {"altitude": 1312, "latitude": 33.522763, "location_source": "LOC_INTERNAL", "longitude": -107.09793, "time_offset_sec": 1044}, "public_key_hex": "13ec2f9813b533355170fb10f7b9edf6968cfa202f05c239991f510b95c8c407", "role": "CLIENT", "short_name": "FXPV", "snr": 7.91, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.22, "iaq": 75, "relative_humidity": 59.03, "temperature": 13.82}, "hops_away": 1, "hw_model": "RAK3401", "last_heard_offset_sec": 7379, "long_name": "Giant Iguana", "next_hop": 182, "num": "0x4dfe4aca", "position": {"altitude": 1564, "latitude": 33.87132, "location_source": "LOC_INTERNAL", "longitude": -107.840595, "time_offset_sec": 7664}, "public_key_hex": "", "role": "CLIENT", "short_name": "GZ9E", "snr": 4.22, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.223, "battery_level": 17, "channel_utilization": 11.34, "uptime_seconds": 59951, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.46, "iaq": 82, "relative_humidity": 100.0, "temperature": 13.55}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3541, "long_name": "Frosty Otter", "next_hop": 0, "num": "0x4eb45c1b", "position": {"altitude": 1558, "latitude": 33.765757, "location_source": "LOC_INTERNAL", "longitude": -107.524716, "time_offset_sec": 3795}, "public_key_hex": "51b4aa27f341a7fec39219763ac33a36632089e7791650baeaa6c3bd7667cb8b", "role": "CLIENT", "short_name": "FMHL", "snr": 2.7, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2403, "long_name": "Canyon Pike", "next_hop": 0, "num": "0x4f25ffaf", "position": {"altitude": 978, "latitude": 33.295073, "location_source": "LOC_INTERNAL", "longitude": -107.935912, "time_offset_sec": 2565}, "public_key_hex": "21f12d839251e7f55a50977347c0af93868e95069eab8a009570663787d55725", "role": "CLIENT", "short_name": "CHTT", "snr": 5.71, "status": null, "telemetry": {"air_util_tx": 0.173, "battery_level": 32, "channel_utilization": 19.04, "uptime_seconds": 37771, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1444, "long_name": "Howling Tortoise W52LP", "next_hop": 0, "num": "0x4fdbfa4e", "position": {"altitude": 1521, "latitude": 31.835226, "location_source": "LOC_INTERNAL", "longitude": -107.13257, "time_offset_sec": 1486}, "public_key_hex": "d473b075d25507367010facf4b34a5c144d239bc76506eee18143557850e5262", "role": "CLIENT", "short_name": "H19L", "snr": 0.59, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3414, "long_name": "Found Aspen", "next_hop": 0, "num": "0x52ed71ee", "position": {"altitude": 1309, "latitude": 33.404487, "location_source": "LOC_INTERNAL", "longitude": -107.502565, "time_offset_sec": 3426}, "public_key_hex": "", "role": "CLIENT", "short_name": "FJ4E", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 716, "long_name": "River Crane", "next_hop": 0, "num": "0x54feae48", "position": {"altitude": 1529, "latitude": 32.829459, "location_source": "LOC_INTERNAL", "longitude": -107.885714, "time_offset_sec": 764}, "public_key_hex": "775ebe272d85d4cf4aedf637cffff0cc46855f0be5f3bde6955c223b122b9efa", "role": "CLIENT", "short_name": "ROMG", "snr": 5.32, "status": null, "telemetry": {"air_util_tx": 1.047, "battery_level": 63, "channel_utilization": 15.17, "uptime_seconds": 73126, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 4286, "long_name": "White Adder", "next_hop": 155, "num": "0x5542b82e", "position": {"altitude": 1199, "latitude": 33.325201, "location_source": "LOC_INTERNAL", "longitude": -107.359853, "time_offset_sec": 4325}, "public_key_hex": "23e55c5b94a22177bd6b7b480e5e9a2a781366259e03d169fd5af83b17572937", "role": "CLIENT", "short_name": "WFIS", "snr": 7.24, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 8886, "long_name": "Roving Shark", "next_hop": 0, "num": "0x556fe77a", "position": null, "public_key_hex": "200a37f6b73e3c525cd6cc8cad5af856428c4234d491ab404754739e93f4eca8", "role": "CLIENT", "short_name": "RJKM", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.168, "battery_level": 79, "channel_utilization": 7.74, "uptime_seconds": 64718, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 476, "long_name": "Lost Adder", "next_hop": 23, "num": "0x55daf3c7", "position": {"altitude": 1141, "latitude": 33.477638, "location_source": "LOC_INTERNAL", "longitude": -107.152887, "time_offset_sec": 670}, "public_key_hex": "63867a7544c551fc3e97436e9a2c9a4e0d508ee5d43367b9441108835c23c0de", "role": "CLIENT", "short_name": "LGUR", "snr": 3.8, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2083, "long_name": "Sunny Aspen", "next_hop": 40, "num": "0x560289a0", "position": null, "public_key_hex": "758c818600d3dcf61e33702311a7e78d8d5ff3a5c2c0a0cc281537b0c32aa9d4", "role": "CLIENT", "short_name": "SC6I", "snr": 6.55, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.365, "battery_level": 90, "channel_utilization": 12.33, "uptime_seconds": 171767, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 241, "long_name": "Wandering Bear", "next_hop": 125, "num": "0x5614cdff", "position": {"altitude": 1380, "latitude": 33.963666, "location_source": "LOC_INTERNAL", "longitude": -108.530761, "time_offset_sec": 390}, "public_key_hex": "e7b26726240c6b8b457842831bcb1a82dd1e69215644d1e0adbae406bcc95010", "role": "ROUTER", "short_name": "WIMK", "snr": -1.84, "status": null, "telemetry": {"air_util_tx": 0.547, "battery_level": 46, "channel_utilization": 1.46, "uptime_seconds": 163671, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 7032, "long_name": "Sneaky Aspen", "next_hop": 94, "num": "0x561cfd98", "position": {"altitude": 1380, "latitude": 33.055831, "location_source": "LOC_INTERNAL", "longitude": -107.450441, "time_offset_sec": 7189}, "public_key_hex": "3a0f320e86669da351f6ba7b62df332050d30bc8b37332d1ef9c841a85d265e1", "role": "CLIENT", "short_name": "SSKN", "snr": 9.77, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6234, "long_name": "Drowsy Whale", "next_hop": 245, "num": "0x56e1cb9d", "position": {"altitude": 1750, "latitude": 33.794131, "location_source": "LOC_INTERNAL", "longitude": -107.239442, "time_offset_sec": 6447}, "public_key_hex": "db63270d2c6cf45ef9f76f16bccbf17c1a07329fc5cceeec58924383e673b40b", "role": "CLIENT", "short_name": "DYTA", "snr": 9.73, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8604, "long_name": "Tiny Doe", "next_hop": 0, "num": "0x575f0d95", "position": {"altitude": 1546, "latitude": 33.873224, "location_source": "LOC_INTERNAL", "longitude": -107.630514, "time_offset_sec": 8652}, "public_key_hex": "8f933aa44310ebcc8e988f2b180e3aad23fa7a8554b4762b9c645d3b833b6358", "role": "SENSOR", "short_name": "TIH4", "snr": 10.42, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.062, "battery_level": 21, "channel_utilization": 5.18, "uptime_seconds": 8818, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 349, "long_name": "Frosty Bear", "next_hop": 0, "num": "0x57c19004", "position": null, "public_key_hex": "", "role": "ROUTER", "short_name": "🌵", "snr": 6.04, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 601, "long_name": "Dusk Adder", "next_hop": 207, "num": "0x57e60523", "position": {"altitude": 1549, "latitude": 32.829103, "location_source": "LOC_INTERNAL", "longitude": -107.560567, "time_offset_sec": 830}, "public_key_hex": "56c6eb6548952819973855d9771675bd6b7d558d4a2266016e31d5737e792c07", "role": "CLIENT_MUTE", "short_name": "DDM3", "snr": 9.71, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.036, "battery_level": 28, "channel_utilization": 10.8, "uptime_seconds": 259221, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.6, "iaq": 84, "relative_humidity": 27.27, "temperature": 13.97}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1478, "long_name": "Dawn Turtle", "next_hop": 0, "num": "0x5896a943", "position": {"altitude": 1167, "latitude": 33.168007, "location_source": "LOC_INTERNAL", "longitude": -107.364102, "time_offset_sec": 1491}, "public_key_hex": "d0b326bc3c6cb2bf029fae9c194080c65224a09371d2c69be954d4198f039f89", "role": "CLIENT", "short_name": "DHR5", "snr": 6.29, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.44, "iaq": 14, "relative_humidity": 63.52, "temperature": 30.46}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1140, "long_name": "Solar Juniper", "next_hop": 0, "num": "0x5a4b7fad", "position": null, "public_key_hex": "03daa9821e8e2aa97c0f48637ebef53d057a4a5d48dd71e6350577c665ac6e58", "role": "CLIENT", "short_name": "SFNK", "snr": 8.01, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 2454, "long_name": "Canyon Mole", "next_hop": 204, "num": "0x5a7e7001", "position": {"altitude": 1350, "latitude": 33.054618, "location_source": "LOC_INTERNAL", "longitude": -107.149237, "time_offset_sec": 2705}, "public_key_hex": "984d1c5f9099ae076b3ea4729001af8ce4ef8fbd444a3a390d6de8dc245f2c78", "role": "CLIENT", "short_name": "C5DS", "snr": -2.29, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.709, "battery_level": 13, "channel_utilization": 18.33, "uptime_seconds": 338757, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.96, "iaq": 65, "relative_humidity": 45.12, "temperature": 17.17}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 655, "long_name": "Slow Bear", "next_hop": 0, "num": "0x5ab0ef52", "position": {"altitude": 1458, "latitude": 33.247835, "location_source": "LOC_INTERNAL", "longitude": -106.650083, "time_offset_sec": 729}, "public_key_hex": "4333ec040b37aa1b83c3bc243af0fe1af308e9abf22241cbde4f15cbf389b8a7", "role": "CLIENT", "short_name": "🐢", "snr": 4.76, "status": null, "telemetry": {"air_util_tx": 1.372, "battery_level": 63, "channel_utilization": 13.22, "uptime_seconds": 39950, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 2380, "long_name": "Roving Trout", "next_hop": 0, "num": "0x5b53b025", "position": {"altitude": 1058, "latitude": 33.419824, "location_source": "LOC_INTERNAL", "longitude": -106.100673, "time_offset_sec": 2647}, "public_key_hex": "3b5f5e5a541107f3d05babc1b1e67b08d5bd603e1897fbb3f904a0020717b793", "role": "ROUTER", "short_name": "RDI5", "snr": 7.3, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.474, "battery_level": 46, "channel_utilization": 13.57, "uptime_seconds": 6168, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 1672, "long_name": "Red Heron KQ5BO", "next_hop": 43, "num": "0x5d303a81", "position": {"altitude": 1066, "latitude": 33.490828, "location_source": "LOC_INTERNAL", "longitude": -106.612674, "time_offset_sec": 1834}, "public_key_hex": "17e0b9248eaf07c217234ab685b331cceb5223fbabdd8448c630353803ef883b", "role": "CLIENT", "short_name": "R8YI", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.633, "battery_level": 75, "channel_utilization": 11.71, "uptime_seconds": 161845, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.33, "iaq": 23, "relative_humidity": 41.44, "temperature": 24.44}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2583, "long_name": "Whispering Crane", "next_hop": 93, "num": "0x5d419cc9", "position": {"altitude": 2030, "latitude": 33.687682, "location_source": "LOC_INTERNAL", "longitude": -106.988016, "time_offset_sec": 2854}, "public_key_hex": "205e7bccba09d6670edf6b8ac10cacb1814f5d090f8e6117301ebf5f52af6172", "role": "CLIENT", "short_name": "W2LP", "snr": 8.4, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.544, "battery_level": 71, "channel_utilization": 28.41, "uptime_seconds": 12805, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 6978, "long_name": "Burning Mesa", "next_hop": 0, "num": "0x5d884220", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "🌙", "snr": 7.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 2532, "long_name": "Lone Juniper", "next_hop": 0, "num": "0x5df06225", "position": {"altitude": 1459, "latitude": 33.67153, "location_source": "LOC_INTERNAL", "longitude": -108.298245, "time_offset_sec": 2709}, "public_key_hex": "8e96a3a4c8c92d3a6bdca70311296555f9673de6589f53a7c1bbf5025203663f", "role": "CLIENT", "short_name": "L4J5", "snr": 7.22, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 1.076, "battery_level": 74, "channel_utilization": 14.44, "uptime_seconds": 49677, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 533, "long_name": "Silver Beaver", "next_hop": 0, "num": "0x5e2a849f", "position": {"altitude": 1205, "latitude": 31.904079, "location_source": "LOC_INTERNAL", "longitude": -106.811678, "time_offset_sec": 629}, "public_key_hex": "2595fac54c897f642e2401c5bc531e8fda9f093303e314ca10c3d71a9765ba99", "role": "CLIENT", "short_name": "SMRO", "snr": 9.05, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 10743, "long_name": "Loud Ridge", "next_hop": 0, "num": "0x5ee7b9ab", "position": {"altitude": 1458, "latitude": 32.804323, "location_source": "LOC_INTERNAL", "longitude": -107.157219, "time_offset_sec": 10931}, "public_key_hex": "324fe432faf69f8542e27a4f842930cc5427c6506c43d223c1327b9b450ab218", "role": "CLIENT", "short_name": "L4S6", "snr": 3.08, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.57, "iaq": 0, "relative_humidity": 30.0, "temperature": 15.21}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 291, "long_name": "Stone Juniper", "next_hop": 0, "num": "0x5eed232e", "position": null, "public_key_hex": "25819aa7eb792ba9c7536908ce600978222847123ec836dd5efffb4a99042fe5", "role": "CLIENT", "short_name": "SDK1", "snr": 9.23, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.338, "battery_level": 29, "channel_utilization": 11.26, "uptime_seconds": 43857, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1005.29, "iaq": 0, "relative_humidity": 73.52, "temperature": 17.84}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3582, "long_name": "Sunny Doe", "next_hop": 0, "num": "0x5f3a27b9", "position": null, "public_key_hex": "b650660e551a64ba46e1dc907598af9a63c830004ea1b58f4fa2f8d2981928c2", "role": "CLIENT", "short_name": "SY3A", "snr": 9.44, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.32, "iaq": 55, "relative_humidity": 54.13, "temperature": 27.45}, "hops_away": 0, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 4388, "long_name": "Bright Juniper", "next_hop": 0, "num": "0x60d24c47", "position": {"altitude": 1787, "latitude": 33.333417, "location_source": "LOC_INTERNAL", "longitude": -107.568488, "time_offset_sec": 4652}, "public_key_hex": "cb7db9874d97fb0ac506c3fd41b0c8c7bd2eb559826276c36e74e2e7555f5a81", "role": "CLIENT", "short_name": "🦉", "snr": 8.43, "status": null, "telemetry": {"air_util_tx": 1.427, "battery_level": 90, "channel_utilization": 14.19, "uptime_seconds": 76369, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1056, "long_name": "Wild Badger", "next_hop": 77, "num": "0x6200f9ce", "position": {"altitude": 1253, "latitude": 33.041987, "location_source": "LOC_INTERNAL", "longitude": -106.950457, "time_offset_sec": 1202}, "public_key_hex": "e23be3dd4813837634a58c9d1854da85f3dbd150ae89174b27682acc486193da", "role": "CLIENT", "short_name": "🔥", "snr": 4.7, "status": null, "telemetry": {"air_util_tx": 0.535, "battery_level": 82, "channel_utilization": 10.39, "uptime_seconds": 59624, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 6752, "long_name": "Forest Adder", "next_hop": 28, "num": "0x63013b03", "position": {"altitude": 1583, "latitude": 33.190369, "location_source": "LOC_INTERNAL", "longitude": -106.809159, "time_offset_sec": 6781}, "public_key_hex": "58aa10becbc7ede30777f73b3ed202f8450ad4e4437adf4cdd8e8f80e3712cd0", "role": "CLIENT", "short_name": "🌊", "snr": 9.73, "status": null, "telemetry": {"air_util_tx": 0.868, "battery_level": 17, "channel_utilization": 4.02, "uptime_seconds": 34171, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 66, "long_name": "Misty Crow", "next_hop": 0, "num": "0x634d6509", "position": null, "public_key_hex": "a1b2605f00d145be4708ced109705732bd361a0c8007de5a7b9ff0d6f51d8e18", "role": "CLIENT", "short_name": "MBRM", "snr": 7.44, "status": null, "telemetry": {"air_util_tx": 0.281, "battery_level": 63, "channel_utilization": 15.75, "uptime_seconds": 127038, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 7252, "long_name": "Found Gecko", "next_hop": 0, "num": "0x637a7112", "position": {"altitude": 1446, "latitude": 32.690082, "location_source": "LOC_INTERNAL", "longitude": -107.860767, "time_offset_sec": 7428}, "public_key_hex": "8846b0dbc05917aeb4705ad55a561bdce07fd33292ea833420fdad02af31e165", "role": "CLIENT", "short_name": "FMBH", "snr": 4.12, "status": null, "telemetry": {"air_util_tx": 0.813, "battery_level": 65, "channel_utilization": 10.12, "uptime_seconds": 3331, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 4011, "long_name": "Smooth Doe", "next_hop": 0, "num": "0x64527cb3", "position": {"altitude": 1352, "latitude": 33.789084, "location_source": "LOC_INTERNAL", "longitude": -107.13058, "time_offset_sec": 4044}, "public_key_hex": "5e2df0ded253ee2ef12d3a480bbc7d5224c32a1a10f031a5f4767929dd51c957", "role": "ROUTER", "short_name": "SSCW", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.042, "battery_level": 98, "channel_utilization": 5.9, "uptime_seconds": 373425, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3443, "long_name": "River Pike", "next_hop": 0, "num": "0x645fe930", "position": {"altitude": 1695, "latitude": 33.371402, "location_source": "LOC_INTERNAL", "longitude": -108.077662, "time_offset_sec": 3707}, "public_key_hex": "2b53f8e1f6d917da69b84232cf02ef52d63c3110d243a6365ca0d9f624a9676a", "role": "CLIENT_MUTE", "short_name": "RYR4", "snr": 9.34, "status": null, "telemetry": {"air_util_tx": 0.699, "battery_level": 55, "channel_utilization": 15.11, "uptime_seconds": 47524, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7136, "long_name": "Blue Mamba", "next_hop": 75, "num": "0x64a95e31", "position": {"altitude": 953, "latitude": 33.832252, "location_source": "LOC_INTERNAL", "longitude": -108.054814, "time_offset_sec": 7336}, "public_key_hex": "6b53989af627f43df024b8cc2af77b8807e116d529a4f2a935f4841f115cd2f7", "role": "CLIENT", "short_name": "B41R", "snr": 2.61, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1273, "long_name": "River Coyote", "next_hop": 0, "num": "0x64cc9bac", "position": null, "public_key_hex": "93157fedd5c540dd2dba4723d1ac996e48a4a821b5a5c92e2c032f74f62f3cf2", "role": "CLIENT", "short_name": "RIS0", "snr": 3.55, "status": null, "telemetry": {"air_util_tx": 1.163, "battery_level": 89, "channel_utilization": 16.15, "uptime_seconds": 62238, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 1921, "long_name": "Dusk Squirrel", "next_hop": 0, "num": "0x65bd58c3", "position": {"altitude": 1254, "latitude": 33.254787, "location_source": "LOC_INTERNAL", "longitude": -107.104805, "time_offset_sec": 2207}, "public_key_hex": "66c55dd5187047a62840c5dbc4609548c111433a75f9e871de15fe8fa8f618d8", "role": "CLIENT", "short_name": "DUYQ", "snr": 2.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.51, "iaq": 68, "relative_humidity": 47.61, "temperature": 32.73}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 8470, "long_name": "Sleepy Cobra", "next_hop": 0, "num": "0x6630f717", "position": {"altitude": 806, "latitude": 33.128898, "location_source": "LOC_INTERNAL", "longitude": -107.493292, "time_offset_sec": 8739}, "public_key_hex": "8f0b633549dc4dc8293407769a55bf0e85cf3c08b158d76a89ca5c0b10545431", "role": "CLIENT", "short_name": "SMQ1", "snr": 7.84, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.08, "battery_level": 83, "channel_utilization": 21.33, "uptime_seconds": 108301, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7996, "long_name": "Silent Phoenix", "next_hop": 0, "num": "0x6644ef0a", "position": {"altitude": 1187, "latitude": 33.112567, "location_source": "LOC_INTERNAL", "longitude": -107.827152, "time_offset_sec": 8174}, "public_key_hex": "1b93a24c71ee7ea1b995d78094fdb96358abebd9217b88cc780752a933d89239", "role": "CLIENT", "short_name": "SBGO", "snr": 0.65, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2863, "long_name": "Dusk Hare", "next_hop": 0, "num": "0x6726f381", "position": {"altitude": 1010, "latitude": 32.705604, "location_source": "LOC_INTERNAL", "longitude": -106.046648, "time_offset_sec": 3132}, "public_key_hex": "181c2327d931a05576a5de436b0a31fb67542f0712c5cfde5c456a17b370f89e", "role": "CLIENT", "short_name": "DOM5", "snr": 4.13, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.76, "iaq": 39, "relative_humidity": 99.59, "temperature": 22.67}, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 750, "long_name": "Drowsy Mamba", "next_hop": 0, "num": "0x675d77f2", "position": {"altitude": 1325, "latitude": 33.642278, "location_source": "LOC_INTERNAL", "longitude": -107.295189, "time_offset_sec": 837}, "public_key_hex": "cee4b8911b3920af5c95a121d34a201df99a6056909c028395064bdd7303907a", "role": "ROUTER", "short_name": "D1SL", "snr": 8.82, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.992, "battery_level": 11, "channel_utilization": 14.51, "uptime_seconds": 6720, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1315, "long_name": "Mountain Mole", "next_hop": 0, "num": "0x675e586d", "position": {"altitude": 1221, "latitude": 33.30228, "location_source": "LOC_INTERNAL", "longitude": -107.004475, "time_offset_sec": 1318}, "public_key_hex": "787716e0fe5232c05fd38d208b996e83d6d20b618ce8358ecc8c31725c6b579a", "role": "CLIENT", "short_name": "MGNO", "snr": 2.54, "status": null, "telemetry": {"air_util_tx": 0.117, "battery_level": 16, "channel_utilization": 8.72, "uptime_seconds": 198097, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 3622, "long_name": "Lunar Adder", "next_hop": 28, "num": "0x679bda37", "position": {"altitude": 1045, "latitude": 33.072766, "location_source": "LOC_INTERNAL", "longitude": -107.309558, "time_offset_sec": 3683}, "public_key_hex": "e5972098bc4c8ca34697e9696ab413b34113f251d3e796b1fa530f18a5b4f1bd", "role": "TRACKER", "short_name": "LIHE", "snr": 2.11, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.537, "battery_level": 17, "channel_utilization": 8.68, "uptime_seconds": 135160, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3914, "long_name": "Roving Lynx", "next_hop": 0, "num": "0x68725ce0", "position": {"altitude": 832, "latitude": 32.945027, "location_source": "LOC_INTERNAL", "longitude": -107.479834, "time_offset_sec": 4072}, "public_key_hex": "ecd43f13c918a4f7209806d2c559c3aed2b9e5d5a7c77c0efa288eefe27ca02c", "role": "CLIENT", "short_name": "RE96", "snr": 6.58, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 13132, "long_name": "Mountain Turtle", "next_hop": 0, "num": "0x68c6bb7d", "position": {"altitude": 1143, "latitude": 33.711301, "location_source": "LOC_INTERNAL", "longitude": -107.708401, "time_offset_sec": 13212}, "public_key_hex": "e8548136b1d7de28fd487fae5c42e5e260717df2bf12792f383026e426ad067e", "role": "CLIENT", "short_name": "M215", "snr": 9.88, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.45, "battery_level": 49, "channel_utilization": 14.85, "uptime_seconds": 145984, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1235, "long_name": "Drifting Owl", "next_hop": 0, "num": "0x68e7d58a", "position": {"altitude": 1415, "latitude": 33.558474, "location_source": "LOC_INTERNAL", "longitude": -107.839134, "time_offset_sec": 1430}, "public_key_hex": "01333b1228fd5e6d5cf18b0c6aae8fd6607251652c7f1a43f8846f3ac477dd15", "role": "CLIENT", "short_name": "D0H7", "snr": 4.19, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.244, "battery_level": 13, "channel_utilization": 26.08, "uptime_seconds": 72799, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 2887, "long_name": "Red Pine", "next_hop": 197, "num": "0x69348f05", "position": {"altitude": 1216, "latitude": 33.204311, "location_source": "LOC_INTERNAL", "longitude": -106.604498, "time_offset_sec": 3187}, "public_key_hex": "89e55fe503cb45232d898096b28af07ed4bef11e92c21c928d4650d2c71501d9", "role": "CLIENT_HIDDEN", "short_name": "RO7M", "snr": 10.35, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4817, "long_name": "Burning Seal", "next_hop": 0, "num": "0x69998187", "position": {"altitude": 1293, "latitude": 32.392812, "location_source": "LOC_INTERNAL", "longitude": -106.344819, "time_offset_sec": 4831}, "public_key_hex": "1a4931a427d05e1bdaefa99f70dd2b2a220bfa2e8dec553847d9712b0c70b8d5", "role": "CLIENT", "short_name": "BSS4", "snr": -0.02, "status": null, "telemetry": {"air_util_tx": 0.239, "battery_level": 35, "channel_utilization": 6.97, "uptime_seconds": 204896, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 10076, "long_name": "Blue Squirrel", "next_hop": 212, "num": "0x6a7fcd9f", "position": {"altitude": 1505, "latitude": 32.932357, "location_source": "LOC_INTERNAL", "longitude": -107.01105, "time_offset_sec": 10153}, "public_key_hex": "1bdafc49306dbfb207f4ee164db19b4cb0c1135bb6b63fa03eb007d0dbb2da9b", "role": "ROUTER_LATE", "short_name": "🗻", "snr": 6.42, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.82, "iaq": 102, "relative_humidity": 81.51, "temperature": 13.71}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 61, "long_name": "Sky Mesa", "next_hop": 0, "num": "0x6a9a8728", "position": {"altitude": 1638, "latitude": 33.079767, "location_source": "LOC_INTERNAL", "longitude": -106.860994, "time_offset_sec": 262}, "public_key_hex": "e39040ae3ea3066dbf14b8cf387e1b5d9fdb09bd077b6469c1d2a66b0d0219dc", "role": "TAK_TRACKER", "short_name": "SE1H", "snr": 5.92, "status": null, "telemetry": {"air_util_tx": 0.459, "battery_level": 101, "channel_utilization": 4.93, "uptime_seconds": 56564, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1016.22, "iaq": 62, "relative_humidity": 26.67, "temperature": 11.62}, "hops_away": 2, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 170, "long_name": "River Oak", "next_hop": 103, "num": "0x6b3d3aa3", "position": {"altitude": 1571, "latitude": 33.14479, "location_source": "LOC_INTERNAL", "longitude": -107.376011, "time_offset_sec": 200}, "public_key_hex": "d41101385568b35c0c170c5e2098fa3e11b88bde31402a021e9cb481f8933f21", "role": "CLIENT", "short_name": "RYK6", "snr": 8.07, "status": null, "telemetry": {"air_util_tx": 1.217, "battery_level": 32, "channel_utilization": 6.0, "uptime_seconds": 84071, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 50, "long_name": "River Hawk", "next_hop": 194, "num": "0x6bb1ca2b", "position": {"altitude": 1110, "latitude": 32.829006, "location_source": "LOC_INTERNAL", "longitude": -106.27801, "time_offset_sec": 295}, "public_key_hex": "2bba507bbb6153f5a25d4cb9d186ce98c3e93e9dfc5eccaa98125dd437c1b196", "role": "CLIENT", "short_name": "RYWL", "snr": 0.44, "status": null, "telemetry": {"air_util_tx": 0.346, "battery_level": 87, "channel_utilization": 10.21, "uptime_seconds": 421, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4785, "long_name": "Black Whale", "next_hop": 127, "num": "0x6bb4d10a", "position": {"altitude": 1237, "latitude": 33.509274, "location_source": "LOC_INTERNAL", "longitude": -106.277284, "time_offset_sec": 4880}, "public_key_hex": "7de62156e43c349c6a67dcc5a3afb96c4f088dcf59854561594eebb0280759a8", "role": "CLIENT", "short_name": "BFHZ", "snr": 1.01, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.162, "battery_level": 82, "channel_utilization": 10.28, "uptime_seconds": 20090, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4229, "long_name": "Iron Tortoise", "next_hop": 0, "num": "0x6c43002f", "position": {"altitude": 1582, "latitude": 33.864042, "location_source": "LOC_INTERNAL", "longitude": -107.226095, "time_offset_sec": 4305}, "public_key_hex": "e56dfc70a9f0b54b0a21d867b7b9efeb1859fade19a8bc178becee94549646f7", "role": "CLIENT", "short_name": "I3HH", "snr": 8.44, "status": null, "telemetry": {"air_util_tx": 0.307, "battery_level": 24, "channel_utilization": 13.72, "uptime_seconds": 23775, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 298, "long_name": "Brave Doe", "next_hop": 182, "num": "0x6cc6ba99", "position": {"altitude": 1194, "latitude": 32.579495, "location_source": "LOC_INTERNAL", "longitude": -106.163977, "time_offset_sec": 572}, "public_key_hex": "01a7d519845227b7f66960aa742159c8258c155ab55aa628eab78726d39d4cc8", "role": "ROUTER", "short_name": "BMOQ", "snr": 4.42, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.106, "battery_level": 46, "channel_utilization": 5.93, "uptime_seconds": 15221, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1556, "long_name": "Wandering Phoenix", "next_hop": 0, "num": "0x6d0c22c9", "position": {"altitude": 1703, "latitude": 32.702117, "location_source": "LOC_INTERNAL", "longitude": -106.689231, "time_offset_sec": 1797}, "public_key_hex": "4c41a2ca4984fd5632ca82f398454fcb1702528255edeefdf2da4a237fb3239f", "role": "CLIENT", "short_name": "🦋", "snr": 3.31, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 6253, "long_name": "Drowsy Raven KE7YJ", "next_hop": 0, "num": "0x6d39e1de", "position": {"altitude": 1292, "latitude": 32.404734, "location_source": "LOC_INTERNAL", "longitude": -107.157604, "time_offset_sec": 6487}, "public_key_hex": "fc6ee6e0f7c7960932b081f1b00c18fcb8e423f20c6f0e4fca30062c419bcaa1", "role": "SENSOR", "short_name": "DA49", "snr": 4.41, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.475, "battery_level": 49, "channel_utilization": 8.17, "uptime_seconds": 25790, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 12232, "long_name": "Fast Lynx", "next_hop": 48, "num": "0x6e0bad26", "position": null, "public_key_hex": "66930b442ce2a258b51c21dc63651a4ecc466b0ec25378ed28d7220beb586c4a", "role": "CLIENT", "short_name": "FNQ9", "snr": 3.75, "status": null, "telemetry": {"air_util_tx": 0.872, "battery_level": 33, "channel_utilization": 31.18, "uptime_seconds": 165544, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 8933, "long_name": "Green Falcon", "next_hop": 153, "num": "0x6f95c8de", "position": {"altitude": 1507, "latitude": 33.058043, "location_source": "LOC_INTERNAL", "longitude": -107.753248, "time_offset_sec": 9029}, "public_key_hex": "4a0fd161559916272677d7be03a4385deee355bca63ede5f2e89ac17b4544cc6", "role": "CLIENT", "short_name": "GG11", "snr": 2.21, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 3961, "long_name": "Desert Arroyo", "next_hop": 104, "num": "0x70672210", "position": {"altitude": 2110, "latitude": 33.806679, "location_source": "LOC_INTERNAL", "longitude": -108.245578, "time_offset_sec": 4111}, "public_key_hex": "5f76a5eb313191ea039acdb18eb537840b25569c0605981c8edbb7e2c484b50d", "role": "CLIENT_MUTE", "short_name": "DTNH", "snr": 7.35, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.148, "battery_level": 31, "channel_utilization": 12.08, "uptime_seconds": 202950, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1016.01, "iaq": 19, "relative_humidity": 46.48, "temperature": 25.33}, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1898, "long_name": "Sleepy Moose", "next_hop": 135, "num": "0x70b79b40", "position": {"altitude": 1072, "latitude": 33.477423, "location_source": "LOC_INTERNAL", "longitude": -107.485895, "time_offset_sec": 1982}, "public_key_hex": "23f70e1dd5dd6191ac0d07dc43186a8bb3cae86918212db4a90cd65e4bec27fc", "role": "CLIENT", "short_name": "SEJI", "snr": 8.49, "status": null, "telemetry": {"air_util_tx": 0.426, "battery_level": 92, "channel_utilization": 5.77, "uptime_seconds": 34594, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.78, "iaq": 88, "relative_humidity": 78.78, "temperature": 17.98}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 81, "long_name": "Iron Dolphin", "next_hop": 0, "num": "0x71f35893", "position": {"altitude": 1195, "latitude": 33.158653, "location_source": "LOC_INTERNAL", "longitude": -106.688519, "time_offset_sec": 179}, "public_key_hex": "6f5d9f31615955d38cc92366808bd822a5754c33fcf5b22f2474952711b333a0", "role": "CLIENT_MUTE", "short_name": "I8FE", "snr": 8.95, "status": null, "telemetry": {"air_util_tx": 0.104, "battery_level": 37, "channel_utilization": 11.73, "uptime_seconds": 200720, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3827, "long_name": "Drowsy Pony", "next_hop": 0, "num": "0x726683a9", "position": {"altitude": 1471, "latitude": 34.724546, "location_source": "LOC_INTERNAL", "longitude": -107.151964, "time_offset_sec": 4047}, "public_key_hex": "ae5af96e0c00ec919f651fa6da80144a28b8ec69ac33f5b0de3282f19c1c1e7a", "role": "CLIENT", "short_name": "DDUV", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 2.128, "battery_level": 10, "channel_utilization": 3.03, "uptime_seconds": 145294, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1763, "long_name": "Steel Yucca", "next_hop": 0, "num": "0x73341f37", "position": {"altitude": 1716, "latitude": 33.486852, "location_source": "LOC_INTERNAL", "longitude": -108.240058, "time_offset_sec": 1912}, "public_key_hex": "f6cea0dd788cba03ac16d70b0bd99ae41018ff0be20d67497abb12d7327a41ba", "role": "CLIENT", "short_name": "SHCI", "snr": 3.0, "status": null, "telemetry": {"air_util_tx": 0.36, "battery_level": 38, "channel_utilization": 15.8, "uptime_seconds": 134943, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 139, "long_name": "Tiny Mole", "next_hop": 0, "num": "0x7348832d", "position": {"altitude": 1543, "latitude": 33.096162, "location_source": "LOC_INTERNAL", "longitude": -107.278135, "time_offset_sec": 297}, "public_key_hex": "1b0d2d4d35da0ced6fa31182ef5423eb536b7e3e53bdf5f98e58c44f54dc1530", "role": "TRACKER", "short_name": "TKFB", "snr": 11.46, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.26, "iaq": 66, "relative_humidity": 64.85, "temperature": 19.22}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 780, "long_name": "Tall Crane", "next_hop": 0, "num": "0x738e72e8", "position": {"altitude": 1695, "latitude": 32.448476, "location_source": "LOC_INTERNAL", "longitude": -106.519086, "time_offset_sec": 947}, "public_key_hex": "c3019685ef33305cfc329b54740d4ada92b4a40fb535425a089f1e3d0b4d0053", "role": "TAK", "short_name": "TV6K", "snr": 0.74, "status": null, "telemetry": {"air_util_tx": 1.299, "battery_level": 38, "channel_utilization": 12.46, "uptime_seconds": 51020, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1033.06, "iaq": 7, "relative_humidity": 65.48, "temperature": 25.7}, "hops_away": 0, "hw_model": "HELTEC_HT62", "last_heard_offset_sec": 4821, "long_name": "Hidden Trout", "next_hop": 0, "num": "0x7491806d", "position": {"altitude": 1434, "latitude": 33.056575, "location_source": "LOC_INTERNAL", "longitude": -107.720741, "time_offset_sec": 5075}, "public_key_hex": "7c8691bfc6658d31d67acd477f09750318317ce021dee1599bb108ed25cdf0e3", "role": "CLIENT", "short_name": "🦉", "snr": 11.07, "status": null, "telemetry": {"air_util_tx": 1.117, "battery_level": 49, "channel_utilization": 14.84, "uptime_seconds": 11493, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 5, "environment": null, "hops_away": 1, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 834, "long_name": "Hidden Bronco", "next_hop": 97, "num": "0x749590b2", "position": null, "public_key_hex": "522be6ad7b0c0737d1080a2dd82f8972aba976c16596884ce7e2a8f58e4e0dc1", "role": "CLIENT", "short_name": "H2R2", "snr": 7.6, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3119, "long_name": "Bright Bluff", "next_hop": 0, "num": "0x7629ea15", "position": {"altitude": 1657, "latitude": 32.845151, "location_source": "LOC_INTERNAL", "longitude": -107.295757, "time_offset_sec": 3314}, "public_key_hex": "e3fcb20d29c60cffaac484bc143758079667bd954af5dfb57e2f125a216379d8", "role": "CLIENT_BASE", "short_name": "B1SA", "snr": 6.42, "status": null, "telemetry": {"air_util_tx": 0.154, "battery_level": 28, "channel_utilization": 10.1, "uptime_seconds": 102430, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 749, "long_name": "Drowsy Mustang", "next_hop": 0, "num": "0x768eeba7", "position": {"altitude": 1345, "latitude": 32.71891, "location_source": "LOC_INTERNAL", "longitude": -106.516389, "time_offset_sec": 804}, "public_key_hex": "18ad33e52e42b79a698639dc4cd32e651f0a5fb5bd672f76381b0193fd9db55f", "role": "CLIENT", "short_name": "DV2L", "snr": 7.02, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.39, "iaq": 35, "relative_humidity": 55.44, "temperature": 29.33}, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 554, "long_name": "Drowsy Crow", "next_hop": 65, "num": "0x76dc5c41", "position": {"altitude": 844, "latitude": 33.041994, "location_source": "LOC_INTERNAL", "longitude": -107.408948, "time_offset_sec": 822}, "public_key_hex": "5304dd65402a959318fa4c834d9e03128c6c0ced8ac983d3666d33e9762c41e1", "role": "CLIENT", "short_name": "DMAJ", "snr": 6.41, "status": null, "telemetry": {"air_util_tx": 0.826, "battery_level": 64, "channel_utilization": 7.58, "uptime_seconds": 90535, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 6010, "long_name": "Iron Fox", "next_hop": 172, "num": "0x7723d1a0", "position": {"altitude": 1262, "latitude": 33.212945, "location_source": "LOC_INTERNAL", "longitude": -107.64994, "time_offset_sec": 6239}, "public_key_hex": "04ba223a1cd802a0bf24cf805578318d66509cae17d34ae5397e5b2b5837bb14", "role": "CLIENT", "short_name": "ITHM", "snr": 4.81, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 393, "long_name": "Quick Heron", "next_hop": 126, "num": "0x77fbcdf6", "position": {"altitude": 1617, "latitude": 33.585965, "location_source": "LOC_INTERNAL", "longitude": -106.371643, "time_offset_sec": 397}, "public_key_hex": "d0d19a00abd92f2bcf1592962a1313138225a83a8e87064cdb8114ecfba39ce3", "role": "CLIENT", "short_name": "🔥", "snr": 5.24, "status": null, "telemetry": {"air_util_tx": 0.794, "battery_level": 43, "channel_utilization": 21.17, "uptime_seconds": 58781, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 7221, "long_name": "Sleepy Colt", "next_hop": 0, "num": "0x790493d5", "position": {"altitude": 1295, "latitude": 33.4132, "location_source": "LOC_INTERNAL", "longitude": -106.63144, "time_offset_sec": 7510}, "public_key_hex": "", "role": "CLIENT", "short_name": "SCHP", "snr": 5.7, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2110, "long_name": "Drifting Mesa KE0ZH", "next_hop": 49, "num": "0x79d393b0", "position": {"altitude": 1682, "latitude": 32.968426, "location_source": "LOC_INTERNAL", "longitude": -107.457182, "time_offset_sec": 2339}, "public_key_hex": "6e0ae215f2384f9122ece761c69ba9e1a42073a3470047274fcdd72a0248cad4", "role": "CLIENT", "short_name": "DA8J", "snr": -0.97, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1759, "long_name": "Red Hare KX2EP", "next_hop": 70, "num": "0x7ad3664f", "position": {"altitude": 2141, "latitude": 33.229175, "location_source": "LOC_INTERNAL", "longitude": -107.993856, "time_offset_sec": 2018}, "public_key_hex": "b2bc9c7db94f5f5f0f6753219c59fdf7ab659d649d1c63bc059983b5025d7d51", "role": "CLIENT", "short_name": "R1OR", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.352, "battery_level": 27, "channel_utilization": 4.5, "uptime_seconds": 25899, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 8573, "long_name": "Floating Stag", "next_hop": 0, "num": "0x7bb70bb7", "position": {"altitude": 1272, "latitude": 33.168645, "location_source": "LOC_INTERNAL", "longitude": -107.684837, "time_offset_sec": 8756}, "public_key_hex": "b6b88b3e8a9e389b8c41dd7b6a26bf2180090f59d4f1d8e577297aebe4b34562", "role": "CLIENT", "short_name": "FZ3H", "snr": 4.97, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.936, "battery_level": 27, "channel_utilization": 7.47, "uptime_seconds": 76225, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2148, "long_name": "Rough Bear", "next_hop": 241, "num": "0x7c224653", "position": {"altitude": 1438, "latitude": 34.000152, "location_source": "LOC_INTERNAL", "longitude": -106.625412, "time_offset_sec": 2152}, "public_key_hex": "331ea14519572dfc8abde388ba5d1c29810e4c1934d08f9bf43f49cc8505a54b", "role": "CLIENT", "short_name": "RNUC", "snr": 7.3, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.75, "battery_level": 38, "channel_utilization": 9.21, "uptime_seconds": 407434, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 432, "long_name": "Burning Bronco", "next_hop": 0, "num": "0x7ccb0a96", "position": {"altitude": 1365, "latitude": 33.576907, "location_source": "LOC_INTERNAL", "longitude": -107.563729, "time_offset_sec": 581}, "public_key_hex": "8cca6b93f5b2f58f481c81480578391486d8f0e5bda344fe5dc6a29c447c10e7", "role": "TRACKER", "short_name": "BGCJ", "snr": 6.05, "status": null, "telemetry": {"air_util_tx": 0.056, "battery_level": 93, "channel_utilization": 7.59, "uptime_seconds": 165200, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1428, "long_name": "Shady Lion", "next_hop": 0, "num": "0x7ce782e2", "position": {"altitude": 1380, "latitude": 32.431543, "location_source": "LOC_INTERNAL", "longitude": -107.604725, "time_offset_sec": 1458}, "public_key_hex": "0daf87c9019c3bb9741fd05905f67682b647dc97733fa5078e27df5fa05b93c5", "role": "CLIENT", "short_name": "SWX9", "snr": 8.03, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1804, "long_name": "Mountain Cedar", "next_hop": 0, "num": "0x7e0d0b69", "position": {"altitude": 1321, "latitude": 33.11493, "location_source": "LOC_INTERNAL", "longitude": -107.449334, "time_offset_sec": 2074}, "public_key_hex": "bf5042d4e85efc712dd1e4681db857f2b3484e1739e897f2a49792ee0f18c0bc", "role": "CLIENT", "short_name": "M7J1", "snr": 0.98, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.639, "battery_level": 81, "channel_utilization": 3.58, "uptime_seconds": 19425, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 882, "long_name": "Rough Hawk", "next_hop": 32, "num": "0x7e39ee69", "position": {"altitude": 1278, "latitude": 33.405852, "location_source": "LOC_INTERNAL", "longitude": -106.491004, "time_offset_sec": 943}, "public_key_hex": "521e42d928118b4cdafb25bc69f8974f03ffc910c991069ec726dbb02ec7778c", "role": "CLIENT", "short_name": "RHCK", "snr": 8.01, "status": null, "telemetry": {"air_util_tx": 0.672, "battery_level": 55, "channel_utilization": 29.49, "uptime_seconds": 105577, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 788, "long_name": "Sleepy Iguana", "next_hop": 0, "num": "0x7e49f748", "position": {"altitude": 1316, "latitude": 33.409957, "location_source": "LOC_INTERNAL", "longitude": -107.430865, "time_offset_sec": 891}, "public_key_hex": "174cdc5204bdf752801c9abd6aaf17a5b9602b484f9ba55e4ec9a8b2e01f6adb", "role": "CLIENT", "short_name": "SDJ2", "snr": -5.74, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 11666, "long_name": "Howling Squirrel", "next_hop": 0, "num": "0x7eb8bc45", "position": {"altitude": 1520, "latitude": 32.659288, "location_source": "LOC_INTERNAL", "longitude": -106.233421, "time_offset_sec": 11833}, "public_key_hex": "d804b803ddb8b9a6bb456b2965da9eef06a6bc1db080049c4e278ed4860f292e", "role": "CLIENT_MUTE", "short_name": "H9UH", "snr": 8.35, "status": null, "telemetry": {"air_util_tx": 1.476, "battery_level": 32, "channel_utilization": 18.0, "uptime_seconds": 145120, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": {"barometric_pressure": 992.43, "iaq": 26, "relative_humidity": 70.77, "temperature": 16.27}, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1291, "long_name": "Tiny Seal", "next_hop": 242, "num": "0x7f039d52", "position": {"altitude": 1391, "latitude": 33.312521, "location_source": "LOC_INTERNAL", "longitude": -107.046926, "time_offset_sec": 1350}, "public_key_hex": "51db617b30735e34a4d75ad1725ef1b002ec1a9a38ef32d4b51acf9e8c7b146f", "role": "CLIENT", "short_name": "T82O", "snr": 9.81, "status": null, "telemetry": {"air_util_tx": 1.794, "battery_level": 68, "channel_utilization": 14.75, "uptime_seconds": 68126, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 356, "long_name": "Green Tortoise", "next_hop": 0, "num": "0x7f95833e", "position": {"altitude": 1431, "latitude": 32.123231, "location_source": "LOC_INTERNAL", "longitude": -107.638817, "time_offset_sec": 405}, "public_key_hex": "c925ceda2f3e4bbbf987e13974a3d3fa532794dd994730a4b784d9fbebd09784", "role": "CLIENT", "short_name": "GK1S", "snr": 1.37, "status": null, "telemetry": {"air_util_tx": 1.209, "battery_level": 94, "channel_utilization": 16.27, "uptime_seconds": 136843, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3472, "long_name": "Sleepy Aspen", "next_hop": 0, "num": "0x7f98caa8", "position": {"altitude": 1866, "latitude": 33.495868, "location_source": "LOC_INTERNAL", "longitude": -108.063052, "time_offset_sec": 3488}, "public_key_hex": "0b2dc28d0c3b5d165ec9b161a0943737d6e2d88b545d5da717182f8f152e9e43", "role": "CLIENT", "short_name": "SX11", "snr": 8.03, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.845, "battery_level": 25, "channel_utilization": 16.27, "uptime_seconds": 165688, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4141, "long_name": "Happy Arroyo", "next_hop": 111, "num": "0x7fe40bcc", "position": {"altitude": 1958, "latitude": 32.443587, "location_source": "LOC_INTERNAL", "longitude": -106.084226, "time_offset_sec": 4285}, "public_key_hex": "", "role": "CLIENT", "short_name": "HZAG", "snr": 8.71, "status": null, "telemetry": null} diff --git a/test/fixtures/nodedb/seed_v25_0500.jsonl b/test/fixtures/nodedb/seed_v25_0500.jsonl new file mode 100644 index 00000000000..70092dba781 --- /dev/null +++ b/test/fixtures/nodedb/seed_v25_0500.jsonl @@ -0,0 +1,501 @@ +{"_meta": {"centroid": [33.1284, -107.2528], "count": 500, "coverage": {"environment": 0.25, "position": 0.85, "status": 0.4, "telemetry": 0.7}, "generated_at_iso": "1970-08-23T11:55:12Z", "last_heard_max_sec": 604800, "last_heard_mean_sec": 3600, "my_node_num_excluded": null, "seed": 20260512, "spread_km": 60.0, "version": 25}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3197, "long_name": "Frosty Whale", "next_hop": 173, "num": "0x00b40906", "position": {"altitude": 1519, "latitude": 32.151666, "location_source": "LOC_INTERNAL", "longitude": -107.6939, "time_offset_sec": 3317}, "public_key_hex": "3be3f3db2ea4843edb428e1d50d5f7096daf217bd4259709c44b7c5f7031a1c8", "role": "CLIENT", "short_name": "FL7K", "snr": 5.16, "status": null, "telemetry": {"air_util_tx": 0.197, "battery_level": 71, "channel_utilization": 2.45, "uptime_seconds": 61699, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 17404, "long_name": "Drifting Yucca", "next_hop": 0, "num": "0x011e2fed", "position": {"altitude": 1597, "latitude": 33.929344, "location_source": "LOC_INTERNAL", "longitude": -107.48397, "time_offset_sec": 17610}, "public_key_hex": "5e83f0b1e902e0609e31daa68131955ca54601b6a401ef40f3dd3075578e1d26", "role": "CLIENT", "short_name": "DGNX", "snr": 6.01, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 998.92, "iaq": 1, "relative_humidity": 41.97, "temperature": 37.71}, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3471, "long_name": "Shady Pike", "next_hop": 173, "num": "0x0189ac5e", "position": {"altitude": 1726, "latitude": 33.013084, "location_source": "LOC_INTERNAL", "longitude": -107.196206, "time_offset_sec": 3607}, "public_key_hex": "d7941aebdd024277a3c21efc85e57993d8619bae9d020e48a10698a63999009d", "role": "TRACKER", "short_name": "SVCS", "snr": 6.77, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.18, "battery_level": 88, "channel_utilization": 15.72, "uptime_seconds": 104301, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.77, "iaq": 66, "relative_humidity": 60.03, "temperature": 16.12}, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 1955, "long_name": "Lost Cactus", "next_hop": 142, "num": "0x01b84c91", "position": null, "public_key_hex": "928626e03eabe5f6236aab36347b40dd833ba7903cc16fbe4b3871900a4c81ee", "role": "CLIENT", "short_name": "🗻", "snr": 4.24, "status": null, "telemetry": {"air_util_tx": 0.866, "battery_level": 50, "channel_utilization": 31.43, "uptime_seconds": 58418, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.26, "iaq": 51, "relative_humidity": 60.87, "temperature": 25.07}, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 63, "long_name": "Forest Shark", "next_hop": 172, "num": "0x01d003a2", "position": {"altitude": 1242, "latitude": 33.298407, "location_source": "LOC_INTERNAL", "longitude": -107.374181, "time_offset_sec": 348}, "public_key_hex": "c638bc498f50956b06bdadd5a155b5ffed4efd86d4edec9e2ef33e578b7dcb26", "role": "CLIENT", "short_name": "FTDK", "snr": 2.09, "status": null, "telemetry": {"air_util_tx": 0.196, "battery_level": 79, "channel_utilization": 19.63, "uptime_seconds": 25163, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 2213, "long_name": "Sneaky Viper", "next_hop": 119, "num": "0x021d5a39", "position": {"altitude": 1650, "latitude": 32.828212, "location_source": "LOC_INTERNAL", "longitude": -106.562505, "time_offset_sec": 2411}, "public_key_hex": "1e20cd9b42884ea7b5a13ed9474886d9841c9102408fefac5d426f14ab3c26e6", "role": "CLIENT", "short_name": "ST00", "snr": 8.44, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 2.146, "battery_level": 45, "channel_utilization": 33.11, "uptime_seconds": 4177, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 34, "long_name": "Solar Sage", "next_hop": 0, "num": "0x02486b0c", "position": {"altitude": 1566, "latitude": 33.25455, "location_source": "LOC_INTERNAL", "longitude": -108.276457, "time_offset_sec": 282}, "public_key_hex": "639100e236b166787ce42d49d5ab889f3d678bc11fa947250247f89c2659cdde", "role": "CLIENT", "short_name": "🦂", "snr": 2.71, "status": null, "telemetry": {"air_util_tx": 0.818, "battery_level": 15, "channel_utilization": 10.26, "uptime_seconds": 14197, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.55, "iaq": 93, "relative_humidity": 61.52, "temperature": 22.54}, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5878, "long_name": "Whispering Cougar", "next_hop": 119, "num": "0x02554ce8", "position": {"altitude": 943, "latitude": 34.283555, "location_source": "LOC_INTERNAL", "longitude": -107.74003, "time_offset_sec": 5911}, "public_key_hex": "9cd4142858ee82e04b22ba3b33f7c032b40063f39bc5c5a0d3f492636b598d9e", "role": "CLIENT", "short_name": "WOT9", "snr": 6.19, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 1577, "long_name": "Iron Whale", "next_hop": 0, "num": "0x0430fd13", "position": {"altitude": 1620, "latitude": 32.657264, "location_source": "LOC_INTERNAL", "longitude": -107.204545, "time_offset_sec": 1871}, "public_key_hex": "c9a747534744cb9ee1bd2c5b253182175bee5e5f2a5284f9530889836593852e", "role": "CLIENT", "short_name": "I189", "snr": 5.64, "status": null, "telemetry": {"air_util_tx": 0.121, "battery_level": 50, "channel_utilization": 18.75, "uptime_seconds": 49495, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4591, "long_name": "Sneaky Heron", "next_hop": 138, "num": "0x04b75b6d", "position": {"altitude": 1287, "latitude": 33.452725, "location_source": "LOC_INTERNAL", "longitude": -106.460239, "time_offset_sec": 4757}, "public_key_hex": "11afa777f0189e7bc7f93f065be232df98e21f8b9ecdde49d738201a2c4956fc", "role": "CLIENT", "short_name": "🌊", "snr": 5.38, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 174, "long_name": "Sunny Pony", "next_hop": 108, "num": "0x05267c0d", "position": {"altitude": 1003, "latitude": 33.195449, "location_source": "LOC_INTERNAL", "longitude": -106.986062, "time_offset_sec": 439}, "public_key_hex": "7a195aad3491a11143e61ab8201b4ce1a8552c37985fb4a7d4b885c5cd5323be", "role": "CLIENT", "short_name": "SRBM", "snr": 7.29, "status": null, "telemetry": {"air_util_tx": 0.668, "battery_level": 23, "channel_utilization": 9.39, "uptime_seconds": 117938, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5294, "long_name": "Lunar Gecko", "next_hop": 157, "num": "0x054f2796", "position": null, "public_key_hex": "201aff9fd3405ff131e325f089a44ccd8ce67184dae2015b4a10e142a1c4550f", "role": "CLIENT", "short_name": "LESX", "snr": 7.12, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.598, "battery_level": 14, "channel_utilization": 16.05, "uptime_seconds": 475034, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 5, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 1380, "long_name": "Howling Eagle", "next_hop": 35, "num": "0x058f496a", "position": {"altitude": 1347, "latitude": 33.202974, "location_source": "LOC_INTERNAL", "longitude": -107.351541, "time_offset_sec": 1541}, "public_key_hex": "11955ea65b430f745ead784933c3ac04798aa1f9f094fe49cc7fb3c91e50f08e", "role": "CLIENT", "short_name": "HZDF", "snr": 9.38, "status": null, "telemetry": {"air_util_tx": 1.512, "battery_level": 55, "channel_utilization": 8.96, "uptime_seconds": 88941, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7137, "long_name": "Sunny Shark", "next_hop": 21, "num": "0x05b218d2", "position": {"altitude": 1536, "latitude": 33.202605, "location_source": "LOC_INTERNAL", "longitude": -107.383081, "time_offset_sec": 7193}, "public_key_hex": "99784925060b03ef3230387e5b124d347a7308525c830a113a654df744c0e836", "role": "CLIENT", "short_name": "🦋", "snr": 6.96, "status": null, "telemetry": {"air_util_tx": 0.001, "battery_level": 73, "channel_utilization": 6.35, "uptime_seconds": 95615, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1014.17, "iaq": 34, "relative_humidity": 36.77, "temperature": 27.02}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4830, "long_name": "Found Eagle", "next_hop": 0, "num": "0x05d3737a", "position": {"altitude": 1757, "latitude": 34.578404, "location_source": "LOC_INTERNAL", "longitude": -106.975336, "time_offset_sec": 4918}, "public_key_hex": "2ea628e36802c997cca7426463e167fa75986add373452a27cfa0210242e5268", "role": "CLIENT", "short_name": "FXWZ", "snr": 3.57, "status": null, "telemetry": {"air_util_tx": 0.362, "battery_level": 70, "channel_utilization": 1.49, "uptime_seconds": 12694, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 9839, "long_name": "Wandering Marmot", "next_hop": 0, "num": "0x05fc1a3f", "position": {"altitude": 1129, "latitude": 32.659091, "location_source": "LOC_INTERNAL", "longitude": -107.505247, "time_offset_sec": 9923}, "public_key_hex": "a337f9ab3ea8333215289724cbad941ae7621da8b8d6e3d5ab57e71524e5709a", "role": "CLIENT", "short_name": "W250", "snr": 2.53, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.586, "battery_level": 56, "channel_utilization": 12.14, "uptime_seconds": 42260, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.99, "iaq": 0, "relative_humidity": 71.41, "temperature": 15.52}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 408, "long_name": "New Gecko", "next_hop": 61, "num": "0x066d6e76", "position": {"altitude": 1747, "latitude": 32.773832, "location_source": "LOC_INTERNAL", "longitude": -107.220236, "time_offset_sec": 698}, "public_key_hex": "", "role": "CLIENT", "short_name": "N2ZX", "snr": 0.58, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.313, "battery_level": 92, "channel_utilization": 3.14, "uptime_seconds": 15258, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2782, "long_name": "Fast Yucca KX5XX", "next_hop": 141, "num": "0x06c7ea9f", "position": {"altitude": 1472, "latitude": 33.093005, "location_source": "LOC_INTERNAL", "longitude": -107.511463, "time_offset_sec": 2851}, "public_key_hex": "12c1f6217e378cb6f61533272c9641fd6eaf282c3180a3a71b1c08b1a89ffbf0", "role": "CLIENT", "short_name": "FLXR", "snr": 7.65, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 997.01, "iaq": 35, "relative_humidity": 61.92, "temperature": 24.34}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 692, "long_name": "Found Ridge", "next_hop": 0, "num": "0x06cab439", "position": {"altitude": 933, "latitude": 33.474999, "location_source": "LOC_INTERNAL", "longitude": -107.067398, "time_offset_sec": 845}, "public_key_hex": "", "role": "CLIENT", "short_name": "FDF7", "snr": -0.59, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 638, "long_name": "Copper Bass", "next_hop": 154, "num": "0x06dda523", "position": {"altitude": 1395, "latitude": 33.438741, "location_source": "LOC_INTERNAL", "longitude": -108.072906, "time_offset_sec": 873}, "public_key_hex": "", "role": "CLIENT", "short_name": "CVYO", "snr": 11.55, "status": null, "telemetry": {"air_util_tx": 1.484, "battery_level": 13, "channel_utilization": 6.1, "uptime_seconds": 98410, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 112, "long_name": "Green Gecko", "next_hop": 78, "num": "0x072e0237", "position": {"altitude": 853, "latitude": 34.012327, "location_source": "LOC_INTERNAL", "longitude": -107.188724, "time_offset_sec": 262}, "public_key_hex": "700d510170e7fe912da2da8cbf930e43c6710829c8faa89af423910945e6265e", "role": "CLIENT", "short_name": "GGC5", "snr": 10.05, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1245, "long_name": "Brave Bear", "next_hop": 0, "num": "0x0783e8d1", "position": {"altitude": 1537, "latitude": 33.749784, "location_source": "LOC_INTERNAL", "longitude": -107.841257, "time_offset_sec": 1414}, "public_key_hex": "178879e3e697a4c3c52ec66fbe8dd216cfa64ade0135a634cc8687ea3ade7320", "role": "TRACKER", "short_name": "BFRT", "snr": 3.14, "status": null, "telemetry": {"air_util_tx": 1.759, "battery_level": 30, "channel_utilization": 14.5, "uptime_seconds": 78399, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2045, "long_name": "Stone Bear", "next_hop": 0, "num": "0x078cfba6", "position": null, "public_key_hex": "467443a858386925299334fbf59f7754583f47a8731763404a98c9b30a74d90d", "role": "CLIENT", "short_name": "SORT", "snr": 6.29, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.588, "battery_level": 79, "channel_utilization": 6.6, "uptime_seconds": 53720, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5613, "long_name": "Frosty Elk", "next_hop": 0, "num": "0x07948431", "position": {"altitude": 1186, "latitude": 32.122731, "location_source": "LOC_INTERNAL", "longitude": -106.693574, "time_offset_sec": 5803}, "public_key_hex": "7133e1eda8cf2b46701bb55a1cdc136f4dd4efa26c296f92188c770b6f6b11c5", "role": "CLIENT", "short_name": "F4ZQ", "snr": 6.09, "status": null, "telemetry": {"air_util_tx": 0.318, "battery_level": 101, "channel_utilization": 8.56, "uptime_seconds": 76257, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4207, "long_name": "Frosty Elk", "next_hop": 0, "num": "0x079d9ed4", "position": {"altitude": 1431, "latitude": 33.80937, "location_source": "LOC_INTERNAL", "longitude": -107.229029, "time_offset_sec": 4335}, "public_key_hex": "8c7857ee2dc180990083b56b1114163bacad59fd8f324a7dd6a3c52ce13880a9", "role": "TRACKER", "short_name": "FNZ9", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.514, "battery_level": 87, "channel_utilization": 7.82, "uptime_seconds": 5846, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 102, "long_name": "Wandering Hawk", "next_hop": 0, "num": "0x0839477d", "position": {"altitude": 1244, "latitude": 33.568907, "location_source": "LOC_INTERNAL", "longitude": -107.90779, "time_offset_sec": 292}, "public_key_hex": "4e12b947441a7467ea24d8ec904d0525483c6f4a5f53b73fafe1e1a1bb55414d", "role": "CLIENT", "short_name": "WD7D", "snr": 5.47, "status": null, "telemetry": {"air_util_tx": 0.391, "battery_level": 94, "channel_utilization": 4.9, "uptime_seconds": 106184, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 3873, "long_name": "Sneaky Elk", "next_hop": 0, "num": "0x087eacd5", "position": {"altitude": 1012, "latitude": 32.148225, "location_source": "LOC_INTERNAL", "longitude": -106.891906, "time_offset_sec": 3959}, "public_key_hex": "5dd12b3b478e5b2e86998d3785934c71d80505a4d650d72fc8f1a2f4b83c61f9", "role": "CLIENT_MUTE", "short_name": "SS28", "snr": 7.13, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 402, "long_name": "Stone Lion", "next_hop": 138, "num": "0x08b8aa97", "position": {"altitude": 1643, "latitude": 33.526974, "location_source": "LOC_INTERNAL", "longitude": -107.248283, "time_offset_sec": 581}, "public_key_hex": "c7a7d65cd7befa04cc4cdd2a7923f43797fc07eeb7816dd0a71aa4376daa9a8a", "role": "ROUTER", "short_name": "S461", "snr": 5.4, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.831, "battery_level": 70, "channel_utilization": 7.46, "uptime_seconds": 510, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 585, "long_name": "Dusk Eagle", "next_hop": 53, "num": "0x0942c7fa", "position": {"altitude": 1276, "latitude": 33.564835, "location_source": "LOC_INTERNAL", "longitude": -107.990751, "time_offset_sec": 635}, "public_key_hex": "33d6722868eb0b3446af8787b9d79446a8a3af0a85a50f4e94d7bfcf2567872c", "role": "CLIENT", "short_name": "DW3D", "snr": 1.03, "status": null, "telemetry": {"air_util_tx": 1.126, "battery_level": 84, "channel_utilization": 11.33, "uptime_seconds": 67579, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5824, "long_name": "Sleepy Iguana", "next_hop": 181, "num": "0x09681313", "position": {"altitude": 1536, "latitude": 33.940617, "location_source": "LOC_INTERNAL", "longitude": -107.400953, "time_offset_sec": 5935}, "public_key_hex": "7e768501c7c0b27a253cad9dfe04de9ea9f0fb7178cc52e749613e7a2448aacc", "role": "CLIENT", "short_name": "SPCZ", "snr": 8.54, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 9057, "long_name": "Drifting Bear", "next_hop": 0, "num": "0x099cef2d", "position": {"altitude": 1154, "latitude": 32.927688, "location_source": "LOC_INTERNAL", "longitude": -107.983437, "time_offset_sec": 9311}, "public_key_hex": "df026c53580a0014d8d0b0acf05b0a7699129b38ef54b62a6ad6b459f80cde4c", "role": "CLIENT_BASE", "short_name": "DUM8", "snr": 4.26, "status": null, "telemetry": {"air_util_tx": 0.22, "battery_level": 10, "channel_utilization": 20.01, "uptime_seconds": 4610, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 5657, "long_name": "Steel Eagle KE0EN", "next_hop": 218, "num": "0x09c2d2c5", "position": {"altitude": 1432, "latitude": 34.093196, "location_source": "LOC_INTERNAL", "longitude": -107.180771, "time_offset_sec": 5761}, "public_key_hex": "4a013f9630e13615cba93620efa713f6eb856dd9a0889ebf6d538a3fc1d8905b", "role": "CLIENT", "short_name": "S7YX", "snr": 3.85, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.252, "battery_level": 83, "channel_utilization": 8.77, "uptime_seconds": 22235, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2759, "long_name": "Lunar Crow", "next_hop": 0, "num": "0x09f3a0c9", "position": {"altitude": 1513, "latitude": 32.201649, "location_source": "LOC_INTERNAL", "longitude": -107.870751, "time_offset_sec": 2857}, "public_key_hex": "ffd5dd9578ff4a710f98bd3726af95ddd74c7ed48f6bc326dd8c82fea467c688", "role": "CLIENT", "short_name": "L71T", "snr": 5.65, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.45, "battery_level": 62, "channel_utilization": 12.01, "uptime_seconds": 13126, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 365, "long_name": "Sneaky Yucca", "next_hop": 0, "num": "0x0a227668", "position": {"altitude": 1357, "latitude": 33.858842, "location_source": "LOC_INTERNAL", "longitude": -107.968186, "time_offset_sec": 458}, "public_key_hex": "a82a9214cba439128a6c66746ef3657b91d20818ace487f368054430497cd801", "role": "CLIENT", "short_name": "STUO", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.185, "battery_level": 93, "channel_utilization": 1.96, "uptime_seconds": 304258, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 59, "long_name": "Lone Hawk", "next_hop": 61, "num": "0x0a26c767", "position": {"altitude": 1736, "latitude": 33.066119, "location_source": "LOC_INTERNAL", "longitude": -107.365383, "time_offset_sec": 299}, "public_key_hex": "1325d09f2302a1a82f853963373de3719162f3029523553cacd9a8c8ff924e58", "role": "CLIENT", "short_name": "LUNM", "snr": 5.04, "status": null, "telemetry": {"air_util_tx": 0.599, "battery_level": 26, "channel_utilization": 18.17, "uptime_seconds": 39452, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5198, "long_name": "Slow Whale", "next_hop": 0, "num": "0x0a4e9ac7", "position": {"altitude": 1324, "latitude": 33.512009, "location_source": "LOC_INTERNAL", "longitude": -107.240115, "time_offset_sec": 5468}, "public_key_hex": "9f5080010b809a79bf8326674391a223a0ab14ad189d57f1659e8ba88a9910b0", "role": "CLIENT", "short_name": "SVG3", "snr": 7.72, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 6777, "long_name": "Soft Moose", "next_hop": 0, "num": "0x0a5b2072", "position": {"altitude": 1405, "latitude": 33.166582, "location_source": "LOC_INTERNAL", "longitude": -106.571049, "time_offset_sec": 7036}, "public_key_hex": "5dabc625e1757b630efd1bbe1dd2f31089b0e1dfc3929d72e5c45419748b997b", "role": "CLIENT", "short_name": "SDXX", "snr": 9.36, "status": null, "telemetry": {"air_util_tx": 0.91, "battery_level": 19, "channel_utilization": 4.61, "uptime_seconds": 51974, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 6114, "long_name": "Blue Iguana", "next_hop": 140, "num": "0x0ae73f47", "position": {"altitude": 1565, "latitude": 32.903508, "location_source": "LOC_INTERNAL", "longitude": -107.918895, "time_offset_sec": 6394}, "public_key_hex": "4824b9ca37d9e316d315bb42cb2dedb6d75125da6d11a12a3227a1f60b145f11", "role": "CLIENT", "short_name": "BSL5", "snr": 6.76, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 293, "long_name": "Misty Wolf", "next_hop": 23, "num": "0x0b094c92", "position": {"altitude": 1016, "latitude": 33.315313, "location_source": "LOC_INTERNAL", "longitude": -107.122867, "time_offset_sec": 406}, "public_key_hex": "1d08505017b6456355c7670bd0ce8c6701ea43b67ca338140306ea6afb5a0df7", "role": "CLIENT", "short_name": "MJK1", "snr": 8.08, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 9125, "long_name": "Storm Phoenix", "next_hop": 224, "num": "0x0b24a78e", "position": null, "public_key_hex": "", "role": "ROUTER_LATE", "short_name": "SIFA", "snr": 4.14, "status": null, "telemetry": {"air_util_tx": 0.06, "battery_level": 20, "channel_utilization": 8.81, "uptime_seconds": 94121, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1016, "long_name": "Giant Doe", "next_hop": 121, "num": "0x0b6940c2", "position": {"altitude": 1321, "latitude": 33.478159, "location_source": "LOC_INTERNAL", "longitude": -107.629302, "time_offset_sec": 1142}, "public_key_hex": "9b2dcb301b031c955820f4ccd600248598d76e606df1e1654374cfe95f426d1c", "role": "SENSOR", "short_name": "GM0W", "snr": 3.34, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 1.146, "battery_level": 57, "channel_utilization": 25.09, "uptime_seconds": 159619, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.0, "iaq": 68, "relative_humidity": 82.96, "temperature": 28.08}, "hops_away": 1, "hw_model": "RAK3312", "last_heard_offset_sec": 8146, "long_name": "Mountain Whale", "next_hop": 18, "num": "0x0bd266b2", "position": null, "public_key_hex": "88d5229035bc6d41e800ce92858bb0a641626aae0415ab68ebce9a716b4608c1", "role": "ROUTER", "short_name": "MNX3", "snr": 3.2, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.49, "battery_level": 16, "channel_utilization": 14.79, "uptime_seconds": 9989, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 7991, "long_name": "Loud Squirrel", "next_hop": 102, "num": "0x0c008042", "position": {"altitude": 1286, "latitude": 33.407558, "location_source": "LOC_INTERNAL", "longitude": -107.246507, "time_offset_sec": 8012}, "public_key_hex": "dc12d958c362cba44f5e51cd494b2d5146a2bc27a49f00bbe52568b48b12c0b0", "role": "TRACKER", "short_name": "LK99", "snr": 4.49, "status": null, "telemetry": {"air_util_tx": 1.485, "battery_level": 73, "channel_utilization": 28.13, "uptime_seconds": 4958, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 920, "long_name": "Shady Lynx", "next_hop": 219, "num": "0x0cf9d526", "position": {"altitude": 1597, "latitude": 32.392711, "location_source": "LOC_INTERNAL", "longitude": -107.605544, "time_offset_sec": 1003}, "public_key_hex": "", "role": "CLIENT", "short_name": "S8XM", "snr": 7.37, "status": null, "telemetry": {"air_util_tx": 0.901, "battery_level": 40, "channel_utilization": 7.94, "uptime_seconds": 66704, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 3355, "long_name": "Sharp Mamba", "next_hop": 0, "num": "0x0cfb3f42", "position": {"altitude": 1362, "latitude": 32.872047, "location_source": "LOC_INTERNAL", "longitude": -106.062756, "time_offset_sec": 3597}, "public_key_hex": "d2466d6c7a0b2fcdbc3e9e031fb15e9823d49974a8827414a95864e60704b09b", "role": "CLIENT", "short_name": "SHHU", "snr": 6.36, "status": null, "telemetry": {"air_util_tx": 0.318, "battery_level": 12, "channel_utilization": 14.33, "uptime_seconds": 240511, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 4138, "long_name": "Soft Seal", "next_hop": 0, "num": "0x0d1114f7", "position": {"altitude": 1159, "latitude": 32.862239, "location_source": "LOC_INTERNAL", "longitude": -107.364042, "time_offset_sec": 4194}, "public_key_hex": "0904dcbd8806913637e185a58f0a9702fe1dfd7eb1a299752031d53e87f3472e", "role": "CLIENT", "short_name": "🦉", "snr": 1.54, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.19, "iaq": 39, "relative_humidity": 31.48, "temperature": 22.47}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5500, "long_name": "Rough Mole AB6UD", "next_hop": 0, "num": "0x0d1134f2", "position": {"altitude": 1246, "latitude": 32.923274, "location_source": "LOC_INTERNAL", "longitude": -107.365595, "time_offset_sec": 5501}, "public_key_hex": "e6f5ad1ee2cca9af4e6964dd650337302bd9f53d123ec3873458bbafceb4e51d", "role": "CLIENT", "short_name": "R6Q0", "snr": 0.35, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.109, "battery_level": 24, "channel_utilization": 9.18, "uptime_seconds": 305791, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 948, "long_name": "Sneaky Gecko", "next_hop": 0, "num": "0x0d6592a2", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "SM2S", "snr": 0.81, "status": null, "telemetry": {"air_util_tx": 0.139, "battery_level": 92, "channel_utilization": 15.72, "uptime_seconds": 114522, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 366, "long_name": "Loud Mamba", "next_hop": 0, "num": "0x0d968387", "position": {"altitude": 1407, "latitude": 32.519848, "location_source": "LOC_INTERNAL", "longitude": -107.380676, "time_offset_sec": 417}, "public_key_hex": "", "role": "CLIENT", "short_name": "LC5U", "snr": 4.14, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.571, "battery_level": 100, "channel_utilization": 19.43, "uptime_seconds": 80280, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.03, "iaq": 27, "relative_humidity": 63.2, "temperature": 30.0}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 254, "long_name": "Canyon Doe", "next_hop": 0, "num": "0x0da58b20", "position": {"altitude": 1054, "latitude": 33.143337, "location_source": "LOC_INTERNAL", "longitude": -107.818269, "time_offset_sec": 289}, "public_key_hex": "4114b0445f6a57bc0faf565f772acb9571fbbd5d1a7df7755692413b3eb71ce4", "role": "TRACKER", "short_name": "CBZJ", "snr": 10.44, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1007.83, "iaq": 73, "relative_humidity": 47.03, "temperature": 16.81}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1829, "long_name": "White Pine", "next_hop": 0, "num": "0x0e2eefbc", "position": {"altitude": 1436, "latitude": 33.443858, "location_source": "LOC_INTERNAL", "longitude": -108.82553, "time_offset_sec": 1883}, "public_key_hex": "c7e997350b80f8ac4c89a2e48b6afc0af224c50ed253842f31e4c2e82d7e6dcb", "role": "CLIENT", "short_name": "WSBP", "snr": 7.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.15, "iaq": 65, "relative_humidity": 58.13, "temperature": 21.44}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2998, "long_name": "Black Lion", "next_hop": 25, "num": "0x0e40250b", "position": {"altitude": 1280, "latitude": 33.201427, "location_source": "LOC_INTERNAL", "longitude": -106.820707, "time_offset_sec": 3195}, "public_key_hex": "0c9a4734918a056d180a803e6ca48645062b6b44249dc9ccef860793be97f881", "role": "CLIENT", "short_name": "B0WQ", "snr": 9.36, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.527, "battery_level": 71, "channel_utilization": 16.14, "uptime_seconds": 155116, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 9399, "long_name": "Drowsy Mustang", "next_hop": 214, "num": "0x0ea265d7", "position": {"altitude": 1393, "latitude": 32.898121, "location_source": "LOC_INTERNAL", "longitude": -107.524646, "time_offset_sec": 9491}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "D8ME", "snr": 5.32, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.48, "battery_level": 65, "channel_utilization": 10.72, "uptime_seconds": 15078, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 360, "long_name": "Iron Crow", "next_hop": 0, "num": "0x0ef13332", "position": {"altitude": 1469, "latitude": 32.917393, "location_source": "LOC_INTERNAL", "longitude": -106.817302, "time_offset_sec": 485}, "public_key_hex": "b43b345c37ed2ac879d0c1449f7f4a5c10b3cf8cdf5135b2b37014e44b8dbaa6", "role": "CLIENT", "short_name": "I5SF", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.214, "battery_level": 35, "channel_utilization": 29.6, "uptime_seconds": 51980, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4082, "long_name": "Smooth Doe", "next_hop": 0, "num": "0x0f446c80", "position": {"altitude": 1122, "latitude": 31.767256, "location_source": "LOC_INTERNAL", "longitude": -106.825891, "time_offset_sec": 4255}, "public_key_hex": "8e0f9b2b699d27476d8b5e5c9e1a5735359eb7b192932dbc107024f5e83b69e1", "role": "CLIENT_MUTE", "short_name": "ST7O", "snr": 3.14, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAG", "last_heard_offset_sec": 390, "long_name": "Canyon Phoenix", "next_hop": 165, "num": "0x0f630f75", "position": {"altitude": 1089, "latitude": 33.100863, "location_source": "LOC_INTERNAL", "longitude": -107.714667, "time_offset_sec": 404}, "public_key_hex": "d0fc8f0d06868c85f6e4b9d1efd2d3ed2e8fcb2ca39d0b344bf9bf3a26e7b259", "role": "CLIENT", "short_name": "C20S", "snr": 4.37, "status": null, "telemetry": {"air_util_tx": 0.57, "battery_level": 50, "channel_utilization": 4.35, "uptime_seconds": 237778, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 17446, "long_name": "Lost Sage", "next_hop": 0, "num": "0x0f78f772", "position": {"altitude": 1565, "latitude": 32.065953, "location_source": "LOC_INTERNAL", "longitude": -108.155905, "time_offset_sec": 17681}, "public_key_hex": "4ea922a38c62d176fa256ae06bd26f606d273a33768a338fad8b14427e1b1ffa", "role": "CLIENT", "short_name": "LQ9J", "snr": 7.09, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.13, "iaq": 81, "relative_humidity": 75.96, "temperature": 25.99}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1013, "long_name": "Blue Fox", "next_hop": 229, "num": "0x0f8e526f", "position": {"altitude": 1412, "latitude": 32.720316, "location_source": "LOC_INTERNAL", "longitude": -107.264431, "time_offset_sec": 1257}, "public_key_hex": "327554b2882cddbbdd82666aeb0b35b2037f63ca79e616fbbb53668ccbbe8c2b", "role": "ROUTER", "short_name": "B3HK", "snr": 5.69, "status": null, "telemetry": {"air_util_tx": 1.508, "battery_level": 25, "channel_utilization": 21.97, "uptime_seconds": 106202, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2928, "long_name": "Sharp Aspen", "next_hop": 0, "num": "0x0fde1eec", "position": {"altitude": 1449, "latitude": 33.530237, "location_source": "LOC_INTERNAL", "longitude": -107.771161, "time_offset_sec": 3067}, "public_key_hex": "98d7f97f098e04fd3d92350ff9907094ff5f48ef20523280e064d4fdaacd8e16", "role": "CLIENT", "short_name": "SCMN", "snr": 2.42, "status": null, "telemetry": {"air_util_tx": 1.011, "battery_level": 88, "channel_utilization": 9.51, "uptime_seconds": 27684, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.62, "iaq": 72, "relative_humidity": 76.4, "temperature": 19.6}, "hops_away": 0, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 8518, "long_name": "Copper Pony", "next_hop": 0, "num": "0x1018994e", "position": {"altitude": 1570, "latitude": 33.624451, "location_source": "LOC_INTERNAL", "longitude": -107.627157, "time_offset_sec": 8580}, "public_key_hex": "", "role": "ROUTER", "short_name": "CNKI", "snr": 2.18, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 30, "long_name": "Floating Falcon", "next_hop": 0, "num": "0x101b6e63", "position": {"altitude": 1221, "latitude": 33.737624, "location_source": "LOC_INTERNAL", "longitude": -108.391569, "time_offset_sec": 72}, "public_key_hex": "e165537fda9c4c72a40c6c4859db82bc37daff03108de62043fadd3b40f459fe", "role": "SENSOR", "short_name": "FH7Y", "snr": 4.88, "status": null, "telemetry": {"air_util_tx": 0.321, "battery_level": 23, "channel_utilization": 15.8, "uptime_seconds": 186477, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2121, "long_name": "Copper Otter", "next_hop": 0, "num": "0x10205b63", "position": {"altitude": 1284, "latitude": 34.863075, "location_source": "LOC_INTERNAL", "longitude": -106.550002, "time_offset_sec": 2382}, "public_key_hex": "763052e1aa56f5a160c1d63923414ea597bd0031d639017e64f296dbde851a75", "role": "CLIENT", "short_name": "🌵", "snr": -0.94, "status": null, "telemetry": {"air_util_tx": 1.534, "battery_level": 24, "channel_utilization": 1.95, "uptime_seconds": 6014, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 7778, "long_name": "Black Hawk", "next_hop": 0, "num": "0x10227727", "position": {"altitude": 1462, "latitude": 32.90932, "location_source": "LOC_INTERNAL", "longitude": -107.285806, "time_offset_sec": 7995}, "public_key_hex": "ad0fe604a7094f2f89a7fc987fedd6017b03114ed474791054a31896f9542612", "role": "CLIENT", "short_name": "BB18", "snr": 2.25, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.78, "battery_level": 101, "channel_utilization": 12.64, "uptime_seconds": 1932, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1_EINK", "last_heard_offset_sec": 786, "long_name": "Old Cactus N56DI", "next_hop": 0, "num": "0x10b40519", "position": {"altitude": 1504, "latitude": 33.104311, "location_source": "LOC_INTERNAL", "longitude": -107.434206, "time_offset_sec": 1004}, "public_key_hex": "533b17828a9ede6fa74f3edb846843e8446717435b3a1dc27269e11009bcef6a", "role": "TAK_TRACKER", "short_name": "OCQ8", "snr": 0.49, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.125, "battery_level": 74, "channel_utilization": 17.91, "uptime_seconds": 124718, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 7537, "long_name": "Green Cedar", "next_hop": 0, "num": "0x10de9aae", "position": {"altitude": 723, "latitude": 33.326766, "location_source": "LOC_INTERNAL", "longitude": -108.003463, "time_offset_sec": 7750}, "public_key_hex": "e2d62f46d725f2949002b1684d3426c529ad503310aa76c51280ec5c806d14b3", "role": "ROUTER", "short_name": "GEJ1", "snr": 2.28, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2688, "long_name": "Black Sage", "next_hop": 0, "num": "0x11cf9223", "position": {"altitude": 1398, "latitude": 32.813263, "location_source": "LOC_INTERNAL", "longitude": -108.097167, "time_offset_sec": 2947}, "public_key_hex": "f2eb8a2e7daa401077ee6974e307733d39f767072e1c5bed2fd8a91c78170e31", "role": "CLIENT", "short_name": "BQUV", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.672, "battery_level": 73, "channel_utilization": 3.24, "uptime_seconds": 243864, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1004.57, "iaq": 30, "relative_humidity": 35.69, "temperature": 12.18}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1969, "long_name": "Frosty Bison", "next_hop": 0, "num": "0x11d59092", "position": null, "public_key_hex": "ecb4eeafca0fa40bed7355c5fd9aa3b1cf3b50a590053b9224b2a3d751320538", "role": "CLIENT", "short_name": "FHZ2", "snr": 4.53, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.48, "battery_level": 42, "channel_utilization": 15.48, "uptime_seconds": 88977, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 5949, "long_name": "Wild Mustang", "next_hop": 0, "num": "0x11d7f212", "position": {"altitude": 1024, "latitude": 33.272749, "location_source": "LOC_INTERNAL", "longitude": -107.297537, "time_offset_sec": 6240}, "public_key_hex": "f54d9988a8cd53c53ee267318e0cae68e4093e5e2e6c0ea6b958e62dcf54a6a3", "role": "TRACKER", "short_name": "W2A5", "snr": 1.3, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.042, "battery_level": 39, "channel_utilization": 14.9, "uptime_seconds": 17083, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3414, "long_name": "Storm Crane", "next_hop": 0, "num": "0x11fcd2c7", "position": null, "public_key_hex": "cb700ea5209ced2ce7fb56e78498359db8d0b212bf344767233e6a16d4ad8c54", "role": "CLIENT", "short_name": "S5DG", "snr": -1.79, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.06, "iaq": 56, "relative_humidity": 64.5, "temperature": 21.15}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1961, "long_name": "Tiny Tortoise", "next_hop": 0, "num": "0x124293c9", "position": {"altitude": 1593, "latitude": 33.348839, "location_source": "LOC_INTERNAL", "longitude": -106.734719, "time_offset_sec": 2028}, "public_key_hex": "", "role": "CLIENT", "short_name": "🐢", "snr": 0.06, "status": null, "telemetry": {"air_util_tx": 0.603, "battery_level": 29, "channel_utilization": 4.14, "uptime_seconds": 83202, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.97, "iaq": 55, "relative_humidity": 47.9, "temperature": 27.96}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3815, "long_name": "Quick Lynx", "next_hop": 0, "num": "0x12793b94", "position": null, "public_key_hex": "4b71268babbada3e398b200e02f4f502412aba779546f65b4f1d6390e3c11c71", "role": "CLIENT", "short_name": "🦅", "snr": 1.71, "status": null, "telemetry": {"air_util_tx": 0.15, "battery_level": 24, "channel_utilization": 6.86, "uptime_seconds": 37207, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.45, "iaq": 28, "relative_humidity": 29.78, "temperature": 24.0}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1265, "long_name": "Gold Aspen", "next_hop": 0, "num": "0x12b1a994", "position": null, "public_key_hex": "c10646601f2cc6d352787547ab609271a62f40eb40f5ec3ebb4cee0b877a7a94", "role": "CLIENT", "short_name": "GAUX", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.824, "battery_level": 72, "channel_utilization": 14.07, "uptime_seconds": 78788, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 4582, "long_name": "Rough Sage W59QH", "next_hop": 111, "num": "0x134b790b", "position": {"altitude": 1199, "latitude": 33.051761, "location_source": "LOC_INTERNAL", "longitude": -107.200236, "time_offset_sec": 4881}, "public_key_hex": "5505535ae415d55b775ea3d8677bcc16a4362ce1534f3d95be20065271f9f5b1", "role": "CLIENT", "short_name": "RODU", "snr": 9.9, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 671, "long_name": "Frozen Elk", "next_hop": 0, "num": "0x1356e5a7", "position": {"altitude": 1282, "latitude": 32.367066, "location_source": "LOC_INTERNAL", "longitude": -107.325336, "time_offset_sec": 689}, "public_key_hex": "ba1368b21e97bc91b19adfbf5f0031ce456ff07aa56d6a8b6eec0ccc96799047", "role": "TAK_TRACKER", "short_name": "FUQL", "snr": 11.9, "status": null, "telemetry": {"air_util_tx": 0.443, "battery_level": 49, "channel_utilization": 6.68, "uptime_seconds": 41398, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 13755, "long_name": "Storm Moose", "next_hop": 0, "num": "0x1379b6f8", "position": {"altitude": 1283, "latitude": 33.488927, "location_source": "LOC_INTERNAL", "longitude": -107.17989, "time_offset_sec": 13977}, "public_key_hex": "94bcb6ebe3bc3ec012f4dbcf228e3439668758ca425184c26c68128fad5552b1", "role": "ROUTER_LATE", "short_name": "SO4X", "snr": 7.42, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.385, "battery_level": 36, "channel_utilization": 20.65, "uptime_seconds": 20292, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 3390, "long_name": "Wild Shark", "next_hop": 0, "num": "0x13aa96e0", "position": null, "public_key_hex": "522f72c0a046e007baedf483b49090d229c6cd95555aa3a8a9c4a72ff6bbffd0", "role": "CLIENT", "short_name": "WXOE", "snr": 8.25, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.552, "battery_level": 101, "channel_utilization": 10.44, "uptime_seconds": 28836, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 572, "long_name": "Slow Tortoise KE8BZ", "next_hop": 0, "num": "0x13c260ca", "position": {"altitude": 988, "latitude": 32.950608, "location_source": "LOC_INTERNAL", "longitude": -107.691689, "time_offset_sec": 751}, "public_key_hex": "de56d298c6d8b7322ebceeeaa5b5c220cd8f518819cb08fb98c054953ac0e43a", "role": "CLIENT", "short_name": "SJEL", "snr": 10.47, "status": null, "telemetry": {"air_util_tx": 2.227, "battery_level": 49, "channel_utilization": 17.7, "uptime_seconds": 95503, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 3627, "long_name": "Burning Moose", "next_hop": 11, "num": "0x1418f8cd", "position": {"altitude": 1294, "latitude": 33.659566, "location_source": "LOC_INTERNAL", "longitude": -108.238162, "time_offset_sec": 3734}, "public_key_hex": "22c0a92b37e0d813e0606b32cebbe17031ee9ab24370bfa0ec5c62c244fcca3e", "role": "ROUTER", "short_name": "🌊", "snr": 9.05, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6908, "long_name": "Green Iguana", "next_hop": 189, "num": "0x14344f00", "position": {"altitude": 829, "latitude": 32.897047, "location_source": "LOC_INTERNAL", "longitude": -107.223164, "time_offset_sec": 7051}, "public_key_hex": "d1cbe0db99baa5af5dc3179d2bbdd50c2db5f98b1c338be18ec2c55f73836d38", "role": "CLIENT", "short_name": "GHVH", "snr": 3.87, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.306, "battery_level": 30, "channel_utilization": 12.84, "uptime_seconds": 72917, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 1071, "long_name": "Tall Wolf", "next_hop": 0, "num": "0x14d33250", "position": {"altitude": 1562, "latitude": 32.694901, "location_source": "LOC_INTERNAL", "longitude": -107.508362, "time_offset_sec": 1246}, "public_key_hex": "081e7a86105059118e56233bc00bcbd77dde78a465ea4759bef00ab5ca3e0181", "role": "CLIENT", "short_name": "TH3H", "snr": 2.73, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.842, "battery_level": 27, "channel_utilization": 8.35, "uptime_seconds": 56438, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4481, "long_name": "Wandering Moose", "next_hop": 0, "num": "0x14eb1f2d", "position": {"altitude": 1654, "latitude": 33.730089, "location_source": "LOC_INTERNAL", "longitude": -107.361317, "time_offset_sec": 4625}, "public_key_hex": "8a4a31c8023223e5c508b31f3829c13008e82a957c1d15c38be451b797e3d8b5", "role": "CLIENT", "short_name": "W94X", "snr": 8.99, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.146, "battery_level": 89, "channel_utilization": 26.52, "uptime_seconds": 61941, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.5, "iaq": 76, "relative_humidity": 67.21, "temperature": 28.61}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 9181, "long_name": "Silver Bear", "next_hop": 0, "num": "0x15008bbd", "position": {"altitude": 1303, "latitude": 33.304292, "location_source": "LOC_INTERNAL", "longitude": -106.704279, "time_offset_sec": 9318}, "public_key_hex": "e0e5fc0c2810e96cbdbaa94273448a433a56f85d833446f4a78dd567f24564af", "role": "CLIENT", "short_name": "SV3M", "snr": 5.67, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.252, "battery_level": 44, "channel_utilization": 10.76, "uptime_seconds": 20110, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1_EINK", "last_heard_offset_sec": 12488, "long_name": "Dawn Bison", "next_hop": 0, "num": "0x15079a96", "position": {"altitude": 1142, "latitude": 33.178914, "location_source": "LOC_INTERNAL", "longitude": -106.882896, "time_offset_sec": 12552}, "public_key_hex": "b19b49ec63540cbb0683e60d45179dcab274743512d5e9c81351057c625f031b", "role": "CLIENT", "short_name": "🗻", "snr": 4.12, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4732, "long_name": "Lost Oak", "next_hop": 85, "num": "0x151e3fb5", "position": {"altitude": 1556, "latitude": 33.030756, "location_source": "LOC_INTERNAL", "longitude": -107.105935, "time_offset_sec": 4801}, "public_key_hex": "b0c3b2db380cf1b42a013974b3936b6d8a03b9e36bee94abd5ee7030ed151f45", "role": "CLIENT", "short_name": "LZTS", "snr": 7.17, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.79, "iaq": 51, "relative_humidity": 27.03, "temperature": 12.69}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5740, "long_name": "Bright Juniper", "next_hop": 0, "num": "0x15dc23b2", "position": {"altitude": 868, "latitude": 32.860217, "location_source": "LOC_INTERNAL", "longitude": -108.123223, "time_offset_sec": 5988}, "public_key_hex": "cd14d907287a83262d5f58b428faad9d6f79832399ad9f1e38853c799756ae09", "role": "CLIENT", "short_name": "BS7U", "snr": 6.24, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.414, "battery_level": 39, "channel_utilization": 8.47, "uptime_seconds": 112298, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": {"barometric_pressure": 1014.5, "iaq": 107, "relative_humidity": 91.9, "temperature": 22.23}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 510, "long_name": "Floating Fox", "next_hop": 102, "num": "0x16108cd9", "position": {"altitude": 1198, "latitude": 33.289005, "location_source": "LOC_INTERNAL", "longitude": -107.249121, "time_offset_sec": 649}, "public_key_hex": "4cdfc9b5d15351036ad5f84ff783a1ce059cdef45e08c87252221687624e4d51", "role": "CLIENT", "short_name": "FO2G", "snr": 7.94, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.415, "battery_level": 46, "channel_utilization": 6.21, "uptime_seconds": 27755, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 555, "long_name": "River Turtle", "next_hop": 0, "num": "0x162f1f2d", "position": {"altitude": 1048, "latitude": 33.456724, "location_source": "LOC_INTERNAL", "longitude": -107.166742, "time_offset_sec": 680}, "public_key_hex": "96b2115da7d505865a41d707f058f860536de871f4dc1a1ddfa730939dd14e4a", "role": "CLIENT_HIDDEN", "short_name": "RIBX", "snr": 10.55, "status": null, "telemetry": {"air_util_tx": 0.271, "battery_level": 95, "channel_utilization": 2.71, "uptime_seconds": 58824, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 3463, "long_name": "Steel Trout", "next_hop": 183, "num": "0x163f2d40", "position": {"altitude": 1354, "latitude": 32.863185, "location_source": "LOC_INTERNAL", "longitude": -108.147876, "time_offset_sec": 3725}, "public_key_hex": "41838ad0fc0e569398ea924f689c3daa2d1057f363d35c8b444e6c536fe4d24f", "role": "CLIENT", "short_name": "SC17", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 232, "long_name": "Whispering Lynx", "next_hop": 0, "num": "0x167494c0", "position": {"altitude": 1556, "latitude": 33.115465, "location_source": "LOC_INTERNAL", "longitude": -107.426901, "time_offset_sec": 281}, "public_key_hex": "c2478ff9f49a90e70290f8027b4c8e3a64849b721419e698728b4c6c1d1f0666", "role": "CLIENT", "short_name": "WFFL", "snr": 4.16, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 3.127, "battery_level": 54, "channel_utilization": 14.67, "uptime_seconds": 49601, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1560, "long_name": "Black Cougar", "next_hop": 28, "num": "0x16897d73", "position": {"altitude": 1673, "latitude": 32.91074, "location_source": "LOC_INTERNAL", "longitude": -108.369742, "time_offset_sec": 1624}, "public_key_hex": "c112995c30570dcea089ca8cc09e113877c301f2a02fa50cf3de16b175006618", "role": "CLIENT", "short_name": "BYO0", "snr": 8.38, "status": null, "telemetry": {"air_util_tx": 0.664, "battery_level": 14, "channel_utilization": 6.06, "uptime_seconds": 212294, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2627, "long_name": "Storm Elk", "next_hop": 95, "num": "0x16b275f9", "position": null, "public_key_hex": "2a82e18a602e00282dedae0ec94bc011f1f63e6501c1154fdf4088e8f9e66221", "role": "CLIENT", "short_name": "SD28", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.022, "battery_level": 96, "channel_utilization": 14.35, "uptime_seconds": 44369, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 4213, "long_name": "Blue Lion", "next_hop": 111, "num": "0x16b2a0d6", "position": {"altitude": 1426, "latitude": 32.811334, "location_source": "LOC_INTERNAL", "longitude": -107.621788, "time_offset_sec": 4332}, "public_key_hex": "8539ed4565ae84a1422af41bbf3b9931250e817a1afaced69f213a6deba68cd7", "role": "CLIENT", "short_name": "BOU8", "snr": 6.59, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1223, "long_name": "Wild Badger", "next_hop": 184, "num": "0x16c3bc1e", "position": {"altitude": 1210, "latitude": 31.911923, "location_source": "LOC_INTERNAL", "longitude": -106.914223, "time_offset_sec": 1415}, "public_key_hex": "9b5a6fbbad28e1bb34255eb4e1c06ebae5112d6865efe8f0f7300e8e615fcecd", "role": "CLIENT", "short_name": "WJIU", "snr": 8.91, "status": null, "telemetry": {"air_util_tx": 0.308, "battery_level": 68, "channel_utilization": 14.45, "uptime_seconds": 68262, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 3751, "long_name": "Lost Seal KQ5CO", "next_hop": 125, "num": "0x174ad192", "position": {"altitude": 1619, "latitude": 33.035231, "location_source": "LOC_INTERNAL", "longitude": -106.344659, "time_offset_sec": 3887}, "public_key_hex": "60c3cf0a8a24067b3dd13f67bf5fb744a064eff33318446e69a7173025035ba6", "role": "CLIENT", "short_name": "L3G4", "snr": 8.3, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.595, "battery_level": 68, "channel_utilization": 5.31, "uptime_seconds": 44253, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3105, "long_name": "Dusk Bear", "next_hop": 0, "num": "0x1766b643", "position": null, "public_key_hex": "2a662dddcc43dbaf9307f399f96979586ec89075447f8dcd8d0770d24d8bb8c1", "role": "CLIENT", "short_name": "D06J", "snr": 2.87, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.311, "battery_level": 90, "channel_utilization": 23.58, "uptime_seconds": 63789, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1090, "long_name": "Soft Mesa", "next_hop": 40, "num": "0x17e79e67", "position": {"altitude": 1287, "latitude": 32.365261, "location_source": "LOC_INTERNAL", "longitude": -106.634547, "time_offset_sec": 1289}, "public_key_hex": "ece402bde0b1a1d0d81cf6a77bb47ed8cdea95fe5c572fb26bb0e0be659b0b1d", "role": "CLIENT", "short_name": "SUXH", "snr": 6.42, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.641, "battery_level": 17, "channel_utilization": 20.0, "uptime_seconds": 1774, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6549, "long_name": "Fast Pike", "next_hop": 0, "num": "0x18315641", "position": {"altitude": 1600, "latitude": 33.22812, "location_source": "LOC_INTERNAL", "longitude": -106.528869, "time_offset_sec": 6792}, "public_key_hex": "1a028f6e546ff000ccd3e5b381ea64df4a7c67fe8e48a3a7a5e7a6b5e6f31954", "role": "TRACKER", "short_name": "🌙", "snr": -1.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 233, "long_name": "Drowsy Beaver", "next_hop": 0, "num": "0x1879f157", "position": {"altitude": 1403, "latitude": 32.948675, "location_source": "LOC_INTERNAL", "longitude": -107.057998, "time_offset_sec": 236}, "public_key_hex": "69673da7dbee9e58d0582faf761ff65d3f53b504871de416fd3cd4bf756fb02b", "role": "CLIENT", "short_name": "D72I", "snr": 9.62, "status": null, "telemetry": {"air_util_tx": 0.401, "battery_level": 36, "channel_utilization": 35.24, "uptime_seconds": 96943, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7571, "long_name": "Smooth Otter", "next_hop": 0, "num": "0x18e8f8e8", "position": {"altitude": 1321, "latitude": 32.871304, "location_source": "LOC_INTERNAL", "longitude": -106.837591, "time_offset_sec": 7854}, "public_key_hex": "", "role": "CLIENT", "short_name": "SWO2", "snr": 3.69, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.199, "battery_level": 96, "channel_utilization": 2.78, "uptime_seconds": 172071, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.31, "iaq": 37, "relative_humidity": 87.49, "temperature": 33.24}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 18931, "long_name": "Roving Falcon", "next_hop": 0, "num": "0x18ef3676", "position": null, "public_key_hex": "e7c21800cd0080b0283e471d96c9145398f52b79584122317b54c691dbb7f7f2", "role": "CLIENT", "short_name": "🦅", "snr": 4.74, "status": null, "telemetry": {"air_util_tx": 0.98, "battery_level": 25, "channel_utilization": 27.08, "uptime_seconds": 101477, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 994.67, "iaq": 57, "relative_humidity": 50.76, "temperature": 11.41}, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 3752, "long_name": "Wandering Adder", "next_hop": 201, "num": "0x1988381c", "position": {"altitude": 1575, "latitude": 33.57496, "location_source": "LOC_INTERNAL", "longitude": -107.855479, "time_offset_sec": 3771}, "public_key_hex": "9309172700d9b8c4dcdbfb6cb9a70f1667e3d1d4a63fa2f79b25c97c1fb27e78", "role": "CLIENT", "short_name": "W19E", "snr": 5.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2950, "long_name": "Short Colt", "next_hop": 216, "num": "0x19f3157f", "position": {"altitude": 1615, "latitude": 32.464438, "location_source": "LOC_INTERNAL", "longitude": -107.616346, "time_offset_sec": 3081}, "public_key_hex": "eb6cfc06557874a6d463e4f4f2e108734d4a480588a123874b01145932dd322a", "role": "CLIENT", "short_name": "SG5Q", "snr": -0.24, "status": null, "telemetry": {"air_util_tx": 0.085, "battery_level": 23, "channel_utilization": 5.55, "uptime_seconds": 85090, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1780, "long_name": "Old Phoenix", "next_hop": 0, "num": "0x1a12e784", "position": {"altitude": 1200, "latitude": 33.519889, "location_source": "LOC_INTERNAL", "longitude": -107.338351, "time_offset_sec": 1808}, "public_key_hex": "d9ceee6f3c4b4da117cb125d2214bbd093339914243306a487dd72a3268df1d8", "role": "CLIENT", "short_name": "🐝", "snr": 6.02, "status": null, "telemetry": {"air_util_tx": 0.713, "battery_level": 62, "channel_utilization": 0.76, "uptime_seconds": 46755, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 181, "long_name": "Found Raven", "next_hop": 0, "num": "0x1b3b65e9", "position": {"altitude": 1293, "latitude": 33.288548, "location_source": "LOC_INTERNAL", "longitude": -106.793415, "time_offset_sec": 332}, "public_key_hex": "16abe3f61fba13eecc05937c951b3dc75f49c7a91b931f65cb78e51b71347a66", "role": "CLIENT", "short_name": "FGB8", "snr": 8.32, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 128, "long_name": "Tall Crow", "next_hop": 166, "num": "0x1b3dc88d", "position": {"altitude": 1529, "latitude": 33.179202, "location_source": "LOC_INTERNAL", "longitude": -107.497106, "time_offset_sec": 169}, "public_key_hex": "03eb9d64485bf32d9e0c759235d16d583bdd1ab21dea084426d8f84683de1d3f", "role": "CLIENT", "short_name": "TVOT", "snr": 7.57, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.232, "battery_level": 43, "channel_utilization": 16.23, "uptime_seconds": 62860, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2400, "long_name": "Storm Moose", "next_hop": 0, "num": "0x1b96157a", "position": {"altitude": 1109, "latitude": 32.378496, "location_source": "LOC_INTERNAL", "longitude": -107.954423, "time_offset_sec": 2652}, "public_key_hex": "6f495139fa0535f498dd4e452971c3b048303ef830853b0775562372d0aed5ef", "role": "CLIENT", "short_name": "SVDY", "snr": 3.46, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 660, "long_name": "River Adder", "next_hop": 23, "num": "0x1bb73d04", "position": {"altitude": 1641, "latitude": 33.68275, "location_source": "LOC_INTERNAL", "longitude": -106.336486, "time_offset_sec": 729}, "public_key_hex": "00d33760c0ae44ec4ed815ed2f8fb856de3dc8cba6d4a3fe87e864bb163c21fc", "role": "CLIENT", "short_name": "🦇", "snr": 8.36, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.259, "battery_level": 16, "channel_utilization": 10.86, "uptime_seconds": 148515, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.25, "iaq": 35, "relative_humidity": 29.15, "temperature": 10.43}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 281, "long_name": "Tall Sage", "next_hop": 0, "num": "0x1bb8ec2e", "position": {"altitude": 1531, "latitude": 32.77112, "location_source": "LOC_INTERNAL", "longitude": -108.092313, "time_offset_sec": 401}, "public_key_hex": "", "role": "CLIENT", "short_name": "TYN9", "snr": 5.06, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.803, "battery_level": 50, "channel_utilization": 15.33, "uptime_seconds": 214193, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 5105, "long_name": "Desert Crane", "next_hop": 0, "num": "0x1c5d1282", "position": {"altitude": 1015, "latitude": 32.714988, "location_source": "LOC_INTERNAL", "longitude": -107.149688, "time_offset_sec": 5361}, "public_key_hex": "31ac1d441f3a1497f3fe0c690feb1be2c7e1ae206e75076adb0266cbb3aa6db4", "role": "CLIENT", "short_name": "DPH4", "snr": 4.23, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.329, "battery_level": 79, "channel_utilization": 24.0, "uptime_seconds": 118737, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1510, "long_name": "Red Adder", "next_hop": 0, "num": "0x1c65ab38", "position": {"altitude": 1119, "latitude": 32.218937, "location_source": "LOC_INTERNAL", "longitude": -107.683511, "time_offset_sec": 1670}, "public_key_hex": "526c2de137bac9ced28684319ac3c88652bec2857257494a88ce4953cde8f35d", "role": "ROUTER_LATE", "short_name": "RCQ5", "snr": 5.32, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1734, "long_name": "Silent Owl", "next_hop": 0, "num": "0x1cd35371", "position": null, "public_key_hex": "760e3df9c2c870447ce712166b29f524bfef1f96d810b291ac9fea3a958d7bf5", "role": "CLIENT", "short_name": "SQ2L", "snr": 4.4, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2380, "long_name": "Shady Viper", "next_hop": 35, "num": "0x1d034814", "position": {"altitude": 1558, "latitude": 32.964971, "location_source": "LOC_INTERNAL", "longitude": -106.457279, "time_offset_sec": 2402}, "public_key_hex": "e5942d09ce2a6eb922b60ce70af7f117c247e8e31b9f8f86beab1e90557ccfbc", "role": "CLIENT", "short_name": "SDKU", "snr": 3.29, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.374, "battery_level": 83, "channel_utilization": 4.34, "uptime_seconds": 52904, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 6228, "long_name": "White Hawk", "next_hop": 186, "num": "0x1d250bf1", "position": {"altitude": 1580, "latitude": 33.745509, "location_source": "LOC_INTERNAL", "longitude": -106.22969, "time_offset_sec": 6390}, "public_key_hex": "34e2a11726966b49cadf79e782bb6411b20db72dc599384e90036ec2aa948b5f", "role": "CLIENT", "short_name": "🌲", "snr": 6.15, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.717, "battery_level": 69, "channel_utilization": 17.24, "uptime_seconds": 4879, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 1115, "long_name": "Silent Hare", "next_hop": 104, "num": "0x1d7b308f", "position": {"altitude": 1699, "latitude": 32.964606, "location_source": "LOC_INTERNAL", "longitude": -106.834004, "time_offset_sec": 1146}, "public_key_hex": "7d6fc2a73a3613727447a5213e5b28f2d2a6daf1bc9d37fb85af98f924060068", "role": "CLIENT", "short_name": "SEF4", "snr": 9.34, "status": null, "telemetry": {"air_util_tx": 0.141, "battery_level": 19, "channel_utilization": 7.71, "uptime_seconds": 24330, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3021, "long_name": "Dusk Ridge", "next_hop": 0, "num": "0x1d8a404d", "position": {"altitude": 1272, "latitude": 32.667972, "location_source": "LOC_INTERNAL", "longitude": -107.58155, "time_offset_sec": 3245}, "public_key_hex": "", "role": "SENSOR", "short_name": "DA2X", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.123, "battery_level": 77, "channel_utilization": 8.91, "uptime_seconds": 100385, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 7594, "long_name": "Loud Moose KE2CF", "next_hop": 0, "num": "0x1d922594", "position": {"altitude": 1512, "latitude": 33.685806, "location_source": "LOC_INTERNAL", "longitude": -106.311204, "time_offset_sec": 7624}, "public_key_hex": "668759f4208b636bc246eb4c674b149e6580afba036d0521aa4bd644f533016d", "role": "CLIENT", "short_name": "🦋", "snr": 8.05, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.086, "battery_level": 87, "channel_utilization": 10.32, "uptime_seconds": 53171, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1005.76, "iaq": 48, "relative_humidity": 84.51, "temperature": 25.52}, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 390, "long_name": "Dusk Falcon", "next_hop": 90, "num": "0x1e48de96", "position": {"altitude": 1754, "latitude": 32.922495, "location_source": "LOC_INTERNAL", "longitude": -107.761571, "time_offset_sec": 566}, "public_key_hex": "9cfad118faa52d6a962cfe920c3223a2435ed86716ec4fff20ee094bc3be9247", "role": "CLIENT", "short_name": "DSY7", "snr": 8.44, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.288, "battery_level": 65, "channel_utilization": 6.55, "uptime_seconds": 72604, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 4374, "long_name": "Silver Trout", "next_hop": 24, "num": "0x1e55127e", "position": null, "public_key_hex": "fad8bd37ec2fd7cb5f22e6c9bd0d59315ce728a1809408904587d3ee07c0de2c", "role": "CLIENT", "short_name": "SAGX", "snr": 2.95, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.309, "battery_level": 58, "channel_utilization": 12.57, "uptime_seconds": 5476, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 2906, "long_name": "Floating Salmon", "next_hop": 178, "num": "0x1ec65817", "position": {"altitude": 1642, "latitude": 32.054247, "location_source": "LOC_INTERNAL", "longitude": -107.714698, "time_offset_sec": 2991}, "public_key_hex": "2eece35b6bbf4844a5b4fa626cf6b911072f493897cbdac4dc6b51c9cdbac750", "role": "CLIENT", "short_name": "FQR6", "snr": 0.63, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.98, "iaq": 89, "relative_humidity": 93.47, "temperature": 35.52}, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1356, "long_name": "Steel Lion", "next_hop": 70, "num": "0x1edcadc8", "position": {"altitude": 1132, "latitude": 32.809096, "location_source": "LOC_INTERNAL", "longitude": -108.059756, "time_offset_sec": 1643}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "🔥", "snr": 9.65, "status": null, "telemetry": {"air_util_tx": 1.187, "battery_level": 12, "channel_utilization": 13.29, "uptime_seconds": 8597, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.68, "iaq": 0, "relative_humidity": 85.78, "temperature": 20.85}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 19869, "long_name": "Quick Colt", "next_hop": 0, "num": "0x1f3c099d", "position": {"altitude": 1528, "latitude": 33.641254, "location_source": "LOC_INTERNAL", "longitude": -107.003798, "time_offset_sec": 19950}, "public_key_hex": "a93259deaa48ea388df51930b660b4db7ad3205f39fdaa796cada03c5f1d94ca", "role": "CLIENT", "short_name": "Q1T3", "snr": 7.01, "status": null, "telemetry": {"air_util_tx": 1.197, "battery_level": 22, "channel_utilization": 2.57, "uptime_seconds": 17868, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4696, "long_name": "Green Owl", "next_hop": 0, "num": "0x1fa1ecc4", "position": {"altitude": 1704, "latitude": 33.608806, "location_source": "LOC_INTERNAL", "longitude": -107.078775, "time_offset_sec": 4921}, "public_key_hex": "d8f7463ef6088318f1a337f9d78745f2414d7a5e1ef74a7b3a027f11a21a5b22", "role": "SENSOR", "short_name": "G70L", "snr": 4.43, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.747, "battery_level": 94, "channel_utilization": 42.31, "uptime_seconds": 40259, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.37, "iaq": 88, "relative_humidity": 39.53, "temperature": 19.13}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2032, "long_name": "Hidden Cedar", "next_hop": 153, "num": "0x1fd14395", "position": {"altitude": 1345, "latitude": 34.041731, "location_source": "LOC_INTERNAL", "longitude": -107.545165, "time_offset_sec": 2280}, "public_key_hex": "d49a566319a2b467494d96d04c294b9b0f25f3e517570b3ef67921e55391baf4", "role": "CLIENT", "short_name": "HZUZ", "snr": -3.41, "status": null, "telemetry": {"air_util_tx": 1.013, "battery_level": 59, "channel_utilization": 17.63, "uptime_seconds": 11804, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3757, "long_name": "Wandering Beaver", "next_hop": 0, "num": "0x1ff10642", "position": {"altitude": 1336, "latitude": 33.635048, "location_source": "LOC_INTERNAL", "longitude": -108.082046, "time_offset_sec": 3847}, "public_key_hex": "1d69bdcc66ded4d1fd928233ff5e4a69dfb31c3e0def2e355642a4c02a066c10", "role": "CLIENT", "short_name": "WC3M", "snr": 11.11, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 8655, "long_name": "Old Owl", "next_hop": 0, "num": "0x1ff9fc94", "position": {"altitude": 1630, "latitude": 32.689108, "location_source": "LOC_INTERNAL", "longitude": -107.204299, "time_offset_sec": 8694}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "OEFS", "snr": 6.12, "status": null, "telemetry": {"air_util_tx": 0.495, "battery_level": 35, "channel_utilization": 25.26, "uptime_seconds": 67098, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5112, "long_name": "Black Cougar", "next_hop": 58, "num": "0x20584ab0", "position": {"altitude": 1650, "latitude": 32.332627, "location_source": "LOC_INTERNAL", "longitude": -106.842898, "time_offset_sec": 5277}, "public_key_hex": "24fb486fdca3f123deb52e67730c80d56e73da33f0567a5a9bce05dd7a9ba977", "role": "ROUTER", "short_name": "BCZC", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.388, "battery_level": 46, "channel_utilization": 11.56, "uptime_seconds": 31821, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1423, "long_name": "Sky Doe", "next_hop": 25, "num": "0x205d8291", "position": {"altitude": 1394, "latitude": 33.553239, "location_source": "LOC_INTERNAL", "longitude": -106.866388, "time_offset_sec": 1523}, "public_key_hex": "a8cabf3339c05e04fe61bf45c061bb0bcd2d724d58d6817a9577e9ee08d203a1", "role": "CLIENT", "short_name": "S7B4", "snr": 10.74, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.014, "battery_level": 93, "channel_utilization": 6.16, "uptime_seconds": 1547, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1231, "long_name": "Steel Juniper", "next_hop": 0, "num": "0x21033a28", "position": {"altitude": 1264, "latitude": 33.356136, "location_source": "LOC_INTERNAL", "longitude": -106.614493, "time_offset_sec": 1239}, "public_key_hex": "f2a9858f9df95595e2297da50cd6ff39f6dc6a98e95a3d1301ccf60268dcc006", "role": "CLIENT", "short_name": "SIMB", "snr": 2.0, "status": null, "telemetry": {"air_util_tx": 0.783, "battery_level": 101, "channel_utilization": 4.42, "uptime_seconds": 38270, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 5800, "long_name": "Gold Pony", "next_hop": 90, "num": "0x217fcefe", "position": {"altitude": 1327, "latitude": 33.145461, "location_source": "LOC_INTERNAL", "longitude": -106.737792, "time_offset_sec": 5964}, "public_key_hex": "e2f5c1574f518f68f6feb70ec37f88df48bcd397b0de92f000afe8f3aefb38d3", "role": "CLIENT", "short_name": "GMEF", "snr": 8.53, "status": null, "telemetry": {"air_util_tx": 0.161, "battery_level": 88, "channel_utilization": 24.95, "uptime_seconds": 202680, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 530, "long_name": "Iron Yucca", "next_hop": 0, "num": "0x218bfe2c", "position": {"altitude": 1722, "latitude": 33.303433, "location_source": "LOC_INTERNAL", "longitude": -106.970972, "time_offset_sec": 647}, "public_key_hex": "ed244b20dc77f39a789646b51e74048b10380d945510652c1681a60b27a05cb9", "role": "CLIENT", "short_name": "IZ30", "snr": 10.09, "status": null, "telemetry": {"air_util_tx": 2.038, "battery_level": 43, "channel_utilization": 5.35, "uptime_seconds": 84598, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 4028, "long_name": "Desert Yucca", "next_hop": 216, "num": "0x22152f01", "position": {"altitude": 1490, "latitude": 32.902735, "location_source": "LOC_INTERNAL", "longitude": -108.083973, "time_offset_sec": 4073}, "public_key_hex": "4b59cc1528251d21d567bf07f1c171c781b9ffe3c08fa95446a7b3d58e8107a6", "role": "CLIENT_MUTE", "short_name": "🦋", "snr": 9.79, "status": null, "telemetry": {"air_util_tx": 0.558, "battery_level": 64, "channel_utilization": 13.37, "uptime_seconds": 84244, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3662, "long_name": "New Cobra", "next_hop": 252, "num": "0x225663cf", "position": {"altitude": 1321, "latitude": 33.636697, "location_source": "LOC_INTERNAL", "longitude": -107.268583, "time_offset_sec": 3843}, "public_key_hex": "35b56c0424b193ef053313349bcae772fce851ea41d3dccd93597a98601f2654", "role": "CLIENT", "short_name": "🐢", "snr": 7.75, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.601, "battery_level": 80, "channel_utilization": 29.33, "uptime_seconds": 32338, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1000.51, "iaq": 84, "relative_humidity": 14.79, "temperature": 40.91}, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 309, "long_name": "Brave Juniper", "next_hop": 9, "num": "0x22681b09", "position": {"altitude": 1313, "latitude": 33.196654, "location_source": "LOC_INTERNAL", "longitude": -106.997179, "time_offset_sec": 575}, "public_key_hex": "0c5ce9a2ae203f5698b67669463c7650d7898547aba0ed6acc456e3d24b1a401", "role": "CLIENT", "short_name": "BPP2", "snr": 3.84, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.797, "battery_level": 62, "channel_utilization": 9.31, "uptime_seconds": 349552, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7718, "long_name": "Desert Cobra", "next_hop": 0, "num": "0x22d7d309", "position": {"altitude": 1311, "latitude": 33.255675, "location_source": "LOC_INTERNAL", "longitude": -107.444882, "time_offset_sec": 7959}, "public_key_hex": "90b5dee4acb9274a52c640a2b854603a543a4be718e7ae478212bf60cc53a252", "role": "CLIENT", "short_name": "DFPE", "snr": -0.05, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 1.058, "battery_level": 40, "channel_utilization": 35.25, "uptime_seconds": 5417, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 6737, "long_name": "Silver Cedar", "next_hop": 0, "num": "0x22d9762a", "position": null, "public_key_hex": "ca92433f107ebf95e44c6286a649d0d559a0c10e1cbe12194b02fd05a1a0be7f", "role": "CLIENT", "short_name": "SOC1", "snr": 2.98, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.834, "battery_level": 65, "channel_utilization": 10.13, "uptime_seconds": 92911, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1038, "long_name": "Sneaky Mustang", "next_hop": 250, "num": "0x22fa3ede", "position": {"altitude": 998, "latitude": 33.530425, "location_source": "LOC_INTERNAL", "longitude": -108.118632, "time_offset_sec": 1071}, "public_key_hex": "cd67de99c01cef3a4bccf8be77fdc9867e9d36140ed85dd4db2580090a8b12a4", "role": "CLIENT", "short_name": "S4ZQ", "snr": 7.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 11870, "long_name": "Drifting Squirrel", "next_hop": 0, "num": "0x230de5d6", "position": null, "public_key_hex": "207f473e204ad579961c472b68f36b5c2417f263d527abfad579c2c1fbd2292d", "role": "CLIENT", "short_name": "DJWM", "snr": 9.27, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.127, "battery_level": 10, "channel_utilization": 3.15, "uptime_seconds": 24031, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1030.51, "iaq": 42, "relative_humidity": 54.16, "temperature": 29.92}, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5016, "long_name": "Smooth Marmot", "next_hop": 126, "num": "0x2375e66b", "position": {"altitude": 1412, "latitude": 32.61115, "location_source": "LOC_INTERNAL", "longitude": -107.686434, "time_offset_sec": 5113}, "public_key_hex": "f050748fd0d4f37779d5eb745a51fca43f61111f3631fe587e8236676090dac6", "role": "CLIENT", "short_name": "STBK", "snr": -0.04, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.332, "battery_level": 57, "channel_utilization": 18.05, "uptime_seconds": 5906, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M6", "last_heard_offset_sec": 694, "long_name": "Sky Doe", "next_hop": 0, "num": "0x237668c0", "position": {"altitude": 1742, "latitude": 32.685164, "location_source": "LOC_INTERNAL", "longitude": -107.454203, "time_offset_sec": 855}, "public_key_hex": "eb89e19cbd4889d2ceaee0559bc54eade7dd1f3e572d58d67aa5eb5e4f75c750", "role": "CLIENT", "short_name": "SR0J", "snr": 6.47, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.496, "battery_level": 19, "channel_utilization": 7.6, "uptime_seconds": 31164, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.49, "iaq": 24, "relative_humidity": 62.45, "temperature": 35.82}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1384, "long_name": "Whispering Salmon", "next_hop": 0, "num": "0x23c98a6d", "position": {"altitude": 1614, "latitude": 33.614918, "location_source": "LOC_INTERNAL", "longitude": -107.429103, "time_offset_sec": 1647}, "public_key_hex": "c43fa3accf988f4100b88711423b7661a756eb33315f6c2ddcb88c60cb65e663", "role": "CLIENT_MUTE", "short_name": "W297", "snr": 4.03, "status": null, "telemetry": {"air_util_tx": 1.534, "battery_level": 31, "channel_utilization": 2.71, "uptime_seconds": 126260, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 6333, "long_name": "Dawn Owl AE5DW", "next_hop": 0, "num": "0x23f41648", "position": {"altitude": 1149, "latitude": 32.670635, "location_source": "LOC_INTERNAL", "longitude": -106.966118, "time_offset_sec": 6611}, "public_key_hex": "d19c786d7447228d443f44206e3df23dcbe9bdca14d6f251b0fc27b3e7c597e1", "role": "CLIENT", "short_name": "DV8L", "snr": 5.4, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2484, "long_name": "Roving Elk", "next_hop": 214, "num": "0x24595fd0", "position": {"altitude": 704, "latitude": 33.437723, "location_source": "LOC_INTERNAL", "longitude": -106.824987, "time_offset_sec": 2523}, "public_key_hex": "7199ef6b37f76349421ef6bfb82b4bb5f0d5a6e009d6fc769c02310b836c412e", "role": "CLIENT", "short_name": "R1XY", "snr": 10.11, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.412, "battery_level": 47, "channel_utilization": 16.49, "uptime_seconds": 43935, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5231, "long_name": "Steel Phoenix", "next_hop": 67, "num": "0x24ef37f2", "position": {"altitude": 881, "latitude": 33.382287, "location_source": "LOC_INTERNAL", "longitude": -106.683959, "time_offset_sec": 5469}, "public_key_hex": "9d7e3a32de5ddba3bd77fb3156f58e94be20aeb4b559655b9338c4618c655d17", "role": "LOST_AND_FOUND", "short_name": "🔥", "snr": 4.36, "status": null, "telemetry": {"air_util_tx": 0.683, "battery_level": 36, "channel_utilization": 2.5, "uptime_seconds": 8316, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.29, "iaq": 67, "relative_humidity": 56.81, "temperature": 16.91}, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2457, "long_name": "Hidden Ridge", "next_hop": 121, "num": "0x255e347c", "position": {"altitude": 1191, "latitude": 32.831029, "location_source": "LOC_INTERNAL", "longitude": -107.8584, "time_offset_sec": 2463}, "public_key_hex": "a55a7dcc277e8487d135f7b6f73a8a5378f36d8a105f6c5bf432119cee055d00", "role": "CLIENT", "short_name": "HRX0", "snr": 2.36, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.061, "battery_level": 14, "channel_utilization": 40.63, "uptime_seconds": 19839, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4139, "long_name": "Lost Eagle", "next_hop": 0, "num": "0x25aa8181", "position": {"altitude": 1358, "latitude": 32.614367, "location_source": "LOC_INTERNAL", "longitude": -107.462449, "time_offset_sec": 4389}, "public_key_hex": "6bbe98fbf5844c3f15b8ecd858fb92bf91e29a76dc7feb61708038e5c45044a2", "role": "CLIENT", "short_name": "LECX", "snr": 4.8, "status": null, "telemetry": {"air_util_tx": 1.039, "battery_level": 101, "channel_utilization": 14.91, "uptime_seconds": 29832, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 6403, "long_name": "Stone Seal", "next_hop": 187, "num": "0x25da47be", "position": {"altitude": 1445, "latitude": 34.003693, "location_source": "LOC_INTERNAL", "longitude": -106.236603, "time_offset_sec": 6660}, "public_key_hex": "552c56b590e2bdd964dbfc232a14a69afad16be2625e6fec65844d55c535eb99", "role": "CLIENT", "short_name": "SLY8", "snr": -0.39, "status": null, "telemetry": {"air_util_tx": 0.525, "battery_level": 76, "channel_utilization": 30.06, "uptime_seconds": 5620, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.6, "iaq": 0, "relative_humidity": 37.72, "temperature": 13.49}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 904, "long_name": "Floating Wolf", "next_hop": 0, "num": "0x25e5b378", "position": {"altitude": 1299, "latitude": 32.597977, "location_source": "LOC_INTERNAL", "longitude": -107.462789, "time_offset_sec": 972}, "public_key_hex": "1619e3db54a4d1696fd63d79a7e2b0a35858ae97ec30bf98fc49550db1a9e8cc", "role": "CLIENT", "short_name": "FLL7", "snr": 1.1, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.401, "battery_level": 32, "channel_utilization": 11.97, "uptime_seconds": 73909, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2381, "long_name": "Shady Hawk", "next_hop": 0, "num": "0x25eea078", "position": {"altitude": 1548, "latitude": 32.966234, "location_source": "LOC_INTERNAL", "longitude": -107.004419, "time_offset_sec": 2406}, "public_key_hex": "497a86eed0ca8ad7804229c089b8fd5fbf48839bfd625d02d6872418fa92e852", "role": "CLIENT", "short_name": "SIC9", "snr": 8.74, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.207, "battery_level": 50, "channel_utilization": 12.97, "uptime_seconds": 19430, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1081, "long_name": "Happy Sage", "next_hop": 203, "num": "0x2667d1f8", "position": {"altitude": 1552, "latitude": 33.095074, "location_source": "LOC_INTERNAL", "longitude": -107.13094, "time_offset_sec": 1281}, "public_key_hex": "0c000b564a9244a33072f13726b86b280a1200a0670fad5dcfa817889d69ad42", "role": "CLIENT", "short_name": "H1X4", "snr": 5.61, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1085, "long_name": "Frosty Phoenix NM1FH", "next_hop": 196, "num": "0x26ca2f96", "position": {"altitude": 1514, "latitude": 32.151259, "location_source": "LOC_INTERNAL", "longitude": -106.862516, "time_offset_sec": 1238}, "public_key_hex": "ce6fcb85cc3e972ffdf1d82ae23f7c1d5a770cbc578fc1574fa7fd665fa6f62e", "role": "CLIENT", "short_name": "FQCL", "snr": 8.5, "status": null, "telemetry": {"air_util_tx": 0.334, "battery_level": 41, "channel_utilization": 18.85, "uptime_seconds": 421633, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.01, "iaq": 68, "relative_humidity": 88.46, "temperature": 26.35}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 7290, "long_name": "Burning Mustang", "next_hop": 1, "num": "0x27425ae4", "position": null, "public_key_hex": "000e54002f8c0ab1480cbba098217703d232c413abb1ea40e45354716a1eb986", "role": "CLIENT", "short_name": "BN4S", "snr": 6.09, "status": null, "telemetry": {"air_util_tx": 0.245, "battery_level": 22, "channel_utilization": 8.9, "uptime_seconds": 138472, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4044, "long_name": "Misty Mesa", "next_hop": 166, "num": "0x28189553", "position": {"altitude": 1530, "latitude": 34.237739, "location_source": "LOC_INTERNAL", "longitude": -107.293962, "time_offset_sec": 4114}, "public_key_hex": "7bd4aad3389db08c0ab3187703ad4b8890f3894fe5336f6bf207aa86a2fb6d0d", "role": "CLIENT_BASE", "short_name": "MWNZ", "snr": 3.45, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.33, "iaq": 17, "relative_humidity": 48.69, "temperature": 12.51}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 5033, "long_name": "Whispering Mesa", "next_hop": 0, "num": "0x286532a5", "position": {"altitude": 1587, "latitude": 33.100997, "location_source": "LOC_INTERNAL", "longitude": -107.578256, "time_offset_sec": 5317}, "public_key_hex": "408f11c37752fb1225f8f6ac0e07af1bc9525af15356cddad6afddbb9cdfd572", "role": "CLIENT_MUTE", "short_name": "WDPB", "snr": 2.02, "status": null, "telemetry": {"air_util_tx": 0.463, "battery_level": 28, "channel_utilization": 3.29, "uptime_seconds": 6869, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 3696, "long_name": "Slow Eagle", "next_hop": 0, "num": "0x2871d2c3", "position": {"altitude": 1315, "latitude": 32.745414, "location_source": "LOC_INTERNAL", "longitude": -107.895128, "time_offset_sec": 3752}, "public_key_hex": "1fff7ed20f2ff6f158999d61b02e3e18377cd1bf32017465ce2458bb7688205d", "role": "CLIENT", "short_name": "🦅", "snr": 5.72, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.312, "battery_level": 18, "channel_utilization": 4.14, "uptime_seconds": 78516, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 6358, "long_name": "Canyon Bluff", "next_hop": 0, "num": "0x28b78c55", "position": {"altitude": 1051, "latitude": 33.951996, "location_source": "LOC_INTERNAL", "longitude": -108.109663, "time_offset_sec": 6621}, "public_key_hex": "a4748a9966ad143c72cb65ab661b9fa78bc7197600d4149361c1c9fe7790290d", "role": "CLIENT", "short_name": "CUWK", "snr": 2.14, "status": null, "telemetry": {"air_util_tx": 1.151, "battery_level": 101, "channel_utilization": 1.72, "uptime_seconds": 78558, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2160, "long_name": "Frozen Yucca", "next_hop": 0, "num": "0x2902b034", "position": {"altitude": 2009, "latitude": 32.901567, "location_source": "LOC_INTERNAL", "longitude": -107.182626, "time_offset_sec": 2266}, "public_key_hex": "0a82dcd2a4fbdfd1209f04d2784e33b87e9391a976b92d6d20846aeb4fb48032", "role": "CLIENT", "short_name": "F9PB", "snr": 7.53, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.388, "battery_level": 100, "channel_utilization": 30.03, "uptime_seconds": 127938, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 593, "long_name": "Copper Bluff", "next_hop": 0, "num": "0x294b4ff2", "position": null, "public_key_hex": "95f367b92ac6236e41ff536b3800fee8d14ff4482c678b7a9d3e44d11935e382", "role": "CLIENT", "short_name": "C9HF", "snr": 5.99, "status": null, "telemetry": {"air_util_tx": 0.024, "battery_level": 97, "channel_utilization": 11.51, "uptime_seconds": 16495, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2761, "long_name": "Frozen Crane", "next_hop": 237, "num": "0x29735d36", "position": {"altitude": 1161, "latitude": 32.957968, "location_source": "LOC_INTERNAL", "longitude": -107.416665, "time_offset_sec": 2855}, "public_key_hex": "d38c2fc7f95c3e4306a68f3ec9ca5eb4c6a4327154b73e50e32adf4f62e71ef9", "role": "TRACKER", "short_name": "F9K1", "snr": 2.16, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.263, "battery_level": 71, "channel_utilization": 12.68, "uptime_seconds": 65271, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.33, "iaq": 60, "relative_humidity": 87.04, "temperature": 11.48}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2916, "long_name": "Happy Moose", "next_hop": 0, "num": "0x29c9c9f0", "position": {"altitude": 1068, "latitude": 33.048205, "location_source": "LOC_INTERNAL", "longitude": -107.182212, "time_offset_sec": 3211}, "public_key_hex": "754a5b838bc5dba0743c1fe2cb527abaacfdcd0eb9956be4def3c4101e79d1e7", "role": "ROUTER", "short_name": "H9AT", "snr": 6.68, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.627, "battery_level": 30, "channel_utilization": 0.9, "uptime_seconds": 83541, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 11816, "long_name": "Short Juniper", "next_hop": 0, "num": "0x29d4f0d6", "position": {"altitude": 1657, "latitude": 33.175304, "location_source": "LOC_INTERNAL", "longitude": -107.340189, "time_offset_sec": 12064}, "public_key_hex": "82daf0da564c692a844e39c61351aba1af6df06fbefb5b8d1067fab533e88f20", "role": "CLIENT", "short_name": "SBUX", "snr": 7.39, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 11612, "long_name": "Sky Eagle", "next_hop": 0, "num": "0x29d75e55", "position": {"altitude": 1363, "latitude": 32.634353, "location_source": "LOC_INTERNAL", "longitude": -106.629271, "time_offset_sec": 11653}, "public_key_hex": "", "role": "CLIENT", "short_name": "SCET", "snr": 4.27, "status": null, "telemetry": {"air_util_tx": 0.121, "battery_level": 76, "channel_utilization": 16.94, "uptime_seconds": 10144, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 191, "long_name": "Sneaky Bear", "next_hop": 192, "num": "0x2a7eafe7", "position": {"altitude": 1050, "latitude": 33.006934, "location_source": "LOC_INTERNAL", "longitude": -107.180556, "time_offset_sec": 377}, "public_key_hex": "aa4c4b6ce50afd0f90e0c74aad3074c4a3e9bc1f4b7cd7eedacc37c98728d84e", "role": "CLIENT", "short_name": "S2BM", "snr": 1.17, "status": null, "telemetry": {"air_util_tx": 0.268, "battery_level": 70, "channel_utilization": 0.88, "uptime_seconds": 62363, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 9242, "long_name": "Frozen Lion", "next_hop": 0, "num": "0x2a8ddd3b", "position": {"altitude": 1289, "latitude": 33.408764, "location_source": "LOC_INTERNAL", "longitude": -106.941602, "time_offset_sec": 9277}, "public_key_hex": "36e68f8e390af3201a9c29dfee18c770ea4e06bd0d29bc6103ee153c9a53cbb7", "role": "CLIENT", "short_name": "FUET", "snr": 8.86, "status": null, "telemetry": {"air_util_tx": 0.142, "battery_level": 30, "channel_utilization": 10.54, "uptime_seconds": 67024, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 1682, "long_name": "Quick Lion", "next_hop": 0, "num": "0x2afcb0c0", "position": {"altitude": 1064, "latitude": 33.062992, "location_source": "LOC_INTERNAL", "longitude": -108.242545, "time_offset_sec": 1920}, "public_key_hex": "", "role": "ROUTER", "short_name": "QCH1", "snr": 9.0, "status": null, "telemetry": {"air_util_tx": 0.025, "battery_level": 36, "channel_utilization": 5.1, "uptime_seconds": 864, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 1956, "long_name": "Sunny Lynx", "next_hop": 0, "num": "0x2b347222", "position": {"altitude": 1296, "latitude": 33.162348, "location_source": "LOC_INTERNAL", "longitude": -107.196228, "time_offset_sec": 2042}, "public_key_hex": "e926767fbe87024d4b7a0af44cae50c39e8c8c5755616a8e59b20d80b9944b15", "role": "CLIENT", "short_name": "SAHV", "snr": 11.11, "status": null, "telemetry": {"air_util_tx": 0.501, "battery_level": 77, "channel_utilization": 8.54, "uptime_seconds": 228867, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2557, "long_name": "Silent Pony", "next_hop": 112, "num": "0x2b34a346", "position": {"altitude": 1489, "latitude": 33.126571, "location_source": "LOC_INTERNAL", "longitude": -106.378904, "time_offset_sec": 2563}, "public_key_hex": "f5a89f662c2f3a5f55d478e7c2f33ec9e7f4359b7891a3c229689ea8c0c7d68c", "role": "SENSOR", "short_name": "SL36", "snr": 0.9, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 5, "hw_model": "THINKNODE_M6", "last_heard_offset_sec": 9092, "long_name": "Giant Stag", "next_hop": 227, "num": "0x2b5e052c", "position": {"altitude": 1221, "latitude": 34.103918, "location_source": "LOC_INTERNAL", "longitude": -108.470304, "time_offset_sec": 9124}, "public_key_hex": "6a5ec94870af7d5b5e665de3bd732d04638d7af8fd295b7ac2d69fdc1846163b", "role": "CLIENT", "short_name": "GDT6", "snr": 5.84, "status": null, "telemetry": {"air_util_tx": 0.161, "battery_level": 23, "channel_utilization": 24.35, "uptime_seconds": 52091, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 997.51, "iaq": 54, "relative_humidity": 66.77, "temperature": 21.53}, "hops_away": 6, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 11094, "long_name": "Canyon Turtle", "next_hop": 103, "num": "0x2ba1d378", "position": {"altitude": 1078, "latitude": 32.838468, "location_source": "LOC_INTERNAL", "longitude": -107.571802, "time_offset_sec": 11359}, "public_key_hex": "f463d51e7aaecb4d1428131e72f3b7ea449e6a8d5c22855800867a43e5b10af4", "role": "CLIENT", "short_name": "CMWK", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.194, "battery_level": 86, "channel_utilization": 14.99, "uptime_seconds": 9072, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 6651, "long_name": "Tiny Mole", "next_hop": 0, "num": "0x2c3b2dcc", "position": {"altitude": 1171, "latitude": 33.265768, "location_source": "LOC_INTERNAL", "longitude": -107.158108, "time_offset_sec": 6708}, "public_key_hex": "95fefb0414af73334daa18eb1d43b1ef42aed034cc4f34ad42485ae89a6e836a", "role": "CLIENT_BASE", "short_name": "TFKN", "snr": -0.19, "status": null, "telemetry": {"air_util_tx": 1.682, "battery_level": 43, "channel_utilization": 3.54, "uptime_seconds": 333245, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1313, "long_name": "Silent Bison", "next_hop": 95, "num": "0x2c44806f", "position": {"altitude": 1673, "latitude": 33.332687, "location_source": "LOC_INTERNAL", "longitude": -108.403882, "time_offset_sec": 1392}, "public_key_hex": "a1dbb254d365eb8ce1df7ae1ada160c03eaa9dd8eab64d6b86364e347cb732c4", "role": "CLIENT", "short_name": "SO8Q", "snr": 3.28, "status": null, "telemetry": {"air_util_tx": 0.161, "battery_level": 12, "channel_utilization": 2.33, "uptime_seconds": 1147, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.9, "iaq": 70, "relative_humidity": 57.26, "temperature": 23.7}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 371, "long_name": "Green Shark", "next_hop": 0, "num": "0x2c4f0a46", "position": {"altitude": 1198, "latitude": 32.459029, "location_source": "LOC_INTERNAL", "longitude": -106.520458, "time_offset_sec": 566}, "public_key_hex": "25e2d778098b7e11fcd2dcca143d2b3fb92888c4310bc5116dafa090525f627b", "role": "TAK", "short_name": "GGZ8", "snr": 8.81, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3647, "long_name": "Sky Crow", "next_hop": 0, "num": "0x2cc1b44c", "position": {"altitude": 1766, "latitude": 33.038761, "location_source": "LOC_INTERNAL", "longitude": -107.317439, "time_offset_sec": 3896}, "public_key_hex": "74f1a468c5ffc4551aa890f3adfaad3c6b546c3037d647a496242260916f9c25", "role": "CLIENT", "short_name": "🐺", "snr": 11.05, "status": null, "telemetry": {"air_util_tx": 0.47, "battery_level": 23, "channel_utilization": 2.74, "uptime_seconds": 129382, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 10432, "long_name": "Old Juniper", "next_hop": 0, "num": "0x2cdbaab1", "position": {"altitude": 1133, "latitude": 33.066707, "location_source": "LOC_INTERNAL", "longitude": -107.620177, "time_offset_sec": 10514}, "public_key_hex": "24325838a5b348c60c12a6de6da9f9d56f0b91cdb4e35ae05d8d7d7b0b60145e", "role": "TRACKER", "short_name": "OTGD", "snr": 1.91, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 786, "long_name": "Drifting Shark", "next_hop": 0, "num": "0x2cffb46b", "position": {"altitude": 1769, "latitude": 33.111323, "location_source": "LOC_INTERNAL", "longitude": -107.792845, "time_offset_sec": 809}, "public_key_hex": "7b2d36372449f86224e31c825f0b83df3242c51e6590d8ee43fdd3d3b6eb2cf4", "role": "CLIENT_MUTE", "short_name": "DMHA", "snr": 2.03, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.26, "iaq": 60, "relative_humidity": 52.62, "temperature": 10.59}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 2156, "long_name": "Bright Pike", "next_hop": 0, "num": "0x2d097018", "position": {"altitude": 1961, "latitude": 32.728872, "location_source": "LOC_INTERNAL", "longitude": -107.467619, "time_offset_sec": 2242}, "public_key_hex": "4032dc18f7ac255f8b45e6d0de53c9e26a87773fe1fa144d0bead43fcfef6dc0", "role": "CLIENT", "short_name": "BLUO", "snr": 9.96, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.296, "battery_level": 26, "channel_utilization": 5.76, "uptime_seconds": 352047, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1297, "long_name": "White Seal", "next_hop": 203, "num": "0x2d467a1e", "position": {"altitude": 606, "latitude": 32.590838, "location_source": "LOC_INTERNAL", "longitude": -108.031101, "time_offset_sec": 1423}, "public_key_hex": "cc3909ddca959ec85a5651226abfe0466c75711dabfdd4c95c1eace8fa95eaed", "role": "CLIENT", "short_name": "WS08", "snr": 6.64, "status": null, "telemetry": {"air_util_tx": 1.644, "battery_level": 66, "channel_utilization": 8.71, "uptime_seconds": 55069, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.43, "iaq": 55, "relative_humidity": 55.36, "temperature": 21.12}, "hops_away": 0, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 8020, "long_name": "Rough Bison", "next_hop": 0, "num": "0x2da1c655", "position": {"altitude": 652, "latitude": 33.586611, "location_source": "LOC_INTERNAL", "longitude": -108.107971, "time_offset_sec": 8318}, "public_key_hex": "91b61115d77af4176a7c0816507b80a701deffeb9c71c1f1129aa08ec3e2b8b2", "role": "CLIENT", "short_name": "REY0", "snr": 7.9, "status": null, "telemetry": {"air_util_tx": 0.83, "battery_level": 35, "channel_utilization": 11.3, "uptime_seconds": 47328, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 996.72, "iaq": 31, "relative_humidity": 58.79, "temperature": 13.99}, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 6928, "long_name": "Floating Falcon", "next_hop": 0, "num": "0x2dff7287", "position": {"altitude": 1298, "latitude": 34.609606, "location_source": "LOC_INTERNAL", "longitude": -106.295517, "time_offset_sec": 7087}, "public_key_hex": "ec530a9f8866ba7523aaea2a20934463ce8a143ddc553e3e85a2d2173007b4a6", "role": "CLIENT", "short_name": "F1BM", "snr": 5.16, "status": null, "telemetry": {"air_util_tx": 0.682, "battery_level": 101, "channel_utilization": 15.94, "uptime_seconds": 19307, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2915, "long_name": "Red Coyote", "next_hop": 0, "num": "0x2e0aa0ef", "position": null, "public_key_hex": "ccfcbc27c57006045a1608fbe32bd1835b6e618d32a1ed4ee86794fb98e9de8b", "role": "CLIENT", "short_name": "R4CS", "snr": 10.93, "status": null, "telemetry": {"air_util_tx": 0.68, "battery_level": 99, "channel_utilization": 19.5, "uptime_seconds": 52545, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.3, "iaq": 45, "relative_humidity": 38.32, "temperature": 25.02}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2771, "long_name": "Shady Owl", "next_hop": 0, "num": "0x2e5a3396", "position": {"altitude": 1560, "latitude": 33.447145, "location_source": "LOC_INTERNAL", "longitude": -107.147302, "time_offset_sec": 2916}, "public_key_hex": "595bd7bc432fac984064d001e07e153d1a37856a9b1c4425f1a595cd99912bea", "role": "CLIENT", "short_name": "SKRA", "snr": 1.49, "status": null, "telemetry": {"air_util_tx": 1.042, "battery_level": 38, "channel_utilization": 2.38, "uptime_seconds": 4539, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3479, "long_name": "Lost Eagle", "next_hop": 0, "num": "0x2ed3f2c9", "position": {"altitude": 1409, "latitude": 34.047196, "location_source": "LOC_INTERNAL", "longitude": -107.411467, "time_offset_sec": 3584}, "public_key_hex": "901e5d5764542878fd2435626e367e76eb6a8db9ad51ff6de3d1019ff78017fd", "role": "CLIENT", "short_name": "LB5Y", "snr": 4.57, "status": null, "telemetry": {"air_util_tx": 0.888, "battery_level": 85, "channel_utilization": 5.94, "uptime_seconds": 12625, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2410, "long_name": "Lunar Falcon", "next_hop": 0, "num": "0x2f0f3244", "position": {"altitude": 1196, "latitude": 33.385031, "location_source": "LOC_INTERNAL", "longitude": -108.171963, "time_offset_sec": 2489}, "public_key_hex": "f8615cc5faf7f35fbaddfa32d3e468615a58d7e9113d905271d96d4fa5b0c767", "role": "CLIENT", "short_name": "LCNT", "snr": 6.43, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1008.36, "iaq": 66, "relative_humidity": 32.6, "temperature": 24.35}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 13431, "long_name": "Roving Otter", "next_hop": 0, "num": "0x2f2b0cc3", "position": {"altitude": 1502, "latitude": 33.075668, "location_source": "LOC_INTERNAL", "longitude": -106.545429, "time_offset_sec": 13708}, "public_key_hex": "3fe4d1db3059e2ba5734e2442ad2f192e141d6d02a1000d1124ec34bfe4969c0", "role": "CLIENT", "short_name": "RYEF", "snr": 2.93, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.196, "battery_level": 85, "channel_utilization": 11.67, "uptime_seconds": 43860, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4490, "long_name": "Red Cobra", "next_hop": 178, "num": "0x2f335cb1", "position": {"altitude": 1284, "latitude": 32.952871, "location_source": "LOC_INTERNAL", "longitude": -106.45338, "time_offset_sec": 4598}, "public_key_hex": "d38ba579c9c86914d7a46952920363d445a63077cec64ed8eedd2f530f199aac", "role": "CLIENT", "short_name": "R8FE", "snr": 10.84, "status": null, "telemetry": {"air_util_tx": 0.271, "battery_level": 30, "channel_utilization": 5.39, "uptime_seconds": 16626, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.84, "iaq": 107, "relative_humidity": 86.07, "temperature": 26.78}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1556, "long_name": "Wild Mamba", "next_hop": 0, "num": "0x2f4a24be", "position": {"altitude": 1283, "latitude": 34.196781, "location_source": "LOC_INTERNAL", "longitude": -108.081588, "time_offset_sec": 1742}, "public_key_hex": "d302285d86ef2cfd19f365606d60be646a461dc86436bd75682ff8c97b9d5629", "role": "CLIENT", "short_name": "WIR0", "snr": 6.83, "status": null, "telemetry": {"air_util_tx": 0.218, "battery_level": 99, "channel_utilization": 10.27, "uptime_seconds": 22497, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 8074, "long_name": "Short Mustang", "next_hop": 0, "num": "0x2f8041b6", "position": {"altitude": 1629, "latitude": 32.430916, "location_source": "LOC_INTERNAL", "longitude": -106.903131, "time_offset_sec": 8241}, "public_key_hex": "ca5b2ba5ef9580e07e2f24bc7255e9051a47e7051993a3f64d6208f2506d9d98", "role": "CLIENT", "short_name": "SPK3", "snr": 3.93, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.103, "battery_level": 92, "channel_utilization": 24.36, "uptime_seconds": 89728, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.39, "iaq": 77, "relative_humidity": 67.81, "temperature": 16.65}, "hops_away": 1, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 293, "long_name": "Tiny Bluff", "next_hop": 222, "num": "0x2f9511af", "position": {"altitude": 1078, "latitude": 33.394593, "location_source": "LOC_INTERNAL", "longitude": -107.04634, "time_offset_sec": 572}, "public_key_hex": "5852c0696fce46ca89d723cd0a4f45a629c2536cfbee3e14deb0fda6e827e2fe", "role": "CLIENT", "short_name": "TBIV", "snr": -0.85, "status": null, "telemetry": {"air_util_tx": 1.387, "battery_level": 96, "channel_utilization": 4.88, "uptime_seconds": 30393, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1010.15, "iaq": 73, "relative_humidity": 69.95, "temperature": 25.35}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 3521, "long_name": "Silent Hawk NM2XD", "next_hop": 25, "num": "0x2fddcc45", "position": {"altitude": 951, "latitude": 33.560012, "location_source": "LOC_INTERNAL", "longitude": -107.640412, "time_offset_sec": 3551}, "public_key_hex": "4cfdd5c01b12f81cd4f90801d290858e46ea16f21e300805ffe48bd02412bdf8", "role": "ROUTER", "short_name": "SBQM", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.015, "battery_level": 26, "channel_utilization": 12.25, "uptime_seconds": 439034, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1017.89, "iaq": 36, "relative_humidity": 86.12, "temperature": 32.4}, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2517, "long_name": "Howling Fox", "next_hop": 102, "num": "0x2ffc54b6", "position": null, "public_key_hex": "94381db82f1ffdc0ce3680dc424a2550f0e3186e33a46b5dbfaf012b8c982c73", "role": "CLIENT", "short_name": "HGU8", "snr": 3.02, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.67, "iaq": 34, "relative_humidity": 51.43, "temperature": 31.02}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 755, "long_name": "Old Lion", "next_hop": 0, "num": "0x3019784d", "position": null, "public_key_hex": "dd70ca76b27534160accff9cfbffa89c475ed6a0df324753942cd9670946b7cd", "role": "CLIENT", "short_name": "🦇", "snr": 4.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.06, "iaq": 15, "relative_humidity": 61.18, "temperature": 24.46}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2640, "long_name": "Steel Aspen", "next_hop": 34, "num": "0x302276b1", "position": {"altitude": 1389, "latitude": 32.178993, "location_source": "LOC_INTERNAL", "longitude": -106.728026, "time_offset_sec": 2827}, "public_key_hex": "10903a4d8c75b7d8d645b213d4bd7bf2e7510008b1058338040e1899ffb88905", "role": "CLIENT", "short_name": "🐝", "snr": -3.89, "status": null, "telemetry": {"air_util_tx": 0.033, "battery_level": 95, "channel_utilization": 16.84, "uptime_seconds": 85688, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.87, "iaq": 26, "relative_humidity": 38.89, "temperature": 22.29}, "hops_away": 4, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1055, "long_name": "Canyon Heron", "next_hop": 59, "num": "0x302347c6", "position": {"altitude": 1500, "latitude": 33.175513, "location_source": "LOC_INTERNAL", "longitude": -108.193238, "time_offset_sec": 1148}, "public_key_hex": "13f20da3a657fce0204811efc1ae982c95ccaebb7261a0e5366262f686f210de", "role": "ROUTER", "short_name": "CL68", "snr": 3.4, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.501, "battery_level": 19, "channel_utilization": 13.65, "uptime_seconds": 55497, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1081, "long_name": "Slow Moose", "next_hop": 61, "num": "0x307e37f5", "position": {"altitude": 1723, "latitude": 32.61456, "location_source": "LOC_INTERNAL", "longitude": -107.310215, "time_offset_sec": 1181}, "public_key_hex": "a16dc21e89c07d49ecfc537b7806c9bbc0188f7defbd5cef438c5e43e8ec9ce3", "role": "CLIENT", "short_name": "SJ62", "snr": 4.57, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2493, "long_name": "Sharp Phoenix", "next_hop": 36, "num": "0x30b51075", "position": {"altitude": 1704, "latitude": 33.168725, "location_source": "LOC_INTERNAL", "longitude": -106.951606, "time_offset_sec": 2683}, "public_key_hex": "35c7f7d6bb4712bb032246ffc038364d6369f4039bbae66478bf682e95c45d45", "role": "CLIENT", "short_name": "SU0J", "snr": 10.27, "status": null, "telemetry": {"air_util_tx": 0.51, "battery_level": 11, "channel_utilization": 26.69, "uptime_seconds": 39780, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 9096, "long_name": "Drowsy Bronco", "next_hop": 0, "num": "0x30efe3b4", "position": {"altitude": 1245, "latitude": 33.106372, "location_source": "LOC_INTERNAL", "longitude": -106.7179, "time_offset_sec": 9270}, "public_key_hex": "b0e4d9ce00bb3ffc3f329d3ab02eef180478951cc871cf8971b26c0decf26d0c", "role": "CLIENT", "short_name": "DE6W", "snr": 6.29, "status": null, "telemetry": {"air_util_tx": 1.628, "battery_level": 72, "channel_utilization": 10.45, "uptime_seconds": 29813, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.35, "iaq": 91, "relative_humidity": 43.65, "temperature": 18.36}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 357, "long_name": "Dawn Coyote", "next_hop": 116, "num": "0x30f40c60", "position": {"altitude": 1338, "latitude": 32.549872, "location_source": "LOC_INTERNAL", "longitude": -106.643419, "time_offset_sec": 626}, "public_key_hex": "69ff9ea49887dbb9f206994b2aea3aa7c1428110ad50b54c0dd8ea32b716a4ba", "role": "CLIENT", "short_name": "DFTL", "snr": 0.96, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.038, "battery_level": 24, "channel_utilization": 10.63, "uptime_seconds": 54787, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 754, "long_name": "Loud Bass", "next_hop": 0, "num": "0x30fa4c03", "position": {"altitude": 1498, "latitude": 33.853065, "location_source": "LOC_INTERNAL", "longitude": -107.51827, "time_offset_sec": 854}, "public_key_hex": "74834eb0e774da1724557df897960e7f153351e1b4361a69dc4dc2528c302b06", "role": "ROUTER_LATE", "short_name": "LKB1", "snr": 6.84, "status": null, "telemetry": {"air_util_tx": 0.676, "battery_level": 98, "channel_utilization": 8.2, "uptime_seconds": 1124, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3800, "long_name": "Bright Trout", "next_hop": 19, "num": "0x31328c71", "position": {"altitude": 1732, "latitude": 32.840214, "location_source": "LOC_INTERNAL", "longitude": -107.137479, "time_offset_sec": 4085}, "public_key_hex": "7fc6cad0e2b814bcb1d3fb7f070e737a17c3197f48d16abb9c1a7775155f7d66", "role": "ROUTER", "short_name": "BGVZ", "snr": 3.4, "status": null, "telemetry": {"air_util_tx": 0.948, "battery_level": 23, "channel_utilization": 6.91, "uptime_seconds": 168843, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 4140, "long_name": "Loud Bear", "next_hop": 0, "num": "0x314173e9", "position": {"altitude": 1218, "latitude": 32.880206, "location_source": "LOC_INTERNAL", "longitude": -107.441087, "time_offset_sec": 4236}, "public_key_hex": "c92c8f7bae844f67fa71181009c39e75cf5f68d22de5de67d2a621cfddf57114", "role": "CLIENT", "short_name": "LBDB", "snr": 3.82, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 70, "long_name": "Old Stag", "next_hop": 0, "num": "0x3148a12f", "position": {"altitude": 1020, "latitude": 33.518529, "location_source": "LOC_INTERNAL", "longitude": -106.755016, "time_offset_sec": 93}, "public_key_hex": "4b902cc9230b4885370f304fe23a6075f3faf48bf9b57467c2db1fb8622dcb30", "role": "CLIENT", "short_name": "OJ86", "snr": 4.7, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1791, "long_name": "Loud Adder", "next_hop": 0, "num": "0x31a6a1e3", "position": {"altitude": 1118, "latitude": 32.634524, "location_source": "LOC_INTERNAL", "longitude": -107.177717, "time_offset_sec": 2041}, "public_key_hex": "299a024b5ba48817147c466d268a5f1a19df19cc757ba4226c9c6f37f7a3f2e9", "role": "CLIENT", "short_name": "LMAU", "snr": 1.54, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.824, "battery_level": 70, "channel_utilization": 8.51, "uptime_seconds": 23034, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 5550, "long_name": "Desert Cedar", "next_hop": 0, "num": "0x31fff78e", "position": {"altitude": 1916, "latitude": 32.538876, "location_source": "LOC_INTERNAL", "longitude": -107.03072, "time_offset_sec": 5711}, "public_key_hex": "0781a7eb508fc0c538cdd99780f491250f7e5325a63d37fc8ef3664f671991e3", "role": "CLIENT", "short_name": "DGCN", "snr": 7.54, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.935, "battery_level": 17, "channel_utilization": 9.45, "uptime_seconds": 35953, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.25, "iaq": 45, "relative_humidity": 30.09, "temperature": 23.56}, "hops_away": 2, "hw_model": "SEEED_WIO_TRACKER_L1_EINK", "last_heard_offset_sec": 1401, "long_name": "Frozen Heron", "next_hop": 103, "num": "0x3210af50", "position": {"altitude": 1273, "latitude": 33.848447, "location_source": "LOC_INTERNAL", "longitude": -107.514036, "time_offset_sec": 1503}, "public_key_hex": "e1e001184ee54ec7c0646a98c35b4561ecec57f3c2f4c10a6885cb6024dffca8", "role": "ROUTER", "short_name": "F5HQ", "snr": 1.84, "status": null, "telemetry": {"air_util_tx": 0.24, "battery_level": 75, "channel_utilization": 24.19, "uptime_seconds": 117051, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2567, "long_name": "Brave Pony", "next_hop": 117, "num": "0x32919c69", "position": {"altitude": 1493, "latitude": 33.621492, "location_source": "LOC_INTERNAL", "longitude": -107.271347, "time_offset_sec": 2582}, "public_key_hex": "6dd3bcfb3a06e79a64f2d91f880a7abb3091eca24f9ab291a1e97ae18c61c383", "role": "CLIENT", "short_name": "BP7J", "snr": 9.61, "status": null, "telemetry": {"air_util_tx": 0.122, "battery_level": 71, "channel_utilization": 6.1, "uptime_seconds": 21430, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2961, "long_name": "Sunny Bronco", "next_hop": 0, "num": "0x32dc1a76", "position": {"altitude": 1417, "latitude": 32.755904, "location_source": "LOC_INTERNAL", "longitude": -107.013091, "time_offset_sec": 3254}, "public_key_hex": "cc1303af364737c5f324c4b320d8c2072124475f10becb923fbfd08708f05021", "role": "CLIENT_MUTE", "short_name": "STBX", "snr": 4.13, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 2894, "long_name": "Storm Mole", "next_hop": 22, "num": "0x32f499a6", "position": {"altitude": 1596, "latitude": 33.814613, "location_source": "LOC_INTERNAL", "longitude": -107.14183, "time_offset_sec": 2962}, "public_key_hex": "7ff62f65c7a67fc4f7852b3faf04002d94522d7dbc22e36be2298374214b0e0d", "role": "CLIENT", "short_name": "SLZ8", "snr": 6.07, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 6321, "long_name": "Silver Bass", "next_hop": 204, "num": "0x330f06d8", "position": {"altitude": 1549, "latitude": 33.241452, "location_source": "LOC_INTERNAL", "longitude": -108.032326, "time_offset_sec": 6536}, "public_key_hex": "5e71597621bf7267184483913b770267bec132efc5d34f75b37e177dd6503eb6", "role": "CLIENT", "short_name": "🌵", "snr": 0.32, "status": null, "telemetry": {"air_util_tx": 0.219, "battery_level": 24, "channel_utilization": 14.56, "uptime_seconds": 6201, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 301, "long_name": "White Doe", "next_hop": 0, "num": "0x33b5c384", "position": {"altitude": 1916, "latitude": 33.342177, "location_source": "LOC_INTERNAL", "longitude": -107.48488, "time_offset_sec": 380}, "public_key_hex": "8dd111fc91dce2a62afa71e4612e4b0778102f5a46d545bdf04b3228dedd608e", "role": "CLIENT_HIDDEN", "short_name": "WENR", "snr": 4.32, "status": null, "telemetry": {"air_util_tx": 0.292, "battery_level": 62, "channel_utilization": 11.98, "uptime_seconds": 54735, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 4872, "long_name": "White Bass", "next_hop": 0, "num": "0x33b6711f", "position": {"altitude": 1275, "latitude": 33.221311, "location_source": "LOC_INTERNAL", "longitude": -107.068917, "time_offset_sec": 5022}, "public_key_hex": "12f331b46977af65cefbbe75943be108c668439b5079ebce47b4ec175f489364", "role": "CLIENT", "short_name": "W3X2", "snr": 11.91, "status": null, "telemetry": {"air_util_tx": 1.203, "battery_level": 50, "channel_utilization": 14.41, "uptime_seconds": 170602, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1790, "long_name": "Soft Pike", "next_hop": 0, "num": "0x348294ad", "position": {"altitude": 710, "latitude": 33.598746, "location_source": "LOC_INTERNAL", "longitude": -108.138437, "time_offset_sec": 1922}, "public_key_hex": "a0f3c269ce8c86665e461ddb1b37ee7705907807ec0ef1f5f9f279c407a0b20c", "role": "CLIENT", "short_name": "🌙", "snr": 7.01, "status": null, "telemetry": {"air_util_tx": 0.308, "battery_level": 65, "channel_utilization": 19.14, "uptime_seconds": 42664, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2941, "long_name": "Tiny Badger", "next_hop": 38, "num": "0x34bf5541", "position": {"altitude": 1620, "latitude": 32.840343, "location_source": "LOC_INTERNAL", "longitude": -107.512689, "time_offset_sec": 2966}, "public_key_hex": "2a86016d6430e85b0dac4d8e4880491e5312197853d46a24c8871c40188a30db", "role": "CLIENT", "short_name": "TZ4C", "snr": 11.35, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.031, "battery_level": 23, "channel_utilization": 28.95, "uptime_seconds": 147350, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 5891, "long_name": "New Otter", "next_hop": 0, "num": "0x3543fc07", "position": {"altitude": 1414, "latitude": 34.551594, "location_source": "LOC_INTERNAL", "longitude": -106.134115, "time_offset_sec": 6027}, "public_key_hex": "85b64f1767c97c79d4cb15d12be49ab011e3f85011e21c44d81220b4b86283ca", "role": "CLIENT", "short_name": "N97N", "snr": 11.67, "status": null, "telemetry": {"air_util_tx": 0.612, "battery_level": 77, "channel_utilization": 1.99, "uptime_seconds": 242076, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 125, "long_name": "Sharp Lion", "next_hop": 141, "num": "0x359c376f", "position": {"altitude": 1180, "latitude": 33.557869, "location_source": "LOC_INTERNAL", "longitude": -107.626806, "time_offset_sec": 138}, "public_key_hex": "", "role": "CLIENT_BASE", "short_name": "SN7T", "snr": 6.47, "status": {"status": "low-batt"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1929, "long_name": "Silent Cedar", "next_hop": 157, "num": "0x35e89ead", "position": null, "public_key_hex": "1489005e7bb07aa8e8695ad0e92706492e40be9c37ccc50f6837ef4d79d03224", "role": "CLIENT", "short_name": "🐢", "snr": 5.67, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 4919, "long_name": "White Ridge", "next_hop": 132, "num": "0x37294851", "position": {"altitude": 979, "latitude": 32.075937, "location_source": "LOC_INTERNAL", "longitude": -107.008495, "time_offset_sec": 5058}, "public_key_hex": "", "role": "CLIENT", "short_name": "WEDP", "snr": 4.49, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 9052, "long_name": "Steel Hawk", "next_hop": 0, "num": "0x372e6f8c", "position": {"altitude": 1797, "latitude": 32.753516, "location_source": "LOC_INTERNAL", "longitude": -108.284545, "time_offset_sec": 9141}, "public_key_hex": "0db68598c24a5862f20efb3157d1f202e4cd43a6a2f7e0ee459016a8a0e3dd9f", "role": "CLIENT", "short_name": "S6VC", "snr": 4.3, "status": null, "telemetry": {"air_util_tx": 0.959, "battery_level": 85, "channel_utilization": 5.57, "uptime_seconds": 20709, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 5158, "long_name": "Solar Adder", "next_hop": 0, "num": "0x375dc1a6", "position": {"altitude": 1177, "latitude": 32.721335, "location_source": "LOC_INTERNAL", "longitude": -107.359895, "time_offset_sec": 5417}, "public_key_hex": "4ba37ff4b9c69ae5c1a759d095d2a09ffd342c2e0110e36027a95c4c76e507b8", "role": "CLIENT", "short_name": "SF6A", "snr": 3.63, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.469, "battery_level": 62, "channel_utilization": 3.24, "uptime_seconds": 68574, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 12875, "long_name": "Sky Pony", "next_hop": 104, "num": "0x379552d2", "position": {"altitude": 1489, "latitude": 33.789051, "location_source": "LOC_INTERNAL", "longitude": -107.119094, "time_offset_sec": 13111}, "public_key_hex": "39e8349e42d7f60352c7aba9e07d6e1b988ecc43d1cb0cb5e102ca97dcb53838", "role": "CLIENT", "short_name": "SP46", "snr": 4.12, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 80, "long_name": "Misty Oak", "next_hop": 67, "num": "0x37b9fab4", "position": {"altitude": 1393, "latitude": 33.269396, "location_source": "LOC_INTERNAL", "longitude": -107.181673, "time_offset_sec": 245}, "public_key_hex": "94e90eec8fe7d12cac241d6fc450a724f1aef2901f60e60abf775639accddddc", "role": "CLIENT", "short_name": "MBZW", "snr": 8.79, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2543, "long_name": "Dusk Cobra", "next_hop": 176, "num": "0x37dcc34a", "position": {"altitude": 1496, "latitude": 33.340681, "location_source": "LOC_INTERNAL", "longitude": -107.083151, "time_offset_sec": 2608}, "public_key_hex": "8180ae7681f78718a0e3fa25494d569ed1918cbde94dcbd79303d1c5a2558f4b", "role": "LOST_AND_FOUND", "short_name": "🗻", "snr": 1.02, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.035, "battery_level": 68, "channel_utilization": 6.26, "uptime_seconds": 133154, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3263, "long_name": "Canyon Oak", "next_hop": 0, "num": "0x380f2b0e", "position": {"altitude": 1501, "latitude": 33.048196, "location_source": "LOC_INTERNAL", "longitude": -106.975414, "time_offset_sec": 3425}, "public_key_hex": "ed94a403602015b926702b5631037268e97e122f8dd719158872f828ed258411", "role": "ROUTER_LATE", "short_name": "CR99", "snr": 10.68, "status": null, "telemetry": {"air_util_tx": 0.66, "battery_level": 53, "channel_utilization": 1.08, "uptime_seconds": 90754, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 5718, "long_name": "Shady Crane", "next_hop": 12, "num": "0x38422b34", "position": {"altitude": 1398, "latitude": 31.939514, "location_source": "LOC_INTERNAL", "longitude": -107.647159, "time_offset_sec": 5875}, "public_key_hex": "39a05519ebcc12e828091b42c58f65fae034f53defcbdd07a204f7f568a7c0cc", "role": "CLIENT", "short_name": "SXAJ", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2528, "long_name": "Blue Doe", "next_hop": 0, "num": "0x390512a7", "position": {"altitude": 1173, "latitude": 32.38155, "location_source": "LOC_INTERNAL", "longitude": -107.627094, "time_offset_sec": 2567}, "public_key_hex": "f84aa57ff1dbbe3f4b025ddf092f0ba21b802f99cbb58781e31440bb56737ded", "role": "CLIENT", "short_name": "BBXY", "snr": 5.5, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2557, "long_name": "Sky Mustang", "next_hop": 0, "num": "0x39ec6ec9", "position": {"altitude": 1173, "latitude": 33.228267, "location_source": "LOC_INTERNAL", "longitude": -107.236545, "time_offset_sec": 2770}, "public_key_hex": "", "role": "CLIENT", "short_name": "SRG8", "snr": 7.55, "status": null, "telemetry": {"air_util_tx": 0.059, "battery_level": 94, "channel_utilization": 6.87, "uptime_seconds": 34215, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.82, "iaq": 0, "relative_humidity": 37.18, "temperature": 23.8}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 512, "long_name": "Stone Arroyo", "next_hop": 0, "num": "0x3a813d75", "position": {"altitude": 1630, "latitude": 33.686901, "location_source": "LOC_INTERNAL", "longitude": -107.651696, "time_offset_sec": 694}, "public_key_hex": "8017e8ecf12f6b8730615c2897daaacae5ab46e6a6576c920d924f063965ae4b", "role": "CLIENT", "short_name": "SH40", "snr": 10.31, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.037, "battery_level": 101, "channel_utilization": 14.66, "uptime_seconds": 148162, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.96, "iaq": 52, "relative_humidity": 75.04, "temperature": 32.27}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 959, "long_name": "Sneaky Otter", "next_hop": 0, "num": "0x3a84b3a1", "position": {"altitude": 1511, "latitude": 32.411666, "location_source": "LOC_INTERNAL", "longitude": -107.110419, "time_offset_sec": 1253}, "public_key_hex": "37223366dd83347f2e8414b53f15c66b66906ee6a0fc2a3785df2d41398d867f", "role": "CLIENT", "short_name": "SKAE", "snr": 3.55, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 15749, "long_name": "Tall Dolphin", "next_hop": 175, "num": "0x3a8c2376", "position": {"altitude": 1897, "latitude": 32.405617, "location_source": "LOC_INTERNAL", "longitude": -107.218084, "time_offset_sec": 15903}, "public_key_hex": "a97f23293aba138f94d8b449e493576ee5cbff156159ec9d1d3d69f5dab54c69", "role": "CLIENT_HIDDEN", "short_name": "TLMT", "snr": 3.79, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 230, "long_name": "Dusk Tortoise", "next_hop": 177, "num": "0x3ad732db", "position": {"altitude": 1555, "latitude": 33.345739, "location_source": "LOC_INTERNAL", "longitude": -106.721907, "time_offset_sec": 255}, "public_key_hex": "f3b5b31e8f4319acb8e7dc8c4ca2d16c559207fd0bd08c8676e317d8abd219b4", "role": "CLIENT", "short_name": "DJ1E", "snr": 2.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 286, "long_name": "Storm Lion", "next_hop": 0, "num": "0x3b2a4f38", "position": {"altitude": 1485, "latitude": 32.976493, "location_source": "LOC_INTERNAL", "longitude": -107.309679, "time_offset_sec": 570}, "public_key_hex": "", "role": "CLIENT", "short_name": "🌲", "snr": 2.6, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.719, "battery_level": 22, "channel_utilization": 2.8, "uptime_seconds": 84144, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1135, "long_name": "Loud Pike", "next_hop": 0, "num": "0x3bbdf785", "position": {"altitude": 1010, "latitude": 31.796316, "location_source": "LOC_INTERNAL", "longitude": -106.618855, "time_offset_sec": 1417}, "public_key_hex": "", "role": "CLIENT", "short_name": "L8HA", "snr": 6.95, "status": null, "telemetry": {"air_util_tx": 2.242, "battery_level": 13, "channel_utilization": 13.33, "uptime_seconds": 179196, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 833, "long_name": "Sunny Turtle", "next_hop": 0, "num": "0x3bec6322", "position": null, "public_key_hex": "a36c93ac8c31e0e366057b42995c278bce4f58303e61ffd82c69d9b6d19c036f", "role": "CLIENT", "short_name": "S8R7", "snr": 10.81, "status": null, "telemetry": {"air_util_tx": 0.106, "battery_level": 24, "channel_utilization": 5.61, "uptime_seconds": 74568, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3132, "long_name": "Storm Hare", "next_hop": 0, "num": "0x3c260256", "position": {"altitude": 1173, "latitude": 33.178951, "location_source": "LOC_INTERNAL", "longitude": -107.43765, "time_offset_sec": 3375}, "public_key_hex": "019a1fcd102952418ce2c96a9ca88b32060dac7f35c9607bbc7b3089a6b7e424", "role": "CLIENT", "short_name": "S7F4", "snr": 5.59, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.565, "battery_level": 101, "channel_utilization": 8.06, "uptime_seconds": 123017, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 495, "long_name": "Dusk Coyote", "next_hop": 30, "num": "0x3c7ec803", "position": null, "public_key_hex": "f5b3257469beb79d06461d9ced20fd6b0f6adcc88af8819ac75733c11b234e58", "role": "CLIENT", "short_name": "DI41", "snr": 9.76, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.13, "battery_level": 101, "channel_utilization": 3.31, "uptime_seconds": 18934, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 20329, "long_name": "Gold Lynx", "next_hop": 120, "num": "0x3cabd975", "position": {"altitude": 1276, "latitude": 32.83804, "location_source": "LOC_INTERNAL", "longitude": -106.289307, "time_offset_sec": 20449}, "public_key_hex": "94f93fe3d04a4a8512b2c1ef538ab300915f177f802f195115d6f9c503d8993c", "role": "CLIENT", "short_name": "G2NU", "snr": 3.88, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6311, "long_name": "Dawn Yucca", "next_hop": 178, "num": "0x3cb174cf", "position": {"altitude": 909, "latitude": 32.290424, "location_source": "LOC_INTERNAL", "longitude": -107.492656, "time_offset_sec": 6417}, "public_key_hex": "05022520e20550639ca28ef8dc8355cab42603a05ee1faa2c11c265f29cc13a7", "role": "CLIENT_MUTE", "short_name": "DU03", "snr": 4.56, "status": {"status": "running"}, "telemetry": {"air_util_tx": 2.419, "battery_level": 37, "channel_utilization": 13.49, "uptime_seconds": 43314, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2703, "long_name": "Sharp Phoenix", "next_hop": 167, "num": "0x3d43985f", "position": {"altitude": 1576, "latitude": 32.197037, "location_source": "LOC_INTERNAL", "longitude": -107.449257, "time_offset_sec": 2818}, "public_key_hex": "3860815ade9fc1911beef95e37cebde1e0ebd7a1f7d8aa1ea78d897b81eb8e06", "role": "CLIENT", "short_name": "SZ7I", "snr": 7.09, "status": null, "telemetry": {"air_util_tx": 0.263, "battery_level": 13, "channel_utilization": 24.4, "uptime_seconds": 4251, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.74, "iaq": 53, "relative_humidity": 47.2, "temperature": 8.43}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1209, "long_name": "Red Arroyo", "next_hop": 0, "num": "0x3de79994", "position": null, "public_key_hex": "df6274b7cd63d960c7bb72d541153cf794f2d5ad2f9083f283318c2c0d68edff", "role": "CLIENT", "short_name": "RWS6", "snr": 3.89, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 546, "long_name": "Stone Cedar", "next_hop": 0, "num": "0x3e1c0ece", "position": {"altitude": 1595, "latitude": 32.885307, "location_source": "LOC_INTERNAL", "longitude": -107.535058, "time_offset_sec": 744}, "public_key_hex": "c051f4a8daa982873bc2fec7e2647eec49fb097e704e5cfc695b83c1874e8fe7", "role": "CLIENT", "short_name": "SIQG", "snr": -1.15, "status": null, "telemetry": {"air_util_tx": 1.175, "battery_level": 42, "channel_utilization": 2.81, "uptime_seconds": 484989, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4819, "long_name": "Sky Gecko", "next_hop": 49, "num": "0x3e2e20ef", "position": {"altitude": 1115, "latitude": 33.106531, "location_source": "LOC_INTERNAL", "longitude": -107.458733, "time_offset_sec": 5073}, "public_key_hex": "c9b9950e932fb3c1ccc7abca60fe1dad4f7653cd9df0412f81e913d143765ca1", "role": "ROUTER", "short_name": "SMVM", "snr": 9.61, "status": null, "telemetry": {"air_util_tx": 0.047, "battery_level": 84, "channel_utilization": 13.63, "uptime_seconds": 91994, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1509, "long_name": "Shady Crow", "next_hop": 158, "num": "0x3e345006", "position": {"altitude": 861, "latitude": 34.091143, "location_source": "LOC_INTERNAL", "longitude": -108.017718, "time_offset_sec": 1701}, "public_key_hex": "12720e4479465a158aa2c3bb3a70d7f6fada35bd731846f6daf1e93d11f2cd3c", "role": "CLIENT", "short_name": "SPIY", "snr": 7.95, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1604, "long_name": "Happy Bronco", "next_hop": 0, "num": "0x3e37d297", "position": {"altitude": 1028, "latitude": 33.487795, "location_source": "LOC_INTERNAL", "longitude": -108.381078, "time_offset_sec": 1706}, "public_key_hex": "", "role": "SENSOR", "short_name": "HQS2", "snr": 0.69, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 5, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 279, "long_name": "Sneaky Bison", "next_hop": 72, "num": "0x3e82ffd3", "position": {"altitude": 1935, "latitude": 32.577825, "location_source": "LOC_INTERNAL", "longitude": -107.174252, "time_offset_sec": 441}, "public_key_hex": "7347bc043dc918381d40d8b051a320d52f9dfcfc8821248023171d01c1fdf9bd", "role": "CLIENT", "short_name": "STTN", "snr": 1.29, "status": null, "telemetry": {"air_util_tx": 1.068, "battery_level": 101, "channel_utilization": 13.55, "uptime_seconds": 13102, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1007.63, "iaq": 32, "relative_humidity": 51.24, "temperature": 31.2}, "hops_away": 0, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 988, "long_name": "Wild Mamba", "next_hop": 0, "num": "0x3ef69851", "position": {"altitude": 1700, "latitude": 32.488215, "location_source": "LOC_INTERNAL", "longitude": -106.708983, "time_offset_sec": 1278}, "public_key_hex": "2ef11dda5453dbf8030bc69942f50d41949d9ade3b08fdf988a1898b81931b64", "role": "CLIENT", "short_name": "WK4I", "snr": 3.82, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7395, "long_name": "Giant Sage", "next_hop": 0, "num": "0x3f4e40a6", "position": {"altitude": 1124, "latitude": 33.227145, "location_source": "LOC_INTERNAL", "longitude": -107.497745, "time_offset_sec": 7540}, "public_key_hex": "7e5a41230314362f9bc3bcc353dc039bfda14de95c0630eb9b08e6901ea33242", "role": "CLIENT", "short_name": "GC2V", "snr": 4.58, "status": null, "telemetry": {"air_util_tx": 2.102, "battery_level": 54, "channel_utilization": 15.97, "uptime_seconds": 580, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 8505, "long_name": "Sky Iguana", "next_hop": 107, "num": "0x3f5be4f1", "position": {"altitude": 1117, "latitude": 33.851012, "location_source": "LOC_INTERNAL", "longitude": -107.749199, "time_offset_sec": 8548}, "public_key_hex": "3a799fffecacb9692386c0d7af0284d392b541bde45ffc3e3a2c4251b375e3c6", "role": "CLIENT", "short_name": "S7XR", "snr": 3.26, "status": null, "telemetry": {"air_util_tx": 0.187, "battery_level": 11, "channel_utilization": 0.88, "uptime_seconds": 11913, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 5551, "long_name": "Smooth Lynx", "next_hop": 152, "num": "0x3faae676", "position": {"altitude": 1090, "latitude": 33.621759, "location_source": "LOC_INTERNAL", "longitude": -107.207629, "time_offset_sec": 5624}, "public_key_hex": "6b2df0142fbbd3f0ecae48cbfaa5e226e48ea965f68ee21c0a3529fa681f395f", "role": "CLIENT_MUTE", "short_name": "SLVN", "snr": 8.14, "status": {"status": "weak-signal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 5055, "long_name": "Copper Falcon", "next_hop": 0, "num": "0x3fc1c32e", "position": {"altitude": 1715, "latitude": 33.357183, "location_source": "LOC_INTERNAL", "longitude": -107.29983, "time_offset_sec": 5266}, "public_key_hex": "a8f177a6bf45a3cd0482142ed187d183d34690fa7cad78fccd2ff3c0b9d8ca6b", "role": "CLIENT", "short_name": "CA61", "snr": 3.39, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.372, "battery_level": 42, "channel_utilization": 3.79, "uptime_seconds": 29867, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2065, "long_name": "Copper Sage", "next_hop": 81, "num": "0x407a7762", "position": {"altitude": 1109, "latitude": 33.584418, "location_source": "LOC_INTERNAL", "longitude": -106.875554, "time_offset_sec": 2266}, "public_key_hex": "ff0e287fb0024ef961b17b6282670664bc449f0d9514473a1759fd62e9ffc1b5", "role": "CLIENT", "short_name": "CI0W", "snr": 10.75, "status": null, "telemetry": {"air_util_tx": 0.134, "battery_level": 73, "channel_utilization": 13.32, "uptime_seconds": 183501, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 893, "long_name": "Wild Stag", "next_hop": 0, "num": "0x409da0b9", "position": {"altitude": 1599, "latitude": 32.899158, "location_source": "LOC_INTERNAL", "longitude": -107.531942, "time_offset_sec": 1062}, "public_key_hex": "d3ba91f50140e489296741e63741c381ea2e523f01bcb47917e7a70f117c5d3e", "role": "CLIENT", "short_name": "WFNN", "snr": 9.37, "status": null, "telemetry": {"air_util_tx": 0.551, "battery_level": 78, "channel_utilization": 23.83, "uptime_seconds": 223308, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3013, "long_name": "White Shark", "next_hop": 166, "num": "0x421ed9cd", "position": null, "public_key_hex": "39b00b0a5880ab4c581c7c992a3c288d353c77eac7d0022b16f17fd53a3fb148", "role": "ROUTER", "short_name": "WWUV", "snr": 10.36, "status": null, "telemetry": {"air_util_tx": 1.01, "battery_level": 60, "channel_utilization": 15.15, "uptime_seconds": 8298, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4705, "long_name": "Lone Dolphin", "next_hop": 143, "num": "0x425b3167", "position": {"altitude": 1324, "latitude": 32.239844, "location_source": "LOC_INTERNAL", "longitude": -107.204437, "time_offset_sec": 4903}, "public_key_hex": "67f16b834d9faa0218e6a172b5ce1a425d13a53cbdc7ccdeda6033ff5c16de4c", "role": "CLIENT", "short_name": "🦉", "snr": 1.71, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 798, "long_name": "Red Mamba", "next_hop": 0, "num": "0x42634862", "position": {"altitude": 1468, "latitude": 32.720221, "location_source": "LOC_INTERNAL", "longitude": -106.922564, "time_offset_sec": 833}, "public_key_hex": "5054c78aa27629488c6868c7f84b2fbb0301ea781f67b574855d49887628d8d2", "role": "ROUTER", "short_name": "RL00", "snr": 2.64, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.19, "battery_level": 83, "channel_utilization": 4.48, "uptime_seconds": 4820, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1498, "long_name": "Quick Mesa", "next_hop": 0, "num": "0x42b4cb24", "position": {"altitude": 1632, "latitude": 32.996339, "location_source": "LOC_INTERNAL", "longitude": -106.079693, "time_offset_sec": 1708}, "public_key_hex": "94142bb7f81006173eab540143cd692017d4d1919bd1fd17804d604c46170991", "role": "CLIENT", "short_name": "Q633", "snr": 1.13, "status": null, "telemetry": {"air_util_tx": 0.753, "battery_level": 54, "channel_utilization": 11.35, "uptime_seconds": 35851, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 991.66, "iaq": 73, "relative_humidity": 63.16, "temperature": 4.66}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 606, "long_name": "Happy Colt", "next_hop": 172, "num": "0x42cd002f", "position": {"altitude": 1024, "latitude": 33.287455, "location_source": "LOC_INTERNAL", "longitude": -107.171553, "time_offset_sec": 814}, "public_key_hex": "9eedd9572ef82c539e9c4beb86ae45642d6f8f49f81003d17f329f9f15fd0fad", "role": "CLIENT", "short_name": "HIX1", "snr": 9.97, "status": null, "telemetry": {"air_util_tx": 0.805, "battery_level": 60, "channel_utilization": 6.72, "uptime_seconds": 19641, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.37, "iaq": 71, "relative_humidity": 62.97, "temperature": 16.45}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 10218, "long_name": "Blue Yucca", "next_hop": 0, "num": "0x42cfc132", "position": {"altitude": 788, "latitude": 33.702353, "location_source": "LOC_INTERNAL", "longitude": -106.776716, "time_offset_sec": 10278}, "public_key_hex": "c171e7e65e37259d6b0d1a755d35368bae1ce20bad2834362ed7229a8afefe7f", "role": "CLIENT", "short_name": "🐝", "snr": 5.07, "status": null, "telemetry": {"air_util_tx": 0.941, "battery_level": 34, "channel_utilization": 9.39, "uptime_seconds": 35246, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3792, "long_name": "Old Cobra", "next_hop": 0, "num": "0x42dffc4f", "position": {"altitude": 1697, "latitude": 32.803374, "location_source": "LOC_INTERNAL", "longitude": -107.081468, "time_offset_sec": 4018}, "public_key_hex": "c392a2e251f6983101813a5237cbbe02b5119ed360f037d0f33585bcd10f8e60", "role": "CLIENT", "short_name": "OQWC", "snr": -0.14, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M6", "last_heard_offset_sec": 5042, "long_name": "Lunar Mustang", "next_hop": 169, "num": "0x433523d0", "position": null, "public_key_hex": "d253a965504c9632d3ce55d66e9c5b67be335079e309154915c60c23ef0973c8", "role": "CLIENT", "short_name": "LEBO", "snr": -3.74, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 4528, "long_name": "White Stag", "next_hop": 3, "num": "0x43fbf777", "position": {"altitude": 1537, "latitude": 33.003847, "location_source": "LOC_INTERNAL", "longitude": -106.629808, "time_offset_sec": 4634}, "public_key_hex": "23c075dac68fa78051ed99c03bc904cb534f9c2acb50882aaf93b271b9fa265e", "role": "SENSOR", "short_name": "🐝", "snr": 7.42, "status": null, "telemetry": {"air_util_tx": 1.153, "battery_level": 82, "channel_utilization": 20.87, "uptime_seconds": 15486, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2199, "long_name": "Storm Seal", "next_hop": 0, "num": "0x447b9432", "position": {"altitude": 1839, "latitude": 33.347711, "location_source": "LOC_INTERNAL", "longitude": -106.773231, "time_offset_sec": 2322}, "public_key_hex": "44c2ce2da5b8ed09ffb3ec7675c09be2aaba7f89fe9a28d4ff96bddf00eca6e2", "role": "CLIENT", "short_name": "🦇", "snr": 2.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 3, "hw_model": "MUZI_R1_NEO", "last_heard_offset_sec": 9703, "long_name": "Gold Whale", "next_hop": 52, "num": "0x4495d3e7", "position": {"altitude": 1687, "latitude": 33.139536, "location_source": "LOC_INTERNAL", "longitude": -107.128211, "time_offset_sec": 9823}, "public_key_hex": "", "role": "CLIENT", "short_name": "G5TG", "snr": 5.28, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 4433, "long_name": "Bright Lynx", "next_hop": 0, "num": "0x4585a4d7", "position": {"altitude": 1508, "latitude": 34.392774, "location_source": "LOC_INTERNAL", "longitude": -107.622506, "time_offset_sec": 4707}, "public_key_hex": "7363006b39bc789935aef32753e5a6b2463f253c6fc81b247dbb4e2c6ee6d8cc", "role": "CLIENT", "short_name": "BEX9", "snr": 5.31, "status": null, "telemetry": {"air_util_tx": 0.123, "battery_level": 93, "channel_utilization": 3.01, "uptime_seconds": 45971, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 3304, "long_name": "Stone Ridge", "next_hop": 104, "num": "0x464a3092", "position": null, "public_key_hex": "236a842d43af5200d5851b93149ceb271d6401c0d1f9869b4d03ff2b6d9b3504", "role": "CLIENT", "short_name": "S3FD", "snr": 5.87, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.119, "battery_level": 93, "channel_utilization": 6.64, "uptime_seconds": 83, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.56, "iaq": 69, "relative_humidity": 59.24, "temperature": 22.74}, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 1878, "long_name": "Old Coyote", "next_hop": 161, "num": "0x465a3f45", "position": null, "public_key_hex": "589dd2ce3e852dfcebc8383716b51049fedf5958d4c47bfd4fcd8180753c9265", "role": "CLIENT", "short_name": "ODG9", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 996.74, "iaq": 30, "relative_humidity": 22.68, "temperature": 19.77}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1650, "long_name": "Howling Bear", "next_hop": 26, "num": "0x46829c18", "position": {"altitude": 1186, "latitude": 32.485891, "location_source": "LOC_INTERNAL", "longitude": -106.568956, "time_offset_sec": 1650}, "public_key_hex": "20b91aa8f3a37933c469b27ca1ef6b2ead041017c665e224db5815bcf0ceee06", "role": "CLIENT", "short_name": "HAMZ", "snr": 8.05, "status": null, "telemetry": {"air_util_tx": 0.672, "battery_level": 101, "channel_utilization": 11.65, "uptime_seconds": 108535, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3288, "long_name": "Smooth Wolf", "next_hop": 0, "num": "0x46d3022f", "position": {"altitude": 1030, "latitude": 33.623966, "location_source": "LOC_INTERNAL", "longitude": -106.987025, "time_offset_sec": 3366}, "public_key_hex": "02d902254db70ef843d914b4a652673b97d5f83869476410da2b0b0d9e3c6bdf", "role": "CLIENT", "short_name": "🐺", "snr": 2.34, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2607, "long_name": "Iron Otter", "next_hop": 0, "num": "0x4723794e", "position": {"altitude": 1665, "latitude": 33.972929, "location_source": "LOC_INTERNAL", "longitude": -107.536336, "time_offset_sec": 2621}, "public_key_hex": "e50842a4c37590a895eb24393289b2cd43f3389d75f651610d1ce8a7a6363c8d", "role": "CLIENT", "short_name": "I5UV", "snr": 10.29, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.857, "battery_level": 65, "channel_utilization": 34.29, "uptime_seconds": 40045, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 5635, "long_name": "Loud Mole", "next_hop": 189, "num": "0x4745fcb5", "position": {"altitude": 1856, "latitude": 32.963171, "location_source": "LOC_INTERNAL", "longitude": -106.7369, "time_offset_sec": 5846}, "public_key_hex": "eaa31929e29386358a971950bcc69f76cc6e63d7864bb0f333a017881d62798d", "role": "LOST_AND_FOUND", "short_name": "LIM1", "snr": 1.13, "status": {"status": "online"}, "telemetry": {"air_util_tx": 2.709, "battery_level": 94, "channel_utilization": 28.13, "uptime_seconds": 43337, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 10027, "long_name": "Slow Iguana", "next_hop": 0, "num": "0x4747fb52", "position": null, "public_key_hex": "7f4c72ed406a733349893efe8a0954be75f10614bc89b504591a34a42d8ed1d6", "role": "CLIENT", "short_name": "SL24", "snr": 3.13, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.22, "battery_level": 51, "channel_utilization": 2.01, "uptime_seconds": 78386, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1015.49, "iaq": 29, "relative_humidity": 47.55, "temperature": 17.24}, "hops_away": 1, "hw_model": "XIAO_NRF52_KIT", "last_heard_offset_sec": 3124, "long_name": "Fast Adder", "next_hop": 44, "num": "0x47bce2ba", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "FKTF", "snr": 10.59, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.198, "battery_level": 62, "channel_utilization": 26.66, "uptime_seconds": 43070, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2073, "long_name": "River Marmot", "next_hop": 0, "num": "0x47c71459", "position": null, "public_key_hex": "422de85eb248649548c824bd9d138293e7d7acce9420b4306f742fcf27da6c94", "role": "CLIENT", "short_name": "🦂", "snr": 9.33, "status": null, "telemetry": {"air_util_tx": 1.326, "battery_level": 38, "channel_utilization": 11.01, "uptime_seconds": 93587, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 987, "long_name": "Howling Gecko", "next_hop": 0, "num": "0x47fb66b1", "position": {"altitude": 1262, "latitude": 33.495338, "location_source": "LOC_INTERNAL", "longitude": -107.307232, "time_offset_sec": 1179}, "public_key_hex": "432574e8c84556bb7f265bb9dd4ffd3b0f85ffea3869b0accdfc47ba65c74b35", "role": "CLIENT", "short_name": "HBFQ", "snr": 5.38, "status": null, "telemetry": {"air_util_tx": 0.999, "battery_level": 14, "channel_utilization": 3.54, "uptime_seconds": 222947, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3776, "long_name": "Smooth Cactus", "next_hop": 0, "num": "0x483a694f", "position": {"altitude": 1465, "latitude": 33.427641, "location_source": "LOC_INTERNAL", "longitude": -107.340998, "time_offset_sec": 3834}, "public_key_hex": "f58d38baf30c2b9e7f5352fb608cc61cb8db6fecf43580b179335c916d09c71e", "role": "CLIENT", "short_name": "SCF7", "snr": 4.95, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.783, "battery_level": 92, "channel_utilization": 2.41, "uptime_seconds": 122984, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.77, "iaq": 25, "relative_humidity": 19.83, "temperature": 26.33}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4438, "long_name": "Howling Oak", "next_hop": 189, "num": "0x483c475f", "position": {"altitude": 1427, "latitude": 33.889055, "location_source": "LOC_INTERNAL", "longitude": -107.334535, "time_offset_sec": 4520}, "public_key_hex": "ec54f6144acd6f5a389097a1cdc454e25216a542e339aa758bfd6b5bf6999666", "role": "CLIENT", "short_name": "HP27", "snr": 8.16, "status": null, "telemetry": {"air_util_tx": 0.619, "battery_level": 24, "channel_utilization": 14.2, "uptime_seconds": 76704, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1418, "long_name": "Brave Raven", "next_hop": 54, "num": "0x483eca7c", "position": {"altitude": 1152, "latitude": 32.978165, "location_source": "LOC_INTERNAL", "longitude": -108.217729, "time_offset_sec": 1425}, "public_key_hex": "e78392b59682d33d2ada4cec85466d16393a3d8d71d2a36d145658c25933367d", "role": "CLIENT", "short_name": "🐢", "snr": 2.07, "status": null, "telemetry": {"air_util_tx": 0.22, "battery_level": 53, "channel_utilization": 11.54, "uptime_seconds": 407445, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1383, "long_name": "Happy Seal", "next_hop": 132, "num": "0x4854156c", "position": {"altitude": 1442, "latitude": 33.606331, "location_source": "LOC_INTERNAL", "longitude": -106.600787, "time_offset_sec": 1589}, "public_key_hex": "81a71b86b569f004e34cf904aa6e8c5412423ce32aa4183ba187c4fe734b1d14", "role": "CLIENT", "short_name": "HENS", "snr": 9.18, "status": null, "telemetry": {"air_util_tx": 0.237, "battery_level": 58, "channel_utilization": 9.9, "uptime_seconds": 47487, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1018, "long_name": "Sneaky Cougar", "next_hop": 0, "num": "0x487d2471", "position": null, "public_key_hex": "ebcc5fc22b928136fea4b0ca3c1f5094674e47bbb2eb8e6ffe7874fab8b28f58", "role": "CLIENT", "short_name": "SAKC", "snr": 4.16, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 394, "long_name": "Shady Cobra", "next_hop": 145, "num": "0x48e901f1", "position": {"altitude": 1380, "latitude": 32.872246, "location_source": "LOC_INTERNAL", "longitude": -107.212042, "time_offset_sec": 510}, "public_key_hex": "ad5b6379d1d65f12df4d33cee476e17dff44ce1efb71a74fc55d35a7333c6b52", "role": "SENSOR", "short_name": "SQ69", "snr": 4.21, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.444, "battery_level": 60, "channel_utilization": 15.12, "uptime_seconds": 19835, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 10719, "long_name": "Brave Otter", "next_hop": 201, "num": "0x490313b5", "position": {"altitude": 1593, "latitude": 33.112042, "location_source": "LOC_INTERNAL", "longitude": -107.730151, "time_offset_sec": 11017}, "public_key_hex": "69e40f351dc853e1aa8fdfc5b913dc73439febe73e3290cbfd2c43b1f6768963", "role": "CLIENT", "short_name": "B8SD", "snr": 5.64, "status": null, "telemetry": {"air_util_tx": 0.148, "battery_level": 44, "channel_utilization": 8.24, "uptime_seconds": 79369, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3047, "long_name": "Shady Crow", "next_hop": 0, "num": "0x491344f0", "position": {"altitude": 1296, "latitude": 32.046758, "location_source": "LOC_INTERNAL", "longitude": -107.505796, "time_offset_sec": 3118}, "public_key_hex": "35b6a7167c3329e4dd884e4463bd0f229255e32375d05bc0e72154777e8f5182", "role": "CLIENT", "short_name": "🦉", "snr": 4.89, "status": null, "telemetry": {"air_util_tx": 0.678, "battery_level": 45, "channel_utilization": 28.61, "uptime_seconds": 123590, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2664, "long_name": "Bright Moose", "next_hop": 249, "num": "0x49150ca0", "position": {"altitude": 1185, "latitude": 33.136873, "location_source": "LOC_INTERNAL", "longitude": -107.306155, "time_offset_sec": 2827}, "public_key_hex": "18c470234ec7dd4cd49ccedb8655a11d095fba920d7d695148d569eccfb2e596", "role": "CLIENT", "short_name": "B7Y5", "snr": 7.32, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3760, "long_name": "Happy Bison", "next_hop": 112, "num": "0x49a0c8c0", "position": {"altitude": 1093, "latitude": 34.141068, "location_source": "LOC_INTERNAL", "longitude": -107.324738, "time_offset_sec": 3785}, "public_key_hex": "776c6f1ea4fe04fabd21ef54f4cd6e04772041a21b83dc4e465e5b9c925207f8", "role": "CLIENT_BASE", "short_name": "H31Z", "snr": 4.87, "status": null, "telemetry": {"air_util_tx": 0.722, "battery_level": 53, "channel_utilization": 15.65, "uptime_seconds": 73213, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3548, "long_name": "Sunny Phoenix", "next_hop": 0, "num": "0x4a0630d7", "position": null, "public_key_hex": "8e095643dbfb5687150b592ce52b655bed7182e40bb3805bd5603021bd5b7973", "role": "CLIENT", "short_name": "S67Z", "snr": 5.25, "status": null, "telemetry": {"air_util_tx": 0.862, "battery_level": 33, "channel_utilization": 11.21, "uptime_seconds": 16708, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.65, "iaq": 65, "relative_humidity": 15.51, "temperature": 14.0}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1691, "long_name": "Quick Crane", "next_hop": 48, "num": "0x4a7fcb93", "position": {"altitude": 1567, "latitude": 32.745722, "location_source": "LOC_INTERNAL", "longitude": -106.880759, "time_offset_sec": 1734}, "public_key_hex": "", "role": "CLIENT", "short_name": "Q2LX", "snr": 7.11, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 189, "long_name": "Lunar Doe", "next_hop": 201, "num": "0x4ab21734", "position": {"altitude": 1001, "latitude": 33.468173, "location_source": "LOC_INTERNAL", "longitude": -105.934058, "time_offset_sec": 308}, "public_key_hex": "43bfb69a2f86b78000b259d576f8f0352e1e420c5930882690f4281487248bf8", "role": "CLIENT", "short_name": "LO5R", "snr": 3.23, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.37, "battery_level": 10, "channel_utilization": 12.63, "uptime_seconds": 511662, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 770, "long_name": "Brave Hawk", "next_hop": 0, "num": "0x4ae230a9", "position": {"altitude": 1082, "latitude": 32.741686, "location_source": "LOC_INTERNAL", "longitude": -107.136659, "time_offset_sec": 859}, "public_key_hex": "49be2f27b080df1a5ed4aa7c30212ae34b6f568f173eb78f9ffb3c36344663ff", "role": "CLIENT", "short_name": "BHTB", "snr": 10.44, "status": null, "telemetry": {"air_util_tx": 0.278, "battery_level": 34, "channel_utilization": 4.11, "uptime_seconds": 2907, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1957, "long_name": "Canyon Oak", "next_hop": 227, "num": "0x4aec3038", "position": null, "public_key_hex": "9dda018df6f1e6ff27f619de60411bb27aa73ce0846de74d5dca1f01036f1c40", "role": "ROUTER_LATE", "short_name": "C9DX", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.298, "battery_level": 31, "channel_utilization": 7.99, "uptime_seconds": 69430, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 532, "long_name": "Steel Otter", "next_hop": 2, "num": "0x4b187b87", "position": {"altitude": 1352, "latitude": 33.03721, "location_source": "LOC_INTERNAL", "longitude": -108.066405, "time_offset_sec": 807}, "public_key_hex": "c121485dc7bab5d818080c7cbc32f96e2c987d362b00b55221713ad65663f8fb", "role": "SENSOR", "short_name": "S3JY", "snr": 2.94, "status": null, "telemetry": {"air_util_tx": 2.114, "battery_level": 52, "channel_utilization": 20.96, "uptime_seconds": 188564, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.93, "iaq": 13, "relative_humidity": 52.34, "temperature": 15.56}, "hops_away": 1, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 134, "long_name": "Canyon Cobra", "next_hop": 134, "num": "0x4b370785", "position": null, "public_key_hex": "7dfb7417dcd46e90037e97d0f81ff2b4f617fff6e1a29914bad7d1b3176f7dcd", "role": "CLIENT", "short_name": "🌲", "snr": 10.14, "status": null, "telemetry": {"air_util_tx": 0.746, "battery_level": 36, "channel_utilization": 17.54, "uptime_seconds": 155984, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3533, "long_name": "Old Fox", "next_hop": 102, "num": "0x4b4a8774", "position": {"altitude": 1648, "latitude": 32.894622, "location_source": "LOC_INTERNAL", "longitude": -107.582707, "time_offset_sec": 3707}, "public_key_hex": "2083f83ca66ec5c091f40bf757c6e331bf8606a420feb3fc92b57235b882bb92", "role": "CLIENT", "short_name": "OE96", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.557, "battery_level": 93, "channel_utilization": 6.69, "uptime_seconds": 54186, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 804, "long_name": "Slow Falcon", "next_hop": 0, "num": "0x4b637625", "position": {"altitude": 797, "latitude": 32.688944, "location_source": "LOC_INTERNAL", "longitude": -107.674431, "time_offset_sec": 1069}, "public_key_hex": "366f4d2e6562fbac0a875f3952383cf43fcfa929fa89e39209d2a851be5d7af2", "role": "CLIENT", "short_name": "SJLO", "snr": 6.1, "status": null, "telemetry": {"air_util_tx": 0.317, "battery_level": 46, "channel_utilization": 12.34, "uptime_seconds": 14979, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 651, "long_name": "Drifting Moose", "next_hop": 0, "num": "0x4b77d530", "position": {"altitude": 1052, "latitude": 34.148328, "location_source": "LOC_INTERNAL", "longitude": -106.696747, "time_offset_sec": 951}, "public_key_hex": "88e3e6b2d5c4b4d573209e2f060a2dc9bd860f8c7dfc2d1606cec8fb242d741f", "role": "CLIENT", "short_name": "DLDU", "snr": 1.83, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.374, "battery_level": 69, "channel_utilization": 17.12, "uptime_seconds": 2429, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 4768, "long_name": "Old Heron", "next_hop": 0, "num": "0x4b9ee1da", "position": {"altitude": 1137, "latitude": 32.759026, "location_source": "LOC_INTERNAL", "longitude": -106.833195, "time_offset_sec": 5043}, "public_key_hex": "321a0086ee8d59658a42927710adab144ce483f912b38f64b04a39c6ac3e5e56", "role": "CLIENT", "short_name": "OU7P", "snr": 2.01, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.15, "iaq": 20, "relative_humidity": 82.06, "temperature": 30.37}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2498, "long_name": "Stone Elk", "next_hop": 0, "num": "0x4c1991e8", "position": {"altitude": 1529, "latitude": 33.014879, "location_source": "LOC_INTERNAL", "longitude": -107.731089, "time_offset_sec": 2507}, "public_key_hex": "3a08be45aa8e7346b7a03740e9f3896a1bbbe608bcebac459509fd6d0d9dad12", "role": "CLIENT", "short_name": "SLAD", "snr": 0.7, "status": null, "telemetry": {"air_util_tx": 0.548, "battery_level": 78, "channel_utilization": 9.59, "uptime_seconds": 96023, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.94, "iaq": 20, "relative_humidity": 45.95, "temperature": 6.92}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2408, "long_name": "Silver Eagle", "next_hop": 0, "num": "0x4c7f99b5", "position": {"altitude": 1566, "latitude": 33.220154, "location_source": "LOC_INTERNAL", "longitude": -107.446302, "time_offset_sec": 2438}, "public_key_hex": "", "role": "CLIENT", "short_name": "SJ3H", "snr": 2.03, "status": null, "telemetry": {"air_util_tx": 0.399, "battery_level": 28, "channel_utilization": 10.79, "uptime_seconds": 120116, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 445, "long_name": "Misty Shark", "next_hop": 158, "num": "0x4d7dce61", "position": null, "public_key_hex": "63b65466fcaa7cbf18700e7f8ad84289fce0c5fd56946276c2ea83ac02181fe5", "role": "CLIENT", "short_name": "MJTP", "snr": 3.32, "status": null, "telemetry": {"air_util_tx": 0.552, "battery_level": 96, "channel_utilization": 3.19, "uptime_seconds": 175167, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.59, "iaq": 96, "relative_humidity": 49.5, "temperature": 17.54}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2601, "long_name": "Lost Coyote", "next_hop": 181, "num": "0x4d8f946b", "position": {"altitude": 1546, "latitude": 32.611293, "location_source": "LOC_INTERNAL", "longitude": -107.455073, "time_offset_sec": 2653}, "public_key_hex": "b9c191e8ea6bb9ecb384049a7b09f63a790d954b2cafdd62574c11b184f01a25", "role": "CLIENT", "short_name": "L8A8", "snr": 6.98, "status": null, "telemetry": {"air_util_tx": 1.42, "battery_level": 87, "channel_utilization": 9.8, "uptime_seconds": 31218, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3719, "long_name": "Tiny Ridge", "next_hop": 101, "num": "0x4ddc2937", "position": {"altitude": 1220, "latitude": 33.794181, "location_source": "LOC_INTERNAL", "longitude": -107.157124, "time_offset_sec": 3787}, "public_key_hex": "afc8355d4a457c73c1d8cdb22be6ad3e572dc59b71d37c9315c1218c1a43e86a", "role": "CLIENT_MUTE", "short_name": "TTGP", "snr": 4.87, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5483, "long_name": "Desert Coyote", "next_hop": 0, "num": "0x4e049a8c", "position": {"altitude": 1375, "latitude": 33.384528, "location_source": "LOC_INTERNAL", "longitude": -106.995136, "time_offset_sec": 5607}, "public_key_hex": "35dbef035ae32ba475e26ee69d99bf1ac15e4e53888d43e836eb1cc0e4ed0d6e", "role": "ROUTER", "short_name": "D1LD", "snr": 10.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2179, "long_name": "Storm Cedar", "next_hop": 0, "num": "0x4e2d03c2", "position": {"altitude": 1149, "latitude": 32.9513, "location_source": "LOC_INTERNAL", "longitude": -105.825534, "time_offset_sec": 2430}, "public_key_hex": "c6130f6febbc11c8cf77f3ba078643cb45f047ab748286f7b5a539c05ab8a573", "role": "SENSOR", "short_name": "SQEE", "snr": -4.28, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.48, "iaq": 57, "relative_humidity": 35.31, "temperature": 26.64}, "hops_away": 0, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 1885, "long_name": "Sharp Cougar", "next_hop": 0, "num": "0x4f11eba6", "position": {"altitude": 1055, "latitude": 34.90857, "location_source": "LOC_INTERNAL", "longitude": -107.03887, "time_offset_sec": 2026}, "public_key_hex": "642a6cfd1d98bde0fbd3cf4ba1dc04905aa2afee856cd74d63fa80aaa8dee2db", "role": "CLIENT_MUTE", "short_name": "S9A0", "snr": 7.29, "status": null, "telemetry": {"air_util_tx": 0.912, "battery_level": 101, "channel_utilization": 30.44, "uptime_seconds": 129550, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 263, "long_name": "Black Pony", "next_hop": 0, "num": "0x4f31fb85", "position": {"altitude": 1136, "latitude": 31.978064, "location_source": "LOC_INTERNAL", "longitude": -107.569375, "time_offset_sec": 431}, "public_key_hex": "d92a31657f1e0db1f6a5d74d754a2b580d6600b61b5b6ca7b3d69a0bd0cb3949", "role": "ROUTER", "short_name": "BY4G", "snr": 2.29, "status": null, "telemetry": {"air_util_tx": 0.592, "battery_level": 100, "channel_utilization": 6.02, "uptime_seconds": 118228, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3440, "long_name": "Sunny Wolf", "next_hop": 0, "num": "0x4f3ebf31", "position": {"altitude": 1551, "latitude": 34.316344, "location_source": "LOC_INTERNAL", "longitude": -107.365624, "time_offset_sec": 3568}, "public_key_hex": "ba99e2e6c1767c7f5c5cea3c46b275d14fbaa71c263ae33cfc398b326b557d36", "role": "CLIENT", "short_name": "SWFP", "snr": 8.75, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 543, "long_name": "Misty Bronco", "next_hop": 0, "num": "0x4f4ef47e", "position": {"altitude": 1373, "latitude": 32.574421, "location_source": "LOC_INTERNAL", "longitude": -107.359873, "time_offset_sec": 645}, "public_key_hex": "9d04d02a9ba7d657dbedeab50802d6490f4769c81d66b0d15fe7969a5bdd267d", "role": "CLIENT", "short_name": "MCXF", "snr": 9.47, "status": null, "telemetry": {"air_util_tx": 0.412, "battery_level": 31, "channel_utilization": 12.38, "uptime_seconds": 20434, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 998.74, "iaq": 63, "relative_humidity": 13.22, "temperature": 20.18}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 9351, "long_name": "Smooth Crow", "next_hop": 0, "num": "0x4f9acebc", "position": {"altitude": 1108, "latitude": 33.287967, "location_source": "LOC_INTERNAL", "longitude": -107.163393, "time_offset_sec": 9381}, "public_key_hex": "3b816d65102ecf171e0f0f555ce6d8ca4e1ce7ba32367bb9990380c957139d8f", "role": "CLIENT_MUTE", "short_name": "S71S", "snr": 3.07, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.195, "battery_level": 20, "channel_utilization": 26.92, "uptime_seconds": 13428, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 609, "long_name": "Tall Tortoise", "next_hop": 100, "num": "0x4fc83db2", "position": {"altitude": 1896, "latitude": 33.09145, "location_source": "LOC_INTERNAL", "longitude": -106.624006, "time_offset_sec": 618}, "public_key_hex": "f1e091acacb9615e6e8cd0715fc858ad89a9db10fd73fd685419d23c57c6346e", "role": "SENSOR", "short_name": "T2JM", "snr": 8.7, "status": null, "telemetry": {"air_util_tx": 0.314, "battery_level": 55, "channel_utilization": 14.87, "uptime_seconds": 92459, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.25, "iaq": 31, "relative_humidity": 77.58, "temperature": 22.82}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1425, "long_name": "Mountain Cedar", "next_hop": 0, "num": "0x4ff32b5e", "position": {"altitude": 1836, "latitude": 33.221978, "location_source": "LOC_INTERNAL", "longitude": -107.595271, "time_offset_sec": 1646}, "public_key_hex": "0befe882264538237ac3631c6941146633a8ce0c571e3c0622d7e9e304aa7929", "role": "CLIENT", "short_name": "MFHA", "snr": 10.17, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.355, "battery_level": 81, "channel_utilization": 12.59, "uptime_seconds": 77130, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 3454, "long_name": "Happy Shark", "next_hop": 0, "num": "0x5023db81", "position": null, "public_key_hex": "31c73ee9d269a171330ac84440f6645be805a47ddcdb2203b8de2434358f8bea", "role": "CLIENT", "short_name": "HM7R", "snr": 6.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.27, "battery_level": 90, "channel_utilization": 8.95, "uptime_seconds": 171267, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 7469, "long_name": "Silent Hawk", "next_hop": 0, "num": "0x508c60c8", "position": {"altitude": 1288, "latitude": 33.001922, "location_source": "LOC_INTERNAL", "longitude": -106.259455, "time_offset_sec": 7716}, "public_key_hex": "ea2bd595e1b796e42fbc44297d60e0531956cd4f3317b6076ce57122fe6f3c86", "role": "CLIENT", "short_name": "SJ20", "snr": 7.66, "status": null, "telemetry": {"air_util_tx": 0.505, "battery_level": 35, "channel_utilization": 7.29, "uptime_seconds": 86288, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.16, "iaq": 40, "relative_humidity": 88.93, "temperature": 37.22}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3740, "long_name": "Blue Mustang", "next_hop": 0, "num": "0x50b05df0", "position": {"altitude": 1427, "latitude": 33.410179, "location_source": "LOC_INTERNAL", "longitude": -108.284215, "time_offset_sec": 3925}, "public_key_hex": "31d87416d15fe6e101ebd01b7baf580e279a2ce822dd91ab6bee7b596a369e16", "role": "CLIENT", "short_name": "B6GZ", "snr": 2.12, "status": null, "telemetry": {"air_util_tx": 0.898, "battery_level": 86, "channel_utilization": 27.07, "uptime_seconds": 22298, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.43, "iaq": 101, "relative_humidity": 60.39, "temperature": 33.91}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 9252, "long_name": "Found Owl", "next_hop": 0, "num": "0x50dd87e3", "position": {"altitude": 1131, "latitude": 32.556593, "location_source": "LOC_INTERNAL", "longitude": -106.934135, "time_offset_sec": 9491}, "public_key_hex": "336592c4a40d750aa4670787e036588046d62213f3b2f4f95089098bf027a8c0", "role": "ROUTER", "short_name": "F1AY", "snr": 7.97, "status": null, "telemetry": {"air_util_tx": 0.814, "battery_level": 64, "channel_utilization": 9.45, "uptime_seconds": 119563, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "THINKNODE_M6", "last_heard_offset_sec": 3505, "long_name": "Drifting Pine", "next_hop": 99, "num": "0x51491b42", "position": {"altitude": 1242, "latitude": 33.247823, "location_source": "LOC_INTERNAL", "longitude": -107.706515, "time_offset_sec": 3676}, "public_key_hex": "51ae2f4e7f9b3ff151af4228978d1f2ac23e77da14966f3571c0558c1bfd8b32", "role": "CLIENT", "short_name": "DRWU", "snr": 4.55, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.528, "battery_level": 30, "channel_utilization": 4.94, "uptime_seconds": 40074, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7461, "long_name": "Howling Tortoise", "next_hop": 0, "num": "0x519de129", "position": {"altitude": 1614, "latitude": 31.546025, "location_source": "LOC_INTERNAL", "longitude": -107.668609, "time_offset_sec": 7581}, "public_key_hex": "50ebe012c33ad935d8424042de6fd34b213b0a944a8f5a39976340707b12d6e9", "role": "CLIENT", "short_name": "H9JH", "snr": 5.57, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.689, "battery_level": 50, "channel_utilization": 21.52, "uptime_seconds": 57636, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.45, "iaq": 44, "relative_humidity": 31.78, "temperature": 24.49}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1163, "long_name": "Sleepy Marmot", "next_hop": 0, "num": "0x5218f3e7", "position": {"altitude": 1546, "latitude": 33.195416, "location_source": "LOC_INTERNAL", "longitude": -107.135683, "time_offset_sec": 1343}, "public_key_hex": "02c70a7f0ab1ac7229915f55dca20206d905785472e3f0ca8141c099c5f4eabc", "role": "CLIENT", "short_name": "SFPW", "snr": 6.81, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 5932, "long_name": "Green Fox", "next_hop": 0, "num": "0x52bbeaa5", "position": {"altitude": 1757, "latitude": 33.602032, "location_source": "LOC_INTERNAL", "longitude": -107.223963, "time_offset_sec": 6146}, "public_key_hex": "f5d1637c5e0550a7e697bafade0d7ba7212668e331751150942c30406402f419", "role": "CLIENT", "short_name": "GGPI", "snr": 7.64, "status": null, "telemetry": {"air_util_tx": 0.445, "battery_level": 38, "channel_utilization": 3.86, "uptime_seconds": 86972, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2971, "long_name": "Copper Arroyo", "next_hop": 251, "num": "0x52f652ad", "position": {"altitude": 1286, "latitude": 32.62743, "location_source": "LOC_INTERNAL", "longitude": -108.063504, "time_offset_sec": 2982}, "public_key_hex": "9f32ddcda90bdf3992ae1d16f9c8c0c78a24b2d55d9555e8e724102164235d36", "role": "CLIENT", "short_name": "CTSY", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.141, "battery_level": 15, "channel_utilization": 25.22, "uptime_seconds": 56615, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 51, "long_name": "Rough Adder", "next_hop": 0, "num": "0x53436ec5", "position": {"altitude": 1632, "latitude": 33.309706, "location_source": "LOC_INTERNAL", "longitude": -107.307216, "time_offset_sec": 170}, "public_key_hex": "534da73a459a3dfb53510163cff89e5bf20db3ab6c68859462650a786d0497a9", "role": "CLIENT", "short_name": "ROEV", "snr": -0.98, "status": null, "telemetry": {"air_util_tx": 0.826, "battery_level": 68, "channel_utilization": 9.48, "uptime_seconds": 204231, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 435, "long_name": "White Fox", "next_hop": 160, "num": "0x538bf9f2", "position": null, "public_key_hex": "2a08f094239016f205dc2e4016631515b4f53c37c8fb4c9b3a00c01c1e406fd5", "role": "CLIENT", "short_name": "WXY6", "snr": 0.77, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.373, "battery_level": 95, "channel_utilization": 2.63, "uptime_seconds": 252920, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.75, "iaq": 15, "relative_humidity": 30.92, "temperature": 23.8}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1261, "long_name": "Silent Badger", "next_hop": 0, "num": "0x543b382e", "position": {"altitude": 1286, "latitude": 32.104133, "location_source": "LOC_INTERNAL", "longitude": -107.468768, "time_offset_sec": 1454}, "public_key_hex": "88f5c63f5d5b2454974415eae24d6cb018dbca877aebea64f6bdec0af4a81df8", "role": "CLIENT_MUTE", "short_name": "SA9A", "snr": 4.66, "status": null, "telemetry": {"air_util_tx": 1.007, "battery_level": 37, "channel_utilization": 5.87, "uptime_seconds": 137381, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2245, "long_name": "Dusk Viper", "next_hop": 0, "num": "0x547cee65", "position": null, "public_key_hex": "dd298a5996289aa6a82c9182024d6441e0238cab10420dea868ffc4ab5a1e8a4", "role": "ROUTER", "short_name": "🌊", "snr": 11.2, "status": {"status": "offline-soon"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 999.33, "iaq": 55, "relative_humidity": 89.01, "temperature": 15.19}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4943, "long_name": "Green Colt", "next_hop": 198, "num": "0x54c245c7", "position": {"altitude": 1305, "latitude": 33.409967, "location_source": "LOC_INTERNAL", "longitude": -107.484346, "time_offset_sec": 5110}, "public_key_hex": "6171d755c496ec8dfa3186bfb9c5928c40cbc5712b5a955cdbd961a45e43d4a9", "role": "CLIENT", "short_name": "GGIS", "snr": 0.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 4821, "long_name": "Silent Hawk", "next_hop": 0, "num": "0x552e163e", "position": null, "public_key_hex": "6ea41c3cbf15684f04cca490e24fde2dd9a375478de136630bd2a6c08062fa3c", "role": "CLIENT", "short_name": "SM6L", "snr": 6.71, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.208, "battery_level": 28, "channel_utilization": 7.63, "uptime_seconds": 33588, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.66, "iaq": 32, "relative_humidity": 45.54, "temperature": 29.04}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 5132, "long_name": "Giant Ridge", "next_hop": 0, "num": "0x5542038a", "position": {"altitude": 1711, "latitude": 32.932384, "location_source": "LOC_INTERNAL", "longitude": -107.960917, "time_offset_sec": 5188}, "public_key_hex": "253fdd50e950a675c5f8392966b54b49c6f2990f9f06b7788199c1b2b46a3eb4", "role": "CLIENT", "short_name": "GYDB", "snr": 8.34, "status": null, "telemetry": {"air_util_tx": 0.425, "battery_level": 97, "channel_utilization": 12.63, "uptime_seconds": 160275, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1025.23, "iaq": 51, "relative_humidity": 100.0, "temperature": 21.58}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2309, "long_name": "Frosty Wolf", "next_hop": 0, "num": "0x55734537", "position": {"altitude": 937, "latitude": 33.393204, "location_source": "LOC_INTERNAL", "longitude": -107.850923, "time_offset_sec": 2530}, "public_key_hex": "7e4652e64745f9818402f3468dde97348c5ce54112110556f75dee40bb04e1f8", "role": "TAK_TRACKER", "short_name": "FIQP", "snr": 4.64, "status": null, "telemetry": {"air_util_tx": 1.204, "battery_level": 53, "channel_utilization": 8.74, "uptime_seconds": 140213, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2239, "long_name": "Short Yucca", "next_hop": 0, "num": "0x55799f51", "position": {"altitude": 1188, "latitude": 33.614414, "location_source": "LOC_INTERNAL", "longitude": -107.219677, "time_offset_sec": 2515}, "public_key_hex": "899cfaecd22f954af1d43f588273dee5002e72549b71e8881c18129b8d6c0c00", "role": "CLIENT", "short_name": "SGJO", "snr": 6.31, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.113, "battery_level": 57, "channel_utilization": 2.77, "uptime_seconds": 51030, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 227, "long_name": "Canyon Adder", "next_hop": 43, "num": "0x55fc07e7", "position": {"altitude": 1751, "latitude": 33.220694, "location_source": "LOC_INTERNAL", "longitude": -107.741995, "time_offset_sec": 500}, "public_key_hex": "7a74561a78f535fc95f5890d163818433e9fca44121884787afec20f5aea5ab7", "role": "CLIENT", "short_name": "C5NQ", "snr": 2.48, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.674, "battery_level": 20, "channel_utilization": 8.32, "uptime_seconds": 320816, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 6828, "long_name": "Short Mesa", "next_hop": 0, "num": "0x5607821b", "position": {"altitude": 1447, "latitude": 32.264792, "location_source": "LOC_INTERNAL", "longitude": -107.556886, "time_offset_sec": 6995}, "public_key_hex": "a147c79d0822db6eaf78c8ab0555df6d23b5b4a43dc7921fd5a6bc575c87a39e", "role": "CLIENT", "short_name": "S5IO", "snr": 9.31, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.259, "battery_level": 100, "channel_utilization": 6.39, "uptime_seconds": 59620, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1539, "long_name": "Silent Hare", "next_hop": 0, "num": "0x560ca843", "position": {"altitude": 1427, "latitude": 32.975042, "location_source": "LOC_INTERNAL", "longitude": -106.346847, "time_offset_sec": 1663}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦉", "snr": 5.35, "status": null, "telemetry": {"air_util_tx": 0.756, "battery_level": 98, "channel_utilization": 6.16, "uptime_seconds": 249833, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 4080, "long_name": "Stone Raven", "next_hop": 0, "num": "0x56801f9c", "position": {"altitude": 1224, "latitude": 33.196324, "location_source": "LOC_INTERNAL", "longitude": -107.29657, "time_offset_sec": 4236}, "public_key_hex": "b542b16c88d335f3c205f46dbaaa5a989fc3eb898e77f4c75e5d5f330ba9b7e6", "role": "ROUTER_LATE", "short_name": "SKCP", "snr": 6.2, "status": null, "telemetry": {"air_util_tx": 0.715, "battery_level": 58, "channel_utilization": 3.8, "uptime_seconds": 356181, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4671, "long_name": "White Turtle", "next_hop": 178, "num": "0x56cf95b6", "position": {"altitude": 1680, "latitude": 32.541764, "location_source": "LOC_INTERNAL", "longitude": -106.914057, "time_offset_sec": 4815}, "public_key_hex": "55d8e0e2e4054e8fa5887db5caa1688ee1b6aec13d6119ddf7041c377d2246e4", "role": "CLIENT", "short_name": "W1A4", "snr": 0.79, "status": null, "telemetry": {"air_util_tx": 0.28, "battery_level": 51, "channel_utilization": 14.89, "uptime_seconds": 188313, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2526, "long_name": "Howling Lion", "next_hop": 61, "num": "0x57300ef4", "position": {"altitude": 1344, "latitude": 32.916285, "location_source": "LOC_INTERNAL", "longitude": -107.634001, "time_offset_sec": 2775}, "public_key_hex": "50e4eb0ba899c8f757558ce2bbfd5e09dc232399450fe3ca3348399d708020a9", "role": "CLIENT", "short_name": "HLE2", "snr": 8.41, "status": null, "telemetry": {"air_util_tx": 0.033, "battery_level": 42, "channel_utilization": 16.39, "uptime_seconds": 122991, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3214, "long_name": "Sleepy Cougar", "next_hop": 0, "num": "0x57352c4b", "position": {"altitude": 1371, "latitude": 32.55933, "location_source": "LOC_INTERNAL", "longitude": -106.986804, "time_offset_sec": 3408}, "public_key_hex": "29b6a9c3fec1a346e1de89825cac16cf8b6d4d33f98f52683a2ebd010888147c", "role": "CLIENT", "short_name": "SL29", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.216, "battery_level": 94, "channel_utilization": 9.51, "uptime_seconds": 48903, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 7817, "long_name": "Black Bear", "next_hop": 0, "num": "0x575d1b79", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "B1JA", "snr": 5.49, "status": null, "telemetry": {"air_util_tx": 1.738, "battery_level": 27, "channel_utilization": 5.98, "uptime_seconds": 24654, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5513, "long_name": "Steel Tortoise", "next_hop": 0, "num": "0x57b6c2de", "position": {"altitude": 1210, "latitude": 32.811198, "location_source": "LOC_INTERNAL", "longitude": -106.890358, "time_offset_sec": 5527}, "public_key_hex": "3d54a4ebaa20e77dbec8f7a781cb0af6eca5563258e5e5055faab72aeb6af2af", "role": "CLIENT", "short_name": "SGEA", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.971, "battery_level": 47, "channel_utilization": 15.67, "uptime_seconds": 26310, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 7086, "long_name": "Iron Bronco", "next_hop": 210, "num": "0x5832817c", "position": {"altitude": 1569, "latitude": 32.821781, "location_source": "LOC_INTERNAL", "longitude": -107.140782, "time_offset_sec": 7225}, "public_key_hex": "6c423146b02c426a5dbb7de0c0fc3477a71514105884410b96f512704755c326", "role": "CLIENT", "short_name": "IFIP", "snr": 3.97, "status": null, "telemetry": {"air_util_tx": 1.422, "battery_level": 16, "channel_utilization": 10.66, "uptime_seconds": 63821, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1692, "long_name": "Giant Cobra", "next_hop": 0, "num": "0x583c5a5b", "position": {"altitude": 1032, "latitude": 32.813046, "location_source": "LOC_INTERNAL", "longitude": -107.794138, "time_offset_sec": 1832}, "public_key_hex": "962a97a7b5b4aac4806156eb170f177fd5d9ca9e2b4e8d248b5d63fce6d1cac3", "role": "CLIENT", "short_name": "GMJ4", "snr": 3.52, "status": null, "telemetry": {"air_util_tx": 1.012, "battery_level": 45, "channel_utilization": 7.19, "uptime_seconds": 35130, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 126, "long_name": "Fast Lynx KQ3LW", "next_hop": 6, "num": "0x5849da73", "position": null, "public_key_hex": "1a6b6734b2bf587c354c48c13c1e62fa72dc166cae43dc8c870a5b6716245d94", "role": "CLIENT_MUTE", "short_name": "FI93", "snr": 3.16, "status": null, "telemetry": {"air_util_tx": 0.643, "battery_level": 32, "channel_utilization": 23.9, "uptime_seconds": 43328, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2425, "long_name": "Storm Trout", "next_hop": 0, "num": "0x586b6883", "position": {"altitude": 1236, "latitude": 32.815968, "location_source": "LOC_INTERNAL", "longitude": -107.542767, "time_offset_sec": 2443}, "public_key_hex": "7dd18327c0124af056634160508731d3378308b0569a4aff289026f9d66f5d3a", "role": "CLIENT", "short_name": "SO1Y", "snr": 0.63, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.67, "iaq": 36, "relative_humidity": 41.59, "temperature": 16.27}, "hops_away": 1, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 1775, "long_name": "Quick Cedar W58AU", "next_hop": 6, "num": "0x588f87cf", "position": {"altitude": 1230, "latitude": 33.116822, "location_source": "LOC_INTERNAL", "longitude": -107.137944, "time_offset_sec": 1982}, "public_key_hex": "", "role": "CLIENT", "short_name": "QZB0", "snr": 8.87, "status": null, "telemetry": {"air_util_tx": 1.663, "battery_level": 53, "channel_utilization": 4.08, "uptime_seconds": 9436, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 6678, "long_name": "Fast Hare", "next_hop": 0, "num": "0x58d23c2d", "position": {"altitude": 1440, "latitude": 32.920805, "location_source": "LOC_INTERNAL", "longitude": -107.680564, "time_offset_sec": 6831}, "public_key_hex": "3ff2802508ac7af3b8942c20093932e9eb77600233079968709f921deae7eff1", "role": "CLIENT", "short_name": "FHT9", "snr": 1.98, "status": null, "telemetry": {"air_util_tx": 0.243, "battery_level": 97, "channel_utilization": 6.41, "uptime_seconds": 22521, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3336, "long_name": "Shady Oak", "next_hop": 171, "num": "0x58d3d466", "position": {"altitude": 1483, "latitude": 34.052506, "location_source": "LOC_INTERNAL", "longitude": -106.57317, "time_offset_sec": 3348}, "public_key_hex": "3258a0e7fe246f0fe1761c5048fa6021095d0542f89b268515c4870f243a99df", "role": "CLIENT", "short_name": "SZA8", "snr": 6.74, "status": null, "telemetry": {"air_util_tx": 0.674, "battery_level": 57, "channel_utilization": 13.16, "uptime_seconds": 69659, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4108, "long_name": "Wild Coyote", "next_hop": 0, "num": "0x590a2b97", "position": null, "public_key_hex": "93580e89ee97f6ac8b0a84305b15d8466cfe969d7843c699bf09320798f99c79", "role": "CLIENT", "short_name": "WBM7", "snr": 4.44, "status": null, "telemetry": {"air_util_tx": 0.406, "battery_level": 19, "channel_utilization": 21.25, "uptime_seconds": 76854, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5000, "long_name": "Loud Lion", "next_hop": 0, "num": "0x59493fdf", "position": {"altitude": 1707, "latitude": 34.040154, "location_source": "LOC_INTERNAL", "longitude": -108.243014, "time_offset_sec": 5114}, "public_key_hex": "f2d8ed013f3fa29c7e9c4e9629c17cd1b63a73f25b10e816502eb1dbcc563033", "role": "CLIENT", "short_name": "L15F", "snr": 1.09, "status": null, "telemetry": {"air_util_tx": 1.901, "battery_level": 79, "channel_utilization": 3.25, "uptime_seconds": 214227, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2673, "long_name": "Roving Lion", "next_hop": 0, "num": "0x5b10e9b7", "position": {"altitude": 1321, "latitude": 32.38925, "location_source": "LOC_INTERNAL", "longitude": -107.069099, "time_offset_sec": 2942}, "public_key_hex": "820b486c6495e1ad09e7dab117a1b7552c829fe3d8caf278e8a5facfaf187699", "role": "CLIENT", "short_name": "🐺", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.929, "battery_level": 11, "channel_utilization": 15.13, "uptime_seconds": 80013, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1026.63, "iaq": 31, "relative_humidity": 79.54, "temperature": 15.58}, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 290, "long_name": "Tall Yucca", "next_hop": 96, "num": "0x5b55f45c", "position": {"altitude": 1730, "latitude": 33.727681, "location_source": "LOC_INTERNAL", "longitude": -106.987046, "time_offset_sec": 291}, "public_key_hex": "18f90c990661567a0a1c270b184201979599918f0cdc8bed391f5020d26537d1", "role": "CLIENT", "short_name": "TG7E", "snr": 7.53, "status": null, "telemetry": {"air_util_tx": 1.309, "battery_level": 66, "channel_utilization": 4.61, "uptime_seconds": 46397, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 784, "long_name": "Roving Eagle", "next_hop": 0, "num": "0x5b6a8991", "position": {"altitude": 1247, "latitude": 32.625816, "location_source": "LOC_INTERNAL", "longitude": -107.227175, "time_offset_sec": 788}, "public_key_hex": "8e7bc4885275255e537cdfc9e064b2191291f5b6cb57c39c04058d2142ed1563", "role": "CLIENT", "short_name": "R8M5", "snr": 3.96, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.088, "battery_level": 80, "channel_utilization": 9.12, "uptime_seconds": 27191, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1110, "long_name": "Sleepy Stag", "next_hop": 195, "num": "0x5b739989", "position": {"altitude": 1427, "latitude": 33.273372, "location_source": "LOC_INTERNAL", "longitude": -107.3321, "time_offset_sec": 1348}, "public_key_hex": "1ce344c6d332c8b1114014e33ae347fca6cd6cfd7e4fd16b8e4443d8efc5c833", "role": "CLIENT", "short_name": "SDQS", "snr": 0.77, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.97, "iaq": 48, "relative_humidity": 63.94, "temperature": 23.68}, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4754, "long_name": "Forest Viper", "next_hop": 55, "num": "0x5be84694", "position": {"altitude": 1526, "latitude": 32.959566, "location_source": "LOC_INTERNAL", "longitude": -106.82651, "time_offset_sec": 4837}, "public_key_hex": "a9ff29378ab3ec7a94f0db45d3946a7a53f1d6b00589dfbfb468b1e1a645f9c4", "role": "CLIENT", "short_name": "FW2V", "snr": 4.24, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 8689, "long_name": "Desert Bronco KE7JW", "next_hop": 0, "num": "0x5bfa9b5d", "position": null, "public_key_hex": "53c3ffd3c55d07a4d8f7f166c59d89c8c478b360e979ff35165f8393fcfa3dad", "role": "CLIENT", "short_name": "DUXZ", "snr": 6.21, "status": null, "telemetry": {"air_util_tx": 0.351, "battery_level": 20, "channel_utilization": 0.33, "uptime_seconds": 24488, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 4542, "long_name": "Misty Badger", "next_hop": 0, "num": "0x5c3e3c24", "position": {"altitude": 1513, "latitude": 32.539944, "location_source": "LOC_INTERNAL", "longitude": -107.688447, "time_offset_sec": 4628}, "public_key_hex": "d852643bcc812b122022a8f74545d621041f1a97fa673eee510cd1e6df8ecf34", "role": "CLIENT", "short_name": "MK6S", "snr": 4.44, "status": null, "telemetry": {"air_util_tx": 1.826, "battery_level": 58, "channel_utilization": 8.23, "uptime_seconds": 41859, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 990.78, "iaq": 13, "relative_humidity": 31.97, "temperature": 33.84}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 687, "long_name": "Storm Marmot", "next_hop": 203, "num": "0x5c6c119b", "position": {"altitude": 1533, "latitude": 33.031282, "location_source": "LOC_INTERNAL", "longitude": -107.060367, "time_offset_sec": 967}, "public_key_hex": "75a45d69da1eeb52542819b3c001d565ed11b2c2ad052cc2de47646093b9432f", "role": "CLIENT_MUTE", "short_name": "S56P", "snr": 1.13, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.034, "battery_level": 10, "channel_utilization": 13.92, "uptime_seconds": 187090, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": {"barometric_pressure": 1020.15, "iaq": 49, "relative_humidity": 1.43, "temperature": 23.6}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 115, "long_name": "Black Iguana", "next_hop": 140, "num": "0x5c9456dd", "position": {"altitude": 1617, "latitude": 32.482529, "location_source": "LOC_INTERNAL", "longitude": -107.675732, "time_offset_sec": 332}, "public_key_hex": "1e691bcc62abda1c9683428874c1091c84b63e69634536bf3d8489d13cf6eedc", "role": "CLIENT", "short_name": "BBYR", "snr": -3.93, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1015.86, "iaq": 53, "relative_humidity": 55.86, "temperature": 20.66}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 7992, "long_name": "Forest Sage AB0MP", "next_hop": 0, "num": "0x5ca89e4b", "position": {"altitude": 1114, "latitude": 34.16658, "location_source": "LOC_INTERNAL", "longitude": -107.3823, "time_offset_sec": 8103}, "public_key_hex": "139f86aa39c096a4a822b810729ab498ac7bed52377414053b5531c9ba304c2a", "role": "CLIENT", "short_name": "F40B", "snr": 2.22, "status": null, "telemetry": {"air_util_tx": 0.59, "battery_level": 85, "channel_utilization": 10.92, "uptime_seconds": 20989, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3557, "long_name": "Steel Salmon", "next_hop": 0, "num": "0x5cac2114", "position": null, "public_key_hex": "abbf4e7d6641963ea122c46fec3d2a40a5fb0c206d45d4b7ad78f93ce1cd43d9", "role": "CLIENT", "short_name": "SNWR", "snr": 3.81, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.608, "battery_level": 62, "channel_utilization": 20.97, "uptime_seconds": 134297, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.84, "iaq": 27, "relative_humidity": 38.56, "temperature": 25.6}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2748, "long_name": "Floating Cactus", "next_hop": 135, "num": "0x5cb62187", "position": null, "public_key_hex": "a084a8b6835d6b60e7d808d2f2f65d8b9bd0c2d04fc671113fcc7892f237043f", "role": "ROUTER", "short_name": "🔥", "snr": 12.0, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.303, "battery_level": 39, "channel_utilization": 2.65, "uptime_seconds": 55285, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1701, "long_name": "Sky Colt", "next_hop": 0, "num": "0x5cc29b65", "position": null, "public_key_hex": "2388ec408d9ea1bffa23ddd1e15904454b425c5dade36c7df2dac7d9b3743516", "role": "CLIENT", "short_name": "SRG7", "snr": 6.88, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1029.5, "iaq": 0, "relative_humidity": 14.37, "temperature": 20.27}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 404, "long_name": "Gold Adder", "next_hop": 0, "num": "0x5cf883f9", "position": {"altitude": 708, "latitude": 32.310391, "location_source": "LOC_INTERNAL", "longitude": -107.199345, "time_offset_sec": 531}, "public_key_hex": "a3cba5d695fe9cadd1b43c91a6584666c33c100d68a8822591dd56f31c623587", "role": "CLIENT", "short_name": "G5FD", "snr": 2.09, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 8072, "long_name": "White Bass", "next_hop": 204, "num": "0x5d1ca7a6", "position": {"altitude": 896, "latitude": 32.738831, "location_source": "LOC_INTERNAL", "longitude": -108.47479, "time_offset_sec": 8103}, "public_key_hex": "56bfeeb88dbe57343040b5bb2f1a4f3cb8e2c10bff76a7ba4d48926e272276a3", "role": "CLIENT", "short_name": "WACF", "snr": 1.84, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4767, "long_name": "Stone Phoenix", "next_hop": 0, "num": "0x5d39a6a5", "position": {"altitude": 1139, "latitude": 33.583354, "location_source": "LOC_INTERNAL", "longitude": -106.054177, "time_offset_sec": 4881}, "public_key_hex": "3c81ba017e67241f3fdc52b7be3210a44fb821859e56dacc070d1ea02fc21bc4", "role": "CLIENT", "short_name": "SE5T", "snr": 8.28, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.244, "battery_level": 76, "channel_utilization": 19.13, "uptime_seconds": 171242, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 2, "environment": {"barometric_pressure": 1024.29, "iaq": 79, "relative_humidity": 64.85, "temperature": 37.25}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 60, "long_name": "Iron Shark", "next_hop": 0, "num": "0x5d3edcd2", "position": {"altitude": 1966, "latitude": 33.271395, "location_source": "LOC_INTERNAL", "longitude": -107.208199, "time_offset_sec": 203}, "public_key_hex": "2438dbee39cdbcb7e7cee34b4241d2b4e40094d3605b27641922c41e426eef5e", "role": "CLIENT", "short_name": "IHT6", "snr": -4.25, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1668, "long_name": "Sharp Wolf", "next_hop": 0, "num": "0x5d5700e6", "position": {"altitude": 1779, "latitude": 33.019747, "location_source": "LOC_INTERNAL", "longitude": -106.850405, "time_offset_sec": 1696}, "public_key_hex": "614c80100854937c661183623c445e5b1edd065a92e042d738f3b6514114dade", "role": "CLIENT", "short_name": "S202", "snr": 8.1, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.566, "battery_level": 66, "channel_utilization": 1.53, "uptime_seconds": 113792, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 305, "long_name": "Drifting Cobra", "next_hop": 103, "num": "0x5d6fa8cf", "position": null, "public_key_hex": "a3c7e53a06cabf0e12a289cd6b0707782abc901592e02e9398dce6d0178d2243", "role": "CLIENT", "short_name": "DIA1", "snr": -1.89, "status": null, "telemetry": {"air_util_tx": 0.204, "battery_level": 30, "channel_utilization": 21.66, "uptime_seconds": 17851, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1210, "long_name": "Sneaky Bison", "next_hop": 94, "num": "0x5e6b5cfa", "position": {"altitude": 1516, "latitude": 32.420087, "location_source": "LOC_INTERNAL", "longitude": -107.710817, "time_offset_sec": 1299}, "public_key_hex": "7c7cbb5c42d3ca7961b038d38015e7bb1263f66fdd8324fabbd03edbb9c7140d", "role": "CLIENT_HIDDEN", "short_name": "SCIN", "snr": 3.97, "status": null, "telemetry": {"air_util_tx": 1.823, "battery_level": 22, "channel_utilization": 1.18, "uptime_seconds": 35076, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.75, "iaq": 89, "relative_humidity": 47.18, "temperature": 22.24}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 8410, "long_name": "Sunny Colt", "next_hop": 0, "num": "0x5e924b24", "position": {"altitude": 1628, "latitude": 32.631907, "location_source": "LOC_INTERNAL", "longitude": -106.731651, "time_offset_sec": 8620}, "public_key_hex": "b3ad636860caf01eaab96dc15ea0bd89285807900ea8ba40e742fa93a6aaf49c", "role": "CLIENT", "short_name": "S35S", "snr": 3.93, "status": null, "telemetry": {"air_util_tx": 1.236, "battery_level": 46, "channel_utilization": 11.87, "uptime_seconds": 5201, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.66, "iaq": 29, "relative_humidity": 42.67, "temperature": 24.87}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 5740, "long_name": "Quick Seal", "next_hop": 178, "num": "0x5f0f76c2", "position": {"altitude": 1464, "latitude": 32.363427, "location_source": "LOC_INTERNAL", "longitude": -106.984259, "time_offset_sec": 5823}, "public_key_hex": "196f793d29b19686a7dff432ed32f1a5e8798b390d26501419c264242c8d34ae", "role": "CLIENT", "short_name": "Q8CY", "snr": -2.67, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4986, "long_name": "Dusk Bison", "next_hop": 0, "num": "0x5f147185", "position": {"altitude": 1140, "latitude": 32.142654, "location_source": "LOC_INTERNAL", "longitude": -107.324034, "time_offset_sec": 5047}, "public_key_hex": "fe392798be3ed2ed2c220c44dcaa1ed24ae2a0149caea4930cc9dad75fefeb51", "role": "CLIENT_MUTE", "short_name": "🦅", "snr": 9.35, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 741, "long_name": "Desert Phoenix", "next_hop": 119, "num": "0x5f2f9f9b", "position": {"altitude": 1055, "latitude": 33.208668, "location_source": "LOC_INTERNAL", "longitude": -106.618164, "time_offset_sec": 908}, "public_key_hex": "82da9cbc77e469d33e7d805403e01d2dfe35dd84debc5e1cecc3cf8f90bf841e", "role": "CLIENT", "short_name": "DXVI", "snr": -2.18, "status": null, "telemetry": {"air_util_tx": 0.025, "battery_level": 100, "channel_utilization": 2.22, "uptime_seconds": 476020, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1025.44, "iaq": 0, "relative_humidity": 59.94, "temperature": 22.55}, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4320, "long_name": "Fast Coyote", "next_hop": 241, "num": "0x5f47ac30", "position": {"altitude": 1135, "latitude": 33.264005, "location_source": "LOC_INTERNAL", "longitude": -107.339905, "time_offset_sec": 4565}, "public_key_hex": "26f5501c4afeac4b12f5f99ac41b267d5cc5de96e888c3b05d5bc3c5198ed694", "role": "CLIENT", "short_name": "FT8E", "snr": 6.11, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 1.158, "battery_level": 92, "channel_utilization": 1.46, "uptime_seconds": 90355, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4821, "long_name": "Wandering Cougar", "next_hop": 6, "num": "0x5fc9d6fa", "position": {"altitude": 984, "latitude": 32.634939, "location_source": "LOC_INTERNAL", "longitude": -107.25922, "time_offset_sec": 4949}, "public_key_hex": "4494a75e3e4d3a7a44ca2944ef45e436d1ce973daeed6b46333a647a7acd28d8", "role": "CLIENT_MUTE", "short_name": "W99T", "snr": 4.11, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.117, "battery_level": 71, "channel_utilization": 4.77, "uptime_seconds": 42360, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 309, "long_name": "Howling Falcon", "next_hop": 43, "num": "0x60a76bbe", "position": null, "public_key_hex": "0cea29776523ebb2443ec07bf5059a2aa62733d12a7f229decfdec3d11cd17a5", "role": "TRACKER", "short_name": "HRDU", "snr": 6.41, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.714, "battery_level": 95, "channel_utilization": 0.97, "uptime_seconds": 280797, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.15, "iaq": 65, "relative_humidity": 76.09, "temperature": 28.15}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2001, "long_name": "Silent Shark", "next_hop": 0, "num": "0x60acb50d", "position": null, "public_key_hex": "d7d3f2084a457d171e595de3ceaed0362aa8a82840b030aeaa33c37a569b34e6", "role": "CLIENT", "short_name": "SG2A", "snr": 10.32, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2071, "long_name": "Dawn Oak", "next_hop": 205, "num": "0x60d52493", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "D57H", "snr": 2.67, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.845, "battery_level": 57, "channel_utilization": 20.85, "uptime_seconds": 23577, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 408, "long_name": "Found Bronco", "next_hop": 16, "num": "0x6126e11d", "position": {"altitude": 1713, "latitude": 32.328562, "location_source": "LOC_INTERNAL", "longitude": -107.831678, "time_offset_sec": 622}, "public_key_hex": "5f1ede392e100f37d57c2c855a75ce067b5fa69354e7452a7de5c8f1f9ffd16f", "role": "CLIENT", "short_name": "FUAB", "snr": 4.63, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6386, "long_name": "Soft Dolphin", "next_hop": 89, "num": "0x6134fee3", "position": {"altitude": 1455, "latitude": 33.08712, "location_source": "LOC_INTERNAL", "longitude": -107.295562, "time_offset_sec": 6664}, "public_key_hex": "0fd08b5fd2e1567150219561d969bec29ef910c420efcd5279c7aea5045e5e49", "role": "TAK_TRACKER", "short_name": "S1JZ", "snr": 2.74, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.143, "battery_level": 43, "channel_utilization": 6.63, "uptime_seconds": 24021, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3325, "long_name": "River Bluff", "next_hop": 57, "num": "0x6143515c", "position": {"altitude": 1670, "latitude": 33.826539, "location_source": "LOC_INTERNAL", "longitude": -107.469218, "time_offset_sec": 3368}, "public_key_hex": "32c1968b4e8bd4c56e62032c9d4a5864196bc83a8687fd629425a8f087d0d1d3", "role": "CLIENT", "short_name": "RL69", "snr": 4.78, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.25, "battery_level": 90, "channel_utilization": 20.09, "uptime_seconds": 248749, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1133, "long_name": "Blue Eagle", "next_hop": 0, "num": "0x614c0e01", "position": null, "public_key_hex": "2c031998d927fa7b524f85fc64952609201c92ab2b966956ffbf4b09be0a610f", "role": "CLIENT", "short_name": "B2AH", "snr": 7.76, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.083, "battery_level": 43, "channel_utilization": 4.71, "uptime_seconds": 3918, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 995.19, "iaq": 48, "relative_humidity": 57.07, "temperature": 18.89}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1317, "long_name": "Sharp Arroyo", "next_hop": 90, "num": "0x618a05ed", "position": {"altitude": 1170, "latitude": 32.531093, "location_source": "LOC_INTERNAL", "longitude": -106.511411, "time_offset_sec": 1427}, "public_key_hex": "dccf199986299e0640f02a00b8c256b76490edaba14d24498565d137f68d05f9", "role": "CLIENT", "short_name": "ST9J", "snr": -0.14, "status": null, "telemetry": {"air_util_tx": 0.181, "battery_level": 71, "channel_utilization": 4.5, "uptime_seconds": 5744, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4758, "long_name": "Brave Otter", "next_hop": 0, "num": "0x619f7ad2", "position": null, "public_key_hex": "03b2fd5dcfc651ab528b2d5a6f09af129027c756cf7ca681d21723adfc1c1958", "role": "CLIENT", "short_name": "BIC2", "snr": 8.86, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.417, "battery_level": 41, "channel_utilization": 7.37, "uptime_seconds": 6155, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1027.12, "iaq": 73, "relative_humidity": 51.07, "temperature": 25.8}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3530, "long_name": "Burning Tortoise", "next_hop": 119, "num": "0x61d754f1", "position": {"altitude": 1192, "latitude": 32.554766, "location_source": "LOC_INTERNAL", "longitude": -107.670555, "time_offset_sec": 3766}, "public_key_hex": "112ff04d11fd11f1683100afd66c85cc606fa7280884df18a08e1f03593db308", "role": "CLIENT", "short_name": "B87D", "snr": 5.43, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.132, "battery_level": 45, "channel_utilization": 7.09, "uptime_seconds": 23000, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 6314, "long_name": "Roving Hawk", "next_hop": 79, "num": "0x61ecc35f", "position": null, "public_key_hex": "", "role": "ROUTER", "short_name": "🐺", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.251, "battery_level": 16, "channel_utilization": 21.55, "uptime_seconds": 11974, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1614, "long_name": "Mountain Pony", "next_hop": 0, "num": "0x62a08a3d", "position": {"altitude": 1014, "latitude": 32.731474, "location_source": "LOC_INTERNAL", "longitude": -107.127675, "time_offset_sec": 1734}, "public_key_hex": "", "role": "CLIENT", "short_name": "MLWS", "snr": 4.12, "status": null, "telemetry": {"air_util_tx": 0.103, "battery_level": 31, "channel_utilization": 10.83, "uptime_seconds": 18147, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1753, "long_name": "Black Mustang", "next_hop": 0, "num": "0x62aeb717", "position": null, "public_key_hex": "7a34ed04662ba4469e5698e6b22f196b5f1efaf282452f44b28ccd54a47e0c8c", "role": "TAK", "short_name": "B15H", "snr": 2.04, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3452, "long_name": "Shady Pony", "next_hop": 0, "num": "0x62e16e9d", "position": {"altitude": 1345, "latitude": 33.943778, "location_source": "LOC_INTERNAL", "longitude": -107.49073, "time_offset_sec": 3628}, "public_key_hex": "4e5d5b74d7a2614f86b3ae23ef6f4e59d1cd9aefda74648d4eedcf000ffc7800", "role": "CLIENT", "short_name": "S38R", "snr": 2.68, "status": null, "telemetry": {"air_util_tx": 1.361, "battery_level": 81, "channel_utilization": 6.38, "uptime_seconds": 100368, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 7272, "long_name": "Lost Bison", "next_hop": 90, "num": "0x63122ae7", "position": {"altitude": 1500, "latitude": 32.953909, "location_source": "LOC_INTERNAL", "longitude": -107.21476, "time_offset_sec": 7292}, "public_key_hex": "a627fe57fe5478e2ff8c532fecad12d3037ee2adfca23980da9d6eb9ece0d5f9", "role": "TRACKER", "short_name": "LIRB", "snr": -0.65, "status": null, "telemetry": {"air_util_tx": 0.872, "battery_level": 49, "channel_utilization": 2.93, "uptime_seconds": 51703, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3216, "long_name": "Drowsy Eagle", "next_hop": 49, "num": "0x6410b8ec", "position": {"altitude": 1310, "latitude": 33.010542, "location_source": "LOC_INTERNAL", "longitude": -106.403195, "time_offset_sec": 3354}, "public_key_hex": "bee10be3a33bb9e755411dd0e03149befe46868e7378bb35817edad58d5b462f", "role": "CLIENT", "short_name": "DI1D", "snr": 4.99, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 10233, "long_name": "Frozen Ridge K16WD", "next_hop": 249, "num": "0x643d413d", "position": {"altitude": 1318, "latitude": 32.853899, "location_source": "LOC_INTERNAL", "longitude": -108.16199, "time_offset_sec": 10354}, "public_key_hex": "08e5488221ef6ddb8dbb1586d41011ecc00fb8b74cc10c5639bc95e5c3151403", "role": "CLIENT", "short_name": "FVA6", "snr": 6.26, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.709, "battery_level": 59, "channel_utilization": 17.97, "uptime_seconds": 42772, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 6694, "long_name": "Dawn Seal", "next_hop": 27, "num": "0x6450c3fb", "position": {"altitude": 1376, "latitude": 32.920828, "location_source": "LOC_INTERNAL", "longitude": -107.07977, "time_offset_sec": 6772}, "public_key_hex": "7c6657dca30be8e3ece68a504aa2d304d472eb3e4e2fef885d848af1e7431f81", "role": "ROUTER", "short_name": "DS11", "snr": -0.65, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.58, "iaq": 69, "relative_humidity": 90.68, "temperature": 14.54}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2177, "long_name": "Drowsy Raven", "next_hop": 0, "num": "0x645bc141", "position": {"altitude": 1036, "latitude": 32.917715, "location_source": "LOC_INTERNAL", "longitude": -107.72338, "time_offset_sec": 2382}, "public_key_hex": "5e0ad59c291ae909ad1fc13618f9e5b3fde276352e6e1511bab5b86a615afa18", "role": "CLIENT", "short_name": "DCNF", "snr": 2.28, "status": null, "telemetry": {"air_util_tx": 0.216, "battery_level": 17, "channel_utilization": 5.1, "uptime_seconds": 72205, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.46, "iaq": 43, "relative_humidity": 100.0, "temperature": 5.43}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4079, "long_name": "Soft Pine", "next_hop": 0, "num": "0x655b61d3", "position": {"altitude": 962, "latitude": 33.818742, "location_source": "LOC_INTERNAL", "longitude": -107.759172, "time_offset_sec": 4092}, "public_key_hex": "d0cbe0fe25b919f38550f3f9561074c0f06aaff8a124b75435b9ac36166366dd", "role": "CLIENT_MUTE", "short_name": "S6K0", "snr": 3.41, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.811, "battery_level": 75, "channel_utilization": 11.02, "uptime_seconds": 104419, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1005.64, "iaq": 113, "relative_humidity": 27.29, "temperature": 14.93}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1527, "long_name": "Slow Mamba", "next_hop": 0, "num": "0x65684a77", "position": {"altitude": 1479, "latitude": 32.519337, "location_source": "LOC_INTERNAL", "longitude": -107.090347, "time_offset_sec": 1738}, "public_key_hex": "768177b124e3416bc53827cbe3eac32d2a122c7652347034363b64d03145f34e", "role": "CLIENT", "short_name": "SK3G", "snr": 2.76, "status": null, "telemetry": {"air_util_tx": 1.46, "battery_level": 100, "channel_utilization": 5.93, "uptime_seconds": 321087, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 87, "long_name": "Sunny Sage", "next_hop": 0, "num": "0x6597cc10", "position": {"altitude": 1022, "latitude": 32.086383, "location_source": "LOC_INTERNAL", "longitude": -107.871811, "time_offset_sec": 167}, "public_key_hex": "f8b0606a45371cf8eecf6ceca7086ab921f4aff7289ab5bafef1dfb1500b5d4b", "role": "ROUTER", "short_name": "🌲", "snr": 7.83, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.025, "battery_level": 20, "channel_utilization": 2.73, "uptime_seconds": 227551, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 5629, "long_name": "River Oak", "next_hop": 0, "num": "0x65a37178", "position": {"altitude": 1177, "latitude": 33.73494, "location_source": "LOC_INTERNAL", "longitude": -108.007027, "time_offset_sec": 5778}, "public_key_hex": "e57787d4990caeada3d54663b25f50e756b11bf275913378f1b5414fa34e00d1", "role": "CLIENT", "short_name": "RBKG", "snr": 8.68, "status": null, "telemetry": {"air_util_tx": 0.108, "battery_level": 45, "channel_utilization": 19.0, "uptime_seconds": 40769, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.81, "iaq": 101, "relative_humidity": 25.77, "temperature": 14.31}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1165, "long_name": "Wild Ridge", "next_hop": 0, "num": "0x65a7b407", "position": {"altitude": 1447, "latitude": 33.54226, "location_source": "LOC_INTERNAL", "longitude": -106.895898, "time_offset_sec": 1228}, "public_key_hex": "58b7fca526c5ed88a273674f7fac0ae3b9be0ce980c0775fb1bc0ce3ea5cca37", "role": "CLIENT", "short_name": "WXI2", "snr": 9.41, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.174, "battery_level": 58, "channel_utilization": 23.06, "uptime_seconds": 67457, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 5989, "long_name": "Lunar Cougar", "next_hop": 0, "num": "0x65af31dc", "position": {"altitude": 1074, "latitude": 32.16723, "location_source": "LOC_INTERNAL", "longitude": -107.285076, "time_offset_sec": 6182}, "public_key_hex": "cda47ed74da113fd8789dfd518c2d5b2f93df64c481784585fb10dcec1c0eeb2", "role": "TRACKER", "short_name": "LC1J", "snr": 4.62, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2437, "long_name": "Rough Falcon", "next_hop": 0, "num": "0x65b43e72", "position": {"altitude": 1280, "latitude": 33.265534, "location_source": "LOC_INTERNAL", "longitude": -107.716745, "time_offset_sec": 2715}, "public_key_hex": "6d4ec445477406994a457799137b4911d74d6d2e6a79c3826a5cf6569ba64af8", "role": "CLIENT", "short_name": "RUFF", "snr": 3.32, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.123, "battery_level": 82, "channel_utilization": 6.32, "uptime_seconds": 6122, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 337, "long_name": "Shady Ridge", "next_hop": 0, "num": "0x667d0bb4", "position": null, "public_key_hex": "f912306b415f7aa4da19556f1bfb6c9f7fb0c1e29580742078b4493e6c8350c7", "role": "CLIENT", "short_name": "S004", "snr": -0.57, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.624, "battery_level": 33, "channel_utilization": 15.8, "uptime_seconds": 5462, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 966, "long_name": "River Mole", "next_hop": 78, "num": "0x66a9fcd8", "position": null, "public_key_hex": "92ae245a2ed70681ef79621614b8385429e6547a47a1acd7a7435296cba6606f", "role": "CLIENT", "short_name": "R7QG", "snr": 9.6, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.625, "battery_level": 61, "channel_utilization": 12.36, "uptime_seconds": 17187, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 603, "long_name": "Misty Pony", "next_hop": 0, "num": "0x66d201f9", "position": {"altitude": 1399, "latitude": 32.243637, "location_source": "LOC_INTERNAL", "longitude": -107.161281, "time_offset_sec": 608}, "public_key_hex": "b32f4a02bb6ba87dd709eaab8aa2def30eea43948da6a88d3e6757ad37fb1bf1", "role": "CLIENT", "short_name": "MHGL", "snr": 2.23, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.079, "battery_level": 46, "channel_utilization": 7.39, "uptime_seconds": 8797, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1615, "long_name": "Loud Mamba", "next_hop": 0, "num": "0x670a401a", "position": {"altitude": 1125, "latitude": 33.386315, "location_source": "LOC_INTERNAL", "longitude": -107.348963, "time_offset_sec": 1654}, "public_key_hex": "3e988b2487f8f86c5b4fc2ff8603ef75d3d603f382bf747a85aa34e2cb56d887", "role": "CLIENT_HIDDEN", "short_name": "LC74", "snr": 10.26, "status": null, "telemetry": {"air_util_tx": 0.634, "battery_level": 11, "channel_utilization": 3.11, "uptime_seconds": 170109, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 6984, "long_name": "Gold Cobra", "next_hop": 0, "num": "0x67828a9d", "position": {"altitude": 1484, "latitude": 33.31937, "location_source": "LOC_INTERNAL", "longitude": -107.462128, "time_offset_sec": 7121}, "public_key_hex": "1d9d963537b6a842696639308d55414c42b2838310bdcbae7fb4f98dd2746a94", "role": "CLIENT", "short_name": "GPD1", "snr": 9.74, "status": null, "telemetry": {"air_util_tx": 0.886, "battery_level": 28, "channel_utilization": 8.35, "uptime_seconds": 41548, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1311, "long_name": "Sneaky Beaver", "next_hop": 0, "num": "0x67a11808", "position": {"altitude": 1033, "latitude": 32.927237, "location_source": "LOC_INTERNAL", "longitude": -106.917122, "time_offset_sec": 1522}, "public_key_hex": "64497ae071d0dbfd1f006af9604dea39b494ae4699108704d05b53296e8c1f4a", "role": "CLIENT", "short_name": "SMW2", "snr": 7.89, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": {"barometric_pressure": 1015.83, "iaq": 0, "relative_humidity": 48.56, "temperature": 27.84}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 882, "long_name": "White Bronco", "next_hop": 0, "num": "0x67a2fc22", "position": {"altitude": 1194, "latitude": 32.069484, "location_source": "LOC_INTERNAL", "longitude": -107.0902, "time_offset_sec": 955}, "public_key_hex": "d319e98a163b943a6b1b51e7aab152d35ac96d09299a8db902c58b1d4189a7af", "role": "CLIENT", "short_name": "WUCR", "snr": 0.46, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3686, "long_name": "Found Falcon", "next_hop": 0, "num": "0x67e769d2", "position": {"altitude": 1633, "latitude": 33.0328, "location_source": "LOC_INTERNAL", "longitude": -107.240522, "time_offset_sec": 3771}, "public_key_hex": "", "role": "CLIENT", "short_name": "FYOX", "snr": 1.48, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.625, "battery_level": 99, "channel_utilization": 14.36, "uptime_seconds": 68022, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1005.59, "iaq": 65, "relative_humidity": 17.76, "temperature": 23.78}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1971, "long_name": "Old Mole", "next_hop": 0, "num": "0x67eda7d6", "position": {"altitude": 1498, "latitude": 32.629438, "location_source": "LOC_INTERNAL", "longitude": -107.269653, "time_offset_sec": 2004}, "public_key_hex": "de38ac98f05428ca2820bbf54be72fbf3e773c07cbf9f6521663752458828e84", "role": "CLIENT_HIDDEN", "short_name": "OU8V", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.562, "battery_level": 101, "channel_utilization": 24.43, "uptime_seconds": 101944, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.69, "iaq": 50, "relative_humidity": 44.1, "temperature": 36.37}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 642, "long_name": "Solar Adder", "next_hop": 0, "num": "0x67f48cb5", "position": {"altitude": 1656, "latitude": 32.913674, "location_source": "LOC_INTERNAL", "longitude": -106.957759, "time_offset_sec": 831}, "public_key_hex": "4790c1b4bad3100ca34cee84c30174dbde73988aa7001d48062877f667403831", "role": "ROUTER", "short_name": "S545", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 2.037, "battery_level": 96, "channel_utilization": 8.25, "uptime_seconds": 77101, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 9835, "long_name": "Roving Seal", "next_hop": 233, "num": "0x67f6ee87", "position": {"altitude": 1634, "latitude": 33.779854, "location_source": "LOC_INTERNAL", "longitude": -107.994639, "time_offset_sec": 10090}, "public_key_hex": "", "role": "CLIENT", "short_name": "RVBJ", "snr": 6.89, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.06, "iaq": 44, "relative_humidity": 21.17, "temperature": 26.13}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 873, "long_name": "Silent Sage", "next_hop": 207, "num": "0x68ca0085", "position": {"altitude": 1681, "latitude": 33.090398, "location_source": "LOC_INTERNAL", "longitude": -106.850398, "time_offset_sec": 902}, "public_key_hex": "03d21555e5d10eecc12e3c033d17a459bb3c9026c3dbcc184e964a83fc0dcd01", "role": "CLIENT", "short_name": "SN2Y", "snr": 7.28, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.444, "battery_level": 70, "channel_utilization": 5.6, "uptime_seconds": 61549, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3693, "long_name": "Forest Badger", "next_hop": 0, "num": "0x68d21cdf", "position": {"altitude": 1447, "latitude": 33.60625, "location_source": "LOC_INTERNAL", "longitude": -106.763408, "time_offset_sec": 3789}, "public_key_hex": "7f22ca57b404fd315a02e7194c02927b8d4a320c3033cd12538b534f38d4a8d5", "role": "CLIENT", "short_name": "F15P", "snr": 1.36, "status": {"status": "offline-soon"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 332, "long_name": "Silver Phoenix", "next_hop": 0, "num": "0x68db9a79", "position": {"altitude": 1436, "latitude": 31.863364, "location_source": "LOC_INTERNAL", "longitude": -106.745974, "time_offset_sec": 537}, "public_key_hex": "7a137a5511c4e21236e69c3fb0d586fa63661d3fe65a8784edd645ffa4e15791", "role": "CLIENT", "short_name": "SRI5", "snr": 6.07, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4_R8", "last_heard_offset_sec": 9910, "long_name": "Dusk Stag", "next_hop": 0, "num": "0x69160996", "position": {"altitude": 1315, "latitude": 33.006478, "location_source": "LOC_INTERNAL", "longitude": -107.60747, "time_offset_sec": 10209}, "public_key_hex": "cd222db4c4df04c04f1c1b67e04e462fb177603fd08e04a1d8f824d614e45211", "role": "LOST_AND_FOUND", "short_name": "D6EE", "snr": 5.47, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.367, "battery_level": 101, "channel_utilization": 11.29, "uptime_seconds": 6571, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 11782, "long_name": "Bright Bison", "next_hop": 31, "num": "0x69443905", "position": {"altitude": 1701, "latitude": 33.557304, "location_source": "LOC_INTERNAL", "longitude": -107.395231, "time_offset_sec": 11794}, "public_key_hex": "1ca60e46542920bb906736a8c09af6753e9167b44b680de0f336b4412bc8fc55", "role": "CLIENT", "short_name": "BQCF", "snr": 4.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1846, "long_name": "Tiny Juniper", "next_hop": 0, "num": "0x69512c9f", "position": null, "public_key_hex": "eb11d406cb1e6b5e57ef2f4efc4067ed893d42159e2f0e2e8f2601a6b2a5b833", "role": "CLIENT", "short_name": "TPGU", "snr": 0.42, "status": null, "telemetry": {"air_util_tx": 0.913, "battery_level": 54, "channel_utilization": 10.96, "uptime_seconds": 44889, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.11, "iaq": 100, "relative_humidity": 56.47, "temperature": 15.63}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3973, "long_name": "Lunar Dolphin", "next_hop": 0, "num": "0x695ea36c", "position": {"altitude": 1448, "latitude": 33.04932, "location_source": "LOC_INTERNAL", "longitude": -107.173961, "time_offset_sec": 4082}, "public_key_hex": "0c64449dad15645f70042cc3dfbb5945ec54ee542633d3aa52f202c28acae1d9", "role": "CLIENT", "short_name": "LHPX", "snr": 6.42, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.169, "battery_level": 42, "channel_utilization": 27.83, "uptime_seconds": 417701, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.43, "iaq": 60, "relative_humidity": 67.3, "temperature": 20.41}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5682, "long_name": "Tall Crow", "next_hop": 0, "num": "0x69959909", "position": {"altitude": 1130, "latitude": 33.247049, "location_source": "LOC_INTERNAL", "longitude": -106.725418, "time_offset_sec": 5705}, "public_key_hex": "7f6b52ee6b5b005b1954b6e317df1945cce6286c5b2af22c27d5686014f7ee82", "role": "CLIENT", "short_name": "TKRG", "snr": 6.74, "status": null, "telemetry": {"air_util_tx": 0.838, "battery_level": 101, "channel_utilization": 10.31, "uptime_seconds": 250687, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 93, "long_name": "Wild Eagle", "next_hop": 0, "num": "0x6a88a937", "position": {"altitude": 1337, "latitude": 32.669787, "location_source": "LOC_INTERNAL", "longitude": -106.905899, "time_offset_sec": 212}, "public_key_hex": "7ce8c5001d1a00b459ab45aa033fdaa12ac6ef42d56893003c3dd834794e9e36", "role": "ROUTER_LATE", "short_name": "WM0N", "snr": 9.78, "status": null, "telemetry": {"air_util_tx": 1.126, "battery_level": 52, "channel_utilization": 4.5, "uptime_seconds": 206846, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7581, "long_name": "River Coyote", "next_hop": 0, "num": "0x6ac03524", "position": {"altitude": 1459, "latitude": 33.131279, "location_source": "LOC_INTERNAL", "longitude": -108.035675, "time_offset_sec": 7612}, "public_key_hex": "68fd7afb6a2f7dbd732fa013bd181143f906cc65ce33d2810edb0c68897a0c04", "role": "SENSOR", "short_name": "RDYM", "snr": 4.5, "status": null, "telemetry": {"air_util_tx": 0.238, "battery_level": 98, "channel_utilization": 12.09, "uptime_seconds": 9279, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.02, "iaq": 47, "relative_humidity": 55.69, "temperature": 28.1}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3598, "long_name": "Frosty Arroyo", "next_hop": 0, "num": "0x6b1505a9", "position": {"altitude": 1280, "latitude": 32.289692, "location_source": "LOC_INTERNAL", "longitude": -106.779069, "time_offset_sec": 3868}, "public_key_hex": "d95c1db8d26a35d1a1a5752866b6414b5f8faba9fc2af8dbdd0dc2ad5fec4de2", "role": "CLIENT", "short_name": "FE2N", "snr": 10.7, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.781, "battery_level": 75, "channel_utilization": 22.77, "uptime_seconds": 72827, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 998.23, "iaq": 33, "relative_humidity": 62.25, "temperature": 36.78}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1330, "long_name": "Floating Squirrel", "next_hop": 0, "num": "0x6b2270e8", "position": {"altitude": 1528, "latitude": 33.389223, "location_source": "LOC_INTERNAL", "longitude": -108.196442, "time_offset_sec": 1470}, "public_key_hex": "31c03b0d9636fe3b833df53b00d92a7e4e5988e7b807c8ba426665808dace31a", "role": "CLIENT_BASE", "short_name": "F0FM", "snr": 0.13, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.645, "battery_level": 94, "channel_utilization": 11.86, "uptime_seconds": 7143, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3090, "long_name": "Smooth Salmon", "next_hop": 0, "num": "0x6b2dce01", "position": {"altitude": 1409, "latitude": 33.100448, "location_source": "LOC_INTERNAL", "longitude": -107.92389, "time_offset_sec": 3305}, "public_key_hex": "16d1f968b4035b6edf635237f3f01138827927a9848d967d6f31350688cf188a", "role": "CLIENT_HIDDEN", "short_name": "SB4A", "snr": 11.58, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.347, "battery_level": 85, "channel_utilization": 8.17, "uptime_seconds": 36591, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 332, "long_name": "Steel Cedar", "next_hop": 0, "num": "0x6bf30f37", "position": null, "public_key_hex": "88808c58de5b8c4058b666a6db248d8e8ad33045d9df74a5d1a67a98f984706e", "role": "TRACKER", "short_name": "SX1D", "snr": 7.31, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 2903, "long_name": "Bright Sage", "next_hop": 0, "num": "0x6c8fbc50", "position": {"altitude": 1586, "latitude": 32.350879, "location_source": "LOC_INTERNAL", "longitude": -107.05561, "time_offset_sec": 3201}, "public_key_hex": "461cee2e618fcdeaf759daa978b36ff4eb4d437a83214fea59ea82808297c0ce", "role": "CLIENT_HIDDEN", "short_name": "BPBG", "snr": 4.0, "status": null, "telemetry": {"air_util_tx": 0.289, "battery_level": 85, "channel_utilization": 0.63, "uptime_seconds": 46196, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 723, "long_name": "Silver Eagle", "next_hop": 42, "num": "0x6cc140c8", "position": {"altitude": 1298, "latitude": 32.802868, "location_source": "LOC_INTERNAL", "longitude": -108.108877, "time_offset_sec": 731}, "public_key_hex": "5d1514847335f3e3b783bf340990baf1bfeeaeaee6d9f4932894d3d7c82ae65f", "role": "CLIENT", "short_name": "🌵", "snr": 2.76, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 14385, "long_name": "Whispering Falcon", "next_hop": 0, "num": "0x6ce558ad", "position": {"altitude": 1567, "latitude": 32.115722, "location_source": "LOC_INTERNAL", "longitude": -106.926052, "time_offset_sec": 14454}, "public_key_hex": "ba072d449993d9384e2f544d437f138e6032a506820882ad23ad764c7c44582d", "role": "CLIENT", "short_name": "W1DJ", "snr": 6.37, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.819, "battery_level": 55, "channel_utilization": 20.22, "uptime_seconds": 73068, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 4890, "long_name": "Bright Cedar", "next_hop": 0, "num": "0x6cfdd4fa", "position": {"altitude": 979, "latitude": 32.657349, "location_source": "LOC_INTERNAL", "longitude": -107.084846, "time_offset_sec": 4950}, "public_key_hex": "332d8658cbbab5eaf335b8bbc848934f402374a08c32f6b79ac32eed2601a08c", "role": "ROUTER_LATE", "short_name": "🐝", "snr": 8.44, "status": null, "telemetry": {"air_util_tx": 0.878, "battery_level": 85, "channel_utilization": 1.34, "uptime_seconds": 58815, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 663, "long_name": "Hidden Turtle", "next_hop": 0, "num": "0x6d6fe2cd", "position": {"altitude": 710, "latitude": 33.357273, "location_source": "LOC_INTERNAL", "longitude": -107.306769, "time_offset_sec": 730}, "public_key_hex": "d2984b2719ee23d69b70e70e9b71e8732b5fa3cd148e7376a3a118932268725b", "role": "CLIENT_MUTE", "short_name": "HAD7", "snr": 5.8, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 565, "long_name": "Mountain Dolphin", "next_hop": 0, "num": "0x6d9575e4", "position": {"altitude": 1204, "latitude": 32.968638, "location_source": "LOC_INTERNAL", "longitude": -108.392893, "time_offset_sec": 632}, "public_key_hex": "2b4948dd23b6acce3f6c8789c371cebc0d6d18e9a738600587c303089115e3cf", "role": "SENSOR", "short_name": "MVX9", "snr": 7.42, "status": null, "telemetry": {"air_util_tx": 1.062, "battery_level": 47, "channel_utilization": 7.94, "uptime_seconds": 33895, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2618, "long_name": "Tiny Bear", "next_hop": 0, "num": "0x6db0eeb2", "position": {"altitude": 1228, "latitude": 32.430937, "location_source": "LOC_INTERNAL", "longitude": -107.468988, "time_offset_sec": 2827}, "public_key_hex": "", "role": "CLIENT_BASE", "short_name": "TE9P", "snr": 10.5, "status": null, "telemetry": {"air_util_tx": 1.415, "battery_level": 70, "channel_utilization": 10.78, "uptime_seconds": 109124, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.51, "iaq": 30, "relative_humidity": 51.2, "temperature": 5.59}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7056, "long_name": "Red Phoenix", "next_hop": 67, "num": "0x6ebad280", "position": {"altitude": 1406, "latitude": 32.587193, "location_source": "LOC_INTERNAL", "longitude": -107.864844, "time_offset_sec": 7077}, "public_key_hex": "4b37206cf6c334f1c025ed1eca12c826ff2e72fcf5b24a4d12ea0349f3754647", "role": "TAK", "short_name": "RSBL", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.068, "battery_level": 101, "channel_utilization": 4.58, "uptime_seconds": 9420, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 748, "long_name": "Happy Mole AE7OR", "next_hop": 0, "num": "0x6f29b8be", "position": {"altitude": 1471, "latitude": 34.024057, "location_source": "LOC_INTERNAL", "longitude": -107.980924, "time_offset_sec": 956}, "public_key_hex": "76d9fbe59a5ec44941574d30735c45f34b0df3b859aa31f4842e316c16493060", "role": "CLIENT", "short_name": "HEAD", "snr": 1.7, "status": null, "telemetry": {"air_util_tx": 2.07, "battery_level": 89, "channel_utilization": 2.5, "uptime_seconds": 4008, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.52, "iaq": 43, "relative_humidity": 27.02, "temperature": 34.46}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1148, "long_name": "Short Mesa WD3LM", "next_hop": 0, "num": "0x6fdf60c0", "position": null, "public_key_hex": "7b8a79f2d32792730b51bb3c49fe52b7f32f9c9d0bdad2e236613e5c370f180b", "role": "CLIENT_MUTE", "short_name": "SPEK", "snr": 7.16, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 3653, "long_name": "Gold Tortoise", "next_hop": 0, "num": "0x70091536", "position": {"altitude": 1231, "latitude": 33.215614, "location_source": "LOC_INTERNAL", "longitude": -107.454934, "time_offset_sec": 3927}, "public_key_hex": "b8bc1238d599bb552daa9bb5854170725e73440aa97a5313a3e809bedf1d2492", "role": "CLIENT", "short_name": "GCR4", "snr": 0.56, "status": null, "telemetry": {"air_util_tx": 0.849, "battery_level": 20, "channel_utilization": 0.54, "uptime_seconds": 72907, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 7, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 10347, "long_name": "Desert Whale", "next_hop": 122, "num": "0x7049f212", "position": {"altitude": 1563, "latitude": 34.863907, "location_source": "LOC_INTERNAL", "longitude": -107.289982, "time_offset_sec": 10465}, "public_key_hex": "0804773e1c6637bfe1ad68e033090eb8785d465d6488aae6e91153313cbfef52", "role": "CLIENT", "short_name": "DYV8", "snr": 6.21, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6547, "long_name": "Fast Mesa", "next_hop": 0, "num": "0x706b89dc", "position": {"altitude": 575, "latitude": 33.070382, "location_source": "LOC_INTERNAL", "longitude": -108.658227, "time_offset_sec": 6632}, "public_key_hex": "3c2e7b204295221ff20739d229fa58cc2a0c918643510c1d9c142af4261a6d40", "role": "CLIENT", "short_name": "FELZ", "snr": 7.66, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.176, "battery_level": 62, "channel_utilization": 3.76, "uptime_seconds": 35160, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1011.87, "iaq": 25, "relative_humidity": 37.46, "temperature": 22.71}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 759, "long_name": "Short Arroyo", "next_hop": 0, "num": "0x7073c568", "position": {"altitude": 1474, "latitude": 32.796892, "location_source": "LOC_INTERNAL", "longitude": -107.986938, "time_offset_sec": 787}, "public_key_hex": "3b06ebd249feb4db8a046d8240f1e6133def1f6681f363341bf1c2d8f35ed253", "role": "CLIENT", "short_name": "SG6Y", "snr": -1.58, "status": null, "telemetry": {"air_util_tx": 0.647, "battery_level": 97, "channel_utilization": 7.86, "uptime_seconds": 205366, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 481, "long_name": "Green Iguana", "next_hop": 100, "num": "0x70b7aa80", "position": {"altitude": 1720, "latitude": 32.751094, "location_source": "LOC_INTERNAL", "longitude": -106.105514, "time_offset_sec": 528}, "public_key_hex": "c8b9feca4dcd64a53b1f15a8e13c3e717bcc5acb72ff3f8d0087dd65f2386e66", "role": "ROUTER_LATE", "short_name": "GLC3", "snr": -1.8, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.327, "battery_level": 59, "channel_utilization": 2.49, "uptime_seconds": 135440, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.52, "iaq": 47, "relative_humidity": 32.24, "temperature": 26.78}, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3893, "long_name": "Mountain Squirrel", "next_hop": 14, "num": "0x70d95891", "position": {"altitude": 1127, "latitude": 33.675798, "location_source": "LOC_INTERNAL", "longitude": -106.883999, "time_offset_sec": 4149}, "public_key_hex": "ca4724e421c616c133571de521b110fb8cc4985681e29399d41b8268fea05b4f", "role": "CLIENT", "short_name": "MYSF", "snr": 9.69, "status": null, "telemetry": {"air_util_tx": 0.899, "battery_level": 17, "channel_utilization": 7.19, "uptime_seconds": 137688, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1509, "long_name": "Gold Adder", "next_hop": 0, "num": "0x71459999", "position": null, "public_key_hex": "01cf4af2928b1fbf274405bf3a17299e7eb73fcdf03ebe8d2efc9e94eb8d179a", "role": "CLIENT", "short_name": "🐺", "snr": 4.63, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.3, "iaq": 0, "relative_humidity": 37.74, "temperature": 32.73}, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1959, "long_name": "Silver Crane", "next_hop": 171, "num": "0x716da39c", "position": null, "public_key_hex": "089363a405cd19f55b88e76e0863663d8d3e6d3b7171d011af89b4f36712a86a", "role": "SENSOR", "short_name": "SIC8", "snr": 0.57, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.419, "battery_level": 45, "channel_utilization": 8.68, "uptime_seconds": 22858, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 521, "long_name": "Floating Raven", "next_hop": 0, "num": "0x719e4ee9", "position": {"altitude": 913, "latitude": 32.781272, "location_source": "LOC_INTERNAL", "longitude": -107.211215, "time_offset_sec": 682}, "public_key_hex": "7b1afdaebc952249765689c2fe0bf4d41d795822a16e836f1804a636466e1e2e", "role": "CLIENT", "short_name": "F9I9", "snr": -2.23, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.537, "battery_level": 61, "channel_utilization": 6.8, "uptime_seconds": 192545, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1049, "long_name": "White Turtle", "next_hop": 0, "num": "0x71aaa863", "position": {"altitude": 1203, "latitude": 33.654085, "location_source": "LOC_INTERNAL", "longitude": -107.263033, "time_offset_sec": 1288}, "public_key_hex": "06ce4a8ec0671200fd3e72a02d2eaef7bb9fce10f617f888700875783d85f0c4", "role": "TAK", "short_name": "W0FC", "snr": 2.43, "status": null, "telemetry": {"air_util_tx": 0.083, "battery_level": 85, "channel_utilization": 16.67, "uptime_seconds": 3562, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 7605, "long_name": "Roving Mamba", "next_hop": 198, "num": "0x7200a1bc", "position": {"altitude": 1097, "latitude": 33.406264, "location_source": "LOC_INTERNAL", "longitude": -107.479595, "time_offset_sec": 7665}, "public_key_hex": "e7e9b3f098033f014a7d0062c6a3794f5b49913c7850b2cdf972aa6a91cba3e7", "role": "CLIENT", "short_name": "RN5I", "snr": 5.81, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.728, "battery_level": 55, "channel_utilization": 12.42, "uptime_seconds": 18866, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3093, "long_name": "Tall Fox", "next_hop": 0, "num": "0x722ac5db", "position": {"altitude": 1158, "latitude": 33.754065, "location_source": "LOC_INTERNAL", "longitude": -108.142416, "time_offset_sec": 3181}, "public_key_hex": "5cb8569f357f1f12a62c7b23d01001c4c2cf4c4c307bff7539251865d9c6ef62", "role": "CLIENT_MUTE", "short_name": "TX3E", "snr": 5.35, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.37, "iaq": 62, "relative_humidity": 46.84, "temperature": 18.62}, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 705, "long_name": "Iron Bison", "next_hop": 38, "num": "0x72317d7d", "position": {"altitude": 1278, "latitude": 31.93437, "location_source": "LOC_INTERNAL", "longitude": -106.770818, "time_offset_sec": 894}, "public_key_hex": "c536dc8aa9f544bec26fa64ec02f33e0bcf3deea7d40cf8a5929d274da9bc02f", "role": "CLIENT", "short_name": "IHD7", "snr": 9.44, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.918, "battery_level": 81, "channel_utilization": 32.59, "uptime_seconds": 147281, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 16666, "long_name": "Fast Bluff", "next_hop": 0, "num": "0x729d57b3", "position": {"altitude": 1409, "latitude": 33.656311, "location_source": "LOC_INTERNAL", "longitude": -106.945183, "time_offset_sec": 16956}, "public_key_hex": "b16d739c9bba5da22bcefeb30daf4237c1f4c197c1e77068ad2b4931537a3003", "role": "CLIENT", "short_name": "FUAM", "snr": 12.0, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.428, "battery_level": 11, "channel_utilization": 17.7, "uptime_seconds": 19864, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1011.24, "iaq": 23, "relative_humidity": 81.21, "temperature": 19.5}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5391, "long_name": "Silver Beaver", "next_hop": 0, "num": "0x72b29372", "position": {"altitude": 1302, "latitude": 33.3054, "location_source": "LOC_INTERNAL", "longitude": -107.445822, "time_offset_sec": 5586}, "public_key_hex": "f90d811e288681ef2a86d709b512b1cc9e8e624d92909a6d231d1af70a0d974b", "role": "CLIENT", "short_name": "S1Y7", "snr": 10.79, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.455, "battery_level": 68, "channel_utilization": 35.0, "uptime_seconds": 2721, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1491, "long_name": "Canyon Bronco K18XM", "next_hop": 0, "num": "0x72d1bb31", "position": {"altitude": 930, "latitude": 33.770664, "location_source": "LOC_INTERNAL", "longitude": -107.936809, "time_offset_sec": 1728}, "public_key_hex": "7db1ca17780b8192e9ce77f648a76bdac0335f6b491e361061312ffe945dd6ec", "role": "CLIENT", "short_name": "CS1D", "snr": 4.57, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 711, "long_name": "Hidden Adder", "next_hop": 204, "num": "0x72d3c9b3", "position": {"altitude": 1352, "latitude": 33.038897, "location_source": "LOC_INTERNAL", "longitude": -106.605698, "time_offset_sec": 769}, "public_key_hex": "3d9d763bb87710e4ae804eb54774f01048d3f31ca450d0a73e92a4711573f388", "role": "CLIENT", "short_name": "HS9T", "snr": 7.44, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 5321, "long_name": "Forest Salmon", "next_hop": 0, "num": "0x737bd88b", "position": null, "public_key_hex": "e75e5375a6229f57a6512e866a3623e41e94cd991cc0b97d8f2001f99bdbc078", "role": "CLIENT", "short_name": "F33J", "snr": 9.6, "status": null, "telemetry": {"air_util_tx": 0.409, "battery_level": 33, "channel_utilization": 26.34, "uptime_seconds": 45767, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3514, "long_name": "Silent Cobra", "next_hop": 235, "num": "0x747b0108", "position": {"altitude": 1113, "latitude": 33.698573, "location_source": "LOC_INTERNAL", "longitude": -107.099209, "time_offset_sec": 3804}, "public_key_hex": "dad3762673a4b521a6bd3333dab99ceed5937dc9e9d8600368bfdb4548aea00d", "role": "CLIENT", "short_name": "SOJ4", "snr": 5.22, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.85, "iaq": 0, "relative_humidity": 47.93, "temperature": 37.57}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1330, "long_name": "Sharp Tortoise", "next_hop": 58, "num": "0x7482140b", "position": {"altitude": 1593, "latitude": 32.909753, "location_source": "LOC_INTERNAL", "longitude": -106.843692, "time_offset_sec": 1452}, "public_key_hex": "", "role": "CLIENT", "short_name": "🐝", "snr": 7.68, "status": null, "telemetry": {"air_util_tx": 0.68, "battery_level": 71, "channel_utilization": 0.74, "uptime_seconds": 72228, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1092, "long_name": "Brave Mustang", "next_hop": 244, "num": "0x74ebe68c", "position": {"altitude": 1066, "latitude": 32.57088, "location_source": "LOC_INTERNAL", "longitude": -107.594198, "time_offset_sec": 1171}, "public_key_hex": "d9c6c924ebe255ffe2f30f4c2b9eb082ee58d1a1a262561b187b55266fcbb44d", "role": "CLIENT", "short_name": "BE1L", "snr": 5.5, "status": null, "telemetry": {"air_util_tx": 0.452, "battery_level": 22, "channel_utilization": 13.31, "uptime_seconds": 31995, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1441, "long_name": "Desert Pony", "next_hop": 95, "num": "0x75819b22", "position": {"altitude": 1642, "latitude": 32.7144, "location_source": "LOC_INTERNAL", "longitude": -107.293768, "time_offset_sec": 1496}, "public_key_hex": "1274ed4d155eeb387db100a0e9580f1aabcda5da48109168d5e07b54911f21bb", "role": "CLIENT", "short_name": "DL22", "snr": 7.75, "status": null, "telemetry": {"air_util_tx": 0.226, "battery_level": 94, "channel_utilization": 4.3, "uptime_seconds": 315832, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 996.53, "iaq": 40, "relative_humidity": 50.26, "temperature": 22.3}, "hops_away": 5, "hw_model": "RAK4631", "last_heard_offset_sec": 2362, "long_name": "Smooth Tortoise", "next_hop": 26, "num": "0x7592642f", "position": null, "public_key_hex": "38a59621a06369e8a4d9663f50092f8aecc501ca6dd7352ac42412bc24596225", "role": "CLIENT", "short_name": "SUJT", "snr": 11.04, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.582, "battery_level": 30, "channel_utilization": 3.46, "uptime_seconds": 106194, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.05, "iaq": 62, "relative_humidity": 20.66, "temperature": 9.6}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1159, "long_name": "Tall Coyote W55FU", "next_hop": 0, "num": "0x75a976fd", "position": {"altitude": 1390, "latitude": 33.082615, "location_source": "LOC_INTERNAL", "longitude": -107.834629, "time_offset_sec": 1184}, "public_key_hex": "c8e6356f5c2ec5c35fa7af9760899bdbd760dd00b9a9f086c4825488224473eb", "role": "CLIENT", "short_name": "TPUY", "snr": 0.29, "status": null, "telemetry": {"air_util_tx": 0.858, "battery_level": 24, "channel_utilization": 18.79, "uptime_seconds": 65427, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 180, "long_name": "Drowsy Shark", "next_hop": 0, "num": "0x763c51ea", "position": {"altitude": 1481, "latitude": 33.016754, "location_source": "LOC_INTERNAL", "longitude": -107.701299, "time_offset_sec": 191}, "public_key_hex": "a2baacc65e28e34b920f93d0abc6f84e2ae418686c165383044e49207f2b4a0b", "role": "CLIENT", "short_name": "D9ZH", "snr": 5.81, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.31, "iaq": 47, "relative_humidity": 36.93, "temperature": 17.88}, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 4592, "long_name": "Sneaky Cobra", "next_hop": 220, "num": "0x76635e54", "position": {"altitude": 1344, "latitude": 32.812135, "location_source": "LOC_INTERNAL", "longitude": -106.951662, "time_offset_sec": 4618}, "public_key_hex": "c3e38660851abef6d4d6c2c8601ab5bb35ff68b6c4b177a60e8d04e407fe33ad", "role": "CLIENT", "short_name": "SPPX", "snr": 6.52, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1060, "long_name": "Dusk Bluff", "next_hop": 0, "num": "0x766e4007", "position": {"altitude": 1436, "latitude": 33.156057, "location_source": "LOC_INTERNAL", "longitude": -106.498656, "time_offset_sec": 1208}, "public_key_hex": "37169d6a09d2b2db79c9b8b86c17d07c152858ee0b7525ebd9016fd0267202d7", "role": "ROUTER", "short_name": "DC7V", "snr": 0.68, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.551, "battery_level": 53, "channel_utilization": 9.38, "uptime_seconds": 33734, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": {"barometric_pressure": 1003.64, "iaq": 0, "relative_humidity": 50.28, "temperature": 34.94}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 16708, "long_name": "River Badger AE6LR", "next_hop": 0, "num": "0x7688d7e7", "position": {"altitude": 1237, "latitude": 32.381937, "location_source": "LOC_INTERNAL", "longitude": -107.515037, "time_offset_sec": 16823}, "public_key_hex": "41c0a3c812d4ec19ff44c06827616f99d7abbdd73833cfd91bcce1ad42efe016", "role": "CLIENT", "short_name": "RRCQ", "snr": 12.0, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.406, "battery_level": 77, "channel_utilization": 25.9, "uptime_seconds": 107176, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 18686, "long_name": "Lost Hawk", "next_hop": 0, "num": "0x768e79f3", "position": {"altitude": 1538, "latitude": 33.305402, "location_source": "LOC_INTERNAL", "longitude": -107.245097, "time_offset_sec": 18952}, "public_key_hex": "96035f83127debc01a20a7a4b63c31312fea59bd4370277ea568d84df8395412", "role": "ROUTER", "short_name": "LWVP", "snr": 6.53, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.87, "iaq": 61, "relative_humidity": 37.63, "temperature": 29.91}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 6606, "long_name": "Misty Trout", "next_hop": 150, "num": "0x770fc426", "position": {"altitude": 1020, "latitude": 33.669337, "location_source": "LOC_INTERNAL", "longitude": -106.755362, "time_offset_sec": 6878}, "public_key_hex": "930cdc2003db4ef1733bf34ac04a138c23c7bb8a2ed96066c855426fca86a6d9", "role": "CLIENT", "short_name": "MSZG", "snr": -3.72, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.747, "battery_level": 24, "channel_utilization": 16.84, "uptime_seconds": 38808, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 2359, "long_name": "Sleepy Juniper", "next_hop": 0, "num": "0x771bf8a9", "position": null, "public_key_hex": "23649b698d1a3c04a949bd6be3103325b8968dac4c3bc37384daeee403e2f4c7", "role": "TAK_TRACKER", "short_name": "S4XI", "snr": 9.37, "status": null, "telemetry": {"air_util_tx": 1.043, "battery_level": 39, "channel_utilization": 23.22, "uptime_seconds": 81536, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 1611, "long_name": "Short Beaver", "next_hop": 0, "num": "0x776c4708", "position": {"altitude": 1255, "latitude": 33.763903, "location_source": "LOC_INTERNAL", "longitude": -106.466717, "time_offset_sec": 1794}, "public_key_hex": "94b30977c1b425535001b8805ad1ec5a0e84248f5fb9131898e21fe072f9c47f", "role": "CLIENT", "short_name": "SNUD", "snr": 8.49, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.003, "battery_level": 84, "channel_utilization": 27.72, "uptime_seconds": 127743, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 2426, "long_name": "Solar Owl", "next_hop": 182, "num": "0x777ac886", "position": {"altitude": 1439, "latitude": 32.592907, "location_source": "LOC_INTERNAL", "longitude": -108.330483, "time_offset_sec": 2636}, "public_key_hex": "eb0873f4bd12f8341cb06ab37dc50cae3f2a8bb9a8cfa28e34dc54d5d2d452ef", "role": "CLIENT", "short_name": "STDJ", "snr": 11.84, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 801, "long_name": "Bright Arroyo", "next_hop": 0, "num": "0x77cfa02a", "position": {"altitude": 1750, "latitude": 33.411219, "location_source": "LOC_INTERNAL", "longitude": -107.565288, "time_offset_sec": 1093}, "public_key_hex": "3e34f1df51c60337e3c4a9c95ee60b7c6569afa18657d7f39074699c28871df3", "role": "SENSOR", "short_name": "BE6T", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.178, "battery_level": 39, "channel_utilization": 5.08, "uptime_seconds": 16174, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 54, "long_name": "Lunar Lynx", "next_hop": 121, "num": "0x780294e9", "position": {"altitude": 1659, "latitude": 33.962318, "location_source": "LOC_INTERNAL", "longitude": -107.544268, "time_offset_sec": 272}, "public_key_hex": "36a4b7e10bf625cc0d160a943b884634f5706f0346e8c47c8f7472f8e35d06f7", "role": "CLIENT", "short_name": "LXIE", "snr": 2.4, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 8650, "long_name": "Iron Heron", "next_hop": 0, "num": "0x780d26b0", "position": {"altitude": 1282, "latitude": 31.893481, "location_source": "LOC_INTERNAL", "longitude": -107.055716, "time_offset_sec": 8868}, "public_key_hex": "4d12ba69521f0d49c49e3493c9cd972617c2bb8a21c5f6e25880f232ed3b15b7", "role": "CLIENT", "short_name": "I1HT", "snr": 6.74, "status": null, "telemetry": {"air_util_tx": 0.545, "battery_level": 57, "channel_utilization": 6.47, "uptime_seconds": 131248, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3022, "long_name": "Brave Pike", "next_hop": 14, "num": "0x78496d44", "position": {"altitude": 1186, "latitude": 32.456601, "location_source": "LOC_INTERNAL", "longitude": -107.308159, "time_offset_sec": 3076}, "public_key_hex": "29ec8065252760321e5f90962332bf1efb623e658da09d3dfc645d1fae66edff", "role": "ROUTER", "short_name": "BQFF", "snr": -0.68, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1519, "long_name": "Tiny Colt", "next_hop": 0, "num": "0x785ece6e", "position": null, "public_key_hex": "7dfbd80205fbaf4ddff7df764b803f0005634acb464f15225ad6aa34312ea8d3", "role": "CLIENT", "short_name": "TVAA", "snr": 5.55, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 253, "long_name": "Misty Shark", "next_hop": 123, "num": "0x78b1b84d", "position": {"altitude": 1392, "latitude": 33.329327, "location_source": "LOC_INTERNAL", "longitude": -107.092506, "time_offset_sec": 305}, "public_key_hex": "b6ede5d696727422a7c14921b90c89447fa29f5b891055f8115646aa4c006a86", "role": "CLIENT", "short_name": "MF0Y", "snr": 4.07, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 2345, "long_name": "Happy Arroyo", "next_hop": 0, "num": "0x790a2d18", "position": {"altitude": 998, "latitude": 32.548507, "location_source": "LOC_INTERNAL", "longitude": -107.187949, "time_offset_sec": 2571}, "public_key_hex": "c1ddda5281f7fc5380a8b40685bf0de8b4e7013e437cea71c0e3b8b1a6bf9859", "role": "CLIENT", "short_name": "🌵", "snr": 1.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1518, "long_name": "Stone Mesa", "next_hop": 0, "num": "0x796bac87", "position": {"altitude": 1532, "latitude": 33.331451, "location_source": "LOC_INTERNAL", "longitude": -107.20749, "time_offset_sec": 1780}, "public_key_hex": "11a1106709cbbe68eaea431f5e1ac3f48426a89045d7437e2be1b0b8a5a32833", "role": "CLIENT_HIDDEN", "short_name": "S0E7", "snr": -0.62, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.559, "battery_level": 25, "channel_utilization": 8.11, "uptime_seconds": 53807, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 3964, "long_name": "Rough Squirrel", "next_hop": 0, "num": "0x79c7fc54", "position": {"altitude": 1184, "latitude": 33.847673, "location_source": "LOC_INTERNAL", "longitude": -108.399321, "time_offset_sec": 4180}, "public_key_hex": "e1128e0570df2d4c6c06b509ced92d7866f742c8bb5acd5d2bae222907026f91", "role": "CLIENT", "short_name": "🌙", "snr": 10.86, "status": null, "telemetry": {"air_util_tx": 0.064, "battery_level": 53, "channel_utilization": 9.05, "uptime_seconds": 44833, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.35, "iaq": 0, "relative_humidity": 66.53, "temperature": 19.84}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3455, "long_name": "Hidden Shark", "next_hop": 0, "num": "0x79cd3168", "position": {"altitude": 1341, "latitude": 32.735161, "location_source": "LOC_INTERNAL", "longitude": -106.941718, "time_offset_sec": 3596}, "public_key_hex": "fd66c9774c563a92c870ae6bed5b6c15f0cf0ac084cbf32a808f6bca6722dd8b", "role": "CLIENT", "short_name": "H4VJ", "snr": 4.53, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1671, "long_name": "Shady Mesa", "next_hop": 0, "num": "0x79fc6f8a", "position": null, "public_key_hex": "b320b94d3ccdee790157a8246ea0cc68e5895bd513718f81d6966fb017b47f80", "role": "CLIENT", "short_name": "SFKE", "snr": 4.16, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 9430, "long_name": "Quick Cactus", "next_hop": 136, "num": "0x7a37ec20", "position": {"altitude": 1345, "latitude": 33.129057, "location_source": "LOC_INTERNAL", "longitude": -107.822688, "time_offset_sec": 9676}, "public_key_hex": "a193c1c68016952a5df22fa46a732a0ea80e4a48deb6885d347b682254b31a31", "role": "CLIENT", "short_name": "QYEE", "snr": 7.7, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.338, "battery_level": 19, "channel_utilization": 6.92, "uptime_seconds": 94670, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 707, "long_name": "Old Salmon", "next_hop": 2, "num": "0x7a4281ac", "position": null, "public_key_hex": "af9afb469b819caf92b67458302e74913fa1c1b6dc88d609d214ad58d57c2ef4", "role": "TAK", "short_name": "OHVD", "snr": 5.82, "status": null, "telemetry": {"air_util_tx": 0.285, "battery_level": 36, "channel_utilization": 9.9, "uptime_seconds": 227163, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 253, "long_name": "Giant Oak", "next_hop": 0, "num": "0x7a623dbf", "position": null, "public_key_hex": "ba3ad195e706e514fe3959becb2fd6597ceb8568d36346aa8b4508de8117b30f", "role": "ROUTER", "short_name": "GGR6", "snr": 2.92, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.421, "battery_level": 29, "channel_utilization": 0.77, "uptime_seconds": 155995, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1624, "long_name": "Whispering Yucca", "next_hop": 133, "num": "0x7afc3bdf", "position": {"altitude": 941, "latitude": 32.651888, "location_source": "LOC_INTERNAL", "longitude": -107.339859, "time_offset_sec": 1856}, "public_key_hex": "", "role": "CLIENT_HIDDEN", "short_name": "W4IF", "snr": 9.03, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "XIAO_NRF52_KIT", "last_heard_offset_sec": 232, "long_name": "Blue Seal", "next_hop": 0, "num": "0x7b03173d", "position": {"altitude": 1281, "latitude": 32.477974, "location_source": "LOC_INTERNAL", "longitude": -106.900437, "time_offset_sec": 327}, "public_key_hex": "ab4e63b10fbced513a64d35b04cec016deeb42571ae924b08766be75c75e43fd", "role": "CLIENT", "short_name": "BS9G", "snr": 3.11, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.137, "battery_level": 27, "channel_utilization": 8.86, "uptime_seconds": 7446, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 8541, "long_name": "Lone Stag", "next_hop": 0, "num": "0x7b14a1a4", "position": {"altitude": 1618, "latitude": 32.00989, "location_source": "LOC_INTERNAL", "longitude": -106.960274, "time_offset_sec": 8759}, "public_key_hex": "b708eaa3003a627f770f3195d5f12d3aed2a7c6798ad50a5c6af1d9615a68d56", "role": "CLIENT_HIDDEN", "short_name": "LBRG", "snr": -2.17, "status": null, "telemetry": {"air_util_tx": 0.396, "battery_level": 46, "channel_utilization": 19.14, "uptime_seconds": 80209, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 15532, "long_name": "Stone Cactus", "next_hop": 0, "num": "0x7b85c491", "position": {"altitude": 948, "latitude": 32.596386, "location_source": "LOC_INTERNAL", "longitude": -107.908213, "time_offset_sec": 15772}, "public_key_hex": "62134c353256dadbae4f680097ea9caabb1680b3659cc1f02c7e4fb0282bf43a", "role": "CLIENT", "short_name": "S7WW", "snr": 5.23, "status": null, "telemetry": {"air_util_tx": 0.344, "battery_level": 100, "channel_utilization": 2.77, "uptime_seconds": 153615, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 748, "long_name": "Dawn Elk", "next_hop": 0, "num": "0x7b95d6c4", "position": {"altitude": 1375, "latitude": 32.814726, "location_source": "LOC_INTERNAL", "longitude": -106.699569, "time_offset_sec": 989}, "public_key_hex": "fef6fe0964937dedb0900268ee852637e0059457bf110d028629e4ccf5f8a717", "role": "CLIENT", "short_name": "🦋", "snr": 5.62, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.362, "battery_level": 62, "channel_utilization": 4.13, "uptime_seconds": 83278, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1868, "long_name": "Floating Cactus", "next_hop": 78, "num": "0x7b9832ba", "position": {"altitude": 1422, "latitude": 34.760079, "location_source": "LOC_INTERNAL", "longitude": -107.083786, "time_offset_sec": 2123}, "public_key_hex": "f3b168f6cb1dc13894a84e6bcea3815232cfe5b60279401832cc51b2cbbf5200", "role": "CLIENT", "short_name": "FZ3S", "snr": 7.67, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 996.03, "iaq": 103, "relative_humidity": 43.23, "temperature": 27.85}, "hops_away": 2, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 2349, "long_name": "Soft Coyote", "next_hop": 141, "num": "0x7c4cf82e", "position": null, "public_key_hex": "39c6faeae76d6831292d9828d401b5247709ae3353bc99be2be6217976f15711", "role": "CLIENT", "short_name": "S34Q", "snr": 0.27, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.144, "battery_level": 79, "channel_utilization": 11.78, "uptime_seconds": 90955, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.12, "iaq": 55, "relative_humidity": 50.17, "temperature": 20.67}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 661, "long_name": "Forest Dolphin", "next_hop": 0, "num": "0x7cb88352", "position": {"altitude": 857, "latitude": 33.336533, "location_source": "LOC_INTERNAL", "longitude": -107.606652, "time_offset_sec": 706}, "public_key_hex": "cdcaa7a375d26a24dbcb6d7685fc62a5562c1536f9520e0d85396f5710c90aa8", "role": "CLIENT", "short_name": "FN6X", "snr": 0.74, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.37, "battery_level": 61, "channel_utilization": 9.89, "uptime_seconds": 372510, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 8676, "long_name": "Brave Iguana", "next_hop": 0, "num": "0x7cf15b3e", "position": {"altitude": 1886, "latitude": 33.819257, "location_source": "LOC_INTERNAL", "longitude": -107.739619, "time_offset_sec": 8913}, "public_key_hex": "6a9c754541b4c2c875a2aadbe922f57c641582c36a2a9b89c8df1e3d22c37eff", "role": "CLIENT", "short_name": "B4LT", "snr": 5.51, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 1.236, "battery_level": 45, "channel_utilization": 10.01, "uptime_seconds": 137576, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2052, "long_name": "Whispering Doe", "next_hop": 0, "num": "0x7cf4966c", "position": {"altitude": 874, "latitude": 32.846001, "location_source": "LOC_INTERNAL", "longitude": -105.956593, "time_offset_sec": 2166}, "public_key_hex": "", "role": "CLIENT", "short_name": "W99D", "snr": 3.4, "status": null, "telemetry": {"air_util_tx": 0.56, "battery_level": 52, "channel_utilization": 3.85, "uptime_seconds": 38863, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 902, "long_name": "White Raven AB5MG", "next_hop": 13, "num": "0x7cfd5051", "position": null, "public_key_hex": "9fa2378b9ce7198261d695d5316c1a3a011cbff32b7b4da3384cff071f968cd0", "role": "CLIENT", "short_name": "WPPH", "snr": 8.19, "status": null, "telemetry": {"air_util_tx": 0.506, "battery_level": 35, "channel_utilization": 18.87, "uptime_seconds": 1244, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1198, "long_name": "Silver Heron", "next_hop": 89, "num": "0x7d3454ac", "position": {"altitude": 1641, "latitude": 33.593007, "location_source": "LOC_INTERNAL", "longitude": -106.858867, "time_offset_sec": 1487}, "public_key_hex": "604c7af1d4a56fc9dfbdd54eeb7226cf7fc5656527578340139441eb8f043dbc", "role": "CLIENT", "short_name": "🌊", "snr": 9.21, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.806, "battery_level": 89, "channel_utilization": 14.05, "uptime_seconds": 89908, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 828, "long_name": "Red Whale", "next_hop": 0, "num": "0x7d3bc64f", "position": {"altitude": 1496, "latitude": 34.091942, "location_source": "LOC_INTERNAL", "longitude": -107.54737, "time_offset_sec": 950}, "public_key_hex": "ba0b75e6258532e1ad5ceb53688bfb9b4d3cecd04313dddc9ae54137b24d6911", "role": "CLIENT", "short_name": "RNTA", "snr": 7.01, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3539, "long_name": "Iron Hawk", "next_hop": 0, "num": "0x7d47b6f8", "position": {"altitude": 1822, "latitude": 33.069863, "location_source": "LOC_INTERNAL", "longitude": -107.612415, "time_offset_sec": 3805}, "public_key_hex": "71715cfe83cd82673ad031905c7b9eec55503ded0490c5ca8f0046d378f539c6", "role": "ROUTER_LATE", "short_name": "IMDI", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.067, "battery_level": 39, "channel_utilization": 0.73, "uptime_seconds": 110889, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 6745, "long_name": "Copper Pine", "next_hop": 0, "num": "0x7daa6446", "position": {"altitude": 1432, "latitude": 32.883057, "location_source": "LOC_INTERNAL", "longitude": -107.857615, "time_offset_sec": 6792}, "public_key_hex": "228c311d5844991c731e338c97905d4686b4c5378a15a6139e7e304ada2e6387", "role": "CLIENT", "short_name": "C25O", "snr": 7.52, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.331, "battery_level": 65, "channel_utilization": 21.64, "uptime_seconds": 159410, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1289, "long_name": "Wild Moose", "next_hop": 0, "num": "0x7e293a9a", "position": null, "public_key_hex": "ef0471274ab1c232c164127963b28415db71aab7ad8988010856ba77ee9363de", "role": "TRACKER", "short_name": "WJXL", "snr": 2.36, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.295, "battery_level": 94, "channel_utilization": 19.23, "uptime_seconds": 148622, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5811, "long_name": "Silver Cobra", "next_hop": 80, "num": "0x7e98136c", "position": {"altitude": 1176, "latitude": 33.644568, "location_source": "LOC_INTERNAL", "longitude": -107.51554, "time_offset_sec": 6021}, "public_key_hex": "d7d36933041896a386b15c785f552e08f16d4b3ee6687e6dc1e2d38aaf8b0a7d", "role": "CLIENT", "short_name": "SQ5M", "snr": 8.33, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.554, "battery_level": 85, "channel_utilization": 5.06, "uptime_seconds": 30228, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 704, "long_name": "Lone Mesa", "next_hop": 0, "num": "0x7ee205cc", "position": {"altitude": 1332, "latitude": 33.586042, "location_source": "LOC_INTERNAL", "longitude": -106.953368, "time_offset_sec": 910}, "public_key_hex": "46f79ed30865e398a21c801f213cfc61e58902b85278f7d515b82b8e2e3adba6", "role": "CLIENT", "short_name": "🦂", "snr": 10.02, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.145, "battery_level": 90, "channel_utilization": 4.22, "uptime_seconds": 219179, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 812, "long_name": "Giant Hawk", "next_hop": 125, "num": "0x7ef09a0c", "position": {"altitude": 1360, "latitude": 33.718694, "location_source": "LOC_INTERNAL", "longitude": -106.810279, "time_offset_sec": 1080}, "public_key_hex": "6a912d9ac3abc3be3bded33ebdc06539f3fd4099e33b3638b20bc9d4278749ab", "role": "CLIENT", "short_name": "GSLL", "snr": 7.27, "status": null, "telemetry": {"air_util_tx": 1.274, "battery_level": 94, "channel_utilization": 13.89, "uptime_seconds": 34740, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.47, "iaq": 77, "relative_humidity": 57.86, "temperature": 19.64}, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1465, "long_name": "Quick Viper", "next_hop": 254, "num": "0x7f2c9c94", "position": {"altitude": 1504, "latitude": 32.929298, "location_source": "LOC_INTERNAL", "longitude": -107.529246, "time_offset_sec": 1691}, "public_key_hex": "98da664d4852437c7b699b87e5214d9433223984ac1a39af47f3b0e20ef19703", "role": "CLIENT", "short_name": "🦋", "snr": 1.03, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 1370, "long_name": "Old Cobra", "next_hop": 96, "num": "0x7f3ce610", "position": {"altitude": 1386, "latitude": 32.584843, "location_source": "LOC_INTERNAL", "longitude": -106.99496, "time_offset_sec": 1565}, "public_key_hex": "46a346b7947ec2d961097e2481c3cd2a29897afb36065ace08b3affac3b0ec4e", "role": "CLIENT", "short_name": "OMR8", "snr": 1.81, "status": null, "telemetry": {"air_util_tx": 0.343, "battery_level": 101, "channel_utilization": 5.13, "uptime_seconds": 184861, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 3375, "long_name": "Copper Mole", "next_hop": 0, "num": "0x7fa9346f", "position": {"altitude": 1088, "latitude": 34.023624, "location_source": "LOC_INTERNAL", "longitude": -107.080344, "time_offset_sec": 3654}, "public_key_hex": "7f351bcc32b693e11f10c794f30fb986a1f701ac9ff394960f3e65af8b4c1e08", "role": "CLIENT", "short_name": "CSX2", "snr": 5.51, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.156, "battery_level": 31, "channel_utilization": 4.14, "uptime_seconds": 126619, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 986.27, "iaq": 104, "relative_humidity": 61.42, "temperature": 17.96}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2608, "long_name": "Quick Beaver", "next_hop": 0, "num": "0x7fb6b696", "position": {"altitude": 1176, "latitude": 33.375736, "location_source": "LOC_INTERNAL", "longitude": -107.702107, "time_offset_sec": 2853}, "public_key_hex": "bbe249baf336d614c31e98d0c3c773de70725991c1244746ab04fcdea1afe730", "role": "CLIENT", "short_name": "Q8U6", "snr": 1.83, "status": null, "telemetry": {"air_util_tx": 0.264, "battery_level": 99, "channel_utilization": 3.68, "uptime_seconds": 59962, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 5106, "long_name": "Sunny Adder", "next_hop": 0, "num": "0x7fd909ad", "position": {"altitude": 1437, "latitude": 32.788073, "location_source": "LOC_INTERNAL", "longitude": -107.233327, "time_offset_sec": 5249}, "public_key_hex": "80c21b4a6f7e3955bfeb3626b0e7d8af8e884e4afd5af927ed6d4787d43d0131", "role": "CLIENT", "short_name": "SDJF", "snr": 8.38, "status": {"status": "nominal"}, "telemetry": null} diff --git a/test/fixtures/nodedb/seed_v25_1000.jsonl b/test/fixtures/nodedb/seed_v25_1000.jsonl new file mode 100644 index 00000000000..988438ffae2 --- /dev/null +++ b/test/fixtures/nodedb/seed_v25_1000.jsonl @@ -0,0 +1,1001 @@ +{"_meta": {"centroid": [33.1284, -107.2528], "count": 1000, "coverage": {"environment": 0.25, "position": 0.85, "status": 0.4, "telemetry": 0.7}, "generated_at_iso": "1970-08-23T11:55:13Z", "last_heard_max_sec": 604800, "last_heard_mean_sec": 3600, "my_node_num_excluded": null, "seed": 20260513, "spread_km": 60.0, "version": 25}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3886, "long_name": "New Heron KQ2PI", "next_hop": 0, "num": "0x0018ea82", "position": {"altitude": 1728, "latitude": 32.329478, "location_source": "LOC_INTERNAL", "longitude": -107.412893, "time_offset_sec": 3961}, "public_key_hex": "556c4acc33c0137569701385a7220a54cedd9d718df628c51f298b6e347824b9", "role": "CLIENT", "short_name": "NL7L", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.437, "battery_level": 41, "channel_utilization": 0.54, "uptime_seconds": 34583, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 9287, "long_name": "Burning Cobra", "next_hop": 0, "num": "0x00300125", "position": {"altitude": 1343, "latitude": 32.854295, "location_source": "LOC_INTERNAL", "longitude": -107.450863, "time_offset_sec": 9329}, "public_key_hex": "", "role": "CLIENT", "short_name": "BX20", "snr": 5.85, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.034, "battery_level": 54, "channel_utilization": 11.32, "uptime_seconds": 58750, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4098, "long_name": "Forest Bronco", "next_hop": 2, "num": "0x0054b786", "position": {"altitude": 1636, "latitude": 32.644708, "location_source": "LOC_INTERNAL", "longitude": -107.199247, "time_offset_sec": 4119}, "public_key_hex": "78c28b2ce186efa0f5e46cb4a81c24eb464885c0b65586daeb920017be5bc2e8", "role": "CLIENT", "short_name": "FWSJ", "snr": 7.62, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.008, "battery_level": 95, "channel_utilization": 12.41, "uptime_seconds": 58300, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 10555, "long_name": "White Eagle", "next_hop": 64, "num": "0x00553133", "position": {"altitude": 1544, "latitude": 33.014401, "location_source": "LOC_INTERNAL", "longitude": -106.946759, "time_offset_sec": 10844}, "public_key_hex": "612a38fa52892ddabb82e2929e653ce405e5310abb3f48d433b7e215fb06e453", "role": "CLIENT", "short_name": "🌊", "snr": 6.55, "status": null, "telemetry": {"air_util_tx": 0.48, "battery_level": 68, "channel_utilization": 7.74, "uptime_seconds": 37510, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1507, "long_name": "Wild Mole", "next_hop": 0, "num": "0x008061cb", "position": null, "public_key_hex": "60a35007ebe68d6dff2907889dadadd77a4b268d57dab7538298757a71844af7", "role": "CLIENT", "short_name": "WI62", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.427, "battery_level": 50, "channel_utilization": 7.05, "uptime_seconds": 87956, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2681, "long_name": "Whispering Arroyo", "next_hop": 0, "num": "0x009e2ae4", "position": {"altitude": 1235, "latitude": 33.233268, "location_source": "LOC_INTERNAL", "longitude": -107.714014, "time_offset_sec": 2710}, "public_key_hex": "37bd45a80b70495242dba7a4cf89b42a7799e87cb265d15357512675a443f573", "role": "CLIENT", "short_name": "W6HS", "snr": 8.59, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 999.79, "iaq": 68, "relative_humidity": 40.94, "temperature": 15.51}, "hops_away": 2, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 201, "long_name": "Roving Heron", "next_hop": 129, "num": "0x00d976e0", "position": {"altitude": 1494, "latitude": 32.96007, "location_source": "LOC_INTERNAL", "longitude": -106.347741, "time_offset_sec": 336}, "public_key_hex": "f156625f2952b16c3d2e18ffbfbbac9ac014138b714cb5d2a9cb627edd033a77", "role": "CLIENT", "short_name": "R4XG", "snr": 1.42, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 60, "long_name": "White Coyote", "next_hop": 0, "num": "0x00d99999", "position": {"altitude": 1270, "latitude": 33.343263, "location_source": "LOC_INTERNAL", "longitude": -107.399774, "time_offset_sec": 99}, "public_key_hex": "f6e4d9f6f9de608851b0d708bf3ac26293c70efa701ed460b679809c649b7644", "role": "CLIENT", "short_name": "WQBU", "snr": 4.88, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.51, "battery_level": 52, "channel_utilization": 21.49, "uptime_seconds": 47993, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 438, "long_name": "Sky Whale", "next_hop": 0, "num": "0x00ef287b", "position": {"altitude": 1447, "latitude": 33.737505, "location_source": "LOC_INTERNAL", "longitude": -107.028242, "time_offset_sec": 489}, "public_key_hex": "627e54450e2b23c218e491550f1f266128714e30dc1290443534a4479f95b265", "role": "CLIENT", "short_name": "S0ZS", "snr": 3.72, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.484, "battery_level": 86, "channel_utilization": 5.11, "uptime_seconds": 14624, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 6800, "long_name": "Lost Pony", "next_hop": 226, "num": "0x00fc66ce", "position": null, "public_key_hex": "ec93e603d0e2161a51a842d2e6119882025ad4215f1867b12b8a9fdf27b7400f", "role": "CLIENT", "short_name": "LDZF", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 10298, "long_name": "Silver Adder", "next_hop": 189, "num": "0x013f6aa0", "position": {"altitude": 1326, "latitude": 33.392785, "location_source": "LOC_INTERNAL", "longitude": -106.874834, "time_offset_sec": 10316}, "public_key_hex": "7c0bd41ce39996b4d6ddd3797c1d6c40dafad2a18c84075d036e039e99ffc92d", "role": "CLIENT", "short_name": "SLRR", "snr": 3.26, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 15228, "long_name": "Silver Salmon N52VM", "next_hop": 0, "num": "0x014864b6", "position": {"altitude": 1676, "latitude": 33.588865, "location_source": "LOC_INTERNAL", "longitude": -107.326855, "time_offset_sec": 15243}, "public_key_hex": "ce602a33050faa60873db4e6a6867ea6942ce45601af20a3aa1b5f78cba8fcbf", "role": "CLIENT", "short_name": "🦂", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 738, "long_name": "Smooth Fox", "next_hop": 187, "num": "0x017d79d7", "position": {"altitude": 1657, "latitude": 32.578894, "location_source": "LOC_INTERNAL", "longitude": -107.613208, "time_offset_sec": 885}, "public_key_hex": "d577675c306c100552bced8a0cdbea97644cde03af440d5ab12264895e1a920e", "role": "CLIENT", "short_name": "SR6Z", "snr": 5.73, "status": null, "telemetry": {"air_util_tx": 0.162, "battery_level": 12, "channel_utilization": 23.79, "uptime_seconds": 226441, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1029.82, "iaq": 65, "relative_humidity": 67.05, "temperature": 12.97}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 6213, "long_name": "White Squirrel", "next_hop": 0, "num": "0x01d673e5", "position": null, "public_key_hex": "fd11f55b274acf27422720561cde832c2a359d877bac92e053df77671a0d8397", "role": "CLIENT", "short_name": "WP3B", "snr": 0.82, "status": null, "telemetry": {"air_util_tx": 1.819, "battery_level": 33, "channel_utilization": 11.56, "uptime_seconds": 16470, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.92, "iaq": 71, "relative_humidity": 52.17, "temperature": 18.97}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2624, "long_name": "Shady Gecko", "next_hop": 0, "num": "0x01d9aff3", "position": {"altitude": 1450, "latitude": 33.218014, "location_source": "LOC_INTERNAL", "longitude": -107.044828, "time_offset_sec": 2688}, "public_key_hex": "5c9045a1734522f25d78bfe2c6eb1634f08bf4d1ee172ee0b3f25689613b72af", "role": "CLIENT", "short_name": "S0HI", "snr": 6.19, "status": null, "telemetry": {"air_util_tx": 0.069, "battery_level": 91, "channel_utilization": 5.1, "uptime_seconds": 67809, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1024.74, "iaq": 35, "relative_humidity": 35.55, "temperature": 30.09}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1651, "long_name": "New Dolphin", "next_hop": 0, "num": "0x01ef79d3", "position": null, "public_key_hex": "e0411df95cf5910c56f7a70634fcdd5a646c22184b1de7fc15366438107b8664", "role": "CLIENT", "short_name": "NB06", "snr": 4.03, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 10196, "long_name": "Sunny Squirrel", "next_hop": 0, "num": "0x01fe1484", "position": null, "public_key_hex": "b8655275e8c068bf8235e4c84d33a8d0cf7edf858dd553254bf3e4ae06ddac0a", "role": "ROUTER", "short_name": "SYLQ", "snr": 11.65, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 1906, "long_name": "Quick Cougar", "next_hop": 31, "num": "0x020f279e", "position": {"altitude": 1523, "latitude": 33.234318, "location_source": "LOC_INTERNAL", "longitude": -107.754129, "time_offset_sec": 2039}, "public_key_hex": "ea5de4eaa420f734e5368465c0bd075970e382fb106daa3d3bf20e5f5eefa405", "role": "CLIENT", "short_name": "Q4C3", "snr": 10.88, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.533, "battery_level": 74, "channel_utilization": 10.45, "uptime_seconds": 38813, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2238, "long_name": "Wandering Marmot", "next_hop": 0, "num": "0x026cb7fc", "position": {"altitude": 1474, "latitude": 33.591754, "location_source": "LOC_INTERNAL", "longitude": -107.44172, "time_offset_sec": 2514}, "public_key_hex": "", "role": "ROUTER", "short_name": "WOWH", "snr": 2.75, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1130, "long_name": "Bright Badger", "next_hop": 0, "num": "0x027c0019", "position": {"altitude": 1438, "latitude": 33.333338, "location_source": "LOC_INTERNAL", "longitude": -106.66606, "time_offset_sec": 1183}, "public_key_hex": "c18308a4879e0cf405705de625e7543ca0b4ae66877ac2df50d3021aa8d7a9c0", "role": "CLIENT", "short_name": "BQXO", "snr": 0.67, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.487, "battery_level": 59, "channel_utilization": 5.71, "uptime_seconds": 36439, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "M5STACK_C6L", "last_heard_offset_sec": 3908, "long_name": "Brave Eagle", "next_hop": 0, "num": "0x02c096a3", "position": {"altitude": 1307, "latitude": 32.804405, "location_source": "LOC_INTERNAL", "longitude": -107.023016, "time_offset_sec": 4014}, "public_key_hex": "1e4d13e7bda7eed1e876e3cdd710059231b5e176d41fc5bca33cb922cdebb715", "role": "CLIENT", "short_name": "B8Y2", "snr": 5.97, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7542, "long_name": "Sunny Phoenix", "next_hop": 130, "num": "0x02c77b0f", "position": {"altitude": 1476, "latitude": 34.768744, "location_source": "LOC_INTERNAL", "longitude": -108.528079, "time_offset_sec": 7653}, "public_key_hex": "", "role": "CLIENT", "short_name": "🐝", "snr": 1.82, "status": null, "telemetry": {"air_util_tx": 0.278, "battery_level": 16, "channel_utilization": 3.56, "uptime_seconds": 96462, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 9959, "long_name": "Stone Badger", "next_hop": 93, "num": "0x02dbfa4f", "position": null, "public_key_hex": "36e616a43bde1fb5c98e18c81f9768860498c5245736f0f989bd5b61d38ed04d", "role": "CLIENT", "short_name": "SB38", "snr": 7.89, "status": null, "telemetry": {"air_util_tx": 1.576, "battery_level": 41, "channel_utilization": 3.93, "uptime_seconds": 51676, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1018.08, "iaq": 118, "relative_humidity": 50.02, "temperature": 8.62}, "hops_away": 0, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 1462, "long_name": "Fast Fox", "next_hop": 0, "num": "0x02e6dcdb", "position": {"altitude": 1213, "latitude": 33.915398, "location_source": "LOC_INTERNAL", "longitude": -106.624529, "time_offset_sec": 1726}, "public_key_hex": "", "role": "ROUTER", "short_name": "FQN8", "snr": 1.43, "status": null, "telemetry": {"air_util_tx": 0.407, "battery_level": 54, "channel_utilization": 23.21, "uptime_seconds": 27073, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 750, "long_name": "Silver Hare", "next_hop": 0, "num": "0x02ffb880", "position": {"altitude": 1248, "latitude": 33.792731, "location_source": "LOC_INTERNAL", "longitude": -107.425586, "time_offset_sec": 880}, "public_key_hex": "617b947b8996acf9e1116495f2697a7695e3601ebd1a235983bb61bbb4292301", "role": "CLIENT", "short_name": "S304", "snr": 11.84, "status": null, "telemetry": {"air_util_tx": 1.404, "battery_level": 28, "channel_utilization": 16.58, "uptime_seconds": 26958, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 479, "long_name": "Sleepy Mamba", "next_hop": 198, "num": "0x0387da83", "position": {"altitude": 1402, "latitude": 32.899619, "location_source": "LOC_INTERNAL", "longitude": -107.802879, "time_offset_sec": 720}, "public_key_hex": "2716f5700f615b02a08d76246f7e4ee743fbe9e5969ff2587f112a994c02e449", "role": "CLIENT_HIDDEN", "short_name": "S01Z", "snr": 3.97, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.62, "battery_level": 101, "channel_utilization": 10.32, "uptime_seconds": 53051, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 307, "long_name": "Rough Aspen", "next_hop": 174, "num": "0x03a361fc", "position": null, "public_key_hex": "ba90209cd0c90129f8e653989cd1705df5f54fff69816513397fbdd66c706ccf", "role": "CLIENT", "short_name": "RYMN", "snr": 8.79, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 185, "long_name": "Happy Lynx", "next_hop": 0, "num": "0x03a5dcb8", "position": {"altitude": 1149, "latitude": 32.490172, "location_source": "LOC_INTERNAL", "longitude": -107.806815, "time_offset_sec": 257}, "public_key_hex": "3cd98fbf2b5a29ff7f451800eb6c88b5df6c6d0d909dc190cb3026ff98c9e50e", "role": "CLIENT", "short_name": "H4D1", "snr": 7.31, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 3267, "long_name": "New Tortoise", "next_hop": 0, "num": "0x03baf449", "position": {"altitude": 1480, "latitude": 32.525448, "location_source": "LOC_INTERNAL", "longitude": -106.831952, "time_offset_sec": 3512}, "public_key_hex": "e891841d8f148c9ecbd27f9a60427b0ca783444e250ab5dcfcb26d7d5604cb65", "role": "CLIENT", "short_name": "NDY5", "snr": 5.29, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.93, "iaq": 42, "relative_humidity": 39.29, "temperature": 22.33}, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 3733, "long_name": "Fast Pine", "next_hop": 138, "num": "0x03f0c996", "position": {"altitude": 1297, "latitude": 33.113972, "location_source": "LOC_INTERNAL", "longitude": -107.062692, "time_offset_sec": 3919}, "public_key_hex": "af97803dcbc4fb2db90ace9b96f70ffd6c484e2fd0d54ab8cb46297c7cad89a1", "role": "CLIENT", "short_name": "FO5O", "snr": 11.13, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.546, "battery_level": 10, "channel_utilization": 11.54, "uptime_seconds": 238252, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.17, "iaq": 84, "relative_humidity": 35.51, "temperature": 25.9}, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 22145, "long_name": "Soft Owl", "next_hop": 149, "num": "0x040d0e56", "position": {"altitude": 1220, "latitude": 33.873053, "location_source": "LOC_INTERNAL", "longitude": -106.321854, "time_offset_sec": 22392}, "public_key_hex": "dd7eb61cb45e387937c05cebde491e8428a53ff00da388e572e3fddc8841dfa3", "role": "CLIENT", "short_name": "SHEI", "snr": 5.67, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2296, "long_name": "Dawn Bronco AE4QZ", "next_hop": 13, "num": "0x04192546", "position": {"altitude": 1467, "latitude": 33.04642, "location_source": "LOC_INTERNAL", "longitude": -107.817141, "time_offset_sec": 2334}, "public_key_hex": "357414dbb768ee64b651943e9ac34fa0701b51fa187751bd832ef1ec994c2553", "role": "CLIENT", "short_name": "D859", "snr": 5.03, "status": null, "telemetry": {"air_util_tx": 0.524, "battery_level": 50, "channel_utilization": 21.1, "uptime_seconds": 96009, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.95, "iaq": 38, "relative_humidity": 11.4, "temperature": 21.63}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5792, "long_name": "Lone Tortoise", "next_hop": 121, "num": "0x04262ce9", "position": {"altitude": 1499, "latitude": 33.779225, "location_source": "LOC_INTERNAL", "longitude": -107.33628, "time_offset_sec": 5799}, "public_key_hex": "f298bca8301ba0af5dffecaad3ff8a9b41afd50a95ab0a3ece76825acb0a0cb6", "role": "CLIENT", "short_name": "LW8K", "snr": 2.67, "status": null, "telemetry": {"air_util_tx": 0.578, "battery_level": 101, "channel_utilization": 9.74, "uptime_seconds": 139228, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 166, "long_name": "Copper Arroyo", "next_hop": 87, "num": "0x0489d8f2", "position": null, "public_key_hex": "151eaf0fde306e8fdaadced23c5b693e4ed56a97418ef03699d5e0f587dba06f", "role": "CLIENT_BASE", "short_name": "🦉", "snr": 6.21, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 612, "long_name": "Forest Hawk", "next_hop": 0, "num": "0x048a1f84", "position": {"altitude": 934, "latitude": 32.720474, "location_source": "LOC_INTERNAL", "longitude": -107.811419, "time_offset_sec": 649}, "public_key_hex": "4561569f94d8bdc422c915a6e69a6d5ee77818c6928dd3f196a107881e5fe602", "role": "CLIENT", "short_name": "F8WX", "snr": 7.69, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.112, "battery_level": 80, "channel_utilization": 17.0, "uptime_seconds": 121320, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.62, "iaq": 98, "relative_humidity": 83.74, "temperature": 14.01}, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 6368, "long_name": "Burning Pine", "next_hop": 0, "num": "0x049508ad", "position": null, "public_key_hex": "fa20dcab866a0e6526504de69ccc713119b5d98a099c23077bfb00ed90577d89", "role": "CLIENT", "short_name": "BPCI", "snr": 5.33, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 11182, "long_name": "Smooth Gecko", "next_hop": 171, "num": "0x04de4675", "position": {"altitude": 967, "latitude": 32.108915, "location_source": "LOC_INTERNAL", "longitude": -107.194847, "time_offset_sec": 11412}, "public_key_hex": "7a1e231191513b609471ada8bfa79e510b55ede597ac998fdc3ccb4cfd526c51", "role": "CLIENT", "short_name": "S5M3", "snr": 5.58, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.201, "battery_level": 20, "channel_utilization": 11.28, "uptime_seconds": 25512, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1842, "long_name": "River Mole", "next_hop": 233, "num": "0x05225858", "position": {"altitude": 1712, "latitude": 33.120145, "location_source": "LOC_INTERNAL", "longitude": -107.725743, "time_offset_sec": 2118}, "public_key_hex": "57789cde7244c9822e2c2e42ee2db2d69d26084f8d16a670fa72524895c152fc", "role": "CLIENT", "short_name": "RMAO", "snr": 4.27, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.77, "iaq": 66, "relative_humidity": 44.41, "temperature": 7.13}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5828, "long_name": "Steel Salmon", "next_hop": 0, "num": "0x0545ca89", "position": {"altitude": 1416, "latitude": 32.814824, "location_source": "LOC_INTERNAL", "longitude": -107.386246, "time_offset_sec": 6002}, "public_key_hex": "ab1f7c93b0be9d216a1f5e6b0a8ae7df8eb170e2b98539e17eeea206cdfbe363", "role": "CLIENT", "short_name": "SBHL", "snr": 5.64, "status": null, "telemetry": {"air_util_tx": 1.05, "battery_level": 78, "channel_utilization": 8.27, "uptime_seconds": 36648, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1831, "long_name": "Storm Wolf", "next_hop": 102, "num": "0x054ada3a", "position": null, "public_key_hex": "fc0ae8ae0eeebc43a3bf82bcfdfd21cbb4dad8d9c5a5fc87416b8da622821065", "role": "CLIENT", "short_name": "🦂", "snr": 0.46, "status": null, "telemetry": {"air_util_tx": 0.331, "battery_level": 96, "channel_utilization": 7.47, "uptime_seconds": 7382, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1899, "long_name": "Sneaky Otter", "next_hop": 164, "num": "0x05890100", "position": {"altitude": 1005, "latitude": 31.740069, "location_source": "LOC_INTERNAL", "longitude": -107.740556, "time_offset_sec": 1964}, "public_key_hex": "", "role": "CLIENT", "short_name": "SYOV", "snr": 8.05, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 851, "long_name": "Short Pike", "next_hop": 0, "num": "0x05adc97d", "position": {"altitude": 1356, "latitude": 34.108477, "location_source": "LOC_INTERNAL", "longitude": -106.933425, "time_offset_sec": 1038}, "public_key_hex": "d4ab230eb450f700d72128e8ec9c033fbdfd2b2d839e80ccadb10da2042059e5", "role": "CLIENT_MUTE", "short_name": "SMGY", "snr": 7.25, "status": null, "telemetry": {"air_util_tx": 0.126, "battery_level": 50, "channel_utilization": 6.3, "uptime_seconds": 48099, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1166, "long_name": "Dusk Marmot", "next_hop": 0, "num": "0x05c60123", "position": {"altitude": 1604, "latitude": 32.960335, "location_source": "LOC_INTERNAL", "longitude": -107.393424, "time_offset_sec": 1378}, "public_key_hex": "1964015163069f68abe23fb5c1db315a8fd407d51c9e7f18f8a562bcf6b1f286", "role": "CLIENT", "short_name": "DU50", "snr": 2.98, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4481, "long_name": "Bright Oak", "next_hop": 0, "num": "0x05e2f3dc", "position": {"altitude": 1574, "latitude": 33.078228, "location_source": "LOC_INTERNAL", "longitude": -107.093882, "time_offset_sec": 4563}, "public_key_hex": "8c76887f0e74e4923f66126b6a9006148240e0d560ac5cff71951c467bfd1cae", "role": "CLIENT", "short_name": "BR18", "snr": 0.94, "status": null, "telemetry": {"air_util_tx": 0.265, "battery_level": 96, "channel_utilization": 1.12, "uptime_seconds": 8955, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 892, "long_name": "Howling Badger", "next_hop": 145, "num": "0x05e4d636", "position": {"altitude": 1384, "latitude": 33.557869, "location_source": "LOC_INTERNAL", "longitude": -107.690178, "time_offset_sec": 1076}, "public_key_hex": "", "role": "CLIENT", "short_name": "HEOI", "snr": 2.41, "status": null, "telemetry": {"air_util_tx": 0.411, "battery_level": 79, "channel_utilization": 7.11, "uptime_seconds": 251204, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1940, "long_name": "Drifting Beaver", "next_hop": 130, "num": "0x06001e41", "position": {"altitude": 2065, "latitude": 31.753656, "location_source": "LOC_INTERNAL", "longitude": -107.334407, "time_offset_sec": 1982}, "public_key_hex": "85f6359e19a4e53e49c2915cc7f9f8b3a3120747932239b2fd376722b77b505d", "role": "CLIENT", "short_name": "DGUW", "snr": -0.02, "status": null, "telemetry": {"air_util_tx": 0.221, "battery_level": 56, "channel_utilization": 13.01, "uptime_seconds": 35089, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 8545, "long_name": "Howling Iguana", "next_hop": 0, "num": "0x06346b1d", "position": null, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "HAJS", "snr": 9.49, "status": null, "telemetry": {"air_util_tx": 0.212, "battery_level": 37, "channel_utilization": 16.53, "uptime_seconds": 103591, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1026.96, "iaq": 45, "relative_humidity": 86.59, "temperature": 14.97}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1537, "long_name": "Loud Mamba", "next_hop": 0, "num": "0x064349f2", "position": {"altitude": 1200, "latitude": 32.913914, "location_source": "LOC_INTERNAL", "longitude": -106.818818, "time_offset_sec": 1716}, "public_key_hex": "9ffb4828de5b64fa75db1f3bb895a7256d0a081dffc4d9f94f26aa4cc516fd76", "role": "CLIENT", "short_name": "LV5Q", "snr": 2.41, "status": null, "telemetry": {"air_util_tx": 0.603, "battery_level": 65, "channel_utilization": 2.36, "uptime_seconds": 36640, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 6443, "long_name": "Blue Lynx", "next_hop": 206, "num": "0x0646c417", "position": {"altitude": 1376, "latitude": 32.498573, "location_source": "LOC_INTERNAL", "longitude": -107.327376, "time_offset_sec": 6671}, "public_key_hex": "d07d0e67185b376aa23cdf5034d4b5e9925b79f12f97ee95b580a1b792f14abf", "role": "CLIENT", "short_name": "🦌", "snr": 8.63, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.271, "battery_level": 78, "channel_utilization": 3.17, "uptime_seconds": 79672, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1133, "long_name": "Frosty Badger", "next_hop": 0, "num": "0x064d85d0", "position": {"altitude": 1467, "latitude": 32.18826, "location_source": "LOC_INTERNAL", "longitude": -108.250205, "time_offset_sec": 1428}, "public_key_hex": "1d9d18a76ba272abe2cfd1e4f4882443a7278acb1dc24fc3038756b8de5d8348", "role": "CLIENT", "short_name": "FFU8", "snr": 7.14, "status": null, "telemetry": {"air_util_tx": 0.294, "battery_level": 52, "channel_utilization": 7.57, "uptime_seconds": 77878, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 2, "environment": null, "hops_away": 2, "hw_model": "M5STACK_C6L", "last_heard_offset_sec": 2369, "long_name": "Silver Hawk", "next_hop": 3, "num": "0x0651b0a2", "position": {"altitude": 1412, "latitude": 33.383357, "location_source": "LOC_INTERNAL", "longitude": -106.18987, "time_offset_sec": 2536}, "public_key_hex": "", "role": "LOST_AND_FOUND", "short_name": "SAGN", "snr": 1.09, "status": null, "telemetry": {"air_util_tx": 0.363, "battery_level": 23, "channel_utilization": 16.15, "uptime_seconds": 197384, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1444, "long_name": "Bright Bronco", "next_hop": 0, "num": "0x06d7a2f5", "position": {"altitude": 1103, "latitude": 32.975673, "location_source": "LOC_INTERNAL", "longitude": -107.459642, "time_offset_sec": 1612}, "public_key_hex": "1671faee9d9887a209096962646056f84afdde7f8d4ab09455bd5b014876c4f7", "role": "CLIENT", "short_name": "BBUT", "snr": 10.41, "status": null, "telemetry": {"air_util_tx": 0.505, "battery_level": 38, "channel_utilization": 5.19, "uptime_seconds": 93927, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.67, "iaq": 52, "relative_humidity": 48.71, "temperature": 24.15}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 523, "long_name": "Sleepy Bear AE6MM", "next_hop": 0, "num": "0x06f9cbd7", "position": {"altitude": 1670, "latitude": 32.529379, "location_source": "LOC_INTERNAL", "longitude": -106.562441, "time_offset_sec": 604}, "public_key_hex": "1f5175aa60babfaaed7e8c928e756ce620d8fab84093110a2e40c264a5241710", "role": "ROUTER", "short_name": "SHFM", "snr": 5.01, "status": null, "telemetry": {"air_util_tx": 0.879, "battery_level": 85, "channel_utilization": 8.89, "uptime_seconds": 167015, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2885, "long_name": "Rough Badger", "next_hop": 168, "num": "0x0715d8cc", "position": {"altitude": 1349, "latitude": 33.360554, "location_source": "LOC_INTERNAL", "longitude": -107.117159, "time_offset_sec": 3058}, "public_key_hex": "3ef8f3235317d3e66ce7c349f3be29a271f09506f9e2847ae2e6aa21ed7a2e78", "role": "CLIENT", "short_name": "RDK5", "snr": 9.68, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1055, "long_name": "Misty Elk", "next_hop": 0, "num": "0x071cb8a0", "position": {"altitude": 1681, "latitude": 33.829224, "location_source": "LOC_INTERNAL", "longitude": -107.067441, "time_offset_sec": 1058}, "public_key_hex": "4131f5d8726bfdc143df155e5d92aae0fe814a95acaf6d2644b13dadff921d18", "role": "ROUTER_LATE", "short_name": "MO5Q", "snr": -0.25, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1020.53, "iaq": 47, "relative_humidity": 48.71, "temperature": 25.0}, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 15089, "long_name": "Soft Shark", "next_hop": 16, "num": "0x0723fb9c", "position": {"altitude": 1705, "latitude": 32.448432, "location_source": "LOC_INTERNAL", "longitude": -108.270829, "time_offset_sec": 15105}, "public_key_hex": "e9f459b1c1d74c2d678388ff439a1fe2aafdea78201abb4dfe37eb7e6cfd38d0", "role": "CLIENT", "short_name": "SAD3", "snr": 4.25, "status": null, "telemetry": {"air_util_tx": 1.948, "battery_level": 82, "channel_utilization": 4.22, "uptime_seconds": 35740, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1531, "long_name": "Lone Salmon", "next_hop": 0, "num": "0x072c12aa", "position": null, "public_key_hex": "1555e67a746b6d9633f38a9cf71f19ccaacd940c84a1e6304f69f0a6e72cc268", "role": "CLIENT", "short_name": "🌵", "snr": 5.41, "status": null, "telemetry": {"air_util_tx": 0.93, "battery_level": 34, "channel_utilization": 1.55, "uptime_seconds": 86767, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.84, "iaq": 23, "relative_humidity": 23.71, "temperature": 14.0}, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 157, "long_name": "Happy Seal AE2GN", "next_hop": 227, "num": "0x073c17e7", "position": {"altitude": 1285, "latitude": 32.551754, "location_source": "LOC_INTERNAL", "longitude": -106.802731, "time_offset_sec": 239}, "public_key_hex": "cd35304d0907a5bbc6a160bb8b16e1dbcaa7548777dd146bd2e40072020a7fa4", "role": "CLIENT", "short_name": "HZQ1", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 346, "long_name": "Frosty Viper", "next_hop": 0, "num": "0x074724ec", "position": {"altitude": 1647, "latitude": 32.582372, "location_source": "LOC_INTERNAL", "longitude": -106.966224, "time_offset_sec": 372}, "public_key_hex": "8d537e388a8f9cc8899172cfd1143a70ba66b8912caff9e081c7ad691520a9b9", "role": "CLIENT", "short_name": "F4E6", "snr": 5.9, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1004.61, "iaq": 30, "relative_humidity": 60.11, "temperature": 24.18}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4993, "long_name": "Silent Cedar", "next_hop": 0, "num": "0x075dc16c", "position": {"altitude": 1517, "latitude": 33.473043, "location_source": "LOC_INTERNAL", "longitude": -107.299801, "time_offset_sec": 5078}, "public_key_hex": "2fdb69b3f5a02f7720eddc68fbe85850ea4e27b12f4f5fc2969441e2435a221a", "role": "CLIENT", "short_name": "SFYY", "snr": 7.45, "status": null, "telemetry": {"air_util_tx": 0.423, "battery_level": 40, "channel_utilization": 8.35, "uptime_seconds": 120747, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2076, "long_name": "Happy Arroyo", "next_hop": 155, "num": "0x079d625d", "position": {"altitude": 968, "latitude": 33.126593, "location_source": "LOC_INTERNAL", "longitude": -106.881345, "time_offset_sec": 2333}, "public_key_hex": "f7e80b23600c17aaedc2c5a8cbe5033b114ada412ed2a7f12d04e10a4c45cb3a", "role": "CLIENT", "short_name": "HMY9", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.099, "battery_level": 16, "channel_utilization": 12.14, "uptime_seconds": 44688, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 8371, "long_name": "Wild Wolf", "next_hop": 54, "num": "0x07aace1b", "position": {"altitude": 1365, "latitude": 33.542699, "location_source": "LOC_INTERNAL", "longitude": -107.953866, "time_offset_sec": 8409}, "public_key_hex": "6b00bec98e8beb19520367528d87a2c9af8369906e9d1c99ab7160267235380c", "role": "CLIENT", "short_name": "🗻", "snr": 9.31, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.426, "battery_level": 25, "channel_utilization": 13.17, "uptime_seconds": 52724, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4113, "long_name": "Wild Squirrel", "next_hop": 53, "num": "0x07f4ab96", "position": {"altitude": 1419, "latitude": 32.917862, "location_source": "LOC_INTERNAL", "longitude": -106.587899, "time_offset_sec": 4224}, "public_key_hex": "81dbbce09005a0860588c54f622f993e58dcea6daa8d012600a13026b98d495e", "role": "CLIENT", "short_name": "WF67", "snr": 2.68, "status": null, "telemetry": {"air_util_tx": 0.506, "battery_level": 16, "channel_utilization": 4.74, "uptime_seconds": 25297, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 10321, "long_name": "Burning Eagle", "next_hop": 68, "num": "0x0801e891", "position": {"altitude": 1032, "latitude": 32.855091, "location_source": "LOC_INTERNAL", "longitude": -107.36248, "time_offset_sec": 10339}, "public_key_hex": "0544f3b3175cca0f203917af4e6588a4588a4be0b333d34269953593890eedb9", "role": "TAK_TRACKER", "short_name": "BF9H", "snr": 6.22, "status": null, "telemetry": {"air_util_tx": 1.414, "battery_level": 38, "channel_utilization": 16.05, "uptime_seconds": 91711, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3884, "long_name": "Silent Shark", "next_hop": 0, "num": "0x080d6bd6", "position": {"altitude": 1479, "latitude": 32.578912, "location_source": "LOC_INTERNAL", "longitude": -106.994027, "time_offset_sec": 3943}, "public_key_hex": "97b5657b6c90edf6148bab2e65d142efb4fa3751314928cb7f56df72cab9de69", "role": "ROUTER", "short_name": "S6YA", "snr": 7.36, "status": null, "telemetry": {"air_util_tx": 1.439, "battery_level": 62, "channel_utilization": 5.04, "uptime_seconds": 38980, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6629, "long_name": "Slow Colt", "next_hop": 0, "num": "0x080db728", "position": {"altitude": 927, "latitude": 32.506716, "location_source": "LOC_INTERNAL", "longitude": -106.736573, "time_offset_sec": 6678}, "public_key_hex": "018a2991c33a77a1fa821ab4562b9b711cfa0e26fe228faab419fe5c106cf457", "role": "CLIENT", "short_name": "SL7T", "snr": 9.7, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.7, "battery_level": 47, "channel_utilization": 7.42, "uptime_seconds": 36162, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3962, "long_name": "Forest Sage", "next_hop": 0, "num": "0x08165754", "position": {"altitude": 1961, "latitude": 33.877503, "location_source": "LOC_INTERNAL", "longitude": -107.157725, "time_offset_sec": 3987}, "public_key_hex": "cd55967f3d91ce493313635397bbcef0c3b1465be204bea69b01e8d0b901077d", "role": "CLIENT_MUTE", "short_name": "FYKM", "snr": 5.8, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.706, "battery_level": 70, "channel_utilization": 1.48, "uptime_seconds": 23229, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 8023, "long_name": "Roving Beaver", "next_hop": 0, "num": "0x0888dc20", "position": {"altitude": 1481, "latitude": 32.442602, "location_source": "LOC_INTERNAL", "longitude": -107.594634, "time_offset_sec": 8170}, "public_key_hex": "4a018a5687c1db54492b101069fdc7d82d7aaa532bad0ccab2ad4a3a562c49fb", "role": "CLIENT", "short_name": "RV4W", "snr": 6.19, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.357, "battery_level": 74, "channel_utilization": 0.29, "uptime_seconds": 348689, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 336, "long_name": "Stone Sage", "next_hop": 0, "num": "0x089c992f", "position": {"altitude": 1340, "latitude": 31.866382, "location_source": "LOC_INTERNAL", "longitude": -107.719935, "time_offset_sec": 356}, "public_key_hex": "e975f5702bae7e2d5f1c5b433a5dffa4821f973ab6664f50ec67c3cdb3223ae3", "role": "CLIENT", "short_name": "S6M1", "snr": 7.29, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.6, "battery_level": 30, "channel_utilization": 11.12, "uptime_seconds": 14121, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2675, "long_name": "Drifting Squirrel", "next_hop": 0, "num": "0x08a7a131", "position": {"altitude": 1226, "latitude": 32.39782, "location_source": "LOC_INTERNAL", "longitude": -107.582217, "time_offset_sec": 2954}, "public_key_hex": "495f55fd6e5b4fde6c7ccb42ba7fb81520052b9ee4160a8266563280a1b31d42", "role": "TAK", "short_name": "DINR", "snr": 4.21, "status": null, "telemetry": {"air_util_tx": 0.164, "battery_level": 85, "channel_utilization": 13.51, "uptime_seconds": 2005, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 275, "long_name": "Forest Phoenix", "next_hop": 0, "num": "0x08a9d55a", "position": {"altitude": 1370, "latitude": 32.957459, "location_source": "LOC_INTERNAL", "longitude": -105.985003, "time_offset_sec": 467}, "public_key_hex": "a1505e9207a53c048cb4842ec544cba3f1cfc918128f35f72137072dca4af1ea", "role": "CLIENT", "short_name": "F1L0", "snr": -1.64, "status": null, "telemetry": {"air_util_tx": 0.248, "battery_level": 11, "channel_utilization": 10.54, "uptime_seconds": 53834, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1094, "long_name": "Black Bluff", "next_hop": 165, "num": "0x08b8afc7", "position": {"altitude": 1387, "latitude": 33.202175, "location_source": "LOC_INTERNAL", "longitude": -106.519702, "time_offset_sec": 1252}, "public_key_hex": "0ad63b36dc8951126e0324da8a5db4ee2b00a15f76ab3e2c7b656a3d57e6d00c", "role": "CLIENT", "short_name": "BYFH", "snr": 6.96, "status": null, "telemetry": {"air_util_tx": 0.523, "battery_level": 66, "channel_utilization": 13.17, "uptime_seconds": 14853, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 6537, "long_name": "Dawn Lion", "next_hop": 0, "num": "0x08d19544", "position": null, "public_key_hex": "d88e2808e7f15de82cd088f1a27cabfc650bba390bfaff7bad0f515919c63b10", "role": "CLIENT", "short_name": "D2EX", "snr": 9.08, "status": null, "telemetry": {"air_util_tx": 0.429, "battery_level": 34, "channel_utilization": 11.66, "uptime_seconds": 76001, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2991, "long_name": "Happy Bear", "next_hop": 0, "num": "0x08e8a381", "position": {"altitude": 1401, "latitude": 33.509274, "location_source": "LOC_INTERNAL", "longitude": -106.885639, "time_offset_sec": 2994}, "public_key_hex": "f41d32158c4c4acb3bf7c868dcbf79531b688a4c034559b203095fe368085c32", "role": "CLIENT", "short_name": "HAJ2", "snr": 5.68, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 999.07, "iaq": 97, "relative_humidity": 69.55, "temperature": 37.56}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 245, "long_name": "Lone Doe", "next_hop": 0, "num": "0x08fc8862", "position": {"altitude": 1394, "latitude": 33.627545, "location_source": "LOC_INTERNAL", "longitude": -106.90887, "time_offset_sec": 543}, "public_key_hex": "885ece25a1db19e0979818bda7d5ee3998b7a597d4ab16bffea0d7c78d17c02b", "role": "CLIENT", "short_name": "LFQV", "snr": 7.11, "status": null, "telemetry": {"air_util_tx": 0.894, "battery_level": 78, "channel_utilization": 22.07, "uptime_seconds": 63772, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 2798, "long_name": "Steel Gecko", "next_hop": 0, "num": "0x090950a4", "position": {"altitude": 1374, "latitude": 33.544218, "location_source": "LOC_INTERNAL", "longitude": -108.232319, "time_offset_sec": 3060}, "public_key_hex": "", "role": "CLIENT", "short_name": "SKJC", "snr": 0.26, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.337, "battery_level": 70, "channel_utilization": 14.73, "uptime_seconds": 4621, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 64, "long_name": "Loud Sage", "next_hop": 0, "num": "0x090f5741", "position": {"altitude": 1489, "latitude": 33.578263, "location_source": "LOC_INTERNAL", "longitude": -107.717846, "time_offset_sec": 235}, "public_key_hex": "88c984da4bb81f3b615950b914b3afefb131cd72eb7897e9936b0607b339f3cb", "role": "ROUTER", "short_name": "LRKY", "snr": 12.0, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 341, "long_name": "Desert Wolf", "next_hop": 145, "num": "0x092ea89e", "position": {"altitude": 941, "latitude": 33.120437, "location_source": "LOC_INTERNAL", "longitude": -107.188417, "time_offset_sec": 635}, "public_key_hex": "764afab71e81e49da003eda720371b79106cb9d675b97599d858c0d2f86a8a7f", "role": "CLIENT", "short_name": "DJ47", "snr": 9.34, "status": null, "telemetry": {"air_util_tx": 0.84, "battery_level": 69, "channel_utilization": 7.99, "uptime_seconds": 19038, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3409, "long_name": "Copper Elk", "next_hop": 0, "num": "0x097dc996", "position": {"altitude": 1671, "latitude": 32.88364, "location_source": "LOC_INTERNAL", "longitude": -107.230958, "time_offset_sec": 3482}, "public_key_hex": "0eb6fd8e8409c42d7aea0deb148092cad8cca0b85cdf0a4e181a5bd80f9162ee", "role": "CLIENT", "short_name": "CODX", "snr": 1.88, "status": null, "telemetry": {"air_util_tx": 1.405, "battery_level": 99, "channel_utilization": 20.52, "uptime_seconds": 115955, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5873, "long_name": "Whispering Lion", "next_hop": 0, "num": "0x0983d728", "position": {"altitude": 1260, "latitude": 33.128203, "location_source": "LOC_INTERNAL", "longitude": -106.830514, "time_offset_sec": 6054}, "public_key_hex": "0106caea710d4b40aa356128be509f22b98f98b54634a2f5713812ecbe376487", "role": "CLIENT", "short_name": "WLNW", "snr": 4.74, "status": null, "telemetry": {"air_util_tx": 1.503, "battery_level": 96, "channel_utilization": 5.25, "uptime_seconds": 176672, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 539, "long_name": "Sneaky Coyote", "next_hop": 69, "num": "0x099b950c", "position": {"altitude": 1699, "latitude": 32.738463, "location_source": "LOC_INTERNAL", "longitude": -107.778308, "time_offset_sec": 590}, "public_key_hex": "39771ebb4200ca4d98ed80bcab49b06c5030234cc1c615c66864904a49acb2f7", "role": "CLIENT", "short_name": "S0OW", "snr": 6.97, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.208, "battery_level": 72, "channel_utilization": 7.86, "uptime_seconds": 124291, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 311, "long_name": "Forest Badger", "next_hop": 0, "num": "0x09edb6bc", "position": {"altitude": 1251, "latitude": 33.774434, "location_source": "LOC_INTERNAL", "longitude": -107.937374, "time_offset_sec": 603}, "public_key_hex": "76a89cdc23bf6047bfe9535ed6322a3dc55b583ffc7bb14de61727e060380baf", "role": "CLIENT", "short_name": "FO50", "snr": 3.22, "status": null, "telemetry": {"air_util_tx": 0.65, "battery_level": 81, "channel_utilization": 6.34, "uptime_seconds": 93584, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 90, "long_name": "Tiny Crane", "next_hop": 0, "num": "0x09f04c5a", "position": null, "public_key_hex": "00e4fa13a398df5355a2c434bd4ce3f27975189054c0352c3727866f62842de1", "role": "TAK_TRACKER", "short_name": "🐢", "snr": 12.0, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 2.01, "battery_level": 69, "channel_utilization": 8.13, "uptime_seconds": 131216, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1707, "long_name": "Copper Lynx", "next_hop": 0, "num": "0x0a5b90f7", "position": {"altitude": 1587, "latitude": 33.345958, "location_source": "LOC_INTERNAL", "longitude": -107.679493, "time_offset_sec": 1914}, "public_key_hex": "260db93c767752553c780cd74dd8c6fec883877892f807a599c2d6cd0ade728b", "role": "CLIENT", "short_name": "C5TM", "snr": 7.88, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2156, "long_name": "Mountain Crow", "next_hop": 0, "num": "0x0a8d8ad8", "position": {"altitude": 1296, "latitude": 34.20868, "location_source": "LOC_INTERNAL", "longitude": -106.242253, "time_offset_sec": 2171}, "public_key_hex": "462cf5e7e2d62298ea5b8bb53d0c0a812997f67e0304245d8d0823d243ca0e2f", "role": "CLIENT", "short_name": "🌲", "snr": 3.54, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3827, "long_name": "Silent Beaver", "next_hop": 0, "num": "0x0aa4cf01", "position": {"altitude": 1480, "latitude": 31.875342, "location_source": "LOC_INTERNAL", "longitude": -106.885328, "time_offset_sec": 3853}, "public_key_hex": "671dd7df2e0ebe1c74f11b71fa700837c6fbce63e4c34e1665d9163aef29deb0", "role": "CLIENT_MUTE", "short_name": "SYHD", "snr": 2.44, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.349, "battery_level": 31, "channel_utilization": 5.01, "uptime_seconds": 328548, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1693, "long_name": "Dusk Trout", "next_hop": 197, "num": "0x0ac727a7", "position": {"altitude": 1684, "latitude": 32.652206, "location_source": "LOC_INTERNAL", "longitude": -106.953675, "time_offset_sec": 1945}, "public_key_hex": "fdd20683dfb80a2e6de0be72f4d2f531e914e81cf370bfd752a89f22b8da7b69", "role": "CLIENT", "short_name": "DKWA", "snr": 10.01, "status": null, "telemetry": {"air_util_tx": 0.884, "battery_level": 61, "channel_utilization": 16.54, "uptime_seconds": 123711, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.08, "iaq": 37, "relative_humidity": 30.47, "temperature": 24.88}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1332, "long_name": "Dawn Doe", "next_hop": 0, "num": "0x0b20254a", "position": {"altitude": 1153, "latitude": 32.192809, "location_source": "LOC_INTERNAL", "longitude": -106.889595, "time_offset_sec": 1547}, "public_key_hex": "533ec55389757e23d8edb0d5ec144dc030893f335cb6d1cbb0a47e6f9bf3b653", "role": "CLIENT", "short_name": "DWBW", "snr": 4.56, "status": null, "telemetry": {"air_util_tx": 1.025, "battery_level": 31, "channel_utilization": 9.45, "uptime_seconds": 990, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1283, "long_name": "Sharp Owl", "next_hop": 161, "num": "0x0b4565dc", "position": {"altitude": 1539, "latitude": 32.550172, "location_source": "LOC_INTERNAL", "longitude": -107.604195, "time_offset_sec": 1503}, "public_key_hex": "ccde7fcf48f370f8ad373222ea409eece3afcab569e27cdd5bdb128e0b61fff4", "role": "CLIENT", "short_name": "SS2L", "snr": 7.99, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 490, "long_name": "Silver Trout", "next_hop": 24, "num": "0x0b458aa5", "position": {"altitude": 962, "latitude": 33.46251, "location_source": "LOC_INTERNAL", "longitude": -108.237812, "time_offset_sec": 562}, "public_key_hex": "12f8bdd5c73e18011e0303b87a95306191508d6d8290d4d737839963f5c89569", "role": "CLIENT", "short_name": "S1HJ", "snr": 6.39, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.637, "battery_level": 80, "channel_utilization": 11.17, "uptime_seconds": 436534, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1027.36, "iaq": 52, "relative_humidity": 7.7, "temperature": 24.74}, "hops_away": 2, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 1119, "long_name": "Red Hare", "next_hop": 125, "num": "0x0b4886c5", "position": {"altitude": 1332, "latitude": 32.618449, "location_source": "LOC_INTERNAL", "longitude": -107.702526, "time_offset_sec": 1286}, "public_key_hex": "67654120a5c31badfdb7d23f718b3f756733cabcaf0f258fdc64bec36c3b792c", "role": "CLIENT_BASE", "short_name": "R2TR", "snr": 8.47, "status": null, "telemetry": {"air_util_tx": 0.584, "battery_level": 62, "channel_utilization": 7.38, "uptime_seconds": 36583, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3119, "long_name": "Shady Ridge", "next_hop": 93, "num": "0x0b548c2a", "position": null, "public_key_hex": "f02c5f9723885a9f7d4870bd55f25bfef8b05bd88f2c113c6a2877b263e7555f", "role": "CLIENT", "short_name": "SAX9", "snr": 1.43, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.27, "iaq": 84, "relative_humidity": 36.0, "temperature": 16.7}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3821, "long_name": "Roving Whale", "next_hop": 0, "num": "0x0b81d568", "position": {"altitude": 1412, "latitude": 32.615656, "location_source": "LOC_INTERNAL", "longitude": -106.75734, "time_offset_sec": 3966}, "public_key_hex": "01969dbf1ce4c8339f524e809441f02b13f2e21fbee6cf22abeeba35e16dd676", "role": "CLIENT", "short_name": "R1WI", "snr": 10.06, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.769, "battery_level": 67, "channel_utilization": 5.01, "uptime_seconds": 103674, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 4571, "long_name": "Copper Lynx", "next_hop": 92, "num": "0x0bb98967", "position": null, "public_key_hex": "eb59251880d22ef4c3e4083daba14d2fc9598b082cff26b974156b36975c9584", "role": "CLIENT", "short_name": "CWLY", "snr": 4.72, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 10512, "long_name": "Lost Colt N53QQ", "next_hop": 0, "num": "0x0bc8f2e4", "position": {"altitude": 1294, "latitude": 32.97512, "location_source": "LOC_INTERNAL", "longitude": -106.633725, "time_offset_sec": 10723}, "public_key_hex": "4a8593e2622051450f11badbd7df77eefd77e0db5919333f118f671de2546d7b", "role": "CLIENT", "short_name": "🗻", "snr": 3.72, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.843, "battery_level": 45, "channel_utilization": 15.51, "uptime_seconds": 39162, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_HT62", "last_heard_offset_sec": 8363, "long_name": "Found Mole", "next_hop": 198, "num": "0x0c2f67c7", "position": {"altitude": 1430, "latitude": 33.69582, "location_source": "LOC_INTERNAL", "longitude": -107.178645, "time_offset_sec": 8380}, "public_key_hex": "7e088c1da7f5d5b21b06f21b30a22101f31fecfb9577603cf4cd4e85213c241b", "role": "CLIENT", "short_name": "FI6S", "snr": 4.12, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1242, "long_name": "Steel Fox", "next_hop": 0, "num": "0x0c4cd884", "position": {"altitude": 1282, "latitude": 33.408593, "location_source": "LOC_INTERNAL", "longitude": -106.790865, "time_offset_sec": 1360}, "public_key_hex": "a1642f2c83f45a5b11c1ee361db24eb3002ccd6c62917d2665445f2ebf968996", "role": "CLIENT", "short_name": "S3G6", "snr": 3.93, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.03, "iaq": 52, "relative_humidity": 75.5, "temperature": 23.05}, "hops_away": 6, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 2053, "long_name": "Canyon Iguana", "next_hop": 238, "num": "0x0c515ca6", "position": {"altitude": 1369, "latitude": 33.518412, "location_source": "LOC_INTERNAL", "longitude": -107.602818, "time_offset_sec": 2067}, "public_key_hex": "9148cd6bf1b0bf9eb262cb6d77176e637239ea5008b342b8d72ba31530dd0112", "role": "CLIENT", "short_name": "C4L2", "snr": 11.26, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 1584, "long_name": "Wild Mole", "next_hop": 189, "num": "0x0c56a93c", "position": {"altitude": 1320, "latitude": 33.391305, "location_source": "LOC_INTERNAL", "longitude": -106.710582, "time_offset_sec": 1666}, "public_key_hex": "804af6b4c1ff7d8befd8d6771581eb5df45cfc7f8433bd42696ea9964439ea66", "role": "CLIENT", "short_name": "W8G5", "snr": 2.62, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.71, "battery_level": 74, "channel_utilization": 5.62, "uptime_seconds": 98653, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.02, "iaq": 76, "relative_humidity": 75.41, "temperature": 22.92}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1550, "long_name": "Sneaky Otter", "next_hop": 0, "num": "0x0c668427", "position": {"altitude": 1417, "latitude": 32.804683, "location_source": "LOC_INTERNAL", "longitude": -107.74108, "time_offset_sec": 1682}, "public_key_hex": "c72a554ae665f8cbdd3d2b0037c6a3f5cf18f882aa382f4cc89df2879aebc9b0", "role": "CLIENT", "short_name": "SX1V", "snr": 11.28, "status": null, "telemetry": {"air_util_tx": 0.072, "battery_level": 58, "channel_utilization": 3.26, "uptime_seconds": 348, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1015.67, "iaq": 66, "relative_humidity": 59.33, "temperature": 9.58}, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 2721, "long_name": "Steel Sage", "next_hop": 39, "num": "0x0caf9eb3", "position": {"altitude": 1340, "latitude": 32.936747, "location_source": "LOC_INTERNAL", "longitude": -107.922605, "time_offset_sec": 2905}, "public_key_hex": "c8e3c0c3f603e4f70e7c2d28afe0647fedb0c6d32fe65ea9985fd68896740a19", "role": "CLIENT", "short_name": "S7UZ", "snr": 4.85, "status": null, "telemetry": {"air_util_tx": 1.128, "battery_level": 15, "channel_utilization": 2.51, "uptime_seconds": 209748, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4010, "long_name": "Drifting Salmon", "next_hop": 11, "num": "0x0cba3881", "position": {"altitude": 1197, "latitude": 33.080975, "location_source": "LOC_INTERNAL", "longitude": -107.279242, "time_offset_sec": 4030}, "public_key_hex": "c50cb1cbcf7ffa6b2bef2f3604d84e25c2b1c7ff03f15507f3338134a956e715", "role": "CLIENT", "short_name": "DYH4", "snr": 2.27, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.116, "battery_level": 42, "channel_utilization": 10.18, "uptime_seconds": 23310, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7175, "long_name": "Silent Yucca", "next_hop": 178, "num": "0x0cd4efbb", "position": {"altitude": 1710, "latitude": 33.211995, "location_source": "LOC_INTERNAL", "longitude": -106.920098, "time_offset_sec": 7251}, "public_key_hex": "4e412d0232d5064aeb34b65aae595e06c2c6c7e68725b9214e3258fe4ddf02e3", "role": "CLIENT", "short_name": "SOVS", "snr": 6.0, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.764, "battery_level": 101, "channel_utilization": 9.06, "uptime_seconds": 2952, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1796, "long_name": "Burning Shark", "next_hop": 0, "num": "0x0d1fe373", "position": {"altitude": 1245, "latitude": 33.269548, "location_source": "LOC_INTERNAL", "longitude": -107.864747, "time_offset_sec": 1804}, "public_key_hex": "2c95a05f1cc317b5143f56738b1afa3b39f208b2c14707379b18d1df43117005", "role": "CLIENT", "short_name": "BBUJ", "snr": 9.57, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.659, "battery_level": 30, "channel_utilization": 6.1, "uptime_seconds": 31503, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "XIAO_NRF52_KIT", "last_heard_offset_sec": 1110, "long_name": "Frosty Cedar", "next_hop": 0, "num": "0x0d85ef71", "position": {"altitude": 1251, "latitude": 33.19538, "location_source": "LOC_INTERNAL", "longitude": -107.387525, "time_offset_sec": 1119}, "public_key_hex": "5eaf73c9bd07af00f79646c181be7a0a658bc54d8e11ef0d1eb9b8c0894b6bfe", "role": "CLIENT", "short_name": "FT82", "snr": 4.36, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.148, "battery_level": 40, "channel_utilization": 2.73, "uptime_seconds": 158367, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 48, "long_name": "Howling Yucca", "next_hop": 169, "num": "0x0d912d6a", "position": {"altitude": 1064, "latitude": 33.365223, "location_source": "LOC_INTERNAL", "longitude": -105.817308, "time_offset_sec": 348}, "public_key_hex": "c72b81924f43929c2a0b4f4ad9ba91f2c8366597cb1f29712d2ca6c838408869", "role": "ROUTER_LATE", "short_name": "🐝", "snr": 2.53, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.599, "battery_level": 29, "channel_utilization": 5.13, "uptime_seconds": 3122, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 7112, "long_name": "Misty Juniper", "next_hop": 0, "num": "0x0d92f6c4", "position": null, "public_key_hex": "e03daa83e2a57b7eb88c58934c1163f9a5b93ae708ef141e8762fbd98a084dac", "role": "CLIENT", "short_name": "MWBU", "snr": 9.92, "status": null, "telemetry": {"air_util_tx": 0.239, "battery_level": 91, "channel_utilization": 6.12, "uptime_seconds": 28687, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 10729, "long_name": "Stone Gecko", "next_hop": 0, "num": "0x0dc9d2cf", "position": {"altitude": 1800, "latitude": 33.41672, "location_source": "LOC_INTERNAL", "longitude": -108.143642, "time_offset_sec": 10950}, "public_key_hex": "4d6e74296eb3400dcc4780695b6c95d0527e9c7f6fd5f9d7a8174ef2181e85ef", "role": "CLIENT", "short_name": "SW0X", "snr": 6.12, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3758, "long_name": "Frosty Coyote", "next_hop": 0, "num": "0x0deb8b5a", "position": {"altitude": 1344, "latitude": 32.478716, "location_source": "LOC_INTERNAL", "longitude": -107.37165, "time_offset_sec": 3897}, "public_key_hex": "b1036ef0947b0656f5ed287ebac028324fe7bd37e4100cbdcb5e014055c953a0", "role": "CLIENT", "short_name": "FLVI", "snr": 9.85, "status": null, "telemetry": {"air_util_tx": 0.861, "battery_level": 48, "channel_utilization": 15.41, "uptime_seconds": 30139, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.46, "iaq": 69, "relative_humidity": 59.72, "temperature": 17.81}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3566, "long_name": "Rough Otter", "next_hop": 0, "num": "0x0def6f6a", "position": {"altitude": 1492, "latitude": 32.536332, "location_source": "LOC_INTERNAL", "longitude": -106.990473, "time_offset_sec": 3643}, "public_key_hex": "7cb43e97fd9657a4a60777359776b3c9924b0417ba93172577d4698fbf6408e9", "role": "CLIENT_BASE", "short_name": "🌲", "snr": 5.23, "status": {"status": "offline-soon"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1889, "long_name": "Black Cobra", "next_hop": 0, "num": "0x0df3b8c4", "position": null, "public_key_hex": "", "role": "SENSOR", "short_name": "BR13", "snr": 1.23, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.135, "battery_level": 39, "channel_utilization": 15.83, "uptime_seconds": 41925, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 577, "long_name": "Old Falcon", "next_hop": 11, "num": "0x0df59edb", "position": {"altitude": 1212, "latitude": 34.479036, "location_source": "LOC_INTERNAL", "longitude": -107.457394, "time_offset_sec": 603}, "public_key_hex": "20842fad6621184c7800983b339dd4871344f58120782a986544bc7b520f1605", "role": "SENSOR", "short_name": "O56W", "snr": 9.02, "status": null, "telemetry": {"air_util_tx": 0.661, "battery_level": 76, "channel_utilization": 13.94, "uptime_seconds": 93583, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4167, "long_name": "Iron Crow", "next_hop": 148, "num": "0x0e4f359b", "position": {"altitude": 1417, "latitude": 32.116536, "location_source": "LOC_INTERNAL", "longitude": -106.464848, "time_offset_sec": 4190}, "public_key_hex": "c65ded3fb0f67064d293c50895bfd2f261dd5d47ff7bae08d0160e8f562bafdd", "role": "CLIENT", "short_name": "IAZK", "snr": 7.07, "status": null, "telemetry": {"air_util_tx": 2.031, "battery_level": 28, "channel_utilization": 4.55, "uptime_seconds": 30260, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.25, "iaq": 87, "relative_humidity": 59.66, "temperature": 27.46}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2267, "long_name": "Giant Dolphin", "next_hop": 0, "num": "0x0e77eadc", "position": {"altitude": 1682, "latitude": 33.857735, "location_source": "LOC_INTERNAL", "longitude": -108.027733, "time_offset_sec": 2355}, "public_key_hex": "adc757ab7ec07f3d1a3e2c823d86ec170a890fbe51f5fcbeaae9dcaf498a4a8b", "role": "CLIENT", "short_name": "GZ7V", "snr": 6.3, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.56, "battery_level": 18, "channel_utilization": 22.65, "uptime_seconds": 122749, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": {"barometric_pressure": 1009.79, "iaq": 36, "relative_humidity": 66.4, "temperature": 20.32}, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1258, "long_name": "Smooth Lynx", "next_hop": 220, "num": "0x0ea378d2", "position": {"altitude": 1194, "latitude": 33.319194, "location_source": "LOC_INTERNAL", "longitude": -107.059689, "time_offset_sec": 1349}, "public_key_hex": "f6a4f1812f7e2c5ad724eddc274442dcef938794fa571ba573f5cdb4ae8c05dc", "role": "CLIENT_MUTE", "short_name": "ST7P", "snr": -0.44, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4344, "long_name": "Short Salmon", "next_hop": 0, "num": "0x0eb1b813", "position": {"altitude": 927, "latitude": 33.72586, "location_source": "LOC_INTERNAL", "longitude": -106.865281, "time_offset_sec": 4442}, "public_key_hex": "fc1a651c0670dc248590b2325c6cbf2e69537b3d71690435e4b415de618a4145", "role": "CLIENT", "short_name": "SE3O", "snr": 2.54, "status": null, "telemetry": {"air_util_tx": 0.692, "battery_level": 94, "channel_utilization": 10.23, "uptime_seconds": 45143, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 1233, "long_name": "Quick Tortoise", "next_hop": 0, "num": "0x0ec13ecd", "position": {"altitude": 1572, "latitude": 33.205671, "location_source": "LOC_INTERNAL", "longitude": -107.82505, "time_offset_sec": 1513}, "public_key_hex": "12fbc71736349cfbb7f6db8ffbfac15ce4d6ee34076808c344e05a54ee39e8c8", "role": "ROUTER", "short_name": "QQUF", "snr": 7.13, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 966, "long_name": "Old Mesa", "next_hop": 158, "num": "0x0ed4544e", "position": {"altitude": 1394, "latitude": 32.488429, "location_source": "LOC_INTERNAL", "longitude": -107.394924, "time_offset_sec": 1198}, "public_key_hex": "25defabdae955971046dfdfefa3c2d9ef299cc15b712fe0c9344678270cd3024", "role": "CLIENT", "short_name": "OTWU", "snr": 3.49, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 3275, "long_name": "Sky Whale", "next_hop": 179, "num": "0x0ee7d248", "position": {"altitude": 1434, "latitude": 32.612913, "location_source": "LOC_INTERNAL", "longitude": -107.016187, "time_offset_sec": 3363}, "public_key_hex": "f69bb17058043717bf70e71e33abb986e130ab7801922d63d796ec7ee5f44bc5", "role": "CLIENT", "short_name": "SMOD", "snr": 6.8, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.584, "battery_level": 91, "channel_utilization": 19.77, "uptime_seconds": 6504, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 3648, "long_name": "Fast Iguana", "next_hop": 1, "num": "0x0f183bb1", "position": null, "public_key_hex": "7c368d2cdd5aaf8824d5e46db264e98c712bb853b95faf71f2dd081268d4eb4a", "role": "CLIENT_HIDDEN", "short_name": "FTCR", "snr": 5.84, "status": null, "telemetry": {"air_util_tx": 0.983, "battery_level": 74, "channel_utilization": 34.09, "uptime_seconds": 113574, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.98, "iaq": 75, "relative_humidity": 48.21, "temperature": 16.26}, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 945, "long_name": "Sunny Stag", "next_hop": 0, "num": "0x0f1d0f4a", "position": {"altitude": 1339, "latitude": 33.322138, "location_source": "LOC_INTERNAL", "longitude": -107.970934, "time_offset_sec": 1188}, "public_key_hex": "b107b44be97382574b6e5d7c40fccfd27a40d508d4826375d4019f00ff1b4c89", "role": "CLIENT", "short_name": "🌙", "snr": 8.97, "status": null, "telemetry": {"air_util_tx": 1.018, "battery_level": 54, "channel_utilization": 13.26, "uptime_seconds": 84072, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.91, "iaq": 11, "relative_humidity": 7.8, "temperature": 14.47}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 519, "long_name": "Desert Turtle", "next_hop": 0, "num": "0x0f2e6add", "position": null, "public_key_hex": "5cc118e69c5aa9c0bd28f79d80e3776da9c6271a1f584eb624818a912db92176", "role": "CLIENT_HIDDEN", "short_name": "D25O", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.393, "battery_level": 82, "channel_utilization": 2.6, "uptime_seconds": 22449, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3770, "long_name": "Smooth Yucca KX2WH", "next_hop": 0, "num": "0x0fbd1182", "position": {"altitude": 1544, "latitude": 32.827035, "location_source": "LOC_INTERNAL", "longitude": -106.921929, "time_offset_sec": 3859}, "public_key_hex": "78eb091f3bcb1673f1ea0b21a4a7df846e969c5d1e405e3c37a26c27ab4334ab", "role": "CLIENT", "short_name": "SEG3", "snr": 3.71, "status": null, "telemetry": {"air_util_tx": 0.034, "battery_level": 30, "channel_utilization": 13.53, "uptime_seconds": 24405, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 94, "long_name": "River Eagle", "next_hop": 152, "num": "0x0fd29740", "position": {"altitude": 1180, "latitude": 33.773325, "location_source": "LOC_INTERNAL", "longitude": -106.964555, "time_offset_sec": 206}, "public_key_hex": "ca6196b71d51528bdb6db185d9b6edd786f8d1e32d701975f3ff8490a321f315", "role": "CLIENT", "short_name": "RIN7", "snr": 11.75, "status": null, "telemetry": {"air_util_tx": 1.818, "battery_level": 19, "channel_utilization": 10.67, "uptime_seconds": 10371, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6040, "long_name": "Iron Whale", "next_hop": 200, "num": "0x0fde0b92", "position": {"altitude": 1544, "latitude": 33.73847, "location_source": "LOC_INTERNAL", "longitude": -107.057098, "time_offset_sec": 6306}, "public_key_hex": "878be7ebc2f45a04772104515a1c352818fc7c9cff2d82995ae2222b48a93556", "role": "CLIENT", "short_name": "IVXC", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.894, "battery_level": 77, "channel_utilization": 15.78, "uptime_seconds": 141188, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 12213, "long_name": "Desert Ridge", "next_hop": 0, "num": "0x1023ae9c", "position": {"altitude": 1611, "latitude": 32.684915, "location_source": "LOC_INTERNAL", "longitude": -107.549063, "time_offset_sec": 12245}, "public_key_hex": "0774c8048e516e6d226294f1af0fb75f8591e075a3ce3af1304979a9a67eaa59", "role": "TRACKER", "short_name": "DWRA", "snr": 11.11, "status": null, "telemetry": {"air_util_tx": 0.417, "battery_level": 101, "channel_utilization": 1.2, "uptime_seconds": 113959, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.19, "iaq": 76, "relative_humidity": 59.34, "temperature": 23.33}, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 758, "long_name": "Lone Oak KE8GD", "next_hop": 102, "num": "0x105fd0d3", "position": null, "public_key_hex": "682ebc16c0c8d5b7093150c35455f89a23e541e92ebedbdc411975de6911f90e", "role": "CLIENT", "short_name": "LI7D", "snr": 6.42, "status": null, "telemetry": {"air_util_tx": 1.14, "battery_level": 101, "channel_utilization": 4.56, "uptime_seconds": 16288, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 495, "long_name": "Black Trout K16WL", "next_hop": 0, "num": "0x108028d6", "position": {"altitude": 1118, "latitude": 33.192007, "location_source": "LOC_INTERNAL", "longitude": -107.238249, "time_offset_sec": 756}, "public_key_hex": "3a2c92622ae27106ca95947c26e04587efe2b63c48f310bd56b17e34dc2147c1", "role": "CLIENT", "short_name": "BK22", "snr": 3.06, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.948, "battery_level": 38, "channel_utilization": 9.72, "uptime_seconds": 23763, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.67, "iaq": 27, "relative_humidity": 70.73, "temperature": 26.59}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 860, "long_name": "Dawn Marmot", "next_hop": 0, "num": "0x10a21605", "position": {"altitude": 1443, "latitude": 33.762657, "location_source": "LOC_INTERNAL", "longitude": -107.35618, "time_offset_sec": 1038}, "public_key_hex": "75c5d6a3a86dd04d07603287881d43e83215ca7776ec8c4b483c97d176145c0a", "role": "CLIENT_HIDDEN", "short_name": "DD9R", "snr": 10.86, "status": null, "telemetry": {"air_util_tx": 0.331, "battery_level": 12, "channel_utilization": 19.29, "uptime_seconds": 26566, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.83, "iaq": 63, "relative_humidity": 89.61, "temperature": 16.04}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1918, "long_name": "Wandering Bronco", "next_hop": 0, "num": "0x10dd0015", "position": {"altitude": 1121, "latitude": 31.909184, "location_source": "LOC_INTERNAL", "longitude": -107.468487, "time_offset_sec": 1945}, "public_key_hex": "f23e2048a6187f99d6201821abeff6b3807209692e8dc04695ba6c6b928146d0", "role": "ROUTER", "short_name": "WBNC", "snr": 3.51, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1019.85, "iaq": 24, "relative_humidity": 74.62, "temperature": 30.43}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 11243, "long_name": "Quick Otter", "next_hop": 122, "num": "0x10f1f52b", "position": {"altitude": 1642, "latitude": 33.251854, "location_source": "LOC_INTERNAL", "longitude": -107.256967, "time_offset_sec": 11304}, "public_key_hex": "ae3e6f9e0a5654322a796e5fe1ee11f24671717a97e9f0096ab415989fc9127b", "role": "CLIENT_MUTE", "short_name": "QC60", "snr": 6.16, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1282, "long_name": "Soft Lynx", "next_hop": 0, "num": "0x110f5b6e", "position": {"altitude": 1156, "latitude": 33.056943, "location_source": "LOC_INTERNAL", "longitude": -107.503093, "time_offset_sec": 1326}, "public_key_hex": "ce55666cb2f3fd0cb695d903c4aef9ac312aaa461435ac4722a364ed099c57d5", "role": "CLIENT", "short_name": "SF9I", "snr": 3.34, "status": null, "telemetry": {"air_util_tx": 1.928, "battery_level": 65, "channel_utilization": 13.75, "uptime_seconds": 8895, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 379, "long_name": "Burning Pony KX8AM", "next_hop": 0, "num": "0x11128fa6", "position": {"altitude": 1005, "latitude": 32.615546, "location_source": "LOC_INTERNAL", "longitude": -107.000828, "time_offset_sec": 504}, "public_key_hex": "0cf6b5ac8cfbf0b3eba62a7ca3b8122f7df7bfb6a4cf8f68fd33a958be39bb12", "role": "ROUTER", "short_name": "B7PD", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.949, "battery_level": 19, "channel_utilization": 22.42, "uptime_seconds": 162502, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 1359, "long_name": "River Seal", "next_hop": 158, "num": "0x112c0e6d", "position": {"altitude": 1191, "latitude": 33.016867, "location_source": "LOC_INTERNAL", "longitude": -107.590775, "time_offset_sec": 1590}, "public_key_hex": "d0aeed8ce7d7333aca260a98be79cdcb61a73d3e99f25af8589362aa07fa911f", "role": "CLIENT", "short_name": "RH38", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.545, "battery_level": 71, "channel_utilization": 16.48, "uptime_seconds": 187354, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 3, "environment": {"barometric_pressure": 1022.71, "iaq": 40, "relative_humidity": 53.07, "temperature": 22.5}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5104, "long_name": "Sunny Mole", "next_hop": 63, "num": "0x112fc6f1", "position": {"altitude": 989, "latitude": 31.905147, "location_source": "LOC_INTERNAL", "longitude": -107.468083, "time_offset_sec": 5289}, "public_key_hex": "83919b37758b78f3b0ef241895c06d14fd28d3c15fdd9949234b5adec7541afe", "role": "CLIENT_MUTE", "short_name": "SXVA", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.731, "battery_level": 72, "channel_utilization": 18.47, "uptime_seconds": 36092, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 3421, "long_name": "Sleepy Falcon", "next_hop": 0, "num": "0x1158c4db", "position": {"altitude": 1450, "latitude": 33.544179, "location_source": "LOC_INTERNAL", "longitude": -106.89327, "time_offset_sec": 3576}, "public_key_hex": "1c7d4db967f06f57a6fb9140bd257966302555d0d6deb65dc38edecf82cc033b", "role": "CLIENT", "short_name": "ST5M", "snr": 10.31, "status": null, "telemetry": {"air_util_tx": 0.32, "battery_level": 83, "channel_utilization": 10.1, "uptime_seconds": 120256, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1126, "long_name": "Lost Aspen", "next_hop": 28, "num": "0x115a9819", "position": {"altitude": 1269, "latitude": 32.863432, "location_source": "LOC_INTERNAL", "longitude": -107.296217, "time_offset_sec": 1326}, "public_key_hex": "1309016223617973a860885fd1acf08aea9d4977204cb42cb01f01e584e090bc", "role": "CLIENT", "short_name": "LASJ", "snr": 2.7, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 188, "long_name": "Tall Fox", "next_hop": 0, "num": "0x1170cac8", "position": {"altitude": 1455, "latitude": 32.527327, "location_source": "LOC_INTERNAL", "longitude": -107.192606, "time_offset_sec": 235}, "public_key_hex": "3742936ad20aaee7316c2a65e50b60c6c6cdc8248cefb5188b87b91ce15c8ec1", "role": "CLIENT", "short_name": "TXDX", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.641, "battery_level": 12, "channel_utilization": 15.5, "uptime_seconds": 23237, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 16808, "long_name": "Misty Coyote", "next_hop": 81, "num": "0x11bbcc67", "position": {"altitude": 975, "latitude": 34.000516, "location_source": "LOC_INTERNAL", "longitude": -108.305185, "time_offset_sec": 17002}, "public_key_hex": "e65e7b15ae69e197d4b18f2bb0d88dc3b8d33eb2183849664495d5023ded52bf", "role": "CLIENT", "short_name": "M5J7", "snr": 2.43, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.458, "battery_level": 85, "channel_utilization": 10.07, "uptime_seconds": 49766, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2186, "long_name": "Canyon Marmot", "next_hop": 0, "num": "0x11c918dc", "position": {"altitude": 1329, "latitude": 32.665425, "location_source": "LOC_INTERNAL", "longitude": -106.651499, "time_offset_sec": 2416}, "public_key_hex": "9d24091e380fc528ee04f31c6851fe389937caaea324ab8b6b76e32190ba5054", "role": "ROUTER", "short_name": "CDX5", "snr": 3.11, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 998.36, "iaq": 60, "relative_humidity": 49.97, "temperature": 33.66}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1869, "long_name": "Copper Ridge", "next_hop": 0, "num": "0x11e2947d", "position": {"altitude": 1295, "latitude": 33.545277, "location_source": "LOC_INTERNAL", "longitude": -106.845661, "time_offset_sec": 1946}, "public_key_hex": "061ef9bc2b959922ddf8d84987500aaeb11f11c5a6db9874d84759ef3586f72d", "role": "CLIENT", "short_name": "CGBU", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.776, "battery_level": 61, "channel_utilization": 14.49, "uptime_seconds": 44763, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 999.88, "iaq": 47, "relative_humidity": 47.12, "temperature": 25.23}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 1374, "long_name": "Desert Elk", "next_hop": 63, "num": "0x11e361d9", "position": {"altitude": 1475, "latitude": 32.642127, "location_source": "LOC_INTERNAL", "longitude": -107.425889, "time_offset_sec": 1393}, "public_key_hex": "86abab77e9a647bd7be8ac2e5d9aaf9d73152b66548b588a390bd0a2cdf2072a", "role": "CLIENT", "short_name": "DFJ5", "snr": 4.38, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2732, "long_name": "Blue Marmot", "next_hop": 0, "num": "0x11fac348", "position": {"altitude": 725, "latitude": 32.96993, "location_source": "LOC_INTERNAL", "longitude": -106.973159, "time_offset_sec": 3008}, "public_key_hex": "029a5ce7ed1054b61569dcb7f35c319a0e5d699255435fbe6abf4c5870ca072e", "role": "CLIENT_MUTE", "short_name": "BOI8", "snr": 1.8, "status": null, "telemetry": {"air_util_tx": 0.373, "battery_level": 50, "channel_utilization": 14.5, "uptime_seconds": 9314, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.41, "iaq": 20, "relative_humidity": 61.4, "temperature": 16.84}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3541, "long_name": "Quick Ridge", "next_hop": 0, "num": "0x1211b098", "position": {"altitude": 1543, "latitude": 32.049579, "location_source": "LOC_INTERNAL", "longitude": -107.263002, "time_offset_sec": 3819}, "public_key_hex": "bbae57d506d3690a267719eb2df66a64d28a76241883e6dd2c89fc93d8fbc7c9", "role": "CLIENT", "short_name": "QPCM", "snr": 5.84, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.955, "battery_level": 81, "channel_utilization": 15.49, "uptime_seconds": 936, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.15, "iaq": 7, "relative_humidity": 61.45, "temperature": 13.69}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3207, "long_name": "Silver Juniper", "next_hop": 0, "num": "0x121881e4", "position": {"altitude": 1051, "latitude": 34.281716, "location_source": "LOC_INTERNAL", "longitude": -107.359156, "time_offset_sec": 3453}, "public_key_hex": "", "role": "CLIENT", "short_name": "SGI7", "snr": 6.58, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1014.82, "iaq": 97, "relative_humidity": 31.18, "temperature": 27.08}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 879, "long_name": "Lone Heron", "next_hop": 210, "num": "0x122de244", "position": null, "public_key_hex": "a7f154dde99624d4e0190d03a5d4f515fc61a3516340eb842d704346b58d8f21", "role": "CLIENT_MUTE", "short_name": "LV0Y", "snr": 4.97, "status": null, "telemetry": {"air_util_tx": 0.365, "battery_level": 55, "channel_utilization": 11.51, "uptime_seconds": 9294, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 357, "long_name": "Lunar Oak", "next_hop": 0, "num": "0x124db1cc", "position": {"altitude": 1191, "latitude": 33.311169, "location_source": "LOC_INTERNAL", "longitude": -107.040298, "time_offset_sec": 394}, "public_key_hex": "065ea229c0de5183fd98e64918bdbd8b9a7d45eff8c6b2527845b04861654c40", "role": "CLIENT", "short_name": "LVXH", "snr": 6.19, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.169, "battery_level": 33, "channel_utilization": 11.14, "uptime_seconds": 127724, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 714, "long_name": "Sky Pine", "next_hop": 0, "num": "0x12d1617d", "position": {"altitude": 1156, "latitude": 33.562953, "location_source": "LOC_INTERNAL", "longitude": -107.718195, "time_offset_sec": 978}, "public_key_hex": "bae0e2e8a6973d371902f2b9b822d81e8a7483917d052d71acd9c5d272783468", "role": "CLIENT", "short_name": "SL27", "snr": 6.67, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.348, "battery_level": 50, "channel_utilization": 16.92, "uptime_seconds": 16658, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 992.97, "iaq": 72, "relative_humidity": 53.2, "temperature": 22.54}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4806, "long_name": "Silver Heron", "next_hop": 170, "num": "0x12da019c", "position": {"altitude": 1469, "latitude": 33.784331, "location_source": "LOC_INTERNAL", "longitude": -106.521504, "time_offset_sec": 4963}, "public_key_hex": "2b2ae65e274a785f3f2af297c2f3c26c6f7a351e35af83030aaeb453c6cfa59a", "role": "CLIENT", "short_name": "SU05", "snr": 7.36, "status": null, "telemetry": {"air_util_tx": 0.671, "battery_level": 81, "channel_utilization": 22.87, "uptime_seconds": 259332, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 5, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 319, "long_name": "Sleepy Pony", "next_hop": 225, "num": "0x13168e37", "position": {"altitude": 1359, "latitude": 32.597583, "location_source": "LOC_INTERNAL", "longitude": -107.327842, "time_offset_sec": 584}, "public_key_hex": "277d6020877161d69733d6fc9e44fb75de417c676a2061f38b9855ce4b36a3a7", "role": "CLIENT", "short_name": "SB43", "snr": 9.42, "status": null, "telemetry": {"air_util_tx": 0.296, "battery_level": 39, "channel_utilization": 27.21, "uptime_seconds": 263327, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 13629, "long_name": "Solar Stag K14DR", "next_hop": 0, "num": "0x1333819b", "position": {"altitude": 1697, "latitude": 32.474472, "location_source": "LOC_INTERNAL", "longitude": -107.995283, "time_offset_sec": 13777}, "public_key_hex": "eeb0e39c18cabc92b077a2e95f281fcf45931404007aaae546fd5b823520f267", "role": "ROUTER", "short_name": "🦉", "snr": -0.02, "status": null, "telemetry": {"air_util_tx": 0.95, "battery_level": 53, "channel_utilization": 12.39, "uptime_seconds": 63865, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2753, "long_name": "Green Bison", "next_hop": 0, "num": "0x136e21cb", "position": {"altitude": 1201, "latitude": 33.184656, "location_source": "LOC_INTERNAL", "longitude": -106.984309, "time_offset_sec": 2788}, "public_key_hex": "1da72315f1466d3d3cd6e469df0958619a2f25423062ca392d528bdcd833eaac", "role": "CLIENT", "short_name": "G4RB", "snr": 3.17, "status": null, "telemetry": {"air_util_tx": 1.149, "battery_level": 101, "channel_utilization": 16.5, "uptime_seconds": 14613, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2039, "long_name": "Loud Trout", "next_hop": 254, "num": "0x13922035", "position": {"altitude": 1398, "latitude": 32.548523, "location_source": "LOC_INTERNAL", "longitude": -107.583098, "time_offset_sec": 2284}, "public_key_hex": "6b324a1ce452ac3db4e2d7579c39bb7b93eb5acd3065f9b3ad863b7e2b5a5040", "role": "CLIENT", "short_name": "L9VN", "snr": 5.73, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.526, "battery_level": 74, "channel_utilization": 13.12, "uptime_seconds": 40612, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 4507, "long_name": "Blue Aspen", "next_hop": 0, "num": "0x13b41093", "position": {"altitude": 1753, "latitude": 33.238413, "location_source": "LOC_INTERNAL", "longitude": -106.09825, "time_offset_sec": 4702}, "public_key_hex": "c3b68bfda030d928e954e6eb6cec91eafccf5845cf681c7bf51681fa8388eafe", "role": "CLIENT", "short_name": "B317", "snr": 3.15, "status": null, "telemetry": {"air_util_tx": 0.354, "battery_level": 26, "channel_utilization": 10.17, "uptime_seconds": 108889, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 997.39, "iaq": 46, "relative_humidity": 82.33, "temperature": 24.5}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 2915, "long_name": "Tiny Viper", "next_hop": 0, "num": "0x13c20487", "position": {"altitude": 1671, "latitude": 32.892854, "location_source": "LOC_INTERNAL", "longitude": -107.494806, "time_offset_sec": 2953}, "public_key_hex": "0d9045f0565f6eb7c24eea4379a860e390d13041f9a587521c5fe63d527e5162", "role": "CLIENT", "short_name": "🐺", "snr": 11.06, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.229, "battery_level": 71, "channel_utilization": 12.11, "uptime_seconds": 127976, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.28, "iaq": 17, "relative_humidity": 50.38, "temperature": 18.8}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 8681, "long_name": "Roving Marmot", "next_hop": 0, "num": "0x13c5fc81", "position": {"altitude": 1099, "latitude": 33.136767, "location_source": "LOC_INTERNAL", "longitude": -106.920636, "time_offset_sec": 8826}, "public_key_hex": "df0cc8b8702c6906017ba5aa4e3365b37918838273bd0183a26fe254a6526cd3", "role": "CLIENT", "short_name": "RMF1", "snr": 0.46, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1636, "long_name": "Shady Falcon", "next_hop": 23, "num": "0x13d0ddac", "position": null, "public_key_hex": "4da9e8635c8a1a4f1b8283d59dc4380256fc997714b10ccb9232c9d3f409e367", "role": "CLIENT", "short_name": "SX0T", "snr": 9.06, "status": null, "telemetry": {"air_util_tx": 0.751, "battery_level": 41, "channel_utilization": 21.65, "uptime_seconds": 41190, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 14657, "long_name": "Forest Cobra", "next_hop": 0, "num": "0x1474947a", "position": {"altitude": 1622, "latitude": 32.325322, "location_source": "LOC_INTERNAL", "longitude": -108.380692, "time_offset_sec": 14836}, "public_key_hex": "38a0da2eab2087b80658bb2c0f859da7cb82e0dc52c5717d00704c282d1087d7", "role": "CLIENT", "short_name": "FEJE", "snr": 6.18, "status": null, "telemetry": {"air_util_tx": 0.45, "battery_level": 25, "channel_utilization": 7.77, "uptime_seconds": 3634, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1969, "long_name": "Whispering Pike", "next_hop": 0, "num": "0x148d0996", "position": {"altitude": 1232, "latitude": 33.107475, "location_source": "LOC_INTERNAL", "longitude": -107.366898, "time_offset_sec": 2187}, "public_key_hex": "3c9f3f6793dd6c95c198b0a7e5985f760d81bf4ee0b12450394eb522809d9074", "role": "ROUTER", "short_name": "WM9Y", "snr": 5.01, "status": null, "telemetry": {"air_util_tx": 1.291, "battery_level": 90, "channel_utilization": 8.95, "uptime_seconds": 15182, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 191, "long_name": "Mountain Pike", "next_hop": 75, "num": "0x14a9958f", "position": {"altitude": 1340, "latitude": 32.964188, "location_source": "LOC_INTERNAL", "longitude": -107.295832, "time_offset_sec": 491}, "public_key_hex": "183792ff27035a232fb5bcd64880d4533e36ce6f07968f96fc06f3e7b8cd131c", "role": "CLIENT_MUTE", "short_name": "MKCO", "snr": 10.53, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.145, "battery_level": 95, "channel_utilization": 8.07, "uptime_seconds": 202430, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1010.23, "iaq": 87, "relative_humidity": 61.14, "temperature": 20.14}, "hops_away": 4, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2794, "long_name": "Giant Pony", "next_hop": 55, "num": "0x14aabb5b", "position": {"altitude": 1137, "latitude": 33.528292, "location_source": "LOC_INTERNAL", "longitude": -106.561147, "time_offset_sec": 2874}, "public_key_hex": "1f735bb5959abfda915aeb63976689ffae552c3869c8ece73a8e2d6b78ebb506", "role": "CLIENT", "short_name": "GZB2", "snr": 11.29, "status": null, "telemetry": {"air_util_tx": 0.313, "battery_level": 10, "channel_utilization": 16.52, "uptime_seconds": 48450, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 37, "long_name": "Brave Arroyo", "next_hop": 0, "num": "0x14c2e8d8", "position": {"altitude": 1413, "latitude": 33.027762, "location_source": "LOC_INTERNAL", "longitude": -107.948968, "time_offset_sec": 267}, "public_key_hex": "fef5e1207eea76e9aa39c406cf66632d8d8cb5fc4f79130e7f2c6447c0c6939d", "role": "SENSOR", "short_name": "BNZQ", "snr": 2.06, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 9009, "long_name": "Mountain Falcon", "next_hop": 0, "num": "0x14dcaabd", "position": {"altitude": 1417, "latitude": 33.687087, "location_source": "LOC_INTERNAL", "longitude": -107.376095, "time_offset_sec": 9170}, "public_key_hex": "1d6713be21b9570f30382d1e745097b60b209abcd7d014c8310f1d0b978b619a", "role": "SENSOR", "short_name": "MCQ2", "snr": -0.43, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 213, "long_name": "Dawn Owl", "next_hop": 0, "num": "0x14e0b73e", "position": {"altitude": 1210, "latitude": 33.801271, "location_source": "LOC_INTERNAL", "longitude": -106.802082, "time_offset_sec": 370}, "public_key_hex": "32a70869957290a3d92833d929e247c6cd331b32dd2c635aa6a064b830fb4e2d", "role": "CLIENT", "short_name": "DLRD", "snr": 7.81, "status": null, "telemetry": {"air_util_tx": 0.778, "battery_level": 86, "channel_utilization": 23.29, "uptime_seconds": 57383, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 911, "long_name": "Howling Shark", "next_hop": 152, "num": "0x151b72d8", "position": {"altitude": 1547, "latitude": 33.298023, "location_source": "LOC_INTERNAL", "longitude": -106.934008, "time_offset_sec": 1136}, "public_key_hex": "e414a29f829f8366e2f38527a227a2a90486148faffcccc94af877bb3e86d92f", "role": "CLIENT", "short_name": "HBRS", "snr": 8.15, "status": null, "telemetry": {"air_util_tx": 0.487, "battery_level": 48, "channel_utilization": 22.65, "uptime_seconds": 25506, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.2, "iaq": 30, "relative_humidity": 60.02, "temperature": 15.81}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 536, "long_name": "Bright Bass", "next_hop": 41, "num": "0x152e8c65", "position": {"altitude": 1738, "latitude": 33.205239, "location_source": "LOC_INTERNAL", "longitude": -107.105309, "time_offset_sec": 641}, "public_key_hex": "0a9d2c0e097f3859cc9b15e0f8dff58403f69145fec03c526390fdf19d58fe70", "role": "CLIENT", "short_name": "B1PR", "snr": 6.86, "status": null, "telemetry": {"air_util_tx": 0.244, "battery_level": 56, "channel_utilization": 16.42, "uptime_seconds": 8319, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 5506, "long_name": "Bright Mesa", "next_hop": 33, "num": "0x15818242", "position": {"altitude": 1099, "latitude": 33.273411, "location_source": "LOC_INTERNAL", "longitude": -108.136297, "time_offset_sec": 5705}, "public_key_hex": "0f08eeaa7563bf80eecba1002fab3abf16b7006db771eff0ede8edb504d8ecf1", "role": "CLIENT", "short_name": "BVXK", "snr": 8.39, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.553, "battery_level": 58, "channel_utilization": 4.97, "uptime_seconds": 90719, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 11409, "long_name": "Stone Phoenix", "next_hop": 0, "num": "0x1595e665", "position": {"altitude": 1856, "latitude": 32.945081, "location_source": "LOC_INTERNAL", "longitude": -106.901396, "time_offset_sec": 11644}, "public_key_hex": "e14f0215d5a08bf1b1717943cb377e94dfdf2509060d6abef9672bce39788179", "role": "CLIENT", "short_name": "SB4Y", "snr": 2.28, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3798, "long_name": "Stone Owl", "next_hop": 0, "num": "0x159e566a", "position": {"altitude": 1065, "latitude": 33.007183, "location_source": "LOC_INTERNAL", "longitude": -106.347215, "time_offset_sec": 3892}, "public_key_hex": "0065ab4f8ebbd4b9dcfec8736de365045081fb86f9136ce0f69f1153ff944ab2", "role": "ROUTER", "short_name": "SBCS", "snr": 4.84, "status": null, "telemetry": {"air_util_tx": 1.157, "battery_level": 28, "channel_utilization": 10.3, "uptime_seconds": 75028, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1169, "long_name": "Brave Dolphin", "next_hop": 0, "num": "0x15d325a0", "position": {"altitude": 1308, "latitude": 32.513593, "location_source": "LOC_INTERNAL", "longitude": -108.186989, "time_offset_sec": 1308}, "public_key_hex": "d5448d2fef30faef4b9096eef41aadc53bec272d238635b92de524020508514b", "role": "CLIENT", "short_name": "B817", "snr": 3.43, "status": null, "telemetry": {"air_util_tx": 0.299, "battery_level": 63, "channel_utilization": 10.46, "uptime_seconds": 134035, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7084, "long_name": "Sneaky Bear", "next_hop": 0, "num": "0x15fb1ab1", "position": {"altitude": 1163, "latitude": 33.758908, "location_source": "LOC_INTERNAL", "longitude": -106.830962, "time_offset_sec": 7204}, "public_key_hex": "", "role": "CLIENT", "short_name": "SK9C", "snr": 8.59, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.57, "battery_level": 44, "channel_utilization": 5.32, "uptime_seconds": 55139, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 1067, "long_name": "Storm Oak", "next_hop": 0, "num": "0x16349d4a", "position": {"altitude": 1532, "latitude": 33.701342, "location_source": "LOC_INTERNAL", "longitude": -107.257542, "time_offset_sec": 1153}, "public_key_hex": "8de93b125e2720b01eeb9d89cd448dd1a0c6920d4895528b88784bc4327be7f7", "role": "CLIENT", "short_name": "🐢", "snr": 2.53, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.05, "iaq": 3, "relative_humidity": 37.5, "temperature": 21.48}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2676, "long_name": "Loud Lion", "next_hop": 239, "num": "0x1635d4f9", "position": null, "public_key_hex": "995c6439d73e8ed64b1bce832a6cae74d9829bd88a978a257d67a50ebac06a2c", "role": "CLIENT_BASE", "short_name": "LA9O", "snr": 5.51, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.331, "battery_level": 43, "channel_utilization": 2.23, "uptime_seconds": 433247, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": {"barometric_pressure": 1003.85, "iaq": 15, "relative_humidity": 30.02, "temperature": 19.82}, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 144, "long_name": "Steel Pike", "next_hop": 129, "num": "0x1654df3c", "position": {"altitude": 1348, "latitude": 33.116172, "location_source": "LOC_INTERNAL", "longitude": -106.75407, "time_offset_sec": 443}, "public_key_hex": "acb923170828d3ad8cad3947b07ae7c43e52c400de000c42962badc904695545", "role": "TAK_TRACKER", "short_name": "SWTE", "snr": 8.56, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.315, "battery_level": 71, "channel_utilization": 14.93, "uptime_seconds": 25653, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAG", "last_heard_offset_sec": 1443, "long_name": "River Turtle", "next_hop": 235, "num": "0x16865793", "position": {"altitude": 1086, "latitude": 33.850653, "location_source": "LOC_INTERNAL", "longitude": -107.148335, "time_offset_sec": 1582}, "public_key_hex": "b5aa29dbeca916d16ff27bb186a2f0a7a130729013807ab51e0270911f43e5af", "role": "CLIENT", "short_name": "R1KS", "snr": 2.18, "status": null, "telemetry": {"air_util_tx": 0.331, "battery_level": 60, "channel_utilization": 14.46, "uptime_seconds": 41901, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 9407, "long_name": "Frosty Lynx", "next_hop": 253, "num": "0x16cf2587", "position": null, "public_key_hex": "508e85d9e06a8cc82416aa71055968d78db9c7f14844515056236025404f39f6", "role": "CLIENT", "short_name": "FAF3", "snr": 6.52, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.081, "battery_level": 52, "channel_utilization": 17.87, "uptime_seconds": 7422, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.47, "iaq": 0, "relative_humidity": 60.17, "temperature": 31.75}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 582, "long_name": "Howling Fox", "next_hop": 0, "num": "0x17047377", "position": null, "public_key_hex": "3ccd284c52a74200eab7412a28d28f421ef65f2ecfba9b476a5b07ef70e2fea8", "role": "ROUTER", "short_name": "HO2Q", "snr": 10.56, "status": null, "telemetry": {"air_util_tx": 1.885, "battery_level": 88, "channel_utilization": 15.08, "uptime_seconds": 71242, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5621, "long_name": "Wandering Cactus", "next_hop": 254, "num": "0x170877f6", "position": {"altitude": 1626, "latitude": 32.653483, "location_source": "LOC_INTERNAL", "longitude": -106.720066, "time_offset_sec": 5728}, "public_key_hex": "846d0992438c990aa5096588e623f32d022c251ac7bfa2db9f1702ab9ad0c5b1", "role": "CLIENT", "short_name": "WX0N", "snr": 9.78, "status": null, "telemetry": {"air_util_tx": 0.146, "battery_level": 62, "channel_utilization": 6.57, "uptime_seconds": 156502, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2198, "long_name": "Drowsy Mole", "next_hop": 0, "num": "0x170e0a29", "position": {"altitude": 933, "latitude": 33.679526, "location_source": "LOC_INTERNAL", "longitude": -107.203049, "time_offset_sec": 2355}, "public_key_hex": "", "role": "CLIENT_HIDDEN", "short_name": "DA45", "snr": 12.0, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.539, "battery_level": 23, "channel_utilization": 8.88, "uptime_seconds": 304675, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4525, "long_name": "Sneaky Cactus", "next_hop": 0, "num": "0x177d18ca", "position": {"altitude": 1714, "latitude": 33.019198, "location_source": "LOC_INTERNAL", "longitude": -107.367255, "time_offset_sec": 4702}, "public_key_hex": "23d5fcd767388541aedf18ef2c6ada503a58ae320f5c4c338ad07173934bb8d9", "role": "CLIENT", "short_name": "🌙", "snr": 9.88, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.34, "battery_level": 30, "channel_utilization": 15.94, "uptime_seconds": 30803, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 7309, "long_name": "Wild Juniper", "next_hop": 0, "num": "0x179212b0", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "🦂", "snr": 5.83, "status": null, "telemetry": {"air_util_tx": 0.05, "battery_level": 13, "channel_utilization": 36.69, "uptime_seconds": 4076, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 993, "long_name": "Howling Mesa", "next_hop": 0, "num": "0x17bd8583", "position": {"altitude": 1530, "latitude": 33.407112, "location_source": "LOC_INTERNAL", "longitude": -107.204627, "time_offset_sec": 1077}, "public_key_hex": "", "role": "CLIENT", "short_name": "H9Y5", "snr": -4.63, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.803, "battery_level": 43, "channel_utilization": 20.17, "uptime_seconds": 63120, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 10305, "long_name": "Silent Eagle", "next_hop": 37, "num": "0x17d03710", "position": {"altitude": 1282, "latitude": 33.694867, "location_source": "LOC_INTERNAL", "longitude": -106.023131, "time_offset_sec": 10375}, "public_key_hex": "020c611154fa9590a5c1c215762276cf9337f27b1bf28dcddb43f01d74106057", "role": "ROUTER", "short_name": "SIPY", "snr": 2.67, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.564, "battery_level": 96, "channel_utilization": 21.11, "uptime_seconds": 83987, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 5202, "long_name": "Solar Coyote", "next_hop": 0, "num": "0x17e194df", "position": {"altitude": 1486, "latitude": 32.214549, "location_source": "LOC_INTERNAL", "longitude": -107.44145, "time_offset_sec": 5252}, "public_key_hex": "e3ec4fb949080ce2f01a2f7f7bd745a388731f24eea55aad982d5f0b2f67804a", "role": "CLIENT", "short_name": "SLNR", "snr": 1.42, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2069, "long_name": "Stone Colt", "next_hop": 202, "num": "0x1807a8f0", "position": {"altitude": 1079, "latitude": 31.904544, "location_source": "LOC_INTERNAL", "longitude": -107.051187, "time_offset_sec": 2330}, "public_key_hex": "90df448b3dc87f6e52b84dbc765c557355131a0cf9cb170b3b800007f9309798", "role": "CLIENT", "short_name": "SFEJ", "snr": 6.6, "status": null, "telemetry": {"air_util_tx": 0.235, "battery_level": 11, "channel_utilization": 28.6, "uptime_seconds": 158504, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 1630, "long_name": "Smooth Stag", "next_hop": 0, "num": "0x181b74ea", "position": {"altitude": 1433, "latitude": 33.092168, "location_source": "LOC_INTERNAL", "longitude": -108.136124, "time_offset_sec": 1843}, "public_key_hex": "", "role": "CLIENT", "short_name": "SNX6", "snr": 4.67, "status": null, "telemetry": {"air_util_tx": 0.707, "battery_level": 23, "channel_utilization": 7.78, "uptime_seconds": 141839, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 45, "long_name": "Found Doe", "next_hop": 0, "num": "0x18383508", "position": {"altitude": 1450, "latitude": 32.719499, "location_source": "LOC_INTERNAL", "longitude": -107.061998, "time_offset_sec": 317}, "public_key_hex": "2d33061747796b70c26c1fc693067522ca1d5418005f72f99d5c8f62c3566415", "role": "CLIENT_BASE", "short_name": "🗻", "snr": 9.87, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1062, "long_name": "Gold Mole", "next_hop": 0, "num": "0x18a50a06", "position": {"altitude": 1335, "latitude": 33.172046, "location_source": "LOC_INTERNAL", "longitude": -107.025888, "time_offset_sec": 1271}, "public_key_hex": "50ad50b71daf18e40234c1e79bc854eb1bb3505d073f089f0f4e23cef5c048a8", "role": "ROUTER", "short_name": "GQ3K", "snr": 0.85, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.542, "battery_level": 58, "channel_utilization": 8.31, "uptime_seconds": 387951, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 945, "long_name": "Floating Adder", "next_hop": 159, "num": "0x18d05a77", "position": {"altitude": 921, "latitude": 33.651115, "location_source": "LOC_INTERNAL", "longitude": -106.302227, "time_offset_sec": 946}, "public_key_hex": "35ca8abd0c6275e3fda7b8d87116206cf9b03ac7b5f72bd397812a93d353d40c", "role": "ROUTER", "short_name": "F5V1", "snr": 11.57, "status": {"status": "rebooted"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 8104, "long_name": "Copper Otter", "next_hop": 132, "num": "0x18f3c188", "position": null, "public_key_hex": "69c4597dcd47fca4a1fd093b3f618db1ddb528b719c730e470021954e9b77abd", "role": "CLIENT", "short_name": "CXH7", "snr": 6.11, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.242, "battery_level": 58, "channel_utilization": 13.86, "uptime_seconds": 157195, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 2219, "long_name": "Lost Cougar", "next_hop": 121, "num": "0x19733be7", "position": {"altitude": 1590, "latitude": 33.236761, "location_source": "LOC_INTERNAL", "longitude": -106.380433, "time_offset_sec": 2403}, "public_key_hex": "2945222a2ec96214fa1e8c67b186695504cf3fadda1b001e24bd8c60577b8abc", "role": "ROUTER", "short_name": "LIYJ", "snr": 8.95, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.46, "battery_level": 67, "channel_utilization": 5.61, "uptime_seconds": 98801, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 3878, "long_name": "Sky Lion", "next_hop": 93, "num": "0x19904e1f", "position": {"altitude": 1200, "latitude": 32.92138, "location_source": "LOC_INTERNAL", "longitude": -107.762805, "time_offset_sec": 3910}, "public_key_hex": "6d10df3b008d13881d9f01ced15f980ace741fd895ba3d5eb1f58903a0f93b9a", "role": "CLIENT", "short_name": "SRO0", "snr": -2.0, "status": null, "telemetry": {"air_util_tx": 0.517, "battery_level": 33, "channel_utilization": 2.73, "uptime_seconds": 157103, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1009.15, "iaq": 10, "relative_humidity": 20.3, "temperature": 28.67}, "hops_away": 3, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2178, "long_name": "Soft Juniper", "next_hop": 147, "num": "0x19d6193e", "position": null, "public_key_hex": "90a609019b9a8ad3326a888dd06b6cf946bdaee49530f116c52d43a3d16d0348", "role": "CLIENT", "short_name": "S2O6", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.322, "battery_level": 54, "channel_utilization": 3.91, "uptime_seconds": 198485, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.81, "iaq": 11, "relative_humidity": 68.38, "temperature": 28.01}, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7506, "long_name": "Brave Gecko", "next_hop": 28, "num": "0x19ed0ccc", "position": {"altitude": 1797, "latitude": 32.660055, "location_source": "LOC_INTERNAL", "longitude": -107.920466, "time_offset_sec": 7680}, "public_key_hex": "637bae084b50675afd248239feea98e6fe9df6efbbcd2592a0a5c10279f1be8c", "role": "CLIENT", "short_name": "BG4U", "snr": 10.05, "status": null, "telemetry": {"air_util_tx": 0.969, "battery_level": 72, "channel_utilization": 5.59, "uptime_seconds": 52979, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.97, "iaq": 77, "relative_humidity": 45.16, "temperature": 26.59}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3258, "long_name": "Roving Hare", "next_hop": 91, "num": "0x1a10cb87", "position": {"altitude": 1382, "latitude": 34.080722, "location_source": "LOC_INTERNAL", "longitude": -107.576173, "time_offset_sec": 3324}, "public_key_hex": "30bd8657f66f7303d6c83ee055c2820e219bf4ebf40268bff2825d9b9e88f022", "role": "CLIENT_MUTE", "short_name": "RSFU", "snr": 5.86, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.079, "battery_level": 40, "channel_utilization": 1.42, "uptime_seconds": 119280, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.46, "iaq": 73, "relative_humidity": 66.32, "temperature": 22.02}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3122, "long_name": "Frozen Pike", "next_hop": 0, "num": "0x1a1af64b", "position": null, "public_key_hex": "abddc6dcf07033e54a4357319c9d90a807529743c3e93597e58ea59e67ab2ee8", "role": "CLIENT", "short_name": "FVSO", "snr": 7.31, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4025, "long_name": "Misty Bronco", "next_hop": 186, "num": "0x1a579540", "position": {"altitude": 1036, "latitude": 32.376114, "location_source": "LOC_INTERNAL", "longitude": -107.200677, "time_offset_sec": 4128}, "public_key_hex": "ce9cf125c14900c0e1c29c09a930ff2ca160ac9a81d65749206a42bb0cc7f0db", "role": "CLIENT_MUTE", "short_name": "MXCR", "snr": 0.7, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2309, "long_name": "Soft Juniper", "next_hop": 3, "num": "0x1a9fa0e5", "position": {"altitude": 1699, "latitude": 33.768703, "location_source": "LOC_INTERNAL", "longitude": -107.476326, "time_offset_sec": 2312}, "public_key_hex": "912bdc4766968667c3e28ced42c592620a94cfb0118ec22255ccae565211b254", "role": "TRACKER", "short_name": "S3EJ", "snr": 2.12, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2223, "long_name": "Floating Bear", "next_hop": 46, "num": "0x1ac10a21", "position": {"altitude": 1449, "latitude": 32.020114, "location_source": "LOC_INTERNAL", "longitude": -107.203295, "time_offset_sec": 2270}, "public_key_hex": "d4e7bd8e8e7e5c63819af1c1857aa90c9595ab163e91a7efb030123a82337f40", "role": "CLIENT", "short_name": "FUCC", "snr": 3.85, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1764, "long_name": "Sunny Trout", "next_hop": 0, "num": "0x1ad2e3db", "position": {"altitude": 1412, "latitude": 33.863561, "location_source": "LOC_INTERNAL", "longitude": -107.395672, "time_offset_sec": 1771}, "public_key_hex": "0449b97a96b1f2674f1589fb17b8dca3e5419caf37e3af9d207dffdea3c204cd", "role": "TRACKER", "short_name": "SAC7", "snr": 1.37, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.312, "battery_level": 101, "channel_utilization": 9.42, "uptime_seconds": 26628, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 922, "long_name": "Giant Adder", "next_hop": 0, "num": "0x1add4882", "position": {"altitude": 1195, "latitude": 32.559862, "location_source": "LOC_INTERNAL", "longitude": -107.961403, "time_offset_sec": 1071}, "public_key_hex": "1d008f386108796d9bba6e29e525411faa10af7cecf796f6c1445db986983a3a", "role": "CLIENT_MUTE", "short_name": "GINF", "snr": 5.79, "status": null, "telemetry": {"air_util_tx": 0.401, "battery_level": 17, "channel_utilization": 6.84, "uptime_seconds": 54057, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.3, "iaq": 70, "relative_humidity": 60.55, "temperature": 21.46}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 475, "long_name": "Drifting Oak", "next_hop": 0, "num": "0x1af53058", "position": null, "public_key_hex": "78565aa6115245795bd0f10ebf459ef0a63978dc942669cfc7d0e7e1214f0bc3", "role": "CLIENT_BASE", "short_name": "🔥", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.594, "battery_level": 43, "channel_utilization": 8.46, "uptime_seconds": 120923, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 657, "long_name": "New Phoenix", "next_hop": 0, "num": "0x1b191e2f", "position": {"altitude": 1135, "latitude": 32.469329, "location_source": "LOC_INTERNAL", "longitude": -107.424641, "time_offset_sec": 914}, "public_key_hex": "918c607d0d4bd18d8937debbda328b982235657991e4c5b82224634391fd4332", "role": "CLIENT", "short_name": "🗻", "snr": 4.35, "status": null, "telemetry": {"air_util_tx": 0.187, "battery_level": 78, "channel_utilization": 17.87, "uptime_seconds": 35945, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3031, "long_name": "Wandering Heron", "next_hop": 0, "num": "0x1b5b7668", "position": {"altitude": 1593, "latitude": 34.009999, "location_source": "LOC_INTERNAL", "longitude": -107.183346, "time_offset_sec": 3208}, "public_key_hex": "32a145e390353deeba3eaf7a7aa3aae48d7ad131101d41e54ea8d4f99c8a649a", "role": "CLIENT", "short_name": "W9NT", "snr": 7.02, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.452, "battery_level": 75, "channel_utilization": 13.03, "uptime_seconds": 284790, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3033, "long_name": "Old Gecko", "next_hop": 0, "num": "0x1b8a7248", "position": {"altitude": 1471, "latitude": 33.930371, "location_source": "LOC_INTERNAL", "longitude": -107.618216, "time_offset_sec": 3188}, "public_key_hex": "", "role": "CLIENT", "short_name": "OC0E", "snr": 2.2, "status": null, "telemetry": {"air_util_tx": 0.456, "battery_level": 71, "channel_utilization": 9.42, "uptime_seconds": 170018, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 6, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 848, "long_name": "Storm Yucca", "next_hop": 99, "num": "0x1bed3a06", "position": {"altitude": 1102, "latitude": 32.747216, "location_source": "LOC_INTERNAL", "longitude": -107.427848, "time_offset_sec": 885}, "public_key_hex": "0e0ad42afec75a8989963d487f26475d547aa9c4adf5a7e87adce4b03d57a8fe", "role": "CLIENT_MUTE", "short_name": "🦊", "snr": 5.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 6505, "long_name": "Loud Mustang", "next_hop": 0, "num": "0x1bf21c03", "position": null, "public_key_hex": "3bea824ae9768b4be5d494102ed115515b1a8872430872cef73b17ead931ec7d", "role": "CLIENT", "short_name": "L7KP", "snr": 7.2, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 2.628, "battery_level": 42, "channel_utilization": 8.21, "uptime_seconds": 135653, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 6591, "long_name": "Silver Falcon", "next_hop": 240, "num": "0x1c09134c", "position": {"altitude": 1117, "latitude": 33.560568, "location_source": "LOC_INTERNAL", "longitude": -107.495972, "time_offset_sec": 6684}, "public_key_hex": "", "role": "CLIENT_BASE", "short_name": "SGSY", "snr": 3.36, "status": null, "telemetry": {"air_util_tx": 1.459, "battery_level": 25, "channel_utilization": 42.69, "uptime_seconds": 160131, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.78, "iaq": 89, "relative_humidity": 64.94, "temperature": 16.68}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 916, "long_name": "Solar Raven", "next_hop": 189, "num": "0x1c31e7e9", "position": {"altitude": 1518, "latitude": 33.471481, "location_source": "LOC_INTERNAL", "longitude": -107.611242, "time_offset_sec": 1132}, "public_key_hex": "2762eeec0f510ae90e71753b23435be37be8004ecd05d9c9e2a29d6e5be7f112", "role": "CLIENT", "short_name": "S0KF", "snr": 1.53, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 1626, "long_name": "Old Squirrel", "next_hop": 0, "num": "0x1c4a6e2b", "position": null, "public_key_hex": "4b1be87ac3c232e4f18bb14debd03c5e5146089384a2c3f35c6ce9d47cc12ad6", "role": "CLIENT_HIDDEN", "short_name": "ONXG", "snr": 4.34, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.119, "battery_level": 79, "channel_utilization": 12.23, "uptime_seconds": 283231, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 2920, "long_name": "Mountain Seal", "next_hop": 96, "num": "0x1c559a0a", "position": {"altitude": 1468, "latitude": 32.956714, "location_source": "LOC_INTERNAL", "longitude": -106.659822, "time_offset_sec": 3114}, "public_key_hex": "490bfc426a423deb01a0276f9c22674265235bf6628eac70a5f5b8310128c98e", "role": "CLIENT", "short_name": "M4LU", "snr": 10.44, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1748, "long_name": "Black Viper KX1YQ", "next_hop": 0, "num": "0x1c6675a8", "position": {"altitude": 1729, "latitude": 32.390537, "location_source": "LOC_INTERNAL", "longitude": -107.82273, "time_offset_sec": 1788}, "public_key_hex": "9a43a9b7afdfa2c9a224419d20371ea8039b963b59f2bfe366e75de9ad440e48", "role": "CLIENT_BASE", "short_name": "B9KI", "snr": -0.41, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.218, "battery_level": 56, "channel_utilization": 4.07, "uptime_seconds": 162910, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1025.03, "iaq": 81, "relative_humidity": 36.1, "temperature": 22.34}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 382, "long_name": "Sneaky Dolphin", "next_hop": 0, "num": "0x1c820ac8", "position": {"altitude": 1224, "latitude": 33.698335, "location_source": "LOC_INTERNAL", "longitude": -107.15932, "time_offset_sec": 565}, "public_key_hex": "58eae91178561f877bea759ff2c07793d1b71560d46918c258e0d02cd2921efc", "role": "CLIENT", "short_name": "S4SQ", "snr": 5.18, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.439, "battery_level": 16, "channel_utilization": 23.17, "uptime_seconds": 24975, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 507, "long_name": "Drowsy Raven", "next_hop": 248, "num": "0x1ca90e85", "position": {"altitude": 1310, "latitude": 34.193388, "location_source": "LOC_INTERNAL", "longitude": -106.899694, "time_offset_sec": 522}, "public_key_hex": "75bf30b5592656dbae67210038d6dd6c9b34332ab995b5f65bfc25020a6f247c", "role": "CLIENT_HIDDEN", "short_name": "DHGZ", "snr": 7.4, "status": null, "telemetry": {"air_util_tx": 0.493, "battery_level": 90, "channel_utilization": 5.41, "uptime_seconds": 40519, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.33, "iaq": 67, "relative_humidity": 36.38, "temperature": 13.47}, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 1536, "long_name": "Copper Elk", "next_hop": 29, "num": "0x1cce1ac2", "position": {"altitude": 1325, "latitude": 32.651413, "location_source": "LOC_INTERNAL", "longitude": -107.345739, "time_offset_sec": 1755}, "public_key_hex": "4a8f8ec21aa7ed7c1db5ee79eb8066158b966131fdae2ce0b5104eb1705a6813", "role": "CLIENT", "short_name": "C4ON", "snr": 4.47, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.386, "battery_level": 56, "channel_utilization": 8.9, "uptime_seconds": 41468, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1003.3, "iaq": 31, "relative_humidity": 78.7, "temperature": 7.47}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2561, "long_name": "Solar Cedar", "next_hop": 0, "num": "0x1ccf1328", "position": {"altitude": 1397, "latitude": 33.022825, "location_source": "LOC_INTERNAL", "longitude": -107.781054, "time_offset_sec": 2734}, "public_key_hex": "", "role": "CLIENT", "short_name": "SR7S", "snr": 8.48, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.434, "battery_level": 89, "channel_utilization": 6.49, "uptime_seconds": 80782, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1137, "long_name": "Lost Mamba", "next_hop": 117, "num": "0x1ce3628e", "position": {"altitude": 1366, "latitude": 33.484885, "location_source": "LOC_INTERNAL", "longitude": -107.057842, "time_offset_sec": 1276}, "public_key_hex": "f89d2d1bd68da6ae5376744d63688f3b6f3b6d301890cf6c0194f0d8410447bb", "role": "CLIENT", "short_name": "LJL3", "snr": 2.48, "status": null, "telemetry": {"air_util_tx": 1.897, "battery_level": 42, "channel_utilization": 16.14, "uptime_seconds": 15156, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2988, "long_name": "Shady Hare", "next_hop": 154, "num": "0x1cf9dd1a", "position": {"altitude": 1670, "latitude": 32.699419, "location_source": "LOC_INTERNAL", "longitude": -106.641062, "time_offset_sec": 2988}, "public_key_hex": "fd7252887871188aa4fee6d9826d724990c6ce6af0f7618a61cf76029901cd3e", "role": "CLIENT", "short_name": "S2WJ", "snr": 8.62, "status": null, "telemetry": {"air_util_tx": 0.725, "battery_level": 101, "channel_utilization": 10.15, "uptime_seconds": 14813, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 13569, "long_name": "Lone Pike", "next_hop": 172, "num": "0x1d09cb2c", "position": {"altitude": 1419, "latitude": 33.086129, "location_source": "LOC_INTERNAL", "longitude": -106.616671, "time_offset_sec": 13854}, "public_key_hex": "91efb569116e2443c8d70ca0fcade9980efec58c61a67dd15b676cf5601bfda9", "role": "TAK_TRACKER", "short_name": "LQNE", "snr": 3.7, "status": null, "telemetry": {"air_util_tx": 1.441, "battery_level": 89, "channel_utilization": 3.52, "uptime_seconds": 97392, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1493, "long_name": "Frosty Seal", "next_hop": 22, "num": "0x1d2d68af", "position": {"altitude": 1326, "latitude": 34.057149, "location_source": "LOC_INTERNAL", "longitude": -106.953165, "time_offset_sec": 1746}, "public_key_hex": "680058d120767ae7af3441570d151a14cad40fa41dd45fea6aaf5fb5f81c58b4", "role": "CLIENT", "short_name": "FANN", "snr": 6.11, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 9881, "long_name": "Happy Mole", "next_hop": 0, "num": "0x1d3440d8", "position": {"altitude": 1668, "latitude": 32.538788, "location_source": "LOC_INTERNAL", "longitude": -107.496199, "time_offset_sec": 9964}, "public_key_hex": "", "role": "CLIENT", "short_name": "🌲", "snr": 4.95, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 2908, "long_name": "Lost Hawk", "next_hop": 0, "num": "0x1d41e233", "position": {"altitude": 881, "latitude": 33.154966, "location_source": "LOC_INTERNAL", "longitude": -107.4298, "time_offset_sec": 3090}, "public_key_hex": "b21da3773007e2c903456ffb03e114a034cfa2eed5f76bdaf84c6e042439ec9d", "role": "SENSOR", "short_name": "LLVJ", "snr": 4.95, "status": null, "telemetry": {"air_util_tx": 0.016, "battery_level": 87, "channel_utilization": 10.68, "uptime_seconds": 8858, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3147, "long_name": "Lone Cobra", "next_hop": 0, "num": "0x1d81bf65", "position": null, "public_key_hex": "926ecb66d016c95adf3ddabc14efaeee84efef79fdea5486111c8865d22daeea", "role": "CLIENT", "short_name": "LT2Y", "snr": 0.68, "status": null, "telemetry": {"air_util_tx": 1.535, "battery_level": 93, "channel_utilization": 24.42, "uptime_seconds": 81858, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5558, "long_name": "Soft Squirrel", "next_hop": 75, "num": "0x1d8596bd", "position": {"altitude": 1050, "latitude": 33.015702, "location_source": "LOC_INTERNAL", "longitude": -107.295968, "time_offset_sec": 5712}, "public_key_hex": "ca4bd686f48dd2698f5b1931609b40de63a420d610f8615dc55b4ac7edd9dbc8", "role": "CLIENT", "short_name": "SDP3", "snr": 5.81, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.362, "battery_level": 81, "channel_utilization": 12.15, "uptime_seconds": 11904, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5008, "long_name": "River Adder", "next_hop": 0, "num": "0x1d8e065b", "position": {"altitude": 1138, "latitude": 32.880832, "location_source": "LOC_INTERNAL", "longitude": -106.99885, "time_offset_sec": 5043}, "public_key_hex": "3f60bb48ac626011ec8485e7b6ea27affa8242a4189273b50b784bb63ed51b1d", "role": "TRACKER", "short_name": "R95I", "snr": 11.7, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.479, "battery_level": 50, "channel_utilization": 7.07, "uptime_seconds": 41634, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 824, "long_name": "River Yucca", "next_hop": 47, "num": "0x1dbed620", "position": {"altitude": 1464, "latitude": 33.483505, "location_source": "LOC_INTERNAL", "longitude": -107.049868, "time_offset_sec": 962}, "public_key_hex": "1aaae40d15e32bb3231c444a742ae4b5cd1b2732ff30a8c6dcf92b82371a96d5", "role": "CLIENT_HIDDEN", "short_name": "RZ57", "snr": 8.35, "status": {"status": "low-batt"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 6500, "long_name": "Frosty Hawk", "next_hop": 0, "num": "0x1dc65abd", "position": {"altitude": 1048, "latitude": 33.088451, "location_source": "LOC_INTERNAL", "longitude": -106.691247, "time_offset_sec": 6629}, "public_key_hex": "", "role": "CLIENT", "short_name": "F5H0", "snr": 1.57, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.678, "battery_level": 74, "channel_utilization": 8.64, "uptime_seconds": 55146, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.82, "iaq": 64, "relative_humidity": 35.38, "temperature": 16.3}, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 657, "long_name": "New Crane", "next_hop": 147, "num": "0x1dea1562", "position": {"altitude": 1292, "latitude": 33.821134, "location_source": "LOC_INTERNAL", "longitude": -107.375863, "time_offset_sec": 692}, "public_key_hex": "553c8795101fb9a0b41de0f81a812f7bed8b90a1144643227b701c9d4fba84ce", "role": "CLIENT", "short_name": "NV1P", "snr": 9.25, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 362, "long_name": "Happy Otter", "next_hop": 0, "num": "0x1e03e2d9", "position": {"altitude": 1413, "latitude": 32.164428, "location_source": "LOC_INTERNAL", "longitude": -107.966005, "time_offset_sec": 417}, "public_key_hex": "4711b4700f5b4febe2317af6cd40d240542cc29b876fe91e5a1a7be10d094ba4", "role": "CLIENT", "short_name": "HRTP", "snr": -2.41, "status": null, "telemetry": {"air_util_tx": 2.222, "battery_level": 62, "channel_utilization": 10.04, "uptime_seconds": 52263, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2248, "long_name": "Frozen Viper", "next_hop": 0, "num": "0x1e0fd2b7", "position": {"altitude": 1574, "latitude": 34.47135, "location_source": "LOC_INTERNAL", "longitude": -107.503932, "time_offset_sec": 2279}, "public_key_hex": "87af3bdc2990d0b2eaa6c111d17253da5e2a4e024e5409d59856d9d2b4af7b25", "role": "CLIENT", "short_name": "FOU4", "snr": 1.33, "status": null, "telemetry": {"air_util_tx": 0.357, "battery_level": 36, "channel_utilization": 5.55, "uptime_seconds": 17888, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 12632, "long_name": "Shady Dolphin", "next_hop": 152, "num": "0x1e3846cd", "position": {"altitude": 1117, "latitude": 32.854083, "location_source": "LOC_INTERNAL", "longitude": -105.758488, "time_offset_sec": 12721}, "public_key_hex": "4c98e0906b5fdc17ab21d015f6f2e4f38da70af6832f6db72bb47b82f8e75468", "role": "CLIENT", "short_name": "SAJQ", "snr": 4.73, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2663, "long_name": "Happy Seal", "next_hop": 0, "num": "0x1e4166fa", "position": {"altitude": 1525, "latitude": 32.074669, "location_source": "LOC_INTERNAL", "longitude": -107.929551, "time_offset_sec": 2836}, "public_key_hex": "26814376c490aeb61548c1fb59ce11e4552dfe3cd5dfce742189aa2accb96ba6", "role": "CLIENT", "short_name": "HKQX", "snr": 5.86, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 5613, "long_name": "Frosty Crow", "next_hop": 230, "num": "0x1e45f1f1", "position": {"altitude": 1324, "latitude": 32.812333, "location_source": "LOC_INTERNAL", "longitude": -107.981179, "time_offset_sec": 5751}, "public_key_hex": "08adc54820d22d5c1e022bf2479a20b63a32a4fc8108a3e50fdce4b533db3986", "role": "CLIENT_MUTE", "short_name": "F0YQ", "snr": 10.61, "status": null, "telemetry": {"air_util_tx": 0.688, "battery_level": 33, "channel_utilization": 16.54, "uptime_seconds": 2425, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.12, "iaq": 68, "relative_humidity": 60.23, "temperature": 26.16}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7879, "long_name": "Canyon Iguana", "next_hop": 0, "num": "0x1e737f00", "position": {"altitude": 1634, "latitude": 32.814699, "location_source": "LOC_INTERNAL", "longitude": -107.768178, "time_offset_sec": 8089}, "public_key_hex": "1b0c27ca5e9c1574cb6086b8b11b3702b6d049ca6eee2ed449115f87530db66b", "role": "CLIENT", "short_name": "C2BK", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5335, "long_name": "Fast Aspen", "next_hop": 87, "num": "0x1ea559d3", "position": {"altitude": 1604, "latitude": 33.704797, "location_source": "LOC_INTERNAL", "longitude": -108.02505, "time_offset_sec": 5490}, "public_key_hex": "bd2fa516c47915b7272d2dfcc00c06af3368520780a362cb2009aae5a87d483a", "role": "CLIENT", "short_name": "🦌", "snr": 6.38, "status": null, "telemetry": {"air_util_tx": 0.764, "battery_level": 33, "channel_utilization": 17.92, "uptime_seconds": 62554, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 585, "long_name": "Found Fox", "next_hop": 70, "num": "0x1eac63a8", "position": {"altitude": 1194, "latitude": 33.01506, "location_source": "LOC_INTERNAL", "longitude": -107.826765, "time_offset_sec": 818}, "public_key_hex": "f81296d63d934366f53176c1e7cdb000588c2bdea5d85bc984e58452b4c4f3d4", "role": "CLIENT", "short_name": "F9HR", "snr": 2.56, "status": null, "telemetry": {"air_util_tx": 0.672, "battery_level": 23, "channel_utilization": 6.28, "uptime_seconds": 27294, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 3469, "long_name": "Howling Crow", "next_hop": 0, "num": "0x1ebece0a", "position": {"altitude": 1543, "latitude": 33.116196, "location_source": "LOC_INTERNAL", "longitude": -107.719366, "time_offset_sec": 3756}, "public_key_hex": "fb06a143cdfaf61d061a755318781f7d00b61ea422dcfbecc15ff35fc7991f33", "role": "CLIENT", "short_name": "HGIX", "snr": 10.96, "status": null, "telemetry": {"air_util_tx": 0.696, "battery_level": 99, "channel_utilization": 6.89, "uptime_seconds": 36884, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 561, "long_name": "Slow Hare", "next_hop": 42, "num": "0x1eda6098", "position": {"altitude": 1477, "latitude": 32.078959, "location_source": "LOC_INTERNAL", "longitude": -106.936164, "time_offset_sec": 566}, "public_key_hex": "43c558e9c0855a2e9e0e1374d93d2e6c63cb358d980a8fface3e6adce3f3aa98", "role": "CLIENT", "short_name": "S9IR", "snr": -4.11, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 6506, "long_name": "Sneaky Coyote", "next_hop": 0, "num": "0x1efd1e7e", "position": {"altitude": 1290, "latitude": 33.705581, "location_source": "LOC_INTERNAL", "longitude": -107.151656, "time_offset_sec": 6539}, "public_key_hex": "5ca38131c547d6c081e83608cc1d2cc0bbbe4015a9e8f8dc80e9d7ab07d658bd", "role": "ROUTER", "short_name": "S8V5", "snr": 6.22, "status": null, "telemetry": {"air_util_tx": 0.167, "battery_level": 74, "channel_utilization": 12.69, "uptime_seconds": 18013, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 13342, "long_name": "Sleepy Crow", "next_hop": 102, "num": "0x1f11ce68", "position": {"altitude": 1340, "latitude": 33.911033, "location_source": "LOC_INTERNAL", "longitude": -107.050224, "time_offset_sec": 13471}, "public_key_hex": "", "role": "CLIENT", "short_name": "SBMH", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.767, "battery_level": 31, "channel_utilization": 12.51, "uptime_seconds": 66952, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.83, "iaq": 83, "relative_humidity": 29.05, "temperature": 17.63}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1192, "long_name": "Howling Pine", "next_hop": 52, "num": "0x1f31ac45", "position": {"altitude": 1379, "latitude": 33.118293, "location_source": "LOC_INTERNAL", "longitude": -107.174784, "time_offset_sec": 1326}, "public_key_hex": "056d263f50158801325d9620ac29fcbb0ef837462a5b39ef17308963cee8e78c", "role": "CLIENT", "short_name": "HYF9", "snr": 8.23, "status": null, "telemetry": {"air_util_tx": 0.356, "battery_level": 68, "channel_utilization": 15.94, "uptime_seconds": 130153, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 661, "long_name": "Red Tortoise", "next_hop": 125, "num": "0x1f330f80", "position": {"altitude": 1434, "latitude": 32.785394, "location_source": "LOC_INTERNAL", "longitude": -106.407745, "time_offset_sec": 938}, "public_key_hex": "a3d5e462b78868c1a51d7a74fa0d55cee79f7fc70ab4db1c119c5f51660cba1e", "role": "CLIENT", "short_name": "RFVT", "snr": 7.76, "status": null, "telemetry": {"air_util_tx": 1.426, "battery_level": 11, "channel_utilization": 14.17, "uptime_seconds": 54152, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4492, "long_name": "Whispering Gecko", "next_hop": 18, "num": "0x1f42747c", "position": {"altitude": 1794, "latitude": 32.198709, "location_source": "LOC_INTERNAL", "longitude": -106.979901, "time_offset_sec": 4677}, "public_key_hex": "e1854816a34c92f8c4ba52845b2548d8bc22780970b322a577ff9139b5f92f9c", "role": "CLIENT", "short_name": "WU17", "snr": 3.34, "status": null, "telemetry": {"air_util_tx": 0.444, "battery_level": 101, "channel_utilization": 9.5, "uptime_seconds": 2108, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 383, "long_name": "Misty Squirrel", "next_hop": 0, "num": "0x1f50aefc", "position": {"altitude": 1911, "latitude": 33.179148, "location_source": "LOC_INTERNAL", "longitude": -106.662164, "time_offset_sec": 621}, "public_key_hex": "53032cc7bd759d5f853b8c5ff2bf0bd4bf09f4ed86fcdda7d829d8ac4d837e8f", "role": "CLIENT", "short_name": "MHHH", "snr": 5.42, "status": null, "telemetry": {"air_util_tx": 0.35, "battery_level": 42, "channel_utilization": 12.91, "uptime_seconds": 66303, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 1607, "long_name": "Dawn Phoenix", "next_hop": 66, "num": "0x1f92233e", "position": {"altitude": 1252, "latitude": 33.579846, "location_source": "LOC_INTERNAL", "longitude": -106.942264, "time_offset_sec": 1655}, "public_key_hex": "6363078329cc77f8b6ec767c887ce5f32f9eaa41fb0980711b0889867f4a39a1", "role": "CLIENT", "short_name": "DQUJ", "snr": -0.69, "status": null, "telemetry": {"air_util_tx": 0.588, "battery_level": 62, "channel_utilization": 2.74, "uptime_seconds": 34982, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1007.39, "iaq": 32, "relative_humidity": 36.51, "temperature": 26.93}, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 468, "long_name": "Desert Dolphin", "next_hop": 9, "num": "0x1feef56c", "position": {"altitude": 1674, "latitude": 33.459024, "location_source": "LOC_INTERNAL", "longitude": -107.362917, "time_offset_sec": 752}, "public_key_hex": "afc5ec3d870692e5fab38f499d7483895a343c5ea758f69fdf129efbcc7f3909", "role": "CLIENT", "short_name": "DLT3", "snr": 9.5, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1076, "long_name": "Steel Arroyo", "next_hop": 0, "num": "0x1ff93746", "position": {"altitude": 1473, "latitude": 32.291951, "location_source": "LOC_INTERNAL", "longitude": -106.762666, "time_offset_sec": 1183}, "public_key_hex": "4890dc5bdd0ac167f49d7eb4fc32c4ccbf5cdbb8703377af9053e6b54600b1b2", "role": "CLIENT", "short_name": "🗻", "snr": 2.26, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.76, "iaq": 13, "relative_humidity": 73.84, "temperature": 30.62}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1582, "long_name": "Sky Trout", "next_hop": 0, "num": "0x20159677", "position": null, "public_key_hex": "cd5c9b67493426786a364f5284cd9c9f07c52f6eaa9201aa3e20a62e893108b0", "role": "CLIENT", "short_name": "SG5D", "snr": 3.04, "status": null, "telemetry": {"air_util_tx": 0.712, "battery_level": 63, "channel_utilization": 19.69, "uptime_seconds": 198192, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2470, "long_name": "Lost Mole", "next_hop": 0, "num": "0x207937b5", "position": {"altitude": 1740, "latitude": 33.228881, "location_source": "LOC_INTERNAL", "longitude": -106.930791, "time_offset_sec": 2629}, "public_key_hex": "971ad15bb46afc0a1c60af65d375e7f1386cc185f9a6579d62eac2517855909d", "role": "CLIENT", "short_name": "LEDA", "snr": 4.91, "status": null, "telemetry": {"air_util_tx": 0.208, "battery_level": 59, "channel_utilization": 5.33, "uptime_seconds": 179004, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 8585, "long_name": "Blue Arroyo", "next_hop": 240, "num": "0x2089a1cb", "position": {"altitude": 889, "latitude": 32.689423, "location_source": "LOC_INTERNAL", "longitude": -106.403417, "time_offset_sec": 8655}, "public_key_hex": "d3f710e95fb7959a30373e01f75925889f6d21a93c2703a53a1aa71618b1d521", "role": "CLIENT", "short_name": "🔥", "snr": 11.21, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.5, "iaq": 86, "relative_humidity": 56.22, "temperature": 31.43}, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2334, "long_name": "Sunny Turtle", "next_hop": 22, "num": "0x20afffaa", "position": {"altitude": 1855, "latitude": 33.250632, "location_source": "LOC_INTERNAL", "longitude": -107.027838, "time_offset_sec": 2377}, "public_key_hex": "77db12974d673b1a9acd7a60304c86fc42a2b03d1fc5e2b7e9ad847ba59c83ca", "role": "CLIENT", "short_name": "S4SB", "snr": 8.7, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.16, "iaq": 26, "relative_humidity": 55.44, "temperature": 10.16}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2726, "long_name": "Sharp Seal", "next_hop": 0, "num": "0x20bdd7b2", "position": {"altitude": 1080, "latitude": 33.331255, "location_source": "LOC_INTERNAL", "longitude": -106.439683, "time_offset_sec": 3018}, "public_key_hex": "e9c47fe40518ad4e85d772e5ad745ed3bc62d6c19189c6178d885f24ce81018e", "role": "CLIENT", "short_name": "SZAL", "snr": 9.24, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 1.542, "battery_level": 69, "channel_utilization": 7.43, "uptime_seconds": 88416, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1026.95, "iaq": 26, "relative_humidity": 59.7, "temperature": 27.47}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1485, "long_name": "Storm Otter", "next_hop": 0, "num": "0x20c7b371", "position": {"altitude": 1488, "latitude": 34.241213, "location_source": "LOC_INTERNAL", "longitude": -106.947948, "time_offset_sec": 1658}, "public_key_hex": "3867f4cdf59a0d935952b0f09445dcf44cf1c1dc9ed465d739b26d3f1396f08c", "role": "CLIENT", "short_name": "SZYQ", "snr": 5.74, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.389, "battery_level": 96, "channel_utilization": 4.8, "uptime_seconds": 114580, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1015.64, "iaq": 97, "relative_humidity": 63.28, "temperature": 27.79}, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 996, "long_name": "Slow Shark", "next_hop": 84, "num": "0x21143543", "position": {"altitude": 716, "latitude": 32.915461, "location_source": "LOC_INTERNAL", "longitude": -106.91585, "time_offset_sec": 1087}, "public_key_hex": "33acb149e43b30ead9d4daba55e7bd6cbb9d73c233d0f7b27d4c75cfbb7cda10", "role": "CLIENT", "short_name": "S2YT", "snr": 6.08, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.43, "battery_level": 67, "channel_utilization": 12.09, "uptime_seconds": 71508, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3676, "long_name": "Frosty Aspen", "next_hop": 171, "num": "0x21237890", "position": {"altitude": 1413, "latitude": 32.619348, "location_source": "LOC_INTERNAL", "longitude": -107.452729, "time_offset_sec": 3837}, "public_key_hex": "aa360bce31b777181526a6a329b8518ceae4932c8dcc1f3a0c1cca9344e759e2", "role": "CLIENT", "short_name": "F9D4", "snr": 6.18, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.18, "iaq": 96, "relative_humidity": 68.97, "temperature": 1.96}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5913, "long_name": "Stone Coyote", "next_hop": 136, "num": "0x215f5084", "position": {"altitude": 1788, "latitude": 33.333029, "location_source": "LOC_INTERNAL", "longitude": -108.582142, "time_offset_sec": 6141}, "public_key_hex": "273e273b50e1550c5c1a715b9ce09cf79858cb8d5f0ffa7cd9554667be0e2869", "role": "ROUTER", "short_name": "SK9Z", "snr": 9.01, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 354, "long_name": "Black Crane", "next_hop": 160, "num": "0x219cedda", "position": null, "public_key_hex": "62b875727859c8b23123a59896c5bd37b061305db638aaf90ca0feecce84075a", "role": "CLIENT", "short_name": "BUH4", "snr": 5.12, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 8884, "long_name": "Lunar Yucca", "next_hop": 0, "num": "0x21e5d298", "position": null, "public_key_hex": "7fd85d2a8e03d86fe8c9fdb85ee35ecd271d873d8d64530dc9d6caec4643d419", "role": "CLIENT", "short_name": "LQJO", "snr": -4.91, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 7251, "long_name": "Hidden Hare", "next_hop": 137, "num": "0x21e98e90", "position": {"altitude": 1466, "latitude": 33.475919, "location_source": "LOC_INTERNAL", "longitude": -106.604767, "time_offset_sec": 7288}, "public_key_hex": "cb73d5200bdf716416f075f429e4ea890785ff4b15065dffdbc1f34e12af7303", "role": "CLIENT", "short_name": "H4EE", "snr": 2.16, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.14, "iaq": 60, "relative_humidity": 62.08, "temperature": 17.26}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 8938, "long_name": "Misty Mamba", "next_hop": 0, "num": "0x220fe80f", "position": null, "public_key_hex": "98f20ecc08673708f9c3f5687d747411712d8aee96afc412ad705e077a8ab891", "role": "CLIENT", "short_name": "MNTS", "snr": 1.98, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.216, "battery_level": 52, "channel_utilization": 0.74, "uptime_seconds": 108869, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1254, "long_name": "Happy Bronco", "next_hop": 106, "num": "0x224c775d", "position": {"altitude": 1414, "latitude": 34.301288, "location_source": "LOC_INTERNAL", "longitude": -107.121756, "time_offset_sec": 1318}, "public_key_hex": "7d600b6c725d1ea6291f45e90908df805553bd7b5b76d721a64865b4ec6dad44", "role": "SENSOR", "short_name": "HK0E", "snr": 9.12, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.735, "battery_level": 58, "channel_utilization": 9.77, "uptime_seconds": 23022, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "M5STACK_C6L", "last_heard_offset_sec": 29673, "long_name": "Drowsy Viper", "next_hop": 212, "num": "0x2267f36b", "position": {"altitude": 1486, "latitude": 32.423356, "location_source": "LOC_INTERNAL", "longitude": -107.656299, "time_offset_sec": 29813}, "public_key_hex": "62b53759bce294782debf075cf6540796189e5bda378e7515627bd5cd330ce2c", "role": "TRACKER", "short_name": "🐺", "snr": -0.97, "status": null, "telemetry": {"air_util_tx": 0.895, "battery_level": 31, "channel_utilization": 2.94, "uptime_seconds": 61877, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 660, "long_name": "Fast Squirrel", "next_hop": 100, "num": "0x229d4a45", "position": {"altitude": 1337, "latitude": 33.00114, "location_source": "LOC_INTERNAL", "longitude": -107.418354, "time_offset_sec": 851}, "public_key_hex": "a16fc0db54a2d22e3fd3de22a216057307a20366bb80eb6d981cf3a6c7c4c164", "role": "CLIENT", "short_name": "FB6N", "snr": 8.84, "status": null, "telemetry": {"air_util_tx": 0.545, "battery_level": 94, "channel_utilization": 5.75, "uptime_seconds": 27626, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5602, "long_name": "Tiny Iguana", "next_hop": 3, "num": "0x22e6cdbc", "position": {"altitude": 1077, "latitude": 34.049263, "location_source": "LOC_INTERNAL", "longitude": -107.839466, "time_offset_sec": 5855}, "public_key_hex": "49c41910497d44ac513509a403501a2cbf7a0f806f48d99479c9a462136c8211", "role": "CLIENT", "short_name": "TXKY", "snr": 4.56, "status": null, "telemetry": {"air_util_tx": 0.251, "battery_level": 91, "channel_utilization": 11.86, "uptime_seconds": 45879, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.97, "iaq": 0, "relative_humidity": 44.05, "temperature": 30.81}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2252, "long_name": "River Mamba", "next_hop": 0, "num": "0x230cfaaf", "position": {"altitude": 1679, "latitude": 33.384588, "location_source": "LOC_INTERNAL", "longitude": -106.915952, "time_offset_sec": 2440}, "public_key_hex": "0f5346568291ab547000ef7becf78c7a4aaee3ff6870d11475312bcfc6b2d596", "role": "CLIENT", "short_name": "RHNP", "snr": -1.31, "status": null, "telemetry": {"air_util_tx": 0.411, "battery_level": 24, "channel_utilization": 10.53, "uptime_seconds": 2675, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.78, "iaq": 74, "relative_humidity": 76.77, "temperature": 16.85}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 533, "long_name": "Sky Bass", "next_hop": 0, "num": "0x230feb72", "position": {"altitude": 1232, "latitude": 32.713085, "location_source": "LOC_INTERNAL", "longitude": -107.18905, "time_offset_sec": 587}, "public_key_hex": "4db218f5cf28778877b5e4361095276fae92c076a9c94aa66c2c3a90a440aeb3", "role": "CLIENT", "short_name": "SFVQ", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.534, "battery_level": 89, "channel_utilization": 19.3, "uptime_seconds": 11389, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1956, "long_name": "Happy Badger", "next_hop": 98, "num": "0x234063f0", "position": {"altitude": 1461, "latitude": 34.050033, "location_source": "LOC_INTERNAL", "longitude": -107.732652, "time_offset_sec": 2094}, "public_key_hex": "464a3c3b40d1d797ff3515568e20f49c79b62b0b8a8f014bdeb24f73a9fd3925", "role": "CLIENT", "short_name": "HE05", "snr": 7.97, "status": null, "telemetry": {"air_util_tx": 0.463, "battery_level": 46, "channel_utilization": 3.64, "uptime_seconds": 168853, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 210, "long_name": "Smooth Viper", "next_hop": 152, "num": "0x235aa1d8", "position": {"altitude": 1194, "latitude": 33.801632, "location_source": "LOC_INTERNAL", "longitude": -107.443527, "time_offset_sec": 261}, "public_key_hex": "0fb0f63ea48dd23abb5d0a017b9f5e8dd1a8e521f2c33c9c163b6b5b541ecb49", "role": "CLIENT", "short_name": "SJ8D", "snr": 0.69, "status": null, "telemetry": {"air_util_tx": 0.141, "battery_level": 58, "channel_utilization": 6.53, "uptime_seconds": 36348, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 623, "long_name": "White Pony", "next_hop": 196, "num": "0x23629f37", "position": null, "public_key_hex": "c623b14b87b86f4fd9ef94f32b8c3f0e36c538d9f2984b95fcca322e0337f13e", "role": "CLIENT", "short_name": "WQ3B", "snr": 9.62, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.576, "battery_level": 48, "channel_utilization": 4.79, "uptime_seconds": 90252, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 1337, "long_name": "Copper Marmot", "next_hop": 0, "num": "0x23938992", "position": {"altitude": 1434, "latitude": 32.995991, "location_source": "LOC_INTERNAL", "longitude": -106.482294, "time_offset_sec": 1403}, "public_key_hex": "4e65a6e90678900a542b484fb1a5e8db30b7e18a73bd8a7dc155523b25db6813", "role": "CLIENT", "short_name": "CXG7", "snr": 8.16, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1025.75, "iaq": 58, "relative_humidity": 37.37, "temperature": 33.45}, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 6875, "long_name": "Sneaky Juniper", "next_hop": 227, "num": "0x23b25df3", "position": {"altitude": 1096, "latitude": 33.450962, "location_source": "LOC_INTERNAL", "longitude": -108.134507, "time_offset_sec": 7017}, "public_key_hex": "9e0abfd94ef18372f034c1d7bae517c0fe1b21dabc63ffbb4b61b52d330c634f", "role": "CLIENT", "short_name": "SSJX", "snr": 4.4, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1019.07, "iaq": 105, "relative_humidity": 66.84, "temperature": -2.2}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1047, "long_name": "Lost Cactus", "next_hop": 136, "num": "0x23bfacd9", "position": {"altitude": 1295, "latitude": 33.179415, "location_source": "LOC_INTERNAL", "longitude": -105.963941, "time_offset_sec": 1244}, "public_key_hex": "", "role": "CLIENT", "short_name": "LFJ3", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.315, "battery_level": 26, "channel_utilization": 1.21, "uptime_seconds": 137586, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4545, "long_name": "Sleepy Fox", "next_hop": 0, "num": "0x23d7110a", "position": {"altitude": 1598, "latitude": 32.926628, "location_source": "LOC_INTERNAL", "longitude": -107.039299, "time_offset_sec": 4573}, "public_key_hex": "ae22d1088e3553c6d448b2459f71cfa1a161abe56fe16e2c57d16a37874b0679", "role": "CLIENT", "short_name": "🦌", "snr": 5.77, "status": null, "telemetry": {"air_util_tx": 0.132, "battery_level": 65, "channel_utilization": 10.79, "uptime_seconds": 92396, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3205, "long_name": "Tiny Bronco", "next_hop": 0, "num": "0x23e42262", "position": {"altitude": 1487, "latitude": 33.223099, "location_source": "LOC_INTERNAL", "longitude": -107.72643, "time_offset_sec": 3378}, "public_key_hex": "9a4f38fa66b1bc916a38c7d866e04657731a5c5628755d5abee1ce62c0958dc6", "role": "CLIENT", "short_name": "T38T", "snr": 3.12, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.369, "battery_level": 32, "channel_utilization": 5.38, "uptime_seconds": 44122, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 5094, "long_name": "Tiny Doe", "next_hop": 0, "num": "0x23e7c833", "position": {"altitude": 1294, "latitude": 33.574836, "location_source": "LOC_INTERNAL", "longitude": -106.692449, "time_offset_sec": 5221}, "public_key_hex": "ebb593b1e6962594ad3f94052fea40d81f411fff539c640f7fde65468204ad55", "role": "CLIENT", "short_name": "TYKB", "snr": 5.33, "status": null, "telemetry": {"air_util_tx": 0.101, "battery_level": 45, "channel_utilization": 29.62, "uptime_seconds": 70548, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1012.49, "iaq": 54, "relative_humidity": 52.07, "temperature": 22.59}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2614, "long_name": "Misty Mole", "next_hop": 0, "num": "0x24036ead", "position": null, "public_key_hex": "35328a9a48d31dc66b505a6a28d90ce033aba59be4e5a5f6108594f28bb1b9c5", "role": "CLIENT", "short_name": "MY24", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.047, "battery_level": 13, "channel_utilization": 6.51, "uptime_seconds": 14697, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5242, "long_name": "Found Mole", "next_hop": 0, "num": "0x2443ec52", "position": {"altitude": 1204, "latitude": 33.82926, "location_source": "LOC_INTERNAL", "longitude": -108.462509, "time_offset_sec": 5441}, "public_key_hex": "dd6d8085361741763fe217db46f723bb25258c60f51e02db87fd775b04f0c7b4", "role": "CLIENT", "short_name": "FYAA", "snr": 9.67, "status": null, "telemetry": {"air_util_tx": 1.184, "battery_level": 27, "channel_utilization": 21.57, "uptime_seconds": 176438, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4442, "long_name": "Sharp Aspen", "next_hop": 0, "num": "0x245124ee", "position": null, "public_key_hex": "f09a24c00d3a5b17710177cbb2f39409f63bbcf11caf9a3586a563eb85076311", "role": "CLIENT", "short_name": "S0R0", "snr": 8.26, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.285, "battery_level": 10, "channel_utilization": 9.07, "uptime_seconds": 62498, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 518, "long_name": "Storm Oak", "next_hop": 181, "num": "0x247c0de6", "position": {"altitude": 965, "latitude": 33.290156, "location_source": "LOC_INTERNAL", "longitude": -107.76127, "time_offset_sec": 549}, "public_key_hex": "e43fda775941e16e8cdfa280bc3fde57827fcd0c076bdb92a602114d618ba089", "role": "CLIENT", "short_name": "🦂", "snr": 5.77, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.747, "battery_level": 53, "channel_utilization": 6.81, "uptime_seconds": 57287, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 599, "long_name": "Smooth Elk", "next_hop": 0, "num": "0x248a9910", "position": {"altitude": 1363, "latitude": 32.97192, "location_source": "LOC_INTERNAL", "longitude": -106.783777, "time_offset_sec": 845}, "public_key_hex": "3dc34f48e639af79ccb41fa7719b50e9f1fea0dd42e66a65d334aa5821c1153f", "role": "CLIENT", "short_name": "SKW0", "snr": 10.09, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 6716, "long_name": "Drowsy Doe", "next_hop": 0, "num": "0x24956fd8", "position": {"altitude": 1175, "latitude": 34.035105, "location_source": "LOC_INTERNAL", "longitude": -107.911986, "time_offset_sec": 7009}, "public_key_hex": "98d65a870b3404b6a63373ea0b0bd328a1474ba2be71ee483c95f1444022f911", "role": "CLIENT", "short_name": "DLDJ", "snr": 5.05, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 497, "long_name": "Quick Squirrel", "next_hop": 0, "num": "0x249ab509", "position": {"altitude": 1313, "latitude": 33.704449, "location_source": "LOC_INTERNAL", "longitude": -107.210518, "time_offset_sec": 720}, "public_key_hex": "7935edeae770b033b87de191225e19d73f99058bcbed1d11bffc924c4dca2820", "role": "ROUTER", "short_name": "🌲", "snr": 8.29, "status": null, "telemetry": {"air_util_tx": 1.257, "battery_level": 14, "channel_utilization": 9.5, "uptime_seconds": 4502, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.19, "iaq": 18, "relative_humidity": 57.73, "temperature": 23.75}, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 801, "long_name": "Gold Cedar", "next_hop": 22, "num": "0x24ae0511", "position": null, "public_key_hex": "3b75e67376f6cf2524b071df9b63d40b51c2f12a914ff2de225b40e678b78f25", "role": "CLIENT", "short_name": "GFKG", "snr": 3.14, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 286, "long_name": "Roving Fox", "next_hop": 231, "num": "0x24c39e2a", "position": {"altitude": 1548, "latitude": 33.075438, "location_source": "LOC_INTERNAL", "longitude": -106.866183, "time_offset_sec": 474}, "public_key_hex": "0a266a3f7ef17d8248449594ec2193d325cbb6372c3df64743e89ed147aeb561", "role": "CLIENT", "short_name": "RUG4", "snr": 7.56, "status": null, "telemetry": {"air_util_tx": 1.144, "battery_level": 25, "channel_utilization": 7.55, "uptime_seconds": 82381, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 798, "long_name": "Frozen Bison", "next_hop": 172, "num": "0x24ddc3d8", "position": null, "public_key_hex": "432a74505f7064b0ccb22b098c5985daed2663bbc0fc26305f63bb5fd688f49e", "role": "CLIENT_BASE", "short_name": "🐢", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.037, "battery_level": 84, "channel_utilization": 22.28, "uptime_seconds": 176596, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2891, "long_name": "Canyon Dolphin", "next_hop": 0, "num": "0x24e96ecd", "position": {"altitude": 1230, "latitude": 32.079853, "location_source": "LOC_INTERNAL", "longitude": -106.651277, "time_offset_sec": 3162}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "CIPH", "snr": 0.32, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.282, "battery_level": 73, "channel_utilization": 11.36, "uptime_seconds": 25332, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 5293, "long_name": "Dawn Wolf", "next_hop": 203, "num": "0x250f8021", "position": {"altitude": 745, "latitude": 33.073504, "location_source": "LOC_INTERNAL", "longitude": -107.337275, "time_offset_sec": 5341}, "public_key_hex": "f06838d71c2760770b8cc83eac83cebedeff2ae7520074c15a4d72275564c57a", "role": "CLIENT", "short_name": "DYMC", "snr": 9.1, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 409, "long_name": "Dawn Bluff", "next_hop": 0, "num": "0x251f0133", "position": {"altitude": 1693, "latitude": 33.373734, "location_source": "LOC_INTERNAL", "longitude": -106.159191, "time_offset_sec": 497}, "public_key_hex": "83c6412d757a2abc7db3dc307925ad64c66e12a7b91295d4cc7528c6778870bc", "role": "CLIENT", "short_name": "D4Z3", "snr": 3.78, "status": null, "telemetry": {"air_util_tx": 1.005, "battery_level": 33, "channel_utilization": 1.25, "uptime_seconds": 79378, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1394, "long_name": "Canyon Cedar", "next_hop": 223, "num": "0x252e5aad", "position": {"altitude": 1126, "latitude": 33.010149, "location_source": "LOC_INTERNAL", "longitude": -107.764763, "time_offset_sec": 1490}, "public_key_hex": "deed5a13c187350dee9e77ca75a1d1d1772994c3c54facb64b95e0a22533ac8d", "role": "CLIENT", "short_name": "🐝", "snr": 4.47, "status": {"status": "active"}, "telemetry": {"air_util_tx": 2.942, "battery_level": 38, "channel_utilization": 26.83, "uptime_seconds": 106702, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 3570, "long_name": "Drifting Pine N56QA", "next_hop": 80, "num": "0x25346632", "position": {"altitude": 1684, "latitude": 32.994528, "location_source": "LOC_INTERNAL", "longitude": -106.813663, "time_offset_sec": 3697}, "public_key_hex": "5a793b851bdc2623b6b1256eb8de65b9fef8b6aa8a847a7574e6baaf9edb95a2", "role": "CLIENT", "short_name": "DVNZ", "snr": -0.61, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.368, "battery_level": 74, "channel_utilization": 24.77, "uptime_seconds": 80362, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.47, "iaq": 38, "relative_humidity": 68.96, "temperature": 29.35}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1392, "long_name": "Fast Adder", "next_hop": 0, "num": "0x25616cb9", "position": null, "public_key_hex": "73fc3ba0e095026b952b74b75d5826000e75f0acda4667a7468c302395672867", "role": "ROUTER", "short_name": "F2NK", "snr": 6.19, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 83, "long_name": "Gold Mustang", "next_hop": 80, "num": "0x258f6439", "position": {"altitude": 1481, "latitude": 34.17001, "location_source": "LOC_INTERNAL", "longitude": -108.313686, "time_offset_sec": 281}, "public_key_hex": "278be24e84baa79d30817e4be0767471ca82e77a27f9f52d304fb42e787af7a8", "role": "CLIENT", "short_name": "GF5S", "snr": 10.75, "status": null, "telemetry": {"air_util_tx": 0.144, "battery_level": 99, "channel_utilization": 7.15, "uptime_seconds": 417105, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 8392, "long_name": "River Oak", "next_hop": 117, "num": "0x2591ad83", "position": {"altitude": 940, "latitude": 33.243364, "location_source": "LOC_INTERNAL", "longitude": -107.062889, "time_offset_sec": 8436}, "public_key_hex": "f8f382cfb4d699d39f45b682693c1c55c70a928377edc3eb52cfd95a2aeca262", "role": "CLIENT", "short_name": "R9RG", "snr": 8.58, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.199, "battery_level": 77, "channel_utilization": 8.38, "uptime_seconds": 85278, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2441, "long_name": "Copper Lion", "next_hop": 172, "num": "0x259bc2ee", "position": null, "public_key_hex": "05b83db80e0aa8176dfead1e080062bb91abd1cc3fbb9683452af8d46b31290f", "role": "CLIENT", "short_name": "CCZ3", "snr": 3.01, "status": null, "telemetry": {"air_util_tx": 0.607, "battery_level": 18, "channel_utilization": 17.91, "uptime_seconds": 86658, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1726, "long_name": "Mountain Dolphin", "next_hop": 108, "num": "0x25b3369f", "position": {"altitude": 1473, "latitude": 31.832854, "location_source": "LOC_INTERNAL", "longitude": -107.694115, "time_offset_sec": 1809}, "public_key_hex": "b25f939d678d75d1399416d319cf181ce457a1b7cff1bd6e4f878d19d616d8bb", "role": "CLIENT_HIDDEN", "short_name": "🦉", "snr": 11.21, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.39, "battery_level": 14, "channel_utilization": 15.21, "uptime_seconds": 35218, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 832, "long_name": "Lunar Yucca", "next_hop": 0, "num": "0x25e04a17", "position": {"altitude": 1513, "latitude": 32.757462, "location_source": "LOC_INTERNAL", "longitude": -106.118663, "time_offset_sec": 1087}, "public_key_hex": "3dc2b6b5e3fe4fc9799793fcf6377d687c46d1bb93a79b0c4f766b94908e289c", "role": "CLIENT", "short_name": "LAAO", "snr": 7.65, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.967, "battery_level": 76, "channel_utilization": 9.0, "uptime_seconds": 71860, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 14440, "long_name": "Green Beaver", "next_hop": 103, "num": "0x25e3b820", "position": {"altitude": 1442, "latitude": 31.970928, "location_source": "LOC_INTERNAL", "longitude": -107.032555, "time_offset_sec": 14501}, "public_key_hex": "a4974b1c5dc6c696bcd05338662b7bdbf046077c4569c0f0e1bc156f5f22b17a", "role": "CLIENT", "short_name": "G33R", "snr": 7.05, "status": null, "telemetry": {"air_util_tx": 0.37, "battery_level": 54, "channel_utilization": 14.88, "uptime_seconds": 65232, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.37, "iaq": 68, "relative_humidity": 35.02, "temperature": 14.42}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 3830, "long_name": "Black Bison", "next_hop": 0, "num": "0x260da738", "position": {"altitude": 1283, "latitude": 33.112125, "location_source": "LOC_INTERNAL", "longitude": -107.24965, "time_offset_sec": 3894}, "public_key_hex": "8a6ae15efc11de6472e5ab6c10fe14e0246fb946aea8c97f22aa760615181f32", "role": "CLIENT", "short_name": "BTMI", "snr": 10.34, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.547, "battery_level": 15, "channel_utilization": 14.47, "uptime_seconds": 476009, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 6097, "long_name": "Drifting Badger", "next_hop": 0, "num": "0x263416e7", "position": {"altitude": 1096, "latitude": 33.830303, "location_source": "LOC_INTERNAL", "longitude": -107.490771, "time_offset_sec": 6325}, "public_key_hex": "b823ce47f01999ab1637c7d77ca2fb7ba35f908e07e1de645c639026f2b1a167", "role": "SENSOR", "short_name": "DTSX", "snr": -1.59, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.8, "battery_level": 40, "channel_utilization": 20.91, "uptime_seconds": 87881, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3521, "long_name": "Blue Eagle", "next_hop": 133, "num": "0x26cf9199", "position": {"altitude": 1478, "latitude": 32.629431, "location_source": "LOC_INTERNAL", "longitude": -106.350308, "time_offset_sec": 3727}, "public_key_hex": "321fbae733735d01988187bc0b84958b73f606bccbfc2eca4d00aec3268b1b07", "role": "CLIENT", "short_name": "B98P", "snr": 9.1, "status": null, "telemetry": {"air_util_tx": 0.144, "battery_level": 87, "channel_utilization": 3.44, "uptime_seconds": 164918, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.0, "iaq": 62, "relative_humidity": 83.41, "temperature": 32.8}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1743, "long_name": "Quick Whale K16FQ", "next_hop": 230, "num": "0x271d81f7", "position": {"altitude": 1386, "latitude": 32.29077, "location_source": "LOC_INTERNAL", "longitude": -107.239959, "time_offset_sec": 1758}, "public_key_hex": "29b9e50a34cb7e2296e3b1e785ae71b0d52bd4cd4f83f4410dd5b29d767cc8c2", "role": "CLIENT", "short_name": "Q5HY", "snr": -2.9, "status": null, "telemetry": {"air_util_tx": 0.807, "battery_level": 37, "channel_utilization": 16.41, "uptime_seconds": 129252, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.88, "iaq": 62, "relative_humidity": 54.5, "temperature": 4.77}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 14, "long_name": "Fast Fox", "next_hop": 0, "num": "0x272f0f16", "position": {"altitude": 1433, "latitude": 33.731822, "location_source": "LOC_INTERNAL", "longitude": -107.069841, "time_offset_sec": 76}, "public_key_hex": "503b85749c185a4c928d5870033ae5827e87a631e5281271c716a820ff21de7c", "role": "CLIENT", "short_name": "🦇", "snr": 8.53, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.433, "battery_level": 45, "channel_utilization": 8.68, "uptime_seconds": 189939, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1234, "long_name": "Iron Moose", "next_hop": 0, "num": "0x2760d00c", "position": {"altitude": 992, "latitude": 32.31578, "location_source": "LOC_INTERNAL", "longitude": -107.341612, "time_offset_sec": 1519}, "public_key_hex": "ea31a62b48a52060304d2eb3ceac39b7e0aeab5453e62ec358b1e60f10c95610", "role": "CLIENT", "short_name": "IAU5", "snr": 4.7, "status": null, "telemetry": {"air_util_tx": 0.221, "battery_level": 44, "channel_utilization": 6.03, "uptime_seconds": 86250, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 620, "long_name": "Lost Badger", "next_hop": 0, "num": "0x2767e43f", "position": {"altitude": 1433, "latitude": 31.783371, "location_source": "LOC_INTERNAL", "longitude": -107.620251, "time_offset_sec": 860}, "public_key_hex": "599fa817e354f5d8d839ba9ad24cc8cc3fd51214ea4757864c3259ad9fec6911", "role": "TRACKER", "short_name": "🦋", "snr": 6.3, "status": null, "telemetry": {"air_util_tx": 0.597, "battery_level": 67, "channel_utilization": 5.52, "uptime_seconds": 23361, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 947, "long_name": "Tiny Bronco", "next_hop": 174, "num": "0x27a157ac", "position": {"altitude": 1712, "latitude": 33.953631, "location_source": "LOC_INTERNAL", "longitude": -106.363428, "time_offset_sec": 1043}, "public_key_hex": "e091234be7d4315069783d9919a08368423b40e00c7dcae5a4a52dfe9fb16904", "role": "CLIENT", "short_name": "🦉", "snr": 6.92, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.053, "battery_level": 33, "channel_utilization": 5.6, "uptime_seconds": 4558, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 223, "long_name": "Old Doe", "next_hop": 0, "num": "0x27c094b4", "position": {"altitude": 1458, "latitude": 33.531525, "location_source": "LOC_INTERNAL", "longitude": -107.447893, "time_offset_sec": 300}, "public_key_hex": "5a3da8d4c441032d62768f1c5afad87e053b0809390ce3536d0769f29853cee0", "role": "CLIENT", "short_name": "🐝", "snr": 6.88, "status": null, "telemetry": {"air_util_tx": 0.446, "battery_level": 30, "channel_utilization": 4.99, "uptime_seconds": 14081, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 3780, "long_name": "Forest Viper", "next_hop": 189, "num": "0x27cdf227", "position": {"altitude": 1201, "latitude": 33.410008, "location_source": "LOC_INTERNAL", "longitude": -107.102175, "time_offset_sec": 3993}, "public_key_hex": "8c71bdbe4ba4140608e16cc17fe256510c545eea81896f70d2d0f7189696d1c9", "role": "CLIENT", "short_name": "F2FR", "snr": 6.03, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.646, "battery_level": 18, "channel_utilization": 0.98, "uptime_seconds": 86140, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 295, "long_name": "New Cactus", "next_hop": 66, "num": "0x27d41924", "position": {"altitude": 1164, "latitude": 33.730347, "location_source": "LOC_INTERNAL", "longitude": -107.32601, "time_offset_sec": 492}, "public_key_hex": "", "role": "CLIENT", "short_name": "N5NB", "snr": 7.6, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.062, "battery_level": 77, "channel_utilization": 8.58, "uptime_seconds": 53483, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": {"barometric_pressure": 1026.7, "iaq": 91, "relative_humidity": 51.86, "temperature": 13.92}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1888, "long_name": "Frosty Lion", "next_hop": 0, "num": "0x27f2baea", "position": {"altitude": 1618, "latitude": 32.946329, "location_source": "LOC_INTERNAL", "longitude": -107.227918, "time_offset_sec": 1972}, "public_key_hex": "c3a615db3338573cce21b2fa659a5ab3d18fd7fa810f1269c2796908147abc37", "role": "CLIENT", "short_name": "🌊", "snr": 3.66, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2618, "long_name": "Misty Gecko", "next_hop": 0, "num": "0x285ceb44", "position": {"altitude": 1199, "latitude": 32.737459, "location_source": "LOC_INTERNAL", "longitude": -106.602673, "time_offset_sec": 2686}, "public_key_hex": "da69d7a4da04143b4709b9a0b664c8a1e9b0dbb4bcb629bfe082c7a9cc57e3a2", "role": "CLIENT", "short_name": "M374", "snr": 6.09, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 2.308, "battery_level": 48, "channel_utilization": 14.36, "uptime_seconds": 76830, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.08, "iaq": 23, "relative_humidity": 84.02, "temperature": 8.93}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 28520, "long_name": "White Turtle", "next_hop": 0, "num": "0x28924653", "position": null, "public_key_hex": "9a51304ff3894beaed8868e84d0346958e683d4fd2496b3da9f3abb6d91356f8", "role": "CLIENT", "short_name": "WFTI", "snr": 11.02, "status": null, "telemetry": {"air_util_tx": 0.933, "battery_level": 70, "channel_utilization": 6.89, "uptime_seconds": 15650, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1000.28, "iaq": 67, "relative_humidity": 47.3, "temperature": 13.68}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 1368, "long_name": "Solar Salmon", "next_hop": 0, "num": "0x28b60469", "position": {"altitude": 1235, "latitude": 33.45206, "location_source": "LOC_INTERNAL", "longitude": -106.859315, "time_offset_sec": 1370}, "public_key_hex": "149a824c6832581e4cd0a638ce6ea1a55b4163e8b169476465aac341cd5ff504", "role": "CLIENT", "short_name": "SCR5", "snr": 2.52, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.05, "iaq": 61, "relative_humidity": 87.04, "temperature": 28.2}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2260, "long_name": "River Bluff", "next_hop": 0, "num": "0x28dd21f3", "position": {"altitude": 1245, "latitude": 33.318388, "location_source": "LOC_INTERNAL", "longitude": -107.891433, "time_offset_sec": 2303}, "public_key_hex": "d556f2aaea30aaccc6320b9123706fef208f162bf28ac3588f8075dc337e9b2c", "role": "CLIENT", "short_name": "R475", "snr": 4.83, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 242, "long_name": "Stone Bass KX9NU", "next_hop": 39, "num": "0x28ef95ef", "position": {"altitude": 1695, "latitude": 34.205858, "location_source": "LOC_INTERNAL", "longitude": -107.85134, "time_offset_sec": 523}, "public_key_hex": "99db72ab41e9566f10e7a854f37f605907501daa99ff8913620c5b9ac43efaf6", "role": "CLIENT", "short_name": "SHZQ", "snr": 2.58, "status": null, "telemetry": {"air_util_tx": 0.23, "battery_level": 88, "channel_utilization": 10.41, "uptime_seconds": 117515, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8026, "long_name": "Sunny Arroyo", "next_hop": 0, "num": "0x290d418e", "position": {"altitude": 972, "latitude": 33.133674, "location_source": "LOC_INTERNAL", "longitude": -106.857304, "time_offset_sec": 8041}, "public_key_hex": "dfd2d83a5fe40b37008d4301fd1f04f18e2e685e145f9ad2bbd1e120d5bc631f", "role": "CLIENT", "short_name": "SBLY", "snr": 0.98, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1012.39, "iaq": 56, "relative_humidity": 40.96, "temperature": 1.46}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 250, "long_name": "White Cougar", "next_hop": 0, "num": "0x2920e29a", "position": {"altitude": 1003, "latitude": 33.424286, "location_source": "LOC_INTERNAL", "longitude": -107.958336, "time_offset_sec": 294}, "public_key_hex": "8b69dbb90b073027831039c58d3758bf0d72d4894e25288ecf9e265e35176f30", "role": "CLIENT", "short_name": "WI5P", "snr": 1.83, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 548, "long_name": "Sneaky Stag", "next_hop": 32, "num": "0x2959dc8d", "position": null, "public_key_hex": "71536125b9a4b023c70986552fa43ed0d513f8e9062bf5f6b1b4d1988eff3485", "role": "CLIENT_BASE", "short_name": "S5E8", "snr": 1.67, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2695, "long_name": "Shady Pony KE1OH", "next_hop": 145, "num": "0x295a05a2", "position": {"altitude": 1422, "latitude": 33.208019, "location_source": "LOC_INTERNAL", "longitude": -106.903827, "time_offset_sec": 2775}, "public_key_hex": "3917a43e313c6eb69609ed2a2b6172ab6c83fefddc0e46c8bd6b4ec3283f09c6", "role": "CLIENT_MUTE", "short_name": "S2SX", "snr": 4.15, "status": null, "telemetry": {"air_util_tx": 1.571, "battery_level": 69, "channel_utilization": 12.39, "uptime_seconds": 23475, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.97, "iaq": 45, "relative_humidity": 21.44, "temperature": 27.01}, "hops_away": 1, "hw_model": "RAK3401", "last_heard_offset_sec": 4987, "long_name": "White Beaver", "next_hop": 153, "num": "0x296e04de", "position": {"altitude": 942, "latitude": 32.532366, "location_source": "LOC_INTERNAL", "longitude": -107.439799, "time_offset_sec": 5169}, "public_key_hex": "f0a171a1a7acfbd197fc3dc5d61c08df2175840710aeed5ef8ee1bd444b4a19b", "role": "CLIENT", "short_name": "W2JY", "snr": 2.54, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 3810, "long_name": "River Viper", "next_hop": 208, "num": "0x297394b9", "position": {"altitude": 1547, "latitude": 33.490259, "location_source": "LOC_INTERNAL", "longitude": -107.185015, "time_offset_sec": 4037}, "public_key_hex": "27c030dd511f10b25defa49b893b5a4884f95a0cc6310ae4420ece318e788acc", "role": "CLIENT", "short_name": "RRBS", "snr": 1.12, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.922, "battery_level": 101, "channel_utilization": 16.68, "uptime_seconds": 63446, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 94, "long_name": "Lost Stag", "next_hop": 113, "num": "0x29a00c5b", "position": {"altitude": 1730, "latitude": 33.66469, "location_source": "LOC_INTERNAL", "longitude": -107.430676, "time_offset_sec": 289}, "public_key_hex": "2bae631737ec97094aac6a41e1b1087214e128a21d5942dfcd3acd3aaaab4e60", "role": "CLIENT_MUTE", "short_name": "LC5B", "snr": 3.86, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3352, "long_name": "Fast Bluff", "next_hop": 0, "num": "0x29ab61f7", "position": {"altitude": 1409, "latitude": 33.0083, "location_source": "LOC_INTERNAL", "longitude": -107.135504, "time_offset_sec": 3581}, "public_key_hex": "41d51f204b4b1a79ee4dc7b18dc96f001c50037fe088a6b3a729b4aa548482bb", "role": "CLIENT", "short_name": "FODU", "snr": 4.59, "status": null, "telemetry": {"air_util_tx": 0.233, "battery_level": 75, "channel_utilization": 8.71, "uptime_seconds": 103119, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.8, "iaq": 80, "relative_humidity": 39.12, "temperature": 31.16}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 996, "long_name": "Wild Squirrel", "next_hop": 219, "num": "0x29d0c73a", "position": null, "public_key_hex": "b0f62f434574c52350c8f003c6acbc35af9bc016c3320a26a071e85affe66dbd", "role": "CLIENT", "short_name": "WDZW", "snr": 11.77, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.661, "battery_level": 15, "channel_utilization": 6.69, "uptime_seconds": 136903, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 237, "long_name": "Solar Crow", "next_hop": 21, "num": "0x29d64ea8", "position": {"altitude": 1602, "latitude": 32.401284, "location_source": "LOC_INTERNAL", "longitude": -107.096473, "time_offset_sec": 471}, "public_key_hex": "6f257f37e33db723f337ecd26f0b9ebb7e67e8a6027b2a2605111726ca25d576", "role": "CLIENT", "short_name": "SRMQ", "snr": 6.38, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 991.97, "iaq": 50, "relative_humidity": 56.37, "temperature": 27.16}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 9269, "long_name": "Green Crane", "next_hop": 0, "num": "0x29ecbd55", "position": {"altitude": 1489, "latitude": 33.44898, "location_source": "LOC_INTERNAL", "longitude": -107.708919, "time_offset_sec": 9329}, "public_key_hex": "64ffc8099cab39cba810fcb39060ffbec37414791076f413beada9b0a2373114", "role": "CLIENT", "short_name": "GURJ", "snr": 4.38, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.993, "battery_level": 24, "channel_utilization": 16.07, "uptime_seconds": 34076, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 21, "long_name": "Frozen Whale", "next_hop": 236, "num": "0x29ed2717", "position": {"altitude": 1586, "latitude": 33.400643, "location_source": "LOC_INTERNAL", "longitude": -107.361883, "time_offset_sec": 101}, "public_key_hex": "d9f37894aa0fe94372d80aa0fbeb9691e11d90134c06018abab305c3a8c38bf5", "role": "ROUTER_LATE", "short_name": "FI41", "snr": -2.52, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.657, "battery_level": 81, "channel_utilization": 9.98, "uptime_seconds": 3468, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 12906, "long_name": "Smooth Arroyo", "next_hop": 146, "num": "0x29f07aac", "position": {"altitude": 1607, "latitude": 32.278247, "location_source": "LOC_INTERNAL", "longitude": -106.276912, "time_offset_sec": 13013}, "public_key_hex": "8e6597833a852a89892a72a94c8fd7eba68969bf27ca825b06a6a924000a788c", "role": "CLIENT", "short_name": "SK97", "snr": 2.24, "status": null, "telemetry": {"air_util_tx": 0.584, "battery_level": 92, "channel_utilization": 11.89, "uptime_seconds": 4954, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 8568, "long_name": "Lone Falcon", "next_hop": 0, "num": "0x2a1fcfa4", "position": {"altitude": 892, "latitude": 32.955682, "location_source": "LOC_INTERNAL", "longitude": -106.772784, "time_offset_sec": 8742}, "public_key_hex": "8df74e58c5fca69aeef4e6cacce38631d9b639a2e033fa9cf06c8ee302247970", "role": "CLIENT_MUTE", "short_name": "🦂", "snr": 5.25, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3713, "long_name": "Misty Hawk", "next_hop": 45, "num": "0x2a3608d4", "position": {"altitude": 1087, "latitude": 34.114105, "location_source": "LOC_INTERNAL", "longitude": -107.581192, "time_offset_sec": 3789}, "public_key_hex": "", "role": "TRACKER", "short_name": "M4CO", "snr": 7.33, "status": null, "telemetry": {"air_util_tx": 1.155, "battery_level": 95, "channel_utilization": 10.03, "uptime_seconds": 168366, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 281, "long_name": "Blue Elk", "next_hop": 0, "num": "0x2a3e6316", "position": {"altitude": 1470, "latitude": 32.424089, "location_source": "LOC_INTERNAL", "longitude": -106.877345, "time_offset_sec": 312}, "public_key_hex": "0a6ca347a69e0d90a524288b483223bece0f56199f6b70d597a7337b1389e0a3", "role": "CLIENT", "short_name": "B0FI", "snr": 6.93, "status": null, "telemetry": {"air_util_tx": 0.803, "battery_level": 29, "channel_utilization": 1.75, "uptime_seconds": 32053, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.45, "iaq": 81, "relative_humidity": 48.47, "temperature": 32.21}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 412, "long_name": "Brave Mamba", "next_hop": 0, "num": "0x2a58dd10", "position": {"altitude": 1665, "latitude": 33.293126, "location_source": "LOC_INTERNAL", "longitude": -107.627795, "time_offset_sec": 691}, "public_key_hex": "f3ef9d9b48464d1767afd5915dcc2289a92da2d93a4d79f229c67666dfe5f882", "role": "CLIENT_BASE", "short_name": "BZZJ", "snr": 9.64, "status": null, "telemetry": {"air_util_tx": 0.531, "battery_level": 43, "channel_utilization": 1.76, "uptime_seconds": 7950, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7763, "long_name": "Short Trout", "next_hop": 0, "num": "0x2af01829", "position": {"altitude": 989, "latitude": 32.414528, "location_source": "LOC_INTERNAL", "longitude": -107.497786, "time_offset_sec": 8046}, "public_key_hex": "824c50cfeeccac074c930d4a5f3fac9cb99173d518aa8de3ca6c3b331dcdad8f", "role": "CLIENT", "short_name": "SSEZ", "snr": 3.54, "status": null, "telemetry": {"air_util_tx": 0.724, "battery_level": 20, "channel_utilization": 8.99, "uptime_seconds": 87297, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 10562, "long_name": "River Ridge", "next_hop": 0, "num": "0x2af0af50", "position": {"altitude": 1184, "latitude": 33.249259, "location_source": "LOC_INTERNAL", "longitude": -107.399329, "time_offset_sec": 10696}, "public_key_hex": "", "role": "CLIENT", "short_name": "RGSE", "snr": 9.16, "status": null, "telemetry": {"air_util_tx": 0.502, "battery_level": 23, "channel_utilization": 1.84, "uptime_seconds": 66240, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.5, "iaq": 58, "relative_humidity": 26.12, "temperature": 15.55}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 159, "long_name": "Blue Moose", "next_hop": 109, "num": "0x2b07a2ee", "position": {"altitude": 1523, "latitude": 33.470365, "location_source": "LOC_INTERNAL", "longitude": -108.259475, "time_offset_sec": 264}, "public_key_hex": "6ee1e7a834e2ba14328ce4f1d8a23b36b8d17dc1fc0455fb8b6d412b1133168a", "role": "CLIENT", "short_name": "BLBP", "snr": 12.0, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 160, "long_name": "Sky Pine", "next_hop": 35, "num": "0x2b151250", "position": {"altitude": 993, "latitude": 32.39639, "location_source": "LOC_INTERNAL", "longitude": -107.267018, "time_offset_sec": 388}, "public_key_hex": "59e6d9b656316fea8a262b1e9fe16a5198ae672e1da4751ce3d50afc570d227b", "role": "CLIENT_MUTE", "short_name": "SPDA", "snr": 4.34, "status": null, "telemetry": {"air_util_tx": 0.187, "battery_level": 57, "channel_utilization": 11.0, "uptime_seconds": 19723, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3585, "long_name": "Brave Coyote", "next_hop": 0, "num": "0x2b1fe8e1", "position": {"altitude": 1366, "latitude": 33.373229, "location_source": "LOC_INTERNAL", "longitude": -107.325053, "time_offset_sec": 3714}, "public_key_hex": "03d9c7ef6ff5251006fded85c8d8e654be668f08981d2a63bb457fe70a2c069a", "role": "CLIENT", "short_name": "BKGO", "snr": 3.84, "status": null, "telemetry": {"air_util_tx": 0.189, "battery_level": 18, "channel_utilization": 10.25, "uptime_seconds": 126457, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 6632, "long_name": "Black Cougar", "next_hop": 218, "num": "0x2b369d93", "position": {"altitude": 1354, "latitude": 33.509018, "location_source": "LOC_INTERNAL", "longitude": -108.274872, "time_offset_sec": 6757}, "public_key_hex": "43585a334c3881790ca3c2b0d48592ae4e1c6d7f0c753ba5fca0f20251657779", "role": "CLIENT", "short_name": "BD4W", "snr": 8.5, "status": null, "telemetry": {"air_util_tx": 0.264, "battery_level": 29, "channel_utilization": 9.22, "uptime_seconds": 95338, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 5328, "long_name": "Burning Arroyo", "next_hop": 0, "num": "0x2b41375a", "position": {"altitude": 1618, "latitude": 33.413122, "location_source": "LOC_INTERNAL", "longitude": -106.747565, "time_offset_sec": 5337}, "public_key_hex": "295bed8ea0ca7e10f66ce051b00a90fb7edd1e1ac725f90701a22eca0fecfcb1", "role": "TRACKER", "short_name": "BD6J", "snr": 3.25, "status": null, "telemetry": {"air_util_tx": 1.679, "battery_level": 87, "channel_utilization": 26.98, "uptime_seconds": 20908, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1777, "long_name": "Desert Trout", "next_hop": 6, "num": "0x2b540086", "position": {"altitude": 1602, "latitude": 32.94434, "location_source": "LOC_INTERNAL", "longitude": -107.380062, "time_offset_sec": 1975}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "DDRZ", "snr": 3.89, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7531, "long_name": "Hidden Owl", "next_hop": 108, "num": "0x2b5889aa", "position": {"altitude": 1260, "latitude": 33.053816, "location_source": "LOC_INTERNAL", "longitude": -106.385623, "time_offset_sec": 7622}, "public_key_hex": "960a0ce8122ec0313e7b6d09cb5fbd7edafe309b73ab5fff00b75799c3d290ca", "role": "CLIENT", "short_name": "HQIN", "snr": 8.53, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 1.018, "battery_level": 72, "channel_utilization": 8.61, "uptime_seconds": 20263, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.55, "iaq": 92, "relative_humidity": 100.0, "temperature": 26.63}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 6278, "long_name": "Tall Elk", "next_hop": 0, "num": "0x2bb3d78d", "position": {"altitude": 1506, "latitude": 34.300671, "location_source": "LOC_INTERNAL", "longitude": -107.569727, "time_offset_sec": 6352}, "public_key_hex": "c051eb00843e71829af7a74e6d117d1727d66ca7aadeee3b94e1a0c3977651ce", "role": "CLIENT", "short_name": "TI8N", "snr": 6.64, "status": null, "telemetry": {"air_util_tx": 1.037, "battery_level": 31, "channel_utilization": 7.1, "uptime_seconds": 32163, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2715, "long_name": "Howling Bear", "next_hop": 0, "num": "0x2c064160", "position": {"altitude": 1202, "latitude": 33.053541, "location_source": "LOC_INTERNAL", "longitude": -107.178531, "time_offset_sec": 2787}, "public_key_hex": "6dd2b67e1f0aaae3ec6e2949e78923da54803ac716a2bd5448a10867911702a9", "role": "CLIENT", "short_name": "H9AJ", "snr": 1.42, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.614, "battery_level": 24, "channel_utilization": 19.87, "uptime_seconds": 223453, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4046, "long_name": "Silent Wolf", "next_hop": 0, "num": "0x2c2ddd36", "position": {"altitude": 1475, "latitude": 33.043842, "location_source": "LOC_INTERNAL", "longitude": -106.663846, "time_offset_sec": 4055}, "public_key_hex": "f575f33d815acb2ce8d23a68d1bf4ff6fd85b95b51352586a948be952d31f507", "role": "CLIENT", "short_name": "S7W3", "snr": 3.42, "status": null, "telemetry": {"air_util_tx": 0.365, "battery_level": 69, "channel_utilization": 11.68, "uptime_seconds": 84869, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 2212, "long_name": "Frosty Bass", "next_hop": 199, "num": "0x2c406d8d", "position": {"altitude": 1214, "latitude": 33.280348, "location_source": "LOC_INTERNAL", "longitude": -106.695916, "time_offset_sec": 2289}, "public_key_hex": "", "role": "CLIENT", "short_name": "F7DH", "snr": 6.44, "status": null, "telemetry": {"air_util_tx": 0.109, "battery_level": 29, "channel_utilization": 10.64, "uptime_seconds": 123047, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8280, "long_name": "Silver Stag", "next_hop": 140, "num": "0x2c42f408", "position": {"altitude": 1465, "latitude": 32.872477, "location_source": "LOC_INTERNAL", "longitude": -106.952602, "time_offset_sec": 8557}, "public_key_hex": "609478521a1fb75fb0c9b29a1793086112b00ec2bf44bd00ba1dc08466de2a67", "role": "CLIENT", "short_name": "SB0K", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2692, "long_name": "Rough Mustang", "next_hop": 0, "num": "0x2c83699b", "position": {"altitude": 1107, "latitude": 32.602556, "location_source": "LOC_INTERNAL", "longitude": -107.51332, "time_offset_sec": 2746}, "public_key_hex": "bded85cd70ccd4d930b548b826094a6a4e61006891103e5b2c5a84cfeb47a438", "role": "CLIENT", "short_name": "RMTC", "snr": 1.78, "status": null, "telemetry": {"air_util_tx": 0.343, "battery_level": 78, "channel_utilization": 13.86, "uptime_seconds": 160449, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.66, "iaq": 47, "relative_humidity": 74.97, "temperature": 21.89}, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3560, "long_name": "White Arroyo", "next_hop": 4, "num": "0x2ca25a3d", "position": null, "public_key_hex": "3038fae9e3edf4ddd64c15563570d932924a5d5a3cdc4f3083df7e36419aed26", "role": "CLIENT", "short_name": "W6PV", "snr": 5.28, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2394, "long_name": "Sharp Sage", "next_hop": 0, "num": "0x2cce7739", "position": null, "public_key_hex": "902ec328cd19911359c576fcdee6a72811b1eb49ecbac913322bdb0864b3fc5e", "role": "CLIENT", "short_name": "SMB2", "snr": 1.99, "status": null, "telemetry": {"air_util_tx": 0.474, "battery_level": 15, "channel_utilization": 2.19, "uptime_seconds": 17585, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 4068, "long_name": "Roving Adder", "next_hop": 35, "num": "0x2cd4d8fd", "position": {"altitude": 1657, "latitude": 32.423267, "location_source": "LOC_INTERNAL", "longitude": -106.183765, "time_offset_sec": 4293}, "public_key_hex": "27c2934fafe7bcd73bf669a6b491d3e2540129429fc8cde0a36bb956ea45cd87", "role": "CLIENT", "short_name": "RU56", "snr": 0.46, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 729, "long_name": "Blue Moose", "next_hop": 0, "num": "0x2cd7972c", "position": {"altitude": 1274, "latitude": 32.749778, "location_source": "LOC_INTERNAL", "longitude": -107.846717, "time_offset_sec": 1029}, "public_key_hex": "b49fbb20e03c68f88c157fba364db723406dbdeeb537831b520627bdca05f84d", "role": "CLIENT", "short_name": "B2VV", "snr": 10.38, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5293, "long_name": "Blue Aspen", "next_hop": 45, "num": "0x2cfbc56c", "position": {"altitude": 1344, "latitude": 32.737511, "location_source": "LOC_INTERNAL", "longitude": -107.694047, "time_offset_sec": 5444}, "public_key_hex": "3d83bd7468d264160520d19b49286fc7ad9c925cba9695379f0ce617b09e873b", "role": "CLIENT", "short_name": "BY1M", "snr": 6.87, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 8741, "long_name": "Silent Mesa", "next_hop": 244, "num": "0x2d0ad938", "position": {"altitude": 1695, "latitude": 33.962694, "location_source": "LOC_INTERNAL", "longitude": -107.283978, "time_offset_sec": 8987}, "public_key_hex": "ad50016fb1a15373850e52d0d697f3ca2d1a176bfae9f07e4effea089bc2ebc6", "role": "CLIENT", "short_name": "🌵", "snr": 7.35, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.17, "battery_level": 78, "channel_utilization": 12.99, "uptime_seconds": 93602, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 518, "long_name": "Dawn Eagle", "next_hop": 34, "num": "0x2d5b18bb", "position": {"altitude": 1781, "latitude": 33.174691, "location_source": "LOC_INTERNAL", "longitude": -106.690466, "time_offset_sec": 524}, "public_key_hex": "1443c8a01e804146565696e6f4abf518c22959c0f9d9d9f744c9c0cffec91407", "role": "CLIENT", "short_name": "D5VB", "snr": 4.65, "status": null, "telemetry": {"air_util_tx": 1.285, "battery_level": 40, "channel_utilization": 17.73, "uptime_seconds": 165212, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3819, "long_name": "Hidden Bear", "next_hop": 0, "num": "0x2d6c2030", "position": {"altitude": 1118, "latitude": 32.771578, "location_source": "LOC_INTERNAL", "longitude": -106.552367, "time_offset_sec": 3959}, "public_key_hex": "fa61f3b5d29a84f551187036c7ee53697b2252be964c9ade6b32d6fa7f2ee458", "role": "CLIENT", "short_name": "HZGD", "snr": 1.94, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.236, "battery_level": 98, "channel_utilization": 14.69, "uptime_seconds": 165378, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 251, "long_name": "Quick Pike", "next_hop": 0, "num": "0x2dd70965", "position": {"altitude": 1587, "latitude": 32.404412, "location_source": "LOC_INTERNAL", "longitude": -107.525004, "time_offset_sec": 469}, "public_key_hex": "833beccf2a97a8f8d5120b55f9d9c185103cf01da50d3678abec7adf862789ca", "role": "CLIENT_MUTE", "short_name": "QXGN", "snr": 9.49, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.788, "battery_level": 43, "channel_utilization": 13.36, "uptime_seconds": 47198, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 8117, "long_name": "Burning Juniper", "next_hop": 0, "num": "0x2ddbd021", "position": {"altitude": 1156, "latitude": 32.353647, "location_source": "LOC_INTERNAL", "longitude": -106.545398, "time_offset_sec": 8258}, "public_key_hex": "6919a5683bb64bf73a53ad217c8ccbc46b19414d9418082cc823abddb002c286", "role": "ROUTER", "short_name": "BF1Z", "snr": 7.2, "status": null, "telemetry": {"air_util_tx": 0.813, "battery_level": 101, "channel_utilization": 2.11, "uptime_seconds": 156069, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4440, "long_name": "Loud Doe", "next_hop": 0, "num": "0x2e14ae3a", "position": {"altitude": 999, "latitude": 32.477337, "location_source": "LOC_INTERNAL", "longitude": -107.486755, "time_offset_sec": 4453}, "public_key_hex": "fa3e38061ce6d563cc732a16e5225db8caf998b0dfefe7425019d5f66ec1f412", "role": "CLIENT", "short_name": "LPC1", "snr": 7.18, "status": null, "telemetry": {"air_util_tx": 1.386, "battery_level": 56, "channel_utilization": 2.57, "uptime_seconds": 222029, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 471, "long_name": "Tiny Heron", "next_hop": 153, "num": "0x2e444919", "position": {"altitude": 1815, "latitude": 32.018757, "location_source": "LOC_INTERNAL", "longitude": -106.892279, "time_offset_sec": 518}, "public_key_hex": "43cc14c9eef5806bfbf5adb942246bf2270fe301a1d9e9200015334d60ae278b", "role": "CLIENT", "short_name": "T66X", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 4092, "long_name": "Roving Oak", "next_hop": 0, "num": "0x2ec496c7", "position": {"altitude": 1513, "latitude": 33.148346, "location_source": "LOC_INTERNAL", "longitude": -106.670354, "time_offset_sec": 4188}, "public_key_hex": "e2133291579cf5291ae736b334b8a36155969a15c8148dc41359af65a68c1599", "role": "CLIENT", "short_name": "RWGZ", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.24, "battery_level": 67, "channel_utilization": 11.47, "uptime_seconds": 65517, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 10401, "long_name": "Sleepy Oak", "next_hop": 34, "num": "0x2ed3dddc", "position": {"altitude": 1474, "latitude": 33.00574, "location_source": "LOC_INTERNAL", "longitude": -107.534748, "time_offset_sec": 10493}, "public_key_hex": "1d5b61539b0749f0540bde7a74bd6b18b048fc9f3294bf4daf44197f66c5e86e", "role": "CLIENT_BASE", "short_name": "S8NO", "snr": 5.87, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 402, "long_name": "Slow Lion", "next_hop": 0, "num": "0x2ed779e3", "position": {"altitude": 1413, "latitude": 33.534586, "location_source": "LOC_INTERNAL", "longitude": -109.134808, "time_offset_sec": 673}, "public_key_hex": "f7ff5e1f3e5820962d41a3fa189eeebf92c486c36544bbbfd05793ad6cfe108e", "role": "CLIENT", "short_name": "S4JV", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.843, "battery_level": 79, "channel_utilization": 20.01, "uptime_seconds": 281550, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3159, "long_name": "Hidden Salmon", "next_hop": 250, "num": "0x2ef724cf", "position": {"altitude": 1412, "latitude": 32.8682, "location_source": "LOC_INTERNAL", "longitude": -106.719442, "time_offset_sec": 3371}, "public_key_hex": "de588749f8d715c1cfa0e06c09d550c662041b7229f657beec36e545f5680960", "role": "CLIENT", "short_name": "H1HY", "snr": 10.04, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7942, "long_name": "Happy Dolphin", "next_hop": 0, "num": "0x2f3129ff", "position": null, "public_key_hex": "f730d1390e36559355e6ead25ef5a39bb774bd2a589ea1e113501d8f93ef90b2", "role": "CLIENT", "short_name": "H9BE", "snr": 3.65, "status": null, "telemetry": {"air_util_tx": 0.283, "battery_level": 71, "channel_utilization": 4.01, "uptime_seconds": 101756, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 399, "long_name": "Misty Mole", "next_hop": 0, "num": "0x2f39c6bf", "position": {"altitude": 1162, "latitude": 33.627722, "location_source": "LOC_INTERNAL", "longitude": -107.295756, "time_offset_sec": 622}, "public_key_hex": "d40ac94d9a167e11cd139b4d8c7f81ea7deb5564232716c8f809b0f16550ee95", "role": "CLIENT_MUTE", "short_name": "MFV0", "snr": 5.81, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.815, "battery_level": 32, "channel_utilization": 17.84, "uptime_seconds": 264158, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1026.57, "iaq": 49, "relative_humidity": 86.21, "temperature": 18.95}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 9191, "long_name": "Black Cobra", "next_hop": 0, "num": "0x2f7522cb", "position": {"altitude": 1494, "latitude": 33.115303, "location_source": "LOC_INTERNAL", "longitude": -107.40015, "time_offset_sec": 9413}, "public_key_hex": "c9c5b9ef102081b5c2d162f6e90875ff95b2bd4bf24f909a5677a9158fad43ec", "role": "ROUTER", "short_name": "BXPA", "snr": 3.43, "status": null, "telemetry": {"air_util_tx": 0.724, "battery_level": 51, "channel_utilization": 15.46, "uptime_seconds": 14773, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.01, "iaq": 0, "relative_humidity": 57.69, "temperature": 27.13}, "hops_away": 1, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 931, "long_name": "Whispering Cedar", "next_hop": 213, "num": "0x2f83a338", "position": {"altitude": 1244, "latitude": 33.023709, "location_source": "LOC_INTERNAL", "longitude": -107.324534, "time_offset_sec": 1206}, "public_key_hex": "1ebbdf891164a4a437e3d4f1d6baf1c678af9ef786cbcdf7be5e5b7c31bcd519", "role": "CLIENT", "short_name": "WQ3Q", "snr": 5.5, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5156, "long_name": "Whispering Tortoise", "next_hop": 94, "num": "0x2fa0ddba", "position": {"altitude": 1743, "latitude": 33.239586, "location_source": "LOC_INTERNAL", "longitude": -107.238791, "time_offset_sec": 5169}, "public_key_hex": "774db79a594e1c21dc3f2a048f8f05d055597d48c6817c048cbae4eebbbf9a3f", "role": "CLIENT", "short_name": "WC3D", "snr": 5.04, "status": null, "telemetry": {"air_util_tx": 0.255, "battery_level": 93, "channel_utilization": 41.46, "uptime_seconds": 115942, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 125, "long_name": "White Mole", "next_hop": 0, "num": "0x2fad4496", "position": {"altitude": 1243, "latitude": 33.228923, "location_source": "LOC_INTERNAL", "longitude": -107.476298, "time_offset_sec": 316}, "public_key_hex": "e863dd75bd48e3b7c2d4b07ebfd19934057193628d17852699ad6dac9ab18c37", "role": "CLIENT", "short_name": "W3KZ", "snr": 7.91, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 6566, "long_name": "White Mole", "next_hop": 67, "num": "0x2fd8ce3a", "position": {"altitude": 1476, "latitude": 34.386061, "location_source": "LOC_INTERNAL", "longitude": -107.189066, "time_offset_sec": 6663}, "public_key_hex": "d68ee0b5ef45c1510d7437e395d55b5f21c60089050e345b7ef40ca67f7de50a", "role": "ROUTER", "short_name": "WA45", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.11, "iaq": 55, "relative_humidity": 56.52, "temperature": 20.83}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 52, "long_name": "Red Aspen", "next_hop": 26, "num": "0x30024f39", "position": {"altitude": 1240, "latitude": 33.425142, "location_source": "LOC_INTERNAL", "longitude": -108.096302, "time_offset_sec": 285}, "public_key_hex": "4e7153bd5656fffbe831da6cff45d9416c2aa8ea05334a0ac8f907411c2b7911", "role": "CLIENT", "short_name": "R62I", "snr": 4.51, "status": null, "telemetry": {"air_util_tx": 1.447, "battery_level": 85, "channel_utilization": 2.62, "uptime_seconds": 42772, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 12906, "long_name": "Burning Squirrel", "next_hop": 137, "num": "0x30190ed0", "position": {"altitude": 1384, "latitude": 34.306838, "location_source": "LOC_INTERNAL", "longitude": -107.236331, "time_offset_sec": 13006}, "public_key_hex": "231d7218cbb155bf525e1246e2d18632f1b37667fbf207db18ed39ed348eee46", "role": "CLIENT_MUTE", "short_name": "BXJ7", "snr": 5.9, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 4656, "long_name": "Silver Pony", "next_hop": 0, "num": "0x30282da6", "position": {"altitude": 1562, "latitude": 32.433478, "location_source": "LOC_INTERNAL", "longitude": -107.842876, "time_offset_sec": 4914}, "public_key_hex": "c9b6643faad22d76ca70299f8de9dab1d3b9465f05ccdeb2161bc7bb6357d67d", "role": "ROUTER", "short_name": "SV3T", "snr": 2.85, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.149, "battery_level": 64, "channel_utilization": 8.22, "uptime_seconds": 380697, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 2418, "long_name": "River Mustang", "next_hop": 0, "num": "0x30453a23", "position": {"altitude": 1024, "latitude": 33.073057, "location_source": "LOC_INTERNAL", "longitude": -107.631775, "time_offset_sec": 2565}, "public_key_hex": "b1a4c8eecbbfcae56105b8ba81ffd6e65197f17c75b2ed88362a3f77ca8d98f7", "role": "CLIENT", "short_name": "RDM5", "snr": 2.36, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 881, "long_name": "Silver Shark", "next_hop": 0, "num": "0x305839ca", "position": {"altitude": 2023, "latitude": 33.813564, "location_source": "LOC_INTERNAL", "longitude": -107.073139, "time_offset_sec": 1006}, "public_key_hex": "dc8b96dd24f4b46361b29a397c57b644c7e832ca79c0b2e33a2441b77d0692d4", "role": "ROUTER_LATE", "short_name": "S4XD", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.237, "battery_level": 58, "channel_utilization": 23.71, "uptime_seconds": 8846, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1083, "long_name": "Tall Cougar", "next_hop": 0, "num": "0x308051e7", "position": {"altitude": 1626, "latitude": 32.566838, "location_source": "LOC_INTERNAL", "longitude": -106.465365, "time_offset_sec": 1367}, "public_key_hex": "04d0b74cb5b8863f576b9073ec8fab1bd39feb884931588cc2eccf72064e249e", "role": "CLIENT", "short_name": "TBYG", "snr": 5.42, "status": null, "telemetry": {"air_util_tx": 0.501, "battery_level": 87, "channel_utilization": 16.31, "uptime_seconds": 15868, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1048, "long_name": "New Colt", "next_hop": 202, "num": "0x30c015b2", "position": {"altitude": 1531, "latitude": 33.280866, "location_source": "LOC_INTERNAL", "longitude": -106.071556, "time_offset_sec": 1112}, "public_key_hex": "d2185d48c16d3ede1285640aa958c9a009816ae6a7c702392598d4721f26cef1", "role": "CLIENT", "short_name": "🦌", "snr": 8.32, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.915, "battery_level": 58, "channel_utilization": 2.27, "uptime_seconds": 194279, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 3357, "long_name": "Blue Oak", "next_hop": 0, "num": "0x30dbf65e", "position": {"altitude": 1197, "latitude": 33.387359, "location_source": "LOC_INTERNAL", "longitude": -107.466167, "time_offset_sec": 3381}, "public_key_hex": "cbae9caff8c2fef61f88fc8ceca25ed45b254f36aa0dd2bb31c382f1c8689d4b", "role": "CLIENT", "short_name": "B01U", "snr": 5.68, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.745, "battery_level": 54, "channel_utilization": 6.52, "uptime_seconds": 7761, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 999.5, "iaq": 0, "relative_humidity": 45.72, "temperature": 35.98}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 110, "long_name": "Smooth Cobra", "next_hop": 0, "num": "0x30ebd339", "position": null, "public_key_hex": "5dc467f994e20ea922cd579c0ba6caa970952ab66ea6ec66f1593c75f1cfbe49", "role": "CLIENT", "short_name": "🌲", "snr": 4.94, "status": null, "telemetry": {"air_util_tx": 0.947, "battery_level": 92, "channel_utilization": 20.73, "uptime_seconds": 16772, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3446, "long_name": "Wild Cougar", "next_hop": 132, "num": "0x31164080", "position": {"altitude": 1736, "latitude": 34.13362, "location_source": "LOC_INTERNAL", "longitude": -107.182711, "time_offset_sec": 3605}, "public_key_hex": "d467172583bbd805df406168d90aecae0644fe4c59cb8237698d73d04bb95457", "role": "CLIENT_HIDDEN", "short_name": "WZXS", "snr": 0.52, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 4954, "long_name": "Slow Ridge", "next_hop": 0, "num": "0x315e213c", "position": {"altitude": 1347, "latitude": 33.496744, "location_source": "LOC_INTERNAL", "longitude": -108.208066, "time_offset_sec": 5125}, "public_key_hex": "651be75a02e4e6d7363c4cfd19a38cb5dea74f1f937508717828e4bd3516cdc5", "role": "CLIENT", "short_name": "SHTR", "snr": 5.06, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.266, "battery_level": 48, "channel_utilization": 19.99, "uptime_seconds": 18457, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1045, "long_name": "Sharp Adder", "next_hop": 0, "num": "0x318b26bb", "position": {"altitude": 1604, "latitude": 33.538093, "location_source": "LOC_INTERNAL", "longitude": -106.862067, "time_offset_sec": 1154}, "public_key_hex": "d9567fc355c3d48bd1c46e042a715efb6d3815fc53c9fb7e5ded2552d59ac3d8", "role": "TAK", "short_name": "S7H3", "snr": 6.59, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1012.11, "iaq": 28, "relative_humidity": 25.48, "temperature": 28.05}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 354, "long_name": "Old Mole", "next_hop": 87, "num": "0x31a6c4e8", "position": {"altitude": 1663, "latitude": 32.554485, "location_source": "LOC_INTERNAL", "longitude": -107.615204, "time_offset_sec": 418}, "public_key_hex": "cf3dfe3ca822bd04280eafeb91041a4fb834f59c86c0cb2f8e72e073063709fa", "role": "CLIENT", "short_name": "O16S", "snr": 4.71, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 6200, "long_name": "Found Tortoise", "next_hop": 0, "num": "0x31d27f54", "position": {"altitude": 1446, "latitude": 31.64219, "location_source": "LOC_INTERNAL", "longitude": -106.551893, "time_offset_sec": 6374}, "public_key_hex": "26cf66ac27d5385cebcc8b9aaba0fec6bb85155a4b2ab22e894106db918754b4", "role": "CLIENT", "short_name": "FK94", "snr": 11.82, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 70, "long_name": "Solar Badger", "next_hop": 218, "num": "0x31e44d48", "position": null, "public_key_hex": "061ee379c8b101611c65754a4bd2bc2866e52e5497dca5c08bda5ad1e3b053d2", "role": "CLIENT", "short_name": "SD0D", "snr": 4.16, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1185, "long_name": "Sunny Tortoise", "next_hop": 0, "num": "0x32042558", "position": {"altitude": 926, "latitude": 33.54273, "location_source": "LOC_INTERNAL", "longitude": -107.160991, "time_offset_sec": 1186}, "public_key_hex": "f34b354f219745034c815dd16c8b5430d0f0872efb930bdd673d1aa4573b3d7b", "role": "CLIENT", "short_name": "S1QJ", "snr": -1.33, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 4491, "long_name": "Gold Mesa", "next_hop": 0, "num": "0x3216137b", "position": null, "public_key_hex": "f27972739843e3b05388971e62a65756672adceb705f4f08d6e6ede332dfc0c4", "role": "CLIENT", "short_name": "G2M4", "snr": 1.24, "status": null, "telemetry": {"air_util_tx": 0.233, "battery_level": 56, "channel_utilization": 7.57, "uptime_seconds": 507382, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1008.02, "iaq": 51, "relative_humidity": 68.38, "temperature": 34.44}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4060, "long_name": "Giant Salmon", "next_hop": 250, "num": "0x3227bd7e", "position": {"altitude": 1411, "latitude": 32.344233, "location_source": "LOC_INTERNAL", "longitude": -106.530415, "time_offset_sec": 4287}, "public_key_hex": "f470ee91ac72b146a74252887d9326791a5ef74d5a83aeab048d7900c39ff670", "role": "CLIENT", "short_name": "G8CI", "snr": 5.87, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 4685, "long_name": "Black Coyote", "next_hop": 126, "num": "0x32414255", "position": {"altitude": 1110, "latitude": 33.903196, "location_source": "LOC_INTERNAL", "longitude": -106.870216, "time_offset_sec": 4740}, "public_key_hex": "19298560b859b17b719bd9c375bdcdb1727a7b57db744b99b798d725551fef44", "role": "CLIENT", "short_name": "BFHA", "snr": 2.58, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 2.18, "battery_level": 21, "channel_utilization": 1.03, "uptime_seconds": 153017, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5055, "long_name": "White Yucca KQ3TG", "next_hop": 0, "num": "0x3244dee5", "position": {"altitude": 1064, "latitude": 33.054692, "location_source": "LOC_INTERNAL", "longitude": -107.371718, "time_offset_sec": 5177}, "public_key_hex": "40e755cf4dc242cc4fcced0938447d51335ce704459d8e635d8f1b0e15ec4cfc", "role": "CLIENT", "short_name": "W3AP", "snr": 1.87, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.493, "battery_level": 74, "channel_utilization": 9.94, "uptime_seconds": 132642, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2090, "long_name": "Shady Squirrel", "next_hop": 0, "num": "0x324756b7", "position": {"altitude": 1149, "latitude": 33.671093, "location_source": "LOC_INTERNAL", "longitude": -106.855613, "time_offset_sec": 2106}, "public_key_hex": "923451e5621d2bfd75680765d05515dcd00e2f714791ae67305fa24d943fafa8", "role": "CLIENT", "short_name": "SMS8", "snr": 9.44, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.557, "battery_level": 90, "channel_utilization": 2.75, "uptime_seconds": 40161, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 24, "long_name": "Stone Wolf", "next_hop": 187, "num": "0x329b2914", "position": null, "public_key_hex": "6734cec9a8ce4ea965f2af7d495dcfd248483536dd6df2fbc377476401b5266d", "role": "CLIENT_MUTE", "short_name": "SF75", "snr": 8.07, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4276, "long_name": "Whispering Moose WD6GQ", "next_hop": 0, "num": "0x32a7ee06", "position": null, "public_key_hex": "", "role": "ROUTER_LATE", "short_name": "WHA4", "snr": 7.09, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.397, "battery_level": 80, "channel_utilization": 2.32, "uptime_seconds": 1272, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1028.21, "iaq": 48, "relative_humidity": 44.16, "temperature": 21.71}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1874, "long_name": "Fast Wolf", "next_hop": 229, "num": "0x3333cced", "position": null, "public_key_hex": "c63e1538af5e5fd8de84bde301801198119e185e74e1d91da42018531438ec06", "role": "ROUTER", "short_name": "F340", "snr": 0.87, "status": null, "telemetry": {"air_util_tx": 1.127, "battery_level": 93, "channel_utilization": 17.03, "uptime_seconds": 86216, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.61, "iaq": 72, "relative_humidity": 50.47, "temperature": 29.05}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 784, "long_name": "Giant Cobra", "next_hop": 0, "num": "0x33596ead", "position": {"altitude": 1662, "latitude": 33.653654, "location_source": "LOC_INTERNAL", "longitude": -107.568531, "time_offset_sec": 867}, "public_key_hex": "bcfaee834d8a2ba1c6a0ab7ae8851cdd9c00b4d6d3735eb64ce35f2408309612", "role": "CLIENT", "short_name": "GLXS", "snr": -4.79, "status": null, "telemetry": {"air_util_tx": 0.437, "battery_level": 101, "channel_utilization": 0.53, "uptime_seconds": 129609, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1245, "long_name": "Desert Squirrel", "next_hop": 95, "num": "0x335f55bd", "position": {"altitude": 1324, "latitude": 33.63381, "location_source": "LOC_INTERNAL", "longitude": -106.484226, "time_offset_sec": 1401}, "public_key_hex": "3fb5cd78c2c002c41c75641b0cab12900fef78426520c196554be1619602fe94", "role": "CLIENT", "short_name": "DDVA", "snr": 7.5, "status": null, "telemetry": {"air_util_tx": 0.084, "battery_level": 45, "channel_utilization": 4.86, "uptime_seconds": 181740, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 3366, "long_name": "Steel Squirrel", "next_hop": 246, "num": "0x3364bfe3", "position": null, "public_key_hex": "a6907d24438d786a4f776efdea0d8f6de6def858ae42ced0cdefea9e02541166", "role": "CLIENT", "short_name": "S3I9", "snr": 8.96, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 4644, "long_name": "Sharp Cactus", "next_hop": 0, "num": "0x33687f5f", "position": {"altitude": 1334, "latitude": 32.827593, "location_source": "LOC_INTERNAL", "longitude": -106.966164, "time_offset_sec": 4844}, "public_key_hex": "2460173c8d595b3cec65e0d0cdae1c4fe52009a9f287efa7d8d6250b77d3db65", "role": "ROUTER", "short_name": "SYM5", "snr": 6.22, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.491, "battery_level": 94, "channel_utilization": 13.52, "uptime_seconds": 29499, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 13189, "long_name": "Rough Bronco", "next_hop": 34, "num": "0x33732f68", "position": {"altitude": 1289, "latitude": 31.95301, "location_source": "LOC_INTERNAL", "longitude": -108.002412, "time_offset_sec": 13343}, "public_key_hex": "ccc7821b946e5c02e57fc294bdd82f91adf492ca7d43770d9d5fbceeb7401cf3", "role": "CLIENT", "short_name": "R29E", "snr": 6.46, "status": null, "telemetry": {"air_util_tx": 0.138, "battery_level": 99, "channel_utilization": 9.33, "uptime_seconds": 277396, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 2742, "long_name": "Sleepy Beaver", "next_hop": 12, "num": "0x3376295e", "position": {"altitude": 1339, "latitude": 33.244028, "location_source": "LOC_INTERNAL", "longitude": -107.598559, "time_offset_sec": 3003}, "public_key_hex": "2ff183225ddd283fc51ceba504c5a5e10bfa098a6d2be930005abbab516cf7eb", "role": "CLIENT", "short_name": "S031", "snr": -0.87, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 563, "long_name": "Canyon Mustang", "next_hop": 79, "num": "0x337f510e", "position": {"altitude": 1194, "latitude": 33.986421, "location_source": "LOC_INTERNAL", "longitude": -107.043342, "time_offset_sec": 849}, "public_key_hex": "fe8ba6ea64af3a7349aac70f8f7a9ef5461d514d5422ec60977d3ed0dfa83c0f", "role": "CLIENT", "short_name": "🌊", "snr": 2.63, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.0, "iaq": 31, "relative_humidity": 24.23, "temperature": 22.13}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2983, "long_name": "Bright Adder", "next_hop": 0, "num": "0x338e3969", "position": {"altitude": 1518, "latitude": 32.698664, "location_source": "LOC_INTERNAL", "longitude": -106.68094, "time_offset_sec": 3200}, "public_key_hex": "f46d9f02f4d28367e401e4857ca43d18edd39f020040e98f2f33ecb2f17aea54", "role": "CLIENT", "short_name": "BFYY", "snr": 2.32, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 229, "long_name": "Rough Bluff", "next_hop": 0, "num": "0x33d55421", "position": {"altitude": 1437, "latitude": 34.256087, "location_source": "LOC_INTERNAL", "longitude": -106.9941, "time_offset_sec": 282}, "public_key_hex": "9da5c7a336c9f5053613d6a4b06eb0724a0a4513578f62eaf3af5beae0e4b64e", "role": "CLIENT", "short_name": "R20A", "snr": 4.43, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 17873, "long_name": "River Bison", "next_hop": 139, "num": "0x33de64d1", "position": {"altitude": 794, "latitude": 32.898294, "location_source": "LOC_INTERNAL", "longitude": -107.921448, "time_offset_sec": 18173}, "public_key_hex": "d3bbc20444514ef8a0fb4ccf48c3039a9b0e3f39570a71f776147714de0f0536", "role": "CLIENT", "short_name": "R9QJ", "snr": 1.23, "status": null, "telemetry": {"air_util_tx": 0.9, "battery_level": 12, "channel_utilization": 11.55, "uptime_seconds": 92014, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5846, "long_name": "Drifting Pony", "next_hop": 76, "num": "0x33f688cb", "position": null, "public_key_hex": "fb38d1b7d333515b41c34b0510deef07ba03c3a874573b75d23da5e4a9e69259", "role": "CLIENT", "short_name": "DQ2I", "snr": 4.4, "status": null, "telemetry": {"air_util_tx": 0.626, "battery_level": 25, "channel_utilization": 10.08, "uptime_seconds": 28061, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.51, "iaq": 48, "relative_humidity": 44.56, "temperature": 28.39}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1368, "long_name": "Storm Lion", "next_hop": 145, "num": "0x340d10da", "position": null, "public_key_hex": "41ab58371ae1fb6221d928e41b697e4faed71722b2da11e00305492fe58fdb9a", "role": "CLIENT", "short_name": "SOAH", "snr": 5.38, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.009, "battery_level": 42, "channel_utilization": 11.25, "uptime_seconds": 22975, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 2, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 4116, "long_name": "Roving Elk", "next_hop": 134, "num": "0x342c24d8", "position": {"altitude": 1312, "latitude": 33.129759, "location_source": "LOC_INTERNAL", "longitude": -107.592514, "time_offset_sec": 4155}, "public_key_hex": "dd81c4690044f00d3855896bc6cf8f40c103a8b44406fa1d8072d9fece80dd9b", "role": "CLIENT", "short_name": "R2S8", "snr": 8.94, "status": null, "telemetry": {"air_util_tx": 0.708, "battery_level": 34, "channel_utilization": 5.57, "uptime_seconds": 95433, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 8313, "long_name": "Wild Bronco", "next_hop": 65, "num": "0x34424784", "position": {"altitude": 1299, "latitude": 32.527231, "location_source": "LOC_INTERNAL", "longitude": -106.330034, "time_offset_sec": 8511}, "public_key_hex": "a23f2f74a85b9a51858b2c61f461624165cff260edc5e97662873c8f8f74ac2e", "role": "CLIENT", "short_name": "W8P3", "snr": 3.22, "status": null, "telemetry": {"air_util_tx": 0.485, "battery_level": 31, "channel_utilization": 6.85, "uptime_seconds": 64572, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 476, "long_name": "Storm Wolf", "next_hop": 23, "num": "0x34adaf9d", "position": {"altitude": 1439, "latitude": 33.439898, "location_source": "LOC_INTERNAL", "longitude": -106.821773, "time_offset_sec": 745}, "public_key_hex": "ecd7c0408943287afc554c2a0b83251aa85094f62dfecbdd88ae0d6f84588d4a", "role": "CLIENT", "short_name": "SJ7Z", "snr": 3.6, "status": null, "telemetry": {"air_util_tx": 0.976, "battery_level": 56, "channel_utilization": 7.95, "uptime_seconds": 215594, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 831, "long_name": "Lost Cougar", "next_hop": 84, "num": "0x34b288f6", "position": {"altitude": 1271, "latitude": 32.520106, "location_source": "LOC_INTERNAL", "longitude": -106.964476, "time_offset_sec": 934}, "public_key_hex": "1cd26f49e8cb7536e52bf177b066355f288c719fe2ba331b23d35138597ee374", "role": "TRACKER", "short_name": "LV5W", "snr": 10.05, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 566, "long_name": "Desert Cougar", "next_hop": 162, "num": "0x34b7addc", "position": {"altitude": 1636, "latitude": 33.755569, "location_source": "LOC_INTERNAL", "longitude": -107.119098, "time_offset_sec": 621}, "public_key_hex": "7259e8ef8ae1c99a2906c805c1cf961ec20b87bfe41a05e0c5a460a7bd086f2a", "role": "CLIENT", "short_name": "DXKA", "snr": 6.7, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.87, "iaq": 13, "relative_humidity": 60.89, "temperature": 22.06}, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1328, "long_name": "White Badger", "next_hop": 145, "num": "0x34c4102d", "position": {"altitude": 1060, "latitude": 33.158871, "location_source": "LOC_INTERNAL", "longitude": -106.654245, "time_offset_sec": 1393}, "public_key_hex": "7c089cf6ca1fa9ecd25faae3be5c3658a11e95fb223f4bac680ed2a47e2a6602", "role": "CLIENT", "short_name": "W3R9", "snr": 6.78, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.71, "battery_level": 13, "channel_utilization": 17.9, "uptime_seconds": 15427, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1016.01, "iaq": 34, "relative_humidity": 75.55, "temperature": 25.26}, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 3829, "long_name": "Giant Lynx", "next_hop": 106, "num": "0x35071c41", "position": null, "public_key_hex": "880987172f21f8f3d0c62d56c85fd545358b7708efeb56ef0457259f192fe953", "role": "CLIENT", "short_name": "GSFZ", "snr": 6.62, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.447, "battery_level": 28, "channel_utilization": 23.23, "uptime_seconds": 71779, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1008.8, "iaq": 52, "relative_humidity": 58.92, "temperature": 24.29}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1928, "long_name": "Blue Oak", "next_hop": 67, "num": "0x35168024", "position": {"altitude": 1552, "latitude": 33.564945, "location_source": "LOC_INTERNAL", "longitude": -106.970152, "time_offset_sec": 2040}, "public_key_hex": "", "role": "CLIENT", "short_name": "BSWJ", "snr": 9.26, "status": null, "telemetry": {"air_util_tx": 0.518, "battery_level": 49, "channel_utilization": 7.48, "uptime_seconds": 12178, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5143, "long_name": "Gold Crane", "next_hop": 45, "num": "0x35207511", "position": {"altitude": 1123, "latitude": 33.81455, "location_source": "LOC_INTERNAL", "longitude": -106.837823, "time_offset_sec": 5274}, "public_key_hex": "45acbfc99ad1a9965418fd6f2803f901f9a6c2a349d97b8787b71aba45344f48", "role": "CLIENT", "short_name": "GKKS", "snr": 2.1, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.614, "battery_level": 49, "channel_utilization": 1.47, "uptime_seconds": 67587, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.44, "iaq": 0, "relative_humidity": 23.57, "temperature": 32.7}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 334, "long_name": "Sneaky Sage", "next_hop": 0, "num": "0x354f9b66", "position": {"altitude": 1576, "latitude": 32.118872, "location_source": "LOC_INTERNAL", "longitude": -106.793721, "time_offset_sec": 628}, "public_key_hex": "095dbcd4370983d9e021e01ee7949c97d1acd3b1c25cc28d3889fe57c2a4e0db", "role": "CLIENT_BASE", "short_name": "S65I", "snr": 3.02, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.398, "battery_level": 24, "channel_utilization": 17.62, "uptime_seconds": 31708, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.74, "iaq": 63, "relative_humidity": 57.96, "temperature": 18.34}, "hops_away": 0, "hw_model": "THINKNODE_M6", "last_heard_offset_sec": 799, "long_name": "Silver Bronco", "next_hop": 0, "num": "0x35a1abb8", "position": {"altitude": 1409, "latitude": 32.527683, "location_source": "LOC_INTERNAL", "longitude": -107.694342, "time_offset_sec": 1023}, "public_key_hex": "ad888cb0ac672e90e7c640134ee6e9bd30c943e18c41b03c9d480edff172266a", "role": "CLIENT", "short_name": "SU29", "snr": 3.94, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.425, "battery_level": 59, "channel_utilization": 12.95, "uptime_seconds": 100362, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": {"barometric_pressure": 1021.66, "iaq": 0, "relative_humidity": 51.59, "temperature": 26.69}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4377, "long_name": "Roving Crane", "next_hop": 0, "num": "0x35a43c4e", "position": {"altitude": 1049, "latitude": 33.325947, "location_source": "LOC_INTERNAL", "longitude": -107.023994, "time_offset_sec": 4430}, "public_key_hex": "39231f33e922b30038b25508a6b5a8076aeaafcc7628c4797016141a7d8bc63a", "role": "CLIENT", "short_name": "RVIY", "snr": 3.07, "status": null, "telemetry": {"air_util_tx": 0.215, "battery_level": 56, "channel_utilization": 15.39, "uptime_seconds": 66192, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 629, "long_name": "Copper Pike", "next_hop": 87, "num": "0x35a613c3", "position": null, "public_key_hex": "4d3283a2091dfaaf353e82f96cf43b2545eb23dfada019e959e47b3da249ab86", "role": "CLIENT", "short_name": "C8FC", "snr": 6.04, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.243, "battery_level": 74, "channel_utilization": 11.63, "uptime_seconds": 267948, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2054, "long_name": "Iron Bluff", "next_hop": 45, "num": "0x35c3f1ef", "position": null, "public_key_hex": "ed2d64bdb237fdcd5939d999922972d325d22fd5187f7dd7d91dea1d2e5221d1", "role": "CLIENT", "short_name": "I14K", "snr": 4.28, "status": null, "telemetry": {"air_util_tx": 2.017, "battery_level": 101, "channel_utilization": 6.19, "uptime_seconds": 81361, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1300, "long_name": "Frozen Cactus", "next_hop": 120, "num": "0x35dae01c", "position": {"altitude": 1484, "latitude": 33.835112, "location_source": "LOC_INTERNAL", "longitude": -107.353162, "time_offset_sec": 1553}, "public_key_hex": "582e104613c627af22ebf678d5ba0b43651e63c3e86c7df0a7bc71d542cd1410", "role": "CLIENT", "short_name": "FL50", "snr": 6.19, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1418, "long_name": "Floating Stag", "next_hop": 0, "num": "0x35db3c3e", "position": {"altitude": 1676, "latitude": 32.343716, "location_source": "LOC_INTERNAL", "longitude": -107.769017, "time_offset_sec": 1671}, "public_key_hex": "38c7ca1997014ce5d6e136ff095d7b7441450585ca281a775b5eea582e0fcd1d", "role": "CLIENT", "short_name": "FY8M", "snr": 3.86, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2330, "long_name": "Sky Viper", "next_hop": 0, "num": "0x35ea7ed2", "position": {"altitude": 1252, "latitude": 32.797432, "location_source": "LOC_INTERNAL", "longitude": -107.429744, "time_offset_sec": 2454}, "public_key_hex": "85a955e277bec5dcea8d666a8a09e2ddfe059494e0aa1197f73db5a2bff970f0", "role": "CLIENT", "short_name": "🦉", "snr": 6.85, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 826, "long_name": "Stone Trout W52DQ", "next_hop": 139, "num": "0x35f7ac94", "position": {"altitude": 1537, "latitude": 32.937819, "location_source": "LOC_INTERNAL", "longitude": -107.681375, "time_offset_sec": 1013}, "public_key_hex": "0a1930994021749f4edfd5d047f77ef97dd1685b29d9cd00c601e61b054479ef", "role": "CLIENT", "short_name": "S3KJ", "snr": 5.6, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.217, "battery_level": 55, "channel_utilization": 5.88, "uptime_seconds": 58932, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1402, "long_name": "Copper Dolphin", "next_hop": 0, "num": "0x35f9bdfc", "position": {"altitude": 1501, "latitude": 33.569734, "location_source": "LOC_INTERNAL", "longitude": -107.044029, "time_offset_sec": 1622}, "public_key_hex": "443e07f95f39bd4659adddce98a78c4ca4362846eb6da71674246c176e3ed6ee", "role": "CLIENT", "short_name": "CIT7", "snr": 11.57, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5153, "long_name": "Frozen Elk", "next_hop": 0, "num": "0x36029add", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "F1V1", "snr": 4.91, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.919, "battery_level": 18, "channel_utilization": 4.44, "uptime_seconds": 90902, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1011, "long_name": "White Phoenix", "next_hop": 187, "num": "0x36106b3d", "position": {"altitude": 1177, "latitude": 33.234942, "location_source": "LOC_INTERNAL", "longitude": -108.020069, "time_offset_sec": 1054}, "public_key_hex": "ee532a264de84ced25b740607eaaddce1abaf5a99fdc90b4d20bd6c4b331748c", "role": "CLIENT", "short_name": "W5Q0", "snr": 11.56, "status": null, "telemetry": {"air_util_tx": 1.396, "battery_level": 95, "channel_utilization": 2.39, "uptime_seconds": 91097, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 769, "long_name": "Wild Adder", "next_hop": 223, "num": "0x36134f03", "position": {"altitude": 1455, "latitude": 33.323609, "location_source": "LOC_INTERNAL", "longitude": -107.567024, "time_offset_sec": 864}, "public_key_hex": "5c16204521c9fb5b62e659b1d5d5c9f1484f0de67ce91877350afa2089d03d19", "role": "TRACKER", "short_name": "WFZD", "snr": 1.46, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_R1_NEO", "last_heard_offset_sec": 7768, "long_name": "Rough Beaver", "next_hop": 0, "num": "0x36f2e9c6", "position": {"altitude": 1138, "latitude": 33.213028, "location_source": "LOC_INTERNAL", "longitude": -107.752569, "time_offset_sec": 7960}, "public_key_hex": "3dd13892598cf279dd48c75871a15d789fd7320cbd49ab43fcf19196c340cd80", "role": "CLIENT", "short_name": "R80P", "snr": 9.29, "status": null, "telemetry": {"air_util_tx": 0.444, "battery_level": 53, "channel_utilization": 6.69, "uptime_seconds": 61270, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7804, "long_name": "Happy Lion", "next_hop": 0, "num": "0x3701d941", "position": null, "public_key_hex": "e4600f6181689bf692ed7334bd29e5f6ae213edd9e5f1c72fabf3cd6f046decf", "role": "LOST_AND_FOUND", "short_name": "🦋", "snr": 5.02, "status": null, "telemetry": {"air_util_tx": 0.589, "battery_level": 49, "channel_utilization": 32.03, "uptime_seconds": 216595, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 3483, "long_name": "Floating Mamba", "next_hop": 19, "num": "0x371a4b75", "position": {"altitude": 1682, "latitude": 32.949753, "location_source": "LOC_INTERNAL", "longitude": -107.430402, "time_offset_sec": 3753}, "public_key_hex": "79fb6b41ad61d76a419eafdfee8dfad1003ffdc4446eb63f238fc01039b6b702", "role": "CLIENT", "short_name": "FHOV", "snr": 3.46, "status": null, "telemetry": {"air_util_tx": 1.007, "battery_level": 74, "channel_utilization": 11.15, "uptime_seconds": 7033, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2916, "long_name": "Desert Viper", "next_hop": 0, "num": "0x3724b7a1", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "D2C3", "snr": 11.63, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1016.56, "iaq": 36, "relative_humidity": 56.25, "temperature": 25.67}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1815, "long_name": "Burning Badger", "next_hop": 0, "num": "0x37337a20", "position": {"altitude": 1258, "latitude": 33.104357, "location_source": "LOC_INTERNAL", "longitude": -106.308815, "time_offset_sec": 1960}, "public_key_hex": "", "role": "CLIENT", "short_name": "BOO3", "snr": 6.79, "status": null, "telemetry": {"air_util_tx": 1.273, "battery_level": 62, "channel_utilization": 33.1, "uptime_seconds": 53216, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1913, "long_name": "Tall Mesa", "next_hop": 183, "num": "0x37437a1a", "position": {"altitude": 1088, "latitude": 33.803992, "location_source": "LOC_INTERNAL", "longitude": -106.851891, "time_offset_sec": 2135}, "public_key_hex": "838ebd04d82d3c3f5e8ed038aa42aeca026ce425636bae181877428ab9971657", "role": "CLIENT", "short_name": "T0CC", "snr": 3.24, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.898, "battery_level": 78, "channel_utilization": 2.13, "uptime_seconds": 77464, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 3051, "long_name": "Bright Gecko", "next_hop": 98, "num": "0x374f6c66", "position": {"altitude": 977, "latitude": 33.924513, "location_source": "LOC_INTERNAL", "longitude": -106.571074, "time_offset_sec": 3052}, "public_key_hex": "c0a22460cc231d5be537fad67ac715c71ae7fc852e3a848c3c70ae5819eebef9", "role": "CLIENT", "short_name": "🦉", "snr": 4.25, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.627, "battery_level": 89, "channel_utilization": 9.58, "uptime_seconds": 6602, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.54, "iaq": 14, "relative_humidity": 14.54, "temperature": 11.12}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 1784, "long_name": "Happy Moose", "next_hop": 0, "num": "0x377cb0de", "position": {"altitude": 1210, "latitude": 33.334456, "location_source": "LOC_INTERNAL", "longitude": -106.71217, "time_offset_sec": 1990}, "public_key_hex": "6e349167ab72693929806c0054a3a213ff4d5d2b2b85ae130ef0919f30ebdbc7", "role": "CLIENT", "short_name": "H4V7", "snr": 8.4, "status": null, "telemetry": {"air_util_tx": 0.922, "battery_level": 11, "channel_utilization": 11.06, "uptime_seconds": 86947, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 3171, "long_name": "River Bronco", "next_hop": 173, "num": "0x37936c11", "position": {"altitude": 1567, "latitude": 33.128895, "location_source": "LOC_INTERNAL", "longitude": -107.857262, "time_offset_sec": 3244}, "public_key_hex": "e928c21db0a00fe94a840e88d354464580101b9add73e6dd713ecda910eb0c58", "role": "CLIENT", "short_name": "RBWC", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 260, "long_name": "Frozen Hare", "next_hop": 0, "num": "0x3799f379", "position": null, "public_key_hex": "3a3310ec3389951f6eef5dabdbebfb0b5bfcfb93d0ad223d490112b04c604d47", "role": "CLIENT", "short_name": "FTGR", "snr": -2.66, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.553, "battery_level": 24, "channel_utilization": 23.17, "uptime_seconds": 21991, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.64, "iaq": 47, "relative_humidity": 35.76, "temperature": 15.35}, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 1428, "long_name": "Solar Otter", "next_hop": 158, "num": "0x379d0c24", "position": {"altitude": 1293, "latitude": 33.065582, "location_source": "LOC_INTERNAL", "longitude": -107.056377, "time_offset_sec": 1691}, "public_key_hex": "a040cb867eebc8eeacc1ba051b842de6f29e6ff76aa1b9dda2f35dac0719eb65", "role": "ROUTER", "short_name": "S1AC", "snr": 6.12, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.744, "battery_level": 66, "channel_utilization": 5.34, "uptime_seconds": 25149, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.65, "iaq": 55, "relative_humidity": 36.07, "temperature": 31.9}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5817, "long_name": "Old Sage", "next_hop": 93, "num": "0x37d4b09b", "position": {"altitude": 1673, "latitude": 33.314729, "location_source": "LOC_INTERNAL", "longitude": -107.449779, "time_offset_sec": 6052}, "public_key_hex": "ecb8cb85eca201bc6a25c319ef5725715c796e3f82437a4c0cd76048d7f6030d", "role": "CLIENT", "short_name": "OTKR", "snr": 2.27, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1806, "long_name": "Misty Doe", "next_hop": 0, "num": "0x380f231f", "position": {"altitude": 889, "latitude": 33.466063, "location_source": "LOC_INTERNAL", "longitude": -107.041538, "time_offset_sec": 1974}, "public_key_hex": "9746f132189a6636ca986adf3e28da9b4b77583e771b874c294c799a3d025e98", "role": "CLIENT", "short_name": "MXYV", "snr": 7.85, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.452, "battery_level": 34, "channel_utilization": 4.26, "uptime_seconds": 13149, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 7493, "long_name": "Fast Pine", "next_hop": 91, "num": "0x38535939", "position": {"altitude": 1621, "latitude": 32.937257, "location_source": "LOC_INTERNAL", "longitude": -107.476764, "time_offset_sec": 7726}, "public_key_hex": "8c7b1908d41b67f2bb70cf469f2c66047038a4a681713b88eba49c3a5fbe3767", "role": "CLIENT", "short_name": "FL4S", "snr": 5.18, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.269, "battery_level": 47, "channel_utilization": 1.87, "uptime_seconds": 116044, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 8315, "long_name": "Desert Bear", "next_hop": 242, "num": "0x3855f488", "position": null, "public_key_hex": "1f5ad192df304d6a7b0a174072c4ec50524b77b9353dea47ccb4285958ffe12e", "role": "CLIENT", "short_name": "🌙", "snr": 7.34, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2247, "long_name": "Roving Dolphin", "next_hop": 0, "num": "0x387baa46", "position": {"altitude": 1585, "latitude": 32.946234, "location_source": "LOC_INTERNAL", "longitude": -106.568664, "time_offset_sec": 2398}, "public_key_hex": "5677a9d162c1ae14e8c47db7e7d4df8046fa6ff1ec7ef3f2660efe89fe8da967", "role": "CLIENT", "short_name": "R6NB", "snr": 7.19, "status": null, "telemetry": {"air_util_tx": 0.079, "battery_level": 101, "channel_utilization": 11.99, "uptime_seconds": 20784, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 902, "long_name": "Blue Gecko", "next_hop": 58, "num": "0x387c6517", "position": {"altitude": 1397, "latitude": 33.25169, "location_source": "LOC_INTERNAL", "longitude": -107.249586, "time_offset_sec": 1086}, "public_key_hex": "163062a414be917503d8265ba013bc051536be59d25aedbc3458816eff30ec15", "role": "CLIENT", "short_name": "🌵", "snr": 11.8, "status": null, "telemetry": {"air_util_tx": 0.39, "battery_level": 98, "channel_utilization": 2.92, "uptime_seconds": 181494, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4_R8", "last_heard_offset_sec": 528, "long_name": "Forest Lynx", "next_hop": 0, "num": "0x3881e3dc", "position": {"altitude": 1082, "latitude": 33.339241, "location_source": "LOC_INTERNAL", "longitude": -106.759975, "time_offset_sec": 534}, "public_key_hex": "16e2fa206f80f49aab31e3637986c482cd49f2d31f327935a67aeaae5917f868", "role": "CLIENT", "short_name": "FB6T", "snr": 2.02, "status": null, "telemetry": {"air_util_tx": 0.463, "battery_level": 25, "channel_utilization": 6.73, "uptime_seconds": 166413, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2382, "long_name": "Copper Coyote", "next_hop": 31, "num": "0x388f716e", "position": {"altitude": 1201, "latitude": 33.06052, "location_source": "LOC_INTERNAL", "longitude": -106.824736, "time_offset_sec": 2543}, "public_key_hex": "0e6836ae3a1672ccb342e1deae6493de045028f86c352f5651c239379d147923", "role": "CLIENT", "short_name": "CWWP", "snr": 6.1, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.088, "battery_level": 40, "channel_utilization": 8.97, "uptime_seconds": 149854, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 8912, "long_name": "Sunny Crow", "next_hop": 0, "num": "0x3897140e", "position": {"altitude": 1211, "latitude": 32.42361, "location_source": "LOC_INTERNAL", "longitude": -107.714716, "time_offset_sec": 9192}, "public_key_hex": "b7c8c268b1aa4ab83d295ac5f8ab328672357921c3af95d7c712bf125771d48b", "role": "TRACKER", "short_name": "SWMJ", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3500, "long_name": "Sharp Trout", "next_hop": 0, "num": "0x38b54229", "position": {"altitude": 1094, "latitude": 33.668914, "location_source": "LOC_INTERNAL", "longitude": -107.007356, "time_offset_sec": 3799}, "public_key_hex": "26d74450534daa59c3f60e39d2098bf7a58b33865580f531f658de03f9cb0edf", "role": "CLIENT", "short_name": "S3FY", "snr": 10.9, "status": null, "telemetry": {"air_util_tx": 0.995, "battery_level": 48, "channel_utilization": 18.75, "uptime_seconds": 46291, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 50, "long_name": "Misty Wolf", "next_hop": 0, "num": "0x38df418c", "position": {"altitude": 1493, "latitude": 34.616109, "location_source": "LOC_INTERNAL", "longitude": -107.782552, "time_offset_sec": 97}, "public_key_hex": "e616a9261bab05e6c36563b3a7daee39f0c5854a5028a47e72d588b65e1e4358", "role": "CLIENT", "short_name": "MO9Y", "snr": 5.4, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.757, "battery_level": 38, "channel_utilization": 5.68, "uptime_seconds": 78836, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1324, "long_name": "Dusk Otter", "next_hop": 0, "num": "0x38f4f974", "position": {"altitude": 1225, "latitude": 34.001878, "location_source": "LOC_INTERNAL", "longitude": -105.665591, "time_offset_sec": 1540}, "public_key_hex": "bda7d4ec6199bac74fde0b2af7c5115162e6afb45200c5f97622a7d055863d57", "role": "CLIENT", "short_name": "DPQ8", "snr": 4.78, "status": null, "telemetry": {"air_util_tx": 0.254, "battery_level": 74, "channel_utilization": 6.63, "uptime_seconds": 21479, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5303, "long_name": "Slow Pony", "next_hop": 0, "num": "0x38fa940c", "position": null, "public_key_hex": "7b8912ae52cdb82e7f66924cafdbb5d23ac091b77d0dd1d1663b975c14920665", "role": "CLIENT", "short_name": "SBO1", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 3, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 4622, "long_name": "Wandering Phoenix", "next_hop": 66, "num": "0x391b9f4a", "position": {"altitude": 1506, "latitude": 32.816077, "location_source": "LOC_INTERNAL", "longitude": -105.848858, "time_offset_sec": 4629}, "public_key_hex": "", "role": "TRACKER", "short_name": "W7F0", "snr": 6.82, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.099, "battery_level": 60, "channel_utilization": 31.67, "uptime_seconds": 66305, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1420, "long_name": "Bright Mole", "next_hop": 96, "num": "0x3934105a", "position": null, "public_key_hex": "c89c33c70a6036c1d717688a21aee0d6bdc8a867f220bb6aa1c7043664acb45a", "role": "ROUTER", "short_name": "BHVU", "snr": 5.51, "status": null, "telemetry": {"air_util_tx": 1.461, "battery_level": 95, "channel_utilization": 4.12, "uptime_seconds": 287159, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_R1_NEO", "last_heard_offset_sec": 11313, "long_name": "Lone Seal", "next_hop": 0, "num": "0x394da439", "position": {"altitude": 809, "latitude": 32.706884, "location_source": "LOC_INTERNAL", "longitude": -106.87885, "time_offset_sec": 11504}, "public_key_hex": "9cd12d4ccf6b9b283b9db86d0c9dcf7516f9fefb2ce956b4b270d74974c74948", "role": "CLIENT", "short_name": "LXXK", "snr": -4.02, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.788, "battery_level": 85, "channel_utilization": 9.03, "uptime_seconds": 139290, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2685, "long_name": "Sunny Heron", "next_hop": 35, "num": "0x395255c1", "position": null, "public_key_hex": "7454891e9161a14cc990a34dc82a870067995a8b2d194eb49c69097e49a864b5", "role": "CLIENT", "short_name": "SN3J", "snr": 5.11, "status": null, "telemetry": {"air_util_tx": 0.637, "battery_level": 46, "channel_utilization": 11.87, "uptime_seconds": 95480, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3135, "long_name": "Green Crow", "next_hop": 0, "num": "0x39a48822", "position": {"altitude": 1205, "latitude": 32.549894, "location_source": "LOC_INTERNAL", "longitude": -106.497469, "time_offset_sec": 3280}, "public_key_hex": "ac85603fce2de798dc94fa156b6a7842681d8caad54b598bb51c96070726ee5f", "role": "CLIENT", "short_name": "GA7K", "snr": 10.92, "status": null, "telemetry": {"air_util_tx": 0.439, "battery_level": 26, "channel_utilization": 4.82, "uptime_seconds": 43857, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1969, "long_name": "Black Squirrel", "next_hop": 153, "num": "0x39d5f7c4", "position": {"altitude": 1498, "latitude": 34.256985, "location_source": "LOC_INTERNAL", "longitude": -106.910027, "time_offset_sec": 2247}, "public_key_hex": "a72bc3453f81976f1c7ce917dfe2ab9f77d20a8000a4aedc11fed52febc7d04b", "role": "ROUTER_LATE", "short_name": "🦌", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.907, "battery_level": 63, "channel_utilization": 4.04, "uptime_seconds": 33770, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.26, "iaq": 97, "relative_humidity": 83.87, "temperature": 21.17}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 3508, "long_name": "Desert Seal", "next_hop": 0, "num": "0x3a0097b7", "position": {"altitude": 1570, "latitude": 34.027461, "location_source": "LOC_INTERNAL", "longitude": -107.154375, "time_offset_sec": 3640}, "public_key_hex": "b708a0f93c351427c9a763d98d3cd9d2410ea9d533760136aeaabf02cf1d18d0", "role": "CLIENT", "short_name": "D016", "snr": 5.77, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.189, "battery_level": 13, "channel_utilization": 26.24, "uptime_seconds": 187397, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1828, "long_name": "Burning Mustang", "next_hop": 0, "num": "0x3a0cfe86", "position": {"altitude": 1865, "latitude": 33.400613, "location_source": "LOC_INTERNAL", "longitude": -108.244314, "time_offset_sec": 2048}, "public_key_hex": "5d31a8bc86d293800216a18e321b5fb8a21412c22389bbad0f1a7d18969f043d", "role": "CLIENT", "short_name": "BUOZ", "snr": 4.13, "status": null, "telemetry": {"air_util_tx": 0.456, "battery_level": 20, "channel_utilization": 13.32, "uptime_seconds": 37569, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2231, "long_name": "Slow Whale", "next_hop": 0, "num": "0x3a26ce21", "position": {"altitude": 1577, "latitude": 34.257864, "location_source": "LOC_INTERNAL", "longitude": -107.731134, "time_offset_sec": 2479}, "public_key_hex": "76f9e54a07b4272dd7dc7330e6c610e4c53e42df5ffc756e97da77cca85ff80c", "role": "CLIENT", "short_name": "S1CH", "snr": 4.9, "status": null, "telemetry": {"air_util_tx": 0.332, "battery_level": 61, "channel_utilization": 9.61, "uptime_seconds": 97487, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 7657, "long_name": "River Bass", "next_hop": 0, "num": "0x3a654008", "position": {"altitude": 1092, "latitude": 33.104079, "location_source": "LOC_INTERNAL", "longitude": -107.451983, "time_offset_sec": 7894}, "public_key_hex": "76e9bc62e95f9e7d7ef60f348e3694afc5b6c2d7bf4f8384b5bd100061994cdc", "role": "CLIENT", "short_name": "RMEU", "snr": 6.8, "status": null, "telemetry": {"air_util_tx": 0.869, "battery_level": 56, "channel_utilization": 28.06, "uptime_seconds": 350442, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 2258, "long_name": "New Iguana", "next_hop": 0, "num": "0x3a79480b", "position": {"altitude": 1191, "latitude": 33.166697, "location_source": "LOC_INTERNAL", "longitude": -108.312367, "time_offset_sec": 2528}, "public_key_hex": "38f84ced1d6f41f39199dc1c6dac310da010b75ea24a60d66c5d084018255a4b", "role": "CLIENT", "short_name": "NVWJ", "snr": 5.63, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 2, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2957, "long_name": "Forest Doe", "next_hop": 27, "num": "0x3a8a250e", "position": {"altitude": 1810, "latitude": 33.651354, "location_source": "LOC_INTERNAL", "longitude": -107.078358, "time_offset_sec": 3116}, "public_key_hex": "3d3da6c73586d7ce600b2c0c3fa08b3f8615b10488b13a0b746e93c571c1169e", "role": "CLIENT", "short_name": "FT53", "snr": 8.74, "status": null, "telemetry": {"air_util_tx": 0.577, "battery_level": 100, "channel_utilization": 4.46, "uptime_seconds": 22350, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.66, "iaq": 90, "relative_humidity": 64.87, "temperature": 24.68}, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2025, "long_name": "Tiny Raven", "next_hop": 65, "num": "0x3ae24f73", "position": {"altitude": 931, "latitude": 32.694844, "location_source": "LOC_INTERNAL", "longitude": -106.99217, "time_offset_sec": 2102}, "public_key_hex": "", "role": "SENSOR", "short_name": "TYT9", "snr": 4.05, "status": null, "telemetry": {"air_util_tx": 1.749, "battery_level": 68, "channel_utilization": 7.03, "uptime_seconds": 5688, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.67, "iaq": 73, "relative_humidity": 68.8, "temperature": 24.91}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1295, "long_name": "Rough Viper", "next_hop": 0, "num": "0x3b525700", "position": {"altitude": 1627, "latitude": 32.621109, "location_source": "LOC_INTERNAL", "longitude": -107.733751, "time_offset_sec": 1310}, "public_key_hex": "630886a26fbd1510710ecce56120e1585ff69e46cc79eae59a87b303ba402f66", "role": "CLIENT", "short_name": "R9BD", "snr": 10.04, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.566, "battery_level": 12, "channel_utilization": 7.7, "uptime_seconds": 74216, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2043, "long_name": "Red Falcon", "next_hop": 199, "num": "0x3bc4d219", "position": {"altitude": 1565, "latitude": 32.92565, "location_source": "LOC_INTERNAL", "longitude": -108.089454, "time_offset_sec": 2258}, "public_key_hex": "da2b7638aa90789379f1b762e3159050bf1428a320bac6d9d5717400b2355808", "role": "CLIENT", "short_name": "🦌", "snr": -4.58, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1461, "long_name": "Black Elk", "next_hop": 0, "num": "0x3bd42c68", "position": {"altitude": 1217, "latitude": 32.578999, "location_source": "LOC_INTERNAL", "longitude": -106.638417, "time_offset_sec": 1558}, "public_key_hex": "e108060fc64991622bcaa7b86af55a4f1b3e696e660d44eb933b64204757e347", "role": "TRACKER", "short_name": "B45E", "snr": 9.22, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 802, "long_name": "Drifting Bison", "next_hop": 80, "num": "0x3bdda3fd", "position": null, "public_key_hex": "807b953ad45dfb5474fec418c7bbb794f48b16d0fc478c238384b9d587b5d201", "role": "CLIENT", "short_name": "DSHO", "snr": 4.03, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.418, "battery_level": 17, "channel_utilization": 16.77, "uptime_seconds": 154974, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 695, "long_name": "Forest Cougar", "next_hop": 0, "num": "0x3be695f8", "position": null, "public_key_hex": "c50f14c2f3bcef25d6fdbcc46bdd171d9b7d3d118104807d43c67403c3b8c270", "role": "CLIENT", "short_name": "FHOI", "snr": 0.39, "status": null, "telemetry": {"air_util_tx": 0.953, "battery_level": 79, "channel_utilization": 13.97, "uptime_seconds": 49666, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1121, "long_name": "Rough Colt", "next_hop": 0, "num": "0x3bf902d5", "position": {"altitude": 1675, "latitude": 33.181031, "location_source": "LOC_INTERNAL", "longitude": -107.780647, "time_offset_sec": 1198}, "public_key_hex": "90b7567c62385741312be14a756f1dec5940caecf3af1b3e88d02459684befab", "role": "CLIENT", "short_name": "R7H2", "snr": 7.6, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1007.0, "iaq": 90, "relative_humidity": 69.05, "temperature": 28.37}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2233, "long_name": "Wild Moose", "next_hop": 113, "num": "0x3c1925bf", "position": {"altitude": 1588, "latitude": 33.585821, "location_source": "LOC_INTERNAL", "longitude": -108.340206, "time_offset_sec": 2243}, "public_key_hex": "a521f845dba5e996cee28db15079ee2d6d3e5960eb7bee1cdcfd37bb2ec3d817", "role": "CLIENT", "short_name": "WB2N", "snr": 5.27, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1031.95, "iaq": 54, "relative_humidity": 43.27, "temperature": 23.79}, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3419, "long_name": "Misty Lion", "next_hop": 79, "num": "0x3c5e4339", "position": {"altitude": 1050, "latitude": 33.161535, "location_source": "LOC_INTERNAL", "longitude": -107.196741, "time_offset_sec": 3643}, "public_key_hex": "eda009d357d7da2dc0512505b95216e18321a4c066be17a92699513e11bda037", "role": "ROUTER", "short_name": "MEHK", "snr": 10.66, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 622, "long_name": "Tall Viper", "next_hop": 0, "num": "0x3c72adb0", "position": {"altitude": 1644, "latitude": 33.457519, "location_source": "LOC_INTERNAL", "longitude": -108.178099, "time_offset_sec": 786}, "public_key_hex": "6a435b22fca9a8c4b8b96275a7d60d5e2a269f332ae1a968dd28ced97d0d3e1e", "role": "ROUTER_LATE", "short_name": "T8DH", "snr": 8.45, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.331, "battery_level": 45, "channel_utilization": 22.1, "uptime_seconds": 11149, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 5332, "long_name": "Canyon Cedar", "next_hop": 17, "num": "0x3c77fd33", "position": null, "public_key_hex": "4c10ff6e398a243357364fc4763ad7b2032af6ff8f44e324681c880572de4f13", "role": "TAK", "short_name": "CZBZ", "snr": 8.51, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1001.5, "iaq": 67, "relative_humidity": 41.03, "temperature": 41.75}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 10707, "long_name": "Slow Bronco", "next_hop": 95, "num": "0x3c7d9def", "position": null, "public_key_hex": "b0ce9b44b8b0f91dae0b964c7e5d535d4becec22d0723a506aaa2c40726b81b1", "role": "CLIENT", "short_name": "🦊", "snr": 7.97, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.345, "battery_level": 69, "channel_utilization": 21.78, "uptime_seconds": 159522, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.41, "iaq": 71, "relative_humidity": 17.02, "temperature": 28.47}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 14128, "long_name": "Stone Owl", "next_hop": 0, "num": "0x3c9b5abb", "position": {"altitude": 1118, "latitude": 34.118469, "location_source": "LOC_INTERNAL", "longitude": -107.293379, "time_offset_sec": 14129}, "public_key_hex": "", "role": "CLIENT", "short_name": "SKG5", "snr": -1.94, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.543, "battery_level": 75, "channel_utilization": 14.71, "uptime_seconds": 99260, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 422, "long_name": "Roving Cobra", "next_hop": 0, "num": "0x3ce1c954", "position": {"altitude": 1581, "latitude": 33.519349, "location_source": "LOC_INTERNAL", "longitude": -107.841289, "time_offset_sec": 501}, "public_key_hex": "92b5e2404e76edffea57125132ca403b076a3e4dd96e51b5334795e7e0c518ac", "role": "CLIENT_BASE", "short_name": "RZZA", "snr": 8.12, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.016, "battery_level": 39, "channel_utilization": 11.73, "uptime_seconds": 47338, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 8100, "long_name": "Short Oak", "next_hop": 102, "num": "0x3ce58362", "position": {"altitude": 1876, "latitude": 34.070306, "location_source": "LOC_INTERNAL", "longitude": -106.877395, "time_offset_sec": 8288}, "public_key_hex": "1e81a75ee7741a0564f3b2b4600f48244de39826cdf6cd91ac80e6f331528782", "role": "CLIENT", "short_name": "SNB7", "snr": 7.95, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1230, "long_name": "Frozen Mesa", "next_hop": 237, "num": "0x3cf9b967", "position": {"altitude": 1745, "latitude": 33.345725, "location_source": "LOC_INTERNAL", "longitude": -106.292891, "time_offset_sec": 1387}, "public_key_hex": "", "role": "CLIENT", "short_name": "FALI", "snr": 7.51, "status": null, "telemetry": {"air_util_tx": 0.198, "battery_level": 22, "channel_utilization": 10.69, "uptime_seconds": 172607, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1466, "long_name": "Old Bluff", "next_hop": 0, "num": "0x3d12ea50", "position": {"altitude": 1395, "latitude": 33.671726, "location_source": "LOC_INTERNAL", "longitude": -106.462366, "time_offset_sec": 1634}, "public_key_hex": "4573202415e06163e866698fac83c01d8ed8155f38be203209f615e8132fde00", "role": "CLIENT", "short_name": "🦉", "snr": 10.35, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 1098, "long_name": "Roving Moose", "next_hop": 207, "num": "0x3d4ac7bb", "position": {"altitude": 1522, "latitude": 32.972123, "location_source": "LOC_INTERNAL", "longitude": -106.870318, "time_offset_sec": 1148}, "public_key_hex": "2f327c103a5b5f1633a96f4efa09018c33de01bd28b4e14372736fdc8e8ae930", "role": "CLIENT", "short_name": "R8AE", "snr": 2.17, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.34, "iaq": 24, "relative_humidity": 60.49, "temperature": 22.28}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4852, "long_name": "River Juniper", "next_hop": 7, "num": "0x3d4e2854", "position": {"altitude": 1343, "latitude": 33.081698, "location_source": "LOC_INTERNAL", "longitude": -107.243011, "time_offset_sec": 4976}, "public_key_hex": "cb7626282d1e2c5db32611660f1a571fb392f88e0a72dc8fe5931ec6394e280e", "role": "CLIENT", "short_name": "R843", "snr": 0.01, "status": null, "telemetry": {"air_util_tx": 0.415, "battery_level": 97, "channel_utilization": 2.76, "uptime_seconds": 37333, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2509, "long_name": "Old Marmot WD4MW", "next_hop": 188, "num": "0x3d712dbc", "position": {"altitude": 1166, "latitude": 33.070513, "location_source": "LOC_INTERNAL", "longitude": -107.198687, "time_offset_sec": 2779}, "public_key_hex": "31811c5b14bb40315f7d467f4b48a58550a1f44adfa638bf4b175563f732d91a", "role": "CLIENT", "short_name": "O4WH", "snr": 5.35, "status": null, "telemetry": {"air_util_tx": 0.537, "battery_level": 92, "channel_utilization": 5.06, "uptime_seconds": 356381, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1646, "long_name": "Iron Falcon", "next_hop": 0, "num": "0x3d7231b2", "position": {"altitude": 1650, "latitude": 33.076531, "location_source": "LOC_INTERNAL", "longitude": -107.283181, "time_offset_sec": 1794}, "public_key_hex": "", "role": "CLIENT", "short_name": "IP1B", "snr": -0.44, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.928, "battery_level": 49, "channel_utilization": 8.4, "uptime_seconds": 14042, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 115, "long_name": "Stone Owl", "next_hop": 0, "num": "0x3d8f9195", "position": {"altitude": 1794, "latitude": 33.512192, "location_source": "LOC_INTERNAL", "longitude": -107.239508, "time_offset_sec": 326}, "public_key_hex": "447cef090f4bf260788ceecb05ef523fab00680ee4a7a59ff61c7fcad93c7022", "role": "CLIENT", "short_name": "SYTV", "snr": 8.08, "status": null, "telemetry": {"air_util_tx": 0.356, "battery_level": 29, "channel_utilization": 24.63, "uptime_seconds": 100738, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 1977, "long_name": "Floating Sage", "next_hop": 0, "num": "0x3d991949", "position": {"altitude": 1128, "latitude": 33.10813, "location_source": "LOC_INTERNAL", "longitude": -106.898806, "time_offset_sec": 2247}, "public_key_hex": "46beef09567b5299822b38489d56e12c998379bff11254256be79c416207de05", "role": "CLIENT", "short_name": "F1EL", "snr": 6.54, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.22, "iaq": 33, "relative_humidity": 57.43, "temperature": 41.03}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 4427, "long_name": "Lone Pony", "next_hop": 104, "num": "0x3db68f38", "position": {"altitude": 1342, "latitude": 33.998221, "location_source": "LOC_INTERNAL", "longitude": -107.531056, "time_offset_sec": 4601}, "public_key_hex": "0f2d244365970128eae4bb0f0fcd56fda9623e29b9435b58e675967a40cf8401", "role": "TRACKER", "short_name": "LVQ8", "snr": 3.65, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.664, "battery_level": 83, "channel_utilization": 10.59, "uptime_seconds": 34703, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3859, "long_name": "Rough Moose", "next_hop": 21, "num": "0x3dddb043", "position": {"altitude": 1028, "latitude": 33.859021, "location_source": "LOC_INTERNAL", "longitude": -107.139479, "time_offset_sec": 4037}, "public_key_hex": "07dacd51218c717e61a465e8216be729431f8419ad8f7bbac28bbb813b52e94d", "role": "ROUTER", "short_name": "RB3O", "snr": 2.27, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 751, "long_name": "Dusk Owl", "next_hop": 0, "num": "0x3e010d55", "position": {"altitude": 1613, "latitude": 32.892028, "location_source": "LOC_INTERNAL", "longitude": -107.086661, "time_offset_sec": 769}, "public_key_hex": "", "role": "CLIENT", "short_name": "DX5Z", "snr": 2.35, "status": null, "telemetry": {"air_util_tx": 0.224, "battery_level": 29, "channel_utilization": 2.98, "uptime_seconds": 56385, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 10781, "long_name": "Storm Gecko", "next_hop": 7, "num": "0x3e30978e", "position": null, "public_key_hex": "a896244419e5a582c6a557d7c5b06439ffeabab38491f8eecabd2e97c669e6a4", "role": "CLIENT", "short_name": "S174", "snr": 4.34, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4962, "long_name": "Silver Marmot", "next_hop": 0, "num": "0x3e36ee51", "position": null, "public_key_hex": "07b8e98c29f74ab11d022e114f7f1e9dc95ac397d5e43999cb465e81a5d06d09", "role": "CLIENT", "short_name": "S4Y7", "snr": 6.69, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2605, "long_name": "Frozen Salmon", "next_hop": 0, "num": "0x3e450fa8", "position": {"altitude": 1075, "latitude": 33.823942, "location_source": "LOC_INTERNAL", "longitude": -106.887666, "time_offset_sec": 2891}, "public_key_hex": "12123c050f43f6da4fc8ec3b4e5c5117dc573889194c31c05d86e1b9cff37350", "role": "CLIENT", "short_name": "🌲", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.367, "battery_level": 11, "channel_utilization": 17.22, "uptime_seconds": 126013, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 4901, "long_name": "Burning Viper", "next_hop": 0, "num": "0x3e833cd9", "position": null, "public_key_hex": "a95445b43aee0741eff44fce24204bb11362534d0fd047291f6173dc9f0c3b69", "role": "CLIENT", "short_name": "BK7S", "snr": 2.35, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.241, "battery_level": 30, "channel_utilization": 7.5, "uptime_seconds": 42958, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.25, "iaq": 30, "relative_humidity": 39.12, "temperature": 19.93}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 219, "long_name": "Found Viper", "next_hop": 0, "num": "0x3ea038cf", "position": {"altitude": 1660, "latitude": 33.23963, "location_source": "LOC_INTERNAL", "longitude": -107.39309, "time_offset_sec": 277}, "public_key_hex": "34757dcdbc665a4256367247c97353fc7a273176350ce77c4de5c7db6afe147c", "role": "CLIENT", "short_name": "🌵", "snr": 2.13, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.223, "battery_level": 55, "channel_utilization": 8.04, "uptime_seconds": 25583, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 389, "long_name": "Bright Hare", "next_hop": 0, "num": "0x3ea27b32", "position": {"altitude": 1393, "latitude": 33.468417, "location_source": "LOC_INTERNAL", "longitude": -106.98055, "time_offset_sec": 404}, "public_key_hex": "190c4eda28e4a0095c8caa955a3c5a7e31902f232e44bcf263b5a025cb96be7b", "role": "CLIENT", "short_name": "🌊", "snr": 1.81, "status": null, "telemetry": {"air_util_tx": 0.295, "battery_level": 27, "channel_utilization": 5.41, "uptime_seconds": 50884, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1824, "long_name": "Drifting Marmot", "next_hop": 0, "num": "0x3eaa773a", "position": {"altitude": 1468, "latitude": 33.076725, "location_source": "LOC_INTERNAL", "longitude": -106.96384, "time_offset_sec": 1864}, "public_key_hex": "61a53a8b8f7577ea6740ed5cfe503ad9526de830278817713de24c22755dafac", "role": "TRACKER", "short_name": "DJUF", "snr": 3.47, "status": {"status": "no-gps"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.74, "iaq": 115, "relative_humidity": 37.39, "temperature": 30.12}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1285, "long_name": "Blue Bear", "next_hop": 171, "num": "0x3ec2c802", "position": {"altitude": 1245, "latitude": 32.775269, "location_source": "LOC_INTERNAL", "longitude": -106.764929, "time_offset_sec": 1314}, "public_key_hex": "97e31ef9e65f24698822203f5732d2d6b4bf177178ec637c41157296c4693a0a", "role": "CLIENT", "short_name": "B20Y", "snr": 4.16, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.505, "battery_level": 72, "channel_utilization": 15.35, "uptime_seconds": 11350, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 834, "long_name": "Green Seal", "next_hop": 0, "num": "0x3ec4e573", "position": {"altitude": 1398, "latitude": 33.47215, "location_source": "LOC_INTERNAL", "longitude": -107.93485, "time_offset_sec": 873}, "public_key_hex": "", "role": "CLIENT", "short_name": "GSII", "snr": 8.72, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.434, "battery_level": 29, "channel_utilization": 3.15, "uptime_seconds": 114026, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2212, "long_name": "Sunny Stag", "next_hop": 217, "num": "0x3ec9b5e8", "position": {"altitude": 1330, "latitude": 33.790191, "location_source": "LOC_INTERNAL", "longitude": -108.367287, "time_offset_sec": 2449}, "public_key_hex": "24465674f5b13d1da68c89a09d0944a5a9c57e3802161d949d34551f918875bf", "role": "CLIENT", "short_name": "SEK1", "snr": 7.36, "status": null, "telemetry": {"air_util_tx": 0.496, "battery_level": 35, "channel_utilization": 8.47, "uptime_seconds": 69459, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1000.34, "iaq": 20, "relative_humidity": 24.47, "temperature": 12.38}, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 292, "long_name": "New Crow", "next_hop": 103, "num": "0x3edf8644", "position": {"altitude": 1553, "latitude": 32.4199, "location_source": "LOC_INTERNAL", "longitude": -108.268097, "time_offset_sec": 406}, "public_key_hex": "c20355b6af336db8633d33fd6e90d5a397718094a30632953f9bae97d6ed4d35", "role": "CLIENT", "short_name": "N4VI", "snr": -2.79, "status": null, "telemetry": {"air_util_tx": 0.079, "battery_level": 27, "channel_utilization": 15.55, "uptime_seconds": 958, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.93, "iaq": 60, "relative_humidity": 49.89, "temperature": 20.25}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 144, "long_name": "Burning Coyote", "next_hop": 0, "num": "0x3f39974b", "position": {"altitude": 1044, "latitude": 32.694332, "location_source": "LOC_INTERNAL", "longitude": -107.714272, "time_offset_sec": 370}, "public_key_hex": "", "role": "CLIENT", "short_name": "🌊", "snr": 6.43, "status": null, "telemetry": {"air_util_tx": 0.43, "battery_level": 84, "channel_utilization": 13.32, "uptime_seconds": 65054, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.54, "iaq": 80, "relative_humidity": 81.19, "temperature": 29.48}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2985, "long_name": "Giant Coyote", "next_hop": 24, "num": "0x3f8f3011", "position": null, "public_key_hex": "02e9805668081ee59996909e936f79a7f22bd437c5be9a291c827536fe75bfcc", "role": "CLIENT", "short_name": "GEL4", "snr": 4.79, "status": null, "telemetry": {"air_util_tx": 0.737, "battery_level": 32, "channel_utilization": 12.67, "uptime_seconds": 59751, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1246, "long_name": "Drowsy Hawk", "next_hop": 219, "num": "0x3fe7bc45", "position": {"altitude": 1370, "latitude": 33.671215, "location_source": "LOC_INTERNAL", "longitude": -107.967687, "time_offset_sec": 1290}, "public_key_hex": "e07c3539aacd2c192456d03817d2487376f2bdcecc8894ffda616682f518916d", "role": "CLIENT", "short_name": "DISX", "snr": 9.68, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5703, "long_name": "Solar Dolphin", "next_hop": 0, "num": "0x3feab2d8", "position": null, "public_key_hex": "75f3f6d9e1644f8739381e8588f58a24a0af45fb8d78b2a26c0beb8833a9a5ba", "role": "CLIENT", "short_name": "SD8I", "snr": 10.65, "status": null, "telemetry": {"air_util_tx": 1.47, "battery_level": 15, "channel_utilization": 1.11, "uptime_seconds": 49218, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1016.99, "iaq": 53, "relative_humidity": 60.91, "temperature": 22.12}, "hops_away": 3, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 2104, "long_name": "Canyon Trout", "next_hop": 110, "num": "0x3ff60655", "position": null, "public_key_hex": "30fdde16e6330a576cd7930af7d224d06a77cb9f6c1c9c30812bc8cc93c0e499", "role": "ROUTER", "short_name": "CE4Q", "snr": 3.86, "status": {"status": "online"}, "telemetry": {"air_util_tx": 2.955, "battery_level": 67, "channel_utilization": 18.23, "uptime_seconds": 60878, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.79, "iaq": 33, "relative_humidity": 93.57, "temperature": 27.95}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3903, "long_name": "Tiny Shark", "next_hop": 0, "num": "0x4002cf22", "position": {"altitude": 1093, "latitude": 34.076794, "location_source": "LOC_INTERNAL", "longitude": -107.665697, "time_offset_sec": 4110}, "public_key_hex": "245ac84c063ef9f9c6bf44669cc4a1b37147dfa7daccbf22cfd42ab6965fc903", "role": "TAK", "short_name": "TPTY", "snr": 8.22, "status": null, "telemetry": {"air_util_tx": 1.58, "battery_level": 35, "channel_utilization": 5.05, "uptime_seconds": 12054, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 9406, "long_name": "Frozen Iguana", "next_hop": 128, "num": "0x400614f1", "position": null, "public_key_hex": "d30b8ea60ac6ffaefaf0b396c63f687aea96da8ab9eff4325423dbd17eb6c7d4", "role": "CLIENT", "short_name": "FWP4", "snr": 11.54, "status": null, "telemetry": {"air_util_tx": 0.177, "battery_level": 54, "channel_utilization": 14.07, "uptime_seconds": 133338, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2633, "long_name": "Dawn Pine", "next_hop": 0, "num": "0x402469e0", "position": {"altitude": 1564, "latitude": 34.366327, "location_source": "LOC_INTERNAL", "longitude": -107.211258, "time_offset_sec": 2778}, "public_key_hex": "", "role": "CLIENT", "short_name": "DHUP", "snr": -2.09, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5854, "long_name": "Slow Mamba", "next_hop": 157, "num": "0x4038fa1d", "position": {"altitude": 1400, "latitude": 32.695282, "location_source": "LOC_INTERNAL", "longitude": -106.281168, "time_offset_sec": 5960}, "public_key_hex": "1df342aa809f75eb927dae97e1d600c4abdd23b1093c5607dba04e8d6a32e5da", "role": "CLIENT", "short_name": "SJK7", "snr": 5.97, "status": null, "telemetry": {"air_util_tx": 0.312, "battery_level": 16, "channel_utilization": 12.38, "uptime_seconds": 3042, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1407, "long_name": "Misty Beaver", "next_hop": 0, "num": "0x40470dc9", "position": {"altitude": 1566, "latitude": 33.81347, "location_source": "LOC_INTERNAL", "longitude": -107.278826, "time_offset_sec": 1536}, "public_key_hex": "902232803e85864bb6a39bf424d78da4793dc1115d78220b44b95ad71ff3b80a", "role": "CLIENT", "short_name": "🗻", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.256, "battery_level": 84, "channel_utilization": 23.52, "uptime_seconds": 129937, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 644, "long_name": "Tall Pony", "next_hop": 0, "num": "0x40642d82", "position": {"altitude": 635, "latitude": 33.682449, "location_source": "LOC_INTERNAL", "longitude": -107.200818, "time_offset_sec": 804}, "public_key_hex": "7b62313eda07ad525951cc77488e5b3d52c85c8998abba2c362639ff93a21fe4", "role": "CLIENT", "short_name": "TMEL", "snr": 8.32, "status": null, "telemetry": {"air_util_tx": 0.152, "battery_level": 79, "channel_utilization": 3.04, "uptime_seconds": 94054, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2211, "long_name": "Lost Seal", "next_hop": 13, "num": "0x406f16a6", "position": {"altitude": 1841, "latitude": 33.699967, "location_source": "LOC_INTERNAL", "longitude": -106.88803, "time_offset_sec": 2246}, "public_key_hex": "7a888f1952d754a9b1b4a2c117e06a339385992fd3817e55e4cb6207062cf53c", "role": "CLIENT", "short_name": "LUH4", "snr": 3.8, "status": null, "telemetry": {"air_util_tx": 1.22, "battery_level": 38, "channel_utilization": 28.24, "uptime_seconds": 86438, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 16919, "long_name": "Copper Pike", "next_hop": 149, "num": "0x40a93650", "position": {"altitude": 1290, "latitude": 32.819679, "location_source": "LOC_INTERNAL", "longitude": -107.364314, "time_offset_sec": 17197}, "public_key_hex": "ddae049532dcd97e91474f8ded601e54a9441e79c83f1bbe8aa25f4d2713c9cb", "role": "CLIENT", "short_name": "CK94", "snr": 2.81, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.779, "battery_level": 64, "channel_utilization": 16.23, "uptime_seconds": 91555, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2120, "long_name": "Desert Fox", "next_hop": 87, "num": "0x40d6eb4d", "position": {"altitude": 1681, "latitude": 33.04389, "location_source": "LOC_INTERNAL", "longitude": -106.379988, "time_offset_sec": 2257}, "public_key_hex": "873694df4522c9df69426e9131231022473f8806eab96560a9a075bdb6c3f27e", "role": "CLIENT", "short_name": "DT7C", "snr": 2.4, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.44, "battery_level": 47, "channel_utilization": 8.19, "uptime_seconds": 65846, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 4281, "long_name": "Sharp Coyote", "next_hop": 230, "num": "0x40f477ff", "position": {"altitude": 1343, "latitude": 32.435555, "location_source": "LOC_INTERNAL", "longitude": -107.385704, "time_offset_sec": 4417}, "public_key_hex": "a735dfb90b62c7bba66c06b26f7d7b8071bd958ec82477f9e5afd8435896e3f6", "role": "CLIENT", "short_name": "SKVX", "snr": 4.87, "status": null, "telemetry": {"air_util_tx": 0.142, "battery_level": 62, "channel_utilization": 10.81, "uptime_seconds": 12359, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3840, "long_name": "Silent Oak", "next_hop": 179, "num": "0x4112f13a", "position": {"altitude": 1368, "latitude": 33.31722, "location_source": "LOC_INTERNAL", "longitude": -107.462409, "time_offset_sec": 4068}, "public_key_hex": "ac36b310aa4a8f0038d5c294a3d028ab6a3ab5ddcfa0c0092c2c5dd6548e5abc", "role": "CLIENT", "short_name": "SQ5F", "snr": 8.23, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.541, "battery_level": 77, "channel_utilization": 6.01, "uptime_seconds": 35222, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3492, "long_name": "Storm Raven", "next_hop": 0, "num": "0x415b6dd8", "position": {"altitude": 1284, "latitude": 33.63148, "location_source": "LOC_INTERNAL", "longitude": -107.159968, "time_offset_sec": 3526}, "public_key_hex": "ff21fbeecafc8af8af3bebe1b87437511dc2189d4c6b413c488e9a3c4258bfe3", "role": "CLIENT", "short_name": "🦋", "snr": 7.78, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.097, "battery_level": 88, "channel_utilization": 7.93, "uptime_seconds": 172718, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 11835, "long_name": "Mountain Iguana", "next_hop": 8, "num": "0x416e9433", "position": {"altitude": 1522, "latitude": 33.012437, "location_source": "LOC_INTERNAL", "longitude": -107.115921, "time_offset_sec": 12131}, "public_key_hex": "f0fca3a42bfe3aa5c3d495b4044e24712faed800fc12b810bbbc063f845d7171", "role": "CLIENT", "short_name": "MDOS", "snr": 10.26, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.366, "battery_level": 57, "channel_utilization": 9.57, "uptime_seconds": 10511, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1320, "long_name": "Storm Crow", "next_hop": 0, "num": "0x4172cc9a", "position": null, "public_key_hex": "c2f90972ae3d7d9c75ec06af28d29cdb1aaa4cfbfbae19640c9f64c5f4690f81", "role": "CLIENT", "short_name": "SZII", "snr": -0.71, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.126, "battery_level": 14, "channel_utilization": 12.94, "uptime_seconds": 41959, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 6089, "long_name": "Blue Bass", "next_hop": 0, "num": "0x41816485", "position": {"altitude": 1565, "latitude": 32.564174, "location_source": "LOC_INTERNAL", "longitude": -107.283644, "time_offset_sec": 6278}, "public_key_hex": "ac21e8b90f9ef4081998572aea6b7afc546ab238da741c6cc9fe521ad688d813", "role": "CLIENT", "short_name": "B0EH", "snr": 8.11, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.276, "battery_level": 23, "channel_utilization": 4.13, "uptime_seconds": 25302, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 10615, "long_name": "Hidden Pine", "next_hop": 0, "num": "0x41ad2d14", "position": {"altitude": 1872, "latitude": 32.270509, "location_source": "LOC_INTERNAL", "longitude": -106.932524, "time_offset_sec": 10863}, "public_key_hex": "d47be74617f93bfabe215af34aa2b5cd4ade6af50b3bf21c1618bc528d92c25d", "role": "CLIENT", "short_name": "HDV7", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.792, "battery_level": 66, "channel_utilization": 18.92, "uptime_seconds": 4191, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 3217, "long_name": "Shady Lion", "next_hop": 111, "num": "0x41bef73d", "position": {"altitude": 956, "latitude": 32.755935, "location_source": "LOC_INTERNAL", "longitude": -107.623333, "time_offset_sec": 3258}, "public_key_hex": "6f7c32ab0730b0811ba3545709e766ccd726ff7abbdb6403205179eed51b7780", "role": "CLIENT", "short_name": "🦌", "snr": 11.18, "status": null, "telemetry": {"air_util_tx": 0.506, "battery_level": 57, "channel_utilization": 18.69, "uptime_seconds": 84736, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4993, "long_name": "Red Heron", "next_hop": 2, "num": "0x41fb920d", "position": {"altitude": 1567, "latitude": 33.826485, "location_source": "LOC_INTERNAL", "longitude": -107.625617, "time_offset_sec": 5225}, "public_key_hex": "a37826ed231cc20f051caa9cbb38869f3b6e5d6ac07cf4caf31e554038fafd7f", "role": "CLIENT", "short_name": "RTAP", "snr": -2.2, "status": null, "telemetry": {"air_util_tx": 0.449, "battery_level": 58, "channel_utilization": 1.49, "uptime_seconds": 26721, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2512, "long_name": "Tiny Mesa", "next_hop": 0, "num": "0x42326039", "position": {"altitude": 1207, "latitude": 32.522987, "location_source": "LOC_INTERNAL", "longitude": -108.650195, "time_offset_sec": 2763}, "public_key_hex": "988133a498e8ee16e075fa58b05340a4f775ab25034a2a81ec7879976b8063e2", "role": "ROUTER", "short_name": "T09N", "snr": 11.54, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1286, "long_name": "Storm Doe", "next_hop": 0, "num": "0x42333451", "position": {"altitude": 1371, "latitude": 34.593409, "location_source": "LOC_INTERNAL", "longitude": -108.235792, "time_offset_sec": 1318}, "public_key_hex": "ab1456bd8bc0ae1f43725af96523efa18cfa3b5abfb756820dbdcf888e03238f", "role": "CLIENT", "short_name": "SG4P", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.254, "battery_level": 55, "channel_utilization": 3.83, "uptime_seconds": 31995, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.5, "iaq": 42, "relative_humidity": 41.88, "temperature": 23.32}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1650, "long_name": "Frozen Cobra", "next_hop": 0, "num": "0x424935e4", "position": {"altitude": 1222, "latitude": 34.131292, "location_source": "LOC_INTERNAL", "longitude": -107.414572, "time_offset_sec": 1724}, "public_key_hex": "d8658a15f2a298ae9834ee1556598cffd54e60021503e0d7546556a82827a2ea", "role": "CLIENT", "short_name": "F2V2", "snr": 5.79, "status": null, "telemetry": {"air_util_tx": 0.209, "battery_level": 101, "channel_utilization": 10.08, "uptime_seconds": 41361, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1016.18, "iaq": 20, "relative_humidity": 61.65, "temperature": 29.51}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2517, "long_name": "Slow Coyote", "next_hop": 245, "num": "0x426c5a95", "position": {"altitude": 1287, "latitude": 33.602834, "location_source": "LOC_INTERNAL", "longitude": -107.497411, "time_offset_sec": 2603}, "public_key_hex": "0262c168d9d3da010e33248c6db7e4d7cc6a8cd46f9e5421c780efd7f5c93d47", "role": "CLIENT", "short_name": "🌲", "snr": 8.58, "status": null, "telemetry": {"air_util_tx": 0.695, "battery_level": 81, "channel_utilization": 13.19, "uptime_seconds": 31130, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.9, "iaq": 61, "relative_humidity": 99.12, "temperature": 23.18}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1899, "long_name": "Gold Falcon", "next_hop": 0, "num": "0x4283c581", "position": null, "public_key_hex": "39895be2db5b7b22fda1f77bbc819f78d721149826e7781cc2d70100775a23cb", "role": "CLIENT", "short_name": "GHU9", "snr": 3.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3390, "long_name": "Frosty Gecko", "next_hop": 0, "num": "0x428bc9bf", "position": {"altitude": 1539, "latitude": 33.257725, "location_source": "LOC_INTERNAL", "longitude": -106.987108, "time_offset_sec": 3485}, "public_key_hex": "56b8b27f3b549009c60cc7af119a26a5bdbe94c1e72237f1b65ebd47e7b109b3", "role": "TAK_TRACKER", "short_name": "🌲", "snr": 12.0, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.0, "iaq": 54, "relative_humidity": 99.17, "temperature": 21.95}, "hops_away": 3, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 1554, "long_name": "Fast Tortoise", "next_hop": 95, "num": "0x42a28cb0", "position": null, "public_key_hex": "b6eb1dadaabcc09e9ef78bd3de27d417a0c91d0d9299f146d46d43a0b2745f81", "role": "ROUTER", "short_name": "F39H", "snr": 2.74, "status": null, "telemetry": {"air_util_tx": 0.239, "battery_level": 46, "channel_utilization": 7.47, "uptime_seconds": 32165, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1010.95, "iaq": 80, "relative_humidity": 30.11, "temperature": 16.52}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1510, "long_name": "Frozen Sage AE1IM", "next_hop": 0, "num": "0x42a607ea", "position": {"altitude": 1644, "latitude": 32.93685, "location_source": "LOC_INTERNAL", "longitude": -107.655203, "time_offset_sec": 1542}, "public_key_hex": "3728a3476dd521706aba5509e3b85e57adafa386b4adc0e93f32188cc6a1d848", "role": "ROUTER", "short_name": "FBM2", "snr": 8.39, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.395, "battery_level": 67, "channel_utilization": 10.13, "uptime_seconds": 57768, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3401, "long_name": "Found Tortoise", "next_hop": 0, "num": "0x42a8c9fc", "position": {"altitude": 1166, "latitude": 32.555488, "location_source": "LOC_INTERNAL", "longitude": -107.34651, "time_offset_sec": 3622}, "public_key_hex": "254d5a773dec528323efc390dbc7737ff9730905bfee5470e738543856b7b7b5", "role": "ROUTER", "short_name": "FDID", "snr": 8.15, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.324, "battery_level": 58, "channel_utilization": 5.78, "uptime_seconds": 32175, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6841, "long_name": "Drifting Iguana", "next_hop": 80, "num": "0x42b005a8", "position": {"altitude": 1468, "latitude": 32.482368, "location_source": "LOC_INTERNAL", "longitude": -108.082038, "time_offset_sec": 7061}, "public_key_hex": "", "role": "CLIENT", "short_name": "DWSB", "snr": 0.4, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.232, "battery_level": 37, "channel_utilization": 10.79, "uptime_seconds": 80626, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3601, "long_name": "New Badger", "next_hop": 129, "num": "0x42b575e9", "position": {"altitude": 1068, "latitude": 33.196057, "location_source": "LOC_INTERNAL", "longitude": -105.533151, "time_offset_sec": 3608}, "public_key_hex": "579c81e4cb14014761e6f2cac5a816990c0effc114e21a010ceaacfb414d9c14", "role": "CLIENT", "short_name": "NWET", "snr": 4.78, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.765, "battery_level": 59, "channel_utilization": 12.48, "uptime_seconds": 21434, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.39, "iaq": 119, "relative_humidity": 6.09, "temperature": 16.99}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 4029, "long_name": "Roving Crane", "next_hop": 0, "num": "0x42dfb603", "position": {"altitude": 1037, "latitude": 33.73512, "location_source": "LOC_INTERNAL", "longitude": -107.064997, "time_offset_sec": 4248}, "public_key_hex": "0a5ef457512c74c08b94794324750ece4330ee701bba6aa434df62a8f1dc3a16", "role": "CLIENT", "short_name": "RRJ6", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.891, "battery_level": 18, "channel_utilization": 5.67, "uptime_seconds": 191180, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.47, "iaq": 68, "relative_humidity": 43.28, "temperature": 29.3}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 9577, "long_name": "Black Otter", "next_hop": 0, "num": "0x43419a68", "position": {"altitude": 1400, "latitude": 33.431716, "location_source": "LOC_INTERNAL", "longitude": -106.608764, "time_offset_sec": 9773}, "public_key_hex": "b98ab54cd05d67ea024f7b82a79906a4a4d6e3e10e7d381db110a631cca9c5bf", "role": "CLIENT", "short_name": "🌲", "snr": 8.2, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 183, "long_name": "Loud Raven", "next_hop": 0, "num": "0x4344bdfa", "position": {"altitude": 1570, "latitude": 33.164513, "location_source": "LOC_INTERNAL", "longitude": -107.190425, "time_offset_sec": 443}, "public_key_hex": "ebd57ae2d9646c1249db9e80a48fc2b0f81679befaa675438b9fc7c25fca7038", "role": "ROUTER", "short_name": "LU0B", "snr": 10.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 3448, "long_name": "Silent Mamba", "next_hop": 147, "num": "0x43b32a0c", "position": {"altitude": 1306, "latitude": 33.652731, "location_source": "LOC_INTERNAL", "longitude": -106.812795, "time_offset_sec": 3680}, "public_key_hex": "3ccc5793f79eef438fc82edb941867edc3c8feeaf1317172d84ee4fa692e46ba", "role": "CLIENT", "short_name": "SFMV", "snr": 9.36, "status": null, "telemetry": {"air_util_tx": 0.301, "battery_level": 34, "channel_utilization": 12.29, "uptime_seconds": 53977, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.66, "iaq": 98, "relative_humidity": 56.02, "temperature": 17.49}, "hops_away": 2, "hw_model": "M5STACK_C6L", "last_heard_offset_sec": 80, "long_name": "Silent Pine", "next_hop": 145, "num": "0x43d4f68d", "position": null, "public_key_hex": "451e3a2b3c2d8d694183c3167d6d2607e5397e42adc4b5eb9867e771bcc24778", "role": "CLIENT", "short_name": "SQY5", "snr": 8.22, "status": null, "telemetry": {"air_util_tx": 0.907, "battery_level": 86, "channel_utilization": 7.77, "uptime_seconds": 205136, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 144, "long_name": "Lone Cougar AB9AD", "next_hop": 0, "num": "0x43dc2355", "position": {"altitude": 1224, "latitude": 32.592146, "location_source": "LOC_INTERNAL", "longitude": -107.390896, "time_offset_sec": 362}, "public_key_hex": "3d5fd1a48b10477775418ad5d3f69acc210f789b47b40e1a60eedfa4abdbf551", "role": "CLIENT", "short_name": "LU6P", "snr": -1.45, "status": null, "telemetry": {"air_util_tx": 0.18, "battery_level": 16, "channel_utilization": 22.82, "uptime_seconds": 65316, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 1540, "long_name": "Green Marmot", "next_hop": 0, "num": "0x43f03bb3", "position": {"altitude": 1030, "latitude": 33.097237, "location_source": "LOC_INTERNAL", "longitude": -107.500424, "time_offset_sec": 1808}, "public_key_hex": "6d6e4267d26fcffd00afb71d49b3200fec5b6945581b81283709f848fbd389e4", "role": "ROUTER", "short_name": "GA4W", "snr": 3.99, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1346, "long_name": "Soft Falcon", "next_hop": 17, "num": "0x440016b6", "position": {"altitude": 1258, "latitude": 33.676714, "location_source": "LOC_INTERNAL", "longitude": -106.770814, "time_offset_sec": 1464}, "public_key_hex": "71c1e96996f63f877a0b1d1789ec8fc0641d00c02ad0a289290469b3a9707af7", "role": "CLIENT", "short_name": "SD5W", "snr": -0.37, "status": null, "telemetry": {"air_util_tx": 2.595, "battery_level": 52, "channel_utilization": 8.3, "uptime_seconds": 7143, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 984, "long_name": "Red Yucca", "next_hop": 0, "num": "0x44108847", "position": null, "public_key_hex": "4abf02a96e9b8f396713c6c056221682e8690d48a1570c80769d7eb89de362ae", "role": "CLIENT", "short_name": "RGDX", "snr": 2.77, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 494, "long_name": "Smooth Dolphin", "next_hop": 0, "num": "0x441b8e1e", "position": {"altitude": 1355, "latitude": 31.754034, "location_source": "LOC_INTERNAL", "longitude": -107.27488, "time_offset_sec": 535}, "public_key_hex": "6329df895f77d9f5dbd8610d6b60cca032317444607561aaff17f3f9297e54c0", "role": "CLIENT", "short_name": "SN6D", "snr": 10.15, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 62, "long_name": "Dusk Cactus", "next_hop": 0, "num": "0x444ee2e3", "position": {"altitude": 1618, "latitude": 33.794342, "location_source": "LOC_INTERNAL", "longitude": -106.977996, "time_offset_sec": 296}, "public_key_hex": "c5c6cb84799136c059d1fc00534e0734cfeaa2049ba7f585bcd457ca37ea57ea", "role": "CLIENT", "short_name": "DDP3", "snr": 8.44, "status": null, "telemetry": {"air_util_tx": 0.424, "battery_level": 48, "channel_utilization": 31.68, "uptime_seconds": 187593, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 646, "long_name": "Red Bison", "next_hop": 0, "num": "0x4465905f", "position": {"altitude": 1674, "latitude": 33.004381, "location_source": "LOC_INTERNAL", "longitude": -107.199039, "time_offset_sec": 721}, "public_key_hex": "0e8262073a8a404c053b1910d9cf51fcdc4a2a24ba8622fe2be4d8738f33bbd6", "role": "CLIENT", "short_name": "🦇", "snr": 3.39, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.026, "battery_level": 71, "channel_utilization": 8.41, "uptime_seconds": 110212, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4060, "long_name": "Fast Bison", "next_hop": 0, "num": "0x4476443f", "position": {"altitude": 1050, "latitude": 33.33353, "location_source": "LOC_INTERNAL", "longitude": -106.845376, "time_offset_sec": 4133}, "public_key_hex": "8c621c8e395aeca0ba0f30c5205090f75c634982d98784af2ebff1c7d1abe345", "role": "CLIENT", "short_name": "FKYA", "snr": 8.75, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "RAK4631", "last_heard_offset_sec": 1035, "long_name": "Lunar Cactus", "next_hop": 217, "num": "0x447f4ab6", "position": {"altitude": 901, "latitude": 33.350036, "location_source": "LOC_INTERNAL", "longitude": -108.816899, "time_offset_sec": 1230}, "public_key_hex": "21e64cbad66a7ec70401f6b52c90c2af3ce8b591f4be91bad0cf4ade3d443919", "role": "CLIENT", "short_name": "L6A1", "snr": 6.11, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 10019, "long_name": "Drifting Phoenix", "next_hop": 107, "num": "0x448a48ec", "position": {"altitude": 1404, "latitude": 33.091558, "location_source": "LOC_INTERNAL", "longitude": -107.158015, "time_offset_sec": 10172}, "public_key_hex": "d9515b859e3f693490fb3d86e32c10ec08ce69fdf987987400a07796a68afd2a", "role": "CLIENT", "short_name": "D6NW", "snr": 9.96, "status": null, "telemetry": {"air_util_tx": 0.392, "battery_level": 96, "channel_utilization": 3.11, "uptime_seconds": 43744, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.92, "iaq": 28, "relative_humidity": 68.06, "temperature": 12.52}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 5779, "long_name": "Happy Mamba", "next_hop": 0, "num": "0x44b2d147", "position": {"altitude": 1316, "latitude": 32.858138, "location_source": "LOC_INTERNAL", "longitude": -107.60154, "time_offset_sec": 5873}, "public_key_hex": "419ee5ac74ca0ada93cba20104865fb208bca60fc806190409eff79cc226f3f7", "role": "CLIENT", "short_name": "H191", "snr": 7.02, "status": null, "telemetry": {"air_util_tx": 0.182, "battery_level": 63, "channel_utilization": 11.63, "uptime_seconds": 21801, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 328, "long_name": "Black Turtle", "next_hop": 129, "num": "0x44ded7d0", "position": null, "public_key_hex": "0ba49420ce4008ac6c0d94e87f65d97c4235f11e65a4d2aa6214a7663d7b935d", "role": "CLIENT", "short_name": "BZGZ", "snr": 9.02, "status": null, "telemetry": {"air_util_tx": 0.331, "battery_level": 37, "channel_utilization": 26.11, "uptime_seconds": 270182, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1429, "long_name": "Smooth Wolf", "next_hop": 211, "num": "0x4515d67e", "position": {"altitude": 1339, "latitude": 34.047038, "location_source": "LOC_INTERNAL", "longitude": -107.207526, "time_offset_sec": 1504}, "public_key_hex": "cb02c5ef60dfeb328977f015ea996834417eedc5894033b8ee107648c487d306", "role": "CLIENT", "short_name": "SFB4", "snr": 7.88, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.428, "battery_level": 42, "channel_utilization": 4.6, "uptime_seconds": 91149, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 953, "long_name": "Blue Shark", "next_hop": 183, "num": "0x45221a14", "position": {"altitude": 1435, "latitude": 33.361594, "location_source": "LOC_INTERNAL", "longitude": -107.028918, "time_offset_sec": 1195}, "public_key_hex": "99115dc9e79397038cb4b2a53d6a37325f3b6c4763c5fc6f31d4486fca4ba9ef", "role": "CLIENT", "short_name": "BJQ3", "snr": 5.88, "status": null, "telemetry": {"air_util_tx": 1.385, "battery_level": 83, "channel_utilization": 4.46, "uptime_seconds": 23329, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2437, "long_name": "Sunny Hare", "next_hop": 0, "num": "0x45318419", "position": {"altitude": 1349, "latitude": 32.067275, "location_source": "LOC_INTERNAL", "longitude": -108.132531, "time_offset_sec": 2721}, "public_key_hex": "a12928d469b3a586643b8437c8d03c63e93a13cc7de285cdf05a9fd803a615ba", "role": "CLIENT", "short_name": "SOIH", "snr": 11.12, "status": null, "telemetry": {"air_util_tx": 0.477, "battery_level": 44, "channel_utilization": 17.42, "uptime_seconds": 112690, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1012.13, "iaq": 0, "relative_humidity": 31.43, "temperature": 34.08}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1779, "long_name": "Silent Crane", "next_hop": 0, "num": "0x4555a669", "position": null, "public_key_hex": "c053ea1f07a86056b2c66d93e8368e2d7f88dc2799d1c0c2134a46b65288fda5", "role": "CLIENT", "short_name": "SZXU", "snr": 5.66, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2585, "long_name": "Gold Badger", "next_hop": 41, "num": "0x4572f924", "position": {"altitude": 1191, "latitude": 33.04237, "location_source": "LOC_INTERNAL", "longitude": -106.745387, "time_offset_sec": 2841}, "public_key_hex": "5a554f00453a7afddc08243b4bd1712b31f650b1ee966785951415ebe9b57b1d", "role": "CLIENT", "short_name": "GTNM", "snr": 4.87, "status": null, "telemetry": {"air_util_tx": 1.57, "battery_level": 32, "channel_utilization": 4.71, "uptime_seconds": 9709, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3091, "long_name": "Dawn Pine", "next_hop": 0, "num": "0x45a2fa28", "position": {"altitude": 1414, "latitude": 33.609768, "location_source": "LOC_INTERNAL", "longitude": -107.618633, "time_offset_sec": 3199}, "public_key_hex": "5960517bdc2fa90f012ba9b161e7921bb94faeffc421f97c817ef6b746f7a5b7", "role": "CLIENT", "short_name": "DVU4", "snr": 1.55, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.321, "battery_level": 88, "channel_utilization": 6.96, "uptime_seconds": 18842, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 368, "long_name": "Red Mamba", "next_hop": 14, "num": "0x45a5e0e3", "position": {"altitude": 935, "latitude": 32.155514, "location_source": "LOC_INTERNAL", "longitude": -108.106269, "time_offset_sec": 410}, "public_key_hex": "fc88eb62c3b39b23d581580e82997e9116444e03875636872ec58f5cc453ce71", "role": "CLIENT_MUTE", "short_name": "RFC3", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.526, "battery_level": 55, "channel_utilization": 21.24, "uptime_seconds": 203488, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 996.28, "iaq": 52, "relative_humidity": 48.62, "temperature": 18.33}, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3836, "long_name": "Old Viper", "next_hop": 122, "num": "0x45e476cd", "position": null, "public_key_hex": "905259ff4e84e941df2a329c860fe71ef1701b57235659059d2ad484ccabff7a", "role": "CLIENT", "short_name": "OAMS", "snr": 11.31, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 6097, "long_name": "Forest Mole", "next_hop": 26, "num": "0x45e7332c", "position": {"altitude": 1370, "latitude": 33.784676, "location_source": "LOC_INTERNAL", "longitude": -107.232956, "time_offset_sec": 6314}, "public_key_hex": "d38d6076aa886179ed1dda16626433b7f929b0ae53fe9ad51e8189d45300e268", "role": "CLIENT", "short_name": "FJIP", "snr": 4.57, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.497, "battery_level": 87, "channel_utilization": 14.48, "uptime_seconds": 68645, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.49, "iaq": 55, "relative_humidity": 70.11, "temperature": 15.73}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 5393, "long_name": "Stone Arroyo AB5NJ", "next_hop": 57, "num": "0x46ad3ad3", "position": null, "public_key_hex": "00ac79136e9ab5de3d85da94ed7eb483c5fd2d018f52495c3612eee9eb48d5f6", "role": "CLIENT", "short_name": "SCO6", "snr": 1.9, "status": null, "telemetry": {"air_util_tx": 1.227, "battery_level": 32, "channel_utilization": 9.55, "uptime_seconds": 6358, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 9534, "long_name": "Happy Mustang", "next_hop": 0, "num": "0x46b0e590", "position": {"altitude": 1095, "latitude": 32.766147, "location_source": "LOC_INTERNAL", "longitude": -106.777544, "time_offset_sec": 9769}, "public_key_hex": "4ca7b1fd306923868e5e245c981204e438d05f1582a1efc07a836d23d21fc48e", "role": "CLIENT", "short_name": "🦌", "snr": 7.05, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.126, "battery_level": 39, "channel_utilization": 9.96, "uptime_seconds": 280537, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2595, "long_name": "Smooth Wolf", "next_hop": 0, "num": "0x46c1df1d", "position": null, "public_key_hex": "9a064b7cf3e43ae048920b980d04ab57250ec24715835d9bed929586adecef89", "role": "CLIENT", "short_name": "SRRA", "snr": 1.91, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.239, "battery_level": 58, "channel_utilization": 18.58, "uptime_seconds": 8337, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 318, "long_name": "Dawn Pine", "next_hop": 187, "num": "0x46da324f", "position": {"altitude": 1530, "latitude": 33.011771, "location_source": "LOC_INTERNAL", "longitude": -106.716652, "time_offset_sec": 495}, "public_key_hex": "59a75bef40f42c3150a05d3f4153a338540e1fe33860a62b74f0e53903195cee", "role": "CLIENT", "short_name": "DWMD", "snr": 5.61, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 287, "long_name": "Wild Shark", "next_hop": 0, "num": "0x46f2a7e9", "position": {"altitude": 1344, "latitude": 32.057878, "location_source": "LOC_INTERNAL", "longitude": -106.84311, "time_offset_sec": 563}, "public_key_hex": "b6a98c9c057910636db5486700c137ab9bf0a40a48a179b4c900ea97aa3e3750", "role": "CLIENT", "short_name": "WUN8", "snr": 3.22, "status": null, "telemetry": {"air_util_tx": 0.165, "battery_level": 93, "channel_utilization": 13.95, "uptime_seconds": 144040, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1389, "long_name": "Wild Cactus", "next_hop": 221, "num": "0x47022eb9", "position": null, "public_key_hex": "738c0fc2b0d82b53dff5e5321ac6cf83623fcaa9511ce82c68db826a55194596", "role": "CLIENT", "short_name": "WDWS", "snr": -2.1, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 15505, "long_name": "Burning Pine", "next_hop": 42, "num": "0x471baf07", "position": {"altitude": 1384, "latitude": 32.918671, "location_source": "LOC_INTERNAL", "longitude": -107.367075, "time_offset_sec": 15618}, "public_key_hex": "968e76c3c9e7563b4c406b29d1b9748f5eb1211c483f3085df5297ff40af7aae", "role": "CLIENT", "short_name": "BO0D", "snr": 6.52, "status": null, "telemetry": {"air_util_tx": 2.07, "battery_level": 70, "channel_utilization": 28.06, "uptime_seconds": 270430, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 621, "long_name": "Stone Sage", "next_hop": 0, "num": "0x4721e8bf", "position": {"altitude": 1146, "latitude": 33.123573, "location_source": "LOC_INTERNAL", "longitude": -107.007306, "time_offset_sec": 716}, "public_key_hex": "d3ef049320fd8271f8457962e8c5205e0fceb1adf3bd82050c2b9d6cb3684fbe", "role": "CLIENT", "short_name": "SJSB", "snr": 8.42, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.273, "battery_level": 94, "channel_utilization": 4.15, "uptime_seconds": 97080, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 7, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2209, "long_name": "Lost Aspen", "next_hop": 204, "num": "0x477e4bd6", "position": {"altitude": 1780, "latitude": 33.41369, "location_source": "LOC_INTERNAL", "longitude": -108.103091, "time_offset_sec": 2465}, "public_key_hex": "46df9b10f992156a60be93344f34af80a577f51caf25905d2dfa3764e925cfbf", "role": "CLIENT", "short_name": "LJD0", "snr": 8.24, "status": null, "telemetry": {"air_util_tx": 1.569, "battery_level": 83, "channel_utilization": 13.87, "uptime_seconds": 79918, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.99, "iaq": 31, "relative_humidity": 44.85, "temperature": 24.36}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2902, "long_name": "Sharp Heron", "next_hop": 0, "num": "0x47985e27", "position": {"altitude": 1190, "latitude": 32.422221, "location_source": "LOC_INTERNAL", "longitude": -107.039281, "time_offset_sec": 3194}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦌", "snr": 6.93, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.286, "battery_level": 46, "channel_utilization": 10.51, "uptime_seconds": 158281, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 9555, "long_name": "Lost Mustang", "next_hop": 0, "num": "0x479c189b", "position": null, "public_key_hex": "dcf4ac2e4ade3fe04efd6bde0d61fd45c23a1b77a942ed5ed19bcdb43ae09cf7", "role": "CLIENT", "short_name": "LD7T", "snr": 7.95, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.196, "battery_level": 51, "channel_utilization": 8.83, "uptime_seconds": 13206, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3896, "long_name": "Loud Dolphin", "next_hop": 0, "num": "0x480fcea4", "position": {"altitude": 1778, "latitude": 32.840241, "location_source": "LOC_INTERNAL", "longitude": -107.313532, "time_offset_sec": 4185}, "public_key_hex": "2ec79e5314a91405723316ff017b49c17e80e1755d9c57027f89232a366f3bd2", "role": "ROUTER", "short_name": "LPLR", "snr": 5.17, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 521, "long_name": "Lunar Turtle", "next_hop": 0, "num": "0x4822ad87", "position": {"altitude": 986, "latitude": 32.774176, "location_source": "LOC_INTERNAL", "longitude": -108.691344, "time_offset_sec": 586}, "public_key_hex": "54d5255c783788652e4d4a64835ebe9fe31974799af7901e8afa224e11d3e025", "role": "CLIENT", "short_name": "🦉", "snr": 11.96, "status": null, "telemetry": {"air_util_tx": 1.087, "battery_level": 90, "channel_utilization": 33.09, "uptime_seconds": 53163, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2996, "long_name": "Tall Tortoise", "next_hop": 0, "num": "0x4832e98c", "position": {"altitude": 976, "latitude": 33.471459, "location_source": "LOC_INTERNAL", "longitude": -107.171653, "time_offset_sec": 3143}, "public_key_hex": "", "role": "CLIENT", "short_name": "T0IL", "snr": 6.45, "status": null, "telemetry": {"air_util_tx": 0.191, "battery_level": 99, "channel_utilization": 12.14, "uptime_seconds": 9329, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.36, "iaq": 41, "relative_humidity": 30.87, "temperature": 22.88}, "hops_away": 2, "hw_model": "RAK3312", "last_heard_offset_sec": 10522, "long_name": "Happy Bronco", "next_hop": 164, "num": "0x48382479", "position": null, "public_key_hex": "386696f4bbdaa67575a3a9c64578aaaf0e3abb7cbddb63ee34462195586ec011", "role": "CLIENT", "short_name": "🦋", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2238, "long_name": "Bright Marmot", "next_hop": 0, "num": "0x48656ede", "position": {"altitude": 1392, "latitude": 33.365025, "location_source": "LOC_INTERNAL", "longitude": -107.923029, "time_offset_sec": 2525}, "public_key_hex": "5f76e0c1e978f1f23fc80204f9a80779642518b09cf8981772a38a1e53d656c1", "role": "CLIENT", "short_name": "BA0W", "snr": 2.36, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.634, "battery_level": 32, "channel_utilization": 7.1, "uptime_seconds": 88402, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2638, "long_name": "Roving Viper", "next_hop": 169, "num": "0x489b0bcc", "position": {"altitude": 1235, "latitude": 33.628895, "location_source": "LOC_INTERNAL", "longitude": -107.157744, "time_offset_sec": 2897}, "public_key_hex": "f998e0bfae9c392ae1a4ccc87e1fbea46387c1e928bcf6f0bd461231d5e92464", "role": "CLIENT_MUTE", "short_name": "🐢", "snr": 8.03, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.23, "iaq": 100, "relative_humidity": 74.76, "temperature": 19.57}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 3600, "long_name": "Sunny Salmon", "next_hop": 0, "num": "0x48cf4382", "position": {"altitude": 1526, "latitude": 33.508699, "location_source": "LOC_INTERNAL", "longitude": -107.17438, "time_offset_sec": 3824}, "public_key_hex": "00e8b8dfe2119fb9cc77bfe40cbed2e267cb3e2721afd628e8c6d10a7331d29e", "role": "CLIENT", "short_name": "S56H", "snr": 7.65, "status": null, "telemetry": {"air_util_tx": 1.181, "battery_level": 54, "channel_utilization": 8.84, "uptime_seconds": 46845, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.99, "iaq": 0, "relative_humidity": 60.64, "temperature": 19.49}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1940, "long_name": "Burning Stag", "next_hop": 138, "num": "0x48f937f8", "position": {"altitude": 1552, "latitude": 33.360101, "location_source": "LOC_INTERNAL", "longitude": -107.459674, "time_offset_sec": 2238}, "public_key_hex": "103970bc0dab7deb77038d1ac1fdc374bf202ca568278f5a382053080fbf1cc9", "role": "CLIENT", "short_name": "BR1U", "snr": 5.12, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1028.19, "iaq": 1, "relative_humidity": 67.1, "temperature": 24.89}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4311, "long_name": "Blue Wolf", "next_hop": 179, "num": "0x49305b80", "position": {"altitude": 1351, "latitude": 33.520815, "location_source": "LOC_INTERNAL", "longitude": -107.441028, "time_offset_sec": 4402}, "public_key_hex": "cfc3cbb31d594cd1c99754d4c8fdf0f709b49779ff59d23cf4e329dff29e1da9", "role": "CLIENT_BASE", "short_name": "BHQH", "snr": 9.65, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.147, "battery_level": 31, "channel_utilization": 16.24, "uptime_seconds": 801246, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1809, "long_name": "Sky Elk", "next_hop": 13, "num": "0x49a24b22", "position": {"altitude": 1504, "latitude": 33.384273, "location_source": "LOC_INTERNAL", "longitude": -107.62472, "time_offset_sec": 1825}, "public_key_hex": "81b5833c14f9002165d338f23a33035d116396fac700251df26f3f3597e2449e", "role": "CLIENT", "short_name": "SWVX", "snr": 9.87, "status": null, "telemetry": {"air_util_tx": 1.183, "battery_level": 12, "channel_utilization": 13.85, "uptime_seconds": 117, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2970, "long_name": "Short Ridge", "next_hop": 0, "num": "0x49a31ac2", "position": null, "public_key_hex": "8ed35a06712f8317f990ba12bcf55030206bbb232b77fb3ec266faae37c3efa5", "role": "CLIENT", "short_name": "SRRK", "snr": 11.06, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3370, "long_name": "Floating Marmot", "next_hop": 53, "num": "0x49bd4ad2", "position": {"altitude": 1382, "latitude": 33.898094, "location_source": "LOC_INTERNAL", "longitude": -106.266365, "time_offset_sec": 3599}, "public_key_hex": "556cdc64e4f59f7b6ce317acc74e1acfff0995b2436f9593e3993d7bb8be80a0", "role": "CLIENT", "short_name": "FSPE", "snr": 10.07, "status": null, "telemetry": {"air_util_tx": 0.347, "battery_level": 53, "channel_utilization": 1.14, "uptime_seconds": 83713, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2230, "long_name": "Brave Whale KE2AJ", "next_hop": 0, "num": "0x49d5ad75", "position": {"altitude": 1067, "latitude": 32.335882, "location_source": "LOC_INTERNAL", "longitude": -106.895863, "time_offset_sec": 2413}, "public_key_hex": "74225766e35864675687b86ef1ae57f0e6f6fe089c4c93a61f92449405e3cbc9", "role": "CLIENT", "short_name": "BL76", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.821, "battery_level": 55, "channel_utilization": 27.31, "uptime_seconds": 109283, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3800, "long_name": "Sunny Mustang", "next_hop": 0, "num": "0x4a0f686a", "position": {"altitude": 1737, "latitude": 34.616274, "location_source": "LOC_INTERNAL", "longitude": -108.244077, "time_offset_sec": 3869}, "public_key_hex": "78cddbe0180200dede30842f4d55eaed401de55318947843f6fcf56a4a35c9f9", "role": "TRACKER", "short_name": "SFYE", "snr": 11.19, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.752, "battery_level": 41, "channel_utilization": 2.88, "uptime_seconds": 157937, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3918, "long_name": "Rough Eagle", "next_hop": 0, "num": "0x4a5c5deb", "position": {"altitude": 1291, "latitude": 33.42716, "location_source": "LOC_INTERNAL", "longitude": -107.286996, "time_offset_sec": 3954}, "public_key_hex": "fae2d66cef7a5d06ffce6e14b23259bd9c992e6affef7d3f50986e4eb2611b78", "role": "CLIENT", "short_name": "RYLU", "snr": 11.5, "status": null, "telemetry": {"air_util_tx": 1.581, "battery_level": 37, "channel_utilization": 19.36, "uptime_seconds": 98428, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 3219, "long_name": "Forest Mole", "next_hop": 29, "num": "0x4a647993", "position": {"altitude": 1623, "latitude": 33.356143, "location_source": "LOC_INTERNAL", "longitude": -106.877147, "time_offset_sec": 3463}, "public_key_hex": "049515f1f8ce23143cbb75ab7713a6705aadb41e3f443175cc576e0bf7bd852b", "role": "CLIENT", "short_name": "FGUM", "snr": 3.58, "status": null, "telemetry": {"air_util_tx": 0.499, "battery_level": 101, "channel_utilization": 3.71, "uptime_seconds": 101017, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.08, "iaq": 78, "relative_humidity": 55.26, "temperature": 18.89}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 693, "long_name": "Found Tortoise", "next_hop": 75, "num": "0x4aa730df", "position": {"altitude": 1045, "latitude": 32.161695, "location_source": "LOC_INTERNAL", "longitude": -106.997624, "time_offset_sec": 924}, "public_key_hex": "aa67aea4888bbc095102003d20553ff0edd187db835024084b3c13571dfbd7c7", "role": "ROUTER", "short_name": "FYDH", "snr": 7.2, "status": null, "telemetry": {"air_util_tx": 0.706, "battery_level": 19, "channel_utilization": 4.41, "uptime_seconds": 188266, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 347, "long_name": "Solar Ridge", "next_hop": 155, "num": "0x4ad31098", "position": {"altitude": 1197, "latitude": 33.191226, "location_source": "LOC_INTERNAL", "longitude": -108.245674, "time_offset_sec": 350}, "public_key_hex": "9bf45d543b9efe256e12611720b23e8aaede17d808bb2f9c36d8c8e4c7ab66aa", "role": "CLIENT", "short_name": "SEEK", "snr": 7.81, "status": null, "telemetry": {"air_util_tx": 1.828, "battery_level": 81, "channel_utilization": 18.63, "uptime_seconds": 73175, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1028.36, "iaq": 48, "relative_humidity": 27.06, "temperature": 23.06}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1102, "long_name": "Wild Coyote N51FC", "next_hop": 0, "num": "0x4afdd593", "position": {"altitude": 1041, "latitude": 33.16798, "location_source": "LOC_INTERNAL", "longitude": -106.390143, "time_offset_sec": 1313}, "public_key_hex": "0ccb28a50647c69f5ad97aba247acb86b0869852b99f9787e4d0b4f7f04c2542", "role": "CLIENT", "short_name": "🌙", "snr": 0.98, "status": null, "telemetry": {"air_util_tx": 0.439, "battery_level": 95, "channel_utilization": 18.57, "uptime_seconds": 1567, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 16061, "long_name": "Frosty Ridge", "next_hop": 237, "num": "0x4b2b2633", "position": {"altitude": 1298, "latitude": 33.592657, "location_source": "LOC_INTERNAL", "longitude": -107.856343, "time_offset_sec": 16190}, "public_key_hex": "bf72d86189ec4f2383a9e479bb0ae9e112d92676ccad624d2fddb6466300082a", "role": "CLIENT", "short_name": "FNSM", "snr": -2.76, "status": null, "telemetry": {"air_util_tx": 0.207, "battery_level": 83, "channel_utilization": 9.08, "uptime_seconds": 224067, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 767, "long_name": "Blue Stag", "next_hop": 46, "num": "0x4b88d3c6", "position": {"altitude": 965, "latitude": 32.770881, "location_source": "LOC_INTERNAL", "longitude": -106.216351, "time_offset_sec": 1018}, "public_key_hex": "bfc70199c4904303fbe37723fbe3282edf9b7039a68a98bdc2cb33ce745fc512", "role": "CLIENT_BASE", "short_name": "BUVU", "snr": 5.62, "status": null, "telemetry": {"air_util_tx": 1.337, "battery_level": 36, "channel_utilization": 27.28, "uptime_seconds": 48440, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1731, "long_name": "Loud Crow", "next_hop": 0, "num": "0x4bfd33d0", "position": {"altitude": 1641, "latitude": 34.144841, "location_source": "LOC_INTERNAL", "longitude": -107.294458, "time_offset_sec": 1934}, "public_key_hex": "0c37479ca60ffaf7ae8783afc50ebece2979de63b26ef5883a56faced638eeaa", "role": "SENSOR", "short_name": "L8QS", "snr": 4.93, "status": null, "telemetry": {"air_util_tx": 0.15, "battery_level": 66, "channel_utilization": 9.67, "uptime_seconds": 45085, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 367, "long_name": "Burning Iguana", "next_hop": 41, "num": "0x4c32026c", "position": {"altitude": 1165, "latitude": 33.090983, "location_source": "LOC_INTERNAL", "longitude": -107.502652, "time_offset_sec": 442}, "public_key_hex": "24e4b8b5a88e03d96857d098b34f47d55453a1726c85aeffa34bd5da11a7456f", "role": "CLIENT", "short_name": "BF6P", "snr": 9.18, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 0.674, "battery_level": 86, "channel_utilization": 4.78, "uptime_seconds": 40078, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 3651, "long_name": "Sharp Mamba", "next_hop": 150, "num": "0x4c8fe17f", "position": null, "public_key_hex": "53d0146cd2231a178344347c9e6cfc25d9feed496f5be5e58c12d1d5896cdfdc", "role": "CLIENT", "short_name": "SQXR", "snr": 2.72, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2555, "long_name": "Steel Colt", "next_hop": 0, "num": "0x4caadd9c", "position": {"altitude": 1212, "latitude": 33.41391, "location_source": "LOC_INTERNAL", "longitude": -107.329732, "time_offset_sec": 2649}, "public_key_hex": "6934650967637b688e49bf6ec3bfa6cd2a2bfbd1c888ee629ff96800eb0b8bf3", "role": "CLIENT", "short_name": "🐺", "snr": 3.21, "status": null, "telemetry": {"air_util_tx": 0.343, "battery_level": 10, "channel_utilization": 16.13, "uptime_seconds": 60533, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 384, "long_name": "Red Bison", "next_hop": 0, "num": "0x4cd8567a", "position": {"altitude": 1744, "latitude": 33.234772, "location_source": "LOC_INTERNAL", "longitude": -106.589199, "time_offset_sec": 409}, "public_key_hex": "37e1662c459485dfca8c462455092eda882824507d903c5838fe093b41824be6", "role": "CLIENT", "short_name": "RAKT", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.852, "battery_level": 65, "channel_utilization": 15.55, "uptime_seconds": 68433, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.56, "iaq": 48, "relative_humidity": 59.87, "temperature": 16.67}, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2625, "long_name": "Sky Shark", "next_hop": 207, "num": "0x4d0436ab", "position": {"altitude": 1564, "latitude": 33.062491, "location_source": "LOC_INTERNAL", "longitude": -107.130093, "time_offset_sec": 2884}, "public_key_hex": "4749d2b1bab60bc2b6490be770091e9ca2001127bf11a45de1dd9661e39b16cf", "role": "CLIENT", "short_name": "SB27", "snr": 0.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4219, "long_name": "Found Fox KQ1LW", "next_hop": 0, "num": "0x4d057e4d", "position": {"altitude": 1144, "latitude": 33.333781, "location_source": "LOC_INTERNAL", "longitude": -106.859923, "time_offset_sec": 4379}, "public_key_hex": "4d1526d918a768eb47e4eb2f3f93e50bff5513db7703fdd0edff52bbfe37e9e3", "role": "CLIENT", "short_name": "FBFF", "snr": -0.3, "status": null, "telemetry": {"air_util_tx": 0.48, "battery_level": 55, "channel_utilization": 4.86, "uptime_seconds": 43641, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1000.84, "iaq": 102, "relative_humidity": 68.34, "temperature": 20.88}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 4361, "long_name": "Brave Crow", "next_hop": 0, "num": "0x4d102ead", "position": {"altitude": 1611, "latitude": 33.653226, "location_source": "LOC_INTERNAL", "longitude": -107.344218, "time_offset_sec": 4371}, "public_key_hex": "e07673554e3967276c3cd0d379733ae8ae60c8e83e6c2001c27b4ac8d1a79014", "role": "ROUTER", "short_name": "B52Q", "snr": 0.3, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.853, "battery_level": 52, "channel_utilization": 30.41, "uptime_seconds": 144541, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 576, "long_name": "Soft Gecko", "next_hop": 0, "num": "0x4d14c30e", "position": {"altitude": 1581, "latitude": 33.980441, "location_source": "LOC_INTERNAL", "longitude": -107.296926, "time_offset_sec": 777}, "public_key_hex": "55a675a7526d17321814c3b29b08fb24dc5fc58f69fa7028dd858abed6136828", "role": "CLIENT_MUTE", "short_name": "SFG0", "snr": 4.18, "status": null, "telemetry": {"air_util_tx": 0.34, "battery_level": 49, "channel_utilization": 9.79, "uptime_seconds": 24254, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3550, "long_name": "Short Mole", "next_hop": 185, "num": "0x4d19766d", "position": {"altitude": 1016, "latitude": 33.07354, "location_source": "LOC_INTERNAL", "longitude": -107.204067, "time_offset_sec": 3627}, "public_key_hex": "133a75f8faf1c5fad1f866b1f16f165f6d95c8b12e6390aaa960a722ae9a6ec5", "role": "CLIENT", "short_name": "SX31", "snr": 3.44, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.821, "battery_level": 54, "channel_utilization": 27.28, "uptime_seconds": 163829, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 7652, "long_name": "Lunar Mustang", "next_hop": 0, "num": "0x4d1f51c6", "position": null, "public_key_hex": "8941eaa26a7de6e3ebfc2baf58d2f6c70addd00969a2c4910b6212085ddaf9b6", "role": "CLIENT", "short_name": "LZVA", "snr": 8.33, "status": null, "telemetry": {"air_util_tx": 0.191, "battery_level": 23, "channel_utilization": 13.29, "uptime_seconds": 178805, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.54, "iaq": 59, "relative_humidity": 74.11, "temperature": 20.66}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 10405, "long_name": "White Moose KE3VR", "next_hop": 0, "num": "0x4d77ce6a", "position": null, "public_key_hex": "2e3a17f3d0de7752ee057c92fcb089fd2153fccea075115114de0c962c321ef1", "role": "CLIENT", "short_name": "WXLU", "snr": 6.94, "status": null, "telemetry": {"air_util_tx": 0.391, "battery_level": 47, "channel_utilization": 11.52, "uptime_seconds": 151917, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2942, "long_name": "Frozen Salmon", "next_hop": 0, "num": "0x4db4a7e5", "position": {"altitude": 1230, "latitude": 32.58183, "location_source": "LOC_INTERNAL", "longitude": -107.574796, "time_offset_sec": 3152}, "public_key_hex": "a0dd835ad2b48825b33df3fb8119f3a8b028a65c36c6994256d925d98758f71a", "role": "CLIENT", "short_name": "F0W0", "snr": 5.8, "status": null, "telemetry": {"air_util_tx": 0.894, "battery_level": 79, "channel_utilization": 9.39, "uptime_seconds": 108812, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 5, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 1304, "long_name": "Lone Viper", "next_hop": 123, "num": "0x4dcae162", "position": null, "public_key_hex": "b90b71ec6b6c7f0dc4ef506eb37f7d71e1ada0082a524b476c2331febff80b21", "role": "CLIENT", "short_name": "L1C6", "snr": 5.97, "status": null, "telemetry": {"air_util_tx": 1.655, "battery_level": 71, "channel_utilization": 19.7, "uptime_seconds": 113282, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5231, "long_name": "Bright Pike", "next_hop": 111, "num": "0x4dcafbc8", "position": {"altitude": 1346, "latitude": 32.974246, "location_source": "LOC_INTERNAL", "longitude": -107.793882, "time_offset_sec": 5277}, "public_key_hex": "99db1f58a2333741b8edde6b100707fb7a19828e10d4c0ee6669cd9c568def6e", "role": "CLIENT_HIDDEN", "short_name": "BH72", "snr": 10.57, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 15287, "long_name": "Solar Bluff", "next_hop": 0, "num": "0x4e19fd96", "position": {"altitude": 1079, "latitude": 33.729699, "location_source": "LOC_INTERNAL", "longitude": -106.207323, "time_offset_sec": 15463}, "public_key_hex": "8278bea539294b98a8eaf481cdf74b2264ab258fc8446acff8f178f8e54ad9fd", "role": "ROUTER", "short_name": "🦌", "snr": 0.56, "status": null, "telemetry": {"air_util_tx": 0.524, "battery_level": 98, "channel_utilization": 6.66, "uptime_seconds": 77029, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.82, "iaq": 0, "relative_humidity": 64.08, "temperature": 39.19}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3858, "long_name": "Burning Doe", "next_hop": 0, "num": "0x4e5c527e", "position": {"altitude": 1109, "latitude": 33.044612, "location_source": "LOC_INTERNAL", "longitude": -106.900254, "time_offset_sec": 3954}, "public_key_hex": "4e76f7eeb92dc1fc25630f19783944f63d1e2e6b47566fbbafdbab063128dfae", "role": "CLIENT", "short_name": "B7TN", "snr": 7.49, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.387, "battery_level": 83, "channel_utilization": 13.18, "uptime_seconds": 69175, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 222, "long_name": "Drifting Bass AE7RF", "next_hop": 138, "num": "0x4e6fb7aa", "position": {"altitude": 1132, "latitude": 32.963745, "location_source": "LOC_INTERNAL", "longitude": -107.422873, "time_offset_sec": 373}, "public_key_hex": "bd306c93af2836f34720a9cbad9a699f3c925b428d17c8459fbca599cbc462c3", "role": "CLIENT", "short_name": "D4OX", "snr": 4.52, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 13526, "long_name": "Blue Squirrel", "next_hop": 0, "num": "0x4e74f7cc", "position": {"altitude": 1552, "latitude": 33.039763, "location_source": "LOC_INTERNAL", "longitude": -107.29527, "time_offset_sec": 13821}, "public_key_hex": "f21b14ee9df6e3f2e5fb7180629187ca8a8cc1c603d1869cd460808c1f9cbd2f", "role": "CLIENT", "short_name": "B8OU", "snr": 8.55, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 177, "long_name": "Silver Phoenix", "next_hop": 200, "num": "0x4e8bf219", "position": {"altitude": 779, "latitude": 33.21094, "location_source": "LOC_INTERNAL", "longitude": -107.298041, "time_offset_sec": 477}, "public_key_hex": "e6b0d07028fe2f6e3e9e720d6606385e73e27fa59d8f35e00259980d137f36ab", "role": "CLIENT", "short_name": "SL1W", "snr": 4.51, "status": null, "telemetry": {"air_util_tx": 2.353, "battery_level": 92, "channel_utilization": 29.09, "uptime_seconds": 66198, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 669, "long_name": "Hidden Otter", "next_hop": 97, "num": "0x4e8e0512", "position": {"altitude": 1114, "latitude": 32.857701, "location_source": "LOC_INTERNAL", "longitude": -107.660306, "time_offset_sec": 863}, "public_key_hex": "34574e8e2c8f65666009e72a4844d7fccc84154d8fb02c74d365b84a5298c2da", "role": "CLIENT", "short_name": "H01E", "snr": 5.98, "status": null, "telemetry": {"air_util_tx": 0.486, "battery_level": 24, "channel_utilization": 4.88, "uptime_seconds": 54878, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 7360, "long_name": "Whispering Lion KE2MP", "next_hop": 40, "num": "0x4ea20631", "position": {"altitude": 975, "latitude": 33.793677, "location_source": "LOC_INTERNAL", "longitude": -108.053857, "time_offset_sec": 7392}, "public_key_hex": "bef88052db138ebf5d51a3ee66e35e67ade4eca52806765b47ee22e6135cf126", "role": "CLIENT", "short_name": "WER5", "snr": -0.47, "status": null, "telemetry": {"air_util_tx": 1.745, "battery_level": 33, "channel_utilization": 12.28, "uptime_seconds": 116657, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 294, "long_name": "Misty Dolphin", "next_hop": 0, "num": "0x4eac10fb", "position": {"altitude": 1088, "latitude": 32.651805, "location_source": "LOC_INTERNAL", "longitude": -107.387043, "time_offset_sec": 555}, "public_key_hex": "cfd1bcc3997c697fd1aae728d1d014dd908370618aadfb2691f4cc81d5472aef", "role": "CLIENT", "short_name": "MNTH", "snr": 4.8, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.875, "battery_level": 94, "channel_utilization": 6.68, "uptime_seconds": 2941, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1355, "long_name": "Sky Crow", "next_hop": 149, "num": "0x4ec7fd10", "position": {"altitude": 1315, "latitude": 33.675215, "location_source": "LOC_INTERNAL", "longitude": -106.679484, "time_offset_sec": 1441}, "public_key_hex": "683bab83ee90f90d6b6d31e815c81d2c0c7ab4716b52c55c3ea3955af9d41289", "role": "CLIENT", "short_name": "SUUX", "snr": 11.86, "status": null, "telemetry": {"air_util_tx": 0.487, "battery_level": 67, "channel_utilization": 10.65, "uptime_seconds": 226192, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4645, "long_name": "Old Adder", "next_hop": 112, "num": "0x4ef2740e", "position": {"altitude": 1203, "latitude": 32.652382, "location_source": "LOC_INTERNAL", "longitude": -107.870719, "time_offset_sec": 4733}, "public_key_hex": "958f132baa8508938cef2e6faae13239fc88b4221bdbf798c03c1a828723b854", "role": "CLIENT", "short_name": "OWC0", "snr": 9.48, "status": null, "telemetry": {"air_util_tx": 1.314, "battery_level": 58, "channel_utilization": 10.71, "uptime_seconds": 29037, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.82, "iaq": 84, "relative_humidity": 36.28, "temperature": 19.09}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2138, "long_name": "Desert Cougar", "next_hop": 0, "num": "0x4f041167", "position": {"altitude": 1285, "latitude": 33.552584, "location_source": "LOC_INTERNAL", "longitude": -107.661927, "time_offset_sec": 2434}, "public_key_hex": "9d53cde1ad927600ee10d29f5efbfdb043b6d5b868b54e22a04f80f00d005450", "role": "CLIENT", "short_name": "DYAT", "snr": 10.38, "status": null, "telemetry": {"air_util_tx": 0.93, "battery_level": 37, "channel_utilization": 28.77, "uptime_seconds": 15960, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4312, "long_name": "Green Sage", "next_hop": 24, "num": "0x4f34e85a", "position": {"altitude": 1386, "latitude": 32.882096, "location_source": "LOC_INTERNAL", "longitude": -107.58494, "time_offset_sec": 4416}, "public_key_hex": "c926493d77690e32adf0b053289909bba0b5a475430f382fd8b8c401e6eca570", "role": "CLIENT", "short_name": "G7EQ", "snr": 3.81, "status": null, "telemetry": {"air_util_tx": 0.028, "battery_level": 14, "channel_utilization": 21.32, "uptime_seconds": 10719, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1005.45, "iaq": 64, "relative_humidity": 38.17, "temperature": 25.58}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 18530, "long_name": "Frosty Hare AE4NN", "next_hop": 0, "num": "0x4f425d70", "position": {"altitude": 1329, "latitude": 32.655709, "location_source": "LOC_INTERNAL", "longitude": -107.258867, "time_offset_sec": 18783}, "public_key_hex": "174cb546980d8847774bc31b4e2c7eb298c28c95983aea6fa28e7321afd7e21a", "role": "ROUTER", "short_name": "🦉", "snr": 3.83, "status": null, "telemetry": {"air_util_tx": 0.703, "battery_level": 97, "channel_utilization": 34.36, "uptime_seconds": 247104, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1670, "long_name": "Happy Bluff", "next_hop": 0, "num": "0x4f4915f7", "position": {"altitude": 1277, "latitude": 32.500731, "location_source": "LOC_INTERNAL", "longitude": -107.448158, "time_offset_sec": 1868}, "public_key_hex": "0930b95e467432a8f81a6bc70fe41366262fd4d5506649ea3ba7b201bb7ad468", "role": "CLIENT", "short_name": "🌵", "snr": 10.0, "status": null, "telemetry": {"air_util_tx": 0.961, "battery_level": 21, "channel_utilization": 13.78, "uptime_seconds": 144652, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2963, "long_name": "Silver Aspen", "next_hop": 0, "num": "0x4f6622da", "position": null, "public_key_hex": "053b9411505d8519743b6d745a7260cc4621f16e73c9865d74b7bf1be43445a9", "role": "TRACKER", "short_name": "S2LT", "snr": 5.8, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.02, "battery_level": 17, "channel_utilization": 7.22, "uptime_seconds": 61774, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 6705, "long_name": "Howling Moose", "next_hop": 0, "num": "0x4f78eff9", "position": {"altitude": 1207, "latitude": 33.286023, "location_source": "LOC_INTERNAL", "longitude": -105.884364, "time_offset_sec": 6778}, "public_key_hex": "734c93ca972ab31c3011dce889c94070620f3e6b6904a031812d9e84e4c717b3", "role": "CLIENT", "short_name": "H8ES", "snr": 11.97, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.418, "battery_level": 15, "channel_utilization": 14.73, "uptime_seconds": 7697, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2431, "long_name": "Silver Hare", "next_hop": 0, "num": "0x4f85c7cc", "position": {"altitude": 1016, "latitude": 33.32923, "location_source": "LOC_INTERNAL", "longitude": -107.827843, "time_offset_sec": 2513}, "public_key_hex": "a59e448ad820c9372ba9b0f908550a1809d9f5ef6f09cae91660c6f6203de695", "role": "CLIENT", "short_name": "SD4Q", "snr": -1.91, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.638, "battery_level": 79, "channel_utilization": 7.61, "uptime_seconds": 22419, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1236, "long_name": "Fast Elk", "next_hop": 1, "num": "0x4f9108ed", "position": {"altitude": 1377, "latitude": 33.039632, "location_source": "LOC_INTERNAL", "longitude": -107.020682, "time_offset_sec": 1532}, "public_key_hex": "f746911dc97e7ead7ee0f81808e7ad9589885e69ec53e38ace7fd9d55944c7e8", "role": "CLIENT_HIDDEN", "short_name": "FDJN", "snr": 2.27, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 3871, "long_name": "Tiny Moose", "next_hop": 88, "num": "0x4f93a542", "position": {"altitude": 1501, "latitude": 33.187624, "location_source": "LOC_INTERNAL", "longitude": -106.543661, "time_offset_sec": 3899}, "public_key_hex": "51a48510681b7cb7ab13330a18b17d8fd0f44987b0c3b35f900ecdc292bec7a1", "role": "CLIENT", "short_name": "🐺", "snr": 3.61, "status": null, "telemetry": {"air_util_tx": 0.328, "battery_level": 71, "channel_utilization": 0.98, "uptime_seconds": 111365, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2107, "long_name": "Silver Aspen", "next_hop": 152, "num": "0x4fb5c549", "position": {"altitude": 1502, "latitude": 33.230336, "location_source": "LOC_INTERNAL", "longitude": -106.873568, "time_offset_sec": 2180}, "public_key_hex": "af48ee7972dbb1c023869a71d78a68d34cd0fdd9b50b6edc19f4c4a09371cb89", "role": "CLIENT", "short_name": "SXRB", "snr": 7.05, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.166, "battery_level": 80, "channel_utilization": 8.52, "uptime_seconds": 65134, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.97, "iaq": 74, "relative_humidity": 62.11, "temperature": 26.56}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1268, "long_name": "Desert Pony", "next_hop": 187, "num": "0x4fd1d35d", "position": {"altitude": 1094, "latitude": 33.281746, "location_source": "LOC_INTERNAL", "longitude": -107.699177, "time_offset_sec": 1442}, "public_key_hex": "4894dadc08727c6f8c68fc16762d15c88160a48a37df3d35c5afcceb37c4514c", "role": "CLIENT", "short_name": "🐝", "snr": 1.83, "status": null, "telemetry": {"air_util_tx": 0.332, "battery_level": 21, "channel_utilization": 22.58, "uptime_seconds": 196189, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1400, "long_name": "Whispering Hawk", "next_hop": 0, "num": "0x4ff31430", "position": {"altitude": 1441, "latitude": 33.896317, "location_source": "LOC_INTERNAL", "longitude": -107.298196, "time_offset_sec": 1565}, "public_key_hex": "5fb5f3a959a44fd49415f157c9dcc360652e87c8ac7f56ecfd3db5d6aa3b3853", "role": "CLIENT", "short_name": "WWH8", "snr": 2.52, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4175, "long_name": "Howling Raven", "next_hop": 0, "num": "0x500bc352", "position": {"altitude": 1235, "latitude": 33.318372, "location_source": "LOC_INTERNAL", "longitude": -106.336126, "time_offset_sec": 4317}, "public_key_hex": "", "role": "CLIENT", "short_name": "H3PU", "snr": 2.31, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 7899, "long_name": "Bright Yucca", "next_hop": 0, "num": "0x501564a4", "position": {"altitude": 1818, "latitude": 32.289691, "location_source": "LOC_INTERNAL", "longitude": -107.157217, "time_offset_sec": 8086}, "public_key_hex": "b507bb82b58208a0f142b4cc2c6bb5f7d8185414bc59b0903e6345983c1684a5", "role": "CLIENT", "short_name": "BAWG", "snr": 10.85, "status": null, "telemetry": {"air_util_tx": 0.495, "battery_level": 20, "channel_utilization": 10.01, "uptime_seconds": 93036, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.29, "iaq": 88, "relative_humidity": 54.37, "temperature": 24.4}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 174, "long_name": "Shady Pine", "next_hop": 171, "num": "0x50261e4a", "position": null, "public_key_hex": "b74f477fde6f01921a9a6ec6eff91be6527f1466d2f2b08e24685be59147084b", "role": "TAK", "short_name": "SSSB", "snr": 6.31, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.8, "battery_level": 17, "channel_utilization": 6.99, "uptime_seconds": 288833, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 12503, "long_name": "Dawn Mamba", "next_hop": 0, "num": "0x502906dd", "position": {"altitude": 1444, "latitude": 33.545069, "location_source": "LOC_INTERNAL", "longitude": -106.842301, "time_offset_sec": 12655}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦇", "snr": 6.2, "status": null, "telemetry": {"air_util_tx": 0.07, "battery_level": 101, "channel_utilization": 18.36, "uptime_seconds": 33644, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2621, "long_name": "Desert Phoenix", "next_hop": 155, "num": "0x50442e0e", "position": {"altitude": 1406, "latitude": 33.17118, "location_source": "LOC_INTERNAL", "longitude": -106.705919, "time_offset_sec": 2632}, "public_key_hex": "55656bb08c7f771c784d69d2b616eb6129a7c92b2d3a899dfe9e1bec8f25ec58", "role": "CLIENT", "short_name": "DXHD", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.7, "battery_level": 100, "channel_utilization": 5.16, "uptime_seconds": 43083, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 3636, "long_name": "Sky Crane", "next_hop": 0, "num": "0x50575d92", "position": {"altitude": 791, "latitude": 33.067939, "location_source": "LOC_INTERNAL", "longitude": -107.617128, "time_offset_sec": 3850}, "public_key_hex": "9835a31f15f84d59555d2d52c8a3cd2aa08d750b5f7b9c2a7ed04d04d0ca130c", "role": "CLIENT", "short_name": "SX5A", "snr": -2.34, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": true}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3130, "long_name": "Frozen Pine", "next_hop": 0, "num": "0x507d1f3a", "position": {"altitude": 1184, "latitude": 33.026341, "location_source": "LOC_INTERNAL", "longitude": -108.322809, "time_offset_sec": 3331}, "public_key_hex": "3a0813e99b39ed06d1060bbe1a901da0a80d86da801ea193cdfaabc0da954bae", "role": "CLIENT_MUTE", "short_name": "FRO9", "snr": 9.47, "status": null, "telemetry": {"air_util_tx": 0.269, "battery_level": 18, "channel_utilization": 6.42, "uptime_seconds": 25904, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 714, "long_name": "New Elk", "next_hop": 52, "num": "0x50d659a7", "position": {"altitude": 1107, "latitude": 33.420775, "location_source": "LOC_INTERNAL", "longitude": -107.333191, "time_offset_sec": 932}, "public_key_hex": "3214d32114c99775a1b89ae981f9af31b82c12c288f81151d9851140342af146", "role": "CLIENT_HIDDEN", "short_name": "NZ0S", "snr": 7.09, "status": {"status": "no-gps"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 8255, "long_name": "Iron Tortoise", "next_hop": 80, "num": "0x50ff0ee3", "position": {"altitude": 1200, "latitude": 32.436743, "location_source": "LOC_INTERNAL", "longitude": -106.857077, "time_offset_sec": 8310}, "public_key_hex": "6962b66d88b0ad2a997a63dfc0b90f9f387075c45af67665ceabd6dcf353f5bc", "role": "CLIENT", "short_name": "IXYT", "snr": 0.84, "status": null, "telemetry": {"air_util_tx": 0.289, "battery_level": 100, "channel_utilization": 15.38, "uptime_seconds": 11094, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 156, "long_name": "Misty Raven", "next_hop": 0, "num": "0x51220f56", "position": {"altitude": 1491, "latitude": 32.078451, "location_source": "LOC_INTERNAL", "longitude": -106.991603, "time_offset_sec": 398}, "public_key_hex": "", "role": "SENSOR", "short_name": "MDAK", "snr": 6.05, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK3312", "last_heard_offset_sec": 2271, "long_name": "Blue Bluff", "next_hop": 32, "num": "0x51364304", "position": {"altitude": 1192, "latitude": 32.194736, "location_source": "LOC_INTERNAL", "longitude": -107.285174, "time_offset_sec": 2470}, "public_key_hex": "cba334c7eb832d2d563462990d226966dd4f6d8ed8230b7aacd914d3760884a5", "role": "SENSOR", "short_name": "BL0A", "snr": 7.95, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 4020, "long_name": "Tall Juniper", "next_hop": 0, "num": "0x51c3e083", "position": {"altitude": 1407, "latitude": 33.417959, "location_source": "LOC_INTERNAL", "longitude": -107.342862, "time_offset_sec": 4207}, "public_key_hex": "19a9adb073e8de6fd1d671b940330e67b61480de8777a03ece6bca3136558351", "role": "CLIENT", "short_name": "TGA5", "snr": 3.12, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1091, "long_name": "Giant Sage", "next_hop": 0, "num": "0x524280c8", "position": null, "public_key_hex": "01cc3f591b63c42906e67055eb9377fd3502e8c2a4cfc5199d2f9f7f1b36b786", "role": "CLIENT", "short_name": "G92A", "snr": 4.16, "status": null, "telemetry": {"air_util_tx": 0.908, "battery_level": 88, "channel_utilization": 9.06, "uptime_seconds": 13310, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2328, "long_name": "Slow Bear", "next_hop": 0, "num": "0x52539631", "position": {"altitude": 1273, "latitude": 33.194567, "location_source": "LOC_INTERNAL", "longitude": -107.968456, "time_offset_sec": 2484}, "public_key_hex": "48d789eec979750ef9e88f257bf37fa4a59bfed843520b764c7e3fdedd617dcb", "role": "LOST_AND_FOUND", "short_name": "SZBW", "snr": 2.19, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2405, "long_name": "Slow Eagle", "next_hop": 254, "num": "0x528faf48", "position": {"altitude": 1220, "latitude": 33.180302, "location_source": "LOC_INTERNAL", "longitude": -106.833152, "time_offset_sec": 2593}, "public_key_hex": "998c06b36d674e4384e140c223144dc0f14dda38b6df661de71bc3e41ed71d7f", "role": "CLIENT", "short_name": "🦋", "snr": 0.13, "status": null, "telemetry": {"air_util_tx": 0.498, "battery_level": 69, "channel_utilization": 18.0, "uptime_seconds": 93967, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2794, "long_name": "Short Trout", "next_hop": 198, "num": "0x52a800c9", "position": {"altitude": 1370, "latitude": 33.793797, "location_source": "LOC_INTERNAL", "longitude": -107.806129, "time_offset_sec": 2803}, "public_key_hex": "599c15b555373ec41b20c4d92c5a4ce407571ba8dcdf213c7f1acd3f878c27a6", "role": "CLIENT", "short_name": "S9PV", "snr": 9.49, "status": null, "telemetry": {"air_util_tx": 0.936, "battery_level": 91, "channel_utilization": 7.99, "uptime_seconds": 53381, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3634, "long_name": "Steel Bluff", "next_hop": 0, "num": "0x52cdd711", "position": {"altitude": 1517, "latitude": 33.048432, "location_source": "LOC_INTERNAL", "longitude": -107.676611, "time_offset_sec": 3878}, "public_key_hex": "", "role": "CLIENT", "short_name": "S311", "snr": 10.57, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 373, "long_name": "Bright Oak", "next_hop": 0, "num": "0x53626761", "position": {"altitude": 1330, "latitude": 33.606846, "location_source": "LOC_INTERNAL", "longitude": -107.633076, "time_offset_sec": 584}, "public_key_hex": "d2e51268a1ddd3fc071b3ce51e24099090dcbff6004e98bcd411813ccde5d1fe", "role": "CLIENT", "short_name": "B9P7", "snr": 5.72, "status": null, "telemetry": {"air_util_tx": 0.464, "battery_level": 92, "channel_utilization": 14.04, "uptime_seconds": 97231, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1024.32, "iaq": 69, "relative_humidity": 22.11, "temperature": 21.5}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3936, "long_name": "Sunny Fox", "next_hop": 0, "num": "0x538b628f", "position": {"altitude": 1212, "latitude": 32.653969, "location_source": "LOC_INTERNAL", "longitude": -106.949747, "time_offset_sec": 4068}, "public_key_hex": "d8ef38984a448b63675a4b807e3d7d2b4d493e3e308a1eb60c5da077731297c8", "role": "CLIENT", "short_name": "SZOR", "snr": 6.99, "status": null, "telemetry": {"air_util_tx": 1.383, "battery_level": 76, "channel_utilization": 4.3, "uptime_seconds": 120766, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.95, "iaq": 61, "relative_humidity": 81.01, "temperature": 31.25}, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 742, "long_name": "Smooth Badger", "next_hop": 236, "num": "0x53964a55", "position": {"altitude": 1395, "latitude": 33.055944, "location_source": "LOC_INTERNAL", "longitude": -107.288092, "time_offset_sec": 873}, "public_key_hex": "d1152d9b66bd83b182444382cc7ca2fa0485806732c3f66d14e21f32f511357a", "role": "CLIENT", "short_name": "SFQ9", "snr": 5.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 1453, "long_name": "Iron Badger", "next_hop": 235, "num": "0x53a8b68c", "position": {"altitude": 1460, "latitude": 33.852561, "location_source": "LOC_INTERNAL", "longitude": -107.892376, "time_offset_sec": 1741}, "public_key_hex": "05d7390e65740131939832a128882c12dc553681b041721cb25c04d850f81c63", "role": "ROUTER", "short_name": "🐺", "snr": 2.78, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.13, "battery_level": 27, "channel_utilization": 20.22, "uptime_seconds": 22833, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2459, "long_name": "Gold Marmot", "next_hop": 0, "num": "0x53c32247", "position": {"altitude": 1626, "latitude": 34.212473, "location_source": "LOC_INTERNAL", "longitude": -107.494549, "time_offset_sec": 2744}, "public_key_hex": "95e3a213a8cccb5f7d4afa40661f7458518e034cc61993df7b666fbb16760730", "role": "SENSOR", "short_name": "GGF8", "snr": 9.64, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.041, "battery_level": 77, "channel_utilization": 13.11, "uptime_seconds": 270809, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 51, "long_name": "Sunny Lion", "next_hop": 0, "num": "0x542ca64d", "position": null, "public_key_hex": "ebedcf2c40aa21002aa2b09f550210eea995ea67494584363a665a8bd50cb99a", "role": "CLIENT", "short_name": "SBM7", "snr": 12.0, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 2372, "long_name": "Sneaky Wolf", "next_hop": 0, "num": "0x544d9be9", "position": {"altitude": 1901, "latitude": 32.689092, "location_source": "LOC_INTERNAL", "longitude": -107.699539, "time_offset_sec": 2437}, "public_key_hex": "a47c88c828854ecf534f0287ef363a00996cb0b1a22e21a9bef99d1f077ca5c5", "role": "CLIENT", "short_name": "S2TZ", "snr": 7.29, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.144, "battery_level": 56, "channel_utilization": 5.33, "uptime_seconds": 1200, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3017, "long_name": "Bright Cactus", "next_hop": 85, "num": "0x546b0462", "position": {"altitude": 1079, "latitude": 34.023051, "location_source": "LOC_INTERNAL", "longitude": -107.128097, "time_offset_sec": 3227}, "public_key_hex": "fd30a53b1d9c7d2232ba46982b702dcd40cde9af70e04370be195ffb4374e356", "role": "ROUTER", "short_name": "B7VC", "snr": 8.8, "status": null, "telemetry": {"air_util_tx": 0.215, "battery_level": 17, "channel_utilization": 36.7, "uptime_seconds": 79532, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": {"barometric_pressure": 1011.3, "iaq": 83, "relative_humidity": 60.63, "temperature": 28.98}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1822, "long_name": "Rough Mesa", "next_hop": 86, "num": "0x547f627d", "position": {"altitude": 1265, "latitude": 32.840509, "location_source": "LOC_INTERNAL", "longitude": -107.269821, "time_offset_sec": 1906}, "public_key_hex": "44ee7074871cd2a764e32ec64a0753b16404b64ebacd0db07093c77899a0c095", "role": "CLIENT", "short_name": "R12L", "snr": 11.78, "status": null, "telemetry": {"air_util_tx": 1.291, "battery_level": 28, "channel_utilization": 6.23, "uptime_seconds": 132410, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.45, "iaq": 58, "relative_humidity": 42.05, "temperature": 23.26}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 17129, "long_name": "Silent Cedar", "next_hop": 0, "num": "0x5482d10a", "position": {"altitude": 1514, "latitude": 32.476492, "location_source": "LOC_INTERNAL", "longitude": -108.194037, "time_offset_sec": 17202}, "public_key_hex": "1354b5b9a9eb2302351c5fcb339ef37c2329a6141996e134b3c90539f4afc62a", "role": "CLIENT", "short_name": "SV1N", "snr": 2.73, "status": null, "telemetry": {"air_util_tx": 0.663, "battery_level": 75, "channel_utilization": 5.71, "uptime_seconds": 1645, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 4183, "long_name": "Rough Iguana NM9PE", "next_hop": 0, "num": "0x54971759", "position": {"altitude": 1248, "latitude": 32.563266, "location_source": "LOC_INTERNAL", "longitude": -106.272249, "time_offset_sec": 4356}, "public_key_hex": "f3a671c5fcab49bb7c0632382b31b3ee68be985e545004aa75581bea93c7d4eb", "role": "CLIENT", "short_name": "RFQS", "snr": 0.92, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.212, "battery_level": 73, "channel_utilization": 9.88, "uptime_seconds": 388155, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 2999, "long_name": "Tiny Marmot", "next_hop": 0, "num": "0x54b1b1a0", "position": null, "public_key_hex": "", "role": "ROUTER", "short_name": "TPMQ", "snr": 8.85, "status": null, "telemetry": {"air_util_tx": 0.355, "battery_level": 51, "channel_utilization": 20.16, "uptime_seconds": 28306, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 10135, "long_name": "Lost Pony", "next_hop": 0, "num": "0x54e89bf8", "position": {"altitude": 1230, "latitude": 32.715435, "location_source": "LOC_INTERNAL", "longitude": -107.571023, "time_offset_sec": 10385}, "public_key_hex": "124ac331153c8766bdb0f5c34b71eebd07f62a0ecb1af043cb2a5df5a7a22a85", "role": "ROUTER_LATE", "short_name": "LWV3", "snr": 4.46, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.664, "battery_level": 65, "channel_utilization": 8.02, "uptime_seconds": 176004, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 74, "long_name": "Tiny Beaver", "next_hop": 206, "num": "0x54f1008f", "position": {"altitude": 1354, "latitude": 31.979951, "location_source": "LOC_INTERNAL", "longitude": -106.979355, "time_offset_sec": 213}, "public_key_hex": "1962a9a9e5e120fc6610bc2cb98f33b2923b116a458399601bb9e3403bf46ad8", "role": "CLIENT", "short_name": "TT37", "snr": 7.26, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.295, "battery_level": 40, "channel_utilization": 8.5, "uptime_seconds": 17099, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3013, "long_name": "Copper Cedar", "next_hop": 98, "num": "0x5503f723", "position": {"altitude": 1270, "latitude": 32.695987, "location_source": "LOC_INTERNAL", "longitude": -107.022199, "time_offset_sec": 3139}, "public_key_hex": "b5b813532b1042ded55ce69da664e091f372109462aa9e2b8e2f2e1b8256d043", "role": "CLIENT", "short_name": "CWZY", "snr": 6.55, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.515, "battery_level": 101, "channel_utilization": 5.99, "uptime_seconds": 15210, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1011.34, "iaq": 70, "relative_humidity": 36.93, "temperature": 20.81}, "hops_away": 2, "hw_model": "THINKNODE_M6", "last_heard_offset_sec": 4975, "long_name": "Copper Eagle", "next_hop": 11, "num": "0x551c6976", "position": {"altitude": 1589, "latitude": 32.495876, "location_source": "LOC_INTERNAL", "longitude": -106.869464, "time_offset_sec": 5242}, "public_key_hex": "fc5c4eb1638cb0c844881d78c75b9b27b3b34d626400e28dc7fe170bfb422ae8", "role": "CLIENT", "short_name": "CGA8", "snr": 2.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 6921, "long_name": "Frozen Crane", "next_hop": 0, "num": "0x553452b0", "position": {"altitude": 1102, "latitude": 33.117968, "location_source": "LOC_INTERNAL", "longitude": -107.151655, "time_offset_sec": 7069}, "public_key_hex": "6eb64c09c12f7f9c5db2931ca700da2396216f4971c1062f28489dcfb1ecd74f", "role": "CLIENT", "short_name": "FM09", "snr": 4.57, "status": null, "telemetry": {"air_util_tx": 0.467, "battery_level": 12, "channel_utilization": 13.47, "uptime_seconds": 127126, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 2719, "long_name": "Storm Beaver", "next_hop": 223, "num": "0x555b5d8f", "position": {"altitude": 962, "latitude": 32.580082, "location_source": "LOC_INTERNAL", "longitude": -107.249367, "time_offset_sec": 2770}, "public_key_hex": "89d1b894c150598c73f0eb92d9d2da4ac33a0a965f103d2b3773cae4c913f8f0", "role": "CLIENT", "short_name": "SSKI", "snr": 8.36, "status": null, "telemetry": {"air_util_tx": 0.592, "battery_level": 41, "channel_utilization": 8.75, "uptime_seconds": 67735, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1011.76, "iaq": 66, "relative_humidity": 49.97, "temperature": 20.67}, "hops_away": 3, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1456, "long_name": "Sleepy Bison", "next_hop": 80, "num": "0x5564bdae", "position": {"altitude": 1329, "latitude": 32.668654, "location_source": "LOC_INTERNAL", "longitude": -107.404016, "time_offset_sec": 1614}, "public_key_hex": "5721e364e74d96bc62930c4e7863dddf77142e898c94bd829c21a9254a9a6a80", "role": "CLIENT", "short_name": "SFVE", "snr": 5.97, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.322, "battery_level": 48, "channel_utilization": 9.67, "uptime_seconds": 84021, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2645, "long_name": "Hidden Phoenix", "next_hop": 0, "num": "0x558604e7", "position": {"altitude": 1250, "latitude": 32.468906, "location_source": "LOC_INTERNAL", "longitude": -107.796766, "time_offset_sec": 2748}, "public_key_hex": "0ba59ad9022f9b64fc412f3f8c273efdc4bd11a05c1960ba7e9a2c8ce565adc9", "role": "CLIENT", "short_name": "HWL6", "snr": -1.78, "status": null, "telemetry": {"air_util_tx": 0.16, "battery_level": 99, "channel_utilization": 7.84, "uptime_seconds": 77282, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": {"barometric_pressure": 1001.8, "iaq": 45, "relative_humidity": 37.52, "temperature": 6.95}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 7291, "long_name": "Black Beaver", "next_hop": 0, "num": "0x55966bdd", "position": {"altitude": 1301, "latitude": 32.520932, "location_source": "LOC_INTERNAL", "longitude": -107.681849, "time_offset_sec": 7377}, "public_key_hex": "381bc0c585971f30893c106365c9a6c655a1dacaf3b7e2785f03e58aaaca0230", "role": "CLIENT", "short_name": "BK1C", "snr": 1.28, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.908, "battery_level": 94, "channel_utilization": 18.63, "uptime_seconds": 24243, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2753, "long_name": "Burning Whale", "next_hop": 0, "num": "0x55e96e97", "position": {"altitude": 1046, "latitude": 32.800341, "location_source": "LOC_INTERNAL", "longitude": -107.473045, "time_offset_sec": 2930}, "public_key_hex": "2a227496be05c724cd62cfa2f71f6f169915a6a4ab071ee64d9e2deae7f68fe1", "role": "CLIENT", "short_name": "🦉", "snr": 1.24, "status": null, "telemetry": {"air_util_tx": 0.438, "battery_level": 81, "channel_utilization": 8.84, "uptime_seconds": 93916, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2030, "long_name": "Misty Falcon", "next_hop": 0, "num": "0x55f6b3e3", "position": {"altitude": 1130, "latitude": 32.491773, "location_source": "LOC_INTERNAL", "longitude": -107.856424, "time_offset_sec": 2300}, "public_key_hex": "b5885d4f68e0e5887454c086d435118131c3d361f886281b220bd1cdce429815", "role": "CLIENT", "short_name": "M0ZP", "snr": 2.49, "status": null, "telemetry": {"air_util_tx": 1.858, "battery_level": 42, "channel_utilization": 27.31, "uptime_seconds": 133797, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 737, "long_name": "Brave Adder", "next_hop": 0, "num": "0x5605ce8e", "position": {"altitude": 1115, "latitude": 33.258215, "location_source": "LOC_INTERNAL", "longitude": -107.297533, "time_offset_sec": 809}, "public_key_hex": "605c6b8cd133e18e668f9135f3cacca0dd8bef3760e97df84b43e3a9e3338080", "role": "CLIENT", "short_name": "B15F", "snr": 7.47, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "CROWPANEL", "last_heard_offset_sec": 3984, "long_name": "Sleepy Cedar", "next_hop": 202, "num": "0x56087a62", "position": {"altitude": 1289, "latitude": 33.309435, "location_source": "LOC_INTERNAL", "longitude": -107.592266, "time_offset_sec": 3992}, "public_key_hex": "cdf5564fbc68a02223ff9b2bca476e96b8ba6f3c0025f1329b698f486d58d397", "role": "CLIENT", "short_name": "SOD0", "snr": -2.46, "status": null, "telemetry": {"air_util_tx": 0.763, "battery_level": 99, "channel_utilization": 12.53, "uptime_seconds": 112945, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 717, "long_name": "Old Arroyo", "next_hop": 246, "num": "0x56449d70", "position": {"altitude": 1434, "latitude": 33.638324, "location_source": "LOC_INTERNAL", "longitude": -107.511156, "time_offset_sec": 829}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "OI9R", "snr": 6.18, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "CROWPANEL", "last_heard_offset_sec": 128, "long_name": "Stone Mole", "next_hop": 161, "num": "0x5660dcdf", "position": null, "public_key_hex": "59492fc6f572535e61664ad7e34a697754cdd11f552d762add01bf0be1242390", "role": "ROUTER_LATE", "short_name": "SJZL", "snr": 5.12, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 4453, "long_name": "Mountain Colt", "next_hop": 146, "num": "0x56708115", "position": {"altitude": 1284, "latitude": 33.232642, "location_source": "LOC_INTERNAL", "longitude": -107.30594, "time_offset_sec": 4750}, "public_key_hex": "ba734ab47df0c88bf82cf8d46c43458da183fd52fbb284751c71b84d7190519a", "role": "CLIENT", "short_name": "M4RE", "snr": 4.65, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.9, "iaq": 76, "relative_humidity": 49.56, "temperature": 10.78}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6395, "long_name": "Burning Beaver", "next_hop": 0, "num": "0x56876d92", "position": {"altitude": 1455, "latitude": 32.344492, "location_source": "LOC_INTERNAL", "longitude": -107.411135, "time_offset_sec": 6444}, "public_key_hex": "0ffd74fb4b329926d3b43b5764d050cee6187c3b7a2dcb72e6353621bb9dad40", "role": "ROUTER", "short_name": "BG4X", "snr": 6.37, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 798, "long_name": "Short Doe", "next_hop": 0, "num": "0x56b72c44", "position": {"altitude": 1260, "latitude": 33.240503, "location_source": "LOC_INTERNAL", "longitude": -106.977831, "time_offset_sec": 862}, "public_key_hex": "a730c98db3405cf64eb9208f7f5a4e140dfbcd07967c8f22a59943483959cf5b", "role": "CLIENT", "short_name": "S276", "snr": 6.01, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2076, "long_name": "Black Trout", "next_hop": 0, "num": "0x5713444d", "position": null, "public_key_hex": "d8da7fd715020b10c689e6c60042ae641854a2a707b18916cc0b04a84930a6e9", "role": "TRACKER", "short_name": "BRZZ", "snr": 3.05, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 393, "long_name": "Happy Tortoise", "next_hop": 154, "num": "0x57324f5f", "position": {"altitude": 1648, "latitude": 32.724799, "location_source": "LOC_INTERNAL", "longitude": -107.839359, "time_offset_sec": 425}, "public_key_hex": "d5e9929327c94632799ca0db8de13a60021efbb9313774f0ea8bf2a12c2b4942", "role": "CLIENT", "short_name": "H8F4", "snr": 7.76, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.85, "iaq": 49, "relative_humidity": 81.31, "temperature": 26.3}, "hops_away": 3, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 2764, "long_name": "Mountain Fox", "next_hop": 30, "num": "0x5732cc51", "position": {"altitude": 1533, "latitude": 32.814053, "location_source": "LOC_INTERNAL", "longitude": -107.779951, "time_offset_sec": 2873}, "public_key_hex": "96b38588c0bdb053fdc81c955057521030661b8a7aa2fe7ab62c3729e5e05493", "role": "CLIENT", "short_name": "MK4L", "snr": 2.95, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1017.22, "iaq": 73, "relative_humidity": 28.36, "temperature": 24.65}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 830, "long_name": "Roving Badger", "next_hop": 0, "num": "0x5735207d", "position": {"altitude": 1208, "latitude": 33.165406, "location_source": "LOC_INTERNAL", "longitude": -107.352644, "time_offset_sec": 1118}, "public_key_hex": "6afd7e153a9fe0db66dcef190834f85ca586ba0b10748e8b812eb842eb878d1c", "role": "CLIENT", "short_name": "RAWH", "snr": 5.42, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 997, "long_name": "Desert Doe", "next_hop": 39, "num": "0x5741f5f7", "position": null, "public_key_hex": "f3a6d0a6d9fdaadd8b6e3481ab58ea323cf1c60e62d0af661f3d9691ff838000", "role": "CLIENT", "short_name": "DFK0", "snr": 7.13, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.023, "battery_level": 101, "channel_utilization": 4.5, "uptime_seconds": 279995, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 244, "long_name": "Storm Sage", "next_hop": 115, "num": "0x57482a72", "position": null, "public_key_hex": "a63cc013b62fca44b50d3a4086f8eccae1109800f7c8e912cc9304907c35c3b3", "role": "CLIENT", "short_name": "SJ34", "snr": 6.48, "status": null, "telemetry": {"air_util_tx": 0.853, "battery_level": 45, "channel_utilization": 4.74, "uptime_seconds": 12743, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 185, "long_name": "Frosty Pony", "next_hop": 183, "num": "0x574d5dc7", "position": {"altitude": 943, "latitude": 33.024513, "location_source": "LOC_INTERNAL", "longitude": -107.512656, "time_offset_sec": 383}, "public_key_hex": "fa573f62cb53b583ad8753de8820b4171b6eb415b01f180966023a24da7271d2", "role": "CLIENT", "short_name": "FFHJ", "snr": 1.86, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2757, "long_name": "Blue Hare", "next_hop": 139, "num": "0x5787a83e", "position": {"altitude": 1175, "latitude": 33.264148, "location_source": "LOC_INTERNAL", "longitude": -106.94404, "time_offset_sec": 2826}, "public_key_hex": "59fda031c1a137c661c552ca38f6f9665740fce7cd66f31d2ebaef4bd843bbc5", "role": "CLIENT", "short_name": "BPXX", "snr": 9.16, "status": null, "telemetry": {"air_util_tx": 0.875, "battery_level": 19, "channel_utilization": 13.12, "uptime_seconds": 126047, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 9859, "long_name": "Slow Lion", "next_hop": 0, "num": "0x57935eb5", "position": {"altitude": 1617, "latitude": 32.621017, "location_source": "LOC_INTERNAL", "longitude": -107.235457, "time_offset_sec": 9862}, "public_key_hex": "486c5e8abe3d38b17658edc5e282f98c5c48aa6e4d8659deaf0a4c6a94182981", "role": "CLIENT", "short_name": "S5ZB", "snr": 3.42, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.96, "iaq": 70, "relative_humidity": 42.61, "temperature": 25.89}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5305, "long_name": "Red Bass", "next_hop": 0, "num": "0x57e52b2b", "position": {"altitude": 1641, "latitude": 33.615086, "location_source": "LOC_INTERNAL", "longitude": -107.82846, "time_offset_sec": 5578}, "public_key_hex": "8996a91b7d32312744a45f89352288d6489822680a03344869551c68aaaab299", "role": "CLIENT", "short_name": "R1OP", "snr": 11.1, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.25, "iaq": 33, "relative_humidity": 36.48, "temperature": 25.89}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 71, "long_name": "Bright Viper", "next_hop": 0, "num": "0x581365d6", "position": {"altitude": 1759, "latitude": 33.075842, "location_source": "LOC_INTERNAL", "longitude": -107.034245, "time_offset_sec": 186}, "public_key_hex": "b5bcad9630d8306f3698a94363378d6972aa3d1b8a78cac5b24661e3ca3ae305", "role": "CLIENT", "short_name": "BUFO", "snr": 2.5, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.376, "battery_level": 20, "channel_utilization": 8.84, "uptime_seconds": 8650, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 1288, "long_name": "Dusk Heron", "next_hop": 0, "num": "0x581ede75", "position": {"altitude": 1389, "latitude": 33.400436, "location_source": "LOC_INTERNAL", "longitude": -106.655284, "time_offset_sec": 1349}, "public_key_hex": "a25e64b5f5ebe04b4a7225084bcfdcfbf7e9a2d25c3f35d351dbc83a9939e70a", "role": "CLIENT", "short_name": "DWC1", "snr": 2.15, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.5, "iaq": 83, "relative_humidity": 45.73, "temperature": 11.18}, "hops_away": 4, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 1606, "long_name": "Forest Juniper", "next_hop": 249, "num": "0x582c2282", "position": {"altitude": 1362, "latitude": 32.863354, "location_source": "LOC_INTERNAL", "longitude": -107.450821, "time_offset_sec": 1772}, "public_key_hex": "ec7da5763a7395d477cf85824222fd28c91c92dd9e246e53e0815f8fe1319da4", "role": "CLIENT", "short_name": "FM8A", "snr": 3.21, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1677, "long_name": "Iron Falcon", "next_hop": 0, "num": "0x58370fd4", "position": {"altitude": 1779, "latitude": 32.465579, "location_source": "LOC_INTERNAL", "longitude": -108.689543, "time_offset_sec": 1911}, "public_key_hex": "668647cfffefee0866fd705528d6225560b4a2fc3f3d1b3fd16ca958237e7af6", "role": "CLIENT_MUTE", "short_name": "ITZ9", "snr": 11.96, "status": null, "telemetry": {"air_util_tx": 0.596, "battery_level": 101, "channel_utilization": 2.94, "uptime_seconds": 413715, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 501, "long_name": "Sunny Bass", "next_hop": 0, "num": "0x5879d487", "position": {"altitude": 1717, "latitude": 32.59667, "location_source": "LOC_INTERNAL", "longitude": -106.753247, "time_offset_sec": 610}, "public_key_hex": "6f1a9e4b2f3af313c64b73b0b4485d379a36acdc8cedc3dc45768291bf82f673", "role": "CLIENT_MUTE", "short_name": "SKX4", "snr": 5.81, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 56, "long_name": "Wild Gecko", "next_hop": 204, "num": "0x587d06fb", "position": {"altitude": 1245, "latitude": 32.913693, "location_source": "LOC_INTERNAL", "longitude": -107.734641, "time_offset_sec": 81}, "public_key_hex": "cfecc7ee6b5fd7e0469074bc08838fbe6277c3c9fba743b6421690bdbafbdac7", "role": "SENSOR", "short_name": "WNYB", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.8, "iaq": 23, "relative_humidity": 61.75, "temperature": 26.91}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1009, "long_name": "Misty Lion", "next_hop": 191, "num": "0x588515fc", "position": null, "public_key_hex": "c5fbe5d417e35fe1faa00bddfb9eb81e1acb9623a9058ad9764c6efefc042da0", "role": "CLIENT", "short_name": "MDRW", "snr": 6.3, "status": null, "telemetry": {"air_util_tx": 0.493, "battery_level": 37, "channel_utilization": 6.17, "uptime_seconds": 75011, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_R1_NEO", "last_heard_offset_sec": 1979, "long_name": "River Fox", "next_hop": 0, "num": "0x5889d0a1", "position": {"altitude": 1287, "latitude": 33.048162, "location_source": "LOC_INTERNAL", "longitude": -107.106831, "time_offset_sec": 2091}, "public_key_hex": "1d46e885220db62e6bd314f4bc5a775e4dc2929b286fddfe14b1c6770a0f1411", "role": "CLIENT", "short_name": "R9QB", "snr": 6.22, "status": null, "telemetry": {"air_util_tx": 0.249, "battery_level": 78, "channel_utilization": 15.87, "uptime_seconds": 24642, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 1911, "long_name": "Old Salmon", "next_hop": 0, "num": "0x5891f244", "position": {"altitude": 1131, "latitude": 32.284405, "location_source": "LOC_INTERNAL", "longitude": -106.753218, "time_offset_sec": 1937}, "public_key_hex": "9e1680973e66711956d2d0618020a04520a1318e92214bae56fbf709b7331c5b", "role": "CLIENT_HIDDEN", "short_name": "OLER", "snr": 11.04, "status": null, "telemetry": {"air_util_tx": 2.075, "battery_level": 82, "channel_utilization": 11.2, "uptime_seconds": 134897, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4144, "long_name": "Misty Bronco", "next_hop": 249, "num": "0x589e9ff9", "position": {"altitude": 855, "latitude": 33.565373, "location_source": "LOC_INTERNAL", "longitude": -106.939982, "time_offset_sec": 4266}, "public_key_hex": "debfebf4a7272cae5eb4b03e392369b54811faa2681f42adc2b4915f7439a1c9", "role": "CLIENT", "short_name": "M6XO", "snr": -1.83, "status": null, "telemetry": {"air_util_tx": 0.128, "battery_level": 69, "channel_utilization": 21.37, "uptime_seconds": 2133, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1025.58, "iaq": 57, "relative_humidity": 72.03, "temperature": 15.73}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 4194, "long_name": "New Lynx", "next_hop": 0, "num": "0x58b4dd09", "position": null, "public_key_hex": "3eddd61fd402758ed26db87fda90167a22795d0d2d843a462bbb8722ab324429", "role": "CLIENT_MUTE", "short_name": "NUEX", "snr": 4.42, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.017, "battery_level": 18, "channel_utilization": 2.64, "uptime_seconds": 106225, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6516, "long_name": "Misty Whale", "next_hop": 100, "num": "0x58cfa6c8", "position": {"altitude": 1361, "latitude": 33.342746, "location_source": "LOC_INTERNAL", "longitude": -106.455924, "time_offset_sec": 6780}, "public_key_hex": "50a4272641609589640bd1b3abc298461eabb3ba1f3c6e44907ce4ff90f17e9b", "role": "CLIENT", "short_name": "🦌", "snr": 4.11, "status": null, "telemetry": {"air_util_tx": 0.423, "battery_level": 20, "channel_utilization": 1.62, "uptime_seconds": 120887, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1342, "long_name": "Silver Whale", "next_hop": 36, "num": "0x59075efc", "position": {"altitude": 1283, "latitude": 32.359041, "location_source": "LOC_INTERNAL", "longitude": -107.497133, "time_offset_sec": 1597}, "public_key_hex": "b5532ab93ca4b7ead2db5d467e2d913595f85b67423550c562db7778cded9086", "role": "CLIENT", "short_name": "S3A8", "snr": 7.62, "status": null, "telemetry": {"air_util_tx": 0.047, "battery_level": 72, "channel_utilization": 13.75, "uptime_seconds": 79266, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK3401", "last_heard_offset_sec": 829, "long_name": "Happy Bison", "next_hop": 138, "num": "0x593cca09", "position": {"altitude": 1343, "latitude": 33.071902, "location_source": "LOC_INTERNAL", "longitude": -107.216643, "time_offset_sec": 1058}, "public_key_hex": "f670235db6457fde18b2261cfad93a7a5d859dbea5e05303c3914e231dff9b63", "role": "TAK", "short_name": "HTI0", "snr": 9.55, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.394, "battery_level": 87, "channel_utilization": 6.22, "uptime_seconds": 55352, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 827, "long_name": "Old Dolphin", "next_hop": 93, "num": "0x594ab604", "position": {"altitude": 1265, "latitude": 32.619739, "location_source": "LOC_INTERNAL", "longitude": -107.474517, "time_offset_sec": 1065}, "public_key_hex": "2f7011fbae7923d98f9e0ed9082f2c530ea634bb3a6f2e141296bcc74b2d2f99", "role": "ROUTER", "short_name": "🐢", "snr": 0.27, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4346, "long_name": "Iron Squirrel", "next_hop": 0, "num": "0x596d9e71", "position": {"altitude": 1040, "latitude": 33.391699, "location_source": "LOC_INTERNAL", "longitude": -107.105742, "time_offset_sec": 4404}, "public_key_hex": "", "role": "CLIENT", "short_name": "IZ0F", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 2.581, "battery_level": 14, "channel_utilization": 15.22, "uptime_seconds": 47153, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3583, "long_name": "Found Bronco", "next_hop": 0, "num": "0x5984a109", "position": null, "public_key_hex": "978af4e74eddb0cd777b8ed5238b747d33f7e5a517b3de37b04fdc6ffec5a672", "role": "CLIENT_MUTE", "short_name": "FZ1E", "snr": 7.12, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3715, "long_name": "Wandering Fox", "next_hop": 232, "num": "0x59851875", "position": {"altitude": 1802, "latitude": 32.262138, "location_source": "LOC_INTERNAL", "longitude": -107.863409, "time_offset_sec": 3758}, "public_key_hex": "b30b757879565e6b707d52ef0b62998bc9c727a97ae40cb929a62e7533629300", "role": "CLIENT", "short_name": "WTS6", "snr": 3.16, "status": null, "telemetry": {"air_util_tx": 0.525, "battery_level": 101, "channel_utilization": 14.17, "uptime_seconds": 15258, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 553, "long_name": "Soft Adder", "next_hop": 40, "num": "0x598d926d", "position": {"altitude": 1135, "latitude": 33.122241, "location_source": "LOC_INTERNAL", "longitude": -107.805575, "time_offset_sec": 691}, "public_key_hex": "ff2ec3882b898592e54d73b2940ffde18a3125d2e84b85ac1cc2a628a3698431", "role": "CLIENT", "short_name": "S8AF", "snr": 2.33, "status": null, "telemetry": {"air_util_tx": 1.681, "battery_level": 37, "channel_utilization": 5.99, "uptime_seconds": 68947, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.4, "iaq": 2, "relative_humidity": 33.5, "temperature": 17.31}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 74, "long_name": "Old Dolphin", "next_hop": 0, "num": "0x59b69ea8", "position": {"altitude": 1408, "latitude": 33.1674, "location_source": "LOC_INTERNAL", "longitude": -106.528194, "time_offset_sec": 311}, "public_key_hex": "71582ee19cf270425231bbdfc9571e5057a486f57c3f5f73dac22b4b057b6bf3", "role": "CLIENT", "short_name": "OLBU", "snr": 7.59, "status": null, "telemetry": {"air_util_tx": 0.39, "battery_level": 66, "channel_utilization": 15.98, "uptime_seconds": 84566, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 10996, "long_name": "Hidden Cedar", "next_hop": 0, "num": "0x59b6a794", "position": null, "public_key_hex": "5d1fb1ac280fd7129a3a7f2ee5c1e3b625241341130cbb536c3fb32c42e1ccc1", "role": "CLIENT", "short_name": "H2J2", "snr": 4.2, "status": null, "telemetry": {"air_util_tx": 0.597, "battery_level": 42, "channel_utilization": 15.93, "uptime_seconds": 13201, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 10134, "long_name": "Short Crow", "next_hop": 0, "num": "0x5a0ae2b6", "position": {"altitude": 1285, "latitude": 33.26748, "location_source": "LOC_INTERNAL", "longitude": -107.025202, "time_offset_sec": 10279}, "public_key_hex": "82ffa04c0e22c6d4c5ae89796765955a375fa50362ed42f32ae6bb5071ad73cd", "role": "CLIENT", "short_name": "SL2F", "snr": 4.15, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 15221, "long_name": "Howling Badger", "next_hop": 0, "num": "0x5a469f87", "position": {"altitude": 1132, "latitude": 32.801882, "location_source": "LOC_INTERNAL", "longitude": -107.213736, "time_offset_sec": 15332}, "public_key_hex": "0fef0c7b24ee9f77df5dc0a2be8f7cdcba7b6a646dc09bc1760152b7f1d9aa6b", "role": "CLIENT", "short_name": "HLYP", "snr": 2.56, "status": null, "telemetry": {"air_util_tx": 1.092, "battery_level": 59, "channel_utilization": 25.01, "uptime_seconds": 53120, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.13, "iaq": 36, "relative_humidity": 53.07, "temperature": 26.64}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1475, "long_name": "Roving Phoenix", "next_hop": 0, "num": "0x5a5c8280", "position": {"altitude": 1372, "latitude": 32.190534, "location_source": "LOC_INTERNAL", "longitude": -106.708327, "time_offset_sec": 1740}, "public_key_hex": "d6ca15b4b973558de77d927f0729e3d5e2fa72f28102289a9d615de54bb0a6e8", "role": "CLIENT", "short_name": "RE9K", "snr": -2.04, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.726, "battery_level": 71, "channel_utilization": 8.92, "uptime_seconds": 3214, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1343, "long_name": "Lost Pine", "next_hop": 0, "num": "0x5a69f032", "position": null, "public_key_hex": "61de24d2a38635ec50ebe04d8bbef7f536e431be5fcd8f8931453146d11cea98", "role": "SENSOR", "short_name": "LMTQ", "snr": 7.39, "status": null, "telemetry": {"air_util_tx": 0.803, "battery_level": 68, "channel_utilization": 8.27, "uptime_seconds": 208984, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4611, "long_name": "Shady Crow", "next_hop": 0, "num": "0x5a8bdabd", "position": {"altitude": 1247, "latitude": 32.992567, "location_source": "LOC_INTERNAL", "longitude": -106.842752, "time_offset_sec": 4659}, "public_key_hex": "a3a606fd7c0480bbb279a6a98832e6b5a74899b9883fc43c6412b66ba8ab2e6b", "role": "TRACKER", "short_name": "S9R1", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.602, "battery_level": 79, "channel_utilization": 7.79, "uptime_seconds": 10113, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 598, "long_name": "New Juniper", "next_hop": 0, "num": "0x5a9c3537", "position": {"altitude": 1599, "latitude": 33.424301, "location_source": "LOC_INTERNAL", "longitude": -107.174067, "time_offset_sec": 824}, "public_key_hex": "dba46ea170bb576697239f858c3ce8d74ad14574e9207aff98b8b0346b51c731", "role": "TRACKER", "short_name": "NJW2", "snr": 5.58, "status": null, "telemetry": {"air_util_tx": 0.304, "battery_level": 44, "channel_utilization": 18.94, "uptime_seconds": 12878, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4512, "long_name": "Drifting Ridge W53KC", "next_hop": 0, "num": "0x5aad81c6", "position": {"altitude": 1172, "latitude": 32.956759, "location_source": "LOC_INTERNAL", "longitude": -106.874277, "time_offset_sec": 4801}, "public_key_hex": "42350763d9c2cb27fe234b08666845c55b1ae1352fbf4e6ebf31e1ad5d350dad", "role": "TRACKER", "short_name": "DMGB", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1722, "long_name": "Forest Bluff", "next_hop": 124, "num": "0x5adb8550", "position": {"altitude": 1504, "latitude": 32.75746, "location_source": "LOC_INTERNAL", "longitude": -106.623808, "time_offset_sec": 1888}, "public_key_hex": "d28f0b3b1a3f399de227a79ff4e4dd24f294838e66cca0e366641f9516e9b25b", "role": "CLIENT", "short_name": "FX34", "snr": 9.11, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1012.48, "iaq": 38, "relative_humidity": 87.77, "temperature": 32.81}, "hops_away": 5, "hw_model": "RAK4631", "last_heard_offset_sec": 408, "long_name": "Sneaky Pine", "next_hop": 160, "num": "0x5ae64d32", "position": {"altitude": 1301, "latitude": 33.586315, "location_source": "LOC_INTERNAL", "longitude": -108.255553, "time_offset_sec": 628}, "public_key_hex": "1359ac47a3a60b9ebe8cd3c388bfa02172239b5512ab9966ee63f8198fd9e298", "role": "CLIENT", "short_name": "S44F", "snr": 8.56, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.816, "battery_level": 62, "channel_utilization": 8.57, "uptime_seconds": 8787, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 781, "long_name": "Smooth Stag", "next_hop": 0, "num": "0x5af3db75", "position": {"altitude": 1675, "latitude": 33.62572, "location_source": "LOC_INTERNAL", "longitude": -107.076082, "time_offset_sec": 913}, "public_key_hex": "8c53b87ab5f0029e6748680ff3ed17c50545d5b503a56384478bb003d256a404", "role": "CLIENT", "short_name": "SZGI", "snr": 7.01, "status": null, "telemetry": {"air_util_tx": 0.325, "battery_level": 61, "channel_utilization": 14.58, "uptime_seconds": 96065, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.83, "iaq": 53, "relative_humidity": 69.26, "temperature": 29.14}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7342, "long_name": "Sneaky Crane", "next_hop": 0, "num": "0x5af84331", "position": {"altitude": 764, "latitude": 33.293597, "location_source": "LOC_INTERNAL", "longitude": -107.129529, "time_offset_sec": 7605}, "public_key_hex": "b9e1d1092d0e5e163b2b9a82e902e6369a2c683bd98ead045e10b5692f4a654a", "role": "CLIENT", "short_name": "S62W", "snr": 1.08, "status": null, "telemetry": {"air_util_tx": 0.158, "battery_level": 41, "channel_utilization": 3.22, "uptime_seconds": 37304, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 1536, "long_name": "Dawn Pony", "next_hop": 0, "num": "0x5b020121", "position": {"altitude": 1590, "latitude": 33.050948, "location_source": "LOC_INTERNAL", "longitude": -107.412371, "time_offset_sec": 1750}, "public_key_hex": "6f71cc1d3ecb74a4bb44dd920cfb702dcd2ff9c11285278a6bbd10bb4e99a66a", "role": "CLIENT", "short_name": "🦋", "snr": 1.94, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.14, "battery_level": 77, "channel_utilization": 11.48, "uptime_seconds": 57846, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 4138, "long_name": "Drowsy Hare", "next_hop": 0, "num": "0x5b1555ba", "position": {"altitude": 1123, "latitude": 33.769503, "location_source": "LOC_INTERNAL", "longitude": -107.032568, "time_offset_sec": 4371}, "public_key_hex": "d1c4b065f452090b3c87dd1c261843030e0a004ccacf7c7236fb610a946b5d9c", "role": "CLIENT", "short_name": "D9XZ", "snr": 2.64, "status": null, "telemetry": {"air_util_tx": 0.016, "battery_level": 54, "channel_utilization": 3.72, "uptime_seconds": 77371, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3177, "long_name": "Wild Coyote N51NR", "next_hop": 104, "num": "0x5b414a95", "position": {"altitude": 1174, "latitude": 33.757613, "location_source": "LOC_INTERNAL", "longitude": -107.771211, "time_offset_sec": 3265}, "public_key_hex": "e625891edc183dd2de2a96ff9e41c537928e5733cf95296ce514ee19bb8968e6", "role": "CLIENT_MUTE", "short_name": "WX1F", "snr": 2.03, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 2664, "long_name": "Frozen Crane", "next_hop": 0, "num": "0x5b4b7b7c", "position": {"altitude": 1478, "latitude": 33.257914, "location_source": "LOC_INTERNAL", "longitude": -107.158676, "time_offset_sec": 2961}, "public_key_hex": "9e411987824c3fb5f496c6559f193059e25232904d8cc4993afe7638053d0263", "role": "CLIENT", "short_name": "🌵", "snr": 11.14, "status": null, "telemetry": {"air_util_tx": 0.556, "battery_level": 36, "channel_utilization": 6.19, "uptime_seconds": 56929, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1030, "long_name": "Drifting Hawk", "next_hop": 0, "num": "0x5b92d506", "position": {"altitude": 1711, "latitude": 33.104894, "location_source": "LOC_INTERNAL", "longitude": -106.704984, "time_offset_sec": 1271}, "public_key_hex": "4fc9412f20fb63cc8fff3453436518a72300f86673a32bab4906a7b031615eda", "role": "CLIENT", "short_name": "DSMM", "snr": 7.27, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 753, "long_name": "Loud Iguana", "next_hop": 0, "num": "0x5b997520", "position": {"altitude": 1492, "latitude": 32.986095, "location_source": "LOC_INTERNAL", "longitude": -107.101516, "time_offset_sec": 801}, "public_key_hex": "595762abe9be57793747982badf7b299408fd17c1fc6c7ed9ae5f1ef371c02d0", "role": "TRACKER", "short_name": "🦊", "snr": 4.31, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 529, "long_name": "Canyon Juniper", "next_hop": 254, "num": "0x5bc287b9", "position": {"altitude": 1546, "latitude": 32.201546, "location_source": "LOC_INTERNAL", "longitude": -107.019783, "time_offset_sec": 712}, "public_key_hex": "", "role": "CLIENT", "short_name": "CRVU", "snr": 6.39, "status": null, "telemetry": {"air_util_tx": 1.203, "battery_level": 44, "channel_utilization": 12.02, "uptime_seconds": 24904, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2249, "long_name": "Steel Seal", "next_hop": 32, "num": "0x5bca787a", "position": {"altitude": 1418, "latitude": 32.542471, "location_source": "LOC_INTERNAL", "longitude": -106.748614, "time_offset_sec": 2307}, "public_key_hex": "c5924bda9c4d808e0e6403befac77051a1cab10aa660d56dfac13f5c55a35dc2", "role": "ROUTER", "short_name": "🐝", "snr": 6.21, "status": null, "telemetry": {"air_util_tx": 0.604, "battery_level": 56, "channel_utilization": 16.77, "uptime_seconds": 12015, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 506, "long_name": "Green Juniper AE1UV", "next_hop": 0, "num": "0x5c2b8e76", "position": {"altitude": 1141, "latitude": 32.549731, "location_source": "LOC_INTERNAL", "longitude": -106.910031, "time_offset_sec": 603}, "public_key_hex": "965949afde4328a8158d8884010a5b788113757aa7321813ee27734bc243890a", "role": "ROUTER_LATE", "short_name": "GFE0", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 5453, "long_name": "Rough Bear", "next_hop": 0, "num": "0x5c3a716d", "position": {"altitude": 761, "latitude": 32.926344, "location_source": "LOC_INTERNAL", "longitude": -107.568344, "time_offset_sec": 5542}, "public_key_hex": "4e5af148a46b78eefaf3a2e3f38f9d660c3b60905eac4f51aa976c4448933acd", "role": "ROUTER", "short_name": "RDGF", "snr": 0.86, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 6414, "long_name": "Fast Doe", "next_hop": 115, "num": "0x5cc9256f", "position": {"altitude": 1142, "latitude": 33.446002, "location_source": "LOC_INTERNAL", "longitude": -107.342053, "time_offset_sec": 6433}, "public_key_hex": "eaf9a2832be46706c9f1216c9cdd02aee3bcc37fa1013dadee3143065f77d18a", "role": "CLIENT", "short_name": "F9LK", "snr": 4.24, "status": null, "telemetry": {"air_util_tx": 1.01, "battery_level": 11, "channel_utilization": 7.57, "uptime_seconds": 77216, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3903, "long_name": "Sharp Elk", "next_hop": 0, "num": "0x5cc9fe4c", "position": {"altitude": 1878, "latitude": 31.162345, "location_source": "LOC_INTERNAL", "longitude": -106.049348, "time_offset_sec": 3956}, "public_key_hex": "46e964780b9421a2724d9c66f4e649d7c046ecb7a3ac08473828d4e798c3adae", "role": "CLIENT", "short_name": "🦌", "snr": 6.12, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.76, "iaq": 42, "relative_humidity": 68.84, "temperature": 34.95}, "hops_away": 3, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 594, "long_name": "Sky Trout", "next_hop": 79, "num": "0x5ce2bb67", "position": {"altitude": 1315, "latitude": 32.895677, "location_source": "LOC_INTERNAL", "longitude": -107.217482, "time_offset_sec": 754}, "public_key_hex": "66f08aa4cb0beb7c81d623f7d1ef520905e309e48eb9eb27fcc4ce63d34a1a49", "role": "ROUTER", "short_name": "S0WD", "snr": 4.36, "status": null, "telemetry": {"air_util_tx": 1.151, "battery_level": 71, "channel_utilization": 3.14, "uptime_seconds": 46074, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 12143, "long_name": "Fast Cobra", "next_hop": 0, "num": "0x5ce94c3f", "position": {"altitude": 1246, "latitude": 33.9631, "location_source": "LOC_INTERNAL", "longitude": -106.735183, "time_offset_sec": 12229}, "public_key_hex": "468ccb8e0b79094f656a3d4b41ec9a446053a66928f2cc35abe5a0e33d6d171e", "role": "CLIENT", "short_name": "F54S", "snr": -1.18, "status": null, "telemetry": {"air_util_tx": 1.324, "battery_level": 76, "channel_utilization": 13.12, "uptime_seconds": 38360, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 4392, "long_name": "Copper Wolf", "next_hop": 0, "num": "0x5d038596", "position": {"altitude": 1611, "latitude": 33.499582, "location_source": "LOC_INTERNAL", "longitude": -107.245646, "time_offset_sec": 4400}, "public_key_hex": "fe2d476a9387807af83d555113536d8c00a0e74d5fbae13d8403797f721b6604", "role": "SENSOR", "short_name": "🦋", "snr": 1.95, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.627, "battery_level": 95, "channel_utilization": 31.28, "uptime_seconds": 110146, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.44, "iaq": 48, "relative_humidity": 9.04, "temperature": 12.13}, "hops_away": 4, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3597, "long_name": "Drifting Wolf", "next_hop": 180, "num": "0x5d13f04b", "position": {"altitude": 1028, "latitude": 33.433714, "location_source": "LOC_INTERNAL", "longitude": -107.456255, "time_offset_sec": 3875}, "public_key_hex": "875899d44f3a7e5063421ca6af6c57f0d89423df29ea5e3cf5982d6d859bd3e9", "role": "CLIENT", "short_name": "D8XG", "snr": 6.74, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.06, "iaq": 117, "relative_humidity": 42.6, "temperature": 20.2}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 316, "long_name": "Silent Bluff", "next_hop": 222, "num": "0x5d462a27", "position": null, "public_key_hex": "588060af20029be63cb6cc12d70923226120cf5ad57cef36344a375e0b9e4c35", "role": "TRACKER", "short_name": "SX0C", "snr": 3.11, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.071, "battery_level": 72, "channel_utilization": 5.15, "uptime_seconds": 58324, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2821, "long_name": "Drifting Sage", "next_hop": 0, "num": "0x5d5133ec", "position": {"altitude": 1424, "latitude": 33.858387, "location_source": "LOC_INTERNAL", "longitude": -108.529087, "time_offset_sec": 3114}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "DY8X", "snr": 7.97, "status": null, "telemetry": {"air_util_tx": 0.535, "battery_level": 11, "channel_utilization": 8.69, "uptime_seconds": 37433, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2252, "long_name": "Drifting Iguana", "next_hop": 0, "num": "0x5da09e1f", "position": {"altitude": 1568, "latitude": 32.523526, "location_source": "LOC_INTERNAL", "longitude": -107.532873, "time_offset_sec": 2443}, "public_key_hex": "1c3166310174eea818f284062c867a47cd131c0b6f58e5eb4ba48ef086d24ea3", "role": "CLIENT", "short_name": "DY8W", "snr": 7.99, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.266, "battery_level": 85, "channel_utilization": 21.87, "uptime_seconds": 3847, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 10269, "long_name": "Found Pike", "next_hop": 58, "num": "0x5daae344", "position": {"altitude": 1315, "latitude": 32.735062, "location_source": "LOC_INTERNAL", "longitude": -107.980998, "time_offset_sec": 10272}, "public_key_hex": "86517d50016d021c9402ab376f0aa9b6d561244d029e9e48f3c7ae927099ea18", "role": "CLIENT_MUTE", "short_name": "F0SR", "snr": 4.94, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.206, "battery_level": 14, "channel_utilization": 5.79, "uptime_seconds": 49915, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.2, "iaq": 66, "relative_humidity": 59.27, "temperature": 16.52}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 13911, "long_name": "Hidden Turtle", "next_hop": 0, "num": "0x5db1b8d0", "position": {"altitude": 1239, "latitude": 34.131089, "location_source": "LOC_INTERNAL", "longitude": -107.527797, "time_offset_sec": 13996}, "public_key_hex": "9f955023d6211451812277a1f2a455e77a2f4fedc51da889a6a3b507d2c393dd", "role": "ROUTER", "short_name": "H7YV", "snr": 10.13, "status": null, "telemetry": {"air_util_tx": 0.408, "battery_level": 67, "channel_utilization": 17.63, "uptime_seconds": 1337, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.73, "iaq": 135, "relative_humidity": 56.22, "temperature": 21.39}, "hops_away": 5, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 9623, "long_name": "Rough Salmon", "next_hop": 156, "num": "0x5dcf5fac", "position": {"altitude": 1359, "latitude": 32.0624, "location_source": "LOC_INTERNAL", "longitude": -106.738543, "time_offset_sec": 9749}, "public_key_hex": "fc6a6df0f81cf973dd71e5a5aa2943168028631ede3ac132563861dfdf51ec60", "role": "CLIENT", "short_name": "R69U", "snr": 6.26, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.93, "iaq": 0, "relative_humidity": 43.0, "temperature": 25.66}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 5982, "long_name": "Short Whale", "next_hop": 0, "num": "0x5de19c31", "position": {"altitude": 1404, "latitude": 33.273028, "location_source": "LOC_INTERNAL", "longitude": -107.000014, "time_offset_sec": 6176}, "public_key_hex": "", "role": "CLIENT", "short_name": "SPZ5", "snr": 7.26, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.362, "battery_level": 58, "channel_utilization": 4.37, "uptime_seconds": 19476, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": true, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 2106, "long_name": "Drowsy Shark", "next_hop": 140, "num": "0x5e09e771", "position": {"altitude": 1491, "latitude": 34.149793, "location_source": "LOC_INTERNAL", "longitude": -107.629323, "time_offset_sec": 2298}, "public_key_hex": "f51f969de2747f357db59712b69552cb68de5f8118747c91317b8a66fae689ed", "role": "CLIENT", "short_name": "DV2W", "snr": 10.54, "status": null, "telemetry": {"air_util_tx": 1.072, "battery_level": 10, "channel_utilization": 13.43, "uptime_seconds": 20849, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.13, "iaq": 85, "relative_humidity": 74.1, "temperature": 31.41}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1391, "long_name": "Blue Tortoise", "next_hop": 0, "num": "0x5e2d80af", "position": {"altitude": 1427, "latitude": 33.197328, "location_source": "LOC_INTERNAL", "longitude": -106.627422, "time_offset_sec": 1420}, "public_key_hex": "ec5573a81959846de4175011b872e9b3b3cfcbbfa81bddd36ca0518b92fd01c5", "role": "CLIENT", "short_name": "B3D9", "snr": 4.32, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.83, "iaq": 34, "relative_humidity": 89.21, "temperature": 11.39}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 245, "long_name": "Sharp Beaver", "next_hop": 11, "num": "0x5e32e821", "position": {"altitude": 1049, "latitude": 33.539106, "location_source": "LOC_INTERNAL", "longitude": -107.422137, "time_offset_sec": 466}, "public_key_hex": "c1b8d686c7ff9bfc3b0f6793088a9519a2dd091e36375dd7c7d2ea34ccae21fa", "role": "CLIENT", "short_name": "SMCG", "snr": -3.4, "status": null, "telemetry": {"air_util_tx": 0.681, "battery_level": 11, "channel_utilization": 12.33, "uptime_seconds": 146273, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1323, "long_name": "Forest Trout", "next_hop": 0, "num": "0x5e5ab0f8", "position": {"altitude": 1606, "latitude": 33.548972, "location_source": "LOC_INTERNAL", "longitude": -106.645953, "time_offset_sec": 1436}, "public_key_hex": "0efee0098e4acdcae4f480f6a9caa9b88ed886d7b779b83db097c00fcedadf42", "role": "CLIENT", "short_name": "F9LT", "snr": -0.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4755, "long_name": "Desert Pike", "next_hop": 0, "num": "0x5e710852", "position": {"altitude": 1143, "latitude": 32.034947, "location_source": "LOC_INTERNAL", "longitude": -107.139548, "time_offset_sec": 4962}, "public_key_hex": "9c1a9ddc08bef9f62d394f6a91d8b9b6b083fb73347660c27e7536284bfbb8a4", "role": "CLIENT", "short_name": "DGY6", "snr": 0.55, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK3401", "last_heard_offset_sec": 1241, "long_name": "Frosty Cougar", "next_hop": 0, "num": "0x5e92fa27", "position": null, "public_key_hex": "46040c79fc638356c187fa2f5f8a4135bb6c9b229777bacb60ef62a5a3f44358", "role": "CLIENT", "short_name": "FYRW", "snr": 4.86, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.46, "iaq": 6, "relative_humidity": 71.33, "temperature": 9.94}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8225, "long_name": "Misty Fox", "next_hop": 150, "num": "0x5e968983", "position": {"altitude": 1168, "latitude": 33.567164, "location_source": "LOC_INTERNAL", "longitude": -107.895277, "time_offset_sec": 8358}, "public_key_hex": "efe7aaf198a44b923e07916239cb132dfa6427a7dd2cccbbdb274ddc354f285d", "role": "CLIENT_MUTE", "short_name": "MUXU", "snr": 1.68, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.833, "battery_level": 96, "channel_utilization": 12.33, "uptime_seconds": 31298, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2299, "long_name": "Copper Crane", "next_hop": 0, "num": "0x5ee0a333", "position": null, "public_key_hex": "a258dbfe50e49452e37aacd71a9c9df1638131502af99d882735b00fe8c3eb69", "role": "TRACKER", "short_name": "C3O8", "snr": 10.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 3594, "long_name": "Stone Phoenix", "next_hop": 79, "num": "0x5f14a894", "position": {"altitude": 1613, "latitude": 33.544085, "location_source": "LOC_INTERNAL", "longitude": -106.585814, "time_offset_sec": 3864}, "public_key_hex": "2e0c187a3bf4c021b4a4e4ff78dac8b899e6b587caed0ebe9c382b3834fc6d7f", "role": "CLIENT", "short_name": "S4LC", "snr": 3.52, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.3, "iaq": 77, "relative_humidity": 29.97, "temperature": 27.04}, "hops_away": 1, "hw_model": "M5STACK_C6L", "last_heard_offset_sec": 5266, "long_name": "Howling Stag", "next_hop": 40, "num": "0x5f2f7162", "position": {"altitude": 1002, "latitude": 33.162359, "location_source": "LOC_INTERNAL", "longitude": -106.204077, "time_offset_sec": 5370}, "public_key_hex": "", "role": "ROUTER", "short_name": "HZZH", "snr": 7.47, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 4, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 9019, "long_name": "Sky Heron", "next_hop": 123, "num": "0x5f7109e9", "position": null, "public_key_hex": "b1a962afd94e9fcb3fc4ded57a7e949d63c8c014b8c83b883be3557706173098", "role": "CLIENT", "short_name": "S22N", "snr": 6.86, "status": null, "telemetry": {"air_util_tx": 0.636, "battery_level": 43, "channel_utilization": 2.28, "uptime_seconds": 95363, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.58, "iaq": 16, "relative_humidity": 74.14, "temperature": 6.21}, "hops_away": 5, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 544, "long_name": "Brave Gecko", "next_hop": 65, "num": "0x5f71ba4d", "position": null, "public_key_hex": "9d132e48874596640bc70371ff2492b17b93d45d1c27d95a93c613db7595047e", "role": "CLIENT", "short_name": "BB3F", "snr": -2.32, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.118, "battery_level": 20, "channel_utilization": 12.41, "uptime_seconds": 71725, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1315, "long_name": "Loud Shark", "next_hop": 0, "num": "0x5f749275", "position": {"altitude": 1044, "latitude": 32.37495, "location_source": "LOC_INTERNAL", "longitude": -108.52372, "time_offset_sec": 1494}, "public_key_hex": "7b7b03441a268e4a7f77d5f144926bc20cba6a129f9eb28a7bc7e28f8fffc04b", "role": "CLIENT", "short_name": "LIIT", "snr": 4.96, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2731, "long_name": "Dawn Eagle", "next_hop": 96, "num": "0x5f9c3d6d", "position": {"altitude": 1315, "latitude": 33.013695, "location_source": "LOC_INTERNAL", "longitude": -107.065328, "time_offset_sec": 3004}, "public_key_hex": "804fd8a4447ffea81bb3c07f6587aff4731975230c57c8732b5c0c45f6e59ea5", "role": "CLIENT_MUTE", "short_name": "DXQZ", "snr": 6.22, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.322, "battery_level": 74, "channel_utilization": 19.79, "uptime_seconds": 109862, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2106, "long_name": "Red Bison", "next_hop": 152, "num": "0x5fd462c5", "position": {"altitude": 1269, "latitude": 34.833717, "location_source": "LOC_INTERNAL", "longitude": -107.249846, "time_offset_sec": 2114}, "public_key_hex": "b20f3deacf83591d79fad08a5856991d92e5330bd071c383b8ec9705c0cad65b", "role": "ROUTER", "short_name": "RVZ8", "snr": 6.74, "status": null, "telemetry": {"air_util_tx": 0.216, "battery_level": 20, "channel_utilization": 6.22, "uptime_seconds": 67333, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 3743, "long_name": "Dusk Cactus", "next_hop": 215, "num": "0x5fe179dc", "position": {"altitude": 1572, "latitude": 33.664821, "location_source": "LOC_INTERNAL", "longitude": -106.690648, "time_offset_sec": 3782}, "public_key_hex": "bfd562ec84e40a5436ca2515b8a9da51d8750f9c301fa6e37f58f0b516b3653a", "role": "CLIENT", "short_name": "DAJM", "snr": 4.94, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1015.85, "iaq": 67, "relative_humidity": 63.06, "temperature": 13.51}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 213, "long_name": "Drowsy Elk", "next_hop": 0, "num": "0x5feb9485", "position": {"altitude": 1539, "latitude": 32.599865, "location_source": "LOC_INTERNAL", "longitude": -107.262611, "time_offset_sec": 395}, "public_key_hex": "d5ecb843694d0ae074beb2e8bbd0a576456d802f643f7847dfe94a49163ca0b6", "role": "CLIENT", "short_name": "DL1U", "snr": 2.32, "status": null, "telemetry": {"air_util_tx": 1.258, "battery_level": 68, "channel_utilization": 19.9, "uptime_seconds": 102098, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 7650, "long_name": "Copper Salmon", "next_hop": 0, "num": "0x5ffe3347", "position": {"altitude": 1460, "latitude": 33.229448, "location_source": "LOC_INTERNAL", "longitude": -106.757284, "time_offset_sec": 7888}, "public_key_hex": "716b891bbc5ad56c9d911677d0ad41c6a03d072accbf7f6221707562dfcb61b0", "role": "CLIENT", "short_name": "C77T", "snr": 0.95, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.141, "battery_level": 87, "channel_utilization": 8.3, "uptime_seconds": 45005, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 216, "long_name": "Rough Tortoise", "next_hop": 231, "num": "0x602ea565", "position": {"altitude": 1164, "latitude": 32.928195, "location_source": "LOC_INTERNAL", "longitude": -107.351604, "time_offset_sec": 275}, "public_key_hex": "fedd34c45dfcd1d53f9e0f04370bb1488c4bacafb9405a5c2b67376ab8fae38c", "role": "CLIENT", "short_name": "RGHC", "snr": 3.67, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.496, "battery_level": 27, "channel_utilization": 23.22, "uptime_seconds": 77530, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 4346, "long_name": "River Pony KX7VH", "next_hop": 210, "num": "0x60661e37", "position": {"altitude": 1568, "latitude": 33.834276, "location_source": "LOC_INTERNAL", "longitude": -106.752936, "time_offset_sec": 4364}, "public_key_hex": "a85100985e9cff9ed5cca0773df349926e3a1cd717930f9fdf5a47cf692fcb5c", "role": "SENSOR", "short_name": "RMAM", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 2.014, "battery_level": 59, "channel_utilization": 7.4, "uptime_seconds": 60325, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2807, "long_name": "Short Doe", "next_hop": 202, "num": "0x60a763e9", "position": {"altitude": 1333, "latitude": 33.30522, "location_source": "LOC_INTERNAL", "longitude": -106.575207, "time_offset_sec": 2869}, "public_key_hex": "875b83db72c6948283d3926c45f9487ff35fea8c2747e4d9db59f6c352821f5e", "role": "CLIENT", "short_name": "S7FM", "snr": 5.77, "status": null, "telemetry": {"air_util_tx": 0.243, "battery_level": 55, "channel_utilization": 12.46, "uptime_seconds": 2720, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1016.39, "iaq": 59, "relative_humidity": 26.64, "temperature": 16.08}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5193, "long_name": "Solar Crow", "next_hop": 0, "num": "0x614ff771", "position": {"altitude": 1243, "latitude": 32.349735, "location_source": "LOC_INTERNAL", "longitude": -107.697969, "time_offset_sec": 5264}, "public_key_hex": "e87beff590657ff55f099de8af416abbd1d77f1914c941e205356a52a5a2caf3", "role": "CLIENT", "short_name": "SYOY", "snr": 1.59, "status": null, "telemetry": {"air_util_tx": 0.051, "battery_level": 90, "channel_utilization": 20.19, "uptime_seconds": 30661, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6914, "long_name": "Drowsy Crow", "next_hop": 0, "num": "0x618f2b1d", "position": {"altitude": 1370, "latitude": 32.805903, "location_source": "LOC_INTERNAL", "longitude": -107.345545, "time_offset_sec": 7021}, "public_key_hex": "a153136d381f0e824d5d7b75af038f6c4a8698cb121329c3b9001ccf795dfbf9", "role": "SENSOR", "short_name": "D1ZI", "snr": 2.84, "status": null, "telemetry": {"air_util_tx": 0.841, "battery_level": 80, "channel_utilization": 25.27, "uptime_seconds": 49456, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 664, "long_name": "Old Lion", "next_hop": 0, "num": "0x61aef902", "position": {"altitude": 1549, "latitude": 33.583218, "location_source": "LOC_INTERNAL", "longitude": -107.514052, "time_offset_sec": 704}, "public_key_hex": "5d49941118ae6f6dc8a5cddacb1fbd61880514b95bfb584f760a80fa0d9fa3e4", "role": "ROUTER_LATE", "short_name": "OL01", "snr": 11.42, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.908, "battery_level": 32, "channel_utilization": 11.35, "uptime_seconds": 32976, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5612, "long_name": "Hidden Salmon", "next_hop": 33, "num": "0x61c1599e", "position": {"altitude": 1434, "latitude": 33.695753, "location_source": "LOC_INTERNAL", "longitude": -105.683866, "time_offset_sec": 5893}, "public_key_hex": "3281dd3ec761ac8f6db4cd43fd20adee26ed8481d5b2fc0edf4a59439ff97426", "role": "CLIENT", "short_name": "HCCX", "snr": 10.32, "status": null, "telemetry": {"air_util_tx": 1.002, "battery_level": 20, "channel_utilization": 7.35, "uptime_seconds": 149116, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.65, "iaq": 80, "relative_humidity": 57.63, "temperature": 18.91}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 2737, "long_name": "Storm Lynx", "next_hop": 0, "num": "0x61d4e835", "position": null, "public_key_hex": "5d471048680030c550362dae2721350ef501b901d316369321e9ea4519fadd3b", "role": "CLIENT", "short_name": "SHTJ", "snr": 4.72, "status": null, "telemetry": {"air_util_tx": 0.913, "battery_level": 79, "channel_utilization": 9.42, "uptime_seconds": 18965, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.39, "iaq": 28, "relative_humidity": 73.44, "temperature": 22.65}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4041, "long_name": "Hidden Owl", "next_hop": 31, "num": "0x62186b8c", "position": null, "public_key_hex": "44b725a5ced14c3c80c4950eec7503ce1d433ada1bff81a5fa595321106e1830", "role": "CLIENT", "short_name": "H26F", "snr": 4.13, "status": null, "telemetry": {"air_util_tx": 0.643, "battery_level": 44, "channel_utilization": 12.38, "uptime_seconds": 200047, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4669, "long_name": "Copper Cobra", "next_hop": 0, "num": "0x625ac38b", "position": {"altitude": 1311, "latitude": 33.575949, "location_source": "LOC_INTERNAL", "longitude": -106.692399, "time_offset_sec": 4927}, "public_key_hex": "82ce8b261ba37fcc6677ef875d34ce61578f08560ecaf9c910ea144b498b84b0", "role": "CLIENT", "short_name": "CQ47", "snr": 3.77, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.944, "battery_level": 29, "channel_utilization": 11.19, "uptime_seconds": 20821, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 8556, "long_name": "Shady Fox", "next_hop": 185, "num": "0x6264b305", "position": {"altitude": 769, "latitude": 33.748605, "location_source": "LOC_INTERNAL", "longitude": -107.997785, "time_offset_sec": 8736}, "public_key_hex": "7feb8c53530aa34a0f32b0f746e34a935ac28adcf6491133de57459ff101c4da", "role": "CLIENT", "short_name": "SKYQ", "snr": 7.01, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 1809, "long_name": "Rough Whale", "next_hop": 149, "num": "0x62d4a52d", "position": {"altitude": 1217, "latitude": 32.71326, "location_source": "LOC_INTERNAL", "longitude": -107.823456, "time_offset_sec": 1878}, "public_key_hex": "", "role": "CLIENT", "short_name": "RWQ3", "snr": 4.09, "status": null, "telemetry": {"air_util_tx": 0.205, "battery_level": 36, "channel_utilization": 5.22, "uptime_seconds": 64351, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 7261, "long_name": "Iron Adder", "next_hop": 0, "num": "0x62d760f3", "position": null, "public_key_hex": "580e04cb55c77c418f46aa2ad1b07b54b3bd8fbc0137d343dbfa14f649ab31b5", "role": "ROUTER_LATE", "short_name": "🌊", "snr": 2.82, "status": null, "telemetry": {"air_util_tx": 1.081, "battery_level": 41, "channel_utilization": 15.59, "uptime_seconds": 4151, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 8851, "long_name": "Howling Cobra", "next_hop": 0, "num": "0x62db98ba", "position": {"altitude": 1446, "latitude": 32.685322, "location_source": "LOC_INTERNAL", "longitude": -107.676506, "time_offset_sec": 9022}, "public_key_hex": "f2632dcc92599e53822ab2a561929de4cc4e3b48f13653868a09d22aec2ba833", "role": "CLIENT", "short_name": "HRZ9", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 133, "long_name": "Silent Beaver", "next_hop": 99, "num": "0x632e90c8", "position": {"altitude": 1438, "latitude": 32.709874, "location_source": "LOC_INTERNAL", "longitude": -107.172777, "time_offset_sec": 330}, "public_key_hex": "8b7eeb6ea90079361049da451a55000b84ef865f4db29831954ced6b6328ecd3", "role": "CLIENT", "short_name": "SE2M", "snr": 10.96, "status": null, "telemetry": {"air_util_tx": 0.47, "battery_level": 94, "channel_utilization": 1.54, "uptime_seconds": 95158, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1018.63, "iaq": 62, "relative_humidity": 61.21, "temperature": 26.68}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 8174, "long_name": "Silent Yucca", "next_hop": 0, "num": "0x6351aedb", "position": {"altitude": 1720, "latitude": 32.650139, "location_source": "LOC_INTERNAL", "longitude": -107.211853, "time_offset_sec": 8243}, "public_key_hex": "476371289d6a5b2160f66a1724f28f28ab8a380754b4b958d1fffb09ad196e07", "role": "CLIENT", "short_name": "SURC", "snr": 5.09, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.07, "iaq": 42, "relative_humidity": 19.4, "temperature": 33.8}, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 10849, "long_name": "Burning Doe", "next_hop": 104, "num": "0x638affaa", "position": {"altitude": 1229, "latitude": 34.206999, "location_source": "LOC_INTERNAL", "longitude": -107.572464, "time_offset_sec": 10885}, "public_key_hex": "0b60a3387f703819ddfcabe2a089e889cb532ebf51d6c9ab82e74eabb726821d", "role": "ROUTER", "short_name": "BVT1", "snr": 8.1, "status": null, "telemetry": {"air_util_tx": 0.138, "battery_level": 78, "channel_utilization": 0.55, "uptime_seconds": 120900, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 8578, "long_name": "Lost Coyote", "next_hop": 144, "num": "0x63ac7555", "position": {"altitude": 1147, "latitude": 33.522143, "location_source": "LOC_INTERNAL", "longitude": -107.027031, "time_offset_sec": 8706}, "public_key_hex": "7a01b74c30757a49c17ca74d9e435b1d144c31980c28980160ebf8f4ab6d9a8a", "role": "CLIENT", "short_name": "LMRV", "snr": 7.3, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 900, "long_name": "Steel Salmon", "next_hop": 0, "num": "0x63ff3115", "position": {"altitude": 1013, "latitude": 33.521187, "location_source": "LOC_INTERNAL", "longitude": -107.476884, "time_offset_sec": 1058}, "public_key_hex": "0221fe68364ef6e6e7a759529165574eaf2f475dd9fb7b5ca65f3df5e531339f", "role": "CLIENT", "short_name": "SNKU", "snr": 1.05, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.29, "iaq": 88, "relative_humidity": 30.55, "temperature": 23.44}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 7777, "long_name": "Lone Heron", "next_hop": 0, "num": "0x6401ee7b", "position": null, "public_key_hex": "16f920cdb8f0721bf57d0e73ba5a1e0bfa6b2e71e42d6c7702866809ebf1558a", "role": "CLIENT", "short_name": "LTQU", "snr": 9.94, "status": null, "telemetry": {"air_util_tx": 0.628, "battery_level": 69, "channel_utilization": 6.45, "uptime_seconds": 229311, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 612, "long_name": "New Sage", "next_hop": 0, "num": "0x64640c76", "position": {"altitude": 1369, "latitude": 32.296786, "location_source": "LOC_INTERNAL", "longitude": -106.258827, "time_offset_sec": 789}, "public_key_hex": "", "role": "CLIENT", "short_name": "NWRM", "snr": 5.4, "status": null, "telemetry": {"air_util_tx": 0.105, "battery_level": 33, "channel_utilization": 7.19, "uptime_seconds": 26547, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 199, "long_name": "Happy Whale", "next_hop": 0, "num": "0x649159ca", "position": {"altitude": 982, "latitude": 33.857255, "location_source": "LOC_INTERNAL", "longitude": -107.646439, "time_offset_sec": 304}, "public_key_hex": "dab0c019f49281574bf239eefc94e37d2f10457250acc0af2a95780c347acfdd", "role": "CLIENT", "short_name": "HWB1", "snr": 10.17, "status": null, "telemetry": {"air_util_tx": 0.409, "battery_level": 25, "channel_utilization": 2.27, "uptime_seconds": 79867, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.73, "iaq": 48, "relative_humidity": 72.47, "temperature": 19.86}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 376, "long_name": "Sharp Cedar", "next_hop": 0, "num": "0x6494598f", "position": {"altitude": 1638, "latitude": 34.018022, "location_source": "LOC_INTERNAL", "longitude": -107.154378, "time_offset_sec": 609}, "public_key_hex": "367df11e0912875b76523c6d5e227aa8efd1d4ddc1396580bdf66df960a92f28", "role": "ROUTER", "short_name": "S6LY", "snr": 5.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 2041, "long_name": "Silver Beaver", "next_hop": 104, "num": "0x64ac43cb", "position": null, "public_key_hex": "02f95d811e95eabc3da6fa3bbe75cb955b6d4ae0e68278e6f15d537cb3f63645", "role": "ROUTER_LATE", "short_name": "S4OE", "snr": 10.1, "status": null, "telemetry": {"air_util_tx": 1.178, "battery_level": 15, "channel_utilization": 2.18, "uptime_seconds": 43646, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3239, "long_name": "Slow Badger", "next_hop": 0, "num": "0x64c89b19", "position": {"altitude": 1183, "latitude": 32.619603, "location_source": "LOC_INTERNAL", "longitude": -107.660062, "time_offset_sec": 3412}, "public_key_hex": "f433446d316f16e27ea73b2d8bc13072ac406d0a337c57259eaaa9c1ba4bf9a6", "role": "CLIENT", "short_name": "SUPP", "snr": 4.61, "status": null, "telemetry": {"air_util_tx": 1.348, "battery_level": 79, "channel_utilization": 24.68, "uptime_seconds": 41443, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.38, "iaq": 47, "relative_humidity": 82.34, "temperature": 15.0}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1127, "long_name": "Iron Bear", "next_hop": 28, "num": "0x64f2061b", "position": {"altitude": 1426, "latitude": 33.429525, "location_source": "LOC_INTERNAL", "longitude": -106.374279, "time_offset_sec": 1284}, "public_key_hex": "c23c18cbb7d147096f80cfefd05872eccea23c72b8e9b671e91c9a70163fe013", "role": "CLIENT", "short_name": "I2G5", "snr": 4.14, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.193, "battery_level": 75, "channel_utilization": 4.51, "uptime_seconds": 211810, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1459, "long_name": "Green Doe", "next_hop": 0, "num": "0x65074338", "position": {"altitude": 1362, "latitude": 33.181485, "location_source": "LOC_INTERNAL", "longitude": -107.694153, "time_offset_sec": 1489}, "public_key_hex": "491ba202181ac694b8164c1f7b3694d018d47a26d1c22668075b2bc3d1f59cd4", "role": "CLIENT", "short_name": "GRHZ", "snr": 9.38, "status": null, "telemetry": {"air_util_tx": 1.841, "battery_level": 101, "channel_utilization": 16.36, "uptime_seconds": 328960, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2165, "long_name": "Silent Pine KE8ML", "next_hop": 208, "num": "0x650a093b", "position": {"altitude": 1053, "latitude": 33.663375, "location_source": "LOC_INTERNAL", "longitude": -106.873108, "time_offset_sec": 2366}, "public_key_hex": "dd23a7ab27ce8dd0ffe73de17452462991bdb13393ba4adc67ea297339802323", "role": "CLIENT", "short_name": "SADW", "snr": 8.18, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.706, "battery_level": 100, "channel_utilization": 20.66, "uptime_seconds": 36882, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 2356, "long_name": "Steel Shark", "next_hop": 225, "num": "0x652b7df9", "position": {"altitude": 1079, "latitude": 33.625301, "location_source": "LOC_INTERNAL", "longitude": -107.616288, "time_offset_sec": 2406}, "public_key_hex": "2a6238f954471b399d8ff9d8df44ca54ec2ebbcae2565e1193b527a1520e0449", "role": "CLIENT", "short_name": "SINL", "snr": 7.84, "status": null, "telemetry": {"air_util_tx": 1.145, "battery_level": 16, "channel_utilization": 16.86, "uptime_seconds": 242688, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 386, "long_name": "Lost Salmon KE0EA", "next_hop": 118, "num": "0x6542cdf9", "position": {"altitude": 1219, "latitude": 33.31172, "location_source": "LOC_INTERNAL", "longitude": -106.794601, "time_offset_sec": 393}, "public_key_hex": "7ae68b5289b43c8dc88b3297b64f58528a69520ac00286946c7409a7ee3d5335", "role": "CLIENT", "short_name": "LKTB", "snr": 2.33, "status": null, "telemetry": {"air_util_tx": 0.285, "battery_level": 83, "channel_utilization": 6.37, "uptime_seconds": 47259, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2471, "long_name": "Soft Whale", "next_hop": 0, "num": "0x6546f520", "position": null, "public_key_hex": "cac814b9be51123c1bf4c4b8e0299bb2725f4bdc4d08800ded7f28425fa3f63b", "role": "CLIENT_MUTE", "short_name": "SA8J", "snr": 5.41, "status": null, "telemetry": {"air_util_tx": 1.124, "battery_level": 62, "channel_utilization": 14.83, "uptime_seconds": 215664, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1093, "long_name": "Quick Trout", "next_hop": 0, "num": "0x6554dc79", "position": {"altitude": 1489, "latitude": 32.961403, "location_source": "LOC_INTERNAL", "longitude": -107.945449, "time_offset_sec": 1127}, "public_key_hex": "93c1ee068b8932d3695cec6d70b509e53abedcdde9f0d08c0939ee9368f20eb4", "role": "CLIENT", "short_name": "Q7M9", "snr": 8.59, "status": null, "telemetry": {"air_util_tx": 0.479, "battery_level": 94, "channel_utilization": 4.53, "uptime_seconds": 156173, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1747, "long_name": "Dawn Elk", "next_hop": 0, "num": "0x657723e2", "position": {"altitude": 1534, "latitude": 33.413849, "location_source": "LOC_INTERNAL", "longitude": -107.192404, "time_offset_sec": 1913}, "public_key_hex": "57ebbed8804e091088d31313be5ba5a946e179114045797e53b163d8bf699a49", "role": "CLIENT", "short_name": "DE0W", "snr": 2.56, "status": null, "telemetry": {"air_util_tx": 1.497, "battery_level": 52, "channel_utilization": 3.77, "uptime_seconds": 18160, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 1069, "long_name": "Frosty Fox", "next_hop": 244, "num": "0x659c992d", "position": {"altitude": 1081, "latitude": 32.824127, "location_source": "LOC_INTERNAL", "longitude": -107.430983, "time_offset_sec": 1111}, "public_key_hex": "1fee44c1eb32c5bb1281be606a21f5ff978d527c430d5ae1e304d3e6dffe8648", "role": "CLIENT", "short_name": "F8PW", "snr": 5.46, "status": null, "telemetry": {"air_util_tx": 0.242, "battery_level": 25, "channel_utilization": 17.6, "uptime_seconds": 20640, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 1552, "long_name": "Drifting Bronco", "next_hop": 125, "num": "0x65a893e6", "position": null, "public_key_hex": "dd795e718e1670205adcf9f5713c7ee10ceb1c0b30ff2d2a93aec2969594db46", "role": "CLIENT", "short_name": "DQUV", "snr": 8.64, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.659, "battery_level": 97, "channel_utilization": 16.24, "uptime_seconds": 177051, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1026.66, "iaq": 14, "relative_humidity": 22.53, "temperature": 20.71}, "hops_away": 2, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 14497, "long_name": "Green Cobra", "next_hop": 255, "num": "0x65de48b4", "position": {"altitude": 1404, "latitude": 32.308025, "location_source": "LOC_INTERNAL", "longitude": -107.185084, "time_offset_sec": 14748}, "public_key_hex": "f965443ecc0298b93da4b406907d9658058900c6e45bdcd5328aa5de28ea4db4", "role": "SENSOR", "short_name": "🦂", "snr": -4.61, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.723, "battery_level": 22, "channel_utilization": 21.54, "uptime_seconds": 42806, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1016.43, "iaq": 39, "relative_humidity": 32.01, "temperature": 9.49}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 11239, "long_name": "Happy Fox", "next_hop": 8, "num": "0x65e4d697", "position": {"altitude": 1282, "latitude": 32.38293, "location_source": "LOC_INTERNAL", "longitude": -107.661588, "time_offset_sec": 11325}, "public_key_hex": "455a04d59e4194fa42ba67e01bb29cec4be3ee1f539f2a1fcdc9ce4f03f6d9ee", "role": "CLIENT", "short_name": "🌵", "snr": 4.34, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 199, "long_name": "Howling Mamba", "next_hop": 170, "num": "0x65e9b990", "position": {"altitude": 1426, "latitude": 33.96554, "location_source": "LOC_INTERNAL", "longitude": -107.492575, "time_offset_sec": 370}, "public_key_hex": "3e0099e2e51967b54ef58a8f353582134a78837ad2a40125357b4cd89afb5ef3", "role": "SENSOR", "short_name": "HOKW", "snr": 2.86, "status": null, "telemetry": {"air_util_tx": 1.418, "battery_level": 57, "channel_utilization": 4.97, "uptime_seconds": 14903, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 2765, "long_name": "Lunar Yucca", "next_hop": 225, "num": "0x65f9b0e4", "position": {"altitude": 1828, "latitude": 33.717098, "location_source": "LOC_INTERNAL", "longitude": -106.853647, "time_offset_sec": 2905}, "public_key_hex": "7f7a9dd426df6a4aabed48e5a547b2951d193a477d5d46e9c6f1d10e9a9a9881", "role": "CLIENT", "short_name": "LY93", "snr": 0.85, "status": null, "telemetry": {"air_util_tx": 0.056, "battery_level": 54, "channel_utilization": 10.42, "uptime_seconds": 128114, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.5, "iaq": 87, "relative_humidity": 45.72, "temperature": 18.76}, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 7139, "long_name": "Brave Doe N53OV", "next_hop": 0, "num": "0x6614cc91", "position": {"altitude": 1210, "latitude": 32.320207, "location_source": "LOC_INTERNAL", "longitude": -106.898488, "time_offset_sec": 7331}, "public_key_hex": "d90b369c1ca782bb8071d5bda219ff8011daffa4024b471ee911f6ca3b8fdd41", "role": "CLIENT", "short_name": "BVFN", "snr": 9.76, "status": null, "telemetry": {"air_util_tx": 2.94, "battery_level": 43, "channel_utilization": 8.36, "uptime_seconds": 118050, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.08, "iaq": 59, "relative_humidity": 48.2, "temperature": 8.98}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1188, "long_name": "Dawn Crane", "next_hop": 0, "num": "0x66343b0a", "position": null, "public_key_hex": "436ccf3016eb69e5e30cf9eb593f35c33a0d572f530847194889d598121c2059", "role": "ROUTER", "short_name": "DEEN", "snr": 10.72, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 934, "long_name": "Copper Stag", "next_hop": 0, "num": "0x6634f8df", "position": null, "public_key_hex": "af1882004bb7bd14be4bda0f7f2aab565b3e0fbf1d88b4bc33e2ba42b7356f14", "role": "CLIENT", "short_name": "CUDR", "snr": 7.48, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.484, "battery_level": 81, "channel_utilization": 7.94, "uptime_seconds": 75596, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4647, "long_name": "Drifting Trout", "next_hop": 0, "num": "0x666f4cec", "position": null, "public_key_hex": "182634fb9ec8fd3c9386b6ba109d38b9f35b499c9d33ba5d17369ce3fcb2a548", "role": "CLIENT", "short_name": "D819", "snr": 6.02, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.53, "battery_level": 29, "channel_utilization": 12.36, "uptime_seconds": 156566, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.52, "iaq": 49, "relative_humidity": 35.9, "temperature": 20.16}, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 94, "long_name": "Happy Whale", "next_hop": 0, "num": "0x669f4ba5", "position": {"altitude": 1869, "latitude": 34.024099, "location_source": "LOC_INTERNAL", "longitude": -107.537181, "time_offset_sec": 224}, "public_key_hex": "1410a9f82707c5fb67810c3e28a9da580931cec82df6b4439004e3ddb5985202", "role": "CLIENT", "short_name": "HYZZ", "snr": 8.93, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2287, "long_name": "Solar Whale", "next_hop": 101, "num": "0x66bc279e", "position": {"altitude": 1551, "latitude": 33.181611, "location_source": "LOC_INTERNAL", "longitude": -106.210368, "time_offset_sec": 2440}, "public_key_hex": "c28ab88d60859b858ac16be47fb31a0b5e8b73496ac52671d701f36ea613d6a0", "role": "CLIENT", "short_name": "SJXZ", "snr": 11.87, "status": null, "telemetry": {"air_util_tx": 0.571, "battery_level": 99, "channel_utilization": 8.73, "uptime_seconds": 1472, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 273, "long_name": "Sneaky Ridge", "next_hop": 0, "num": "0x66d29807", "position": null, "public_key_hex": "1ed28687e67597e29aa9fb6e832a5a421a7db02cdbedb0d8d9f6fd005a5f35cc", "role": "CLIENT", "short_name": "SLEB", "snr": 5.91, "status": null, "telemetry": {"air_util_tx": 0.527, "battery_level": 37, "channel_utilization": 8.56, "uptime_seconds": 15478, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 258, "long_name": "Steel Phoenix", "next_hop": 71, "num": "0x6728405f", "position": {"altitude": 1586, "latitude": 32.195728, "location_source": "LOC_INTERNAL", "longitude": -106.543662, "time_offset_sec": 325}, "public_key_hex": "d9f8c0e338ebf1abeabc79099bf468d1c0d02666a6a464abedce7d68d757064e", "role": "ROUTER", "short_name": "SQZS", "snr": 11.54, "status": null, "telemetry": {"air_util_tx": 0.956, "battery_level": 11, "channel_utilization": 6.59, "uptime_seconds": 250610, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1025.27, "iaq": 29, "relative_humidity": 35.53, "temperature": 23.4}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4108, "long_name": "Silver Dolphin", "next_hop": 0, "num": "0x6750a8fc", "position": {"altitude": 1473, "latitude": 33.55992, "location_source": "LOC_INTERNAL", "longitude": -107.175781, "time_offset_sec": 4313}, "public_key_hex": "33b2f13bf4c58b3334b18789a1a227ea96b9fae113f073ed7663a53e18ad62a1", "role": "CLIENT", "short_name": "SEM2", "snr": 4.14, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.505, "battery_level": 48, "channel_utilization": 7.39, "uptime_seconds": 22236, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 734, "long_name": "Iron Cobra", "next_hop": 0, "num": "0x67687acf", "position": {"altitude": 1585, "latitude": 33.601824, "location_source": "LOC_INTERNAL", "longitude": -107.495796, "time_offset_sec": 994}, "public_key_hex": "a33411c3aa1b1f4e7852f9dcb53e95aaac0881f81c3e1fb1f8a1d31373921f40", "role": "CLIENT", "short_name": "IQUK", "snr": 1.83, "status": null, "telemetry": {"air_util_tx": 0.305, "battery_level": 70, "channel_utilization": 8.9, "uptime_seconds": 51935, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": {"barometric_pressure": 1007.17, "iaq": 7, "relative_humidity": 56.79, "temperature": 29.54}, "hops_away": 4, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 13462, "long_name": "Forest Coyote", "next_hop": 206, "num": "0x67828947", "position": {"altitude": 1322, "latitude": 33.501974, "location_source": "LOC_INTERNAL", "longitude": -106.62173, "time_offset_sec": 13760}, "public_key_hex": "d570281ec816fea02bccf4b29df50a07ab021c9a72eff064a240e2bb8351ffc9", "role": "ROUTER", "short_name": "FEHX", "snr": 1.82, "status": null, "telemetry": {"air_util_tx": 0.905, "battery_level": 101, "channel_utilization": 8.56, "uptime_seconds": 14443, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 704, "long_name": "Black Owl", "next_hop": 0, "num": "0x67aae3a7", "position": {"altitude": 1230, "latitude": 34.027394, "location_source": "LOC_INTERNAL", "longitude": -108.590826, "time_offset_sec": 994}, "public_key_hex": "9cd98653fd9ab266b7f17766d752cfa481c7e502583852fadecd66367fc055c7", "role": "CLIENT", "short_name": "🦇", "snr": 0.99, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 14458, "long_name": "New Falcon", "next_hop": 104, "num": "0x67c7db41", "position": {"altitude": 909, "latitude": 34.059076, "location_source": "LOC_INTERNAL", "longitude": -107.148825, "time_offset_sec": 14733}, "public_key_hex": "9e54cbfcaf0118491aa1eab3a7625cc6df49fa0fd834ae1ad8c8123588fa66a6", "role": "CLIENT", "short_name": "🐺", "snr": 9.19, "status": null, "telemetry": {"air_util_tx": 2.583, "battery_level": 74, "channel_utilization": 9.14, "uptime_seconds": 82355, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.27, "iaq": 0, "relative_humidity": 43.17, "temperature": 11.97}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 7702, "long_name": "Wandering Colt", "next_hop": 198, "num": "0x67e94415", "position": {"altitude": 807, "latitude": 33.017485, "location_source": "LOC_INTERNAL", "longitude": -106.716057, "time_offset_sec": 7857}, "public_key_hex": "26ae97b16f9860433c4e082a5cabbeb41dbb6c112540c755568ab7b08f1a498a", "role": "CLIENT", "short_name": "🌵", "snr": 8.05, "status": null, "telemetry": {"air_util_tx": 0.415, "battery_level": 100, "channel_utilization": 11.41, "uptime_seconds": 30248, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.7, "iaq": 90, "relative_humidity": 58.33, "temperature": 27.24}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1229, "long_name": "Slow Pine", "next_hop": 0, "num": "0x67f415eb", "position": {"altitude": 1216, "latitude": 32.636806, "location_source": "LOC_INTERNAL", "longitude": -107.305779, "time_offset_sec": 1398}, "public_key_hex": "0f91d0ed924d2e09d998b725c484c6e4cb2fe5f292471e070f83aa200e75865a", "role": "CLIENT", "short_name": "SJNW", "snr": 6.33, "status": null, "telemetry": {"air_util_tx": 0.305, "battery_level": 68, "channel_utilization": 10.12, "uptime_seconds": 25875, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 422, "long_name": "River Ridge", "next_hop": 123, "num": "0x683b0a45", "position": null, "public_key_hex": "425e5e85cfcd0aa1e0bd53f85b7a4e484bf93792ee91cd3609183d94d20c5d9b", "role": "CLIENT", "short_name": "RG84", "snr": 2.36, "status": null, "telemetry": {"air_util_tx": 2.641, "battery_level": 40, "channel_utilization": 1.66, "uptime_seconds": 17292, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3971, "long_name": "Sharp Cedar", "next_hop": 186, "num": "0x6868c466", "position": {"altitude": 1487, "latitude": 33.193878, "location_source": "LOC_INTERNAL", "longitude": -106.206522, "time_offset_sec": 4001}, "public_key_hex": "59525123f156706a54754c1e67d8b3086e0cb9fd95e1a6b1001640ef6658af89", "role": "CLIENT", "short_name": "SEK2", "snr": 9.6, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.309, "battery_level": 79, "channel_utilization": 23.54, "uptime_seconds": 14647, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 5391, "long_name": "Lunar Yucca", "next_hop": 0, "num": "0x687e0ac9", "position": {"altitude": 1421, "latitude": 33.639059, "location_source": "LOC_INTERNAL", "longitude": -106.877939, "time_offset_sec": 5462}, "public_key_hex": "af9b3e19d9bb981f90a75197e6b914a7484ef81203ac188f0d2eb219da4f6ddd", "role": "CLIENT", "short_name": "LWKP", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.378, "battery_level": 11, "channel_utilization": 17.17, "uptime_seconds": 8387, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.39, "iaq": 25, "relative_humidity": 23.55, "temperature": 27.2}, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 9012, "long_name": "Happy Seal", "next_hop": 13, "num": "0x68cc8fb0", "position": {"altitude": 1018, "latitude": 32.58207, "location_source": "LOC_INTERNAL", "longitude": -106.841591, "time_offset_sec": 9047}, "public_key_hex": "c6cbbb0ddee343dbd5ef771e515641b68074c65eea44f8c8ca84665b6073760d", "role": "CLIENT", "short_name": "HB6B", "snr": 11.5, "status": null, "telemetry": {"air_util_tx": 0.557, "battery_level": 43, "channel_utilization": 6.67, "uptime_seconds": 70546, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.3, "iaq": 101, "relative_humidity": 60.08, "temperature": 37.12}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 637, "long_name": "Drifting Juniper", "next_hop": 0, "num": "0x68f800b9", "position": {"altitude": 1438, "latitude": 33.828176, "location_source": "LOC_INTERNAL", "longitude": -107.150329, "time_offset_sec": 833}, "public_key_hex": "2e6b387d07584479391d09f2cdddc394efabfd35131aebd3f28fae1bed8aa765", "role": "CLIENT", "short_name": "D2AG", "snr": 10.18, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.1, "iaq": 2, "relative_humidity": 59.01, "temperature": 16.35}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1027, "long_name": "Slow Wolf", "next_hop": 0, "num": "0x68fa8def", "position": {"altitude": 847, "latitude": 33.300981, "location_source": "LOC_INTERNAL", "longitude": -107.967287, "time_offset_sec": 1318}, "public_key_hex": "4500591476d63f42875ae2aa5321ac814eeed207ed71ac3ee6b472d18f7bf79f", "role": "CLIENT", "short_name": "S4WM", "snr": 7.14, "status": null, "telemetry": {"air_util_tx": 0.769, "battery_level": 33, "channel_utilization": 17.21, "uptime_seconds": 144544, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 5115, "long_name": "Frosty Hare", "next_hop": 0, "num": "0x6905c62d", "position": {"altitude": 968, "latitude": 32.467589, "location_source": "LOC_INTERNAL", "longitude": -106.664101, "time_offset_sec": 5217}, "public_key_hex": "5da6e07ca7a50d39a43071d3390e5f6cf137df1f183b681f29dea927a9ba10fa", "role": "SENSOR", "short_name": "FJGH", "snr": 8.43, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 5365, "long_name": "Howling Lion NM9PB", "next_hop": 60, "num": "0x6911e1bb", "position": {"altitude": 1294, "latitude": 33.267817, "location_source": "LOC_INTERNAL", "longitude": -107.478924, "time_offset_sec": 5537}, "public_key_hex": "8666beb91a0b831cc2af5cad1d84e12618e57dce061de9a967a81ceaa9211e79", "role": "ROUTER_LATE", "short_name": "HGTB", "snr": 8.46, "status": null, "telemetry": {"air_util_tx": 0.14, "battery_level": 47, "channel_utilization": 6.09, "uptime_seconds": 18672, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4439, "long_name": "Iron Juniper", "next_hop": 138, "num": "0x693605f1", "position": {"altitude": 1334, "latitude": 32.07539, "location_source": "LOC_INTERNAL", "longitude": -107.174793, "time_offset_sec": 4619}, "public_key_hex": "f5c2bb6fc784d62d660659806b83fbcd5a1e2fd4f5da482af089fe881fc90d18", "role": "CLIENT", "short_name": "IAFI", "snr": 8.38, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.699, "battery_level": 26, "channel_utilization": 10.21, "uptime_seconds": 119387, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1679, "long_name": "Sunny Cactus", "next_hop": 0, "num": "0x69364f16", "position": {"altitude": 1457, "latitude": 32.718543, "location_source": "LOC_INTERNAL", "longitude": -107.722017, "time_offset_sec": 1920}, "public_key_hex": "f03bdc84fd103ea1e34870e50c98bfa00d8532944bf3f935a971da28b0a58bd9", "role": "CLIENT", "short_name": "SLZR", "snr": -0.33, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.217, "battery_level": 30, "channel_utilization": 25.73, "uptime_seconds": 120358, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 992, "long_name": "Loud Mustang", "next_hop": 0, "num": "0x6952dede", "position": null, "public_key_hex": "821cece7d7e3e7bef2467c2a21d0c1b7dc3d4dac26921ceb6d95d3e51873b5ea", "role": "CLIENT", "short_name": "LY1Q", "snr": -1.36, "status": null, "telemetry": {"air_util_tx": 0.397, "battery_level": 88, "channel_utilization": 12.25, "uptime_seconds": 102117, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.63, "iaq": 49, "relative_humidity": 67.46, "temperature": 7.21}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3935, "long_name": "Happy Pike", "next_hop": 0, "num": "0x698981d6", "position": {"altitude": 839, "latitude": 32.767505, "location_source": "LOC_INTERNAL", "longitude": -107.381568, "time_offset_sec": 4052}, "public_key_hex": "e7b29602561abe3cfec3bc45258f98c99d8e74413126c9b8b6246b3c01dcc868", "role": "ROUTER", "short_name": "HH2P", "snr": 5.28, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.99, "iaq": 41, "relative_humidity": 52.54, "temperature": 12.4}, "hops_away": 0, "hw_model": "CROWPANEL", "last_heard_offset_sec": 4467, "long_name": "Lunar Raven", "next_hop": 0, "num": "0x699a2e62", "position": {"altitude": 1210, "latitude": 34.176882, "location_source": "LOC_INTERNAL", "longitude": -106.812301, "time_offset_sec": 4538}, "public_key_hex": "9b6d461ca95091a9e1ece0423e0e73dcb808a68c066200c8b94f9cda561ff421", "role": "CLIENT", "short_name": "L8FQ", "snr": 8.55, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.796, "battery_level": 38, "channel_utilization": 12.56, "uptime_seconds": 195524, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.16, "iaq": 26, "relative_humidity": 90.61, "temperature": 18.29}, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 925, "long_name": "Black Beaver", "next_hop": 39, "num": "0x69d51af3", "position": null, "public_key_hex": "1164d2d43ec661614ff043b8d3365ef44f8a79f838c53268d5475bbf5a788d0f", "role": "CLIENT", "short_name": "BZ0K", "snr": 7.29, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.143, "battery_level": 12, "channel_utilization": 12.45, "uptime_seconds": 49543, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 953, "long_name": "Floating Lynx", "next_hop": 0, "num": "0x69de6f0a", "position": {"altitude": 1635, "latitude": 32.798598, "location_source": "LOC_INTERNAL", "longitude": -106.517605, "time_offset_sec": 1207}, "public_key_hex": "19f4aee95f2407f180297d56ead5d926dec72fce86018ea42d7e701baf8dce9b", "role": "CLIENT_HIDDEN", "short_name": "F697", "snr": 7.48, "status": null, "telemetry": {"air_util_tx": 0.274, "battery_level": 33, "channel_utilization": 7.56, "uptime_seconds": 172868, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 154, "long_name": "Sky Crow", "next_hop": 231, "num": "0x69f6cb73", "position": {"altitude": 1118, "latitude": 33.273635, "location_source": "LOC_INTERNAL", "longitude": -107.479269, "time_offset_sec": 327}, "public_key_hex": "", "role": "CLIENT", "short_name": "SLJ7", "snr": -0.73, "status": null, "telemetry": {"air_util_tx": 0.136, "battery_level": 77, "channel_utilization": 7.06, "uptime_seconds": 90621, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 278, "long_name": "Found Lynx", "next_hop": 139, "num": "0x6a1a4ca9", "position": null, "public_key_hex": "1c8fc6ade3d68f2d3b1e4e86911ec724a519cd980a2296dc783c181adda0ed0a", "role": "ROUTER", "short_name": "FYB2", "snr": 2.24, "status": null, "telemetry": {"air_util_tx": 0.975, "battery_level": 41, "channel_utilization": 21.16, "uptime_seconds": 22502, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.03, "iaq": 29, "relative_humidity": 64.25, "temperature": 9.79}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2419, "long_name": "Red Ridge W57XS", "next_hop": 0, "num": "0x6a2549f5", "position": {"altitude": 1357, "latitude": 32.524297, "location_source": "LOC_INTERNAL", "longitude": -107.304498, "time_offset_sec": 2585}, "public_key_hex": "d657b6570d84ec8fdd2eb942d3d755b52d4ed80293e7314067f21f98c7a9bdb2", "role": "CLIENT_HIDDEN", "short_name": "RZQQ", "snr": 6.26, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2568, "long_name": "Howling Gecko", "next_hop": 0, "num": "0x6a50e4d0", "position": null, "public_key_hex": "b47ee0117049d2af425d12f7d8f5fbefed3a480754ee2c0dbecf09a18c025af5", "role": "CLIENT", "short_name": "HD7A", "snr": -1.82, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M6", "last_heard_offset_sec": 5613, "long_name": "Dusk Pike", "next_hop": 0, "num": "0x6a863c8a", "position": {"altitude": 1266, "latitude": 33.956843, "location_source": "LOC_INTERNAL", "longitude": -107.103238, "time_offset_sec": 5745}, "public_key_hex": "5586f21d63d0eb32b509dc147dfa531733f81aa7dc563fc06b9c241453445bc5", "role": "TRACKER", "short_name": "D6SI", "snr": 6.39, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4182, "long_name": "New Stag", "next_hop": 0, "num": "0x6a8651ea", "position": {"altitude": 1601, "latitude": 33.567009, "location_source": "LOC_INTERNAL", "longitude": -106.789567, "time_offset_sec": 4376}, "public_key_hex": "494aa35bd1887854991e6d34262266ab5162983d9972f381d3cc36a9780d3b85", "role": "CLIENT", "short_name": "NO5U", "snr": 5.02, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4166, "long_name": "Wandering Aspen", "next_hop": 0, "num": "0x6a87aa4f", "position": null, "public_key_hex": "cdd2bac4681fcb2c94d259ef0678001c99251295f66131975208870af1d03a7b", "role": "TAK", "short_name": "WDWG", "snr": 10.91, "status": null, "telemetry": {"air_util_tx": 0.503, "battery_level": 18, "channel_utilization": 7.77, "uptime_seconds": 47462, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 962, "long_name": "Blue Bronco", "next_hop": 0, "num": "0x6a8c6946", "position": {"altitude": 1537, "latitude": 32.175682, "location_source": "LOC_INTERNAL", "longitude": -107.309738, "time_offset_sec": 970}, "public_key_hex": "d679071755209084937cfa0475f47859feed9710faf922c743766d5bbee31cc9", "role": "CLIENT", "short_name": "BKZE", "snr": 9.9, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.1, "battery_level": 36, "channel_utilization": 10.34, "uptime_seconds": 37512, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3901, "long_name": "New Turtle", "next_hop": 0, "num": "0x6aa32ab5", "position": {"altitude": 1439, "latitude": 33.198996, "location_source": "LOC_INTERNAL", "longitude": -107.429963, "time_offset_sec": 4068}, "public_key_hex": "6e50f39c554985d8a878f52470fd87564f5e0f667e1941afa54f6bc5a1d8b440", "role": "CLIENT", "short_name": "N9LS", "snr": 3.04, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 993.13, "iaq": 32, "relative_humidity": 39.87, "temperature": 24.98}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1100, "long_name": "Canyon Dolphin K12QW", "next_hop": 0, "num": "0x6ac5a849", "position": null, "public_key_hex": "89b213e946a06d6a93bd464912b06c69976e802cf2329db2bbf4b169ff79b69a", "role": "CLIENT_HIDDEN", "short_name": "🐢", "snr": 4.48, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.253, "battery_level": 92, "channel_utilization": 18.74, "uptime_seconds": 86816, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 147, "long_name": "Dusk Gecko", "next_hop": 0, "num": "0x6afa6822", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "D7K7", "snr": 10.63, "status": null, "telemetry": {"air_util_tx": 0.876, "battery_level": 21, "channel_utilization": 6.81, "uptime_seconds": 103389, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.2, "iaq": 81, "relative_humidity": 78.09, "temperature": 24.66}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 9156, "long_name": "Short Mamba N52FE", "next_hop": 0, "num": "0x6b202303", "position": {"altitude": 1685, "latitude": 33.982483, "location_source": "LOC_INTERNAL", "longitude": -108.10366, "time_offset_sec": 9423}, "public_key_hex": "5c8531db27b6620797eef9fe4ef00b4fe42e68bbdaef9e760b4976dcdfab4ddf", "role": "ROUTER", "short_name": "SBL1", "snr": 9.29, "status": null, "telemetry": {"air_util_tx": 0.652, "battery_level": 77, "channel_utilization": 26.34, "uptime_seconds": 56913, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2003, "long_name": "Brave Cedar", "next_hop": 0, "num": "0x6b56a88b", "position": {"altitude": 1590, "latitude": 33.155252, "location_source": "LOC_INTERNAL", "longitude": -107.41727, "time_offset_sec": 2066}, "public_key_hex": "e99eba2ed94e4026ddd880518db8eb982ec48a4dcc65f7050ef146d1fbfda88a", "role": "CLIENT", "short_name": "BJK7", "snr": 2.36, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.167, "battery_level": 75, "channel_utilization": 26.41, "uptime_seconds": 129313, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.36, "iaq": 61, "relative_humidity": 93.61, "temperature": 28.35}, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 4891, "long_name": "Tiny Otter", "next_hop": 0, "num": "0x6b60f8a6", "position": {"altitude": 1448, "latitude": 33.947455, "location_source": "LOC_INTERNAL", "longitude": -108.046407, "time_offset_sec": 5031}, "public_key_hex": "943e50cf5b0e8c71f8878b433e3dce6b55db7d7907985fdd642d8749e3851dae", "role": "CLIENT", "short_name": "T3AE", "snr": 8.81, "status": null, "telemetry": {"air_util_tx": 0.154, "battery_level": 83, "channel_utilization": 8.19, "uptime_seconds": 129315, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1024.92, "iaq": 95, "relative_humidity": 87.98, "temperature": 22.03}, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 7340, "long_name": "Lost Badger", "next_hop": 182, "num": "0x6b6a3b49", "position": {"altitude": 1130, "latitude": 32.783117, "location_source": "LOC_INTERNAL", "longitude": -107.034703, "time_offset_sec": 7580}, "public_key_hex": "38fae98b337ee3d8b13ef1255de136da15a449df3139dbbc32e44187ac195c5e", "role": "CLIENT", "short_name": "LSH2", "snr": 3.79, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 11343, "long_name": "Roving Turtle", "next_hop": 0, "num": "0x6b6efa85", "position": {"altitude": 1207, "latitude": 32.590766, "location_source": "LOC_INTERNAL", "longitude": -107.069907, "time_offset_sec": 11607}, "public_key_hex": "b294a14e6623f5d02e0d6a9c268c04fefdaab914d1dd3ce4d87607c91e6d9ff1", "role": "CLIENT", "short_name": "RZXT", "snr": 11.68, "status": null, "telemetry": {"air_util_tx": 0.482, "battery_level": 76, "channel_utilization": 8.26, "uptime_seconds": 354381, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 4646, "long_name": "Mountain Lynx", "next_hop": 0, "num": "0x6b7078f0", "position": {"altitude": 959, "latitude": 33.203776, "location_source": "LOC_INTERNAL", "longitude": -107.286921, "time_offset_sec": 4762}, "public_key_hex": "87cbc616c930f7d9f2090035e9cbae07d6a1a1e8696eaea171157a54e6d7314d", "role": "ROUTER", "short_name": "🦉", "snr": 3.96, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.204, "battery_level": 33, "channel_utilization": 8.43, "uptime_seconds": 116327, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2317, "long_name": "Silver Falcon", "next_hop": 167, "num": "0x6b7097b0", "position": {"altitude": 1043, "latitude": 33.077048, "location_source": "LOC_INTERNAL", "longitude": -107.489467, "time_offset_sec": 2561}, "public_key_hex": "b94e33a3c01ea0499d9f66899f305e30ee39a3af00eabbd73fa12f590acab505", "role": "CLIENT", "short_name": "🐢", "snr": 10.25, "status": null, "telemetry": {"air_util_tx": 1.054, "battery_level": 49, "channel_utilization": 13.92, "uptime_seconds": 48079, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 493, "long_name": "Dusk Wolf", "next_hop": 97, "num": "0x6b73a416", "position": {"altitude": 1456, "latitude": 34.133143, "location_source": "LOC_INTERNAL", "longitude": -107.005384, "time_offset_sec": 566}, "public_key_hex": "2735389f6ad58804e311b3ed27540791a05bbb0a0e145949a21ae4b23ccdd9d0", "role": "CLIENT", "short_name": "DVCS", "snr": 3.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 2117, "long_name": "Lunar Turtle", "next_hop": 194, "num": "0x6b7e67d7", "position": {"altitude": 881, "latitude": 33.727922, "location_source": "LOC_INTERNAL", "longitude": -107.431255, "time_offset_sec": 2286}, "public_key_hex": "8d2e22d3a97d7b802989227796e01f96eed4b2c81533aa7e4c189ab39e190053", "role": "CLIENT", "short_name": "LES1", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.143, "battery_level": 18, "channel_utilization": 12.62, "uptime_seconds": 65836, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.23, "iaq": 43, "relative_humidity": 7.94, "temperature": 31.05}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 254, "long_name": "Sharp Hawk AB8YH", "next_hop": 0, "num": "0x6b910353", "position": {"altitude": 1470, "latitude": 33.436322, "location_source": "LOC_INTERNAL", "longitude": -107.678356, "time_offset_sec": 308}, "public_key_hex": "bad2ed6fbadaafa1e5dc899bda0742515be6e3a41a8809d5c4146500ea10b3a2", "role": "CLIENT", "short_name": "🌊", "snr": 4.19, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6888, "long_name": "Frozen Pike", "next_hop": 0, "num": "0x6ba17f51", "position": {"altitude": 1138, "latitude": 32.779565, "location_source": "LOC_INTERNAL", "longitude": -107.179201, "time_offset_sec": 6940}, "public_key_hex": "", "role": "CLIENT", "short_name": "FHJ9", "snr": 6.02, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3579, "long_name": "Loud Falcon", "next_hop": 0, "num": "0x6bb3a0f7", "position": {"altitude": 1204, "latitude": 33.717255, "location_source": "LOC_INTERNAL", "longitude": -107.740182, "time_offset_sec": 3776}, "public_key_hex": "cf29b2b6a579954bf571e91d0a061dd5f49ce81ab96c33d62ee67b05c9d0d5d4", "role": "CLIENT", "short_name": "L2IT", "snr": 8.13, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.235, "battery_level": 25, "channel_utilization": 17.39, "uptime_seconds": 63015, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.0, "iaq": 33, "relative_humidity": 67.51, "temperature": 8.61}, "hops_away": 3, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3745, "long_name": "Loud Oak", "next_hop": 160, "num": "0x6c889f5d", "position": {"altitude": 1400, "latitude": 32.168683, "location_source": "LOC_INTERNAL", "longitude": -107.114379, "time_offset_sec": 3946}, "public_key_hex": "aec22695b24f646a0ac06b6c29597ae8e610094d1aa0999082c97febfb9622d4", "role": "CLIENT", "short_name": "LRN3", "snr": 1.16, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.54, "iaq": 57, "relative_humidity": 40.12, "temperature": 30.27}, "hops_away": 0, "hw_model": "HELTEC_V4_R8", "last_heard_offset_sec": 2694, "long_name": "Gold Falcon", "next_hop": 0, "num": "0x6c90f559", "position": null, "public_key_hex": "c8442c41a8786f0c5ab403a0e2e63d88a5d5dd5c1002bc7ed6a8d2053734a24d", "role": "CLIENT", "short_name": "GY6W", "snr": 5.1, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1388, "long_name": "Drowsy Lion", "next_hop": 0, "num": "0x6cfda2cb", "position": {"altitude": 1426, "latitude": 33.00929, "location_source": "LOC_INTERNAL", "longitude": -107.374433, "time_offset_sec": 1540}, "public_key_hex": "", "role": "CLIENT", "short_name": "🐺", "snr": 5.19, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.727, "battery_level": 73, "channel_utilization": 7.81, "uptime_seconds": 50860, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.15, "iaq": 33, "relative_humidity": 44.11, "temperature": 34.17}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 5785, "long_name": "Mountain Turtle", "next_hop": 0, "num": "0x6d02c569", "position": {"altitude": 1738, "latitude": 34.056317, "location_source": "LOC_INTERNAL", "longitude": -107.410725, "time_offset_sec": 6022}, "public_key_hex": "8ce3de8674b38f76da003ced64245f160dc4da43c1f85fb6c48d8fb2d8d40660", "role": "CLIENT", "short_name": "MCDC", "snr": 10.23, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.475, "battery_level": 35, "channel_utilization": 4.05, "uptime_seconds": 7084, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4265, "long_name": "New Turtle", "next_hop": 119, "num": "0x6d647fc5", "position": {"altitude": 1754, "latitude": 33.577823, "location_source": "LOC_INTERNAL", "longitude": -106.961958, "time_offset_sec": 4351}, "public_key_hex": "cf9f39392192d1b0c83e736d04674bf2fee53297859513f7ba0bb0c2689032cb", "role": "CLIENT", "short_name": "NJ4O", "snr": -1.27, "status": null, "telemetry": {"air_util_tx": 1.876, "battery_level": 69, "channel_utilization": 7.62, "uptime_seconds": 116373, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 6038, "long_name": "Blue Adder", "next_hop": 0, "num": "0x6db5ae6e", "position": {"altitude": 1339, "latitude": 33.601814, "location_source": "LOC_INTERNAL", "longitude": -108.097421, "time_offset_sec": 6221}, "public_key_hex": "802fef60a1d0553d466e56c341af016f31409be95e0ff15bbbb10ead1bf6ab8b", "role": "CLIENT", "short_name": "BOMO", "snr": -0.75, "status": null, "telemetry": {"air_util_tx": 0.376, "battery_level": 64, "channel_utilization": 5.94, "uptime_seconds": 304158, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 742, "long_name": "Tall Owl", "next_hop": 0, "num": "0x6dc71bee", "position": null, "public_key_hex": "9bb28d95f220caf4a738f71da5cc86f96879a8c8fdd6acbc74733fb9f56a2d14", "role": "CLIENT", "short_name": "TDS7", "snr": 3.18, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 511, "long_name": "Sky Colt", "next_hop": 0, "num": "0x6dec45c3", "position": {"altitude": 1174, "latitude": 32.69364, "location_source": "LOC_INTERNAL", "longitude": -107.492125, "time_offset_sec": 727}, "public_key_hex": "23fe8e3ce5bb4349229a21cc679df8ef57b338c75e31fd6be55cae18983160bd", "role": "TRACKER", "short_name": "🔥", "snr": -0.61, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2880, "long_name": "Lone Yucca", "next_hop": 147, "num": "0x6df91657", "position": null, "public_key_hex": "5b1f11fa4cc8f7297deb4f41e7c5c9e87d19b928b9af27883a77fece558e2f86", "role": "CLIENT", "short_name": "LAT9", "snr": 2.57, "status": null, "telemetry": {"air_util_tx": 0.675, "battery_level": 48, "channel_utilization": 11.82, "uptime_seconds": 30166, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 2215, "long_name": "Gold Mamba", "next_hop": 100, "num": "0x6e1e307a", "position": {"altitude": 1390, "latitude": 33.341554, "location_source": "LOC_INTERNAL", "longitude": -106.928921, "time_offset_sec": 2264}, "public_key_hex": "dd8f2342b8c467a315dc320f5956280fd3f4a5292a81150c6966c6d87dab377a", "role": "CLIENT", "short_name": "GMHU", "snr": 2.35, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.186, "battery_level": 65, "channel_utilization": 13.05, "uptime_seconds": 184960, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.51, "iaq": 72, "relative_humidity": 38.39, "temperature": 10.96}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3158, "long_name": "Bright Bison", "next_hop": 9, "num": "0x6e250d8c", "position": {"altitude": 1471, "latitude": 33.354328, "location_source": "LOC_INTERNAL", "longitude": -108.386866, "time_offset_sec": 3431}, "public_key_hex": "db67cafabe56bdc7c5626c41003e7de868f80b14c3a6f7f88696b22341a2fc44", "role": "CLIENT", "short_name": "BBMZ", "snr": 8.27, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.642, "battery_level": 93, "channel_utilization": 4.92, "uptime_seconds": 238, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1019.86, "iaq": 66, "relative_humidity": 68.48, "temperature": 29.8}, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 454, "long_name": "Loud Bluff KQ8PZ", "next_hop": 129, "num": "0x6e5fe7a7", "position": {"altitude": 1774, "latitude": 33.150554, "location_source": "LOC_INTERNAL", "longitude": -107.309803, "time_offset_sec": 519}, "public_key_hex": "6cf1670ce35b95a272021b0b3f794fe0b3967d3bb79b19f613eca72aeb764ee4", "role": "CLIENT", "short_name": "LJ1B", "snr": 4.38, "status": null, "telemetry": {"air_util_tx": 0.477, "battery_level": 57, "channel_utilization": 4.34, "uptime_seconds": 17520, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 5259, "long_name": "Frosty Marmot", "next_hop": 117, "num": "0x6e613cfb", "position": {"altitude": 1339, "latitude": 32.897416, "location_source": "LOC_INTERNAL", "longitude": -107.310706, "time_offset_sec": 5449}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦇", "snr": 10.07, "status": null, "telemetry": {"air_util_tx": 0.374, "battery_level": 84, "channel_utilization": 16.79, "uptime_seconds": 21431, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.13, "iaq": 18, "relative_humidity": 26.36, "temperature": 20.64}, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 1810, "long_name": "Drowsy Juniper", "next_hop": 0, "num": "0x6e713a57", "position": {"altitude": 1737, "latitude": 33.363084, "location_source": "LOC_INTERNAL", "longitude": -107.041566, "time_offset_sec": 2010}, "public_key_hex": "1c031bad9517a06eaf4ab68ccd7a1102d14d6a9ceefce480f17fd56f103902cd", "role": "CLIENT", "short_name": "DVZO", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.237, "battery_level": 89, "channel_utilization": 19.7, "uptime_seconds": 42275, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 3426, "long_name": "Tiny Stag", "next_hop": 0, "num": "0x6eacc54d", "position": {"altitude": 1369, "latitude": 33.061103, "location_source": "LOC_INTERNAL", "longitude": -107.551595, "time_offset_sec": 3469}, "public_key_hex": "88c5862e8435b68d864ea9765c9d9667171b1d8857f18568b7bd2b0092e9568c", "role": "CLIENT_BASE", "short_name": "TUCX", "snr": 8.19, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1016.57, "iaq": 100, "relative_humidity": 58.67, "temperature": 40.04}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4126, "long_name": "Copper Bronco WD6ZF", "next_hop": 135, "num": "0x6eba1993", "position": {"altitude": 1434, "latitude": 34.222493, "location_source": "LOC_INTERNAL", "longitude": -107.019815, "time_offset_sec": 4293}, "public_key_hex": "07767f5cd2306c68f79b7665c60c9fb8635b670dbaddb937c3e9ffa54622236d", "role": "CLIENT", "short_name": "COQ7", "snr": 4.49, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3834, "long_name": "Lost Moose", "next_hop": 0, "num": "0x6ec0f505", "position": null, "public_key_hex": "26e41ddb8976b33069e0c7848732ca6ad065a9dc1e29e98dd943d07d5a4fb0f7", "role": "CLIENT", "short_name": "LAYI", "snr": 9.15, "status": null, "telemetry": {"air_util_tx": 0.134, "battery_level": 30, "channel_utilization": 6.34, "uptime_seconds": 6569, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.65, "iaq": 17, "relative_humidity": 75.61, "temperature": 36.13}, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 3990, "long_name": "Drowsy Tortoise", "next_hop": 0, "num": "0x6f0f0b96", "position": {"altitude": 1334, "latitude": 33.228728, "location_source": "LOC_INTERNAL", "longitude": -108.292462, "time_offset_sec": 4044}, "public_key_hex": "dea06c82b3dde0048a1159d1784421f9fd177ebc36e86e771961dd674ecca0d2", "role": "CLIENT", "short_name": "DI4T", "snr": 0.69, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.167, "battery_level": 56, "channel_utilization": 6.67, "uptime_seconds": 62833, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1014.93, "iaq": 0, "relative_humidity": 53.02, "temperature": 26.98}, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 1562, "long_name": "Desert Ridge", "next_hop": 0, "num": "0x6f1220a7", "position": {"altitude": 1217, "latitude": 32.765129, "location_source": "LOC_INTERNAL", "longitude": -107.158036, "time_offset_sec": 1716}, "public_key_hex": "fab0606fa34296406c22dbbb311394e22475eb59b02189b68a9bfd53b8490681", "role": "CLIENT", "short_name": "DL2S", "snr": 5.54, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3068, "long_name": "Howling Whale", "next_hop": 0, "num": "0x6f34d10b", "position": {"altitude": 1328, "latitude": 33.500233, "location_source": "LOC_INTERNAL", "longitude": -107.708984, "time_offset_sec": 3127}, "public_key_hex": "1bef083a9674680c8fb395f94014c7aafbed493a8bc45db6d67d620f84f8cb1b", "role": "CLIENT_HIDDEN", "short_name": "H8HL", "snr": 4.16, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 817, "long_name": "Roving Cobra", "next_hop": 202, "num": "0x6f5bec3e", "position": {"altitude": 1523, "latitude": 32.114122, "location_source": "LOC_INTERNAL", "longitude": -106.714243, "time_offset_sec": 1076}, "public_key_hex": "b98177b3be0ef75c754af630fe11b509fbfe51b05a787ca20994d8ef379f60f6", "role": "CLIENT", "short_name": "RB2K", "snr": 12.0, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.967, "battery_level": 16, "channel_utilization": 33.62, "uptime_seconds": 251809, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 995.48, "iaq": 57, "relative_humidity": 63.31, "temperature": 24.87}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 7759, "long_name": "Rough Oak", "next_hop": 0, "num": "0x6f7f3a31", "position": null, "public_key_hex": "b9ff23e44cfd2a46b85e5c31a04243838be214a357c49ac6eec17a3de7a5a15f", "role": "ROUTER", "short_name": "RF6U", "snr": 6.36, "status": null, "telemetry": {"air_util_tx": 0.46, "battery_level": 28, "channel_utilization": 29.96, "uptime_seconds": 51626, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 307, "long_name": "Forest Eagle", "next_hop": 0, "num": "0x6f97a9eb", "position": {"altitude": 985, "latitude": 33.565735, "location_source": "LOC_INTERNAL", "longitude": -106.797868, "time_offset_sec": 482}, "public_key_hex": "21a4c8049f11b7446c9c4cffecec820c8086a62a7516102b0288e556d4cd0bc0", "role": "CLIENT", "short_name": "F06C", "snr": 5.58, "status": null, "telemetry": {"air_util_tx": 1.298, "battery_level": 95, "channel_utilization": 12.91, "uptime_seconds": 250425, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.88, "iaq": 69, "relative_humidity": 30.2, "temperature": 25.09}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 274, "long_name": "Tiny Oak", "next_hop": 0, "num": "0x6fa3dfae", "position": {"altitude": 1342, "latitude": 33.306794, "location_source": "LOC_INTERNAL", "longitude": -107.186608, "time_offset_sec": 471}, "public_key_hex": "c10ead2a52a9c092362d13105b51c0c2dfe5b0a17b55962363a569edc3298be4", "role": "CLIENT", "short_name": "T5LU", "snr": 11.59, "status": null, "telemetry": {"air_util_tx": 2.182, "battery_level": 16, "channel_utilization": 7.84, "uptime_seconds": 110141, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 1737, "long_name": "Drowsy Oak", "next_hop": 144, "num": "0x6fd5a4a4", "position": {"altitude": 1497, "latitude": 34.41568, "location_source": "LOC_INTERNAL", "longitude": -107.680624, "time_offset_sec": 1808}, "public_key_hex": "8dfeb1a02b9407688e8418ba06652d769d8f203f809c632b920ce378b34673fb", "role": "CLIENT", "short_name": "D7L1", "snr": 5.63, "status": null, "telemetry": {"air_util_tx": 0.545, "battery_level": 72, "channel_utilization": 9.7, "uptime_seconds": 29684, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2989, "long_name": "Soft Whale", "next_hop": 10, "num": "0x701dc9f8", "position": {"altitude": 1271, "latitude": 33.478611, "location_source": "LOC_INTERNAL", "longitude": -107.408703, "time_offset_sec": 3138}, "public_key_hex": "a991abe2aeab884a39ca8093e02610baa8511d7caa661f4a5946a3d39dda1382", "role": "CLIENT", "short_name": "🌵", "snr": 7.76, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.883, "battery_level": 56, "channel_utilization": 3.81, "uptime_seconds": 71083, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 4387, "long_name": "Fast Tortoise", "next_hop": 0, "num": "0x702100ce", "position": {"altitude": 1503, "latitude": 33.651216, "location_source": "LOC_INTERNAL", "longitude": -107.140825, "time_offset_sec": 4639}, "public_key_hex": "c6d71e735afa85db096d7c5a7509cb8500cbfb168f01c1edff417e28b431d547", "role": "CLIENT", "short_name": "F8MR", "snr": -1.3, "status": null, "telemetry": {"air_util_tx": 0.196, "battery_level": 88, "channel_utilization": 8.68, "uptime_seconds": 34075, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1285, "long_name": "Whispering Aspen", "next_hop": 0, "num": "0x70483cff", "position": {"altitude": 1578, "latitude": 33.340106, "location_source": "LOC_INTERNAL", "longitude": -107.7641, "time_offset_sec": 1336}, "public_key_hex": "3959b38604ec90df4b7b0a46577efac326b143b6ef4b2e177a2387b3c35044a2", "role": "CLIENT", "short_name": "WBLE", "snr": 10.26, "status": null, "telemetry": {"air_util_tx": 1.826, "battery_level": 58, "channel_utilization": 5.42, "uptime_seconds": 216237, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 453, "long_name": "Black Salmon", "next_hop": 0, "num": "0x706182f9", "position": {"altitude": 1528, "latitude": 33.161761, "location_source": "LOC_INTERNAL", "longitude": -106.722945, "time_offset_sec": 498}, "public_key_hex": "0300647d488191b7a25564fcea2bea474d7251a522db7be665f1315ce5d23141", "role": "CLIENT", "short_name": "BIIX", "snr": 3.22, "status": null, "telemetry": {"air_util_tx": 0.079, "battery_level": 55, "channel_utilization": 24.68, "uptime_seconds": 307753, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 6, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3121, "long_name": "Solar Cactus", "next_hop": 65, "num": "0x70bf1327", "position": null, "public_key_hex": "f0e70e8e170003b9dfcb25159dfe07a9ffea27f529e29bc2223210073326fcf8", "role": "CLIENT_MUTE", "short_name": "🌙", "snr": 5.61, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 9283, "long_name": "Old Cougar", "next_hop": 0, "num": "0x70c5df6f", "position": {"altitude": 1677, "latitude": 33.094794, "location_source": "LOC_INTERNAL", "longitude": -106.878224, "time_offset_sec": 9438}, "public_key_hex": "b448c36d3d502334fab6cbc62ae700b9111195aba7face8ba88725aefa459bd1", "role": "CLIENT_HIDDEN", "short_name": "OI80", "snr": 3.84, "status": null, "telemetry": {"air_util_tx": 0.413, "battery_level": 59, "channel_utilization": 17.07, "uptime_seconds": 65346, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.64, "iaq": 57, "relative_humidity": 43.15, "temperature": 14.78}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 714, "long_name": "River Mesa", "next_hop": 0, "num": "0x70ddc138", "position": null, "public_key_hex": "ed3a9627314d519adda95e24944f73d99a4d6fd59c15c786e4abdca1aeafccf8", "role": "CLIENT", "short_name": "RR8X", "snr": 7.03, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 346, "long_name": "Tall Mamba", "next_hop": 0, "num": "0x7121ea57", "position": {"altitude": 1738, "latitude": 32.408201, "location_source": "LOC_INTERNAL", "longitude": -107.312202, "time_offset_sec": 504}, "public_key_hex": "039df83aa04902f6321bb355c66c6f55a0c8dc49661a8a56f900e4027496de34", "role": "CLIENT", "short_name": "T97Z", "snr": 5.14, "status": null, "telemetry": {"air_util_tx": 2.091, "battery_level": 64, "channel_utilization": 10.13, "uptime_seconds": 196835, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2559, "long_name": "Hidden Bass", "next_hop": 18, "num": "0x7130c59e", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "HHN8", "snr": 5.9, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.16, "battery_level": 93, "channel_utilization": 4.52, "uptime_seconds": 52435, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.84, "iaq": 59, "relative_humidity": 32.69, "temperature": 16.24}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1657, "long_name": "Old Sage", "next_hop": 0, "num": "0x7131018f", "position": {"altitude": 1490, "latitude": 32.081112, "location_source": "LOC_INTERNAL", "longitude": -108.032479, "time_offset_sec": 1698}, "public_key_hex": "1dc2d801f7484c3b06add87d5d6f97419c340134aa94d94400b3db2dcac5e59e", "role": "ROUTER", "short_name": "OMWM", "snr": 6.81, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.324, "battery_level": 31, "channel_utilization": 2.77, "uptime_seconds": 33483, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.88, "iaq": 56, "relative_humidity": 57.21, "temperature": 27.01}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2535, "long_name": "Old Gecko", "next_hop": 0, "num": "0x713fb2bf", "position": null, "public_key_hex": "0039ac684cb22cfc7a665c71ca89eca93367c4fe23c75893565a603f86623790", "role": "CLIENT", "short_name": "OMZH", "snr": 4.96, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 393, "long_name": "Iron Raven", "next_hop": 0, "num": "0x7148fc2d", "position": {"altitude": 1417, "latitude": 33.385749, "location_source": "LOC_INTERNAL", "longitude": -107.234875, "time_offset_sec": 498}, "public_key_hex": "", "role": "CLIENT_BASE", "short_name": "ITXR", "snr": 11.7, "status": null, "telemetry": {"air_util_tx": 0.974, "battery_level": 28, "channel_utilization": 22.03, "uptime_seconds": 113989, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2118, "long_name": "Stone Viper", "next_hop": 26, "num": "0x7152ff5a", "position": null, "public_key_hex": "c6a93194fa2b62e3c611d6f38a4bbb540257b66e2c9739553b20ac67b657ce7e", "role": "CLIENT", "short_name": "🗻", "snr": 7.69, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 8975, "long_name": "Silver Tortoise", "next_hop": 0, "num": "0x71555341", "position": {"altitude": 1067, "latitude": 33.435845, "location_source": "LOC_INTERNAL", "longitude": -107.094618, "time_offset_sec": 9219}, "public_key_hex": "b30bd7f0e070109837ee06d8de0038aa9f5e14a555d83098295bcfdd2cffd7ed", "role": "ROUTER", "short_name": "S5NY", "snr": 3.54, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": {"barometric_pressure": 1017.91, "iaq": 0, "relative_humidity": 57.67, "temperature": 17.6}, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 249, "long_name": "Rough Raven", "next_hop": 27, "num": "0x715c0a1d", "position": {"altitude": 1485, "latitude": 34.007687, "location_source": "LOC_INTERNAL", "longitude": -106.976978, "time_offset_sec": 253}, "public_key_hex": "64a8a6577117b509bd9c0a491c6aa9c574fc29e0414cdcb1490369b1f4b3b86a", "role": "CLIENT", "short_name": "RATN", "snr": 8.47, "status": null, "telemetry": {"air_util_tx": 0.638, "battery_level": 73, "channel_utilization": 13.41, "uptime_seconds": 70951, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": {"barometric_pressure": 1009.83, "iaq": 92, "relative_humidity": 53.95, "temperature": 11.06}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2794, "long_name": "Fast Badger", "next_hop": 0, "num": "0x716d590c", "position": {"altitude": 1226, "latitude": 33.423635, "location_source": "LOC_INTERNAL", "longitude": -107.893926, "time_offset_sec": 2818}, "public_key_hex": "", "role": "CLIENT", "short_name": "F1E3", "snr": 1.11, "status": null, "telemetry": {"air_util_tx": 0.406, "battery_level": 45, "channel_utilization": 3.0, "uptime_seconds": 53655, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3099, "long_name": "Roving Pine", "next_hop": 196, "num": "0x71786800", "position": {"altitude": 1213, "latitude": 32.713114, "location_source": "LOC_INTERNAL", "longitude": -106.520489, "time_offset_sec": 3305}, "public_key_hex": "d6e5e79b364db625643ba0ee628386d7554102a9afa30a58f85132cebf67a556", "role": "CLIENT", "short_name": "🦅", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.028, "battery_level": 75, "channel_utilization": 12.83, "uptime_seconds": 13760, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.88, "iaq": 53, "relative_humidity": 70.79, "temperature": 10.76}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 56, "long_name": "Soft Gecko", "next_hop": 0, "num": "0x71868661", "position": {"altitude": 1297, "latitude": 33.728329, "location_source": "LOC_INTERNAL", "longitude": -107.430025, "time_offset_sec": 339}, "public_key_hex": "c6d97276606c45d0b566db842c1d6d5cc8ea46cee8f3b4b769c6eb6fcda20f65", "role": "CLIENT", "short_name": "SKTI", "snr": 7.16, "status": null, "telemetry": {"air_util_tx": 0.18, "battery_level": 70, "channel_utilization": 19.19, "uptime_seconds": 158774, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 10120, "long_name": "Sharp Fox", "next_hop": 0, "num": "0x719747e6", "position": {"altitude": 1441, "latitude": 33.068976, "location_source": "LOC_INTERNAL", "longitude": -107.009403, "time_offset_sec": 10409}, "public_key_hex": "4a19d7fa48d146e03001c862f71df457ccd8d2543b2f13ce8242d76a65eb133f", "role": "CLIENT", "short_name": "SY3W", "snr": 2.51, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.379, "battery_level": 52, "channel_utilization": 16.03, "uptime_seconds": 93432, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 1764, "long_name": "Bright Lion", "next_hop": 11, "num": "0x720be8dd", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "B6Y7", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.284, "battery_level": 33, "channel_utilization": 5.78, "uptime_seconds": 150819, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 917, "long_name": "Iron Cedar", "next_hop": 48, "num": "0x7223fd2c", "position": {"altitude": 1233, "latitude": 33.29581, "location_source": "LOC_INTERNAL", "longitude": -107.375482, "time_offset_sec": 1068}, "public_key_hex": "4c6ec24061eaf6da36611695c5adfacaf4ceeae6b79f5cf4c65c3a803bd78b3b", "role": "CLIENT", "short_name": "I1FZ", "snr": 2.02, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.424, "battery_level": 19, "channel_utilization": 33.44, "uptime_seconds": 80613, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7092, "long_name": "Steel Dolphin", "next_hop": 0, "num": "0x72312b58", "position": {"altitude": 1223, "latitude": 34.216293, "location_source": "LOC_INTERNAL", "longitude": -107.494165, "time_offset_sec": 7280}, "public_key_hex": "6b2c96e89f7dcdfebf06fdedcdd4b0418a5bde57c2c06066b4d7667d9320cc7c", "role": "CLIENT", "short_name": "SV88", "snr": 5.34, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 3.073, "battery_level": 22, "channel_utilization": 13.81, "uptime_seconds": 192804, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 451, "long_name": "Red Yucca", "next_hop": 0, "num": "0x7253753c", "position": {"altitude": 1483, "latitude": 33.224519, "location_source": "LOC_INTERNAL", "longitude": -106.971585, "time_offset_sec": 605}, "public_key_hex": "d986b35ea7fee277ea0779ed5619d9009e833803f256f29f1f88024a98da4a9b", "role": "TRACKER", "short_name": "RWD1", "snr": 7.31, "status": null, "telemetry": {"air_util_tx": 0.194, "battery_level": 18, "channel_utilization": 16.6, "uptime_seconds": 2632, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.95, "iaq": 59, "relative_humidity": 65.96, "temperature": 20.93}, "hops_away": 1, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 580, "long_name": "Drowsy Elk", "next_hop": 55, "num": "0x72685670", "position": {"altitude": 1422, "latitude": 33.702072, "location_source": "LOC_INTERNAL", "longitude": -106.896856, "time_offset_sec": 762}, "public_key_hex": "7794b043bab0515ea197cbb9b278a5dd025d1620caad2b55582e8e863ab814c7", "role": "CLIENT", "short_name": "🐝", "snr": 9.03, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.127, "battery_level": 87, "channel_utilization": 5.49, "uptime_seconds": 36130, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.66, "iaq": 28, "relative_humidity": 53.2, "temperature": 29.01}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3579, "long_name": "Canyon Mamba", "next_hop": 0, "num": "0x727e48dd", "position": {"altitude": 757, "latitude": 32.778749, "location_source": "LOC_INTERNAL", "longitude": -106.643044, "time_offset_sec": 3861}, "public_key_hex": "1e927c64cf0a213ed0c999760130f46143f2ac8fc6416fb1e54a48caf33fd6cf", "role": "ROUTER", "short_name": "COM6", "snr": 6.37, "status": null, "telemetry": {"air_util_tx": 0.411, "battery_level": 18, "channel_utilization": 18.18, "uptime_seconds": 148731, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 851, "long_name": "Sleepy Tortoise K18SD", "next_hop": 216, "num": "0x72813c32", "position": {"altitude": 1385, "latitude": 34.256668, "location_source": "LOC_INTERNAL", "longitude": -107.554962, "time_offset_sec": 1012}, "public_key_hex": "", "role": "ROUTER_LATE", "short_name": "SVPT", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.694, "battery_level": 69, "channel_utilization": 19.27, "uptime_seconds": 23040, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 5040, "long_name": "Tall Mamba", "next_hop": 0, "num": "0x728436cf", "position": {"altitude": 995, "latitude": 32.474851, "location_source": "LOC_INTERNAL", "longitude": -106.791658, "time_offset_sec": 5336}, "public_key_hex": "23f0158336057688c863a40cea1fff6ef45513d3cf665f886eeebbd76086b0b7", "role": "CLIENT", "short_name": "TAW3", "snr": 10.06, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.251, "battery_level": 94, "channel_utilization": 17.41, "uptime_seconds": 91284, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 9163, "long_name": "Brave Mustang", "next_hop": 1, "num": "0x729d8e3c", "position": {"altitude": 1108, "latitude": 33.298873, "location_source": "LOC_INTERNAL", "longitude": -106.746651, "time_offset_sec": 9395}, "public_key_hex": "b1a529df81ee961ed6c5b86d9464e4f56427b71e450b5dc409648fe63bc9344e", "role": "CLIENT", "short_name": "BEZB", "snr": 10.15, "status": null, "telemetry": {"air_util_tx": 1.241, "battery_level": 89, "channel_utilization": 4.48, "uptime_seconds": 55685, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 499, "long_name": "Old Yucca", "next_hop": 0, "num": "0x72a1200b", "position": {"altitude": 1491, "latitude": 33.093721, "location_source": "LOC_INTERNAL", "longitude": -106.86691, "time_offset_sec": 799}, "public_key_hex": "5ac337388fbe1a5f738b816c50df28d5d2f16eb06acf8b3af5fc7393a54a85b1", "role": "CLIENT", "short_name": "OQ75", "snr": 8.62, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.94, "battery_level": 31, "channel_utilization": 9.52, "uptime_seconds": 28213, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1983, "long_name": "Whispering Moose", "next_hop": 0, "num": "0x72bc293d", "position": {"altitude": 996, "latitude": 33.499814, "location_source": "LOC_INTERNAL", "longitude": -105.832972, "time_offset_sec": 2003}, "public_key_hex": "d276f90af382877b55586c2c72e4b1ab6c9f16757491c6b96167ee90d344c060", "role": "CLIENT", "short_name": "W9AV", "snr": 10.9, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.654, "battery_level": 22, "channel_utilization": 7.04, "uptime_seconds": 216148, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 5114, "long_name": "Smooth Mustang", "next_hop": 0, "num": "0x72d1635c", "position": {"altitude": 1365, "latitude": 33.396817, "location_source": "LOC_INTERNAL", "longitude": -107.633905, "time_offset_sec": 5148}, "public_key_hex": "b6fd95995cbb1b7a51733221dfdca6292af9fcc1bd76ee3df4d21b98d7fd373b", "role": "CLIENT", "short_name": "SI8X", "snr": 5.8, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.41, "iaq": 82, "relative_humidity": 48.17, "temperature": 25.64}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 7069, "long_name": "Drifting Bear", "next_hop": 0, "num": "0x72dd0bee", "position": null, "public_key_hex": "58fd7c309096bdd297c363c11cc23e8b1946ee0eedda4fbfe016fb5542d6c3a7", "role": "CLIENT", "short_name": "D2PR", "snr": 2.82, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.217, "battery_level": 101, "channel_utilization": 2.18, "uptime_seconds": 208434, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4042, "long_name": "Lost Mole", "next_hop": 0, "num": "0x72fae3f1", "position": {"altitude": 1425, "latitude": 32.609572, "location_source": "LOC_INTERNAL", "longitude": -106.931851, "time_offset_sec": 4280}, "public_key_hex": "98e0e80205265b51597d6822a1d3e9b47a2d5a27dd08d518f79c1e3e52c731ab", "role": "CLIENT", "short_name": "LFSO", "snr": -0.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.712, "battery_level": 27, "channel_utilization": 17.85, "uptime_seconds": 29154, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1632, "long_name": "Green Sage", "next_hop": 205, "num": "0x73019fbe", "position": {"altitude": 1216, "latitude": 33.068857, "location_source": "LOC_INTERNAL", "longitude": -106.641563, "time_offset_sec": 1890}, "public_key_hex": "6d12a262a6cb8c8820ed068492f8cc5f75776c6a2622b4f855f39743e07d2271", "role": "CLIENT", "short_name": "🐢", "snr": 6.96, "status": null, "telemetry": {"air_util_tx": 0.446, "battery_level": 35, "channel_utilization": 8.05, "uptime_seconds": 36444, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 7194, "long_name": "Happy Coyote", "next_hop": 0, "num": "0x7316927f", "position": {"altitude": 1537, "latitude": 32.729636, "location_source": "LOC_INTERNAL", "longitude": -107.77123, "time_offset_sec": 7268}, "public_key_hex": "6884c367d35312dc8f54d95471ff23278940aa8d295c5dc5754a12c2c39d6f3f", "role": "TRACKER", "short_name": "H8SX", "snr": 11.92, "status": null, "telemetry": {"air_util_tx": 1.226, "battery_level": 81, "channel_utilization": 17.8, "uptime_seconds": 83371, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1545, "long_name": "Copper Coyote", "next_hop": 0, "num": "0x7323ce79", "position": {"altitude": 1692, "latitude": 33.017125, "location_source": "LOC_INTERNAL", "longitude": -107.559084, "time_offset_sec": 1591}, "public_key_hex": "7dd749a91b03b2b81cf6869001b9f53b2a2c5ce2d39cd74d4192be0135d4c423", "role": "CLIENT", "short_name": "CXG3", "snr": 3.64, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.326, "battery_level": 59, "channel_utilization": 3.09, "uptime_seconds": 17676, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4751, "long_name": "Floating Squirrel", "next_hop": 0, "num": "0x737705ba", "position": {"altitude": 1380, "latitude": 32.120108, "location_source": "LOC_INTERNAL", "longitude": -106.836973, "time_offset_sec": 4765}, "public_key_hex": "", "role": "ROUTER", "short_name": "FSR0", "snr": 4.76, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.294, "battery_level": 26, "channel_utilization": 4.37, "uptime_seconds": 115432, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1215, "long_name": "Sky Pike", "next_hop": 3, "num": "0x7382ad72", "position": {"altitude": 1581, "latitude": 32.968829, "location_source": "LOC_INTERNAL", "longitude": -106.670159, "time_offset_sec": 1427}, "public_key_hex": "0b658d9d2e4daa51c00cc125d8d847298dcb00530e3170c693e9e3c657a6ce97", "role": "CLIENT", "short_name": "🦌", "snr": 8.64, "status": null, "telemetry": {"air_util_tx": 1.4, "battery_level": 26, "channel_utilization": 2.34, "uptime_seconds": 156626, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.12, "iaq": 27, "relative_humidity": 35.04, "temperature": 18.6}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4021, "long_name": "Floating Eagle", "next_hop": 0, "num": "0x73d80c76", "position": {"altitude": 734, "latitude": 33.081315, "location_source": "LOC_INTERNAL", "longitude": -107.431315, "time_offset_sec": 4104}, "public_key_hex": "000425f69ba94e2b61fd6797c85ff0c94b9070673fb2716c27f5880fce9789e0", "role": "CLIENT", "short_name": "🌵", "snr": 3.37, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.006, "battery_level": 48, "channel_utilization": 11.03, "uptime_seconds": 167661, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.82, "iaq": 99, "relative_humidity": 19.21, "temperature": 24.89}, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 266, "long_name": "New Raven", "next_hop": 183, "num": "0x73e51348", "position": {"altitude": 1771, "latitude": 32.369386, "location_source": "LOC_INTERNAL", "longitude": -107.502777, "time_offset_sec": 547}, "public_key_hex": "86a18cf73b3a4600b96e1d279d51a953da349f49b0ff8a6834986cd54ac3beb3", "role": "SENSOR", "short_name": "NF6R", "snr": 2.28, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1016.82, "iaq": 71, "relative_humidity": 25.42, "temperature": 25.84}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 9404, "long_name": "Hidden Lynx", "next_hop": 46, "num": "0x73f41663", "position": {"altitude": 908, "latitude": 32.942319, "location_source": "LOC_INTERNAL", "longitude": -107.171054, "time_offset_sec": 9534}, "public_key_hex": "7abed8a9021ba6e01a3525f8da9047c8a82bd219bc85d26eaa79d893abf714b8", "role": "CLIENT", "short_name": "HYYT", "snr": 8.41, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.049, "battery_level": 26, "channel_utilization": 2.69, "uptime_seconds": 647, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1028.14, "iaq": 33, "relative_humidity": 55.22, "temperature": 20.13}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2083, "long_name": "Mountain Stag", "next_hop": 0, "num": "0x74513585", "position": {"altitude": 929, "latitude": 33.295137, "location_source": "LOC_INTERNAL", "longitude": -108.037645, "time_offset_sec": 2257}, "public_key_hex": "3fd05f5ded1144f7265e596de9f1d67c33830e5640184fba5028fd5160b7b761", "role": "CLIENT", "short_name": "MK9B", "snr": 6.24, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.591, "battery_level": 37, "channel_utilization": 3.05, "uptime_seconds": 41684, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3206, "long_name": "Giant Seal", "next_hop": 185, "num": "0x749a1cbb", "position": {"altitude": 1407, "latitude": 33.059136, "location_source": "LOC_INTERNAL", "longitude": -107.137854, "time_offset_sec": 3298}, "public_key_hex": "4aa52cf5073b02c9ad47db0e6d48d8f84f90a66cdc7e668c7534f2c3631e03c8", "role": "CLIENT", "short_name": "🐢", "snr": 8.49, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 949, "long_name": "New Owl", "next_hop": 0, "num": "0x74e1c3f4", "position": {"altitude": 1200, "latitude": 33.375843, "location_source": "LOC_INTERNAL", "longitude": -107.201678, "time_offset_sec": 1176}, "public_key_hex": "1e454e409830ef40826cfb673d703de28084d45f93e7a600eb0b4cfdcca4e850", "role": "CLIENT", "short_name": "N29M", "snr": 5.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.81, "iaq": 88, "relative_humidity": 38.25, "temperature": 11.81}, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 36, "long_name": "Gold Badger", "next_hop": 254, "num": "0x7549083e", "position": {"altitude": 1364, "latitude": 32.970446, "location_source": "LOC_INTERNAL", "longitude": -106.858714, "time_offset_sec": 63}, "public_key_hex": "", "role": "TRACKER", "short_name": "GG5G", "snr": 4.6, "status": null, "telemetry": {"air_util_tx": 0.371, "battery_level": 61, "channel_utilization": 28.0, "uptime_seconds": 144825, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.89, "iaq": 75, "relative_humidity": 48.64, "temperature": 23.59}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4541, "long_name": "Short Mole", "next_hop": 120, "num": "0x755dba58", "position": {"altitude": 1111, "latitude": 32.765541, "location_source": "LOC_INTERNAL", "longitude": -106.700036, "time_offset_sec": 4742}, "public_key_hex": "7b2f196d840a9ebae0556b947b052d4fcf498a7cdef9af6f72702bb154db127d", "role": "CLIENT", "short_name": "SWC3", "snr": 3.07, "status": null, "telemetry": {"air_util_tx": 0.567, "battery_level": 101, "channel_utilization": 8.54, "uptime_seconds": 36438, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5410, "long_name": "Brave Elk", "next_hop": 0, "num": "0x75b1ee30", "position": null, "public_key_hex": "b8017e449bab2177182ed1f007a6d3b628f265691fe17c93cfb7a623b4361cb2", "role": "CLIENT", "short_name": "B3E2", "snr": 8.65, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.489, "battery_level": 38, "channel_utilization": 6.55, "uptime_seconds": 66832, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2260, "long_name": "River Juniper", "next_hop": 0, "num": "0x75b8234b", "position": null, "public_key_hex": "1b13cdb67bf982a8c26b7b9dd4aa42c8393c6b9d998e0d7b5f250416064f27ef", "role": "TRACKER", "short_name": "R9RY", "snr": 12.0, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.601, "battery_level": 47, "channel_utilization": 7.4, "uptime_seconds": 35241, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.36, "iaq": 1, "relative_humidity": 16.12, "temperature": 14.46}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 8012, "long_name": "Hidden Bass KQ6VX", "next_hop": 0, "num": "0x75d75df2", "position": null, "public_key_hex": "91931be8ab888e3ef195da373cfed78dde8a13fd17289925250499dfa5013393", "role": "CLIENT", "short_name": "H6I6", "snr": 6.85, "status": null, "telemetry": {"air_util_tx": 0.93, "battery_level": 34, "channel_utilization": 14.17, "uptime_seconds": 101953, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 4826, "long_name": "Black Marmot", "next_hop": 245, "num": "0x75de8eea", "position": {"altitude": 1391, "latitude": 31.225093, "location_source": "LOC_INTERNAL", "longitude": -106.100109, "time_offset_sec": 5088}, "public_key_hex": "bc3d2795bcc597eceddefb234609df6bd849fe02635b48ac7069bb86ad2591e7", "role": "CLIENT", "short_name": "B39Q", "snr": 6.65, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.986, "battery_level": 54, "channel_utilization": 3.19, "uptime_seconds": 175263, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 3677, "long_name": "Frosty Pike", "next_hop": 61, "num": "0x75ea5907", "position": {"altitude": 1306, "latitude": 32.802528, "location_source": "LOC_INTERNAL", "longitude": -107.870237, "time_offset_sec": 3972}, "public_key_hex": "", "role": "CLIENT", "short_name": "FJ9S", "snr": 9.68, "status": null, "telemetry": {"air_util_tx": 1.056, "battery_level": 38, "channel_utilization": 13.72, "uptime_seconds": 275208, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 11, "long_name": "Short Hawk", "next_hop": 155, "num": "0x760cdfd6", "position": {"altitude": 848, "latitude": 33.861901, "location_source": "LOC_INTERNAL", "longitude": -107.833578, "time_offset_sec": 231}, "public_key_hex": "790a802c03f8760c4e09c3bde1216a775958ce6e99e1f42b264f80de775a76ac", "role": "CLIENT", "short_name": "🔥", "snr": 3.28, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 0.483, "battery_level": 71, "channel_utilization": 18.99, "uptime_seconds": 2891, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 525, "long_name": "Green Hawk", "next_hop": 0, "num": "0x76160d93", "position": {"altitude": 1714, "latitude": 33.135477, "location_source": "LOC_INTERNAL", "longitude": -106.937126, "time_offset_sec": 762}, "public_key_hex": "69a5b8beeace818d04656b457fc61f6bf46d173696278f20c3e4b65f60f0cf7a", "role": "CLIENT", "short_name": "GZJT", "snr": 8.46, "status": null, "telemetry": {"air_util_tx": 0.26, "battery_level": 16, "channel_utilization": 6.26, "uptime_seconds": 216937, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.18, "iaq": 15, "relative_humidity": 36.26, "temperature": 23.74}, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 452, "long_name": "Green Eagle", "next_hop": 175, "num": "0x7695c5a1", "position": null, "public_key_hex": "4f7f4a4fd2b43ec9115d1350deca4f4019e5bb20d83797b8eb4040194f38303e", "role": "CLIENT", "short_name": "GE72", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.056, "battery_level": 20, "channel_utilization": 17.8, "uptime_seconds": 51564, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3457, "long_name": "Brave Salmon", "next_hop": 195, "num": "0x76b0f9b0", "position": null, "public_key_hex": "7ec9810da0d86fed49323909a2de7763ff4fd550c2ad830b74634fde23239a3e", "role": "ROUTER", "short_name": "B9CY", "snr": 3.34, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.56, "iaq": 45, "relative_humidity": 35.46, "temperature": 21.63}, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 108, "long_name": "Drowsy Seal", "next_hop": 222, "num": "0x76b181b2", "position": {"altitude": 1113, "latitude": 33.315792, "location_source": "LOC_INTERNAL", "longitude": -107.253969, "time_offset_sec": 369}, "public_key_hex": "1bbf174804bda183ccc6f70839ab9b7a944a62f2bf03f66f6df4682e9cf1b444", "role": "CLIENT", "short_name": "DPXJ", "snr": 6.08, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.042, "battery_level": 60, "channel_utilization": 20.57, "uptime_seconds": 178443, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3306, "long_name": "Drifting Shark", "next_hop": 0, "num": "0x76bc359f", "position": {"altitude": 1383, "latitude": 32.926539, "location_source": "LOC_INTERNAL", "longitude": -107.53287, "time_offset_sec": 3420}, "public_key_hex": "261690ca91d2412520126410cd8c816986f22febc54803b0f28fb02951020674", "role": "SENSOR", "short_name": "DPMY", "snr": 4.3, "status": null, "telemetry": {"air_util_tx": 0.255, "battery_level": 85, "channel_utilization": 12.48, "uptime_seconds": 25869, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 535, "long_name": "Quick Yucca", "next_hop": 0, "num": "0x76e31cfd", "position": {"altitude": 1444, "latitude": 32.336247, "location_source": "LOC_INTERNAL", "longitude": -107.347544, "time_offset_sec": 707}, "public_key_hex": "241d8c982dc5f7939acc9cf07ba3cb43c3174fb3e802ef23edb7f9dc62a79fa9", "role": "ROUTER", "short_name": "QHXD", "snr": 8.33, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.124, "battery_level": 13, "channel_utilization": 17.17, "uptime_seconds": 48595, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.58, "iaq": 38, "relative_humidity": 70.82, "temperature": 19.5}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5646, "long_name": "Silent Doe", "next_hop": 11, "num": "0x770600d2", "position": null, "public_key_hex": "dc049d140d911e956ab65bda05d773631b28a9e4ef53f53994812672085262a5", "role": "CLIENT", "short_name": "SB2D", "snr": 7.12, "status": null, "telemetry": {"air_util_tx": 0.055, "battery_level": 101, "channel_utilization": 4.89, "uptime_seconds": 32206, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.15, "iaq": 64, "relative_humidity": 22.04, "temperature": 26.02}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3411, "long_name": "Soft Pony", "next_hop": 0, "num": "0x77427b70", "position": null, "public_key_hex": "13e701fbf9497a897b652bcb77fcce7f4b68a1e49d319ab62d120d88204e5487", "role": "CLIENT_MUTE", "short_name": "S0IL", "snr": 1.52, "status": null, "telemetry": {"air_util_tx": 0.945, "battery_level": 37, "channel_utilization": 19.28, "uptime_seconds": 175077, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.3, "iaq": 33, "relative_humidity": 24.54, "temperature": 20.08}, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3433, "long_name": "Green Lion", "next_hop": 255, "num": "0x7742dfa9", "position": {"altitude": 1282, "latitude": 33.759637, "location_source": "LOC_INTERNAL", "longitude": -107.13301, "time_offset_sec": 3692}, "public_key_hex": "7288ae8a5299a2632221c99cbbebd904f954bc38cf31da7c7da0563567411868", "role": "CLIENT", "short_name": "GJGN", "snr": -2.13, "status": null, "telemetry": {"air_util_tx": 1.783, "battery_level": 56, "channel_utilization": 2.03, "uptime_seconds": 5320, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.11, "iaq": 63, "relative_humidity": 44.43, "temperature": 30.61}, "hops_away": 1, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 8393, "long_name": "Sharp Lynx", "next_hop": 192, "num": "0x7755df1e", "position": {"altitude": 1496, "latitude": 33.387831, "location_source": "LOC_INTERNAL", "longitude": -107.508396, "time_offset_sec": 8424}, "public_key_hex": "", "role": "ROUTER", "short_name": "🌲", "snr": 7.38, "status": null, "telemetry": {"air_util_tx": 0.067, "battery_level": 52, "channel_utilization": 5.68, "uptime_seconds": 52582, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1599, "long_name": "Solar Marmot", "next_hop": 255, "num": "0x77567517", "position": {"altitude": 1655, "latitude": 32.894171, "location_source": "LOC_INTERNAL", "longitude": -107.991345, "time_offset_sec": 1759}, "public_key_hex": "da839deebe14bcd2105e906681736166a50931029c4677d187f9d663ab4709a2", "role": "CLIENT", "short_name": "SYZK", "snr": 5.11, "status": null, "telemetry": {"air_util_tx": 0.606, "battery_level": 15, "channel_utilization": 3.61, "uptime_seconds": 14521, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.18, "iaq": 21, "relative_humidity": 63.16, "temperature": 33.81}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2767, "long_name": "River Whale", "next_hop": 0, "num": "0x779568ea", "position": {"altitude": 1345, "latitude": 33.314285, "location_source": "LOC_INTERNAL", "longitude": -106.66014, "time_offset_sec": 2977}, "public_key_hex": "0ac7435070eb5f8f789833659a560154595feb7f207c55c568c7d2f591fce44e", "role": "CLIENT", "short_name": "R7AT", "snr": 5.16, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.638, "battery_level": 99, "channel_utilization": 14.56, "uptime_seconds": 58007, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1899, "long_name": "Lunar Colt", "next_hop": 0, "num": "0x779d282c", "position": {"altitude": 1390, "latitude": 32.34732, "location_source": "LOC_INTERNAL", "longitude": -108.334112, "time_offset_sec": 1927}, "public_key_hex": "6cc95c244eca1974f44ae7dbee1de98729cd6f5aa30fc5bead90657c83726742", "role": "CLIENT", "short_name": "LPC2", "snr": 2.94, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.648, "battery_level": 94, "channel_utilization": 2.14, "uptime_seconds": 92652, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 5, "environment": {"barometric_pressure": 1023.0, "iaq": 34, "relative_humidity": 0.0, "temperature": 13.73}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 1060, "long_name": "Steel Bison", "next_hop": 0, "num": "0x77a89c75", "position": {"altitude": 1415, "latitude": 31.886404, "location_source": "LOC_INTERNAL", "longitude": -107.163127, "time_offset_sec": 1133}, "public_key_hex": "2a65531d4faa6f3994b53cd94319182a52c9cb8600df4bf06417c1573355e4b1", "role": "CLIENT", "short_name": "S7V5", "snr": 2.27, "status": null, "telemetry": {"air_util_tx": 1.345, "battery_level": 30, "channel_utilization": 8.55, "uptime_seconds": 63473, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3114, "long_name": "Mountain Colt", "next_hop": 92, "num": "0x77bbc857", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "🗻", "snr": 5.12, "status": null, "telemetry": {"air_util_tx": 1.312, "battery_level": 68, "channel_utilization": 9.25, "uptime_seconds": 24294, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 17749, "long_name": "Lost Eagle", "next_hop": 0, "num": "0x77fd143a", "position": {"altitude": 1030, "latitude": 33.936066, "location_source": "LOC_INTERNAL", "longitude": -107.43837, "time_offset_sec": 17974}, "public_key_hex": "8b4e607ac084bd118e1913f8911133b2af547e5fe1120f617beb502c84b9b83e", "role": "CLIENT", "short_name": "L7KH", "snr": 10.65, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.622, "battery_level": 33, "channel_utilization": 26.48, "uptime_seconds": 30722, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.13, "iaq": 50, "relative_humidity": 58.22, "temperature": 32.58}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1114, "long_name": "Drifting Hare", "next_hop": 0, "num": "0x780fdbad", "position": {"altitude": 1465, "latitude": 32.136447, "location_source": "LOC_INTERNAL", "longitude": -108.102428, "time_offset_sec": 1349}, "public_key_hex": "82a7d3f9f5645e604b4afb234ae9339476de85c26c2eeabb1f444bc97c0b23ce", "role": "CLIENT", "short_name": "DJVP", "snr": 7.57, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.73, "iaq": 28, "relative_humidity": 40.31, "temperature": 14.25}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1304, "long_name": "River Oak", "next_hop": 5, "num": "0x786b8aa0", "position": null, "public_key_hex": "3311764db18a0f2439232afa3e0cd53c5268d4cd752526ef240a9ccc832bc012", "role": "CLIENT", "short_name": "🔥", "snr": 5.22, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.242, "battery_level": 57, "channel_utilization": 16.86, "uptime_seconds": 5361, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1100, "long_name": "Drowsy Colt", "next_hop": 0, "num": "0x78d7faf4", "position": {"altitude": 949, "latitude": 34.282607, "location_source": "LOC_INTERNAL", "longitude": -107.435052, "time_offset_sec": 1376}, "public_key_hex": "acfd69c0d28bbd6603f58d6b4584f129d0cf610762e875a025478452cbcfad65", "role": "TAK", "short_name": "D4QQ", "snr": 4.07, "status": null, "telemetry": {"air_util_tx": 1.184, "battery_level": 28, "channel_utilization": 19.95, "uptime_seconds": 108563, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.75, "iaq": 58, "relative_humidity": 44.71, "temperature": 24.73}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1988, "long_name": "Short Lion", "next_hop": 0, "num": "0x78d9bf3c", "position": {"altitude": 1499, "latitude": 33.010566, "location_source": "LOC_INTERNAL", "longitude": -107.275692, "time_offset_sec": 2024}, "public_key_hex": "", "role": "CLIENT", "short_name": "🌊", "snr": 7.72, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.677, "battery_level": 41, "channel_utilization": 10.84, "uptime_seconds": 62583, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 3566, "long_name": "Tall Hawk", "next_hop": 0, "num": "0x79222b14", "position": null, "public_key_hex": "c2d013233808d5ae9fcd982511511d5ee094a2bc1d67cf0260ad5fe53d2e9b95", "role": "CLIENT", "short_name": "TJ3G", "snr": -1.43, "status": null, "telemetry": {"air_util_tx": 0.283, "battery_level": 37, "channel_utilization": 8.94, "uptime_seconds": 70170, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 768, "long_name": "Stone Mesa", "next_hop": 0, "num": "0x792343d6", "position": {"altitude": 1484, "latitude": 32.253245, "location_source": "LOC_INTERNAL", "longitude": -106.918699, "time_offset_sec": 838}, "public_key_hex": "e990e9cb4e6b88f271aac985642f03171751670e63fc5a0fcaf7367a0898c07a", "role": "CLIENT", "short_name": "SZE8", "snr": 6.77, "status": null, "telemetry": {"air_util_tx": 0.243, "battery_level": 64, "channel_utilization": 7.61, "uptime_seconds": 71977, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 4885, "long_name": "Steel Bluff", "next_hop": 230, "num": "0x792b91b3", "position": {"altitude": 1970, "latitude": 32.974732, "location_source": "LOC_INTERNAL", "longitude": -107.456881, "time_offset_sec": 5071}, "public_key_hex": "2cf9a0b02423fff76cd30597e146e1d8e02bfdbb740b6dc44d722132f1fa6d7c", "role": "ROUTER", "short_name": "SC0S", "snr": -4.13, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5098, "long_name": "Brave Badger", "next_hop": 164, "num": "0x792f2c38", "position": {"altitude": 1481, "latitude": 32.577246, "location_source": "LOC_INTERNAL", "longitude": -107.38228, "time_offset_sec": 5281}, "public_key_hex": "3a01e9ad5d5a5b3db3323842ebbcac9a09dd39cafb65881ea5eabea5dd0af82b", "role": "CLIENT", "short_name": "BJ1H", "snr": 9.42, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.352, "battery_level": 20, "channel_utilization": 7.52, "uptime_seconds": 37773, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7630, "long_name": "Quick Hare", "next_hop": 212, "num": "0x793d1651", "position": {"altitude": 1533, "latitude": 32.824027, "location_source": "LOC_INTERNAL", "longitude": -107.87589, "time_offset_sec": 7782}, "public_key_hex": "595776e1da011cc96ed6a9ae815e83071be4631e05e3ae935afd26c94043bd8d", "role": "CLIENT", "short_name": "QGR1", "snr": 2.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.43, "iaq": 18, "relative_humidity": 81.23, "temperature": 34.52}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 780, "long_name": "Quick Tortoise", "next_hop": 251, "num": "0x794a7043", "position": {"altitude": 1516, "latitude": 32.325935, "location_source": "LOC_INTERNAL", "longitude": -107.800577, "time_offset_sec": 881}, "public_key_hex": "9b5e7ba1f8be86bcbe451ee3b61ffc2b766483e00bd5173c644433c1fd68d2ce", "role": "CLIENT", "short_name": "QHDW", "snr": -0.58, "status": null, "telemetry": {"air_util_tx": 0.144, "battery_level": 89, "channel_utilization": 7.03, "uptime_seconds": 81469, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2021, "long_name": "Silver Cactus", "next_hop": 0, "num": "0x7969ff0e", "position": {"altitude": 1606, "latitude": 32.625297, "location_source": "LOC_INTERNAL", "longitude": -107.406634, "time_offset_sec": 2098}, "public_key_hex": "6957410d86811c0628efe2d1655025dea3e7bda96c9da5b7503e1986b98ae204", "role": "CLIENT_MUTE", "short_name": "S4WP", "snr": 9.34, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1977, "long_name": "Hidden Mole", "next_hop": 200, "num": "0x797de1c5", "position": {"altitude": 1423, "latitude": 32.459752, "location_source": "LOC_INTERNAL", "longitude": -107.742263, "time_offset_sec": 2018}, "public_key_hex": "174bc4fddd90a7edbf1f17133d86c68ddb60a446f50b55ba989f5e328e80b539", "role": "CLIENT", "short_name": "🦌", "snr": 3.41, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5383, "long_name": "Sky Fox", "next_hop": 0, "num": "0x79aecbfb", "position": null, "public_key_hex": "4e35d28f2fb30a27f17546bd7648de89114b67b63bdb8659fb3b3c1619d27a08", "role": "CLIENT", "short_name": "🌵", "snr": 8.28, "status": null, "telemetry": {"air_util_tx": 0.286, "battery_level": 101, "channel_utilization": 26.42, "uptime_seconds": 156394, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.96, "iaq": 58, "relative_humidity": 50.66, "temperature": 20.22}, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 1671, "long_name": "Sneaky Mole", "next_hop": 63, "num": "0x7a3956c9", "position": {"altitude": 1404, "latitude": 33.13925, "location_source": "LOC_INTERNAL", "longitude": -106.669145, "time_offset_sec": 1787}, "public_key_hex": "274b9929583a45cd1e3e689ab1e32f64dbf43c1a70764782cfa75f02405a02fe", "role": "CLIENT", "short_name": "🗻", "snr": 5.47, "status": null, "telemetry": {"air_util_tx": 0.94, "battery_level": 101, "channel_utilization": 11.77, "uptime_seconds": 3026, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 476, "long_name": "Whispering Cactus", "next_hop": 0, "num": "0x7a438810", "position": null, "public_key_hex": "1846a2825773f8d1da7275b377bc3eedf6be1e2a3db2c95d9e44133a338f20b6", "role": "CLIENT", "short_name": "W3RB", "snr": 8.03, "status": null, "telemetry": {"air_util_tx": 2.126, "battery_level": 22, "channel_utilization": 18.61, "uptime_seconds": 79338, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3136, "long_name": "Wild Elk", "next_hop": 64, "num": "0x7a519a26", "position": {"altitude": 1343, "latitude": 33.504606, "location_source": "LOC_INTERNAL", "longitude": -106.977966, "time_offset_sec": 3389}, "public_key_hex": "d94ec807c34b41171656f210b4484bb7fc76d2b5ae2e2cdab0428469a1ea1d5d", "role": "CLIENT", "short_name": "🌲", "snr": 5.81, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.379, "battery_level": 17, "channel_utilization": 6.21, "uptime_seconds": 191369, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 12906, "long_name": "Sharp Aspen", "next_hop": 0, "num": "0x7a5f8560", "position": {"altitude": 1469, "latitude": 33.449863, "location_source": "LOC_INTERNAL", "longitude": -107.85163, "time_offset_sec": 12931}, "public_key_hex": "", "role": "CLIENT", "short_name": "SQXV", "snr": -0.31, "status": null, "telemetry": {"air_util_tx": 1.682, "battery_level": 50, "channel_utilization": 3.44, "uptime_seconds": 18522, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5028, "long_name": "Sunny Cedar", "next_hop": 246, "num": "0x7aa5f984", "position": {"altitude": 1507, "latitude": 32.289055, "location_source": "LOC_INTERNAL", "longitude": -106.96407, "time_offset_sec": 5063}, "public_key_hex": "95694e50d4ad8fe848c11c8ec7e73916fe106fc373f5c5fce72522d5fd0c3832", "role": "CLIENT", "short_name": "SEE1", "snr": 3.59, "status": null, "telemetry": {"air_util_tx": 0.483, "battery_level": 68, "channel_utilization": 27.29, "uptime_seconds": 42756, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3159, "long_name": "Burning Cactus", "next_hop": 0, "num": "0x7aae968b", "position": {"altitude": 1002, "latitude": 32.691622, "location_source": "LOC_INTERNAL", "longitude": -107.350961, "time_offset_sec": 3413}, "public_key_hex": "6f823e1df013ad8cfb619ce7877a35aef6fe7a16aec378282ad6cbc3ec8b8e31", "role": "SENSOR", "short_name": "BU6Z", "snr": 10.3, "status": null, "telemetry": {"air_util_tx": 0.346, "battery_level": 71, "channel_utilization": 7.0, "uptime_seconds": 289110, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1783, "long_name": "Sharp Aspen", "next_hop": 0, "num": "0x7ab70cbe", "position": {"altitude": 1393, "latitude": 33.317684, "location_source": "LOC_INTERNAL", "longitude": -108.098468, "time_offset_sec": 1790}, "public_key_hex": "082f6fcfe847b33e511de81b9a0c430bd664308ff2b3f3a3a45d46aff90b7e41", "role": "CLIENT", "short_name": "SPAJ", "snr": 6.09, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.22, "iaq": 44, "relative_humidity": 62.38, "temperature": 25.54}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6722, "long_name": "Black Cougar", "next_hop": 151, "num": "0x7aba91a0", "position": {"altitude": 1579, "latitude": 33.491134, "location_source": "LOC_INTERNAL", "longitude": -106.627527, "time_offset_sec": 6769}, "public_key_hex": "48b9aa634ed705b54ef6454c1d43283354d3a5bcecf5aa02ce0632c37095e9ba", "role": "ROUTER_LATE", "short_name": "BZKR", "snr": 1.51, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.211, "battery_level": 34, "channel_utilization": 14.61, "uptime_seconds": 24282, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3850, "long_name": "Found Squirrel", "next_hop": 0, "num": "0x7adbd468", "position": {"altitude": 1678, "latitude": 33.393704, "location_source": "LOC_INTERNAL", "longitude": -106.619992, "time_offset_sec": 3910}, "public_key_hex": "0999541db4fdd3b9eaebeceb7171085264d994187aec6d2dc35e06919971db1b", "role": "CLIENT_MUTE", "short_name": "F1QD", "snr": 8.8, "status": null, "telemetry": {"air_util_tx": 0.296, "battery_level": 20, "channel_utilization": 20.31, "uptime_seconds": 11671, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1239, "long_name": "Silent Heron", "next_hop": 0, "num": "0x7ae72c6b", "position": null, "public_key_hex": "c1f995238ca5a58b6577669b35187cf195d051f96aee448b27595137675d4989", "role": "CLIENT", "short_name": "SYWV", "snr": 5.87, "status": null, "telemetry": {"air_util_tx": 3.091, "battery_level": 42, "channel_utilization": 14.41, "uptime_seconds": 110383, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 9153, "long_name": "Drowsy Bison", "next_hop": 152, "num": "0x7af817a0", "position": {"altitude": 1794, "latitude": 34.619492, "location_source": "LOC_INTERNAL", "longitude": -106.753014, "time_offset_sec": 9293}, "public_key_hex": "8a9571d66ad2ee6c38b992c648ce356e897958dea226c74703fe608099b05734", "role": "CLIENT", "short_name": "DI5F", "snr": 10.15, "status": null, "telemetry": {"air_util_tx": 0.294, "battery_level": 34, "channel_utilization": 19.22, "uptime_seconds": 3588, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.07, "iaq": 84, "relative_humidity": 24.14, "temperature": 27.45}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5161, "long_name": "Old Wolf", "next_hop": 0, "num": "0x7afe6b89", "position": {"altitude": 1221, "latitude": 32.461776, "location_source": "LOC_INTERNAL", "longitude": -108.083244, "time_offset_sec": 5189}, "public_key_hex": "", "role": "CLIENT", "short_name": "🐢", "snr": 5.31, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.86, "iaq": 54, "relative_humidity": 83.77, "temperature": 27.43}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 6269, "long_name": "Solar Yucca", "next_hop": 0, "num": "0x7b039590", "position": {"altitude": 1417, "latitude": 33.197327, "location_source": "LOC_INTERNAL", "longitude": -107.261815, "time_offset_sec": 6479}, "public_key_hex": "45a9f4e6e2e7602e86a779b561b18c612cdcda511405200228ba1b9fe43cdf6a", "role": "CLIENT", "short_name": "SK5L", "snr": 10.29, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1018.85, "iaq": 43, "relative_humidity": 56.96, "temperature": 16.18}, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 1754, "long_name": "Mountain Dolphin", "next_hop": 0, "num": "0x7b233df6", "position": {"altitude": 1476, "latitude": 33.870948, "location_source": "LOC_INTERNAL", "longitude": -106.354185, "time_offset_sec": 1945}, "public_key_hex": "eabd1c2c826b88115222e3ac17fc7acaa056c0f0376d9b1726573200f9982831", "role": "CLIENT", "short_name": "M0TA", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.354, "battery_level": 46, "channel_utilization": 10.33, "uptime_seconds": 107227, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 12688, "long_name": "Gold Bronco", "next_hop": 0, "num": "0x7b318f86", "position": {"altitude": 1614, "latitude": 33.4643, "location_source": "LOC_INTERNAL", "longitude": -107.242949, "time_offset_sec": 12806}, "public_key_hex": "a8597d7ddec471991dfd6fe325cb4d929ad53c71ca39132013df49d02709dcfa", "role": "CLIENT", "short_name": "GSF6", "snr": 2.4, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2635, "long_name": "Sleepy Squirrel", "next_hop": 124, "num": "0x7b3fa3d8", "position": null, "public_key_hex": "b9187af7cd017c1ed61ed848dfb469a5b6634f3d169984850338fc1e1b0f4c14", "role": "CLIENT", "short_name": "SMTT", "snr": 8.72, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.66, "iaq": 0, "relative_humidity": 40.58, "temperature": 23.25}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4959, "long_name": "Slow Coyote", "next_hop": 207, "num": "0x7b5679e6", "position": null, "public_key_hex": "3c887fd9ba460f098f4099d781b3fae45f68bba2b392d379fcc3ab6526fca0fc", "role": "CLIENT", "short_name": "ST34", "snr": 3.35, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.192, "battery_level": 35, "channel_utilization": 8.49, "uptime_seconds": 62160, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 701, "long_name": "Iron Bison", "next_hop": 159, "num": "0x7b572ae5", "position": {"altitude": 1145, "latitude": 32.772993, "location_source": "LOC_INTERNAL", "longitude": -107.027176, "time_offset_sec": 758}, "public_key_hex": "98547cd60f6a63d36f762883edf9b089b1c270b4ae065d644606ec6dc274565b", "role": "TRACKER", "short_name": "IVO4", "snr": 1.43, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.999, "battery_level": 76, "channel_utilization": 17.21, "uptime_seconds": 79091, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4, "long_name": "Lost Cobra WD3LW", "next_hop": 22, "num": "0x7b9abaf4", "position": {"altitude": 973, "latitude": 33.237352, "location_source": "LOC_INTERNAL", "longitude": -107.495161, "time_offset_sec": 275}, "public_key_hex": "ae374917e674d7780d3bd55097ddfed4cda90aa1417e126ca9c3e90dcce8a776", "role": "CLIENT", "short_name": "LXY8", "snr": 4.81, "status": null, "telemetry": {"air_util_tx": 0.767, "battery_level": 94, "channel_utilization": 12.19, "uptime_seconds": 133784, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1681, "long_name": "Forest Pike", "next_hop": 0, "num": "0x7c1a765c", "position": {"altitude": 1890, "latitude": 32.779706, "location_source": "LOC_INTERNAL", "longitude": -106.199705, "time_offset_sec": 1954}, "public_key_hex": "2ea61d08f4d449b77168a96f4a3b0bee909ba638ac62297776aaed0363e46dfb", "role": "CLIENT", "short_name": "F9X3", "snr": 8.18, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.197, "battery_level": 47, "channel_utilization": 31.74, "uptime_seconds": 25356, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.25, "iaq": 50, "relative_humidity": 75.06, "temperature": 10.06}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 888, "long_name": "Rough Adder", "next_hop": 0, "num": "0x7c2a0a2a", "position": {"altitude": 1448, "latitude": 33.344724, "location_source": "LOC_INTERNAL", "longitude": -107.633729, "time_offset_sec": 1045}, "public_key_hex": "a0cc35406e804c54a14b7ef16fb0063a5a11f93738634928d424a48f8b3bb2bc", "role": "CLIENT", "short_name": "R6IS", "snr": 5.8, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1244, "long_name": "Blue Juniper", "next_hop": 132, "num": "0x7c2ac4c3", "position": {"altitude": 1092, "latitude": 33.081501, "location_source": "LOC_INTERNAL", "longitude": -106.900566, "time_offset_sec": 1286}, "public_key_hex": "f27c72312da4495a6667ac9621cd4a0d72eda5bc889f4bb52c1b6656a94c71a6", "role": "CLIENT", "short_name": "BQ7Z", "snr": 7.86, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.359, "battery_level": 40, "channel_utilization": 7.22, "uptime_seconds": 31479, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3300, "long_name": "Tall Wolf", "next_hop": 0, "num": "0x7c3127cf", "position": {"altitude": 1414, "latitude": 32.762714, "location_source": "LOC_INTERNAL", "longitude": -107.199303, "time_offset_sec": 3375}, "public_key_hex": "704720919ede6509695596403aea861920f0105aa0e97519f5ca250c1caedde1", "role": "CLIENT", "short_name": "TIN0", "snr": 10.41, "status": null, "telemetry": {"air_util_tx": 1.778, "battery_level": 90, "channel_utilization": 23.28, "uptime_seconds": 15474, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4672, "long_name": "Copper Shark", "next_hop": 0, "num": "0x7c9d5ea7", "position": {"altitude": 1634, "latitude": 32.994137, "location_source": "LOC_INTERNAL", "longitude": -107.766002, "time_offset_sec": 4807}, "public_key_hex": "393f87eb9e0dd7af02eb6e0a14792f00730829baeeaa7a7cb6a1f785851625b9", "role": "CLIENT", "short_name": "CTSB", "snr": 5.25, "status": null, "telemetry": {"air_util_tx": 0.123, "battery_level": 17, "channel_utilization": 2.13, "uptime_seconds": 6358, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 691, "long_name": "Tiny Seal", "next_hop": 0, "num": "0x7cf15a26", "position": {"altitude": 1144, "latitude": 32.366219, "location_source": "LOC_INTERNAL", "longitude": -107.261681, "time_offset_sec": 720}, "public_key_hex": "2d9f26616732bb0312701bcdb09406582e8e49fac41a42151ae49795b94e5de6", "role": "CLIENT", "short_name": "T4IB", "snr": 10.8, "status": null, "telemetry": {"air_util_tx": 0.536, "battery_level": 59, "channel_utilization": 12.74, "uptime_seconds": 84710, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2759, "long_name": "Silver Colt", "next_hop": 0, "num": "0x7cfde9be", "position": {"altitude": 990, "latitude": 32.694186, "location_source": "LOC_INTERNAL", "longitude": -107.139855, "time_offset_sec": 2821}, "public_key_hex": "", "role": "CLIENT", "short_name": "S5BI", "snr": 4.05, "status": null, "telemetry": {"air_util_tx": 0.097, "battery_level": 54, "channel_utilization": 16.98, "uptime_seconds": 62698, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1000.97, "iaq": 63, "relative_humidity": 35.73, "temperature": 18.98}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 928, "long_name": "Silver Shark", "next_hop": 0, "num": "0x7d0b4d3e", "position": {"altitude": 1288, "latitude": 33.898044, "location_source": "LOC_INTERNAL", "longitude": -106.925478, "time_offset_sec": 1097}, "public_key_hex": "cd6684da3b2628b1519030460bdc9e805853d67b0c228f206068c65b3820051b", "role": "CLIENT", "short_name": "S1IT", "snr": 6.55, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.784, "battery_level": 82, "channel_utilization": 4.31, "uptime_seconds": 58437, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 12747, "long_name": "Wild Wolf", "next_hop": 147, "num": "0x7d4152ec", "position": null, "public_key_hex": "7df18c0dc5dbfcaaeb6f7ca85dc77b9f4cac94b8a712800a0f0c3431eaf135b8", "role": "CLIENT", "short_name": "WLT8", "snr": 11.34, "status": null, "telemetry": {"air_util_tx": 1.973, "battery_level": 95, "channel_utilization": 10.3, "uptime_seconds": 3023, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.16, "iaq": 13, "relative_humidity": 66.13, "temperature": 14.4}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1769, "long_name": "Red Owl", "next_hop": 0, "num": "0x7d4fedc1", "position": {"altitude": 2001, "latitude": 33.335951, "location_source": "LOC_INTERNAL", "longitude": -107.005669, "time_offset_sec": 2063}, "public_key_hex": "5f453c396d2c6f898ff25c9481df718829e9e28a95f9477063515eceab741ab0", "role": "CLIENT", "short_name": "RRAT", "snr": 5.53, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.87, "iaq": 117, "relative_humidity": 73.4, "temperature": 20.88}, "hops_away": 3, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 8450, "long_name": "Sleepy Sage", "next_hop": 6, "num": "0x7d53df7b", "position": {"altitude": 1338, "latitude": 33.03032, "location_source": "LOC_INTERNAL", "longitude": -107.840592, "time_offset_sec": 8691}, "public_key_hex": "", "role": "CLIENT", "short_name": "SJK7", "snr": 5.49, "status": null, "telemetry": {"air_util_tx": 0.532, "battery_level": 44, "channel_utilization": 14.6, "uptime_seconds": 6992, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 5, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7020, "long_name": "Howling Stag", "next_hop": 198, "num": "0x7d551a0e", "position": {"altitude": 1631, "latitude": 33.150953, "location_source": "LOC_INTERNAL", "longitude": -108.04326, "time_offset_sec": 7293}, "public_key_hex": "370e6dc9612084da3160b41c831477c0fb3f53f595d6d5af0e1e8e07de6578e1", "role": "CLIENT", "short_name": "HDML", "snr": 6.31, "status": null, "telemetry": {"air_util_tx": 0.965, "battery_level": 66, "channel_utilization": 6.1, "uptime_seconds": 29502, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.99, "iaq": 34, "relative_humidity": 20.22, "temperature": 33.66}, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 13568, "long_name": "Lost Colt", "next_hop": 224, "num": "0x7de420f0", "position": {"altitude": 1744, "latitude": 33.517931, "location_source": "LOC_INTERNAL", "longitude": -107.443147, "time_offset_sec": 13660}, "public_key_hex": "8cc9a62eda24451862754594565201408feaf18e0651f711a4240e90b3c4a4c8", "role": "ROUTER", "short_name": "L85T", "snr": 0.45, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.332, "battery_level": 62, "channel_utilization": 8.58, "uptime_seconds": 87060, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 8061, "long_name": "Iron Tortoise", "next_hop": 0, "num": "0x7e32100e", "position": {"altitude": 1451, "latitude": 33.384417, "location_source": "LOC_INTERNAL", "longitude": -107.120263, "time_offset_sec": 8133}, "public_key_hex": "032d34cf90a63cd3a8b3219194d4ebff7bbb531ee61b1ba8d322b5d8e13832db", "role": "CLIENT", "short_name": "IQEE", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 11176, "long_name": "Lost Oak", "next_hop": 0, "num": "0x7e3a6bb4", "position": null, "public_key_hex": "da65d7d564e97e085e032065e5da084fc3772351cffc8c2b4e5f635abfab678c", "role": "ROUTER", "short_name": "L342", "snr": 11.65, "status": null, "telemetry": {"air_util_tx": 1.199, "battery_level": 10, "channel_utilization": 32.51, "uptime_seconds": 31864, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2417, "long_name": "Found Bronco", "next_hop": 20, "num": "0x7e790e38", "position": {"altitude": 1446, "latitude": 33.965982, "location_source": "LOC_INTERNAL", "longitude": -106.907238, "time_offset_sec": 2562}, "public_key_hex": "1ea6fb805001cb351012674fbb45eaa920279868c7f16902dc72a65d8420cced", "role": "CLIENT", "short_name": "FYZI", "snr": 4.08, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.306, "battery_level": 55, "channel_utilization": 19.68, "uptime_seconds": 21978, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1732, "long_name": "Forest Crane", "next_hop": 155, "num": "0x7ee57380", "position": {"altitude": 1625, "latitude": 33.7565, "location_source": "LOC_INTERNAL", "longitude": -106.221146, "time_offset_sec": 1959}, "public_key_hex": "cc35eab479984aa078edd3e6565d370ead0830a0a5f050efbd1f5f545bc899d6", "role": "CLIENT", "short_name": "F8RQ", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 2.779, "battery_level": 43, "channel_utilization": 17.51, "uptime_seconds": 74134, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.28, "iaq": 32, "relative_humidity": 87.11, "temperature": 17.11}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2447, "long_name": "Floating Otter", "next_hop": 0, "num": "0x7f5e03cb", "position": null, "public_key_hex": "b602a5c256b3f81fb7b263c4881c88c5262a421d51378d2ac019ba4a04d4ab7d", "role": "CLIENT", "short_name": "FXE4", "snr": 5.58, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1037, "long_name": "Sharp Lynx", "next_hop": 120, "num": "0x7f639609", "position": {"altitude": 1385, "latitude": 32.745139, "location_source": "LOC_INTERNAL", "longitude": -107.423057, "time_offset_sec": 1290}, "public_key_hex": "5b5d72aabd3d2481578748087a584196ee86681a96d8d14466752bdb3057357e", "role": "CLIENT", "short_name": "SZQU", "snr": -0.41, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.989, "battery_level": 101, "channel_utilization": 11.13, "uptime_seconds": 204047, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "M5STACK_C6L", "last_heard_offset_sec": 1451, "long_name": "Shady Pine", "next_hop": 7, "num": "0x7f8ef43f", "position": {"altitude": 1674, "latitude": 33.438551, "location_source": "LOC_INTERNAL", "longitude": -106.791055, "time_offset_sec": 1457}, "public_key_hex": "4cb8ffe22535c5780e98f8f9030d5370485d1843935ee73fa5ffd9e684f8e13e", "role": "CLIENT", "short_name": "SQMU", "snr": 6.59, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.414, "battery_level": 25, "channel_utilization": 3.27, "uptime_seconds": 27887, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 33, "long_name": "Copper Viper", "next_hop": 150, "num": "0x7fa58837", "position": {"altitude": 1249, "latitude": 33.004326, "location_source": "LOC_INTERNAL", "longitude": -106.788775, "time_offset_sec": 83}, "public_key_hex": "b0cf2ed5196b43473b8647b4098549a7253e5a59d51b30344477423f0d599c40", "role": "CLIENT", "short_name": "CNWM", "snr": 3.7, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 962, "long_name": "Shady Colt", "next_hop": 17, "num": "0x7fa8995c", "position": {"altitude": 957, "latitude": 32.352182, "location_source": "LOC_INTERNAL", "longitude": -106.829041, "time_offset_sec": 1137}, "public_key_hex": "67036658387e4fb79209f4e0c4e320580839d685dddd26fcfea54c18c47db02c", "role": "CLIENT", "short_name": "SVXW", "snr": 10.33, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.378, "battery_level": 66, "channel_utilization": 3.57, "uptime_seconds": 56981, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5508, "long_name": "Desert Bear", "next_hop": 197, "num": "0x7fb71dfa", "position": {"altitude": 1154, "latitude": 33.700445, "location_source": "LOC_INTERNAL", "longitude": -107.470476, "time_offset_sec": 5778}, "public_key_hex": "c0b296316ecb1cf5996c186d3229da0e3abcc99d37587a6f6109c3dfadf6ef27", "role": "CLIENT_MUTE", "short_name": "DYH7", "snr": 9.78, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1264, "long_name": "Brave Trout", "next_hop": 107, "num": "0x7fc1d815", "position": {"altitude": 1391, "latitude": 33.794744, "location_source": "LOC_INTERNAL", "longitude": -108.297499, "time_offset_sec": 1535}, "public_key_hex": "cd45ebe1ba3ce2528752024898552eaf563efe3d92664baa19abcfba3eb7d435", "role": "ROUTER", "short_name": "BYX3", "snr": -0.57, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.114, "battery_level": 71, "channel_utilization": 8.19, "uptime_seconds": 33822, "voltage": 3.939}} diff --git a/test/fixtures/nodedb/seed_v25_2000.jsonl b/test/fixtures/nodedb/seed_v25_2000.jsonl new file mode 100644 index 00000000000..f0858df6f12 --- /dev/null +++ b/test/fixtures/nodedb/seed_v25_2000.jsonl @@ -0,0 +1,2001 @@ +{"_meta": {"centroid": [33.1284, -107.2528], "count": 2000, "coverage": {"environment": 0.25, "position": 0.85, "status": 0.4, "telemetry": 0.7}, "generated_at_iso": "1970-08-23T11:55:14Z", "last_heard_max_sec": 604800, "last_heard_mean_sec": 3600, "my_node_num_excluded": null, "seed": 20260514, "spread_km": 60.0, "version": 25}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8229, "long_name": "Bright Mesa", "next_hop": 0, "num": "0x0007f2e0", "position": null, "public_key_hex": "001f0f7628ddfa5767b4ac77049f40e41ac80a29d7fc6746e59680f9c3663816", "role": "ROUTER", "short_name": "B9E8", "snr": 2.64, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.169, "battery_level": 76, "channel_utilization": 17.87, "uptime_seconds": 79712, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAG", "last_heard_offset_sec": 831, "long_name": "Floating Mesa", "next_hop": 219, "num": "0x001dff16", "position": {"altitude": 1022, "latitude": 33.41483, "location_source": "LOC_INTERNAL", "longitude": -107.088781, "time_offset_sec": 925}, "public_key_hex": "c54429bee857b2592c08342aa0dacce7d28a940bbfd8445095975ca264ffd380", "role": "CLIENT", "short_name": "F7HN", "snr": 9.98, "status": null, "telemetry": {"air_util_tx": 0.367, "battery_level": 62, "channel_utilization": 1.92, "uptime_seconds": 1082, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2186, "long_name": "Blue Bass NM0GT", "next_hop": 54, "num": "0x002a48d2", "position": {"altitude": 1439, "latitude": 32.532553, "location_source": "LOC_INTERNAL", "longitude": -107.027253, "time_offset_sec": 2480}, "public_key_hex": "", "role": "CLIENT", "short_name": "BVK5", "snr": 7.73, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3060, "long_name": "Wild Wolf", "next_hop": 220, "num": "0x00532e48", "position": {"altitude": 1461, "latitude": 34.025093, "location_source": "LOC_INTERNAL", "longitude": -106.550424, "time_offset_sec": 3087}, "public_key_hex": "", "role": "TAK", "short_name": "WYL6", "snr": 3.81, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.16, "iaq": 43, "relative_humidity": 93.56, "temperature": 23.65}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 370, "long_name": "Sky Bison", "next_hop": 180, "num": "0x005eba1d", "position": {"altitude": 1347, "latitude": 32.459976, "location_source": "LOC_INTERNAL", "longitude": -107.792932, "time_offset_sec": 450}, "public_key_hex": "8ad99bd8081728e03cfb29d4c8b6cc2eec5ede9d7e6d265a3a681bc42ced37ee", "role": "CLIENT", "short_name": "SFDR", "snr": 7.66, "status": null, "telemetry": {"air_util_tx": 0.263, "battery_level": 72, "channel_utilization": 21.1, "uptime_seconds": 177905, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2127, "long_name": "Brave Squirrel", "next_hop": 25, "num": "0x0066a8e3", "position": {"altitude": 1160, "latitude": 34.110472, "location_source": "LOC_INTERNAL", "longitude": -107.274507, "time_offset_sec": 2356}, "public_key_hex": "0170dd866e65393e83eb49003756ed2d3f13ac4d13410131650d75013d2160a5", "role": "CLIENT", "short_name": "B6GC", "snr": 12.0, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.093, "battery_level": 86, "channel_utilization": 2.33, "uptime_seconds": 24337, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 7111, "long_name": "Steel Phoenix", "next_hop": 0, "num": "0x0068b75c", "position": {"altitude": 1244, "latitude": 32.867827, "location_source": "LOC_INTERNAL", "longitude": -107.293966, "time_offset_sec": 7150}, "public_key_hex": "69a997635edf82d6783987101701b05607f2655c40dec7783c11e427d56632d4", "role": "TRACKER", "short_name": "SRYT", "snr": 10.36, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.72, "iaq": 57, "relative_humidity": 45.74, "temperature": 30.09}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5517, "long_name": "Burning Mamba", "next_hop": 106, "num": "0x006f8488", "position": {"altitude": 1668, "latitude": 33.875796, "location_source": "LOC_INTERNAL", "longitude": -107.257659, "time_offset_sec": 5569}, "public_key_hex": "59cb40936cb59b94a15bbd769e8da0795a9dc07ceb6b6fc83c395bb8fd32b224", "role": "CLIENT", "short_name": "🗻", "snr": 5.97, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.615, "battery_level": 15, "channel_utilization": 12.02, "uptime_seconds": 260294, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2728, "long_name": "Gold Beaver", "next_hop": 180, "num": "0x0079fb5d", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "G7WQ", "snr": 8.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4502, "long_name": "Roving Shark", "next_hop": 0, "num": "0x007bda5d", "position": null, "public_key_hex": "12efa087b562a917706d5c0f5d3be6065b70144ff000603d37c9f91b75836705", "role": "CLIENT_HIDDEN", "short_name": "R07B", "snr": 8.76, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.091, "battery_level": 88, "channel_utilization": 12.13, "uptime_seconds": 249582, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3211, "long_name": "Wandering Whale", "next_hop": 0, "num": "0x00895ace", "position": null, "public_key_hex": "fbbe2995d782641732cd83fb5ce87cf961db7731b10fc840cc109cc216bad130", "role": "CLIENT", "short_name": "WREK", "snr": 10.61, "status": null, "telemetry": {"air_util_tx": 0.014, "battery_level": 92, "channel_utilization": 11.25, "uptime_seconds": 192748, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2318, "long_name": "Mountain Salmon", "next_hop": 0, "num": "0x00b3a1e6", "position": {"altitude": 1649, "latitude": 33.913478, "location_source": "LOC_INTERNAL", "longitude": -107.282379, "time_offset_sec": 2468}, "public_key_hex": "dfc73f00dc167ed329c8da9fda6a7d73f4f8c3570d9245a8a02a9f91956b7063", "role": "CLIENT", "short_name": "MLXE", "snr": 6.13, "status": null, "telemetry": {"air_util_tx": 0.513, "battery_level": 15, "channel_utilization": 13.79, "uptime_seconds": 27814, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 2278, "long_name": "Desert Bear", "next_hop": 211, "num": "0x00ba1d60", "position": null, "public_key_hex": "7914a21fefb679762d7423375f9f92b3c953aabc67f1d758696cc4ecfaa64aaa", "role": "CLIENT", "short_name": "D71X", "snr": 6.84, "status": null, "telemetry": {"air_util_tx": 0.14, "battery_level": 26, "channel_utilization": 3.33, "uptime_seconds": 229103, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_R1_NEO", "last_heard_offset_sec": 2355, "long_name": "Forest Cougar", "next_hop": 0, "num": "0x00c7d5a4", "position": {"altitude": 1566, "latitude": 33.108447, "location_source": "LOC_INTERNAL", "longitude": -106.957214, "time_offset_sec": 2366}, "public_key_hex": "9183f70fef07654f8ea35870209a8d040532d27912d9f0815a5da74388dd506c", "role": "CLIENT", "short_name": "F7PM", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.447, "battery_level": 14, "channel_utilization": 32.52, "uptime_seconds": 188419, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 929, "long_name": "Short Whale", "next_hop": 139, "num": "0x00ca5806", "position": {"altitude": 1568, "latitude": 33.280769, "location_source": "LOC_INTERNAL", "longitude": -106.316486, "time_offset_sec": 1005}, "public_key_hex": "fa7db22381efb2ee3b8127f396babed1541ee616cc25e27b03846ebda38d53ba", "role": "CLIENT", "short_name": "SC1I", "snr": 11.32, "status": null, "telemetry": {"air_util_tx": 1.347, "battery_level": 52, "channel_utilization": 16.68, "uptime_seconds": 95480, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6281, "long_name": "Storm Whale", "next_hop": 0, "num": "0x00d6d971", "position": {"altitude": 1392, "latitude": 33.186277, "location_source": "LOC_INTERNAL", "longitude": -107.131268, "time_offset_sec": 6570}, "public_key_hex": "07c6ea01933b7aefd2559a7544ece36199ad81f6e43b536ebebda3ddf72e4f3d", "role": "CLIENT", "short_name": "🦇", "snr": 5.67, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.068, "battery_level": 101, "channel_utilization": 15.53, "uptime_seconds": 149264, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.95, "iaq": 43, "relative_humidity": 85.69, "temperature": 16.63}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 15364, "long_name": "Tiny Oak", "next_hop": 0, "num": "0x00e8cdf6", "position": {"altitude": 1276, "latitude": 33.570773, "location_source": "LOC_INTERNAL", "longitude": -106.364993, "time_offset_sec": 15498}, "public_key_hex": "ad881a7bc91329d6a277f6e7ad02022122c4bc84dceb45bf34788479827f3f48", "role": "CLIENT_MUTE", "short_name": "T40H", "snr": 7.28, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.469, "battery_level": 85, "channel_utilization": 18.08, "uptime_seconds": 32595, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 454, "long_name": "Rough Crow", "next_hop": 147, "num": "0x00f94e91", "position": null, "public_key_hex": "275449bb98b288785d03e8aef728aa2b3ae97ed2be97a92f52948fde58d70f71", "role": "CLIENT", "short_name": "RA26", "snr": 3.87, "status": null, "telemetry": {"air_util_tx": 0.401, "battery_level": 79, "channel_utilization": 29.81, "uptime_seconds": 99330, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3481, "long_name": "Found Seal", "next_hop": 196, "num": "0x0109b92d", "position": {"altitude": 1479, "latitude": 33.150642, "location_source": "LOC_INTERNAL", "longitude": -107.24407, "time_offset_sec": 3597}, "public_key_hex": "7c834f82f1320882f547928c0cb5eb03892e54ef33144891a3739539dd5d03d8", "role": "CLIENT", "short_name": "F7CZ", "snr": 5.27, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 4991, "long_name": "Storm Hawk", "next_hop": 63, "num": "0x01186b79", "position": {"altitude": 1546, "latitude": 32.833745, "location_source": "LOC_INTERNAL", "longitude": -106.148397, "time_offset_sec": 5115}, "public_key_hex": "4556286658d01600370e868170bdf9ed28e08ee9629b2f134bc3f155c9602e63", "role": "CLIENT", "short_name": "SB1M", "snr": 7.59, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 297, "long_name": "Quick Bison", "next_hop": 135, "num": "0x0125f06b", "position": null, "public_key_hex": "6053d35cfb31794139c4c26c131416f139035ea61bb291102d0eb0ce31b083fc", "role": "TRACKER", "short_name": "QIKS", "snr": 7.01, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.76, "battery_level": 79, "channel_utilization": 23.78, "uptime_seconds": 124064, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5460, "long_name": "New Cedar", "next_hop": 84, "num": "0x01294864", "position": {"altitude": 1567, "latitude": 33.347513, "location_source": "LOC_INTERNAL", "longitude": -107.062965, "time_offset_sec": 5714}, "public_key_hex": "615b5c01acdd36a1549c342c65b012c6424b475d880e6f434c669dadbf815407", "role": "CLIENT", "short_name": "NUGF", "snr": 1.46, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 1.769, "battery_level": 19, "channel_utilization": 16.81, "uptime_seconds": 56811, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 1236, "long_name": "Hidden Phoenix", "next_hop": 51, "num": "0x012d564c", "position": {"altitude": 1294, "latitude": 33.454301, "location_source": "LOC_INTERNAL", "longitude": -106.402495, "time_offset_sec": 1264}, "public_key_hex": "7c77f6564e5a5e441069c93891d7dd08e59e7bb85801254ec6f7f573699833ef", "role": "CLIENT", "short_name": "HKK0", "snr": 6.08, "status": null, "telemetry": {"air_util_tx": 0.086, "battery_level": 60, "channel_utilization": 16.52, "uptime_seconds": 69635, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 95, "long_name": "Misty Badger", "next_hop": 178, "num": "0x0137951d", "position": {"altitude": 1597, "latitude": 32.293529, "location_source": "LOC_INTERNAL", "longitude": -106.137201, "time_offset_sec": 276}, "public_key_hex": "c26bd5565c1dc48d83920191ab46641afdd9931b0ac7691ae429f522658f9ccb", "role": "CLIENT", "short_name": "MTHR", "snr": 2.79, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.06, "battery_level": 52, "channel_utilization": 12.89, "uptime_seconds": 46136, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6710, "long_name": "Forest Mustang", "next_hop": 0, "num": "0x01439793", "position": {"altitude": 1309, "latitude": 32.807738, "location_source": "LOC_INTERNAL", "longitude": -107.850373, "time_offset_sec": 6882}, "public_key_hex": "af01856fed29b12577034cd85eea510eef282fa33e2a00f9e1f0cd5e8fcc3ce1", "role": "CLIENT_MUTE", "short_name": "FDM6", "snr": 11.44, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.65, "iaq": 89, "relative_humidity": 38.28, "temperature": 19.08}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 273, "long_name": "Copper Crane", "next_hop": 0, "num": "0x0147b299", "position": {"altitude": 1860, "latitude": 33.243011, "location_source": "LOC_INTERNAL", "longitude": -106.726425, "time_offset_sec": 369}, "public_key_hex": "c2bd8885192a00c9ce0bdc97c210d02f393e1165144d78fb9e1338c3a5b59f92", "role": "CLIENT", "short_name": "C7VE", "snr": 1.08, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.407, "battery_level": 96, "channel_utilization": 4.15, "uptime_seconds": 4105, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2759, "long_name": "Sunny Tortoise", "next_hop": 52, "num": "0x01542691", "position": {"altitude": 1782, "latitude": 33.922947, "location_source": "LOC_INTERNAL", "longitude": -106.890017, "time_offset_sec": 2992}, "public_key_hex": "c15c3e71c829db177c0aa8bac3109a9ccc58856de619c579fddb19d4f10ab648", "role": "ROUTER_LATE", "short_name": "🦇", "snr": 9.52, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1000.79, "iaq": 58, "relative_humidity": 45.28, "temperature": 29.5}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2919, "long_name": "Sleepy Ridge", "next_hop": 0, "num": "0x01618827", "position": {"altitude": 1477, "latitude": 33.730411, "location_source": "LOC_INTERNAL", "longitude": -106.532796, "time_offset_sec": 2986}, "public_key_hex": "2f2ab18e6790e969b5d7ae1e795b9d44be5b432e87119dbe7e30ef4dddf440cb", "role": "CLIENT", "short_name": "S2CW", "snr": 6.03, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.031, "battery_level": 74, "channel_utilization": 4.73, "uptime_seconds": 118137, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 3069, "long_name": "Bright Pine", "next_hop": 0, "num": "0x017fe0b9", "position": null, "public_key_hex": "f25082507852e27e49d384165d5619a349224a7ae13ff809278a8c06bb0e990e", "role": "CLIENT", "short_name": "🐢", "snr": 6.78, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.326, "battery_level": 14, "channel_utilization": 15.24, "uptime_seconds": 11078, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.42, "iaq": 39, "relative_humidity": 58.28, "temperature": 12.66}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 679, "long_name": "Sneaky Shark", "next_hop": 0, "num": "0x018d9a20", "position": null, "public_key_hex": "2c3cb7fd4da7e9e09990514e53de75c077a37c50e068fc6474cb445e9aa76c25", "role": "ROUTER", "short_name": "SXNT", "snr": 10.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 121, "long_name": "Desert Heron", "next_hop": 190, "num": "0x018f0c45", "position": {"altitude": 1679, "latitude": 32.108472, "location_source": "LOC_INTERNAL", "longitude": -106.822076, "time_offset_sec": 361}, "public_key_hex": "", "role": "CLIENT", "short_name": "DRJ7", "snr": 6.65, "status": {"status": "rebooted"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 2619, "long_name": "Brave Beaver", "next_hop": 0, "num": "0x01914a24", "position": null, "public_key_hex": "5fe668bf584a620c817107cc741bed50f38dec0dbea62a3a00f44fd102b0476b", "role": "CLIENT", "short_name": "BQ0B", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.586, "battery_level": 62, "channel_utilization": 15.2, "uptime_seconds": 167796, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 9978, "long_name": "Dusk Bronco", "next_hop": 190, "num": "0x01abaf9b", "position": {"altitude": 1924, "latitude": 33.579634, "location_source": "LOC_INTERNAL", "longitude": -106.637877, "time_offset_sec": 10182}, "public_key_hex": "559aaafc73d9bcc6a96d4a96e141785d8f3cfb3dd08f6d7f1d07ec846a70b145", "role": "ROUTER", "short_name": "🦉", "snr": 9.21, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1010.98, "iaq": 60, "relative_humidity": 45.71, "temperature": 14.07}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 1093, "long_name": "Floating Phoenix", "next_hop": 43, "num": "0x01acc5fa", "position": null, "public_key_hex": "07fb6df39d3cda78b6aab4fb569ce002419a5597208c7af2eebc6a05e9203398", "role": "CLIENT", "short_name": "FS8T", "snr": 6.72, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.675, "battery_level": 81, "channel_utilization": 30.16, "uptime_seconds": 75913, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.29, "iaq": 0, "relative_humidity": 71.22, "temperature": 36.56}, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 196, "long_name": "Frozen Squirrel", "next_hop": 0, "num": "0x01b8be75", "position": null, "public_key_hex": "fd9edc6c8f0084473212bbbac17b799097d6b2becf9e3bfbf161a59d77600f82", "role": "CLIENT", "short_name": "FQ7K", "snr": 9.91, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.336, "battery_level": 21, "channel_utilization": 8.47, "uptime_seconds": 70574, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.66, "iaq": 71, "relative_humidity": 55.51, "temperature": 16.2}, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 3823, "long_name": "Roving Mole", "next_hop": 0, "num": "0x01c1f109", "position": {"altitude": 1704, "latitude": 32.848258, "location_source": "LOC_INTERNAL", "longitude": -107.229447, "time_offset_sec": 4067}, "public_key_hex": "8c6fb50ed34180f1815755816ad88a7e181c32ab4bcdc4abaea5bea412fb0265", "role": "CLIENT", "short_name": "RN7Z", "snr": 4.19, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.651, "battery_level": 60, "channel_utilization": 9.79, "uptime_seconds": 7207, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 4256, "long_name": "Wild Tortoise", "next_hop": 220, "num": "0x01cca2e4", "position": {"altitude": 1218, "latitude": 33.208119, "location_source": "LOC_INTERNAL", "longitude": -106.590349, "time_offset_sec": 4415}, "public_key_hex": "", "role": "CLIENT", "short_name": "WLBF", "snr": 11.98, "status": null, "telemetry": {"air_util_tx": 0.464, "battery_level": 39, "channel_utilization": 8.63, "uptime_seconds": 6084, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 5141, "long_name": "Floating Cactus", "next_hop": 101, "num": "0x01d370c9", "position": {"altitude": 1250, "latitude": 32.696984, "location_source": "LOC_INTERNAL", "longitude": -107.144795, "time_offset_sec": 5376}, "public_key_hex": "42c5d66920f048c7caadcef7444bd8046af2972f77015ef5945bac1035ef9710", "role": "CLIENT", "short_name": "FYK8", "snr": 0.5, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.197, "battery_level": 72, "channel_utilization": 37.98, "uptime_seconds": 32349, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5759, "long_name": "Quick Seal", "next_hop": 0, "num": "0x01dedf84", "position": {"altitude": 1500, "latitude": 33.186169, "location_source": "LOC_INTERNAL", "longitude": -107.295956, "time_offset_sec": 6011}, "public_key_hex": "71a3b293f69105e9b53e08c23e392d4ca3f97479d85951d07edb168b34f50202", "role": "CLIENT", "short_name": "QUZP", "snr": 9.35, "status": null, "telemetry": {"air_util_tx": 0.348, "battery_level": 10, "channel_utilization": 10.13, "uptime_seconds": 300339, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 4647, "long_name": "Sharp Trout", "next_hop": 0, "num": "0x01e269e5", "position": null, "public_key_hex": "9ad0c39d1093f36ec74b4b181a992c44aada82e938630da4af0ec126735be5c1", "role": "CLIENT", "short_name": "🦌", "snr": 3.25, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2916, "long_name": "Sunny Mustang", "next_hop": 0, "num": "0x01e40fd4", "position": {"altitude": 1167, "latitude": 31.887372, "location_source": "LOC_INTERNAL", "longitude": -108.159169, "time_offset_sec": 3152}, "public_key_hex": "3b280d6a7860fabe288060e8b74eae6a0f2643f0e0d4de8a85816deab040e787", "role": "CLIENT", "short_name": "S21Z", "snr": 1.79, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 4271, "long_name": "Bright Seal", "next_hop": 0, "num": "0x01e85bb4", "position": null, "public_key_hex": "f7d88da8a34b3e4f86235ba1a7a06572fd5c406720c175c9c437d7c6d8a0fd97", "role": "CLIENT", "short_name": "BPR7", "snr": 1.31, "status": null, "telemetry": {"air_util_tx": 0.099, "battery_level": 99, "channel_utilization": 4.59, "uptime_seconds": 34543, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 9176, "long_name": "Desert Otter", "next_hop": 0, "num": "0x022cfd38", "position": null, "public_key_hex": "73db568dbf006898784fc0c9bc6100e469d1c0fd8977d52ce0a80876121f912c", "role": "SENSOR", "short_name": "D65M", "snr": -0.02, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.167, "battery_level": 15, "channel_utilization": 4.51, "uptime_seconds": 3680, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.58, "iaq": 25, "relative_humidity": 53.34, "temperature": 2.78}, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 13256, "long_name": "Forest Tortoise", "next_hop": 206, "num": "0x024a5df1", "position": {"altitude": 1328, "latitude": 33.314028, "location_source": "LOC_INTERNAL", "longitude": -107.015762, "time_offset_sec": 13358}, "public_key_hex": "1ca7ad5c72c970496b7f2e50fa985c4386cb4de662fc696586846c50ad15142d", "role": "CLIENT", "short_name": "🌊", "snr": 8.14, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.077, "battery_level": 48, "channel_utilization": 9.81, "uptime_seconds": 44539, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 994.73, "iaq": 76, "relative_humidity": 17.18, "temperature": 28.13}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5290, "long_name": "Frosty Wolf", "next_hop": 0, "num": "0x027daf3b", "position": {"altitude": 1473, "latitude": 33.881511, "location_source": "LOC_INTERNAL", "longitude": -108.023938, "time_offset_sec": 5429}, "public_key_hex": "cdb00f135de023f295ed4843c3c10582a3bc151a21adbffa0f2e6df3f8bcaf0f", "role": "CLIENT", "short_name": "🦅", "snr": -3.14, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5032, "long_name": "Slow Wolf", "next_hop": 165, "num": "0x0291d169", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "SU4E", "snr": 4.6, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.37, "iaq": 57, "relative_humidity": 64.77, "temperature": 16.75}, "hops_away": 4, "hw_model": "RAK4631", "last_heard_offset_sec": 3648, "long_name": "Tall Iguana", "next_hop": 101, "num": "0x02928995", "position": {"altitude": 958, "latitude": 33.424809, "location_source": "LOC_INTERNAL", "longitude": -107.307867, "time_offset_sec": 3930}, "public_key_hex": "6290f4dc8d1a3a86718d78c37b77997020f7705477091272f7345f40f629c137", "role": "CLIENT", "short_name": "TF12", "snr": 5.82, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2541, "long_name": "Smooth Bass WD6ET", "next_hop": 0, "num": "0x029f6e09", "position": {"altitude": 1238, "latitude": 32.844104, "location_source": "LOC_INTERNAL", "longitude": -107.08442, "time_offset_sec": 2659}, "public_key_hex": "7e054a7159cd47225e6f4bfc5b6b963cee847ca759c58444203c29f09349c46a", "role": "CLIENT", "short_name": "SCS6", "snr": 8.6, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.438, "battery_level": 76, "channel_utilization": 2.79, "uptime_seconds": 193100, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 1188, "long_name": "Gold Oak", "next_hop": 171, "num": "0x02b1744a", "position": {"altitude": 1509, "latitude": 33.783615, "location_source": "LOC_INTERNAL", "longitude": -107.135339, "time_offset_sec": 1226}, "public_key_hex": "e8baa744af48335db7a167eaf8bc55f33fe42a5dff753b152218758f8c75c796", "role": "CLIENT", "short_name": "GJLE", "snr": 9.57, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.532, "battery_level": 101, "channel_utilization": 5.95, "uptime_seconds": 46885, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4614, "long_name": "Frozen Wolf", "next_hop": 0, "num": "0x02b32ce6", "position": {"altitude": 1341, "latitude": 32.942595, "location_source": "LOC_INTERNAL", "longitude": -108.092729, "time_offset_sec": 4658}, "public_key_hex": "f5dbf64c4ef1a66b673bddab6bc84d7137a48daf0465ea53ab760bd1d2f0e626", "role": "CLIENT_MUTE", "short_name": "FUME", "snr": 6.4, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.21, "battery_level": 77, "channel_utilization": 14.33, "uptime_seconds": 83369, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1005.58, "iaq": 63, "relative_humidity": 54.24, "temperature": 13.21}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 5554, "long_name": "Happy Doe", "next_hop": 0, "num": "0x02bae3d1", "position": {"altitude": 1591, "latitude": 32.217043, "location_source": "LOC_INTERNAL", "longitude": -107.189085, "time_offset_sec": 5664}, "public_key_hex": "", "role": "CLIENT", "short_name": "H66X", "snr": -0.14, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.16, "iaq": 9, "relative_humidity": 52.59, "temperature": 14.58}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1903, "long_name": "Short Pine", "next_hop": 0, "num": "0x02d78950", "position": {"altitude": 1469, "latitude": 32.798185, "location_source": "LOC_INTERNAL", "longitude": -107.07539, "time_offset_sec": 2055}, "public_key_hex": "ff2055d440f579bac595ea8af882c96d64861b850dd9b70310369997739f40e5", "role": "CLIENT", "short_name": "SBSD", "snr": 7.77, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 2569, "long_name": "Found Shark", "next_hop": 0, "num": "0x02ddda40", "position": {"altitude": 1364, "latitude": 32.895285, "location_source": "LOC_INTERNAL", "longitude": -107.378306, "time_offset_sec": 2667}, "public_key_hex": "510ccf0dcb7b10009040798a0d0847be9a46cb851fc19066107f724e874c8162", "role": "CLIENT", "short_name": "F8CN", "snr": 3.56, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.628, "battery_level": 12, "channel_utilization": 3.71, "uptime_seconds": 47094, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1745, "long_name": "Quick Moose", "next_hop": 163, "num": "0x02ea9489", "position": {"altitude": 1635, "latitude": 33.080891, "location_source": "LOC_INTERNAL", "longitude": -106.522204, "time_offset_sec": 1903}, "public_key_hex": "77bab2e5b93e5bcd80170d64eefadeb94cd628c8478797c73769c74214d3a15b", "role": "CLIENT", "short_name": "QE8I", "snr": 0.83, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.075, "battery_level": 47, "channel_utilization": 15.11, "uptime_seconds": 56994, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3884, "long_name": "Giant Crane", "next_hop": 84, "num": "0x02ee8e7c", "position": {"altitude": 1322, "latitude": 32.877001, "location_source": "LOC_INTERNAL", "longitude": -107.619291, "time_offset_sec": 4088}, "public_key_hex": "f3005c44852b2456365bf84a40c3a53aa0e3721b51455bb2229782f53662db77", "role": "CLIENT_MUTE", "short_name": "GGPT", "snr": 3.42, "status": null, "telemetry": {"air_util_tx": 1.067, "battery_level": 77, "channel_utilization": 25.18, "uptime_seconds": 177488, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 5991, "long_name": "Giant Wolf", "next_hop": 0, "num": "0x030289ca", "position": {"altitude": 996, "latitude": 32.854767, "location_source": "LOC_INTERNAL", "longitude": -107.688442, "time_offset_sec": 6266}, "public_key_hex": "3c96dffdbdf7cf452c85ad51e51f2cb45e5fc8a110c416522c081d8e59a9ba02", "role": "CLIENT", "short_name": "G57O", "snr": 10.16, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4853, "long_name": "Mountain Fox", "next_hop": 236, "num": "0x030ac1c6", "position": {"altitude": 1029, "latitude": 33.289827, "location_source": "LOC_INTERNAL", "longitude": -108.149727, "time_offset_sec": 4860}, "public_key_hex": "b2e18d18176ba08554195606cc2b3fcb936b278140d4d14d7d8bc7cdf2481c25", "role": "CLIENT", "short_name": "M8EJ", "snr": 10.04, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.986, "battery_level": 64, "channel_utilization": 19.1, "uptime_seconds": 44245, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.89, "iaq": 39, "relative_humidity": 60.21, "temperature": 18.29}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 20215, "long_name": "Sleepy Squirrel", "next_hop": 0, "num": "0x030ba4f8", "position": {"altitude": 1250, "latitude": 34.002477, "location_source": "LOC_INTERNAL", "longitude": -107.584564, "time_offset_sec": 20251}, "public_key_hex": "1e3d714f0981374bc214983d702a2b79d93ff3fc20f73c37e635c31ffaee196c", "role": "CLIENT", "short_name": "SM67", "snr": 7.72, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.414, "battery_level": 37, "channel_utilization": 8.2, "uptime_seconds": 116450, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 3165, "long_name": "Frosty Eagle", "next_hop": 157, "num": "0x032a7678", "position": null, "public_key_hex": "0f173dbff64a5a22f5129afa78a487d3b8287f2fdc64f51547c87a65d1765178", "role": "CLIENT", "short_name": "FMT2", "snr": 4.91, "status": null, "telemetry": {"air_util_tx": 0.286, "battery_level": 98, "channel_utilization": 10.41, "uptime_seconds": 84208, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1525, "long_name": "Forest Crow W50FH", "next_hop": 157, "num": "0x032ff9d4", "position": {"altitude": 1403, "latitude": 33.047827, "location_source": "LOC_INTERNAL", "longitude": -107.246799, "time_offset_sec": 1638}, "public_key_hex": "c952dee9e54f6d5bbcc8395f1a4259e28426bf8ad50e59b55dffc7b261294944", "role": "CLIENT", "short_name": "F213", "snr": 4.33, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.28, "iaq": 9, "relative_humidity": 70.31, "temperature": 23.35}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 13397, "long_name": "New Cobra N54XE", "next_hop": 255, "num": "0x033873fb", "position": {"altitude": 1337, "latitude": 32.956884, "location_source": "LOC_INTERNAL", "longitude": -106.880757, "time_offset_sec": 13503}, "public_key_hex": "282e6ac1745a84453fc137a8a48537666bc663a554bd1361b986bd0d9b40fa0e", "role": "CLIENT", "short_name": "NX2B", "snr": -0.31, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 10127, "long_name": "Found Moose", "next_hop": 71, "num": "0x0360fbf1", "position": {"altitude": 1338, "latitude": 33.050471, "location_source": "LOC_INTERNAL", "longitude": -107.089007, "time_offset_sec": 10170}, "public_key_hex": "e02ce82f4283c6a5204abb5bd725a9dc4f8ae11b0fc52c40041140da673a5fa0", "role": "CLIENT", "short_name": "FMV8", "snr": 11.28, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 264, "long_name": "Rough Mole", "next_hop": 0, "num": "0x036debde", "position": {"altitude": 1650, "latitude": 33.023115, "location_source": "LOC_INTERNAL", "longitude": -106.77483, "time_offset_sec": 429}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "RUMG", "snr": 7.03, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 657, "long_name": "Found Moose", "next_hop": 95, "num": "0x036f3748", "position": {"altitude": 1251, "latitude": 33.306297, "location_source": "LOC_INTERNAL", "longitude": -106.450588, "time_offset_sec": 685}, "public_key_hex": "b6f586ea2f3aee2a6603ddab73786f945b99743feae28d2e6838cefa8cdfd2ef", "role": "CLIENT", "short_name": "FV00", "snr": 6.18, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2745, "long_name": "Storm Oak", "next_hop": 0, "num": "0x03706d92", "position": null, "public_key_hex": "7f12e5667c7085eaf4036fd3d6f8e62e4f169a28e0f57eaf0b27033e127f76bb", "role": "CLIENT", "short_name": "SY2N", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK3312", "last_heard_offset_sec": 5457, "long_name": "Happy Sage", "next_hop": 23, "num": "0x0386b6c0", "position": {"altitude": 1645, "latitude": 32.768968, "location_source": "LOC_INTERNAL", "longitude": -107.913528, "time_offset_sec": 5571}, "public_key_hex": "22abc1ddbebb019357e7522d990a3da42650aa5480b19cdc1802adc646720134", "role": "CLIENT_HIDDEN", "short_name": "H1X7", "snr": 8.25, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.71, "iaq": 8, "relative_humidity": 50.09, "temperature": 11.29}, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2297, "long_name": "Found Cougar", "next_hop": 84, "num": "0x038b848c", "position": null, "public_key_hex": "d2e27692d1e105a4807726fa946576c47eed858afb049dd5b55106a412adc78c", "role": "CLIENT", "short_name": "F56E", "snr": 6.15, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.696, "battery_level": 49, "channel_utilization": 6.22, "uptime_seconds": 122653, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3823, "long_name": "Red Beaver", "next_hop": 0, "num": "0x0396bb67", "position": {"altitude": 945, "latitude": 32.815692, "location_source": "LOC_INTERNAL", "longitude": -107.722354, "time_offset_sec": 4039}, "public_key_hex": "3ef755791a000d63f6c36d16eebff6a6cb2375ab0208ba49f59c778e394c5988", "role": "SENSOR", "short_name": "RFNL", "snr": 5.33, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.92, "iaq": 27, "relative_humidity": 33.2, "temperature": 17.59}, "hops_away": 2, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 158, "long_name": "Desert Whale", "next_hop": 113, "num": "0x03b0539a", "position": {"altitude": 1647, "latitude": 34.496846, "location_source": "LOC_INTERNAL", "longitude": -106.698514, "time_offset_sec": 164}, "public_key_hex": "3226c394b634d1711fd5d421bd3eeb5a8a80741a30ef4807d1f33f1b3ab99bfb", "role": "CLIENT", "short_name": "🦇", "snr": 6.03, "status": null, "telemetry": {"air_util_tx": 0.373, "battery_level": 85, "channel_utilization": 19.2, "uptime_seconds": 45236, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2251, "long_name": "Gold Sage", "next_hop": 0, "num": "0x03b1a289", "position": {"altitude": 1624, "latitude": 33.693434, "location_source": "LOC_INTERNAL", "longitude": -106.174747, "time_offset_sec": 2551}, "public_key_hex": "7e86d09ee5079a77a33fb5e3c781bded25ff633ea1221d5caf1e5f54f8df33c6", "role": "CLIENT", "short_name": "G1H7", "snr": 1.12, "status": null, "telemetry": {"air_util_tx": 0.323, "battery_level": 10, "channel_utilization": 13.86, "uptime_seconds": 92903, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1352, "long_name": "Blue Badger", "next_hop": 173, "num": "0x03b52ab7", "position": {"altitude": 1110, "latitude": 33.357521, "location_source": "LOC_INTERNAL", "longitude": -106.582086, "time_offset_sec": 1413}, "public_key_hex": "7da5afae59a8476ed8da595936d4e03f63e20210274d09f6b6e06619a98729ae", "role": "CLIENT", "short_name": "BWCW", "snr": 7.93, "status": null, "telemetry": {"air_util_tx": 1.295, "battery_level": 44, "channel_utilization": 6.23, "uptime_seconds": 24361, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5366, "long_name": "White Owl", "next_hop": 0, "num": "0x03c65c96", "position": {"altitude": 1413, "latitude": 32.782271, "location_source": "LOC_INTERNAL", "longitude": -107.126056, "time_offset_sec": 5656}, "public_key_hex": "b66dffa6cbae9daf481e3972f5a364f16b626b75041209c6e67dbe92124faa29", "role": "CLIENT", "short_name": "W639", "snr": 8.22, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.741, "battery_level": 40, "channel_utilization": 20.77, "uptime_seconds": 134365, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 565, "long_name": "Sleepy Sage", "next_hop": 0, "num": "0x040a2d30", "position": {"altitude": 1599, "latitude": 33.143998, "location_source": "LOC_INTERNAL", "longitude": -107.229661, "time_offset_sec": 842}, "public_key_hex": "9ddfafdc39f102a156a0347fabca40aa28b1a1525edf63606237ec6372f397e0", "role": "ROUTER", "short_name": "SIJE", "snr": 4.16, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 7725, "long_name": "Forest Lynx", "next_hop": 97, "num": "0x0422ced5", "position": null, "public_key_hex": "e668a114bdf0d16278d82d34909f01ec89732c6799cb21d8eff6550f9a2f7a8c", "role": "ROUTER", "short_name": "FN8W", "snr": 4.35, "status": null, "telemetry": {"air_util_tx": 1.663, "battery_level": 20, "channel_utilization": 5.69, "uptime_seconds": 2140, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2241, "long_name": "Lone Pike", "next_hop": 0, "num": "0x042aeebd", "position": null, "public_key_hex": "13c447dc520557790a89e0ae7b5d294d79065b1dfba9c09bfed9c2971c112277", "role": "CLIENT", "short_name": "LIM8", "snr": 1.08, "status": null, "telemetry": {"air_util_tx": 0.665, "battery_level": 70, "channel_utilization": 10.2, "uptime_seconds": 22524, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1012.43, "iaq": 17, "relative_humidity": 59.56, "temperature": 21.85}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 522, "long_name": "Drowsy Salmon", "next_hop": 108, "num": "0x042cd899", "position": {"altitude": 1434, "latitude": 33.009793, "location_source": "LOC_INTERNAL", "longitude": -107.404766, "time_offset_sec": 809}, "public_key_hex": "044651709d1452c01388ae94e9d880e08e1aaed10095698512e61b92ed7f02fc", "role": "CLIENT", "short_name": "DT37", "snr": 11.46, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 3911, "long_name": "Sleepy Colt", "next_hop": 208, "num": "0x044a77cf", "position": {"altitude": 1613, "latitude": 33.359802, "location_source": "LOC_INTERNAL", "longitude": -107.483342, "time_offset_sec": 3993}, "public_key_hex": "b4e13782c801dc38d773f9c54c170babafc54702fa9d162cda5d31d655d0c836", "role": "CLIENT", "short_name": "SXMT", "snr": 4.81, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1621, "long_name": "Iron Badger", "next_hop": 99, "num": "0x045131da", "position": null, "public_key_hex": "b19387e9749736841a04e0d5b4737f94701ae761b1db9da3d84f663e433ab304", "role": "CLIENT", "short_name": "🗻", "snr": 7.49, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.289, "battery_level": 51, "channel_utilization": 20.65, "uptime_seconds": 117534, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.93, "iaq": 53, "relative_humidity": 100.0, "temperature": 1.26}, "hops_away": 3, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 231, "long_name": "Old Heron", "next_hop": 141, "num": "0x0456a202", "position": {"altitude": 1037, "latitude": 32.535056, "location_source": "LOC_INTERNAL", "longitude": -107.461956, "time_offset_sec": 313}, "public_key_hex": "8c0d5dba28d6c339ae088ebe9d19207a6ff62879a7a236b609ec046c8b51dfef", "role": "CLIENT", "short_name": "ODHG", "snr": 10.3, "status": null, "telemetry": {"air_util_tx": 0.043, "battery_level": 68, "channel_utilization": 15.11, "uptime_seconds": 61060, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1018.29, "iaq": 16, "relative_humidity": 77.03, "temperature": 21.39}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 758, "long_name": "Frosty Dolphin", "next_hop": 185, "num": "0x046fa486", "position": null, "public_key_hex": "53633f4ab93d1ddba3412f953eb64233b675ff42c3ba4d59087d382503ee80cc", "role": "CLIENT", "short_name": "FKGJ", "snr": 2.75, "status": null, "telemetry": {"air_util_tx": 0.909, "battery_level": 88, "channel_utilization": 0.69, "uptime_seconds": 71175, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "CROWPANEL", "last_heard_offset_sec": 5000, "long_name": "Frozen Coyote", "next_hop": 98, "num": "0x0481bdfb", "position": {"altitude": 1441, "latitude": 33.179905, "location_source": "LOC_INTERNAL", "longitude": -107.292679, "time_offset_sec": 5063}, "public_key_hex": "3f45eb3cbb171c7924aac16d041ad3c70b2bb17a7638510e85fc64df05fd5e83", "role": "CLIENT", "short_name": "F8BB", "snr": 2.46, "status": null, "telemetry": {"air_util_tx": 0.775, "battery_level": 74, "channel_utilization": 19.02, "uptime_seconds": 69144, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 285, "long_name": "Blue Pine", "next_hop": 23, "num": "0x0497f615", "position": null, "public_key_hex": "473ce7ee04d3cf8bcf17557acadbe908e03b9a72c5626c57020c001f544bb037", "role": "CLIENT", "short_name": "B4TH", "snr": 4.4, "status": null, "telemetry": {"air_util_tx": 0.634, "battery_level": 47, "channel_utilization": 5.91, "uptime_seconds": 6947, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 6840, "long_name": "Mountain Colt", "next_hop": 0, "num": "0x049925c5", "position": {"altitude": 1238, "latitude": 33.128703, "location_source": "LOC_INTERNAL", "longitude": -107.225896, "time_offset_sec": 7034}, "public_key_hex": "1a0d5bd884c8a5c0ec5d367b6bad00382b2262c9320fcdf512a6405857b78006", "role": "ROUTER", "short_name": "MUJN", "snr": 11.49, "status": null, "telemetry": {"air_util_tx": 0.636, "battery_level": 18, "channel_utilization": 11.73, "uptime_seconds": 147172, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 10633, "long_name": "Old Mole", "next_hop": 13, "num": "0x049e4a77", "position": {"altitude": 1336, "latitude": 34.04006, "location_source": "LOC_INTERNAL", "longitude": -107.083156, "time_offset_sec": 10915}, "public_key_hex": "51278b91840f742645ed90e53fb6445f35f68e0b52be479b2e04ce66163a7d1a", "role": "CLIENT", "short_name": "OCSI", "snr": 12.0, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.652, "battery_level": 100, "channel_utilization": 27.34, "uptime_seconds": 32977, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 186, "long_name": "Sky Phoenix", "next_hop": 0, "num": "0x04b166bf", "position": {"altitude": 1374, "latitude": 33.368123, "location_source": "LOC_INTERNAL", "longitude": -106.939517, "time_offset_sec": 205}, "public_key_hex": "8a22e782b8e4b75d2c114736359e9ed38da28a0ad3c68938d4a2665d03042e37", "role": "ROUTER_LATE", "short_name": "SAVD", "snr": 4.59, "status": null, "telemetry": {"air_util_tx": 0.48, "battery_level": 85, "channel_utilization": 11.48, "uptime_seconds": 17582, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.11, "iaq": 108, "relative_humidity": 62.9, "temperature": 24.51}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1358, "long_name": "Stone Seal", "next_hop": 58, "num": "0x04c2e636", "position": {"altitude": 1622, "latitude": 33.374983, "location_source": "LOC_INTERNAL", "longitude": -106.848982, "time_offset_sec": 1577}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "SDVG", "snr": 3.83, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1459, "long_name": "Sharp Elk", "next_hop": 8, "num": "0x04de4a8e", "position": null, "public_key_hex": "cb13f95e675e98ac3e1da555ae9d905e5505bc127159d355649b9855e0d0dffe", "role": "CLIENT", "short_name": "SBMU", "snr": 12.0, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.555, "battery_level": 39, "channel_utilization": 10.19, "uptime_seconds": 114472, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 4468, "long_name": "Wandering Viper K13NI", "next_hop": 20, "num": "0x04dfccee", "position": {"altitude": 1570, "latitude": 34.257299, "location_source": "LOC_INTERNAL", "longitude": -107.851087, "time_offset_sec": 4581}, "public_key_hex": "afe78852fbdd2fa1f89051ff4f427cc2be9d8aa08e6e1fdff6dc566bd8e36c6c", "role": "CLIENT", "short_name": "WFM5", "snr": 4.4, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 3775, "long_name": "Roving Cedar", "next_hop": 0, "num": "0x04e36ca6", "position": {"altitude": 1259, "latitude": 33.615516, "location_source": "LOC_INTERNAL", "longitude": -106.938668, "time_offset_sec": 4057}, "public_key_hex": "b0291a722fd0877953cc89284ad517e4b57b38ee721f5367f5c613711c4cd821", "role": "CLIENT", "short_name": "R4HP", "snr": 6.93, "status": null, "telemetry": {"air_util_tx": 1.043, "battery_level": 61, "channel_utilization": 15.11, "uptime_seconds": 83211, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.35, "iaq": 54, "relative_humidity": 44.46, "temperature": 28.41}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 2705, "long_name": "Giant Mamba", "next_hop": 0, "num": "0x04e3d4ae", "position": {"altitude": 1451, "latitude": 32.923489, "location_source": "LOC_INTERNAL", "longitude": -107.641168, "time_offset_sec": 2907}, "public_key_hex": "bc9bc4955e52e3d6ec63292f9017bb981d9b148ddf82affcd6974b79f03471ca", "role": "ROUTER", "short_name": "GO7F", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.759, "battery_level": 62, "channel_utilization": 13.98, "uptime_seconds": 94147, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": {"barometric_pressure": 1005.55, "iaq": 78, "relative_humidity": 15.35, "temperature": 27.35}, "hops_away": 2, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2678, "long_name": "Black Bronco", "next_hop": 176, "num": "0x04e999a2", "position": {"altitude": 1231, "latitude": 33.201971, "location_source": "LOC_INTERNAL", "longitude": -107.17823, "time_offset_sec": 2849}, "public_key_hex": "b297b0fe25089d987815ff4110c40060b22afebc6962077cd7f0f0609c0a9fff", "role": "CLIENT", "short_name": "B9U6", "snr": 3.82, "status": null, "telemetry": {"air_util_tx": 1.344, "battery_level": 63, "channel_utilization": 2.66, "uptime_seconds": 18934, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 4146, "long_name": "Soft Ridge", "next_hop": 1, "num": "0x05354300", "position": {"altitude": 1539, "latitude": 33.735947, "location_source": "LOC_INTERNAL", "longitude": -106.274555, "time_offset_sec": 4321}, "public_key_hex": "dd46e4544bba4e1b6fc5fcc05517685d2225b2461b1563296f9d44718e73d536", "role": "CLIENT", "short_name": "SMUV", "snr": 7.69, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1379, "long_name": "New Mole", "next_hop": 255, "num": "0x053b1b38", "position": {"altitude": 1330, "latitude": 33.322138, "location_source": "LOC_INTERNAL", "longitude": -107.754317, "time_offset_sec": 1454}, "public_key_hex": "d2072b86b95ae16802a3dc86347d8742837991fb4221d347d28f78e257e289fb", "role": "CLIENT", "short_name": "NBX9", "snr": 3.31, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.494, "battery_level": 90, "channel_utilization": 7.92, "uptime_seconds": 86077, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1936, "long_name": "Desert Stag", "next_hop": 0, "num": "0x054350f1", "position": {"altitude": 998, "latitude": 33.464785, "location_source": "LOC_INTERNAL", "longitude": -106.719424, "time_offset_sec": 2218}, "public_key_hex": "2bc3f3b94300bbf40a66cbdf2d43fb73dfd34a8276470be73c94cad9f565b07e", "role": "CLIENT", "short_name": "DQ2Y", "snr": 6.71, "status": null, "telemetry": {"air_util_tx": 0.3, "battery_level": 79, "channel_utilization": 6.22, "uptime_seconds": 151628, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 243, "long_name": "Floating Cedar", "next_hop": 0, "num": "0x054e1ad2", "position": {"altitude": 1440, "latitude": 32.89922, "location_source": "LOC_INTERNAL", "longitude": -107.395595, "time_offset_sec": 273}, "public_key_hex": "7f79822b1b89726b1e05e978727eefc6d26d1a7e8293fec0c01b847b556392c9", "role": "CLIENT", "short_name": "FSIM", "snr": 3.44, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 9954, "long_name": "Happy Hawk", "next_hop": 79, "num": "0x05672c11", "position": {"altitude": 1052, "latitude": 33.447418, "location_source": "LOC_INTERNAL", "longitude": -106.507797, "time_offset_sec": 10227}, "public_key_hex": "ca6ef2240cc820f8aec108a12485712063a9970beed19b17528cc687ac150c76", "role": "CLIENT", "short_name": "HM1E", "snr": 3.79, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5007, "long_name": "Soft Stag", "next_hop": 0, "num": "0x0576e5a6", "position": {"altitude": 1621, "latitude": 33.164295, "location_source": "LOC_INTERNAL", "longitude": -107.235216, "time_offset_sec": 5041}, "public_key_hex": "aabf15653c978c65279905a962880718fa3a97198d308fa517423dae02945c20", "role": "CLIENT", "short_name": "SR1A", "snr": -0.06, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.418, "battery_level": 53, "channel_utilization": 12.79, "uptime_seconds": 84336, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 566, "long_name": "Rough Moose", "next_hop": 0, "num": "0x057d6147", "position": {"altitude": 1073, "latitude": 32.828681, "location_source": "LOC_INTERNAL", "longitude": -106.396739, "time_offset_sec": 600}, "public_key_hex": "ff7c8747f644cce8fa64be064c08ffa24e9cd767e8e06e0ca981599ca7c9c013", "role": "CLIENT", "short_name": "🦌", "snr": 4.14, "status": null, "telemetry": {"air_util_tx": 1.626, "battery_level": 53, "channel_utilization": 9.13, "uptime_seconds": 172990, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 4398, "long_name": "Dusk Pony", "next_hop": 54, "num": "0x058478ec", "position": {"altitude": 1554, "latitude": 32.95296, "location_source": "LOC_INTERNAL", "longitude": -107.660035, "time_offset_sec": 4616}, "public_key_hex": "2aee713a72ddc256c7806742c0434cbbcf893498f5fd1662d3b7f2be0bdf7b05", "role": "ROUTER", "short_name": "DHKN", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 6975, "long_name": "New Elk", "next_hop": 0, "num": "0x05980173", "position": {"altitude": 1396, "latitude": 33.465394, "location_source": "LOC_INTERNAL", "longitude": -107.383971, "time_offset_sec": 6986}, "public_key_hex": "2e4082ce955f62d0dfb723d53252a0d49db792884730b25c8cc9c96982e17512", "role": "CLIENT", "short_name": "N2TQ", "snr": 9.19, "status": null, "telemetry": {"air_util_tx": 0.398, "battery_level": 38, "channel_utilization": 1.03, "uptime_seconds": 25923, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 7015, "long_name": "Drifting Arroyo", "next_hop": 0, "num": "0x059d39c9", "position": {"altitude": 1616, "latitude": 32.901071, "location_source": "LOC_INTERNAL", "longitude": -107.496145, "time_offset_sec": 7285}, "public_key_hex": "7aac97848f94ab530ed4651674f4096b98f92a49b94591ccc9080e608c37a7a0", "role": "TAK", "short_name": "DEE2", "snr": 6.92, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3716, "long_name": "Hidden Seal", "next_hop": 230, "num": "0x05a54bff", "position": {"altitude": 1216, "latitude": 34.53046, "location_source": "LOC_INTERNAL", "longitude": -107.079542, "time_offset_sec": 3928}, "public_key_hex": "ddf36d9a86d46ef7e211e0dbd88824ed663a9c472a78ae3de44460b48aaba344", "role": "CLIENT", "short_name": "HD7X", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.616, "battery_level": 101, "channel_utilization": 17.81, "uptime_seconds": 154498, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1065, "long_name": "Quick Arroyo", "next_hop": 76, "num": "0x05a62797", "position": {"altitude": 1728, "latitude": 31.883344, "location_source": "LOC_INTERNAL", "longitude": -107.359231, "time_offset_sec": 1347}, "public_key_hex": "e01c41d1a54ead7ede6dab5a629cb144d707caab3f5c32d8dc37410be78efb98", "role": "CLIENT_HIDDEN", "short_name": "Q4ET", "snr": 4.73, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3920, "long_name": "Forest Bluff", "next_hop": 0, "num": "0x05b4ab04", "position": {"altitude": 1465, "latitude": 33.312773, "location_source": "LOC_INTERNAL", "longitude": -108.129112, "time_offset_sec": 4128}, "public_key_hex": "", "role": "CLIENT", "short_name": "FJ6T", "snr": 8.39, "status": null, "telemetry": {"air_util_tx": 1.306, "battery_level": 44, "channel_utilization": 5.19, "uptime_seconds": 189793, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1003.75, "iaq": 59, "relative_humidity": 56.79, "temperature": 19.73}, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4701, "long_name": "Happy Badger", "next_hop": 210, "num": "0x05e28ea3", "position": {"altitude": 1373, "latitude": 32.927303, "location_source": "LOC_INTERNAL", "longitude": -106.40729, "time_offset_sec": 4949}, "public_key_hex": "22860b0e711e415e3028f7520ade3d1bb441164764bfd3d583651cb0342d512d", "role": "CLIENT", "short_name": "HX47", "snr": 4.82, "status": null, "telemetry": {"air_util_tx": 0.47, "battery_level": 17, "channel_utilization": 3.09, "uptime_seconds": 33552, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 9215, "long_name": "Smooth Shark", "next_hop": 0, "num": "0x05fa1ceb", "position": {"altitude": 1552, "latitude": 33.012463, "location_source": "LOC_INTERNAL", "longitude": -106.798298, "time_offset_sec": 9331}, "public_key_hex": "50ece4528b1c5177ae8b5c838d9c387e25b73f13c1a9192b8b17367eb67f1189", "role": "CLIENT", "short_name": "SU9U", "snr": 2.32, "status": {"status": "weak-signal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5619, "long_name": "Tiny Coyote NM0MT", "next_hop": 36, "num": "0x05fc7c38", "position": {"altitude": 1345, "latitude": 33.642835, "location_source": "LOC_INTERNAL", "longitude": -107.163932, "time_offset_sec": 5741}, "public_key_hex": "", "role": "CLIENT_BASE", "short_name": "TDUU", "snr": 4.87, "status": null, "telemetry": {"air_util_tx": 0.224, "battery_level": 29, "channel_utilization": 27.55, "uptime_seconds": 21723, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 362, "long_name": "Iron Hare", "next_hop": 0, "num": "0x0613f285", "position": {"altitude": 1684, "latitude": 32.696595, "location_source": "LOC_INTERNAL", "longitude": -107.451437, "time_offset_sec": 462}, "public_key_hex": "0ac65252d099366385b68666e62cd61a6ac3539b0e695d071234454d190624a3", "role": "CLIENT", "short_name": "IFAW", "snr": -3.05, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 10371, "long_name": "Desert Shark", "next_hop": 0, "num": "0x064f681a", "position": null, "public_key_hex": "0870521e7e9cfabf7859814e695974f730d553dc07d389a4d1aaf6f71247e337", "role": "CLIENT", "short_name": "🦉", "snr": 11.4, "status": null, "telemetry": {"air_util_tx": 1.207, "battery_level": 38, "channel_utilization": 14.18, "uptime_seconds": 35246, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 624, "long_name": "Storm Wolf", "next_hop": 0, "num": "0x06535cfe", "position": {"altitude": 1652, "latitude": 32.388809, "location_source": "LOC_INTERNAL", "longitude": -107.07354, "time_offset_sec": 722}, "public_key_hex": "c6d3d984aeaea95f24c3ce867bf529098ddae3105a07d0a8ac46bb2657ad77e2", "role": "CLIENT", "short_name": "SPOA", "snr": 9.67, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 4168, "long_name": "Sleepy Mamba", "next_hop": 147, "num": "0x065b4bd5", "position": null, "public_key_hex": "50f947f672eee53e1f822475e5481ae5f2a6494658a7d2a2a78327697a0d553f", "role": "CLIENT", "short_name": "S5N4", "snr": 8.91, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.85, "iaq": 57, "relative_humidity": 92.0, "temperature": 15.57}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2252, "long_name": "Mountain Bear", "next_hop": 0, "num": "0x065c31c6", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "MO8L", "snr": 6.53, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.432, "battery_level": 25, "channel_utilization": 15.12, "uptime_seconds": 5756, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 112, "long_name": "Old Eagle", "next_hop": 0, "num": "0x066cdb59", "position": {"altitude": 1171, "latitude": 32.757772, "location_source": "LOC_INTERNAL", "longitude": -107.205119, "time_offset_sec": 403}, "public_key_hex": "fd4b0a6e058277d650dabf803f53e094f03c8d751d143737f9e0281ae6778bcc", "role": "CLIENT", "short_name": "OKWQ", "snr": 10.55, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 7527, "long_name": "Found Shark", "next_hop": 123, "num": "0x0691449e", "position": {"altitude": 1562, "latitude": 33.517275, "location_source": "LOC_INTERNAL", "longitude": -107.779703, "time_offset_sec": 7739}, "public_key_hex": "d19a49af668d448778a733124620bb0981caa541e18f1c557ed1426ba8ec3198", "role": "CLIENT", "short_name": "F19Y", "snr": -5.91, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.05, "battery_level": 12, "channel_utilization": 30.48, "uptime_seconds": 66771, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2528, "long_name": "Brave Ridge", "next_hop": 0, "num": "0x06952b26", "position": {"altitude": 1435, "latitude": 33.992872, "location_source": "LOC_INTERNAL", "longitude": -106.695361, "time_offset_sec": 2621}, "public_key_hex": "515f390de852ec7ec636b3067151f32affb6bec1a45e38084d99579b27762250", "role": "CLIENT", "short_name": "B61B", "snr": 9.66, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.321, "battery_level": 80, "channel_utilization": 34.33, "uptime_seconds": 128736, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2337, "long_name": "Lone Coyote", "next_hop": 66, "num": "0x069e4ce6", "position": {"altitude": 1453, "latitude": 32.819372, "location_source": "LOC_INTERNAL", "longitude": -106.145965, "time_offset_sec": 2524}, "public_key_hex": "bfe67596ee58b7b3a642bc3ed3ecc468daf9a1b75a26aa3345b894c663451625", "role": "CLIENT", "short_name": "LO83", "snr": 4.44, "status": null, "telemetry": {"air_util_tx": 0.142, "battery_level": 15, "channel_utilization": 6.97, "uptime_seconds": 88351, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 243, "long_name": "Copper Eagle", "next_hop": 185, "num": "0x06bc8cc2", "position": {"altitude": 1673, "latitude": 33.302258, "location_source": "LOC_INTERNAL", "longitude": -106.887775, "time_offset_sec": 459}, "public_key_hex": "2e42e360b2698b9a8cce6bc506cadfe28873fe5116373f8a8b51c6e65e581160", "role": "CLIENT", "short_name": "🦉", "snr": 5.68, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.1, "iaq": 41, "relative_humidity": 82.12, "temperature": 6.16}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3504, "long_name": "Drowsy Mustang", "next_hop": 0, "num": "0x06d0c655", "position": {"altitude": 1458, "latitude": 31.551143, "location_source": "LOC_INTERNAL", "longitude": -106.41566, "time_offset_sec": 3601}, "public_key_hex": "5a16806d6dd194aaee07f8f557aa95dac36f795c6084c7f766baf7274262f5f2", "role": "CLIENT", "short_name": "DQG2", "snr": 2.61, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 205, "long_name": "Wandering Bass", "next_hop": 0, "num": "0x06da0525", "position": null, "public_key_hex": "cb948281a3a8c40e6bc070358f87d249d3f3cf416f8cfa90bcd66a26f5552815", "role": "CLIENT", "short_name": "WR8I", "snr": 9.5, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1003.88, "iaq": 55, "relative_humidity": 77.77, "temperature": 26.87}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5079, "long_name": "Dusk Dolphin", "next_hop": 60, "num": "0x06e07df4", "position": {"altitude": 1377, "latitude": 32.73597, "location_source": "LOC_INTERNAL", "longitude": -106.8607, "time_offset_sec": 5340}, "public_key_hex": "", "role": "CLIENT", "short_name": "DTGU", "snr": 4.29, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 5, "hw_model": "RAK4631", "last_heard_offset_sec": 1665, "long_name": "Shady Salmon", "next_hop": 67, "num": "0x06e0861f", "position": null, "public_key_hex": "c5f4ebb4dff1ff937a3aeb9b11f33a91b862fd6533f3a9f885e2d5363d0c3271", "role": "CLIENT", "short_name": "SEBK", "snr": -6.75, "status": null, "telemetry": {"air_util_tx": 0.777, "battery_level": 79, "channel_utilization": 6.23, "uptime_seconds": 92516, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2851, "long_name": "Blue Tortoise", "next_hop": 0, "num": "0x06f6be4f", "position": {"altitude": 1523, "latitude": 33.657991, "location_source": "LOC_INTERNAL", "longitude": -107.169927, "time_offset_sec": 2956}, "public_key_hex": "91864742f79894ae10ebb1fd7634b6ac16a62c08920b0ae104b3ceae8a1458d4", "role": "ROUTER", "short_name": "🌵", "snr": 0.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2183, "long_name": "Brave Hawk", "next_hop": 0, "num": "0x07020303", "position": {"altitude": 908, "latitude": 34.018171, "location_source": "LOC_INTERNAL", "longitude": -107.498761, "time_offset_sec": 2337}, "public_key_hex": "02d06dab9cc672a8dde414d0c17e979adc2116fec65d996a2bcf06076517dbff", "role": "CLIENT", "short_name": "B6IQ", "snr": 5.05, "status": null, "telemetry": {"air_util_tx": 1.277, "battery_level": 28, "channel_utilization": 21.13, "uptime_seconds": 104935, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.89, "iaq": 75, "relative_humidity": 72.46, "temperature": 16.8}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5515, "long_name": "Red Oak", "next_hop": 1, "num": "0x071c05d9", "position": {"altitude": 1390, "latitude": 32.932856, "location_source": "LOC_INTERNAL", "longitude": -107.574063, "time_offset_sec": 5621}, "public_key_hex": "4696bfaddf38bf55d5e675055b4646b9947b276c983ab68efd02c107c3fb89d9", "role": "CLIENT", "short_name": "RLSZ", "snr": 4.48, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.219, "battery_level": 45, "channel_utilization": 7.69, "uptime_seconds": 31111, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 663, "long_name": "Canyon Coyote", "next_hop": 0, "num": "0x071e844b", "position": {"altitude": 1571, "latitude": 33.387709, "location_source": "LOC_INTERNAL", "longitude": -108.000853, "time_offset_sec": 919}, "public_key_hex": "", "role": "CLIENT", "short_name": "CVKJ", "snr": 11.85, "status": null, "telemetry": {"air_util_tx": 1.774, "battery_level": 101, "channel_utilization": 12.64, "uptime_seconds": 16036, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 172, "long_name": "Green Falcon", "next_hop": 0, "num": "0x072067db", "position": {"altitude": 1141, "latitude": 33.391436, "location_source": "LOC_INTERNAL", "longitude": -107.068131, "time_offset_sec": 305}, "public_key_hex": "2c7b564a1f0c5dff6833847edf8909743b1574eb4c5ea36a51bb7e81e9efd6be", "role": "CLIENT", "short_name": "G6H1", "snr": 2.57, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.213, "battery_level": 74, "channel_utilization": 11.37, "uptime_seconds": 26376, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2960, "long_name": "Forest Mamba", "next_hop": 0, "num": "0x0723e139", "position": null, "public_key_hex": "d1b5845a08327d923256f0497284407b426d8dd4a233f180c28e603cda77db3c", "role": "CLIENT", "short_name": "FNOO", "snr": 5.5, "status": null, "telemetry": {"air_util_tx": 1.009, "battery_level": 34, "channel_utilization": 42.94, "uptime_seconds": 13415, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.08, "iaq": 67, "relative_humidity": 44.03, "temperature": 13.52}, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 2724, "long_name": "Copper Cougar", "next_hop": 5, "num": "0x074e8f78", "position": null, "public_key_hex": "37f54b0a51a6b850b2b94b3ac7ecfe3ed40a8abcc849286fd1945fd28d4d406a", "role": "CLIENT", "short_name": "CVD2", "snr": 2.83, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 7, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3449, "long_name": "Frosty Turtle", "next_hop": 215, "num": "0x0752c875", "position": null, "public_key_hex": "13a1d678ca956295fc4861848c4b323529821954d35502dd4a54dab757d22247", "role": "CLIENT", "short_name": "F7HD", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.216, "battery_level": 52, "channel_utilization": 6.77, "uptime_seconds": 113535, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1361, "long_name": "Quick Dolphin", "next_hop": 0, "num": "0x0762dd3f", "position": {"altitude": 1476, "latitude": 32.720674, "location_source": "LOC_INTERNAL", "longitude": -108.407401, "time_offset_sec": 1585}, "public_key_hex": "d29b5567f1b3bcdf2e300add03a92e31e36e6fec40be950ecb06127cd79b811c", "role": "CLIENT", "short_name": "QVKY", "snr": 1.91, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2495, "long_name": "Lunar Salmon", "next_hop": 0, "num": "0x076a4496", "position": {"altitude": 1143, "latitude": 33.008029, "location_source": "LOC_INTERNAL", "longitude": -107.122045, "time_offset_sec": 2641}, "public_key_hex": "560d5c8aa16e89a02bdd042baff251aa8cd25fc12d5ecee65e3e5c45a60ac9f7", "role": "CLIENT", "short_name": "LH9S", "snr": 5.57, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1172, "long_name": "Happy Doe", "next_hop": 0, "num": "0x077dd4cd", "position": {"altitude": 1315, "latitude": 33.904755, "location_source": "LOC_INTERNAL", "longitude": -106.202734, "time_offset_sec": 1356}, "public_key_hex": "91af4fa84cd27b2e4846049ba03f678d23ace19ebfded7ccc036c8e8c0e8341e", "role": "CLIENT", "short_name": "HG7N", "snr": 0.04, "status": null, "telemetry": {"air_util_tx": 0.029, "battery_level": 90, "channel_utilization": 19.1, "uptime_seconds": 42796, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1153, "long_name": "Black Crow", "next_hop": 3, "num": "0x078014d5", "position": {"altitude": 1049, "latitude": 31.998774, "location_source": "LOC_INTERNAL", "longitude": -107.298018, "time_offset_sec": 1399}, "public_key_hex": "", "role": "CLIENT", "short_name": "BAHU", "snr": 6.02, "status": {"status": "running"}, "telemetry": {"air_util_tx": 2.074, "battery_level": 101, "channel_utilization": 10.71, "uptime_seconds": 275530, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2963, "long_name": "Roving Cobra", "next_hop": 0, "num": "0x078320e0", "position": {"altitude": 1293, "latitude": 32.806485, "location_source": "LOC_INTERNAL", "longitude": -107.663297, "time_offset_sec": 3183}, "public_key_hex": "62c46158b0ebc08633cdadb2265220f3f9dc087e11daa810f5ee2bca0240a742", "role": "CLIENT", "short_name": "R9P3", "snr": 8.2, "status": null, "telemetry": {"air_util_tx": 1.372, "battery_level": 92, "channel_utilization": 27.49, "uptime_seconds": 49725, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 4191, "long_name": "Lunar Bear", "next_hop": 182, "num": "0x079c5681", "position": {"altitude": 1432, "latitude": 32.451247, "location_source": "LOC_INTERNAL", "longitude": -106.950869, "time_offset_sec": 4392}, "public_key_hex": "a8d9541bc0dece7da18180802bad51ad3d0bc1f6faa7b3a9c2fed2a623d014cf", "role": "CLIENT", "short_name": "🐢", "snr": -0.63, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.941, "battery_level": 77, "channel_utilization": 5.48, "uptime_seconds": 170815, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.67, "iaq": 0, "relative_humidity": 31.22, "temperature": 21.31}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 502, "long_name": "Stone Moose", "next_hop": 0, "num": "0x07e1b9bb", "position": {"altitude": 1227, "latitude": 32.624886, "location_source": "LOC_INTERNAL", "longitude": -106.401179, "time_offset_sec": 632}, "public_key_hex": "f27d2f61740f74c19aee78c9fb80cca1d344ef7526030dec321a271a5e0d5102", "role": "CLIENT", "short_name": "SLE7", "snr": 9.96, "status": null, "telemetry": {"air_util_tx": 0.38, "battery_level": 95, "channel_utilization": 5.01, "uptime_seconds": 20729, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 8626, "long_name": "Short Shark", "next_hop": 13, "num": "0x07e60971", "position": {"altitude": 967, "latitude": 33.362559, "location_source": "LOC_INTERNAL", "longitude": -107.181301, "time_offset_sec": 8692}, "public_key_hex": "07aa78620f2fd5b6fada296e0d30d87cb06eee6255dcc1ea0e255ec7f923af4e", "role": "CLIENT", "short_name": "🗻", "snr": 7.26, "status": null, "telemetry": {"air_util_tx": 1.423, "battery_level": 98, "channel_utilization": 12.52, "uptime_seconds": 30580, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 8004, "long_name": "Soft Arroyo", "next_hop": 0, "num": "0x07f967d8", "position": {"altitude": 1563, "latitude": 33.054424, "location_source": "LOC_INTERNAL", "longitude": -107.987715, "time_offset_sec": 8153}, "public_key_hex": "ad93a5fa92222d73a5188af5a12b52b27e783e30ca6cfba4c7fd9a6ba6c287f5", "role": "CLIENT", "short_name": "🐝", "snr": 6.04, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.409, "battery_level": 45, "channel_utilization": 9.46, "uptime_seconds": 6287, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 368, "long_name": "Wild Gecko", "next_hop": 0, "num": "0x080b97ee", "position": {"altitude": 1347, "latitude": 32.248223, "location_source": "LOC_INTERNAL", "longitude": -106.318041, "time_offset_sec": 651}, "public_key_hex": "", "role": "CLIENT_HIDDEN", "short_name": "W7SR", "snr": -0.34, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4414, "long_name": "New Crane", "next_hop": 0, "num": "0x081305af", "position": {"altitude": 1499, "latitude": 32.329377, "location_source": "LOC_INTERNAL", "longitude": -107.351998, "time_offset_sec": 4504}, "public_key_hex": "f357ad57be9f3a7db234a2977b709c92d6668ece0b774f3996e496382a7dd94b", "role": "ROUTER_LATE", "short_name": "🦅", "snr": 4.25, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1027.45, "iaq": 34, "relative_humidity": 53.97, "temperature": 29.28}, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 5497, "long_name": "Gold Wolf", "next_hop": 0, "num": "0x0819a49f", "position": {"altitude": 785, "latitude": 33.133438, "location_source": "LOC_INTERNAL", "longitude": -107.323046, "time_offset_sec": 5551}, "public_key_hex": "4997bc06fa1b86fc8c874545789da424a602b2e51b8fded90643eb5ff0bc0931", "role": "CLIENT", "short_name": "GAAX", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.222, "battery_level": 24, "channel_utilization": 14.23, "uptime_seconds": 57280, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2950, "long_name": "Dusk Aspen", "next_hop": 0, "num": "0x0837b0e0", "position": null, "public_key_hex": "234dc36676d2bbbfcce1a8df29a204d5026e29a89f1a6515a241bfbb828584a4", "role": "CLIENT", "short_name": "D21S", "snr": 7.6, "status": null, "telemetry": {"air_util_tx": 0.361, "battery_level": 83, "channel_utilization": 16.2, "uptime_seconds": 293525, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": true, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 3359, "long_name": "Frosty Phoenix W53NE", "next_hop": 255, "num": "0x083e1e81", "position": {"altitude": 632, "latitude": 32.957442, "location_source": "LOC_INTERNAL", "longitude": -106.764641, "time_offset_sec": 3515}, "public_key_hex": "c3e25c3378f661c2329aa9c0dbb240018d2f7cccea38bb5ddfff64a10f1f3be7", "role": "CLIENT", "short_name": "FKEU", "snr": 6.37, "status": null, "telemetry": {"air_util_tx": 0.63, "battery_level": 49, "channel_utilization": 17.47, "uptime_seconds": 38564, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK3312", "last_heard_offset_sec": 2213, "long_name": "Quick Bass", "next_hop": 95, "num": "0x083e32a7", "position": {"altitude": 1463, "latitude": 34.173589, "location_source": "LOC_INTERNAL", "longitude": -107.622884, "time_offset_sec": 2214}, "public_key_hex": "beaaa54f28fd414b81b5b004d316d29ab01267264f6114d205a5f6b40d8a8718", "role": "CLIENT", "short_name": "Q77N", "snr": 4.14, "status": null, "telemetry": {"air_util_tx": 0.261, "battery_level": 27, "channel_utilization": 3.59, "uptime_seconds": 5147, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.15, "iaq": 84, "relative_humidity": 69.74, "temperature": 18.66}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2034, "long_name": "Howling Phoenix", "next_hop": 0, "num": "0x0849d8db", "position": null, "public_key_hex": "c82a8a8f3874f7cdfbcc84618324b34638166a9fe5db251dfcbfd83884583f63", "role": "LOST_AND_FOUND", "short_name": "HHU3", "snr": 4.63, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.555, "battery_level": 55, "channel_utilization": 8.75, "uptime_seconds": 52958, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.16, "iaq": 100, "relative_humidity": 69.44, "temperature": 22.04}, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 1407, "long_name": "Sky Turtle", "next_hop": 125, "num": "0x0850d017", "position": {"altitude": 1233, "latitude": 32.980602, "location_source": "LOC_INTERNAL", "longitude": -107.004873, "time_offset_sec": 1454}, "public_key_hex": "183689aa6dad5b84a726d7cd3851abf3ea20fbc46e12879ad8eeb71fe931d51d", "role": "CLIENT", "short_name": "🐝", "snr": 1.59, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 1495, "long_name": "Mountain Lion W58IK", "next_hop": 30, "num": "0x0853597a", "position": {"altitude": 868, "latitude": 32.756267, "location_source": "LOC_INTERNAL", "longitude": -106.481989, "time_offset_sec": 1553}, "public_key_hex": "f4b02f6a4d6f4483fa386f95e2edc34ac141024fe73628ad6255922059b09a7f", "role": "CLIENT", "short_name": "MJO6", "snr": 2.62, "status": null, "telemetry": {"air_util_tx": 0.778, "battery_level": 67, "channel_utilization": 12.66, "uptime_seconds": 38316, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.39, "iaq": 126, "relative_humidity": 56.52, "temperature": 22.55}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 382, "long_name": "Lost Marmot", "next_hop": 0, "num": "0x0856c71d", "position": {"altitude": 1239, "latitude": 33.334985, "location_source": "LOC_INTERNAL", "longitude": -107.999354, "time_offset_sec": 658}, "public_key_hex": "98c4e6ceb2b11af66cd030cbe11c975907b0df09cb756cb3543d9c49a56f0460", "role": "CLIENT", "short_name": "LZQZ", "snr": 10.18, "status": null, "telemetry": {"air_util_tx": 0.19, "battery_level": 40, "channel_utilization": 10.73, "uptime_seconds": 80314, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 218, "long_name": "Storm Doe", "next_hop": 200, "num": "0x085c3e69", "position": {"altitude": 1652, "latitude": 32.863093, "location_source": "LOC_INTERNAL", "longitude": -106.897745, "time_offset_sec": 273}, "public_key_hex": "bb947aaed0ba7322674bc0d7b85f09516ef71ae956596225e8a03cdc43db417e", "role": "CLIENT", "short_name": "S8H4", "snr": 5.29, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1940, "long_name": "Blue Wolf", "next_hop": 0, "num": "0x085c4943", "position": {"altitude": 1271, "latitude": 34.728241, "location_source": "LOC_INTERNAL", "longitude": -107.277381, "time_offset_sec": 2155}, "public_key_hex": "", "role": "CLIENT", "short_name": "B7DQ", "snr": 7.43, "status": null, "telemetry": {"air_util_tx": 0.124, "battery_level": 15, "channel_utilization": 6.75, "uptime_seconds": 37221, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 96, "long_name": "Howling Bronco", "next_hop": 202, "num": "0x086a5cf8", "position": null, "public_key_hex": "108dc0e28b4bd3e4480c06b9dbb8d786e4ffaf32828bc2cf2b31a0f69030d5c5", "role": "CLIENT", "short_name": "HMJU", "snr": 3.88, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.74, "iaq": 76, "relative_humidity": 51.17, "temperature": 20.56}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 716, "long_name": "Frozen Viper", "next_hop": 0, "num": "0x08710a85", "position": {"altitude": 1481, "latitude": 33.031381, "location_source": "LOC_INTERNAL", "longitude": -106.480752, "time_offset_sec": 857}, "public_key_hex": "9dcbaf7f8a7fcebab5c66466fd636b4a883a36c9e606ded6bed72206072344cd", "role": "CLIENT", "short_name": "FSWQ", "snr": 5.67, "status": null, "telemetry": {"air_util_tx": 0.465, "battery_level": 32, "channel_utilization": 4.81, "uptime_seconds": 217103, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 1737, "long_name": "Smooth Crane NM9IH", "next_hop": 220, "num": "0x0888fc95", "position": {"altitude": 1609, "latitude": 32.668237, "location_source": "LOC_INTERNAL", "longitude": -107.281778, "time_offset_sec": 1990}, "public_key_hex": "73fa3111129c6d8d9a9b2d9e8796c6c9343284b5d1b4f6fadcd71aedcf5387fe", "role": "CLIENT", "short_name": "S110", "snr": 9.57, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 541, "long_name": "Dawn Pony", "next_hop": 23, "num": "0x088a0b70", "position": {"altitude": 1532, "latitude": 33.547572, "location_source": "LOC_INTERNAL", "longitude": -107.26254, "time_offset_sec": 709}, "public_key_hex": "cacfc535d09d5c7ab8391e4da9095ef225a648fb2ffd2bf46867b78748981393", "role": "CLIENT", "short_name": "DZ37", "snr": 3.91, "status": null, "telemetry": {"air_util_tx": 0.638, "battery_level": 51, "channel_utilization": 15.24, "uptime_seconds": 182921, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.58, "iaq": 41, "relative_humidity": 46.65, "temperature": 25.93}, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 3519, "long_name": "Tiny Tortoise", "next_hop": 37, "num": "0x089d33a1", "position": {"altitude": 1489, "latitude": 33.2918, "location_source": "LOC_INTERNAL", "longitude": -107.318893, "time_offset_sec": 3781}, "public_key_hex": "2149ce7e568754a66513e52f7f8e666fece10ff16be48fdb149f5b37369d2ddf", "role": "CLIENT_HIDDEN", "short_name": "TZKZ", "snr": 8.94, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.15, "iaq": 0, "relative_humidity": 28.57, "temperature": 18.01}, "hops_away": 0, "hw_model": "HELTEC_V4_R8", "last_heard_offset_sec": 6582, "long_name": "Rough Marmot", "next_hop": 0, "num": "0x08b53f5d", "position": {"altitude": 1361, "latitude": 32.527162, "location_source": "LOC_INTERNAL", "longitude": -107.488523, "time_offset_sec": 6604}, "public_key_hex": "1bf21bca118acfb6892c3ce064b5d070e9aa402fb522681708f52e3ed3b4b0fc", "role": "CLIENT", "short_name": "RJ8C", "snr": 8.08, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 14280, "long_name": "Brave Oak", "next_hop": 0, "num": "0x08b684de", "position": {"altitude": 1436, "latitude": 34.023862, "location_source": "LOC_INTERNAL", "longitude": -106.930889, "time_offset_sec": 14404}, "public_key_hex": "afe05d1b53ef10e067fc4c6265ad0cbeecdfd5ae4d26a45993c7959e095f1a51", "role": "CLIENT", "short_name": "BHN6", "snr": 6.4, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.36, "battery_level": 37, "channel_utilization": 3.98, "uptime_seconds": 367372, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 2188, "long_name": "Short Mustang", "next_hop": 45, "num": "0x08bdcaec", "position": {"altitude": 1682, "latitude": 32.978808, "location_source": "LOC_INTERNAL", "longitude": -106.323917, "time_offset_sec": 2318}, "public_key_hex": "8f4868a37e95e25e7e62a052271e5ba2efae801b7701bd4772a07b6e7230b3c4", "role": "ROUTER_LATE", "short_name": "SC15", "snr": 5.45, "status": null, "telemetry": {"air_util_tx": 0.301, "battery_level": 90, "channel_utilization": 8.05, "uptime_seconds": 104, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2180, "long_name": "Stone Juniper", "next_hop": 0, "num": "0x08dcf551", "position": null, "public_key_hex": "ac54848ec9cf8c2f803b4000be28df7dec81ebea8eddc3926edc7635e7cad2fe", "role": "CLIENT", "short_name": "🗻", "snr": 6.26, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.729, "battery_level": 55, "channel_utilization": 25.46, "uptime_seconds": 578, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 130, "long_name": "Loud Tortoise", "next_hop": 87, "num": "0x08dd880e", "position": {"altitude": 1377, "latitude": 33.784304, "location_source": "LOC_INTERNAL", "longitude": -106.334331, "time_offset_sec": 385}, "public_key_hex": "e54b01e843bf0197e8ea3794291dbdac6e25d5e4158302b0ee4b1665c51bd842", "role": "CLIENT", "short_name": "L5LX", "snr": 12.0, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.51, "iaq": 29, "relative_humidity": 79.33, "temperature": 34.06}, "hops_away": 1, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 6380, "long_name": "Misty Doe", "next_hop": 211, "num": "0x08e2c6f9", "position": {"altitude": 1399, "latitude": 34.666633, "location_source": "LOC_INTERNAL", "longitude": -107.305714, "time_offset_sec": 6585}, "public_key_hex": "70f5b255e8e999162cd2aca466416e38da434ef0bef246e5327b23a2c117980f", "role": "CLIENT", "short_name": "MNJK", "snr": 6.15, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 15581, "long_name": "Loud Ridge", "next_hop": 66, "num": "0x08f0ce0f", "position": {"altitude": 1758, "latitude": 32.398697, "location_source": "LOC_INTERNAL", "longitude": -107.422547, "time_offset_sec": 15749}, "public_key_hex": "84c3c8a2b69b7c7b88acf892bdde839ee52e7517786519da2ef19c1a98309732", "role": "CLIENT", "short_name": "LRTG", "snr": 1.59, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.76, "battery_level": 87, "channel_utilization": 16.91, "uptime_seconds": 44928, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 257, "long_name": "Brave Turtle", "next_hop": 0, "num": "0x0900dc4b", "position": {"altitude": 1277, "latitude": 32.634207, "location_source": "LOC_INTERNAL", "longitude": -106.668197, "time_offset_sec": 368}, "public_key_hex": "2563c01935820157cf34856af5fdb2141a0951588de5f6c240c9abcb2e975211", "role": "CLIENT", "short_name": "B6NB", "snr": 2.82, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.61, "iaq": 29, "relative_humidity": 69.5, "temperature": 20.73}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5645, "long_name": "Sunny Oak", "next_hop": 0, "num": "0x0909ed9d", "position": {"altitude": 1561, "latitude": 33.575935, "location_source": "LOC_INTERNAL", "longitude": -107.16658, "time_offset_sec": 5738}, "public_key_hex": "712b6548782c564653f4103ca974eaca06f900a9d392dc10e535181f17542065", "role": "CLIENT", "short_name": "SAOE", "snr": 5.04, "status": null, "telemetry": {"air_util_tx": 0.46, "battery_level": 16, "channel_utilization": 7.59, "uptime_seconds": 29107, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 3799, "long_name": "Forest Mustang", "next_hop": 115, "num": "0x0925d5ce", "position": null, "public_key_hex": "04c379eb0dd11ba42f2b29de556e3b3a0fee6471a48ffdee98b81db048926758", "role": "CLIENT_MUTE", "short_name": "🔥", "snr": 5.23, "status": null, "telemetry": {"air_util_tx": 0.728, "battery_level": 86, "channel_utilization": 6.67, "uptime_seconds": 127731, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.18, "iaq": 37, "relative_humidity": 68.21, "temperature": 21.23}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2882, "long_name": "Fast Cedar", "next_hop": 0, "num": "0x09446e25", "position": {"altitude": 1347, "latitude": 32.614779, "location_source": "LOC_INTERNAL", "longitude": -107.908319, "time_offset_sec": 3050}, "public_key_hex": "68cb28c5f625a12e93d22eac9d7b2bec05d36ab323f9538c72089072266c5124", "role": "CLIENT", "short_name": "F7R5", "snr": 3.51, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.594, "battery_level": 87, "channel_utilization": 6.61, "uptime_seconds": 42758, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 893, "long_name": "Steel Heron", "next_hop": 96, "num": "0x094d5534", "position": {"altitude": 1456, "latitude": 33.407843, "location_source": "LOC_INTERNAL", "longitude": -107.970392, "time_offset_sec": 933}, "public_key_hex": "5e9d4bd679371f63d1f7998d49651211516abde9b15d18c665211e5d70863efb", "role": "CLIENT", "short_name": "🦋", "snr": 6.69, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.173, "battery_level": 10, "channel_utilization": 11.58, "uptime_seconds": 103937, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1025.8, "iaq": 57, "relative_humidity": 30.81, "temperature": 16.54}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1384, "long_name": "Canyon Bluff", "next_hop": 0, "num": "0x09540183", "position": {"altitude": 1138, "latitude": 33.242592, "location_source": "LOC_INTERNAL", "longitude": -106.915445, "time_offset_sec": 1671}, "public_key_hex": "e60a3cde9cd8fd2bda8a9b66ee27db4f5c9581935c5ea1582ecc402ca6f39875", "role": "CLIENT", "short_name": "CXAB", "snr": 11.8, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1006.81, "iaq": 0, "relative_humidity": 44.15, "temperature": 16.63}, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 10189, "long_name": "Mountain Aspen NM3LW", "next_hop": 210, "num": "0x095bafc3", "position": null, "public_key_hex": "943f31a37b80ba58e5c8a59fb346d29761b72caa55b82855499e90a89c93ed19", "role": "CLIENT", "short_name": "MG70", "snr": 6.29, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.058, "battery_level": 80, "channel_utilization": 10.13, "uptime_seconds": 29732, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.5, "iaq": 54, "relative_humidity": 38.6, "temperature": 12.94}, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 973, "long_name": "Rough Phoenix", "next_hop": 0, "num": "0x096056c1", "position": {"altitude": 1778, "latitude": 32.758152, "location_source": "LOC_INTERNAL", "longitude": -106.938697, "time_offset_sec": 1114}, "public_key_hex": "7a39364c34b643e795ff15b80db09c8d07d93a804bfa7bf141524354408709f0", "role": "CLIENT", "short_name": "🗻", "snr": -3.9, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 3986, "long_name": "Storm Hawk", "next_hop": 0, "num": "0x09653928", "position": {"altitude": 1391, "latitude": 34.144598, "location_source": "LOC_INTERNAL", "longitude": -108.169588, "time_offset_sec": 4084}, "public_key_hex": "3c14d693bd6f8096baee1741337c4723a57d32752a2aa8130bc1761336f87630", "role": "CLIENT_MUTE", "short_name": "SUBO", "snr": 8.62, "status": null, "telemetry": {"air_util_tx": 0.378, "battery_level": 82, "channel_utilization": 13.93, "uptime_seconds": 8720, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 808, "long_name": "Floating Dolphin", "next_hop": 0, "num": "0x097efe6a", "position": {"altitude": 1692, "latitude": 32.727965, "location_source": "LOC_INTERNAL", "longitude": -107.769902, "time_offset_sec": 847}, "public_key_hex": "0e20d948635273e0aee77c3b8cb871b1bde8718e1a98fe7894e9a7f47caebce6", "role": "ROUTER", "short_name": "FPVK", "snr": 6.95, "status": null, "telemetry": {"air_util_tx": 0.142, "battery_level": 25, "channel_utilization": 11.67, "uptime_seconds": 97686, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.3, "iaq": 42, "relative_humidity": 68.72, "temperature": 4.05}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 13698, "long_name": "Shady Raven", "next_hop": 113, "num": "0x0984e01e", "position": {"altitude": 1531, "latitude": 32.824386, "location_source": "LOC_INTERNAL", "longitude": -106.875976, "time_offset_sec": 13950}, "public_key_hex": "337d40db4373a952ec2fed6001a8583b78323c78cf84ae8ef9cf1b499da58548", "role": "CLIENT", "short_name": "STE7", "snr": 2.26, "status": null, "telemetry": {"air_util_tx": 0.611, "battery_level": 89, "channel_utilization": 19.13, "uptime_seconds": 170760, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 1512, "long_name": "New Badger", "next_hop": 0, "num": "0x09c1dcc7", "position": {"altitude": 1465, "latitude": 32.684253, "location_source": "LOC_INTERNAL", "longitude": -107.405809, "time_offset_sec": 1585}, "public_key_hex": "eda9aec7a925cc77e6f3734e394e7f3171048eea164e9026ccfa70ef04e3181e", "role": "CLIENT", "short_name": "NH6Q", "snr": 4.08, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.642, "battery_level": 100, "channel_utilization": 6.43, "uptime_seconds": 21738, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 6587, "long_name": "Howling Moose", "next_hop": 0, "num": "0x09c2ef60", "position": {"altitude": 1061, "latitude": 32.585546, "location_source": "LOC_INTERNAL", "longitude": -107.727074, "time_offset_sec": 6865}, "public_key_hex": "2631191d3f3be9f8beeceb0ea54c58b074e67f33e09fbe9d6cbe30704532015e", "role": "CLIENT_HIDDEN", "short_name": "HYSE", "snr": 9.02, "status": null, "telemetry": {"air_util_tx": 0.36, "battery_level": 26, "channel_utilization": 21.54, "uptime_seconds": 87289, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5926, "long_name": "Sharp Bluff", "next_hop": 35, "num": "0x09c5a7ef", "position": {"altitude": 1315, "latitude": 33.224083, "location_source": "LOC_INTERNAL", "longitude": -108.277923, "time_offset_sec": 6038}, "public_key_hex": "bc8fb6e6722cce38ac9bfe672c575455e9bdecf70599892d490a1ba16b452058", "role": "CLIENT", "short_name": "SD5T", "snr": 6.8, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.361, "battery_level": 48, "channel_utilization": 20.05, "uptime_seconds": 48945, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 172, "long_name": "White Ridge", "next_hop": 0, "num": "0x09d648c0", "position": {"altitude": 1412, "latitude": 33.144764, "location_source": "LOC_INTERNAL", "longitude": -106.549811, "time_offset_sec": 178}, "public_key_hex": "aee4a758503283ddb3e70b9491ddb78036acea7cb1d07cc55646724407a5da7e", "role": "CLIENT", "short_name": "WOB8", "snr": 7.73, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.637, "battery_level": 94, "channel_utilization": 34.08, "uptime_seconds": 19840, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2101, "long_name": "Tiny Ridge", "next_hop": 147, "num": "0x09e3a491", "position": {"altitude": 1446, "latitude": 32.993189, "location_source": "LOC_INTERNAL", "longitude": -107.317994, "time_offset_sec": 2112}, "public_key_hex": "", "role": "CLIENT", "short_name": "TYV0", "snr": 9.59, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.739, "battery_level": 40, "channel_utilization": 24.33, "uptime_seconds": 39367, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1000.02, "iaq": 4, "relative_humidity": 52.0, "temperature": 7.2}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1274, "long_name": "Burning Bear", "next_hop": 156, "num": "0x09e455d8", "position": {"altitude": 843, "latitude": 33.583312, "location_source": "LOC_INTERNAL", "longitude": -107.98528, "time_offset_sec": 1508}, "public_key_hex": "e682621a0ee1332c47fec55dba29f5960d09afdde806ba712c21c612c2f14a22", "role": "CLIENT_HIDDEN", "short_name": "BOQZ", "snr": 0.33, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.605, "battery_level": 42, "channel_utilization": 11.23, "uptime_seconds": 39878, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 621, "long_name": "Blue Viper", "next_hop": 0, "num": "0x0a0a9016", "position": {"altitude": 1583, "latitude": 33.366457, "location_source": "LOC_INTERNAL", "longitude": -107.564094, "time_offset_sec": 871}, "public_key_hex": "", "role": "ROUTER_LATE", "short_name": "B77K", "snr": 8.34, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2939, "long_name": "Solar Viper", "next_hop": 96, "num": "0x0a0c66e7", "position": {"altitude": 1444, "latitude": 32.742217, "location_source": "LOC_INTERNAL", "longitude": -107.189458, "time_offset_sec": 2968}, "public_key_hex": "9d6bd952fcd9341e0e023d001ffaf5481bb01c678a3c9a433fb6619ac698cdf7", "role": "CLIENT", "short_name": "SNIJ", "snr": -1.5, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.112, "battery_level": 32, "channel_utilization": 14.42, "uptime_seconds": 128700, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1148, "long_name": "Misty Seal", "next_hop": 163, "num": "0x0a0d74fe", "position": {"altitude": 1424, "latitude": 33.306896, "location_source": "LOC_INTERNAL", "longitude": -106.58337, "time_offset_sec": 1397}, "public_key_hex": "baa338c103f542ceb43fee27d860ac15aedd0bad8f5ba5938b76c131a6973a78", "role": "ROUTER", "short_name": "MI04", "snr": 3.25, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 9506, "long_name": "Sky Lynx", "next_hop": 167, "num": "0x0a136d5e", "position": null, "public_key_hex": "9fcf8466570df74985c7275b3f26b0a4fb0e8645caa268ff831e2044f60357ee", "role": "CLIENT", "short_name": "S86R", "snr": 5.42, "status": null, "telemetry": {"air_util_tx": 0.752, "battery_level": 64, "channel_utilization": 21.14, "uptime_seconds": 59677, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2065, "long_name": "Drifting Falcon", "next_hop": 0, "num": "0x0a18d999", "position": {"altitude": 1480, "latitude": 33.735945, "location_source": "LOC_INTERNAL", "longitude": -107.851499, "time_offset_sec": 2237}, "public_key_hex": "49d90f8bc8a384fff3a099a3dd80b2048a62a25c545b2fed9f93119e7445acfb", "role": "ROUTER", "short_name": "DBDE", "snr": 12.0, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1017.75, "iaq": 48, "relative_humidity": 78.13, "temperature": 25.78}, "hops_away": 3, "hw_model": "XIAO_NRF52_KIT", "last_heard_offset_sec": 5959, "long_name": "Sharp Seal", "next_hop": 182, "num": "0x0a2becc6", "position": {"altitude": 1756, "latitude": 34.233188, "location_source": "LOC_INTERNAL", "longitude": -107.910693, "time_offset_sec": 6060}, "public_key_hex": "67ce160d409d9844ad7dff6274b2e23d77a2082ccd25b8b3ee3cf8bd59e9f76d", "role": "CLIENT", "short_name": "SZ42", "snr": 5.68, "status": null, "telemetry": {"air_util_tx": 1.198, "battery_level": 61, "channel_utilization": 31.28, "uptime_seconds": 100039, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 0, "long_name": "Mountain Mamba", "next_hop": 186, "num": "0x0a49e197", "position": {"altitude": 907, "latitude": 34.07835, "location_source": "LOC_INTERNAL", "longitude": -106.787801, "time_offset_sec": 79}, "public_key_hex": "502e324799b41733485dab3ac03fe1f0389af0377131cacac6e378aa870bf07f", "role": "CLIENT", "short_name": "M7A5", "snr": 6.14, "status": null, "telemetry": {"air_util_tx": 0.713, "battery_level": 55, "channel_utilization": 2.69, "uptime_seconds": 142285, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 438, "long_name": "Loud Arroyo", "next_hop": 0, "num": "0x0a49fcdf", "position": {"altitude": 1272, "latitude": 32.965883, "location_source": "LOC_INTERNAL", "longitude": -107.529237, "time_offset_sec": 561}, "public_key_hex": "927bd87c85962adeafc68c72612924903722becb06ee3e9ec841bafda1f5b88b", "role": "CLIENT", "short_name": "LZNI", "snr": 10.65, "status": null, "telemetry": {"air_util_tx": 0.569, "battery_level": 83, "channel_utilization": 8.44, "uptime_seconds": 298594, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1744, "long_name": "Blue Bison", "next_hop": 45, "num": "0x0a55821a", "position": {"altitude": 1636, "latitude": 32.477075, "location_source": "LOC_INTERNAL", "longitude": -107.198645, "time_offset_sec": 1874}, "public_key_hex": "f6aa316737c89b0fb669843a773e28ccc77daa25e020fa1793da3f3755e32cf9", "role": "CLIENT_BASE", "short_name": "B1VI", "snr": 4.99, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.154, "battery_level": 84, "channel_utilization": 8.59, "uptime_seconds": 346887, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 31457, "long_name": "Sunny Gecko", "next_hop": 0, "num": "0x0a62b151", "position": {"altitude": 1418, "latitude": 33.593152, "location_source": "LOC_INTERNAL", "longitude": -106.27307, "time_offset_sec": 31684}, "public_key_hex": "", "role": "CLIENT", "short_name": "SOIK", "snr": 3.33, "status": null, "telemetry": {"air_util_tx": 0.36, "battery_level": 12, "channel_utilization": 1.68, "uptime_seconds": 65142, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1730, "long_name": "Sunny Sage", "next_hop": 0, "num": "0x0a682240", "position": {"altitude": 1490, "latitude": 32.71307, "location_source": "LOC_INTERNAL", "longitude": -107.424311, "time_offset_sec": 1758}, "public_key_hex": "2ea0d21489a44c28100662381c03ce1dabe397fc6115ad53550f71755cc22a4f", "role": "CLIENT", "short_name": "SR63", "snr": 2.14, "status": null, "telemetry": {"air_util_tx": 0.96, "battery_level": 93, "channel_utilization": 5.29, "uptime_seconds": 74196, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 838, "long_name": "Wandering Salmon", "next_hop": 0, "num": "0x0a6cc559", "position": null, "public_key_hex": "8ee63568195a68c9eedb0a8907742c406b891cc8598b620645d0ba9ba9166287", "role": "ROUTER", "short_name": "W7BX", "snr": 6.94, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.73, "iaq": 85, "relative_humidity": 42.47, "temperature": 22.74}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 9552, "long_name": "Wandering Elk", "next_hop": 0, "num": "0x0a728084", "position": {"altitude": 1566, "latitude": 32.944508, "location_source": "LOC_INTERNAL", "longitude": -106.685651, "time_offset_sec": 9625}, "public_key_hex": "6669f4066f65e41cb5d5362ef81e934ca978239c67204d0866994ace86ebce0b", "role": "CLIENT", "short_name": "WLKM", "snr": -0.43, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 10300, "long_name": "Giant Tortoise", "next_hop": 0, "num": "0x0a884c4b", "position": {"altitude": 1456, "latitude": 33.18785, "location_source": "LOC_INTERNAL", "longitude": -107.581061, "time_offset_sec": 10309}, "public_key_hex": "e24fbc2c6ac275c3e30d6a1b52c5179adc6c533ac5c75def615269b131b567b7", "role": "CLIENT", "short_name": "G1I4", "snr": 1.01, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 3641, "long_name": "Misty Trout", "next_hop": 251, "num": "0x0a8f888b", "position": {"altitude": 1287, "latitude": 34.380237, "location_source": "LOC_INTERNAL", "longitude": -108.070078, "time_offset_sec": 3712}, "public_key_hex": "5d4c5f8aec76e21de09597b7ea7b05405d8ac92ee36520b3656a5ec028a6beb9", "role": "CLIENT", "short_name": "MYDQ", "snr": -5.18, "status": null, "telemetry": {"air_util_tx": 0.895, "battery_level": 63, "channel_utilization": 8.96, "uptime_seconds": 3388, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.69, "iaq": 64, "relative_humidity": 96.0, "temperature": 31.11}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2319, "long_name": "Roving Crow", "next_hop": 233, "num": "0x0a968c5c", "position": null, "public_key_hex": "8fb5860fd16abddb54e7241afec20a67993c771b90a336f6276937a0144b8b34", "role": "TRACKER", "short_name": "RRTQ", "snr": 4.6, "status": null, "telemetry": {"air_util_tx": 0.287, "battery_level": 32, "channel_utilization": 4.89, "uptime_seconds": 5959, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2890, "long_name": "River Adder", "next_hop": 0, "num": "0x0aa18dd8", "position": {"altitude": 1397, "latitude": 34.526364, "location_source": "LOC_INTERNAL", "longitude": -106.102496, "time_offset_sec": 2987}, "public_key_hex": "5d7c8a2c7b7dbd6425bc8cd09645da74a89f36d7686261f7d4e8f648d0a22415", "role": "CLIENT", "short_name": "🦂", "snr": 11.85, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.643, "battery_level": 56, "channel_utilization": 17.8, "uptime_seconds": 78976, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 4461, "long_name": "Forest Colt", "next_hop": 164, "num": "0x0aabba83", "position": null, "public_key_hex": "8e47aecf44f598148cb81a69cefc9caaca81bc0ce1f4287965c93a7a22f52ab0", "role": "CLIENT", "short_name": "FQV2", "snr": 4.46, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.851, "battery_level": 97, "channel_utilization": 6.75, "uptime_seconds": 25711, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.44, "iaq": 2, "relative_humidity": 80.39, "temperature": 24.76}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 6364, "long_name": "New Doe", "next_hop": 222, "num": "0x0aafdcfd", "position": {"altitude": 1387, "latitude": 33.010873, "location_source": "LOC_INTERNAL", "longitude": -108.306355, "time_offset_sec": 6527}, "public_key_hex": "f98b6b036b26f1ae1ed04f6983310196747f81c04d70720e61448e11f99d7b95", "role": "CLIENT", "short_name": "NQUO", "snr": 6.97, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.745, "battery_level": 21, "channel_utilization": 13.04, "uptime_seconds": 24551, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.47, "iaq": 75, "relative_humidity": 35.55, "temperature": 19.67}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 10508, "long_name": "Sleepy Marmot", "next_hop": 0, "num": "0x0aca91e8", "position": {"altitude": 1480, "latitude": 33.436169, "location_source": "LOC_INTERNAL", "longitude": -107.784362, "time_offset_sec": 10572}, "public_key_hex": "0f01df1f15c208ec842e0c6c64fcd59bb2bf5e887f72de7b9ec13b0f5e1fbd42", "role": "CLIENT", "short_name": "SSMW", "snr": 8.94, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.401, "battery_level": 46, "channel_utilization": 4.54, "uptime_seconds": 180626, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1064, "long_name": "Gold Gecko", "next_hop": 229, "num": "0x0ace8c84", "position": {"altitude": 1445, "latitude": 32.565216, "location_source": "LOC_INTERNAL", "longitude": -106.367275, "time_offset_sec": 1192}, "public_key_hex": "fd82d203c3e211ca2076e006091d4600ba4a028f05cf2cd462b079678ed21cbc", "role": "ROUTER", "short_name": "🌙", "snr": 8.09, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.937, "battery_level": 22, "channel_utilization": 30.08, "uptime_seconds": 41625, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.95, "iaq": 27, "relative_humidity": 63.93, "temperature": 18.17}, "hops_away": 1, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 7230, "long_name": "Howling Coyote", "next_hop": 162, "num": "0x0ad73079", "position": {"altitude": 1625, "latitude": 33.280407, "location_source": "LOC_INTERNAL", "longitude": -107.005826, "time_offset_sec": 7398}, "public_key_hex": "644925e37a3446dca109e277ae90a3f98af411b70f9a79397f6420cadbfdf385", "role": "CLIENT", "short_name": "H43S", "snr": 3.74, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.602, "battery_level": 18, "channel_utilization": 6.43, "uptime_seconds": 42060, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.37, "iaq": 59, "relative_humidity": 36.52, "temperature": 31.67}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7550, "long_name": "Dusk Dolphin", "next_hop": 219, "num": "0x0aea7115", "position": {"altitude": 1467, "latitude": 32.507099, "location_source": "LOC_INTERNAL", "longitude": -107.147287, "time_offset_sec": 7818}, "public_key_hex": "6da57e10ea1320257781b4837ca980f2664eacb84fbb0421f7b49c008290be12", "role": "CLIENT", "short_name": "D0MB", "snr": 3.56, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.156, "battery_level": 65, "channel_utilization": 31.25, "uptime_seconds": 32484, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6670, "long_name": "Frozen Bass", "next_hop": 33, "num": "0x0b0458d6", "position": null, "public_key_hex": "d48d09fc84feb514ba04d752d436e3fae1c1a1ed1e5cdd9cfe52c6e47ab98672", "role": "CLIENT", "short_name": "FV89", "snr": 10.15, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.829, "battery_level": 41, "channel_utilization": 3.27, "uptime_seconds": 344643, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2960, "long_name": "Giant Lion", "next_hop": 0, "num": "0x0b06f164", "position": {"altitude": 1052, "latitude": 33.043513, "location_source": "LOC_INTERNAL", "longitude": -106.965169, "time_offset_sec": 3199}, "public_key_hex": "04fde0cd8dd327c710c9625b81762babb9217e0799a9a685c772fb08b59bddfb", "role": "CLIENT", "short_name": "GIKU", "snr": 11.36, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.052, "battery_level": 72, "channel_utilization": 12.74, "uptime_seconds": 16594, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 660, "long_name": "Steel Seal", "next_hop": 31, "num": "0x0b2796e6", "position": null, "public_key_hex": "743bd0de5db469ed321935a53f4b0d79cea118f771f6af6003fbe8acaa7f945c", "role": "CLIENT", "short_name": "S8DZ", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.78, "battery_level": 14, "channel_utilization": 5.83, "uptime_seconds": 17838, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1018.23, "iaq": 45, "relative_humidity": 72.37, "temperature": 39.11}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 6017, "long_name": "Sneaky Bison KQ9AB", "next_hop": 32, "num": "0x0b2f44ac", "position": {"altitude": 1619, "latitude": 33.300708, "location_source": "LOC_INTERNAL", "longitude": -106.544658, "time_offset_sec": 6300}, "public_key_hex": "e603a26690494844ae31dabb0b2424e4df1dab59d85987f42a9673ba875f9985", "role": "ROUTER_LATE", "short_name": "S3YW", "snr": 8.36, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.137, "battery_level": 53, "channel_utilization": 18.57, "uptime_seconds": 49538, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 14552, "long_name": "Blue Lynx", "next_hop": 0, "num": "0x0b34a80c", "position": null, "public_key_hex": "7e4bd5f8d01b3dd239ef1d9561adcd67ee2a185e1e44a07b2a878bba0e62fd4b", "role": "CLIENT", "short_name": "B149", "snr": 9.51, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.33, "battery_level": 59, "channel_utilization": 11.74, "uptime_seconds": 12813, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 4913, "long_name": "Wild Moose", "next_hop": 85, "num": "0x0b660bdd", "position": {"altitude": 1006, "latitude": 32.582276, "location_source": "LOC_INTERNAL", "longitude": -107.320844, "time_offset_sec": 5056}, "public_key_hex": "aa51bd58bada7d0a8c80e581634b77332e1aae877cb57ef7820f47befc4eb327", "role": "CLIENT", "short_name": "WLV9", "snr": 8.01, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1026.15, "iaq": 58, "relative_humidity": 32.53, "temperature": 17.61}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 209, "long_name": "Happy Bear", "next_hop": 27, "num": "0x0b7cbcb5", "position": {"altitude": 1553, "latitude": 33.676631, "location_source": "LOC_INTERNAL", "longitude": -107.125554, "time_offset_sec": 464}, "public_key_hex": "b73f0f3fef5e5b2c0069845625193804e6ec71df772e39081c44cfa68dde95f7", "role": "CLIENT", "short_name": "HJCE", "snr": 4.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 967, "long_name": "Sneaky Hawk", "next_hop": 0, "num": "0x0bf181c3", "position": {"altitude": 1314, "latitude": 33.272412, "location_source": "LOC_INTERNAL", "longitude": -107.213807, "time_offset_sec": 1169}, "public_key_hex": "", "role": "CLIENT", "short_name": "S3FW", "snr": 8.24, "status": null, "telemetry": {"air_util_tx": 0.361, "battery_level": 56, "channel_utilization": 22.77, "uptime_seconds": 14274, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.34, "iaq": 3, "relative_humidity": 39.77, "temperature": 25.28}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 8403, "long_name": "Sleepy Marmot", "next_hop": 0, "num": "0x0c1da0d5", "position": {"altitude": 1226, "latitude": 33.277052, "location_source": "LOC_INTERNAL", "longitude": -106.561003, "time_offset_sec": 8625}, "public_key_hex": "de39131e97f1823feeb75e5772333545a93a909f148d778de0569430f0cae171", "role": "CLIENT", "short_name": "🌙", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2691, "long_name": "Sneaky Wolf", "next_hop": 235, "num": "0x0c3b4509", "position": {"altitude": 1254, "latitude": 33.262219, "location_source": "LOC_INTERNAL", "longitude": -107.374459, "time_offset_sec": 2753}, "public_key_hex": "28b38e0d722cc2dff1a1f9771ff458320959f632e13c72ae4ec7c152a0734133", "role": "CLIENT", "short_name": "S6T5", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.282, "battery_level": 74, "channel_utilization": 12.24, "uptime_seconds": 432954, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 7186, "long_name": "Wild Whale", "next_hop": 0, "num": "0x0c4b6f40", "position": {"altitude": 1571, "latitude": 33.497355, "location_source": "LOC_INTERNAL", "longitude": -106.318717, "time_offset_sec": 7194}, "public_key_hex": "2d755566e1c9e38df9656042aae743b3e250263f455b909e4f994f7f1b55c7b9", "role": "SENSOR", "short_name": "W76D", "snr": 9.36, "status": null, "telemetry": {"air_util_tx": 0.375, "battery_level": 21, "channel_utilization": 19.11, "uptime_seconds": 11463, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 11013, "long_name": "Sneaky Fox", "next_hop": 0, "num": "0x0c598300", "position": null, "public_key_hex": "d4735f1cc7a23067814f93f92b61ea25647e33259b00eb90a65c778fb3b9fd91", "role": "CLIENT", "short_name": "SBY6", "snr": 2.09, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 1.321, "battery_level": 65, "channel_utilization": 37.54, "uptime_seconds": 6946, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2585, "long_name": "Frozen Marmot", "next_hop": 158, "num": "0x0c62979c", "position": {"altitude": 1494, "latitude": 33.439438, "location_source": "LOC_INTERNAL", "longitude": -107.052946, "time_offset_sec": 2826}, "public_key_hex": "44cfa7137146f88d413dfbdcbc2b0ac58426815d691870d7b59feda444e3217b", "role": "CLIENT", "short_name": "FUXD", "snr": 6.95, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 931, "long_name": "Storm Doe", "next_hop": 0, "num": "0x0c81079c", "position": {"altitude": 1951, "latitude": 33.075505, "location_source": "LOC_INTERNAL", "longitude": -107.45176, "time_offset_sec": 1126}, "public_key_hex": "67207220c84fe8cc387bba47c21d42b709a39de1017860c3f5803e072f5895e1", "role": "ROUTER", "short_name": "🐝", "snr": 9.21, "status": null, "telemetry": {"air_util_tx": 0.33, "battery_level": 72, "channel_utilization": 1.6, "uptime_seconds": 130636, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3604, "long_name": "White Cougar", "next_hop": 88, "num": "0x0c87addb", "position": {"altitude": 1420, "latitude": 33.349954, "location_source": "LOC_INTERNAL", "longitude": -106.321151, "time_offset_sec": 3820}, "public_key_hex": "e03da5bb73b91adcb933339371170c0c0048fdbafd4f12444fd6a4de2d4c4fd7", "role": "CLIENT_MUTE", "short_name": "WX30", "snr": 0.87, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.131, "battery_level": 73, "channel_utilization": 19.41, "uptime_seconds": 9142, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 2639, "long_name": "Roving Dolphin", "next_hop": 0, "num": "0x0c96b7ac", "position": {"altitude": 639, "latitude": 33.954096, "location_source": "LOC_INTERNAL", "longitude": -107.453949, "time_offset_sec": 2739}, "public_key_hex": "57b88c09b9efa30b9155c7430ce16029e38168ac00548d2579f0b81ce5b4c404", "role": "CLIENT_MUTE", "short_name": "RZAK", "snr": 9.43, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2123, "long_name": "Stone Juniper", "next_hop": 0, "num": "0x0cc8ffa3", "position": {"altitude": 1266, "latitude": 32.232379, "location_source": "LOC_INTERNAL", "longitude": -107.529807, "time_offset_sec": 2262}, "public_key_hex": "bb7a57b1c68eb0b36e5ee8982861028a9c75339e7c919333542a793badc6143a", "role": "TRACKER", "short_name": "SC4S", "snr": 12.0, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1842, "long_name": "Lone Pony", "next_hop": 0, "num": "0x0ce2d1bf", "position": {"altitude": 1253, "latitude": 33.480347, "location_source": "LOC_INTERNAL", "longitude": -106.904058, "time_offset_sec": 2074}, "public_key_hex": "29cb02461d1f9e373af6f75eb369deb5946ee864b2f8d43a83476255b7fe960b", "role": "CLIENT", "short_name": "L040", "snr": 3.7, "status": null, "telemetry": {"air_util_tx": 1.631, "battery_level": 73, "channel_utilization": 12.2, "uptime_seconds": 260869, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 1398, "long_name": "Steel Crow", "next_hop": 245, "num": "0x0cec183a", "position": {"altitude": 1349, "latitude": 33.043475, "location_source": "LOC_INTERNAL", "longitude": -106.944043, "time_offset_sec": 1568}, "public_key_hex": "6142dcbacc8d91b278adf5c7f06f7e40e6ddee420bd8b6a75ce3cc34957cd393", "role": "CLIENT", "short_name": "🦋", "snr": 4.39, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.151, "battery_level": 62, "channel_utilization": 29.21, "uptime_seconds": 15962, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 6540, "long_name": "Steel Lynx", "next_hop": 0, "num": "0x0cf319e4", "position": {"altitude": 1622, "latitude": 33.573554, "location_source": "LOC_INTERNAL", "longitude": -106.736299, "time_offset_sec": 6689}, "public_key_hex": "8d56679035fd91d260cdd3592c578eaacdf1c40d1ea25eb32917c59202bb6547", "role": "CLIENT", "short_name": "SIZA", "snr": 3.28, "status": null, "telemetry": {"air_util_tx": 0.759, "battery_level": 41, "channel_utilization": 13.5, "uptime_seconds": 25271, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 7653, "long_name": "Howling Otter", "next_hop": 0, "num": "0x0cf865e3", "position": {"altitude": 876, "latitude": 33.33999, "location_source": "LOC_INTERNAL", "longitude": -108.088917, "time_offset_sec": 7910}, "public_key_hex": "b30f1625082b7ecda8443a476706789ceba7412df8c56392c139033840744270", "role": "ROUTER", "short_name": "H890", "snr": 9.91, "status": null, "telemetry": {"air_util_tx": 1.17, "battery_level": 101, "channel_utilization": 5.55, "uptime_seconds": 41844, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4060, "long_name": "Whispering Marmot", "next_hop": 71, "num": "0x0cf8c034", "position": {"altitude": 1300, "latitude": 33.092826, "location_source": "LOC_INTERNAL", "longitude": -108.25385, "time_offset_sec": 4153}, "public_key_hex": "ad054afc6277c385b4ade41e775cb92ca32f2d11fc494d5cce722a4651deb522", "role": "CLIENT", "short_name": "WBM9", "snr": 6.27, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.327, "battery_level": 33, "channel_utilization": 5.02, "uptime_seconds": 12375, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 990.71, "iaq": 22, "relative_humidity": 59.38, "temperature": 22.99}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3203, "long_name": "Sunny Crane", "next_hop": 0, "num": "0x0cf94d51", "position": {"altitude": 1409, "latitude": 33.311224, "location_source": "LOC_INTERNAL", "longitude": -106.515641, "time_offset_sec": 3255}, "public_key_hex": "bf5cef272c127d8132bae6a618f572bce8e90b585231741ee1b0e363aacf5e6e", "role": "CLIENT", "short_name": "SPJF", "snr": 5.4, "status": null, "telemetry": {"air_util_tx": 0.191, "battery_level": 101, "channel_utilization": 10.01, "uptime_seconds": 179676, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.44, "iaq": 69, "relative_humidity": 66.95, "temperature": 13.09}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 4379, "long_name": "Frosty Badger", "next_hop": 0, "num": "0x0d1084dc", "position": {"altitude": 935, "latitude": 32.673368, "location_source": "LOC_INTERNAL", "longitude": -107.174163, "time_offset_sec": 4591}, "public_key_hex": "092b0debf89a28700e90fac0bad612605ecee0ef6be3e9e67ee79d268eb1e11c", "role": "CLIENT", "short_name": "FJMA", "snr": 10.31, "status": null, "telemetry": {"air_util_tx": 0.038, "battery_level": 40, "channel_utilization": 9.38, "uptime_seconds": 36903, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 68, "long_name": "New Hawk", "next_hop": 194, "num": "0x0d17570a", "position": {"altitude": 1296, "latitude": 33.66219, "location_source": "LOC_INTERNAL", "longitude": -107.286552, "time_offset_sec": 189}, "public_key_hex": "a059c7b1ab30b2cd493b7aa28a21dd266acc077baa99bc4db7e27724c7acd7b8", "role": "TRACKER", "short_name": "NIJL", "snr": 11.84, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.523, "battery_level": 59, "channel_utilization": 11.45, "uptime_seconds": 306513, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1840, "long_name": "Tall Marmot", "next_hop": 202, "num": "0x0d4033e1", "position": {"altitude": 1648, "latitude": 33.581755, "location_source": "LOC_INTERNAL", "longitude": -106.952353, "time_offset_sec": 1914}, "public_key_hex": "f47cedd1bfd4dcf707ac02f5350e6569256d4ed143b92005d7fd6f08d7e03dc9", "role": "CLIENT", "short_name": "TQRK", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 7303, "long_name": "Rough Colt", "next_hop": 168, "num": "0x0d46953e", "position": {"altitude": 1650, "latitude": 32.431936, "location_source": "LOC_INTERNAL", "longitude": -106.973777, "time_offset_sec": 7420}, "public_key_hex": "", "role": "CLIENT", "short_name": "R9RR", "snr": 7.37, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1594, "long_name": "Loud Lion", "next_hop": 0, "num": "0x0d69ee41", "position": {"altitude": 1114, "latitude": 33.132481, "location_source": "LOC_INTERNAL", "longitude": -107.937012, "time_offset_sec": 1615}, "public_key_hex": "a6a9cf5954468d187bf4b1982d370b953d896c486b9e9229c96e7a2bc51925e8", "role": "CLIENT", "short_name": "LUIE", "snr": 5.05, "status": null, "telemetry": {"air_util_tx": 1.858, "battery_level": 55, "channel_utilization": 9.82, "uptime_seconds": 4080, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1012.37, "iaq": 0, "relative_humidity": 64.89, "temperature": 29.86}, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 4336, "long_name": "Smooth Arroyo", "next_hop": 0, "num": "0x0d75dbf0", "position": {"altitude": 1157, "latitude": 33.640447, "location_source": "LOC_INTERNAL", "longitude": -107.273095, "time_offset_sec": 4480}, "public_key_hex": "f03b3dadcad3589d4fadaebd40dd390cfd68c39032ad3df25994ea586af84f64", "role": "CLIENT", "short_name": "SME6", "snr": 5.53, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.081, "battery_level": 101, "channel_utilization": 36.96, "uptime_seconds": 13889, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.25, "iaq": 112, "relative_humidity": 32.63, "temperature": 2.94}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 2812, "long_name": "Copper Bass", "next_hop": 0, "num": "0x0d76643c", "position": {"altitude": 1474, "latitude": 32.636104, "location_source": "LOC_INTERNAL", "longitude": -107.426424, "time_offset_sec": 3063}, "public_key_hex": "", "role": "CLIENT", "short_name": "CAZ9", "snr": 11.01, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.179, "battery_level": 89, "channel_utilization": 5.82, "uptime_seconds": 36044, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1128, "long_name": "Rough Sage", "next_hop": 0, "num": "0x0d8b6413", "position": {"altitude": 1339, "latitude": 33.52043, "location_source": "LOC_INTERNAL", "longitude": -106.704583, "time_offset_sec": 1159}, "public_key_hex": "f6b32b4108c35c4c33849b4ad97e697fdce3543e4ebdc729c7428a5865e572b4", "role": "CLIENT", "short_name": "R08H", "snr": 4.09, "status": null, "telemetry": {"air_util_tx": 0.228, "battery_level": 76, "channel_utilization": 24.65, "uptime_seconds": 62175, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.17, "iaq": 95, "relative_humidity": 58.14, "temperature": 18.46}, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 11894, "long_name": "Happy Ridge", "next_hop": 17, "num": "0x0d96ed92", "position": {"altitude": 1187, "latitude": 33.417928, "location_source": "LOC_INTERNAL", "longitude": -107.311973, "time_offset_sec": 11896}, "public_key_hex": "afae632a46854aab0f2b903d8e6e405eee4fa8111549cc72205de3744082a9cb", "role": "LOST_AND_FOUND", "short_name": "HGAX", "snr": 8.39, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.091, "battery_level": 101, "channel_utilization": 11.75, "uptime_seconds": 10140, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 1153, "long_name": "Found Pine", "next_hop": 184, "num": "0x0d98b6da", "position": null, "public_key_hex": "51c47e679621e9359fe3f8a4a71b054b5f365cc7b92fa48160214a4e1632309a", "role": "ROUTER_LATE", "short_name": "F8R3", "snr": 8.2, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 49, "long_name": "Drowsy Eagle", "next_hop": 125, "num": "0x0da63bc7", "position": {"altitude": 1219, "latitude": 33.293434, "location_source": "LOC_INTERNAL", "longitude": -107.219043, "time_offset_sec": 260}, "public_key_hex": "a5b234f09b8c0b88a0cda25ce8435b3912d3bdd4312c45e2897866887acc6fc2", "role": "CLIENT", "short_name": "🐢", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.35, "iaq": 48, "relative_humidity": 73.82, "temperature": 27.62}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 10633, "long_name": "Lunar Mesa", "next_hop": 188, "num": "0x0df1e644", "position": {"altitude": 1050, "latitude": 33.289646, "location_source": "LOC_INTERNAL", "longitude": -107.538748, "time_offset_sec": 10722}, "public_key_hex": "7bead104d719de5d2268a8e0fcc61b4d109be0b00e9b3ccc8eb63ed701d36e89", "role": "CLIENT", "short_name": "LUHC", "snr": 9.1, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.097, "battery_level": 18, "channel_utilization": 3.91, "uptime_seconds": 51507, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 8278, "long_name": "Blue Phoenix", "next_hop": 214, "num": "0x0e00154f", "position": null, "public_key_hex": "fe5aee4678e393d3ab6a8f63606e6be70e3c9d433e0a513e956c4f8633300de2", "role": "CLIENT", "short_name": "BC12", "snr": -2.71, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2260, "long_name": "Loud Yucca", "next_hop": 0, "num": "0x0e0d79f5", "position": {"altitude": 1252, "latitude": 33.300986, "location_source": "LOC_INTERNAL", "longitude": -107.415103, "time_offset_sec": 2337}, "public_key_hex": "5bbe05aa8fc8f41ac655d25c771edb9f881cff701f892bf4c405b5f3e33065a8", "role": "TAK_TRACKER", "short_name": "L697", "snr": 5.38, "status": null, "telemetry": {"air_util_tx": 0.488, "battery_level": 44, "channel_utilization": 10.12, "uptime_seconds": 11523, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 4925, "long_name": "Shady Salmon", "next_hop": 0, "num": "0x0e3ddbd0", "position": {"altitude": 1250, "latitude": 33.130921, "location_source": "LOC_INTERNAL", "longitude": -107.009738, "time_offset_sec": 5169}, "public_key_hex": "d80a5aeb86ae00849fb7acbac987c162573ca044e172f17ebbf11e540e611935", "role": "CLIENT", "short_name": "SQTI", "snr": 10.3, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.744, "battery_level": 15, "channel_utilization": 6.8, "uptime_seconds": 138131, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.54, "iaq": 79, "relative_humidity": 67.11, "temperature": 1.83}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4464, "long_name": "Tall Owl", "next_hop": 201, "num": "0x0e5a5854", "position": {"altitude": 1812, "latitude": 33.603139, "location_source": "LOC_INTERNAL", "longitude": -107.735229, "time_offset_sec": 4614}, "public_key_hex": "bf4ee5dd72d382c1c4f5bca7f168c07121a2b665a9f43e11650bc4ebf7e59724", "role": "CLIENT", "short_name": "TV41", "snr": 6.84, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.219, "battery_level": 24, "channel_utilization": 11.6, "uptime_seconds": 72271, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 91, "long_name": "Sneaky Hawk", "next_hop": 1, "num": "0x0e6dec3b", "position": {"altitude": 1140, "latitude": 33.868996, "location_source": "LOC_INTERNAL", "longitude": -106.840354, "time_offset_sec": 152}, "public_key_hex": "b4e7631ca75bdac0bc36c0d90c97ab59e9a410e3fc73d8e4a171c90d3453e040", "role": "SENSOR", "short_name": "SBBB", "snr": 4.73, "status": null, "telemetry": {"air_util_tx": 0.151, "battery_level": 94, "channel_utilization": 6.57, "uptime_seconds": 2181, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.43, "iaq": 51, "relative_humidity": 58.45, "temperature": 15.21}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3377, "long_name": "Roving Heron", "next_hop": 42, "num": "0x0e7f1313", "position": {"altitude": 881, "latitude": 32.718951, "location_source": "LOC_INTERNAL", "longitude": -107.171452, "time_offset_sec": 3603}, "public_key_hex": "67f7d471c3fa7edc6a2b039cea065d51730a922ca5d7166074413d8b29e750a8", "role": "CLIENT", "short_name": "RT4W", "snr": 2.33, "status": null, "telemetry": {"air_util_tx": 0.499, "battery_level": 29, "channel_utilization": 18.85, "uptime_seconds": 57514, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 358, "long_name": "Misty Marmot", "next_hop": 126, "num": "0x0e8134fe", "position": {"altitude": 1768, "latitude": 33.984527, "location_source": "LOC_INTERNAL", "longitude": -106.447924, "time_offset_sec": 516}, "public_key_hex": "3bd887e5ff4dca4b8cc0e20f8cce959718e841a5ad935e201289a9ed489dc623", "role": "CLIENT", "short_name": "MDVA", "snr": 4.13, "status": null, "telemetry": {"air_util_tx": 1.561, "battery_level": 30, "channel_utilization": 6.28, "uptime_seconds": 61607, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6974, "long_name": "Forest Juniper", "next_hop": 195, "num": "0x0eab4878", "position": {"altitude": 1454, "latitude": 32.302866, "location_source": "LOC_INTERNAL", "longitude": -107.107765, "time_offset_sec": 7209}, "public_key_hex": "78ae62c231a1025b6b599ca6f0012e194c3697b8131d7fca3187b1bceffaf981", "role": "CLIENT", "short_name": "FLKA", "snr": 7.77, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "RAK3312", "last_heard_offset_sec": 4809, "long_name": "Stone Shark", "next_hop": 44, "num": "0x0eb45e9a", "position": {"altitude": 816, "latitude": 32.715786, "location_source": "LOC_INTERNAL", "longitude": -107.549174, "time_offset_sec": 4933}, "public_key_hex": "4dc13d64ccacd7a01efd0c96f886a7b81d21d1e0f885ef093e7718f406a1eca1", "role": "CLIENT", "short_name": "SI8B", "snr": 6.66, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK3312", "last_heard_offset_sec": 22, "long_name": "Wild Moose", "next_hop": 0, "num": "0x0ebfd889", "position": {"altitude": 1229, "latitude": 32.872754, "location_source": "LOC_INTERNAL", "longitude": -107.078689, "time_offset_sec": 303}, "public_key_hex": "b923ae592000979ebe75ae0dcfb041519415110554743f25696c27c696c420e9", "role": "CLIENT_MUTE", "short_name": "W18L", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.448, "battery_level": 26, "channel_utilization": 4.97, "uptime_seconds": 206014, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4631, "long_name": "Storm Raven", "next_hop": 0, "num": "0x0ec25c8b", "position": null, "public_key_hex": "aab82ecf33adf01f2290fb47d44f4a5d47e497c4f6ff0c47c57dbea903168086", "role": "CLIENT", "short_name": "SGY0", "snr": 4.59, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.53, "battery_level": 83, "channel_utilization": 5.12, "uptime_seconds": 20729, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1745, "long_name": "Dawn Beaver", "next_hop": 0, "num": "0x0ed38365", "position": {"altitude": 1324, "latitude": 32.75678, "location_source": "LOC_INTERNAL", "longitude": -107.441926, "time_offset_sec": 1880}, "public_key_hex": "a1e142392febbf5527e25b65c5fa158bdf3c8bdcc7084791bc04529166713e0f", "role": "CLIENT", "short_name": "D4FQ", "snr": 7.6, "status": null, "telemetry": {"air_util_tx": 1.17, "battery_level": 22, "channel_utilization": 8.99, "uptime_seconds": 124269, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 2565, "long_name": "Storm Marmot", "next_hop": 0, "num": "0x0ed68127", "position": {"altitude": 1371, "latitude": 34.257354, "location_source": "LOC_INTERNAL", "longitude": -107.595453, "time_offset_sec": 2845}, "public_key_hex": "2a8610d4d1ffc28a0b3d2addafface75abe59c97216a543c51becdd33071fe46", "role": "CLIENT", "short_name": "SEQZ", "snr": 4.8, "status": null, "telemetry": {"air_util_tx": 1.35, "battery_level": 66, "channel_utilization": 16.52, "uptime_seconds": 102045, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 5332, "long_name": "Burning Bronco", "next_hop": 0, "num": "0x0ee536a3", "position": {"altitude": 1091, "latitude": 32.684995, "location_source": "LOC_INTERNAL", "longitude": -107.462605, "time_offset_sec": 5490}, "public_key_hex": "69b799082929e3f788164a3f01062d44ed83c1fcf25c3f45af15cd353091cee9", "role": "TRACKER", "short_name": "BS7F", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4350, "long_name": "Frosty Moose", "next_hop": 0, "num": "0x0f219f1d", "position": {"altitude": 1288, "latitude": 33.492447, "location_source": "LOC_INTERNAL", "longitude": -106.769231, "time_offset_sec": 4441}, "public_key_hex": "c68bc3380f9132fefc6f716ecd3153a1cf3de2c4bcd3dfd15c81999ffb39cccd", "role": "ROUTER", "short_name": "FTQW", "snr": 7.1, "status": null, "telemetry": {"air_util_tx": 0.128, "battery_level": 62, "channel_utilization": 6.23, "uptime_seconds": 15191, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3424, "long_name": "Loud Turtle", "next_hop": 0, "num": "0x0f2bb7d7", "position": {"altitude": 1594, "latitude": 33.921387, "location_source": "LOC_INTERNAL", "longitude": -108.690851, "time_offset_sec": 3582}, "public_key_hex": "1b8b50a3746baaf13f1e46d4e7d21b609f8a852c14134412714829ead6d611a0", "role": "CLIENT", "short_name": "L2BC", "snr": -1.65, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.319, "battery_level": 84, "channel_utilization": 3.54, "uptime_seconds": 61330, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1006.99, "iaq": 108, "relative_humidity": 47.9, "temperature": 35.68}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 3513, "long_name": "Solar Doe", "next_hop": 0, "num": "0x0f2df5bd", "position": null, "public_key_hex": "69c00cd090e69edde12bb2e98527e6e541631156f4af30689f0f30dcf5060923", "role": "CLIENT", "short_name": "SIB5", "snr": 4.88, "status": null, "telemetry": {"air_util_tx": 0.096, "battery_level": 77, "channel_utilization": 8.68, "uptime_seconds": 114890, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.81, "iaq": 37, "relative_humidity": 48.31, "temperature": 6.73}, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 676, "long_name": "Sunny Pine KX3FJ", "next_hop": 241, "num": "0x0f344bf4", "position": {"altitude": 1260, "latitude": 32.116902, "location_source": "LOC_INTERNAL", "longitude": -108.738321, "time_offset_sec": 684}, "public_key_hex": "a314b45cd85097e2856893ee1c8f1da64632c1299b8221f197239bb011e4348d", "role": "ROUTER", "short_name": "SR37", "snr": 9.37, "status": null, "telemetry": {"air_util_tx": 0.101, "battery_level": 72, "channel_utilization": 3.8, "uptime_seconds": 55508, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6399, "long_name": "Frozen Yucca", "next_hop": 60, "num": "0x0f47a81c", "position": {"altitude": 1456, "latitude": 32.580486, "location_source": "LOC_INTERNAL", "longitude": -106.843993, "time_offset_sec": 6629}, "public_key_hex": "9c34f4034fa733dc334d28620bbe282a4ea279a4aa95472e7c233f7d1b15369e", "role": "CLIENT", "short_name": "FOIS", "snr": 2.58, "status": null, "telemetry": {"air_util_tx": 1.537, "battery_level": 29, "channel_utilization": 16.04, "uptime_seconds": 6272, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7629, "long_name": "Tall Moose", "next_hop": 0, "num": "0x0f576810", "position": {"altitude": 1008, "latitude": 33.854451, "location_source": "LOC_INTERNAL", "longitude": -105.681386, "time_offset_sec": 7689}, "public_key_hex": "508d8cc6c0e0fab38a0f837c7db5412b4f4e83dd2448cc42399139fbeee11db2", "role": "CLIENT", "short_name": "TXBZ", "snr": 7.65, "status": null, "telemetry": {"air_util_tx": 0.489, "battery_level": 38, "channel_utilization": 19.52, "uptime_seconds": 51549, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 385, "long_name": "Sky Doe", "next_hop": 141, "num": "0x0f7b7137", "position": null, "public_key_hex": "3d79399f0c0202e8dded825893db4d20aa36981e34e8c16d1e77783b2f39f119", "role": "CLIENT", "short_name": "SHHK", "snr": 9.41, "status": null, "telemetry": {"air_util_tx": 0.283, "battery_level": 71, "channel_utilization": 7.35, "uptime_seconds": 85448, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 999.12, "iaq": 38, "relative_humidity": 32.65, "temperature": 16.06}, "hops_away": 1, "hw_model": "THINKNODE_M6", "last_heard_offset_sec": 8647, "long_name": "Drifting Turtle K12QD", "next_hop": 37, "num": "0x0f8ea924", "position": {"altitude": 1717, "latitude": 33.254224, "location_source": "LOC_INTERNAL", "longitude": -106.920374, "time_offset_sec": 8748}, "public_key_hex": "0f3193ea1748d35fecb7743c59c1ae1e39a508c583e7749c29a986a6f1f9b944", "role": "CLIENT", "short_name": "🦉", "snr": 4.27, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5208, "long_name": "Silent Otter", "next_hop": 0, "num": "0x0fa1193e", "position": {"altitude": 1288, "latitude": 33.340299, "location_source": "LOC_INTERNAL", "longitude": -106.939282, "time_offset_sec": 5215}, "public_key_hex": "63d2dc44a778a5c9953efb596207fccffc94f2745c257ab2e7110a46c99506f7", "role": "SENSOR", "short_name": "SDDQ", "snr": 5.61, "status": null, "telemetry": {"air_util_tx": 1.479, "battery_level": 70, "channel_utilization": 14.93, "uptime_seconds": 233724, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 2538, "long_name": "Desert Pine", "next_hop": 109, "num": "0x0faff45a", "position": {"altitude": 1283, "latitude": 34.270473, "location_source": "LOC_INTERNAL", "longitude": -106.904643, "time_offset_sec": 2838}, "public_key_hex": "2a0a4c21a8ac8ec5b8fd1ec37cf0806a579f44df7b84c241584b7fbd57b0ef10", "role": "CLIENT", "short_name": "DY00", "snr": 2.28, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1027.09, "iaq": 59, "relative_humidity": 69.03, "temperature": 35.71}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1846, "long_name": "Red Arroyo", "next_hop": 0, "num": "0x0fd1fe93", "position": {"altitude": 1384, "latitude": 32.523806, "location_source": "LOC_INTERNAL", "longitude": -106.331376, "time_offset_sec": 2016}, "public_key_hex": "a7404cf06865804e6fe0d5d1214cc394e6e739ccae1c18ff68232f05c14ef797", "role": "CLIENT", "short_name": "RJTN", "snr": 3.35, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.61, "battery_level": 101, "channel_utilization": 12.74, "uptime_seconds": 47725, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 3587, "long_name": "Dawn Colt", "next_hop": 0, "num": "0x0fef0887", "position": {"altitude": 1162, "latitude": 33.188626, "location_source": "LOC_INTERNAL", "longitude": -108.44131, "time_offset_sec": 3776}, "public_key_hex": "ae1eea3b9549b502855969228eb648160647e0ad1e4879aa2562e87ed0ee9b09", "role": "CLIENT", "short_name": "D8Y9", "snr": 6.14, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 3781, "long_name": "Loud Marmot", "next_hop": 0, "num": "0x0ff95507", "position": {"altitude": 1663, "latitude": 33.904337, "location_source": "LOC_INTERNAL", "longitude": -107.157308, "time_offset_sec": 3940}, "public_key_hex": "21cc8a36d36d8e96c961b8089d8f388792b6285e05b60180ef0d6d4278968009", "role": "CLIENT", "short_name": "🔥", "snr": 9.8, "status": null, "telemetry": {"air_util_tx": 1.384, "battery_level": 14, "channel_utilization": 16.35, "uptime_seconds": 85543, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1411, "long_name": "Sleepy Cobra WD2KW", "next_hop": 58, "num": "0x100f80aa", "position": {"altitude": 1469, "latitude": 32.505368, "location_source": "LOC_INTERNAL", "longitude": -107.035972, "time_offset_sec": 1605}, "public_key_hex": "824cbb62d03a0b1621f43fd133374253e656e1d9c51bcd93c7a7b62007dc5b32", "role": "ROUTER", "short_name": "S2QX", "snr": 8.44, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 351, "long_name": "White Salmon", "next_hop": 0, "num": "0x1014cdc7", "position": {"altitude": 1315, "latitude": 32.712238, "location_source": "LOC_INTERNAL", "longitude": -106.874637, "time_offset_sec": 371}, "public_key_hex": "", "role": "CLIENT", "short_name": "WXDN", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 596, "long_name": "Desert Crane", "next_hop": 113, "num": "0x101dd6b4", "position": {"altitude": 1687, "latitude": 33.385154, "location_source": "LOC_INTERNAL", "longitude": -106.758435, "time_offset_sec": 771}, "public_key_hex": "1501ad478526eee19b2fd5d4e88c375c11195b1361d52fb651013eb9afe0d722", "role": "CLIENT", "short_name": "DEM3", "snr": 10.18, "status": null, "telemetry": {"air_util_tx": 1.29, "battery_level": 95, "channel_utilization": 14.32, "uptime_seconds": 21021, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 252, "long_name": "Sharp Falcon", "next_hop": 129, "num": "0x10344633", "position": {"altitude": 1346, "latitude": 33.175896, "location_source": "LOC_INTERNAL", "longitude": -107.162153, "time_offset_sec": 290}, "public_key_hex": "09e363a90d936f8058610231613b83b32dd670fda1de7a7e4172d06eb778278e", "role": "CLIENT", "short_name": "SVQE", "snr": 0.88, "status": null, "telemetry": {"air_util_tx": 1.633, "battery_level": 92, "channel_utilization": 9.57, "uptime_seconds": 159607, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 19702, "long_name": "Tall Dolphin", "next_hop": 0, "num": "0x103514fe", "position": {"altitude": 1940, "latitude": 33.905696, "location_source": "LOC_INTERNAL", "longitude": -107.284053, "time_offset_sec": 19712}, "public_key_hex": "56f0cc7def2efd20b76645c864daa6edcbbbe7f3e76af5476ac6f3a7f62f06a1", "role": "CLIENT", "short_name": "TEM3", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.435, "battery_level": 55, "channel_utilization": 4.83, "uptime_seconds": 34137, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 589, "long_name": "Sneaky Crane", "next_hop": 0, "num": "0x103ee362", "position": {"altitude": 1599, "latitude": 32.357163, "location_source": "LOC_INTERNAL", "longitude": -107.972991, "time_offset_sec": 757}, "public_key_hex": "3cfe72c689fd928fcdb136c8b76c90f1e0c1492b99beea8358c6c1463f9145e9", "role": "ROUTER", "short_name": "SK9T", "snr": 0.51, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 5593, "long_name": "Quick Arroyo", "next_hop": 0, "num": "0x104f6a60", "position": {"altitude": 1648, "latitude": 33.861848, "location_source": "LOC_INTERNAL", "longitude": -106.924387, "time_offset_sec": 5793}, "public_key_hex": "c90bb2a80fd3ed76eacb0897e5c0f754e3d7292b17d89ecd701ecbb01675894b", "role": "ROUTER", "short_name": "QSBX", "snr": 4.6, "status": null, "telemetry": {"air_util_tx": 0.633, "battery_level": 32, "channel_utilization": 11.79, "uptime_seconds": 155546, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5079, "long_name": "Gold Viper", "next_hop": 0, "num": "0x1061d1e9", "position": {"altitude": 1569, "latitude": 32.944808, "location_source": "LOC_INTERNAL", "longitude": -106.885698, "time_offset_sec": 5104}, "public_key_hex": "da53c4a22298c90a7e80b6204d9b0551234ade24226ccbfb3cbeb5a0dd4f08f6", "role": "CLIENT", "short_name": "G4PJ", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.081, "battery_level": 43, "channel_utilization": 30.21, "uptime_seconds": 109314, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 783, "long_name": "Found Tortoise", "next_hop": 30, "num": "0x10644087", "position": {"altitude": 1392, "latitude": 33.152779, "location_source": "LOC_INTERNAL", "longitude": -106.965188, "time_offset_sec": 998}, "public_key_hex": "493342e9db53bdf8c0b0b834a01cef5fe55429e489e394c0f2a5cadc7dd2149b", "role": "CLIENT", "short_name": "FC97", "snr": 7.58, "status": null, "telemetry": {"air_util_tx": 1.535, "battery_level": 85, "channel_utilization": 16.52, "uptime_seconds": 61746, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4382, "long_name": "Mountain Beaver", "next_hop": 0, "num": "0x109b8bc0", "position": {"altitude": 1728, "latitude": 32.771273, "location_source": "LOC_INTERNAL", "longitude": -107.833569, "time_offset_sec": 4445}, "public_key_hex": "00d36c30876adf370ba24c09ccbd55980cafc82aa3fcb5c307686e6d1975a8fa", "role": "CLIENT", "short_name": "M6ZE", "snr": 9.05, "status": null, "telemetry": {"air_util_tx": 0.336, "battery_level": 85, "channel_utilization": 16.43, "uptime_seconds": 124280, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 179, "long_name": "Happy Turtle", "next_hop": 0, "num": "0x10a2a17b", "position": null, "public_key_hex": "cf637fe398212e903dc97d72ed2d53b793f14d2125ae0e2e2c5f7109f54c99d3", "role": "CLIENT", "short_name": "H3QO", "snr": 9.38, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.909, "battery_level": 93, "channel_utilization": 21.91, "uptime_seconds": 38131, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1_EINK", "last_heard_offset_sec": 2907, "long_name": "Solar Seal", "next_hop": 0, "num": "0x10a7a307", "position": {"altitude": 1612, "latitude": 33.762943, "location_source": "LOC_INTERNAL", "longitude": -106.59034, "time_offset_sec": 3138}, "public_key_hex": "f8b29723bf98087a4efb82aaf8753d016f48b3811a83fccc56d52b9be8bad05a", "role": "CLIENT_MUTE", "short_name": "SHK8", "snr": -2.7, "status": null, "telemetry": {"air_util_tx": 0.347, "battery_level": 58, "channel_utilization": 17.01, "uptime_seconds": 2516, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 4919, "long_name": "Stone Coyote", "next_hop": 0, "num": "0x10d07422", "position": {"altitude": 1361, "latitude": 33.20411, "location_source": "LOC_INTERNAL", "longitude": -107.101992, "time_offset_sec": 5160}, "public_key_hex": "2c9f25c37d639feff97ad662d69acff2be791426e305d8a0fa93bcd9d79c79f9", "role": "CLIENT", "short_name": "S4J7", "snr": 5.55, "status": null, "telemetry": {"air_util_tx": 0.501, "battery_level": 33, "channel_utilization": 7.16, "uptime_seconds": 86675, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4753, "long_name": "Storm Hare", "next_hop": 28, "num": "0x10e305ab", "position": null, "public_key_hex": "ed3ac0379274651770b854d85a28ec07bd05193cce5c94c25f5fa215b3f608af", "role": "ROUTER", "short_name": "STGS", "snr": 0.4, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 41, "long_name": "Shady Bison", "next_hop": 0, "num": "0x1130c00d", "position": {"altitude": 1363, "latitude": 33.452927, "location_source": "LOC_INTERNAL", "longitude": -106.823964, "time_offset_sec": 305}, "public_key_hex": "e525b01b9c64977433681f3927787898fac52aa87ff8cdd27a4c413173c7b9b2", "role": "CLIENT", "short_name": "S96X", "snr": 10.9, "status": null, "telemetry": {"air_util_tx": 0.897, "battery_level": 81, "channel_utilization": 7.02, "uptime_seconds": 136558, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3237, "long_name": "Wandering Elk", "next_hop": 0, "num": "0x1140b432", "position": {"altitude": 1715, "latitude": 32.55773, "location_source": "LOC_INTERNAL", "longitude": -107.026604, "time_offset_sec": 3476}, "public_key_hex": "f8bb4a5512ac1f61d7839fbaca1445c9124e10690b2855d0b742d3649314667f", "role": "CLIENT", "short_name": "WU0N", "snr": 4.5, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3061, "long_name": "Misty Mustang", "next_hop": 0, "num": "0x1144862d", "position": {"altitude": 1552, "latitude": 32.799191, "location_source": "LOC_INTERNAL", "longitude": -108.081215, "time_offset_sec": 3270}, "public_key_hex": "b9f72d8e6754757140b0d284da8eedc29b9b6dd67a5237d5965cc06c00baca25", "role": "CLIENT", "short_name": "M7O5", "snr": 6.62, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.058, "battery_level": 92, "channel_utilization": 8.03, "uptime_seconds": 112487, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 3469, "long_name": "Tiny Otter", "next_hop": 73, "num": "0x11591c7f", "position": {"altitude": 1088, "latitude": 33.498682, "location_source": "LOC_INTERNAL", "longitude": -107.003249, "time_offset_sec": 3672}, "public_key_hex": "5bdb98b5bb699aa313cc37bf347daba01ccee66fe18c1967a2e7c17e2f46a662", "role": "CLIENT", "short_name": "TGQT", "snr": 5.68, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1461, "long_name": "Soft Mustang", "next_hop": 0, "num": "0x116df536", "position": null, "public_key_hex": "121777ef9dfb7bee53a4b13155fb0ef124d2c97872f3b286f2ef77c0006290b0", "role": "CLIENT", "short_name": "S05P", "snr": 5.43, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.254, "battery_level": 47, "channel_utilization": 4.14, "uptime_seconds": 29351, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.13, "iaq": 37, "relative_humidity": 49.44, "temperature": 16.57}, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 2228, "long_name": "Old Trout", "next_hop": 0, "num": "0x117d6f44", "position": {"altitude": 927, "latitude": 33.240707, "location_source": "LOC_INTERNAL", "longitude": -108.298144, "time_offset_sec": 2386}, "public_key_hex": "91e3c478f98805009a63217616c3e8052c481b7e7b0df036b623c7667848ebf5", "role": "CLIENT", "short_name": "O8MP", "snr": 9.21, "status": null, "telemetry": {"air_util_tx": 0.711, "battery_level": 15, "channel_utilization": 4.25, "uptime_seconds": 258852, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 10450, "long_name": "Floating Cougar", "next_hop": 0, "num": "0x119f46e4", "position": {"altitude": 1461, "latitude": 33.958024, "location_source": "LOC_INTERNAL", "longitude": -107.137727, "time_offset_sec": 10743}, "public_key_hex": "c6aef989211fad65a1d08d3116644410c05c8cb7358439106727bbf2b84c1ff7", "role": "CLIENT", "short_name": "FRKP", "snr": 11.61, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.673, "battery_level": 16, "channel_utilization": 29.47, "uptime_seconds": 330996, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1408, "long_name": "Short Squirrel", "next_hop": 127, "num": "0x11a0f8c4", "position": {"altitude": 1001, "latitude": 33.04826, "location_source": "LOC_INTERNAL", "longitude": -107.217384, "time_offset_sec": 1548}, "public_key_hex": "a49c1087fe9feb0f18f22e70be55b14f8435cc5274083f04d43db09399b52c40", "role": "CLIENT", "short_name": "SVLA", "snr": 3.45, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.17, "battery_level": 94, "channel_utilization": 10.84, "uptime_seconds": 5347, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.58, "iaq": 32, "relative_humidity": 71.21, "temperature": 31.12}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2085, "long_name": "Sleepy Adder", "next_hop": 0, "num": "0x11d1849d", "position": {"altitude": 1045, "latitude": 32.793306, "location_source": "LOC_INTERNAL", "longitude": -107.264734, "time_offset_sec": 2119}, "public_key_hex": "bef6a2d00a4543a1719e7cbdd18eef52cae4bafc89d41d5af1a2fe2438f6db08", "role": "CLIENT", "short_name": "SFJ1", "snr": 9.72, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 12017, "long_name": "Tall Elk", "next_hop": 56, "num": "0x11f51c4f", "position": null, "public_key_hex": "", "role": "SENSOR", "short_name": "TKAI", "snr": 5.85, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.447, "battery_level": 101, "channel_utilization": 24.71, "uptime_seconds": 71695, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2335, "long_name": "Rough Gecko", "next_hop": 0, "num": "0x1202f3e9", "position": {"altitude": 1479, "latitude": 32.859014, "location_source": "LOC_INTERNAL", "longitude": -107.089773, "time_offset_sec": 2399}, "public_key_hex": "cf82a53447d153f4728a632cea2701c6ac96ad9860a300772e9aa0d826434751", "role": "CLIENT_MUTE", "short_name": "RCF1", "snr": 7.36, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.139, "battery_level": 18, "channel_utilization": 34.81, "uptime_seconds": 99030, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 24830, "long_name": "Sharp Moose", "next_hop": 204, "num": "0x120c3f2a", "position": {"altitude": 894, "latitude": 32.37951, "location_source": "LOC_INTERNAL", "longitude": -106.911157, "time_offset_sec": 25116}, "public_key_hex": "f7abc5be083672e217fc15e1c786de20ed76a39232c7e31ad15d5d8dfa594e66", "role": "CLIENT", "short_name": "S8RC", "snr": 3.64, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.507, "battery_level": 40, "channel_utilization": 26.05, "uptime_seconds": 11379, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 4969, "long_name": "Tall Yucca", "next_hop": 86, "num": "0x1213aa60", "position": {"altitude": 1158, "latitude": 32.434745, "location_source": "LOC_INTERNAL", "longitude": -107.568684, "time_offset_sec": 5160}, "public_key_hex": "8f0dbd6fb9cde42a603cb7c3d8be4cd215bb3c86b4ef7b4e78ca3ac600a8f508", "role": "CLIENT", "short_name": "TXJT", "snr": 5.85, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 6397, "long_name": "Hidden Mole", "next_hop": 0, "num": "0x121d5fce", "position": {"altitude": 1265, "latitude": 32.784107, "location_source": "LOC_INTERNAL", "longitude": -107.824998, "time_offset_sec": 6661}, "public_key_hex": "d4cc170889de3890f20ed0ba96b7118aaa917dd45170e5a3a7fc022a65a131c9", "role": "CLIENT", "short_name": "HUX0", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.439, "battery_level": 85, "channel_utilization": 12.87, "uptime_seconds": 44018, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_HT62", "last_heard_offset_sec": 60, "long_name": "Forest Moose", "next_hop": 166, "num": "0x122cb1d6", "position": {"altitude": 1490, "latitude": 33.387681, "location_source": "LOC_INTERNAL", "longitude": -107.302076, "time_offset_sec": 173}, "public_key_hex": "c66a1f31ebd0c69b0c55dc0553aba272c73250a74fc93460c040139e0e6364c8", "role": "CLIENT", "short_name": "🦉", "snr": 7.23, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.485, "battery_level": 48, "channel_utilization": 7.97, "uptime_seconds": 108207, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4346, "long_name": "Iron Trout", "next_hop": 0, "num": "0x1259c3d4", "position": {"altitude": 1675, "latitude": 33.344212, "location_source": "LOC_INTERNAL", "longitude": -107.536802, "time_offset_sec": 4544}, "public_key_hex": "d2dc33c9abdf97f8ec13471fa39632611712408c987f180986670a918cc61db3", "role": "CLIENT", "short_name": "IR59", "snr": 5.09, "status": null, "telemetry": {"air_util_tx": 0.528, "battery_level": 15, "channel_utilization": 14.88, "uptime_seconds": 3520, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 4583, "long_name": "Solar Doe", "next_hop": 0, "num": "0x12813205", "position": {"altitude": 1352, "latitude": 33.914999, "location_source": "LOC_INTERNAL", "longitude": -107.070752, "time_offset_sec": 4820}, "public_key_hex": "25b2ca33261dec18d06c842fcf33861bc64dfb4f5847b0040e8adb2271321109", "role": "CLIENT", "short_name": "SZHU", "snr": 4.14, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.626, "battery_level": 95, "channel_utilization": 2.05, "uptime_seconds": 113045, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 961, "long_name": "Tall Arroyo", "next_hop": 145, "num": "0x1287186e", "position": {"altitude": 1116, "latitude": 33.07856, "location_source": "LOC_INTERNAL", "longitude": -107.040112, "time_offset_sec": 1120}, "public_key_hex": "adc2111218b9a0dbd799b64955542c3b46b81ecf496ff161116723e13685d6c9", "role": "CLIENT", "short_name": "TBL1", "snr": 9.86, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.255, "battery_level": 101, "channel_utilization": 8.04, "uptime_seconds": 49469, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 215, "long_name": "Copper Crow", "next_hop": 47, "num": "0x128d91a3", "position": null, "public_key_hex": "582c78ab5e8d3a572d19f792ab460e4ed0a4edf6ff297d90eedf72b32c705a40", "role": "CLIENT", "short_name": "COPG", "snr": 3.52, "status": null, "telemetry": {"air_util_tx": 0.231, "battery_level": 13, "channel_utilization": 14.13, "uptime_seconds": 17498, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1318, "long_name": "Fast Mamba", "next_hop": 0, "num": "0x129447da", "position": {"altitude": 1397, "latitude": 32.55993, "location_source": "LOC_INTERNAL", "longitude": -107.376264, "time_offset_sec": 1318}, "public_key_hex": "563eff14aa489bd875d76a0fa64a20228eb4610ee056c01515ba86478c8ab9bf", "role": "CLIENT", "short_name": "FMSA", "snr": 3.81, "status": null, "telemetry": {"air_util_tx": 0.379, "battery_level": 92, "channel_utilization": 8.21, "uptime_seconds": 110536, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 741, "long_name": "Soft Falcon", "next_hop": 0, "num": "0x12c17d68", "position": {"altitude": 1370, "latitude": 32.194855, "location_source": "LOC_INTERNAL", "longitude": -107.794857, "time_offset_sec": 811}, "public_key_hex": "", "role": "CLIENT", "short_name": "SB5F", "snr": 7.97, "status": null, "telemetry": {"air_util_tx": 0.384, "battery_level": 40, "channel_utilization": 7.24, "uptime_seconds": 221572, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 185, "long_name": "Frozen Aspen", "next_hop": 94, "num": "0x12c5d16c", "position": {"altitude": 1211, "latitude": 34.457666, "location_source": "LOC_INTERNAL", "longitude": -106.036727, "time_offset_sec": 481}, "public_key_hex": "4ca2b5344fe689c17565f8c07e3d975f00044741cd6e1e11740cae00cb1e2117", "role": "CLIENT", "short_name": "FYML", "snr": 3.9, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 996.76, "iaq": 56, "relative_humidity": 51.56, "temperature": 14.82}, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 4420, "long_name": "Stone Viper", "next_hop": 254, "num": "0x12c89a0f", "position": {"altitude": 1313, "latitude": 32.885856, "location_source": "LOC_INTERNAL", "longitude": -107.116431, "time_offset_sec": 4572}, "public_key_hex": "b60648d97a6aa54c557f06c658e65cd6fc7a929f4e531906d2820732b52325ff", "role": "ROUTER", "short_name": "SXKK", "snr": 2.59, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.208, "battery_level": 101, "channel_utilization": 13.22, "uptime_seconds": 111602, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5467, "long_name": "Old Oak", "next_hop": 41, "num": "0x12e35b69", "position": {"altitude": 1528, "latitude": 31.903621, "location_source": "LOC_INTERNAL", "longitude": -106.647205, "time_offset_sec": 5652}, "public_key_hex": "6d33954cdb963b84fa34d183ebde840213ad6a2ce558f263a817c7e13e29505f", "role": "CLIENT_HIDDEN", "short_name": "OSOV", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.488, "battery_level": 87, "channel_utilization": 2.57, "uptime_seconds": 86361, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 4349, "long_name": "Shady Otter", "next_hop": 0, "num": "0x1304a3aa", "position": {"altitude": 1197, "latitude": 33.353677, "location_source": "LOC_INTERNAL", "longitude": -106.67855, "time_offset_sec": 4399}, "public_key_hex": "d161bf90692d519591b4392f380bd6028ab6e60b57edccf67d9eb9e1d160cb91", "role": "CLIENT", "short_name": "S5D9", "snr": 0.55, "status": null, "telemetry": {"air_util_tx": 0.213, "battery_level": 52, "channel_utilization": 5.72, "uptime_seconds": 30783, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.47, "iaq": 89, "relative_humidity": 56.14, "temperature": 26.7}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1926, "long_name": "Rough Whale", "next_hop": 0, "num": "0x1336a148", "position": {"altitude": 960, "latitude": 31.748311, "location_source": "LOC_INTERNAL", "longitude": -106.415419, "time_offset_sec": 2134}, "public_key_hex": "05df159171337fc18f6d9db67e585fff5fdfa2b2cec2d8f265ff6e24a56b77c1", "role": "ROUTER", "short_name": "RBQ6", "snr": 5.07, "status": null, "telemetry": {"air_util_tx": 0.129, "battery_level": 36, "channel_utilization": 35.4, "uptime_seconds": 43297, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.32, "iaq": 25, "relative_humidity": 84.65, "temperature": 24.18}, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 762, "long_name": "Frosty Squirrel", "next_hop": 161, "num": "0x137c43e5", "position": {"altitude": 1878, "latitude": 33.334632, "location_source": "LOC_INTERNAL", "longitude": -106.993609, "time_offset_sec": 950}, "public_key_hex": "5ff61da11751f3151467a7ac42ff3cd438d6ba15a97197f2088e7425a6130142", "role": "CLIENT", "short_name": "FYB7", "snr": 5.51, "status": {"status": "rebooted"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 25, "long_name": "Lone Fox", "next_hop": 74, "num": "0x138b14af", "position": {"altitude": 984, "latitude": 33.81845, "location_source": "LOC_INTERNAL", "longitude": -107.624559, "time_offset_sec": 204}, "public_key_hex": "254e04996f698ec5dd26d1db3a039799cd7ce4561c1de0549355418c74a9c8e0", "role": "CLIENT", "short_name": "LBJG", "snr": 6.74, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 3645, "long_name": "Black Sage", "next_hop": 0, "num": "0x138b510b", "position": {"altitude": 817, "latitude": 33.335348, "location_source": "LOC_INTERNAL", "longitude": -108.715891, "time_offset_sec": 3873}, "public_key_hex": "f9b6512339b4f9ca1a915668d3ee217038899b212c04999d0bae7d3eef829017", "role": "CLIENT", "short_name": "BWAM", "snr": 1.85, "status": null, "telemetry": {"air_util_tx": 1.591, "battery_level": 24, "channel_utilization": 14.07, "uptime_seconds": 192599, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 460, "long_name": "Silver Lynx", "next_hop": 0, "num": "0x13917d48", "position": {"altitude": 1411, "latitude": 33.506683, "location_source": "LOC_INTERNAL", "longitude": -107.427473, "time_offset_sec": 532}, "public_key_hex": "", "role": "CLIENT", "short_name": "S7WW", "snr": 7.44, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2465, "long_name": "Steel Oak", "next_hop": 0, "num": "0x13a5336b", "position": {"altitude": 1200, "latitude": 33.447471, "location_source": "LOC_INTERNAL", "longitude": -106.890587, "time_offset_sec": 2719}, "public_key_hex": "56cc1340b4f541c7715269991617ae6589a64ea52e5100efb3fa6e12a05cc7ab", "role": "CLIENT", "short_name": "S7U5", "snr": 4.49, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4738, "long_name": "Frozen Bronco", "next_hop": 156, "num": "0x13bacfe1", "position": {"altitude": 774, "latitude": 33.147494, "location_source": "LOC_INTERNAL", "longitude": -106.780674, "time_offset_sec": 4751}, "public_key_hex": "d922d02c626dd15e6f0e673d27a7af7b57dbe88e8856425012f294a3d545779b", "role": "TRACKER", "short_name": "FME3", "snr": 0.1, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 399, "long_name": "Green Sage", "next_hop": 0, "num": "0x13bcea44", "position": {"altitude": 1292, "latitude": 33.300976, "location_source": "LOC_INTERNAL", "longitude": -106.763151, "time_offset_sec": 435}, "public_key_hex": "2dc0abc85454b5f173e9430e228a2712aa6c31fa3059887076eef70c9a9fbb66", "role": "CLIENT_HIDDEN", "short_name": "GBOV", "snr": 9.67, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.694, "battery_level": 63, "channel_utilization": 6.83, "uptime_seconds": 24895, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 10926, "long_name": "Gold Aspen", "next_hop": 0, "num": "0x13c6a59b", "position": {"altitude": 1569, "latitude": 33.134492, "location_source": "LOC_INTERNAL", "longitude": -106.672199, "time_offset_sec": 11159}, "public_key_hex": "4ea53870a8f500f93840329441653044f3330690119729cee63be0305830bdcc", "role": "CLIENT", "short_name": "GHCL", "snr": 8.2, "status": null, "telemetry": {"air_util_tx": 1.769, "battery_level": 63, "channel_utilization": 28.04, "uptime_seconds": 13021, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.24, "iaq": 76, "relative_humidity": 39.34, "temperature": 23.27}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2880, "long_name": "Howling Marmot", "next_hop": 18, "num": "0x13ce8bb9", "position": {"altitude": 1267, "latitude": 33.683598, "location_source": "LOC_INTERNAL", "longitude": -106.635657, "time_offset_sec": 3028}, "public_key_hex": "5d59cd34bfcc915b41d56a12604093471027cf7cad61dc4c304e6cde4e32d308", "role": "CLIENT", "short_name": "HZC3", "snr": 7.08, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1003.43, "iaq": 0, "relative_humidity": 72.62, "temperature": 6.4}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 355, "long_name": "Sneaky Coyote", "next_hop": 0, "num": "0x13ef6fe7", "position": {"altitude": 1880, "latitude": 33.487432, "location_source": "LOC_INTERNAL", "longitude": -107.762274, "time_offset_sec": 571}, "public_key_hex": "", "role": "CLIENT", "short_name": "S3J4", "snr": -2.7, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.521, "battery_level": 70, "channel_utilization": 20.4, "uptime_seconds": 220436, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 4511, "long_name": "Canyon Coyote", "next_hop": 0, "num": "0x13fa6a48", "position": {"altitude": 1437, "latitude": 32.722236, "location_source": "LOC_INTERNAL", "longitude": -107.675094, "time_offset_sec": 4770}, "public_key_hex": "b388bedf424723c1294bbbcf7d3d97ab58225b61bb61ec8ac1f449e457d07214", "role": "CLIENT", "short_name": "CBIQ", "snr": 5.24, "status": null, "telemetry": {"air_util_tx": 1.349, "battery_level": 53, "channel_utilization": 14.49, "uptime_seconds": 131893, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 298, "long_name": "Bright Colt", "next_hop": 0, "num": "0x140e69b7", "position": {"altitude": 1632, "latitude": 32.616442, "location_source": "LOC_INTERNAL", "longitude": -107.386185, "time_offset_sec": 451}, "public_key_hex": "", "role": "ROUTER", "short_name": "BS6Q", "snr": -1.7, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.625, "battery_level": 28, "channel_utilization": 7.18, "uptime_seconds": 107938, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8623, "long_name": "Drifting Iguana", "next_hop": 175, "num": "0x141d7c0b", "position": {"altitude": 911, "latitude": 33.90238, "location_source": "LOC_INTERNAL", "longitude": -108.269861, "time_offset_sec": 8846}, "public_key_hex": "5b82eb0cbe399ebc29a4e2664da79e1bc52603a48730c29e79e363cc021ff3cc", "role": "CLIENT", "short_name": "🦅", "snr": 7.82, "status": null, "telemetry": {"air_util_tx": 0.699, "battery_level": 28, "channel_utilization": 15.03, "uptime_seconds": 54621, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4268, "long_name": "Forest Falcon", "next_hop": 0, "num": "0x14209ccd", "position": {"altitude": 918, "latitude": 33.324279, "location_source": "LOC_INTERNAL", "longitude": -107.24355, "time_offset_sec": 4568}, "public_key_hex": "99a8ee58cbbfd7c344888f5231f51053d7e0c1f3ebccc79527cc2b8f94864dcb", "role": "CLIENT", "short_name": "🌊", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.22, "battery_level": 18, "channel_utilization": 1.38, "uptime_seconds": 150315, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5910, "long_name": "Sharp Bronco", "next_hop": 33, "num": "0x1422fbf0", "position": {"altitude": 1047, "latitude": 33.422359, "location_source": "LOC_INTERNAL", "longitude": -106.721269, "time_offset_sec": 6132}, "public_key_hex": "4061d179a807d7df718f1dba36e371aeb8336c6719e903dfb73f04cb52885435", "role": "TRACKER", "short_name": "S4B7", "snr": 3.68, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.84, "battery_level": 90, "channel_utilization": 12.89, "uptime_seconds": 18978, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 3503, "long_name": "Dawn Mesa", "next_hop": 0, "num": "0x14263575", "position": {"altitude": 1002, "latitude": 32.895713, "location_source": "LOC_INTERNAL", "longitude": -107.85541, "time_offset_sec": 3617}, "public_key_hex": "ded9d43b704ffdd8f55354b76bbdd39dae8e8cbb94066b2cf1ab17b64b0f1c98", "role": "CLIENT", "short_name": "DV0Z", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.811, "battery_level": 10, "channel_utilization": 0.13, "uptime_seconds": 144602, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.63, "iaq": 62, "relative_humidity": 32.4, "temperature": 23.92}, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1034, "long_name": "Dawn Bronco", "next_hop": 196, "num": "0x1442f5e8", "position": {"altitude": 1731, "latitude": 33.123595, "location_source": "LOC_INTERNAL", "longitude": -107.015585, "time_offset_sec": 1179}, "public_key_hex": "7e8d090b0f5df330aef56594f16d26262e0b1d133c87a1f26dcbce2124c13697", "role": "CLIENT", "short_name": "DY02", "snr": 5.04, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 10054, "long_name": "Drowsy Turtle", "next_hop": 0, "num": "0x144738e1", "position": {"altitude": 1330, "latitude": 33.678139, "location_source": "LOC_INTERNAL", "longitude": -107.051382, "time_offset_sec": 10277}, "public_key_hex": "42adc67eddc076e6f8859f7c7cf1e9a76f748c122253809c22faed0908389d9d", "role": "CLIENT", "short_name": "D4G6", "snr": 2.06, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 386, "long_name": "Smooth Heron", "next_hop": 0, "num": "0x1449bee3", "position": {"altitude": 1812, "latitude": 32.596081, "location_source": "LOC_INTERNAL", "longitude": -107.265923, "time_offset_sec": 571}, "public_key_hex": "8733d276f804438cba07c5aa3dfd5917a3d15ab68954de234068582e1169a43f", "role": "SENSOR", "short_name": "S63Q", "snr": 4.92, "status": null, "telemetry": {"air_util_tx": 0.341, "battery_level": 72, "channel_utilization": 15.13, "uptime_seconds": 3636, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1969, "long_name": "Drifting Mesa", "next_hop": 0, "num": "0x148ac110", "position": {"altitude": 1165, "latitude": 32.740567, "location_source": "LOC_INTERNAL", "longitude": -107.295022, "time_offset_sec": 2099}, "public_key_hex": "1ae4189e28c20dfee4041818fbe41168027d534e25a31a557d22b03d67d829cb", "role": "TRACKER", "short_name": "DOHA", "snr": 6.72, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.164, "battery_level": 66, "channel_utilization": 24.74, "uptime_seconds": 155542, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3317, "long_name": "Silent Sage", "next_hop": 0, "num": "0x148c0dbd", "position": {"altitude": 1154, "latitude": 33.346429, "location_source": "LOC_INTERNAL", "longitude": -107.110943, "time_offset_sec": 3451}, "public_key_hex": "0f860104f229a12fb774c629b1e8a3b1cafb20a56950d1cd07b95bad00a26f5e", "role": "CLIENT_MUTE", "short_name": "SFH6", "snr": 2.63, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 11386, "long_name": "Gold Cougar", "next_hop": 0, "num": "0x1497da62", "position": {"altitude": 943, "latitude": 33.217452, "location_source": "LOC_INTERNAL", "longitude": -107.362548, "time_offset_sec": 11529}, "public_key_hex": "4014cb0d7d030f9c41e522b13d9a1e96a73f108e587748c1b58ed561b9423308", "role": "CLIENT", "short_name": "GBVU", "snr": 1.34, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.015, "battery_level": 82, "channel_utilization": 9.61, "uptime_seconds": 50576, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.27, "iaq": 90, "relative_humidity": 61.68, "temperature": 21.82}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 158, "long_name": "New Squirrel", "next_hop": 215, "num": "0x1499e0a2", "position": {"altitude": 1710, "latitude": 33.340875, "location_source": "LOC_INTERNAL", "longitude": -108.203226, "time_offset_sec": 271}, "public_key_hex": "162acbb73252d8d76cf37c3f3933f74a9f48284307f5d4696df27e98d413f8e9", "role": "CLIENT", "short_name": "NB4K", "snr": 2.22, "status": null, "telemetry": {"air_util_tx": 1.448, "battery_level": 40, "channel_utilization": 10.27, "uptime_seconds": 361, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 9864, "long_name": "Soft Salmon", "next_hop": 0, "num": "0x14a67714", "position": {"altitude": 1444, "latitude": 33.799028, "location_source": "LOC_INTERNAL", "longitude": -107.192065, "time_offset_sec": 10055}, "public_key_hex": "b153de932435ca616491fd81ec22d554ef378624a01d85feb49d7e5d7f086ef4", "role": "CLIENT", "short_name": "SCTS", "snr": 7.81, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.69, "iaq": 43, "relative_humidity": 38.06, "temperature": 23.94}, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 352, "long_name": "Hidden Bear", "next_hop": 25, "num": "0x14e07172", "position": {"altitude": 1447, "latitude": 33.580747, "location_source": "LOC_INTERNAL", "longitude": -106.736019, "time_offset_sec": 517}, "public_key_hex": "5e76d598636595d08fbf3cc5ffcc60a05b491016787ccd60177e57d992509c09", "role": "CLIENT", "short_name": "H5R2", "snr": 11.24, "status": null, "telemetry": {"air_util_tx": 0.105, "battery_level": 71, "channel_utilization": 10.38, "uptime_seconds": 77217, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 327, "long_name": "Solar Doe", "next_hop": 180, "num": "0x14e2b068", "position": {"altitude": 1076, "latitude": 32.255904, "location_source": "LOC_INTERNAL", "longitude": -107.515015, "time_offset_sec": 439}, "public_key_hex": "d8a5701dacf7b165d37294ca8fce4e051c4c065214457a8a593c72c6979f1eb1", "role": "CLIENT", "short_name": "SWZU", "snr": 6.51, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.733, "battery_level": 58, "channel_utilization": 1.3, "uptime_seconds": 54417, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1231, "long_name": "Iron Stag", "next_hop": 0, "num": "0x14fb9c5c", "position": {"altitude": 605, "latitude": 34.007995, "location_source": "LOC_INTERNAL", "longitude": -107.608662, "time_offset_sec": 1524}, "public_key_hex": "5bc5e1ce2be521d8926dcf3fdb5e0562986988b267df491f32a2f46357cfb634", "role": "CLIENT", "short_name": "IGTL", "snr": 9.24, "status": null, "telemetry": {"air_util_tx": 0.595, "battery_level": 22, "channel_utilization": 7.42, "uptime_seconds": 61, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1226, "long_name": "Rough Shark", "next_hop": 0, "num": "0x150da35e", "position": {"altitude": 1631, "latitude": 32.823798, "location_source": "LOC_INTERNAL", "longitude": -107.226062, "time_offset_sec": 1470}, "public_key_hex": "6510528e07d5c3a5209c3577e6a84c9e20a6606fb34df1db92021e7964109792", "role": "CLIENT", "short_name": "RWO9", "snr": 2.31, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1212, "long_name": "Sunny Shark", "next_hop": 0, "num": "0x15363cfd", "position": null, "public_key_hex": "a55b05009fb90f3be3b4d74f8846d6024c56ee8557c82d7200d72fae2048bcc8", "role": "CLIENT", "short_name": "SYF8", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.127, "battery_level": 57, "channel_utilization": 4.97, "uptime_seconds": 110038, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2159, "long_name": "Tall Coyote", "next_hop": 0, "num": "0x15666bd2", "position": {"altitude": 1191, "latitude": 32.418779, "location_source": "LOC_INTERNAL", "longitude": -107.295213, "time_offset_sec": 2416}, "public_key_hex": "98d50d8317a5fb9163467ee4370b0cf6fa0c40f4eb2e7a73e60bf26f046889df", "role": "CLIENT", "short_name": "🦂", "snr": 10.83, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 7210, "long_name": "Sharp Eagle", "next_hop": 0, "num": "0x157a1336", "position": null, "public_key_hex": "2f9e81540dc03eb7a98766b60264f63255d1f66bf3956b41bee366092a1facbb", "role": "CLIENT", "short_name": "SHHM", "snr": 3.9, "status": null, "telemetry": {"air_util_tx": 0.439, "battery_level": 48, "channel_utilization": 11.83, "uptime_seconds": 102332, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1619, "long_name": "Short Lynx", "next_hop": 224, "num": "0x15abde0d", "position": {"altitude": 1390, "latitude": 33.347304, "location_source": "LOC_INTERNAL", "longitude": -107.781981, "time_offset_sec": 1789}, "public_key_hex": "e926a97d549b6a9c4cd9ca6b055cbc23d13a5ca9ab094f36f7e23c28bb5f60bb", "role": "CLIENT", "short_name": "S7AM", "snr": 9.49, "status": null, "telemetry": {"air_util_tx": 0.119, "battery_level": 90, "channel_utilization": 9.44, "uptime_seconds": 233688, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.09, "iaq": 47, "relative_humidity": 61.35, "temperature": 9.46}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1130, "long_name": "Soft Doe", "next_hop": 150, "num": "0x15ba66f7", "position": {"altitude": 1539, "latitude": 33.241984, "location_source": "LOC_INTERNAL", "longitude": -108.103882, "time_offset_sec": 1228}, "public_key_hex": "d702e9a9b964466b22c1a1396d88de223143542882463495068b415830137456", "role": "ROUTER", "short_name": "🔥", "snr": 5.41, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 195, "long_name": "Blue Fox", "next_hop": 0, "num": "0x15c60f43", "position": {"altitude": 1538, "latitude": 33.9705, "location_source": "LOC_INTERNAL", "longitude": -107.966766, "time_offset_sec": 210}, "public_key_hex": "33638fdf5861edf7570be66e86c37be808e30dcda62c0c36b82badbaf33437eb", "role": "CLIENT_MUTE", "short_name": "BLSZ", "snr": 10.09, "status": null, "telemetry": {"air_util_tx": 0.127, "battery_level": 16, "channel_utilization": 27.81, "uptime_seconds": 278624, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 14430, "long_name": "Fast Bronco", "next_hop": 0, "num": "0x15c7408b", "position": {"altitude": 1523, "latitude": 33.683597, "location_source": "LOC_INTERNAL", "longitude": -106.791178, "time_offset_sec": 14440}, "public_key_hex": "93619eb63cce57e3d42969dcb9bba4a760231f4cb66638fc37d7b072b41ee3a4", "role": "CLIENT", "short_name": "F5M2", "snr": 6.9, "status": {"status": "low-batt"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3004, "long_name": "Drowsy Salmon", "next_hop": 153, "num": "0x15f2d2cd", "position": {"altitude": 1160, "latitude": 32.925866, "location_source": "LOC_INTERNAL", "longitude": -107.125276, "time_offset_sec": 3263}, "public_key_hex": "5c83a33218092642406789fb0f8089a60921fa9ea66784ef015a82ac6a2e31ea", "role": "ROUTER", "short_name": "D23H", "snr": -0.1, "status": null, "telemetry": {"air_util_tx": 0.194, "battery_level": 29, "channel_utilization": 16.84, "uptime_seconds": 21756, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 161, "long_name": "Sharp Turtle", "next_hop": 0, "num": "0x1616af6c", "position": {"altitude": 1848, "latitude": 33.393393, "location_source": "LOC_INTERNAL", "longitude": -107.306015, "time_offset_sec": 324}, "public_key_hex": "353dd9d58ff269ebb9d0687ec4c5f003b74310c366c8169b9b3b46838e1b3166", "role": "CLIENT", "short_name": "SYM3", "snr": 5.25, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 3576, "long_name": "Floating Tortoise", "next_hop": 0, "num": "0x161ebffc", "position": {"altitude": 1507, "latitude": 32.597884, "location_source": "LOC_INTERNAL", "longitude": -106.527195, "time_offset_sec": 3613}, "public_key_hex": "29d8bdfa24d4f24ecba5c3fc9cd3dc5ba9707e40d5822d3a287b42138e92bf62", "role": "CLIENT", "short_name": "F1DI", "snr": 10.21, "status": null, "telemetry": {"air_util_tx": 0.18, "battery_level": 52, "channel_utilization": 15.29, "uptime_seconds": 76065, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5323, "long_name": "Roving Pony", "next_hop": 26, "num": "0x1622b81c", "position": {"altitude": 1030, "latitude": 32.602955, "location_source": "LOC_INTERNAL", "longitude": -106.822541, "time_offset_sec": 5472}, "public_key_hex": "12bbd4c683bca49d72d39be68b37462f3d44b920b0059599dceb392ddac6f030", "role": "CLIENT", "short_name": "R0DR", "snr": -2.03, "status": null, "telemetry": {"air_util_tx": 1.769, "battery_level": 57, "channel_utilization": 14.84, "uptime_seconds": 72425, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.77, "iaq": 96, "relative_humidity": 65.05, "temperature": 36.72}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1924, "long_name": "Soft Pine", "next_hop": 0, "num": "0x1624cfb9", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "SRMA", "snr": 7.03, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.455, "battery_level": 64, "channel_utilization": 14.34, "uptime_seconds": 123872, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 384, "long_name": "Rough Eagle", "next_hop": 0, "num": "0x163cfd86", "position": {"altitude": 1641, "latitude": 33.2687, "location_source": "LOC_INTERNAL", "longitude": -107.122727, "time_offset_sec": 430}, "public_key_hex": "082b154c0faa36698aa7fddeb536ea4d5ab6b31b7ab4f21eea4ae5e6b94ad272", "role": "CLIENT", "short_name": "RO4Q", "snr": 9.96, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2747, "long_name": "New Owl", "next_hop": 19, "num": "0x165367fd", "position": {"altitude": 1338, "latitude": 33.806583, "location_source": "LOC_INTERNAL", "longitude": -106.843628, "time_offset_sec": 2965}, "public_key_hex": "f064891ce6021e484ef4668313aa9b25c906b119a2a7210fe058984a45b48ae9", "role": "CLIENT", "short_name": "NP9I", "snr": 7.93, "status": null, "telemetry": {"air_util_tx": 0.076, "battery_level": 44, "channel_utilization": 9.57, "uptime_seconds": 116112, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 6338, "long_name": "River Otter", "next_hop": 0, "num": "0x168c4e26", "position": {"altitude": 1779, "latitude": 32.643528, "location_source": "LOC_INTERNAL", "longitude": -107.518167, "time_offset_sec": 6561}, "public_key_hex": "180b2857bc0ecfdc32df357b464fe4f5991cfdb72c136ed8e9ad20895ca0ad6c", "role": "CLIENT", "short_name": "RYF7", "snr": 6.59, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.88, "iaq": 63, "relative_humidity": 36.66, "temperature": 17.96}, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2117, "long_name": "Misty Colt", "next_hop": 240, "num": "0x1699e042", "position": {"altitude": 1333, "latitude": 32.93035, "location_source": "LOC_INTERNAL", "longitude": -107.800457, "time_offset_sec": 2247}, "public_key_hex": "0093a7d4c76a7c0707c4ede6ac46847acee069237431e98f502a9c24dad849e5", "role": "CLIENT", "short_name": "M282", "snr": 8.08, "status": null, "telemetry": {"air_util_tx": 0.479, "battery_level": 30, "channel_utilization": 10.78, "uptime_seconds": 20518, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.7, "iaq": 23, "relative_humidity": 52.01, "temperature": 22.5}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2352, "long_name": "Rough Ridge NM0XM", "next_hop": 0, "num": "0x16ad5834", "position": {"altitude": 1392, "latitude": 33.613542, "location_source": "LOC_INTERNAL", "longitude": -107.310826, "time_offset_sec": 2496}, "public_key_hex": "d4850b6d68eaa842e731b6e3a34ed77974d790e825013e8ed0dad08cac73ebbf", "role": "CLIENT_MUTE", "short_name": "🦂", "snr": 9.88, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.269, "battery_level": 94, "channel_utilization": 25.39, "uptime_seconds": 36707, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "RAK4631", "last_heard_offset_sec": 8748, "long_name": "Iron Lion", "next_hop": 207, "num": "0x16b90061", "position": {"altitude": 1476, "latitude": 33.561828, "location_source": "LOC_INTERNAL", "longitude": -107.383816, "time_offset_sec": 8767}, "public_key_hex": "44dd1cf8a487132921690e1c1e94a533de9bbb02a4f4302ca94be9feaa822126", "role": "CLIENT", "short_name": "IGB9", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.016, "battery_level": 83, "channel_utilization": 19.91, "uptime_seconds": 35993, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1023.22, "iaq": 57, "relative_humidity": 33.23, "temperature": 7.91}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 8228, "long_name": "Forest Adder", "next_hop": 69, "num": "0x16c869ad", "position": {"altitude": 1472, "latitude": 33.826009, "location_source": "LOC_INTERNAL", "longitude": -106.83264, "time_offset_sec": 8425}, "public_key_hex": "1bb76aa18b97e766ccad9f44172f07ecb575904a8e0d5887956f70351d7a592d", "role": "CLIENT", "short_name": "F5D5", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 225, "long_name": "Sky Gecko", "next_hop": 197, "num": "0x16d49455", "position": null, "public_key_hex": "34649b748707a123488621fe3556c5f03ccd23eb7a9f79e4bd92a342ce481d6c", "role": "TRACKER", "short_name": "SQP7", "snr": 1.91, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.085, "battery_level": 23, "channel_utilization": 2.64, "uptime_seconds": 14061, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "XIAO_NRF52_KIT", "last_heard_offset_sec": 6797, "long_name": "Short Cactus", "next_hop": 0, "num": "0x16e35386", "position": {"altitude": 1132, "latitude": 32.704487, "location_source": "LOC_INTERNAL", "longitude": -107.047229, "time_offset_sec": 7087}, "public_key_hex": "e45a07129e2195e0dfaa485a6721f5c6ddda2d256fac757e0102487083234060", "role": "CLIENT_MUTE", "short_name": "SAXE", "snr": 7.57, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.831, "battery_level": 29, "channel_utilization": 6.31, "uptime_seconds": 16425, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1000.39, "iaq": 52, "relative_humidity": 49.44, "temperature": 31.38}, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 487, "long_name": "Wandering Sage", "next_hop": 0, "num": "0x16f51217", "position": {"altitude": 1114, "latitude": 33.10492, "location_source": "LOC_INTERNAL", "longitude": -106.721875, "time_offset_sec": 701}, "public_key_hex": "90040a0744b0997cde5283c3d963af5b1574650f6dc8011792c8035fe1dce39d", "role": "CLIENT", "short_name": "WKRS", "snr": 5.69, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.433, "battery_level": 54, "channel_utilization": 3.3, "uptime_seconds": 65147, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 766, "long_name": "Sharp Cedar", "next_hop": 0, "num": "0x1705624a", "position": {"altitude": 1517, "latitude": 32.474761, "location_source": "LOC_INTERNAL", "longitude": -107.105123, "time_offset_sec": 1033}, "public_key_hex": "8d15684b207f0819402c6ae5686cd40aa4f03c7585fd4c9cd07854d7b774b015", "role": "CLIENT", "short_name": "🔥", "snr": 0.92, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3642, "long_name": "Rough Juniper", "next_hop": 0, "num": "0x170856d7", "position": {"altitude": 1238, "latitude": 32.967032, "location_source": "LOC_INTERNAL", "longitude": -108.159484, "time_offset_sec": 3863}, "public_key_hex": "9fe298d5e129dc39d6f12a801e230ed686c0f0f384c141f39c889762fe8d08ff", "role": "CLIENT", "short_name": "REPI", "snr": 4.97, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 4, "hw_model": "RAK4631", "last_heard_offset_sec": 1179, "long_name": "Found Crow", "next_hop": 118, "num": "0x17091931", "position": {"altitude": 1311, "latitude": 33.04791, "location_source": "LOC_INTERNAL", "longitude": -106.5921, "time_offset_sec": 1460}, "public_key_hex": "06ae959652b866de06b60b83fb52aabd3b33a81d67d7fe50fac9092415d0254b", "role": "CLIENT", "short_name": "F3YP", "snr": 5.77, "status": null, "telemetry": {"air_util_tx": 0.163, "battery_level": 11, "channel_utilization": 16.35, "uptime_seconds": 27129, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2379, "long_name": "Mountain Cactus", "next_hop": 0, "num": "0x171afd3e", "position": {"altitude": 1411, "latitude": 33.517268, "location_source": "LOC_INTERNAL", "longitude": -107.250502, "time_offset_sec": 2393}, "public_key_hex": "6e93599dbd0aae9f8070918d2422902414930e5513245c6262ec7756bd7bbce6", "role": "CLIENT", "short_name": "MWRU", "snr": 4.0, "status": null, "telemetry": {"air_util_tx": 0.473, "battery_level": 70, "channel_utilization": 9.22, "uptime_seconds": 3297, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2340, "long_name": "Desert Gecko", "next_hop": 0, "num": "0x171c89cb", "position": null, "public_key_hex": "df658069a02c8363c69dc0372e98402d5983d8c6f3833aa3b9c5a50f7ccf1d72", "role": "TAK", "short_name": "DRBR", "snr": 5.87, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.429, "battery_level": 32, "channel_utilization": 16.37, "uptime_seconds": 164170, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4443, "long_name": "Mountain Mesa", "next_hop": 0, "num": "0x1735e4ca", "position": {"altitude": 1471, "latitude": 33.25168, "location_source": "LOC_INTERNAL", "longitude": -107.845125, "time_offset_sec": 4694}, "public_key_hex": "228e8871c66e93d43e1b55ff850f5a54b2cca10d0cc941c0a5fadbc3613cbcaa", "role": "CLIENT", "short_name": "MJ27", "snr": 6.53, "status": null, "telemetry": {"air_util_tx": 0.963, "battery_level": 89, "channel_utilization": 7.49, "uptime_seconds": 40174, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.22, "iaq": 40, "relative_humidity": 47.38, "temperature": 11.33}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 641, "long_name": "Red Cobra", "next_hop": 77, "num": "0x176452f1", "position": {"altitude": 1428, "latitude": 33.096702, "location_source": "LOC_INTERNAL", "longitude": -107.374434, "time_offset_sec": 735}, "public_key_hex": "4fa01b012d65e72b35f1d221e8135e54ad02777567541078900101da245fc29d", "role": "CLIENT", "short_name": "RVP4", "snr": 0.89, "status": null, "telemetry": {"air_util_tx": 0.241, "battery_level": 11, "channel_utilization": 3.54, "uptime_seconds": 20064, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.51, "iaq": 37, "relative_humidity": 65.8, "temperature": 20.99}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 2347, "long_name": "Silver Cedar", "next_hop": 0, "num": "0x1764e7f9", "position": {"altitude": 1373, "latitude": 32.912173, "location_source": "LOC_INTERNAL", "longitude": -107.173558, "time_offset_sec": 2386}, "public_key_hex": "060c5ae8de19cf9afaa91304ff846f71864336d2aed12821c8078f9c26618841", "role": "CLIENT", "short_name": "SQ8N", "snr": 2.39, "status": null, "telemetry": {"air_util_tx": 0.355, "battery_level": 100, "channel_utilization": 4.44, "uptime_seconds": 91805, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 968, "long_name": "Silver Cougar KQ2MZ", "next_hop": 0, "num": "0x17729ddd", "position": {"altitude": 1492, "latitude": 33.179537, "location_source": "LOC_INTERNAL", "longitude": -107.480566, "time_offset_sec": 1005}, "public_key_hex": "26cb3066694bad6d06d6211cfe25c200d671c2bf20b1342d2fe75f19099e5f95", "role": "CLIENT", "short_name": "SJ5A", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.79, "battery_level": 34, "channel_utilization": 6.5, "uptime_seconds": 14032, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 1942, "long_name": "Brave Lynx", "next_hop": 0, "num": "0x178882dc", "position": {"altitude": 1168, "latitude": 32.875026, "location_source": "LOC_INTERNAL", "longitude": -107.630833, "time_offset_sec": 2240}, "public_key_hex": "6e99b7fcfcf89f85e48921fcf239dee9a7fe2b58e6f9cfb8c75d78028b59b2cf", "role": "CLIENT", "short_name": "🌊", "snr": -1.45, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.364, "battery_level": 75, "channel_utilization": 5.83, "uptime_seconds": 119687, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8208, "long_name": "Hidden Sage", "next_hop": 0, "num": "0x1795540a", "position": {"altitude": 1548, "latitude": 33.151839, "location_source": "LOC_INTERNAL", "longitude": -107.683123, "time_offset_sec": 8374}, "public_key_hex": "c30c972b41ca495e633a9cf75c665df79aa38c6b60f718acdc2c89724c381232", "role": "CLIENT", "short_name": "H2WV", "snr": 6.95, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 5385, "long_name": "Shady Moose", "next_hop": 0, "num": "0x179a05d8", "position": {"altitude": 1149, "latitude": 32.690851, "location_source": "LOC_INTERNAL", "longitude": -107.369609, "time_offset_sec": 5665}, "public_key_hex": "a9fcb2da936ebb29ccf957f724982e90cb0e70c1016ca9e83a736c7f4367f8fc", "role": "ROUTER", "short_name": "SUMU", "snr": 11.74, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.6, "battery_level": 90, "channel_utilization": 9.66, "uptime_seconds": 150707, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 112, "long_name": "Red Cactus", "next_hop": 0, "num": "0x17aa6182", "position": {"altitude": 1417, "latitude": 32.988184, "location_source": "LOC_INTERNAL", "longitude": -107.095767, "time_offset_sec": 233}, "public_key_hex": "f7ecb328b03a63130cb89f741046bc974eed7e5523899ec611be11817a81ccf4", "role": "CLIENT", "short_name": "RQFF", "snr": 8.8, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.417, "battery_level": 81, "channel_utilization": 3.53, "uptime_seconds": 34333, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 10900, "long_name": "Howling Falcon", "next_hop": 0, "num": "0x17adf8a4", "position": {"altitude": 1654, "latitude": 32.603353, "location_source": "LOC_INTERNAL", "longitude": -108.237775, "time_offset_sec": 11156}, "public_key_hex": "c1c89d9ccfd6fd1efb5ac2226e62b843adbb7a9122416100084d9122dc2730cb", "role": "CLIENT", "short_name": "HYAX", "snr": 9.25, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 918, "long_name": "Tall Bluff NM4AB", "next_hop": 221, "num": "0x17bb9a3c", "position": {"altitude": 1330, "latitude": 33.516004, "location_source": "LOC_INTERNAL", "longitude": -106.955556, "time_offset_sec": 1212}, "public_key_hex": "acf0439ed6204b8b3fc878cc218c2b501b084bf20d930feafb6114d686800d88", "role": "CLIENT", "short_name": "T949", "snr": 4.71, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2045, "long_name": "Roving Wolf", "next_hop": 0, "num": "0x17dbfa11", "position": {"altitude": 1508, "latitude": 33.680599, "location_source": "LOC_INTERNAL", "longitude": -107.541429, "time_offset_sec": 2233}, "public_key_hex": "2e018f01eee561dc2ec694fb02cc3dd0cd4e8e31db7fa772ed8b33d25f022f8d", "role": "CLIENT", "short_name": "RV1T", "snr": 4.1, "status": null, "telemetry": {"air_util_tx": 0.57, "battery_level": 101, "channel_utilization": 10.59, "uptime_seconds": 31433, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 998.26, "iaq": 78, "relative_humidity": 74.41, "temperature": 15.44}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 14855, "long_name": "Blue Ridge", "next_hop": 0, "num": "0x17e9278b", "position": {"altitude": 1463, "latitude": 32.47174, "location_source": "LOC_INTERNAL", "longitude": -107.6195, "time_offset_sec": 15062}, "public_key_hex": "", "role": "CLIENT", "short_name": "BAOZ", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 7470, "long_name": "Brave Oak", "next_hop": 204, "num": "0x1804ff9f", "position": null, "public_key_hex": "9d2fcf48c7cabd7a1ce6d1358665bf20ce4685fe30a896652b2430cd2115c1eb", "role": "CLIENT", "short_name": "BNOI", "snr": 4.82, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.94, "battery_level": 24, "channel_utilization": 31.57, "uptime_seconds": 62431, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 14674, "long_name": "Frozen Colt", "next_hop": 0, "num": "0x18188e94", "position": null, "public_key_hex": "34182bcec65a507cf1d05494a462c975bb1c9b99b069a73ba2246f2a73330616", "role": "ROUTER", "short_name": "🦂", "snr": 7.7, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.884, "battery_level": 95, "channel_utilization": 13.2, "uptime_seconds": 41570, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 5785, "long_name": "Frosty Mustang", "next_hop": 0, "num": "0x18357cb3", "position": {"altitude": 1474, "latitude": 32.873032, "location_source": "LOC_INTERNAL", "longitude": -107.924146, "time_offset_sec": 6023}, "public_key_hex": "ec9017ba02b0af77b45980a23fa0a06508e2558cd6b2826e7069f68d20229bda", "role": "CLIENT", "short_name": "F4AH", "snr": 6.36, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.233, "battery_level": 32, "channel_utilization": 10.19, "uptime_seconds": 72450, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2336, "long_name": "Lost Crow", "next_hop": 38, "num": "0x184a52f2", "position": {"altitude": 1284, "latitude": 33.742469, "location_source": "LOC_INTERNAL", "longitude": -107.281405, "time_offset_sec": 2600}, "public_key_hex": "98831dc4f6827f6662c58482f3d843438a6d18c42d7ff0ca56a5e2f8bd3399a8", "role": "CLIENT", "short_name": "LKZD", "snr": 5.82, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.851, "battery_level": 24, "channel_utilization": 5.74, "uptime_seconds": 2294, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 998.72, "iaq": 37, "relative_humidity": 69.98, "temperature": 4.05}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 6967, "long_name": "Tall Juniper", "next_hop": 0, "num": "0x184b3232", "position": {"altitude": 1278, "latitude": 33.486758, "location_source": "LOC_INTERNAL", "longitude": -107.790513, "time_offset_sec": 7266}, "public_key_hex": "dbcb9afd58213d8fd844d07b15de93c34883b0ff4f4196f99d94f580cea1f050", "role": "ROUTER", "short_name": "TYDM", "snr": 9.08, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.229, "battery_level": 23, "channel_utilization": 11.61, "uptime_seconds": 28991, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 385, "long_name": "Frozen Tortoise AE0DG", "next_hop": 173, "num": "0x186408ef", "position": {"altitude": 1624, "latitude": 33.434107, "location_source": "LOC_INTERNAL", "longitude": -107.624485, "time_offset_sec": 549}, "public_key_hex": "db654373727bf0686b290b63aad082372d8b11e3007d444508d02d4aa62d5c1b", "role": "CLIENT", "short_name": "F9AQ", "snr": 4.62, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.152, "battery_level": 87, "channel_utilization": 19.36, "uptime_seconds": 77262, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 163, "long_name": "Stone Badger", "next_hop": 0, "num": "0x18643f6d", "position": null, "public_key_hex": "81f5992889ac9c86d7c91c85b841df736592d0321ae4e9b3c4c2e935aa551cb9", "role": "CLIENT", "short_name": "🐝", "snr": 6.9, "status": null, "telemetry": {"air_util_tx": 0.025, "battery_level": 89, "channel_utilization": 21.03, "uptime_seconds": 7967, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5858, "long_name": "Sneaky Beaver", "next_hop": 207, "num": "0x186a12f2", "position": {"altitude": 1467, "latitude": 34.228739, "location_source": "LOC_INTERNAL", "longitude": -107.080831, "time_offset_sec": 6009}, "public_key_hex": "baa0fce4301d0325df82a72cc1655bd845c9ef7526036e0437e4b57360509c12", "role": "SENSOR", "short_name": "SLEV", "snr": 7.35, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 176, "long_name": "Howling Lynx", "next_hop": 34, "num": "0x186d67ce", "position": null, "public_key_hex": "a241a58ebfd78cc5c4e7c233b2013fbb0caec18d857b2a10aaee8a337222d748", "role": "CLIENT", "short_name": "HYUY", "snr": 3.62, "status": null, "telemetry": {"air_util_tx": 0.998, "battery_level": 33, "channel_utilization": 8.04, "uptime_seconds": 133668, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1406, "long_name": "Loud Hare", "next_hop": 0, "num": "0x1892db2b", "position": {"altitude": 1570, "latitude": 34.348108, "location_source": "LOC_INTERNAL", "longitude": -107.311519, "time_offset_sec": 1695}, "public_key_hex": "1d492b7e2dc8c5245128a408a77c03a322a9143dae3e708c0518e058e772dcd3", "role": "CLIENT_MUTE", "short_name": "LG4R", "snr": 6.04, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.284, "battery_level": 34, "channel_utilization": 10.01, "uptime_seconds": 330758, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2606, "long_name": "Sleepy Squirrel", "next_hop": 26, "num": "0x18b2e779", "position": {"altitude": 987, "latitude": 32.511325, "location_source": "LOC_INTERNAL", "longitude": -105.930097, "time_offset_sec": 2608}, "public_key_hex": "c9432a0d578d7722a2fd94aef96eac8a2123fc2b822d45fd37b83ca9030792ba", "role": "CLIENT", "short_name": "SVX8", "snr": 8.45, "status": null, "telemetry": {"air_util_tx": 0.363, "battery_level": 45, "channel_utilization": 16.28, "uptime_seconds": 126840, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.72, "iaq": 73, "relative_humidity": 57.86, "temperature": 17.83}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1397, "long_name": "River Seal", "next_hop": 0, "num": "0x18c8fe95", "position": {"altitude": 1052, "latitude": 33.775867, "location_source": "LOC_INTERNAL", "longitude": -106.821902, "time_offset_sec": 1624}, "public_key_hex": "5b0d2825a85baa7c20693ee04dec81975cd3df5dee8ac1bbe87ccdee55f3541a", "role": "CLIENT", "short_name": "R9ZU", "snr": 6.42, "status": null, "telemetry": {"air_util_tx": 2.08, "battery_level": 50, "channel_utilization": 29.73, "uptime_seconds": 21617, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1198, "long_name": "Tiny Marmot", "next_hop": 88, "num": "0x18cadec5", "position": {"altitude": 1575, "latitude": 33.29979, "location_source": "LOC_INTERNAL", "longitude": -108.140597, "time_offset_sec": 1492}, "public_key_hex": "5f933caef089f8425197010302f81fe8ae9795ba7c1b9c15954f83c5ecfc08c6", "role": "CLIENT", "short_name": "TLO1", "snr": 12.0, "status": {"status": "no-gps"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1887, "long_name": "Old Adder", "next_hop": 19, "num": "0x18df5d74", "position": {"altitude": 1536, "latitude": 32.634082, "location_source": "LOC_INTERNAL", "longitude": -107.64938, "time_offset_sec": 2159}, "public_key_hex": "af11791b02186d7a048f8fa42201eea327fcd907318cdc3beff320b7206cbb69", "role": "CLIENT", "short_name": "OSS0", "snr": 11.63, "status": null, "telemetry": {"air_util_tx": 0.378, "battery_level": 17, "channel_utilization": 10.59, "uptime_seconds": 97954, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 3707, "long_name": "Dusk Hawk", "next_hop": 111, "num": "0x18ff8a54", "position": {"altitude": 1408, "latitude": 32.843784, "location_source": "LOC_INTERNAL", "longitude": -107.586698, "time_offset_sec": 3720}, "public_key_hex": "e9a1c4251dbcdac7b73f957e1ddb1237d4405cf381f935fe88df5a08c2e2bcc5", "role": "CLIENT", "short_name": "D3KY", "snr": 3.18, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.77, "iaq": 62, "relative_humidity": 71.44, "temperature": 15.42}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2889, "long_name": "Sharp Phoenix", "next_hop": 0, "num": "0x1929bc6f", "position": {"altitude": 1333, "latitude": 31.847644, "location_source": "LOC_INTERNAL", "longitude": -108.260119, "time_offset_sec": 2989}, "public_key_hex": "c55be14698458cf718b68ea14c02007221ea2bb38d6ef2b597e860be3978fb75", "role": "CLIENT", "short_name": "🔥", "snr": 9.34, "status": null, "telemetry": {"air_util_tx": 0.587, "battery_level": 73, "channel_utilization": 9.04, "uptime_seconds": 200910, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 832, "long_name": "Slow Eagle KX8YQ", "next_hop": 0, "num": "0x1932e428", "position": {"altitude": 1778, "latitude": 33.149198, "location_source": "LOC_INTERNAL", "longitude": -107.267673, "time_offset_sec": 951}, "public_key_hex": "439b2f157496b4ceeb1852910c8d2e589e90e7ff62e7a52aaf74cf109db78d61", "role": "CLIENT", "short_name": "S6I6", "snr": 7.98, "status": null, "telemetry": {"air_util_tx": 0.445, "battery_level": 64, "channel_utilization": 22.74, "uptime_seconds": 43587, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 8066, "long_name": "Quick Beaver", "next_hop": 47, "num": "0x19388b92", "position": {"altitude": 1363, "latitude": 33.350679, "location_source": "LOC_INTERNAL", "longitude": -108.088983, "time_offset_sec": 8299}, "public_key_hex": "3b53f5b680464f2d8c9c4289643f4a8bf89bfa0403c35084bc2e77d58166b2f5", "role": "CLIENT", "short_name": "Q6YU", "snr": 1.53, "status": null, "telemetry": {"air_util_tx": 0.267, "battery_level": 18, "channel_utilization": 6.26, "uptime_seconds": 302746, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 45, "long_name": "Short Oak", "next_hop": 0, "num": "0x1967c01a", "position": {"altitude": 799, "latitude": 31.861852, "location_source": "LOC_INTERNAL", "longitude": -107.278187, "time_offset_sec": 123}, "public_key_hex": "2b31c0a354b8053038296fb324ca0d793ab3883e40b353c8bce74f8b0463e271", "role": "CLIENT", "short_name": "S8SY", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.18, "battery_level": 45, "channel_utilization": 18.16, "uptime_seconds": 160951, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 15200, "long_name": "Iron Hare", "next_hop": 129, "num": "0x199687c6", "position": {"altitude": 1257, "latitude": 33.42397, "location_source": "LOC_INTERNAL", "longitude": -106.706687, "time_offset_sec": 15392}, "public_key_hex": "4f111b079f70ea779821a4438aa087cdd35a84dd013a0e8ce8c2b5fe3cfdc27e", "role": "CLIENT", "short_name": "I5WB", "snr": 11.4, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1012.37, "iaq": 50, "relative_humidity": 52.14, "temperature": 22.16}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7895, "long_name": "Soft Gecko", "next_hop": 0, "num": "0x199af3bd", "position": {"altitude": 1760, "latitude": 33.735951, "location_source": "LOC_INTERNAL", "longitude": -107.550925, "time_offset_sec": 8174}, "public_key_hex": "624c0e61dc4f23f6f84a50e8488fe36f65641740588ac276eefe06f6d282bfbb", "role": "CLIENT", "short_name": "S51G", "snr": 6.48, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 10926, "long_name": "Roving Coyote", "next_hop": 0, "num": "0x199e42a7", "position": null, "public_key_hex": "95d94bfb4e6abc3d7ed275cbc165b5a06f06f8e5012fff9f55844821eb6f72d1", "role": "CLIENT", "short_name": "RY49", "snr": 3.91, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.311, "battery_level": 11, "channel_utilization": 5.69, "uptime_seconds": 19933, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 7682, "long_name": "Lone Crow", "next_hop": 0, "num": "0x19ac76dc", "position": null, "public_key_hex": "82fcf8ceb7c9bc4d08b66413de87cecc0d388df820a35e72822d18df8eef3488", "role": "ROUTER", "short_name": "L1U9", "snr": 8.44, "status": null, "telemetry": {"air_util_tx": 1.493, "battery_level": 75, "channel_utilization": 6.09, "uptime_seconds": 23591, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2271, "long_name": "Burning Salmon", "next_hop": 0, "num": "0x19c05cc8", "position": null, "public_key_hex": "fb80a314819a6e79967e0db9e8a02a1f522d124fb75c66e733ec3868817ef7a6", "role": "CLIENT", "short_name": "BJ3P", "snr": 7.71, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.88, "iaq": 33, "relative_humidity": 50.84, "temperature": 24.65}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 2329, "long_name": "Wandering Hare", "next_hop": 0, "num": "0x19d9b8d2", "position": {"altitude": 1473, "latitude": 33.562437, "location_source": "LOC_INTERNAL", "longitude": -107.944789, "time_offset_sec": 2370}, "public_key_hex": "eaecd7fb9ab04851dae604042e98096005045beccb50b3f17d488a1c4d2aecb5", "role": "CLIENT", "short_name": "W6IP", "snr": 2.49, "status": null, "telemetry": {"air_util_tx": 0.16, "battery_level": 92, "channel_utilization": 2.42, "uptime_seconds": 19370, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.92, "iaq": 95, "relative_humidity": 40.13, "temperature": 19.79}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2730, "long_name": "Tiny Pike", "next_hop": 150, "num": "0x1a099268", "position": {"altitude": 1607, "latitude": 33.631881, "location_source": "LOC_INTERNAL", "longitude": -107.450015, "time_offset_sec": 2906}, "public_key_hex": "ef211021ec6461c6203a0027b5d427a30952d19f2ed86180fe187062eb0652d0", "role": "CLIENT", "short_name": "T4GH", "snr": 3.19, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8672, "long_name": "Blue Aspen", "next_hop": 0, "num": "0x1a20c284", "position": {"altitude": 1529, "latitude": 32.393923, "location_source": "LOC_INTERNAL", "longitude": -107.275505, "time_offset_sec": 8879}, "public_key_hex": "65f9fd3d3c72561806b4cece090e40ebbd6fcdd730474a1904bb4a81556eb8de", "role": "CLIENT", "short_name": "BFMJ", "snr": 6.49, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.27, "iaq": 66, "relative_humidity": 67.2, "temperature": 12.52}, "hops_away": 0, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 6130, "long_name": "Roving Salmon", "next_hop": 0, "num": "0x1a44e008", "position": {"altitude": 1576, "latitude": 32.445839, "location_source": "LOC_INTERNAL", "longitude": -107.118743, "time_offset_sec": 6398}, "public_key_hex": "10cf4344c26c382a0dcf1f422690160514eee226a7cc8c05fcfa9d5a5ce89797", "role": "CLIENT", "short_name": "RFXA", "snr": 5.8, "status": null, "telemetry": {"air_util_tx": 2.027, "battery_level": 56, "channel_utilization": 5.5, "uptime_seconds": 157108, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4686, "long_name": "Storm Raven", "next_hop": 202, "num": "0x1a478147", "position": {"altitude": 1304, "latitude": 33.768923, "location_source": "LOC_INTERNAL", "longitude": -107.652265, "time_offset_sec": 4873}, "public_key_hex": "03f7ff9c351da2dfe31d04990f7911aa97b1a5538002aacfb0d2f0b4e9e174f0", "role": "CLIENT", "short_name": "S31K", "snr": 6.62, "status": null, "telemetry": {"air_util_tx": 0.576, "battery_level": 78, "channel_utilization": 11.42, "uptime_seconds": 142105, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5557, "long_name": "Brave Pine", "next_hop": 24, "num": "0x1a75a7b9", "position": {"altitude": 1499, "latitude": 33.023376, "location_source": "LOC_INTERNAL", "longitude": -106.673452, "time_offset_sec": 5574}, "public_key_hex": "a8cf8112347537df8db85123dee8574e8dc58ac371c85f3a476450fb993d9c91", "role": "SENSOR", "short_name": "BVJQ", "snr": 5.78, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.616, "battery_level": 35, "channel_utilization": 12.34, "uptime_seconds": 7784, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.63, "iaq": 30, "relative_humidity": 60.66, "temperature": 25.18}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 517, "long_name": "Iron Eagle", "next_hop": 0, "num": "0x1a7b8120", "position": {"altitude": 1675, "latitude": 32.685788, "location_source": "LOC_INTERNAL", "longitude": -107.469785, "time_offset_sec": 579}, "public_key_hex": "8d993884da83e466d573c9dc445b7bf6ef76d612de2e20622bdc140af0054b35", "role": "TRACKER", "short_name": "IFJY", "snr": 3.48, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 11006, "long_name": "Found Lynx", "next_hop": 0, "num": "0x1a86279d", "position": {"altitude": 1275, "latitude": 32.969941, "location_source": "LOC_INTERNAL", "longitude": -107.889672, "time_offset_sec": 11301}, "public_key_hex": "0b235b1e0639aab8e6c64ee9388dc41a8561a84ba2098d51dbbd20638347dbcd", "role": "CLIENT", "short_name": "F1K5", "snr": 5.25, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.9, "iaq": 69, "relative_humidity": 46.14, "temperature": 23.94}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 8816, "long_name": "Sunny Owl", "next_hop": 223, "num": "0x1a8d3ffb", "position": null, "public_key_hex": "c8e5c98e01e0bfb1d8665f983a66f06bc7765f93fc402f1ed2777cdee2b2a926", "role": "CLIENT_MUTE", "short_name": "SU0Y", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.456, "battery_level": 21, "channel_utilization": 10.2, "uptime_seconds": 77890, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1028.54, "iaq": 27, "relative_humidity": 56.47, "temperature": 20.53}, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 375, "long_name": "White Seal", "next_hop": 149, "num": "0x1a8e7e41", "position": {"altitude": 1551, "latitude": 32.636914, "location_source": "LOC_INTERNAL", "longitude": -107.424905, "time_offset_sec": 548}, "public_key_hex": "6949e1b7481ced511d5f3fdb5398f2871af69554255c11ff8e7afc079c793e28", "role": "CLIENT", "short_name": "🔥", "snr": 8.76, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2677, "long_name": "Stone Cougar", "next_hop": 219, "num": "0x1a909238", "position": {"altitude": 997, "latitude": 32.942497, "location_source": "LOC_INTERNAL", "longitude": -107.593597, "time_offset_sec": 2726}, "public_key_hex": "d3157763f8480af64ea8204e9834c2ade6a1461a1cf7b3ee94be906bdf49a0b5", "role": "ROUTER", "short_name": "S0SD", "snr": 3.08, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.351, "battery_level": 95, "channel_utilization": 7.66, "uptime_seconds": 47558, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 4875, "long_name": "Lost Stag", "next_hop": 0, "num": "0x1a9ee986", "position": {"altitude": 1271, "latitude": 32.729139, "location_source": "LOC_INTERNAL", "longitude": -107.247451, "time_offset_sec": 5148}, "public_key_hex": "2c69a27b4cca1750f3a2b3f00a3e4b414a6f93d562f1250a330dc9c891645113", "role": "CLIENT", "short_name": "LH56", "snr": 2.21, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.77, "iaq": 66, "relative_humidity": 46.09, "temperature": 26.43}, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 921, "long_name": "Gold Raven", "next_hop": 0, "num": "0x1aaaff59", "position": {"altitude": 1286, "latitude": 33.066448, "location_source": "LOC_INTERNAL", "longitude": -107.822257, "time_offset_sec": 1157}, "public_key_hex": "b9969f9deaacb3664fe4995c450c3675514e7c2a0fc781dd14e4e6fa33cf3f34", "role": "CLIENT", "short_name": "GJDW", "snr": 4.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 370, "long_name": "Loud Crane", "next_hop": 0, "num": "0x1ab5cf2f", "position": {"altitude": 1861, "latitude": 33.064061, "location_source": "LOC_INTERNAL", "longitude": -107.102047, "time_offset_sec": 469}, "public_key_hex": "6a54fe61b894cf0e7850dece70d5cbcfcf2c2889ce7a5e750793f4b6f0137a08", "role": "CLIENT", "short_name": "LWJ5", "snr": 10.62, "status": null, "telemetry": {"air_util_tx": 0.764, "battery_level": 39, "channel_utilization": 14.01, "uptime_seconds": 1409, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.51, "iaq": 36, "relative_humidity": 93.43, "temperature": 26.21}, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 113, "long_name": "Old Owl", "next_hop": 0, "num": "0x1ac4b79b", "position": {"altitude": 1417, "latitude": 33.520632, "location_source": "LOC_INTERNAL", "longitude": -106.901308, "time_offset_sec": 334}, "public_key_hex": "0500bad6d2bcf0772f12e995c10478a09b1f371c3a417344bbad2044d3f7f02c", "role": "ROUTER", "short_name": "OYW3", "snr": 5.34, "status": null, "telemetry": {"air_util_tx": 0.844, "battery_level": 72, "channel_utilization": 33.22, "uptime_seconds": 12758, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2690, "long_name": "Storm Elk", "next_hop": 0, "num": "0x1ad42196", "position": {"altitude": 1272, "latitude": 32.350513, "location_source": "LOC_INTERNAL", "longitude": -107.022143, "time_offset_sec": 2902}, "public_key_hex": "82a9e6a27c5f8f56fcb8f0e1677216da3d02d593567acb5769be4741d8869176", "role": "CLIENT", "short_name": "SHE1", "snr": 2.94, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.44, "iaq": 69, "relative_humidity": 87.95, "temperature": 17.66}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2382, "long_name": "Floating Cobra", "next_hop": 7, "num": "0x1ae9e1dd", "position": {"altitude": 1472, "latitude": 33.414178, "location_source": "LOC_INTERNAL", "longitude": -107.644431, "time_offset_sec": 2438}, "public_key_hex": "2916a8ddb07b80a9e7ea97b0de9af2b26eb41fd0f8676e35867ec33b928260c3", "role": "CLIENT", "short_name": "FHHO", "snr": 4.75, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 10043, "long_name": "Drowsy Heron AB5SK", "next_hop": 0, "num": "0x1afb54fb", "position": {"altitude": 1472, "latitude": 32.90285, "location_source": "LOC_INTERNAL", "longitude": -106.324953, "time_offset_sec": 10191}, "public_key_hex": "8b5f1d112f78967b71b51527c69f080e166ede381b08d5fafeb661f325268cf2", "role": "CLIENT", "short_name": "D95H", "snr": -4.22, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.44, "iaq": 31, "relative_humidity": 38.2, "temperature": 19.55}, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5987, "long_name": "Giant Arroyo", "next_hop": 215, "num": "0x1afd0405", "position": {"altitude": 777, "latitude": 32.299561, "location_source": "LOC_INTERNAL", "longitude": -108.073661, "time_offset_sec": 6065}, "public_key_hex": "0e81ec1da19a4970d7f5ccf6847fc8980be55aa9b675fdc4609782b3b3bedca0", "role": "CLIENT", "short_name": "G8BN", "snr": 9.1, "status": null, "telemetry": {"air_util_tx": 1.311, "battery_level": 74, "channel_utilization": 3.9, "uptime_seconds": 161191, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 3325, "long_name": "Lunar Cedar", "next_hop": 0, "num": "0x1b2535fa", "position": {"altitude": 1252, "latitude": 33.738027, "location_source": "LOC_INTERNAL", "longitude": -107.076618, "time_offset_sec": 3607}, "public_key_hex": "6bd5508ba4eb7ac4b5408c3f77a248fe82fadf9bca1d40235efba072a9e04fd8", "role": "CLIENT", "short_name": "L0HJ", "snr": 10.06, "status": null, "telemetry": {"air_util_tx": 0.674, "battery_level": 79, "channel_utilization": 5.97, "uptime_seconds": 74092, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7351, "long_name": "Sneaky Bluff", "next_hop": 140, "num": "0x1b35ecf4", "position": {"altitude": 771, "latitude": 34.06657, "location_source": "LOC_INTERNAL", "longitude": -107.012352, "time_offset_sec": 7406}, "public_key_hex": "d618f33d96b9af2303bbfba4c61a58dde413da9d5fdddabd2a09d5e01c80017a", "role": "CLIENT", "short_name": "S7MB", "snr": 11.62, "status": null, "telemetry": {"air_util_tx": 0.234, "battery_level": 62, "channel_utilization": 5.28, "uptime_seconds": 114012, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 109, "long_name": "Storm Cedar", "next_hop": 231, "num": "0x1b408c80", "position": {"altitude": 991, "latitude": 33.827378, "location_source": "LOC_INTERNAL", "longitude": -106.36929, "time_offset_sec": 191}, "public_key_hex": "9cee822b76ccb3a53d82862323698da460cee6fd509db54e5b13317c43eb6996", "role": "TRACKER", "short_name": "SJUQ", "snr": -2.47, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2881, "long_name": "Lone Mustang KX2RP", "next_hop": 173, "num": "0x1b4a5ed7", "position": {"altitude": 1068, "latitude": 32.842655, "location_source": "LOC_INTERNAL", "longitude": -107.408944, "time_offset_sec": 3069}, "public_key_hex": "", "role": "CLIENT", "short_name": "LZZW", "snr": 2.75, "status": null, "telemetry": {"air_util_tx": 0.696, "battery_level": 96, "channel_utilization": 15.84, "uptime_seconds": 47945, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 10836, "long_name": "Blue Seal", "next_hop": 0, "num": "0x1b4ade6c", "position": {"altitude": 1480, "latitude": 32.315957, "location_source": "LOC_INTERNAL", "longitude": -107.755843, "time_offset_sec": 11059}, "public_key_hex": "6c2f62367bdd3b785caeb9674552b1ad6a5b1978a1710c226ff3306f9940f9c2", "role": "CLIENT", "short_name": "BR6E", "snr": 3.74, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1588, "long_name": "White Moose", "next_hop": 0, "num": "0x1b52eac0", "position": {"altitude": 1388, "latitude": 32.462339, "location_source": "LOC_INTERNAL", "longitude": -107.491796, "time_offset_sec": 1815}, "public_key_hex": "69c421a99deff4968d7e3118ae96a443f248389f526d8b9e540270d5680349b6", "role": "CLIENT", "short_name": "W2XQ", "snr": 3.13, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 576, "long_name": "Bright Ridge", "next_hop": 0, "num": "0x1b55da67", "position": {"altitude": 1365, "latitude": 33.448976, "location_source": "LOC_INTERNAL", "longitude": -107.586916, "time_offset_sec": 764}, "public_key_hex": "75ec0da25ab7ae778192d612fe1a7f378c85acc86c445dbcd096a624ab982f77", "role": "CLIENT", "short_name": "B3X4", "snr": 4.38, "status": null, "telemetry": {"air_util_tx": 0.608, "battery_level": 42, "channel_utilization": 3.36, "uptime_seconds": 4539, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.58, "iaq": 48, "relative_humidity": 31.44, "temperature": 25.98}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7156, "long_name": "Happy Cougar", "next_hop": 0, "num": "0x1b56406c", "position": {"altitude": 1297, "latitude": 32.920077, "location_source": "LOC_INTERNAL", "longitude": -108.844416, "time_offset_sec": 7266}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦇", "snr": 1.69, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 6377, "long_name": "Silver Sage AB2UO", "next_hop": 0, "num": "0x1b5adc4e", "position": {"altitude": 655, "latitude": 32.935298, "location_source": "LOC_INTERNAL", "longitude": -107.307034, "time_offset_sec": 6442}, "public_key_hex": "3e147cf5cf9dc7e6a4073fb9dc1dbc17c318f0e5438c7f032d848526546c53af", "role": "CLIENT", "short_name": "ST7R", "snr": 5.34, "status": null, "telemetry": {"air_util_tx": 0.095, "battery_level": 67, "channel_utilization": 4.13, "uptime_seconds": 36082, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 267, "long_name": "New Heron", "next_hop": 0, "num": "0x1b74162f", "position": {"altitude": 1583, "latitude": 33.389658, "location_source": "LOC_INTERNAL", "longitude": -107.112459, "time_offset_sec": 368}, "public_key_hex": "a82554194a2f04a99fa68d1783d4a63577d11065a93a4e382873c6322af598a7", "role": "CLIENT", "short_name": "NTGK", "snr": 7.38, "status": null, "telemetry": {"air_util_tx": 0.564, "battery_level": 66, "channel_utilization": 10.0, "uptime_seconds": 28963, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1824, "long_name": "Found Fox", "next_hop": 0, "num": "0x1b9f4861", "position": {"altitude": 1301, "latitude": 34.086311, "location_source": "LOC_INTERNAL", "longitude": -107.508674, "time_offset_sec": 1939}, "public_key_hex": "8919a63901462ce279e12067455b03ebc72367995ae4047cf223b0babb7b197c", "role": "CLIENT", "short_name": "FYMF", "snr": 7.56, "status": null, "telemetry": {"air_util_tx": 1.06, "battery_level": 98, "channel_utilization": 9.5, "uptime_seconds": 348097, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2554, "long_name": "Sunny Pony", "next_hop": 121, "num": "0x1ba5567f", "position": {"altitude": 1809, "latitude": 33.691891, "location_source": "LOC_INTERNAL", "longitude": -108.222388, "time_offset_sec": 2700}, "public_key_hex": "380a0a5caa48fbff8cbdcbc421e95e327c79b4f1695f21d2ca1bfb374caebbf6", "role": "CLIENT", "short_name": "SSYS", "snr": 6.02, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.5, "battery_level": 39, "channel_utilization": 10.98, "uptime_seconds": 201142, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 193, "long_name": "Sleepy Pike", "next_hop": 0, "num": "0x1baa0ca2", "position": {"altitude": 1271, "latitude": 33.960214, "location_source": "LOC_INTERNAL", "longitude": -106.077235, "time_offset_sec": 486}, "public_key_hex": "a9aaedfd8953a30f5e75cb8ca797df7a346f91e95aad97bb90399e5967ca975a", "role": "CLIENT", "short_name": "SXP8", "snr": 6.67, "status": null, "telemetry": {"air_util_tx": 0.392, "battery_level": 47, "channel_utilization": 11.56, "uptime_seconds": 11262, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 12830, "long_name": "Smooth Crane", "next_hop": 185, "num": "0x1bc1fce7", "position": {"altitude": 1553, "latitude": 33.459008, "location_source": "LOC_INTERNAL", "longitude": -107.513156, "time_offset_sec": 12892}, "public_key_hex": "fc4372a7cb38c54d21cda54d5dba1655b8549d47d421d79b48aae248ffbaded7", "role": "CLIENT", "short_name": "SB9Y", "snr": 3.92, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4680, "long_name": "Giant Sage", "next_hop": 205, "num": "0x1bc642b3", "position": {"altitude": 1092, "latitude": 33.820607, "location_source": "LOC_INTERNAL", "longitude": -107.291019, "time_offset_sec": 4979}, "public_key_hex": "9762f97768d7ce6ea8d161e5ad66fc4f6d25f38ac7e8b3bb33731e5db0a3a74f", "role": "CLIENT", "short_name": "G557", "snr": 4.81, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 5106, "long_name": "Howling Seal", "next_hop": 0, "num": "0x1bc9fb03", "position": null, "public_key_hex": "9905ea0c9d153415514fc34caa6195e0cde2f99ea64f686d64beb798738c0a0c", "role": "ROUTER", "short_name": "HUUL", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.299, "battery_level": 101, "channel_utilization": 7.44, "uptime_seconds": 134326, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2050, "long_name": "Dusk Beaver KQ6WP", "next_hop": 97, "num": "0x1bcbc7de", "position": {"altitude": 1477, "latitude": 33.889655, "location_source": "LOC_INTERNAL", "longitude": -107.232945, "time_offset_sec": 2115}, "public_key_hex": "", "role": "CLIENT", "short_name": "D2HE", "snr": 10.56, "status": null, "telemetry": {"air_util_tx": 1.455, "battery_level": 27, "channel_utilization": 20.25, "uptime_seconds": 8620, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 4410, "long_name": "Old Crane", "next_hop": 161, "num": "0x1bccc3d5", "position": {"altitude": 1539, "latitude": 32.933284, "location_source": "LOC_INTERNAL", "longitude": -106.906065, "time_offset_sec": 4597}, "public_key_hex": "85faaaa76e7014cfd1c83ee58fe623a8986fb01be0c1c927101c5dd79d64ef2e", "role": "CLIENT", "short_name": "OKBV", "snr": 8.09, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.594, "battery_level": 21, "channel_utilization": 8.81, "uptime_seconds": 10871, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 6208, "long_name": "Lunar Moose", "next_hop": 213, "num": "0x1be7820c", "position": {"altitude": 1591, "latitude": 33.792759, "location_source": "LOC_INTERNAL", "longitude": -107.787122, "time_offset_sec": 6295}, "public_key_hex": "20ec933370b0faa4f1205b1b681bed1fa8527698f2c288e0c806926b3640f79a", "role": "LOST_AND_FOUND", "short_name": "L883", "snr": 8.2, "status": null, "telemetry": {"air_util_tx": 0.88, "battery_level": 91, "channel_utilization": 17.22, "uptime_seconds": 62729, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 4527, "long_name": "Lost Trout", "next_hop": 220, "num": "0x1bec2253", "position": {"altitude": 991, "latitude": 33.252125, "location_source": "LOC_INTERNAL", "longitude": -106.822616, "time_offset_sec": 4755}, "public_key_hex": "427c7cabe4ef69d176f82654effa380ea4d2e38c7e5445730491a1ded9fb5219", "role": "CLIENT", "short_name": "LF3R", "snr": 9.18, "status": null, "telemetry": {"air_util_tx": 0.753, "battery_level": 83, "channel_utilization": 5.54, "uptime_seconds": 18430, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2804, "long_name": "Stone Viper", "next_hop": 0, "num": "0x1bf8e4fa", "position": {"altitude": 1356, "latitude": 33.447484, "location_source": "LOC_INTERNAL", "longitude": -106.521461, "time_offset_sec": 2847}, "public_key_hex": "aef201021043315add921877e683cfa3a88b4f7d0bc82fc127f776e2682952a4", "role": "CLIENT", "short_name": "S0PY", "snr": 9.11, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 818, "long_name": "Dawn Cougar", "next_hop": 14, "num": "0x1bfbe00d", "position": {"altitude": 1380, "latitude": 32.103532, "location_source": "LOC_INTERNAL", "longitude": -106.349279, "time_offset_sec": 1066}, "public_key_hex": "ebce4228ecb7063fb94163aa57dff7a11ee91c14d1a0c8e125b44862cfda7fad", "role": "CLIENT_BASE", "short_name": "DKDC", "snr": 1.85, "status": null, "telemetry": {"air_util_tx": 0.756, "battery_level": 52, "channel_utilization": 8.2, "uptime_seconds": 17743, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 221, "long_name": "Misty Trout", "next_hop": 0, "num": "0x1bfe0abc", "position": {"altitude": 1043, "latitude": 33.56527, "location_source": "LOC_INTERNAL", "longitude": -106.969784, "time_offset_sec": 234}, "public_key_hex": "125b07f740907c3f34941b56cbc5281b97f236b73c2d9e421941a9f0a0ab1eb7", "role": "CLIENT", "short_name": "MQG1", "snr": 6.7, "status": null, "telemetry": {"air_util_tx": 0.209, "battery_level": 88, "channel_utilization": 11.14, "uptime_seconds": 115747, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2543, "long_name": "Rough Bluff", "next_hop": 31, "num": "0x1bff0aeb", "position": {"altitude": 1337, "latitude": 33.438194, "location_source": "LOC_INTERNAL", "longitude": -107.199187, "time_offset_sec": 2753}, "public_key_hex": "3881606512194c15b9193513062606fb32da24223b4f6bb8dff33507ff731aae", "role": "ROUTER", "short_name": "RZ6S", "snr": 3.02, "status": null, "telemetry": {"air_util_tx": 0.792, "battery_level": 55, "channel_utilization": 4.1, "uptime_seconds": 133066, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.61, "iaq": 38, "relative_humidity": 63.88, "temperature": 19.29}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4706, "long_name": "Whispering Hare", "next_hop": 165, "num": "0x1c30e825", "position": {"altitude": 1035, "latitude": 33.47933, "location_source": "LOC_INTERNAL", "longitude": -107.627769, "time_offset_sec": 4898}, "public_key_hex": "fe1f980f0e1d9c47e3c6150f6a13b6f5ad6fa585da69202d3131eebe0109b968", "role": "CLIENT", "short_name": "W0VS", "snr": 9.71, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1503, "long_name": "Quick Pine", "next_hop": 0, "num": "0x1c4c3aa4", "position": {"altitude": 1355, "latitude": 32.785795, "location_source": "LOC_INTERNAL", "longitude": -108.697156, "time_offset_sec": 1549}, "public_key_hex": "ea5da222a1a6eef5caa6ac1a995d58d433d5f375c96d62f1ba00c955d6a82dc3", "role": "CLIENT", "short_name": "QECZ", "snr": 6.1, "status": null, "telemetry": {"air_util_tx": 0.089, "battery_level": 70, "channel_utilization": 16.84, "uptime_seconds": 67047, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3934, "long_name": "Desert Raven", "next_hop": 0, "num": "0x1c4c4169", "position": {"altitude": 1333, "latitude": 33.126223, "location_source": "LOC_INTERNAL", "longitude": -107.466216, "time_offset_sec": 4001}, "public_key_hex": "e8b469c3642d099cc37e1f7941b05d236e15be2e0d0dee766f8946275c46eb13", "role": "CLIENT", "short_name": "DV2I", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.361, "battery_level": 21, "channel_utilization": 21.77, "uptime_seconds": 92958, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.58, "iaq": 76, "relative_humidity": 25.65, "temperature": 21.55}, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 4161, "long_name": "Loud Beaver", "next_hop": 90, "num": "0x1c4e2c56", "position": null, "public_key_hex": "fda7215ddeede89fa6ccb162a36838d315c37ca09e484e60f9f1b8c1fe2b62cc", "role": "CLIENT", "short_name": "LCFO", "snr": 7.32, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.078, "battery_level": 14, "channel_utilization": 9.27, "uptime_seconds": 147242, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 2306, "long_name": "Tall Cobra", "next_hop": 82, "num": "0x1c557410", "position": {"altitude": 1255, "latitude": 33.41744, "location_source": "LOC_INTERNAL", "longitude": -106.72439, "time_offset_sec": 2418}, "public_key_hex": "7376f6faa287f920253b58ba6f58e48bb166f699fee957a968eda4233bd5759e", "role": "CLIENT", "short_name": "TGN8", "snr": 9.38, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 183, "long_name": "Burning Gecko", "next_hop": 0, "num": "0x1c88c6d6", "position": {"altitude": 1988, "latitude": 32.919293, "location_source": "LOC_INTERNAL", "longitude": -107.699731, "time_offset_sec": 233}, "public_key_hex": "52ce66a23634a073c9ae2ca7cae8ea98a258c111211dd31eb91ecaaa9bc86ead", "role": "CLIENT", "short_name": "BFKE", "snr": 7.99, "status": null, "telemetry": {"air_util_tx": 0.366, "battery_level": 11, "channel_utilization": 18.54, "uptime_seconds": 196989, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.96, "iaq": 16, "relative_humidity": 66.41, "temperature": 32.18}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4559, "long_name": "Frosty Dolphin", "next_hop": 0, "num": "0x1cb12207", "position": null, "public_key_hex": "27e216d6ff925ea08ff4783c3fe605854a020817429a385507435334f0cd5f6a", "role": "CLIENT", "short_name": "FF2O", "snr": 2.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.54, "iaq": 64, "relative_humidity": 50.91, "temperature": 12.19}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3904, "long_name": "Wandering Squirrel", "next_hop": 123, "num": "0x1cb3842b", "position": {"altitude": 1796, "latitude": 32.889652, "location_source": "LOC_INTERNAL", "longitude": -108.173727, "time_offset_sec": 4054}, "public_key_hex": "51282548538a59a6b82dbc9f4b6b9f4872fe9365f2a19dcc66abd46facabaa4c", "role": "CLIENT", "short_name": "WCCY", "snr": -1.2, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.81, "iaq": 16, "relative_humidity": 30.21, "temperature": 28.35}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4750, "long_name": "Quick Eagle", "next_hop": 0, "num": "0x1cd893ad", "position": {"altitude": 1658, "latitude": 32.533711, "location_source": "LOC_INTERNAL", "longitude": -107.473181, "time_offset_sec": 4919}, "public_key_hex": "0a06a4e334195880e7e73e47fff94661bfc678b7e86cc99d4db00516862221e3", "role": "CLIENT", "short_name": "QRM7", "snr": 7.21, "status": null, "telemetry": {"air_util_tx": 1.582, "battery_level": 16, "channel_utilization": 10.51, "uptime_seconds": 31719, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1505, "long_name": "Iron Bluff", "next_hop": 0, "num": "0x1cdaca7a", "position": {"altitude": 1207, "latitude": 33.290755, "location_source": "LOC_INTERNAL", "longitude": -107.9424, "time_offset_sec": 1671}, "public_key_hex": "9b1677e8d5b62778a3a3d3d5a73a4d1fde59146936ae510e7c7ec59c80fa0fde", "role": "ROUTER", "short_name": "IZ24", "snr": 5.81, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2312, "long_name": "Drifting Cactus", "next_hop": 223, "num": "0x1ce4c7c4", "position": {"altitude": 1084, "latitude": 33.223352, "location_source": "LOC_INTERNAL", "longitude": -107.488669, "time_offset_sec": 2336}, "public_key_hex": "fb4fad42c082276ba1ef39c9c06bb18240a108a068e918477af2b73a2df001fc", "role": "CLIENT_BASE", "short_name": "D5PM", "snr": 5.89, "status": null, "telemetry": {"air_util_tx": 0.88, "battery_level": 40, "channel_utilization": 16.79, "uptime_seconds": 60041, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3877, "long_name": "Forest Marmot", "next_hop": 211, "num": "0x1ce53673", "position": {"altitude": 913, "latitude": 34.126853, "location_source": "LOC_INTERNAL", "longitude": -107.874221, "time_offset_sec": 4003}, "public_key_hex": "cad0b172262ef4cbf1bf39b76477acbb880a392434fddb29beb745468c4783b2", "role": "CLIENT", "short_name": "F40V", "snr": 1.77, "status": null, "telemetry": {"air_util_tx": 0.598, "battery_level": 12, "channel_utilization": 9.66, "uptime_seconds": 22102, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 3406, "long_name": "Giant Crow", "next_hop": 0, "num": "0x1cec753d", "position": {"altitude": 1477, "latitude": 32.863344, "location_source": "LOC_INTERNAL", "longitude": -107.423052, "time_offset_sec": 3657}, "public_key_hex": "ec839e71f6388c2cde3aa8d1eb49195ff757e62ca20ab2471e1dceb39944806e", "role": "ROUTER_LATE", "short_name": "GDSP", "snr": 7.52, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.274, "battery_level": 18, "channel_utilization": 18.06, "uptime_seconds": 40698, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7771, "long_name": "Drifting Bison", "next_hop": 0, "num": "0x1cf1349c", "position": {"altitude": 1571, "latitude": 33.250153, "location_source": "LOC_INTERNAL", "longitude": -106.966066, "time_offset_sec": 7845}, "public_key_hex": "10eb2565c309d37ab00d0dd859d7840379269c4b63dcdfc69221a5ea38a397b8", "role": "CLIENT", "short_name": "DVXI", "snr": 3.54, "status": null, "telemetry": {"air_util_tx": 1.749, "battery_level": 93, "channel_utilization": 13.9, "uptime_seconds": 126897, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3562, "long_name": "Fast Wolf", "next_hop": 0, "num": "0x1d0eadac", "position": null, "public_key_hex": "d1018eee27018b5242aa92a2ce9f98a2bc35a1b13635eb799d963d0b618c5343", "role": "CLIENT", "short_name": "FK7U", "snr": 9.78, "status": null, "telemetry": {"air_util_tx": 0.323, "battery_level": 21, "channel_utilization": 8.12, "uptime_seconds": 15979, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1027.45, "iaq": 15, "relative_humidity": 48.58, "temperature": 12.49}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2202, "long_name": "Red Pike", "next_hop": 243, "num": "0x1d1372ab", "position": {"altitude": 980, "latitude": 33.583777, "location_source": "LOC_INTERNAL", "longitude": -108.077394, "time_offset_sec": 2312}, "public_key_hex": "4bae302da17d438f24f76078bc5d1a83269a6cbade6c355bf9f627eb47ac8f25", "role": "CLIENT_MUTE", "short_name": "RX7C", "snr": 1.31, "status": null, "telemetry": {"air_util_tx": 1.299, "battery_level": 18, "channel_utilization": 5.38, "uptime_seconds": 12376, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 784, "long_name": "Fast Bison", "next_hop": 223, "num": "0x1d3d952e", "position": {"altitude": 1518, "latitude": 33.545877, "location_source": "LOC_INTERNAL", "longitude": -107.860367, "time_offset_sec": 1014}, "public_key_hex": "", "role": "ROUTER", "short_name": "F38Y", "snr": 9.13, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.569, "battery_level": 65, "channel_utilization": 9.31, "uptime_seconds": 10891, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1030.88, "iaq": 24, "relative_humidity": 77.83, "temperature": 25.53}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2312, "long_name": "Frozen Wolf", "next_hop": 54, "num": "0x1d47c770", "position": {"altitude": 1500, "latitude": 33.421756, "location_source": "LOC_INTERNAL", "longitude": -107.216128, "time_offset_sec": 2589}, "public_key_hex": "e6c4a28009f476accd16a4ef8dc579f42a60f05323a2ec6ca9890daa5ba34a4d", "role": "CLIENT", "short_name": "FBKN", "snr": 6.69, "status": null, "telemetry": {"air_util_tx": 1.732, "battery_level": 65, "channel_utilization": 8.34, "uptime_seconds": 147206, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 737, "long_name": "Bright Beaver", "next_hop": 232, "num": "0x1d536c76", "position": {"altitude": 1318, "latitude": 33.382767, "location_source": "LOC_INTERNAL", "longitude": -106.512484, "time_offset_sec": 876}, "public_key_hex": "a8bfa0d348c8c7df532d32f4badec9b3bb77fdf8625db3d71d3abe47c9f213eb", "role": "CLIENT", "short_name": "B0UH", "snr": 8.14, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 514, "long_name": "Floating Iguana", "next_hop": 0, "num": "0x1d54921f", "position": {"altitude": 1182, "latitude": 32.895644, "location_source": "LOC_INTERNAL", "longitude": -107.560626, "time_offset_sec": 620}, "public_key_hex": "c4369b12c2491a6c7ee77daffdf62d4446a847c080308c010d0d7151c2470b8d", "role": "CLIENT", "short_name": "FW6S", "snr": 12.0, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 1.411, "battery_level": 62, "channel_utilization": 1.16, "uptime_seconds": 21358, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.7, "iaq": 6, "relative_humidity": 68.74, "temperature": 28.36}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7061, "long_name": "New Phoenix", "next_hop": 0, "num": "0x1d5ea5e2", "position": null, "public_key_hex": "54417ed1875e8c83a9a392092e5153552ce628532bdcdd1562bdd9bd066181e0", "role": "CLIENT", "short_name": "NH2S", "snr": 0.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 19610, "long_name": "Storm Lynx", "next_hop": 147, "num": "0x1d88d375", "position": {"altitude": 1323, "latitude": 34.182109, "location_source": "LOC_INTERNAL", "longitude": -106.594716, "time_offset_sec": 19848}, "public_key_hex": "5843683e87f97622206438050e979c386e63aa75a7031b20c48c05381d1496d5", "role": "CLIENT", "short_name": "S2Y6", "snr": 8.02, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 650, "long_name": "Sleepy Phoenix", "next_hop": 0, "num": "0x1da3f5ac", "position": null, "public_key_hex": "fd3e1e1ddac861f441017803952615e0f5b4e22281d21d5e13bdb7a7de5536ab", "role": "CLIENT", "short_name": "SNFA", "snr": 0.66, "status": null, "telemetry": {"air_util_tx": 0.579, "battery_level": 100, "channel_utilization": 16.97, "uptime_seconds": 183185, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 4528, "long_name": "Canyon Bronco", "next_hop": 0, "num": "0x1dc748a1", "position": {"altitude": 1476, "latitude": 32.817199, "location_source": "LOC_INTERNAL", "longitude": -107.242516, "time_offset_sec": 4616}, "public_key_hex": "86e48fd7b5de00a4f5f48daac98a3248ced586cfec523b8067b0e952ea04c9c3", "role": "CLIENT", "short_name": "CYUQ", "snr": 6.79, "status": null, "telemetry": {"air_util_tx": 0.297, "battery_level": 19, "channel_utilization": 9.04, "uptime_seconds": 13257, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1820, "long_name": "Howling Gecko", "next_hop": 0, "num": "0x1dc9e849", "position": {"altitude": 1528, "latitude": 33.88015, "location_source": "LOC_INTERNAL", "longitude": -107.595131, "time_offset_sec": 1943}, "public_key_hex": "baae55b5b486683f4a4c77ccf44dc7778aebdafef117aa32d6006c2b10f019c0", "role": "CLIENT", "short_name": "HO7Z", "snr": 2.74, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.289, "battery_level": 27, "channel_utilization": 10.3, "uptime_seconds": 104480, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.27, "iaq": 66, "relative_humidity": 28.11, "temperature": 14.99}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2674, "long_name": "Wandering Colt", "next_hop": 0, "num": "0x1debe15c", "position": {"altitude": 973, "latitude": 33.545196, "location_source": "LOC_INTERNAL", "longitude": -107.532121, "time_offset_sec": 2946}, "public_key_hex": "1914f634e7c750d139941c2ebfec58a82032882cc4d50a979cadcb8707c1e69c", "role": "CLIENT", "short_name": "W7FA", "snr": 6.1, "status": null, "telemetry": {"air_util_tx": 0.352, "battery_level": 97, "channel_utilization": 12.9, "uptime_seconds": 17956, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 1823, "long_name": "Wild Fox", "next_hop": 17, "num": "0x1ded5c8d", "position": {"altitude": 1513, "latitude": 32.722063, "location_source": "LOC_INTERNAL", "longitude": -106.68341, "time_offset_sec": 1999}, "public_key_hex": "db1b4ae3bde331d6390b32894576397019a2d4ffe11530fa469db67993f2e9c3", "role": "CLIENT", "short_name": "WMK5", "snr": 3.89, "status": null, "telemetry": {"air_util_tx": 0.163, "battery_level": 78, "channel_utilization": 16.42, "uptime_seconds": 39956, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1014.73, "iaq": 74, "relative_humidity": 60.46, "temperature": 29.72}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 10805, "long_name": "Burning Lynx", "next_hop": 163, "num": "0x1e074cd2", "position": {"altitude": 1424, "latitude": 33.968532, "location_source": "LOC_INTERNAL", "longitude": -106.847197, "time_offset_sec": 10812}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦋", "snr": 8.92, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.364, "battery_level": 98, "channel_utilization": 3.41, "uptime_seconds": 28634, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.17, "iaq": 55, "relative_humidity": 79.67, "temperature": 29.43}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3515, "long_name": "River Bass", "next_hop": 0, "num": "0x1e38af61", "position": {"altitude": 1174, "latitude": 33.568132, "location_source": "LOC_INTERNAL", "longitude": -107.525909, "time_offset_sec": 3545}, "public_key_hex": "de923486c197f1a092e0272b83756c0b69cbfff4e812e143e4610f3648e8d284", "role": "CLIENT", "short_name": "RMGR", "snr": 1.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 2482, "long_name": "Found Lynx", "next_hop": 59, "num": "0x1e397b95", "position": {"altitude": 1404, "latitude": 33.427663, "location_source": "LOC_INTERNAL", "longitude": -107.044636, "time_offset_sec": 2619}, "public_key_hex": "64fdcce74f271b9e0c2bb97d2f3f27c24e386eef5dabde8fe674d7e22feac1db", "role": "CLIENT", "short_name": "FNZI", "snr": 0.07, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.303, "battery_level": 59, "channel_utilization": 4.23, "uptime_seconds": 264868, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1694, "long_name": "Solar Ridge", "next_hop": 0, "num": "0x1e474bf5", "position": {"altitude": 1145, "latitude": 33.075633, "location_source": "LOC_INTERNAL", "longitude": -107.838453, "time_offset_sec": 1965}, "public_key_hex": "07dbb0dc0c29906a913a5b972b54b54a81d4071c70b9dbe1c31ffc0de191579f", "role": "SENSOR", "short_name": "S0JA", "snr": 3.1, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.089, "battery_level": 70, "channel_utilization": 11.19, "uptime_seconds": 47552, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1848, "long_name": "Sleepy Cactus", "next_hop": 241, "num": "0x1e640334", "position": {"altitude": 1386, "latitude": 33.284993, "location_source": "LOC_INTERNAL", "longitude": -107.284293, "time_offset_sec": 1866}, "public_key_hex": "8c49af5347e9b62afa7f332f5d73cbb47c11ce3560b59f5929d9af5abcc3ed9b", "role": "CLIENT_HIDDEN", "short_name": "SKPV", "snr": 11.25, "status": null, "telemetry": {"air_util_tx": 0.878, "battery_level": 58, "channel_utilization": 15.59, "uptime_seconds": 153906, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5999, "long_name": "River Dolphin", "next_hop": 0, "num": "0x1e673be1", "position": {"altitude": 1306, "latitude": 33.297148, "location_source": "LOC_INTERNAL", "longitude": -107.327441, "time_offset_sec": 6049}, "public_key_hex": "fd60c98ee8a0a4cf4fe2499c49194ce0ce83063e150ae1c58be29b6007b7c0e0", "role": "TRACKER", "short_name": "RAKZ", "snr": 8.33, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2801, "long_name": "Steel Oak", "next_hop": 0, "num": "0x1e751572", "position": null, "public_key_hex": "eaefe878597ef8f7b6c0c70fa44dd21218d7adddeeb5d0502b7c59ea5b932139", "role": "CLIENT", "short_name": "SSTM", "snr": -2.71, "status": null, "telemetry": {"air_util_tx": 0.222, "battery_level": 93, "channel_utilization": 10.39, "uptime_seconds": 120286, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 58, "long_name": "Floating Aspen", "next_hop": 0, "num": "0x1ece5457", "position": {"altitude": 1127, "latitude": 32.838246, "location_source": "LOC_INTERNAL", "longitude": -107.319395, "time_offset_sec": 296}, "public_key_hex": "f0cbbabdf84723479caa7406cbaa1e1778fff281e72ab74bc6b4c538c7705f88", "role": "CLIENT", "short_name": "FTNF", "snr": 0.78, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.288, "battery_level": 17, "channel_utilization": 2.37, "uptime_seconds": 32703, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 660, "long_name": "Desert Sage", "next_hop": 0, "num": "0x1ede1896", "position": null, "public_key_hex": "", "role": "TRACKER", "short_name": "DKKL", "snr": 6.2, "status": null, "telemetry": {"air_util_tx": 1.555, "battery_level": 97, "channel_utilization": 19.08, "uptime_seconds": 143525, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 30831, "long_name": "Slow Lion", "next_hop": 183, "num": "0x1ee7654c", "position": {"altitude": 1556, "latitude": 33.154066, "location_source": "LOC_INTERNAL", "longitude": -106.845758, "time_offset_sec": 30855}, "public_key_hex": "eed47b925f1401f0d9a2ad3eaf83d4eafcd2026a2bcfdc8f44a414649bcd537d", "role": "CLIENT_MUTE", "short_name": "SSEV", "snr": 3.98, "status": null, "telemetry": {"air_util_tx": 0.874, "battery_level": 94, "channel_utilization": 13.92, "uptime_seconds": 90584, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.78, "iaq": 49, "relative_humidity": 32.57, "temperature": 6.51}, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 11971, "long_name": "Smooth Crow", "next_hop": 232, "num": "0x1eeeb1df", "position": {"altitude": 1643, "latitude": 33.198489, "location_source": "LOC_INTERNAL", "longitude": -107.836752, "time_offset_sec": 12100}, "public_key_hex": "a9845c1fc9ced37abbdcb36efa279fb4ed700c845a4421d4e5c7be94e7ac6219", "role": "CLIENT", "short_name": "SL91", "snr": 7.87, "status": null, "telemetry": {"air_util_tx": 0.387, "battery_level": 82, "channel_utilization": 22.12, "uptime_seconds": 20350, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 5062, "long_name": "Gold Colt", "next_hop": 18, "num": "0x1eef8184", "position": {"altitude": 1243, "latitude": 33.48399, "location_source": "LOC_INTERNAL", "longitude": -107.935233, "time_offset_sec": 5241}, "public_key_hex": "5153124830a7963b44d47493057fa92f6fab7af1ab60c06ac203c6440be6a90e", "role": "CLIENT", "short_name": "🦇", "snr": 1.87, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.036, "battery_level": 84, "channel_utilization": 2.96, "uptime_seconds": 6680, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3248, "long_name": "River Pine", "next_hop": 0, "num": "0x1f0c1c01", "position": {"altitude": 1121, "latitude": 33.35492, "location_source": "LOC_INTERNAL", "longitude": -106.428994, "time_offset_sec": 3275}, "public_key_hex": "5a9515608dd2b85151975b3f9b2c04d2dd0eb87c4c866bb7265ac94da296230b", "role": "CLIENT", "short_name": "RB3Z", "snr": 2.76, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.566, "battery_level": 49, "channel_utilization": 7.12, "uptime_seconds": 55715, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.98, "iaq": 46, "relative_humidity": 53.97, "temperature": 20.43}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1312, "long_name": "Sky Gecko", "next_hop": 0, "num": "0x1f307e3a", "position": {"altitude": 1129, "latitude": 33.083947, "location_source": "LOC_INTERNAL", "longitude": -106.633086, "time_offset_sec": 1575}, "public_key_hex": "3157b6eb3a3050e1098f4687f0a077068d0e158c00b8fafa788aa5a75f353898", "role": "CLIENT", "short_name": "SPL1", "snr": 10.41, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.44, "battery_level": 63, "channel_utilization": 20.34, "uptime_seconds": 66623, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 10292, "long_name": "Dusk Heron", "next_hop": 205, "num": "0x1f5b32ce", "position": {"altitude": 865, "latitude": 33.470405, "location_source": "LOC_INTERNAL", "longitude": -107.55069, "time_offset_sec": 10500}, "public_key_hex": "2c4ee61b9a302214607ed934c64ce3a3885cd6bfe3b9aa01f799fbc3b2e0a4f8", "role": "CLIENT", "short_name": "DUMV", "snr": 4.39, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3536, "long_name": "Found Moose", "next_hop": 208, "num": "0x1f714c5a", "position": {"altitude": 1300, "latitude": 32.573713, "location_source": "LOC_INTERNAL", "longitude": -106.846349, "time_offset_sec": 3696}, "public_key_hex": "a7ee453aa475fb5971b2374d1a2d5c3254c0c6018816576bcca52626eb2d2527", "role": "CLIENT", "short_name": "FRDH", "snr": 3.18, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.767, "battery_level": 93, "channel_utilization": 11.2, "uptime_seconds": 110003, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4430, "long_name": "Frosty Marmot", "next_hop": 0, "num": "0x1f834d19", "position": {"altitude": 973, "latitude": 33.185881, "location_source": "LOC_INTERNAL", "longitude": -107.918663, "time_offset_sec": 4708}, "public_key_hex": "4ddfc8ac8f34e7588c9545308eb64059e108e92d4e565f99e5838761cd599b17", "role": "CLIENT", "short_name": "F9HS", "snr": 4.64, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.601, "battery_level": 43, "channel_utilization": 6.52, "uptime_seconds": 134229, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 5340, "long_name": "Stone Iguana", "next_hop": 167, "num": "0x1f92ba36", "position": {"altitude": 1256, "latitude": 32.137369, "location_source": "LOC_INTERNAL", "longitude": -107.446858, "time_offset_sec": 5470}, "public_key_hex": "dec0c7ad2fb2b3e87278e6e6707d278d79caf879bdba324dbb58d328b1a9b36d", "role": "CLIENT", "short_name": "🦊", "snr": 5.24, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.381, "battery_level": 48, "channel_utilization": 15.33, "uptime_seconds": 25506, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3693, "long_name": "Quick Dolphin", "next_hop": 232, "num": "0x1f9809b2", "position": null, "public_key_hex": "3b2645992f7e852770305136998e0941ae88647f3207803b8a7af565a25c23e5", "role": "CLIENT", "short_name": "QFCD", "snr": 4.32, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.065, "battery_level": 76, "channel_utilization": 8.57, "uptime_seconds": 129695, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.75, "iaq": 0, "relative_humidity": 90.07, "temperature": 19.55}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2027, "long_name": "Iron Mesa", "next_hop": 0, "num": "0x1fc3ece0", "position": {"altitude": 1561, "latitude": 33.856485, "location_source": "LOC_INTERNAL", "longitude": -108.098643, "time_offset_sec": 2186}, "public_key_hex": "14c55b64c95db6324d5c82c98c399b21afd65f0f8adfde56612dc17d4c941a2f", "role": "CLIENT", "short_name": "IOK9", "snr": 11.92, "status": null, "telemetry": {"air_util_tx": 0.182, "battery_level": 64, "channel_utilization": 12.33, "uptime_seconds": 41264, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 9196, "long_name": "Sleepy Dolphin", "next_hop": 0, "num": "0x200bc154", "position": null, "public_key_hex": "594c2ee25b9363f1f44099c406292f62f72928484d1b18223a2d82592b0a9428", "role": "CLIENT", "short_name": "SULO", "snr": 3.3, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.032, "battery_level": 17, "channel_utilization": 15.68, "uptime_seconds": 15780, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 2197, "long_name": "Gold Shark", "next_hop": 124, "num": "0x20124249", "position": {"altitude": 949, "latitude": 33.22127, "location_source": "LOC_INTERNAL", "longitude": -106.390452, "time_offset_sec": 2363}, "public_key_hex": "b46343475935ec0fc6b18cacc0d01fbb94c4dffed12dfab2985a6c07d7f6f7dc", "role": "CLIENT", "short_name": "GZMZ", "snr": 4.47, "status": null, "telemetry": {"air_util_tx": 0.289, "battery_level": 67, "channel_utilization": 19.94, "uptime_seconds": 257151, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1022.69, "iaq": 37, "relative_humidity": 52.71, "temperature": 14.78}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 6298, "long_name": "Hidden Crow", "next_hop": 0, "num": "0x20143f93", "position": {"altitude": 1230, "latitude": 32.950965, "location_source": "LOC_INTERNAL", "longitude": -108.502676, "time_offset_sec": 6483}, "public_key_hex": "e2cfdae97e63b2444c46ec0e1fbc0fcf24218cd03e8818d94b1a7659d854dcfe", "role": "CLIENT", "short_name": "HMSN", "snr": 8.93, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.163, "battery_level": 63, "channel_utilization": 7.02, "uptime_seconds": 206053, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 680, "long_name": "Forest Gecko", "next_hop": 0, "num": "0x201d3b5d", "position": {"altitude": 1764, "latitude": 32.216479, "location_source": "LOC_INTERNAL", "longitude": -106.747207, "time_offset_sec": 977}, "public_key_hex": "acae781a7672a6e6c533022c743d0f4fcda100186a45a604869562ae7deb96c7", "role": "CLIENT", "short_name": "F2QI", "snr": 4.2, "status": null, "telemetry": {"air_util_tx": 0.995, "battery_level": 99, "channel_utilization": 3.89, "uptime_seconds": 5322, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1008.43, "iaq": 8, "relative_humidity": 51.21, "temperature": 16.35}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3519, "long_name": "Howling Bear", "next_hop": 0, "num": "0x203ae3fa", "position": null, "public_key_hex": "eec0eb4b039228dd9a46ff8ee71ccf4159d4771c92ac686aef97681572b642fc", "role": "CLIENT", "short_name": "HPBZ", "snr": 6.74, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.217, "battery_level": 101, "channel_utilization": 1.22, "uptime_seconds": 8544, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1017.61, "iaq": 61, "relative_humidity": 8.53, "temperature": 26.26}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4871, "long_name": "Solar Adder", "next_hop": 39, "num": "0x203f4977", "position": {"altitude": 1597, "latitude": 33.393994, "location_source": "LOC_INTERNAL", "longitude": -107.812241, "time_offset_sec": 5022}, "public_key_hex": "dd7950ed4ea7bec65fdc5747410b3c7ed8caaa477bda63534e99cd0cf476508b", "role": "CLIENT", "short_name": "SI19", "snr": 7.86, "status": {"status": "no-gps"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.07, "iaq": 51, "relative_humidity": 34.07, "temperature": 14.61}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1614, "long_name": "Canyon Coyote", "next_hop": 0, "num": "0x20443515", "position": {"altitude": 1148, "latitude": 33.021747, "location_source": "LOC_INTERNAL", "longitude": -107.61716, "time_offset_sec": 1893}, "public_key_hex": "", "role": "ROUTER_LATE", "short_name": "CCA2", "snr": 2.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.598, "battery_level": 30, "channel_utilization": 42.7, "uptime_seconds": 42097, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 11848, "long_name": "Quick Moose", "next_hop": 38, "num": "0x205bdc9e", "position": {"altitude": 1663, "latitude": 33.834134, "location_source": "LOC_INTERNAL", "longitude": -107.377289, "time_offset_sec": 12015}, "public_key_hex": "915de2e59cfd52104aeca3f97ca5ed588203e6fb68359cf026271ee5c485f46d", "role": "CLIENT", "short_name": "QEQM", "snr": 7.64, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.973, "battery_level": 13, "channel_utilization": 14.33, "uptime_seconds": 186072, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 2181, "long_name": "Green Yucca", "next_hop": 0, "num": "0x205c1081", "position": {"altitude": 1446, "latitude": 33.532321, "location_source": "LOC_INTERNAL", "longitude": -106.267438, "time_offset_sec": 2293}, "public_key_hex": "6617f8804f2bf58637d2989f52f487009c199a9acf0c37d9d2d3ce4c18ea9236", "role": "CLIENT", "short_name": "GNWD", "snr": 7.28, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.109, "battery_level": 52, "channel_utilization": 16.39, "uptime_seconds": 93849, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 15614, "long_name": "Lunar Cobra", "next_hop": 112, "num": "0x2068661f", "position": {"altitude": 1359, "latitude": 32.93935, "location_source": "LOC_INTERNAL", "longitude": -107.526058, "time_offset_sec": 15655}, "public_key_hex": "4d63fcc6082c7de863e2644c259ace69b026d232790af94c7d730395afb71046", "role": "CLIENT", "short_name": "LTTB", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 2690, "long_name": "White Pine", "next_hop": 231, "num": "0x20749340", "position": {"altitude": 1178, "latitude": 32.484441, "location_source": "LOC_INTERNAL", "longitude": -106.168483, "time_offset_sec": 2955}, "public_key_hex": "2dc3f3ad846696dd3297421aaaf01efced449006312896ec1093e9ff429c7c35", "role": "CLIENT_MUTE", "short_name": "W57X", "snr": 4.91, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.887, "battery_level": 60, "channel_utilization": 20.71, "uptime_seconds": 87405, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2488, "long_name": "River Badger", "next_hop": 0, "num": "0x2082e401", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "RBFH", "snr": 5.2, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3880, "long_name": "Gold Cobra", "next_hop": 0, "num": "0x20bd233f", "position": {"altitude": 1301, "latitude": 33.604637, "location_source": "LOC_INTERNAL", "longitude": -107.491073, "time_offset_sec": 3884}, "public_key_hex": "61f414219e08ed61ce0442d06f74fff22d6724bda9aaed8735e59f8f3bff3934", "role": "CLIENT", "short_name": "G12F", "snr": 2.12, "status": null, "telemetry": {"air_util_tx": 0.305, "battery_level": 73, "channel_utilization": 14.11, "uptime_seconds": 92632, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 97, "long_name": "Storm Elk", "next_hop": 228, "num": "0x20cbbc67", "position": null, "public_key_hex": "0cc4ff35d03c000d9af2459223c90db6b7fa6ac330fff9229daac6f378ffc038", "role": "CLIENT", "short_name": "S1JH", "snr": 0.68, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 532, "long_name": "Found Ridge", "next_hop": 0, "num": "0x20d04409", "position": {"altitude": 1391, "latitude": 33.599793, "location_source": "LOC_INTERNAL", "longitude": -106.711263, "time_offset_sec": 535}, "public_key_hex": "d8f66da06b985304918038537e8f526879bab8420ddfc8877d714a861ad46617", "role": "CLIENT_BASE", "short_name": "FKCQ", "snr": 6.11, "status": null, "telemetry": {"air_util_tx": 0.118, "battery_level": 32, "channel_utilization": 18.03, "uptime_seconds": 208748, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.94, "iaq": 46, "relative_humidity": 45.17, "temperature": 9.22}, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2108, "long_name": "Sunny Beaver", "next_hop": 220, "num": "0x20e8b4cb", "position": null, "public_key_hex": "404dc157aafa120573bf4bb28c3fbc7256fcfb11d238f704cbd2f9f9d9f383c3", "role": "CLIENT", "short_name": "SSO2", "snr": 3.27, "status": null, "telemetry": {"air_util_tx": 0.908, "battery_level": 84, "channel_utilization": 25.16, "uptime_seconds": 10448, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 3557, "long_name": "Dawn Otter", "next_hop": 0, "num": "0x20f18b87", "position": {"altitude": 1312, "latitude": 33.701132, "location_source": "LOC_INTERNAL", "longitude": -106.897663, "time_offset_sec": 3617}, "public_key_hex": "4099212a2983c56fffc4fde575f5f26a270c655681a19b80c31324801a8b58eb", "role": "ROUTER_LATE", "short_name": "D35R", "snr": 5.04, "status": null, "telemetry": {"air_util_tx": 0.38, "battery_level": 15, "channel_utilization": 15.13, "uptime_seconds": 210337, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 4089, "long_name": "Roving Bear", "next_hop": 0, "num": "0x20f94dec", "position": {"altitude": 1563, "latitude": 32.675552, "location_source": "LOC_INTERNAL", "longitude": -107.395569, "time_offset_sec": 4319}, "public_key_hex": "46ce59fc31c79b55a4d225655ad6ab7f638817f9743bf65477789a2b6b48efe3", "role": "ROUTER", "short_name": "RYKK", "snr": 8.97, "status": null, "telemetry": {"air_util_tx": 0.315, "battery_level": 13, "channel_utilization": 13.45, "uptime_seconds": 147291, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2954, "long_name": "Silver Bass", "next_hop": 0, "num": "0x20fb5366", "position": {"altitude": 1235, "latitude": 33.897753, "location_source": "LOC_INTERNAL", "longitude": -106.212996, "time_offset_sec": 3211}, "public_key_hex": "db7eb1ec676a6b9773816112bd7d43de8a0b7cd966622e19a8f0da90ea018406", "role": "CLIENT", "short_name": "S4JQ", "snr": 4.41, "status": null, "telemetry": {"air_util_tx": 0.267, "battery_level": 94, "channel_utilization": 13.63, "uptime_seconds": 50936, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1147, "long_name": "Red Otter AE8XU", "next_hop": 0, "num": "0x2103a2f3", "position": {"altitude": 1496, "latitude": 33.028382, "location_source": "LOC_INTERNAL", "longitude": -106.522973, "time_offset_sec": 1222}, "public_key_hex": "a3e44178f6cd1e332f5e8e880e0488f4f236f92a02fecc10b035b07eeb7200aa", "role": "CLIENT", "short_name": "RDHS", "snr": 8.38, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.54, "iaq": 32, "relative_humidity": 61.27, "temperature": 27.55}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1128, "long_name": "River Lion", "next_hop": 0, "num": "0x210795e2", "position": {"altitude": 916, "latitude": 33.324161, "location_source": "LOC_INTERNAL", "longitude": -106.934517, "time_offset_sec": 1230}, "public_key_hex": "", "role": "CLIENT", "short_name": "R7OO", "snr": 7.65, "status": null, "telemetry": {"air_util_tx": 0.448, "battery_level": 79, "channel_utilization": 7.67, "uptime_seconds": 192376, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6256, "long_name": "Short Arroyo", "next_hop": 0, "num": "0x21121c27", "position": {"altitude": 1160, "latitude": 32.972045, "location_source": "LOC_INTERNAL", "longitude": -107.70602, "time_offset_sec": 6355}, "public_key_hex": "f7a48f07cdeb2e7b93fac0db77755e3b17014f30abf8a2c8a0bb35e366248682", "role": "CLIENT", "short_name": "SWO0", "snr": 3.71, "status": null, "telemetry": {"air_util_tx": 0.992, "battery_level": 34, "channel_utilization": 15.51, "uptime_seconds": 60753, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 13089, "long_name": "River Moose", "next_hop": 0, "num": "0x213bd4ab", "position": {"altitude": 1591, "latitude": 33.320647, "location_source": "LOC_INTERNAL", "longitude": -107.630396, "time_offset_sec": 13368}, "public_key_hex": "a3f0136cc1692dd8f11e635a41aebe67195054d616566ef9d8eacb141902f1bb", "role": "CLIENT", "short_name": "ROY9", "snr": 0.65, "status": null, "telemetry": {"air_util_tx": 0.028, "battery_level": 74, "channel_utilization": 11.86, "uptime_seconds": 16849, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 6412, "long_name": "Roving Fox", "next_hop": 74, "num": "0x215d2531", "position": {"altitude": 1612, "latitude": 32.319928, "location_source": "LOC_INTERNAL", "longitude": -107.317338, "time_offset_sec": 6431}, "public_key_hex": "9fd89a904cecefadc45a54db15f071b416ceae572acb299556ba2f72c5872404", "role": "CLIENT_HIDDEN", "short_name": "RJBC", "snr": 9.43, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.816, "battery_level": 68, "channel_utilization": 4.58, "uptime_seconds": 131955, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3562, "long_name": "Howling Bear", "next_hop": 0, "num": "0x21666492", "position": {"altitude": 1424, "latitude": 32.357009, "location_source": "LOC_INTERNAL", "longitude": -106.480372, "time_offset_sec": 3592}, "public_key_hex": "2981b220e3c8e7fb653cd6a8ff02a5d9c4253fbc7b151618e984340ff3e3cbef", "role": "TAK", "short_name": "H9QH", "snr": 3.29, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.109, "battery_level": 96, "channel_utilization": 0.56, "uptime_seconds": 46557, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 35, "long_name": "Fast Viper", "next_hop": 56, "num": "0x218f020d", "position": {"altitude": 1152, "latitude": 32.896209, "location_source": "LOC_INTERNAL", "longitude": -107.171988, "time_offset_sec": 274}, "public_key_hex": "02fba0d2d11f7bbe963b29f2f48de1c8ecfb052244ed31919a7681d9fe984d2b", "role": "CLIENT_MUTE", "short_name": "FGU8", "snr": 7.5, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 556, "long_name": "Misty Doe", "next_hop": 252, "num": "0x219325bb", "position": {"altitude": 1316, "latitude": 32.815061, "location_source": "LOC_INTERNAL", "longitude": -107.916921, "time_offset_sec": 601}, "public_key_hex": "34e6b273ed33bb45cdb7eb149e8b9886ffcb880eb884e86497872dd5171f732c", "role": "CLIENT", "short_name": "MYCD", "snr": 5.33, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 722, "long_name": "Lone Squirrel", "next_hop": 67, "num": "0x21968817", "position": {"altitude": 1569, "latitude": 33.113513, "location_source": "LOC_INTERNAL", "longitude": -106.772876, "time_offset_sec": 778}, "public_key_hex": "bd22206a99d41846b53e98443029280a8565777bf8b5679aceea455e7f65b3ba", "role": "CLIENT_MUTE", "short_name": "LCHC", "snr": 6.55, "status": null, "telemetry": {"air_util_tx": 1.479, "battery_level": 60, "channel_utilization": 5.24, "uptime_seconds": 15825, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1263, "long_name": "Roving Doe", "next_hop": 71, "num": "0x21b9e603", "position": null, "public_key_hex": "8b654ac74ee5a26f69fa4b0997779ffcacadd2564b17437e14db653f00ca0372", "role": "CLIENT", "short_name": "R23O", "snr": 3.34, "status": null, "telemetry": {"air_util_tx": 0.054, "battery_level": 74, "channel_utilization": 10.38, "uptime_seconds": 53830, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 655, "long_name": "Wandering Elk", "next_hop": 0, "num": "0x21ea6013", "position": {"altitude": 1321, "latitude": 33.913918, "location_source": "LOC_INTERNAL", "longitude": -107.831958, "time_offset_sec": 693}, "public_key_hex": "73c516329f7c959d8ec423da42d194eae53b55dd8e90c57dae9de2b496f0ea84", "role": "CLIENT_HIDDEN", "short_name": "WYI8", "snr": 2.86, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.04, "iaq": 27, "relative_humidity": 59.37, "temperature": 18.74}, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 470, "long_name": "Old Turtle", "next_hop": 96, "num": "0x22010cdf", "position": {"altitude": 1270, "latitude": 32.672991, "location_source": "LOC_INTERNAL", "longitude": -107.063203, "time_offset_sec": 631}, "public_key_hex": "", "role": "CLIENT_BASE", "short_name": "🔥", "snr": -1.26, "status": null, "telemetry": {"air_util_tx": 0.337, "battery_level": 38, "channel_utilization": 7.37, "uptime_seconds": 40482, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 5989, "long_name": "Burning Elk", "next_hop": 0, "num": "0x2208ce9f", "position": {"altitude": 1507, "latitude": 33.686174, "location_source": "LOC_INTERNAL", "longitude": -107.587501, "time_offset_sec": 6099}, "public_key_hex": "ca48a7f443655727be3cbfc58b310c22a14e4e82d434afc678bf2dce776ab435", "role": "CLIENT_MUTE", "short_name": "BEN0", "snr": 6.96, "status": null, "telemetry": {"air_util_tx": 0.968, "battery_level": 15, "channel_utilization": 14.4, "uptime_seconds": 5617, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 104, "long_name": "Stone Hare", "next_hop": 155, "num": "0x22326c20", "position": {"altitude": 1066, "latitude": 33.137222, "location_source": "LOC_INTERNAL", "longitude": -106.761892, "time_offset_sec": 205}, "public_key_hex": "6d4ede43e5efcfee0d8d61fac849ad558031235512fe85ebd5a8db66bde6b169", "role": "CLIENT", "short_name": "SA0B", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.124, "battery_level": 77, "channel_utilization": 21.4, "uptime_seconds": 12362, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.1, "iaq": 69, "relative_humidity": 51.77, "temperature": 18.67}, "hops_away": 3, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4037, "long_name": "Green Heron", "next_hop": 41, "num": "0x223a9a84", "position": {"altitude": 1701, "latitude": 32.370575, "location_source": "LOC_INTERNAL", "longitude": -107.129227, "time_offset_sec": 4093}, "public_key_hex": "876ba02e98796a53366811e8615029e5ac95180f859a8a7f262e7ce60b2d2446", "role": "CLIENT", "short_name": "GDEC", "snr": 9.04, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.009, "battery_level": 51, "channel_utilization": 16.19, "uptime_seconds": 13310, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8410, "long_name": "Sky Owl", "next_hop": 180, "num": "0x225fd4e0", "position": {"altitude": 1219, "latitude": 32.985611, "location_source": "LOC_INTERNAL", "longitude": -106.964881, "time_offset_sec": 8508}, "public_key_hex": "d8677e140743457d12d752026f6d4f0ee2ed49441c3878e85e9cd50e1797ac55", "role": "CLIENT", "short_name": "SCFR", "snr": 10.21, "status": null, "telemetry": {"air_util_tx": 1.462, "battery_level": 101, "channel_utilization": 1.14, "uptime_seconds": 167151, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6779, "long_name": "Lost Bass", "next_hop": 0, "num": "0x2273e9c8", "position": {"altitude": 1295, "latitude": 32.729985, "location_source": "LOC_INTERNAL", "longitude": -107.186156, "time_offset_sec": 6921}, "public_key_hex": "2ffb3648166d5d360a0d744e44c3213be8aad8cf86b38627ab6f044e3a04a34e", "role": "ROUTER_LATE", "short_name": "LB9X", "snr": 1.37, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.85, "iaq": 80, "relative_humidity": 95.43, "temperature": 27.41}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5687, "long_name": "Dawn Phoenix", "next_hop": 136, "num": "0x2277c0eb", "position": {"altitude": 1309, "latitude": 33.283887, "location_source": "LOC_INTERNAL", "longitude": -106.668151, "time_offset_sec": 5736}, "public_key_hex": "4c77fbbf9badf367798b85c65516ad3b5640bb6b24c42edb05c83c3038f0e1d9", "role": "CLIENT", "short_name": "DDUN", "snr": 2.16, "status": null, "telemetry": {"air_util_tx": 0.265, "battery_level": 85, "channel_utilization": 10.39, "uptime_seconds": 9586, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.85, "iaq": 14, "relative_humidity": 98.76, "temperature": 36.35}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3183, "long_name": "Canyon Wolf", "next_hop": 0, "num": "0x228268db", "position": {"altitude": 1583, "latitude": 33.545232, "location_source": "LOC_INTERNAL", "longitude": -107.313774, "time_offset_sec": 3265}, "public_key_hex": "59d850bbf6d82396fc33ac7c92ce4eeb399a11bc29e80d7f54abdbf85b8ceb09", "role": "TRACKER", "short_name": "CQFM", "snr": 4.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1005.15, "iaq": 69, "relative_humidity": 59.08, "temperature": 24.07}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 6243, "long_name": "Gold Viper", "next_hop": 0, "num": "0x228a5034", "position": {"altitude": 1493, "latitude": 33.740131, "location_source": "LOC_INTERNAL", "longitude": -107.351405, "time_offset_sec": 6485}, "public_key_hex": "685f35a948850ae13ec5338f3ec3c1df0a031436b55a5e32e61a16bc9b1f8017", "role": "TAK", "short_name": "G5U5", "snr": 6.86, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.837, "battery_level": 16, "channel_utilization": 18.83, "uptime_seconds": 32815, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 295, "long_name": "Hidden Eagle", "next_hop": 0, "num": "0x228f91a5", "position": {"altitude": 1376, "latitude": 33.770875, "location_source": "LOC_INTERNAL", "longitude": -106.693443, "time_offset_sec": 350}, "public_key_hex": "93cc3f01df98234587b5f2c6f6c00804575e67dd261dd664ff9aa6782a6aa7fd", "role": "SENSOR", "short_name": "HOZL", "snr": 10.01, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.41, "battery_level": 81, "channel_utilization": 24.03, "uptime_seconds": 545410, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 1549, "long_name": "Bright Coyote", "next_hop": 232, "num": "0x22b455d9", "position": {"altitude": 1091, "latitude": 32.199908, "location_source": "LOC_INTERNAL", "longitude": -107.765229, "time_offset_sec": 1845}, "public_key_hex": "", "role": "CLIENT", "short_name": "BPNX", "snr": 8.61, "status": null, "telemetry": {"air_util_tx": 0.482, "battery_level": 22, "channel_utilization": 20.95, "uptime_seconds": 102028, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 5006, "long_name": "Red Cactus", "next_hop": 0, "num": "0x22bd42cd", "position": {"altitude": 1084, "latitude": 32.727501, "location_source": "LOC_INTERNAL", "longitude": -106.67497, "time_offset_sec": 5165}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦋", "snr": 12.0, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1000.33, "iaq": 45, "relative_humidity": 26.29, "temperature": 19.63}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 390, "long_name": "New Colt", "next_hop": 0, "num": "0x22d6b85a", "position": {"altitude": 1115, "latitude": 32.781848, "location_source": "LOC_INTERNAL", "longitude": -107.036506, "time_offset_sec": 418}, "public_key_hex": "b3c1c7dda13494ba72b6e5e500af39bd67374a3a9e8fa81ca77d120f5cd9b30f", "role": "CLIENT", "short_name": "NKHI", "snr": 1.38, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.186, "battery_level": 60, "channel_utilization": 17.16, "uptime_seconds": 62136, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1008.24, "iaq": 60, "relative_humidity": 58.75, "temperature": 28.33}, "hops_away": 4, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1450, "long_name": "Sneaky Cactus", "next_hop": 249, "num": "0x22db9455", "position": {"altitude": 1252, "latitude": 33.430953, "location_source": "LOC_INTERNAL", "longitude": -107.522962, "time_offset_sec": 1456}, "public_key_hex": "7c4b740a9c006029df0111562577e62da8b6e082cf49eae665fab95732005958", "role": "CLIENT", "short_name": "SGL3", "snr": 0.43, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.024, "battery_level": 89, "channel_utilization": 19.22, "uptime_seconds": 275271, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1028.01, "iaq": 94, "relative_humidity": 14.57, "temperature": 22.03}, "hops_away": 1, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 1277, "long_name": "Dawn Turtle", "next_hop": 88, "num": "0x22dc51e8", "position": null, "public_key_hex": "602474905fe20dc5f72337a5c6b225c9a412191d5215d83e52935198998ea174", "role": "CLIENT", "short_name": "DYGN", "snr": 8.46, "status": null, "telemetry": {"air_util_tx": 0.86, "battery_level": 87, "channel_utilization": 18.31, "uptime_seconds": 185237, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3255, "long_name": "Rough Otter", "next_hop": 0, "num": "0x22e4d6c7", "position": {"altitude": 1385, "latitude": 33.283723, "location_source": "LOC_INTERNAL", "longitude": -106.83275, "time_offset_sec": 3553}, "public_key_hex": "6f61d72af160b39031e1e127df98627cf127bbe863d0381bf8eb0a1a4f6b98d9", "role": "CLIENT", "short_name": "RDF5", "snr": 3.12, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.373, "battery_level": 63, "channel_utilization": 28.02, "uptime_seconds": 14524, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 935, "long_name": "Blue Ridge N56UH", "next_hop": 124, "num": "0x230dd3f5", "position": {"altitude": 1696, "latitude": 32.674612, "location_source": "LOC_INTERNAL", "longitude": -107.732019, "time_offset_sec": 1162}, "public_key_hex": "dd9bc5e2899e7279bd126d3e4df3e23d9867c614002948dcbd6ca5a19b9abc21", "role": "CLIENT", "short_name": "BF1J", "snr": 9.83, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.49, "iaq": 31, "relative_humidity": 100.0, "temperature": 15.21}, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 326, "long_name": "Gold Mamba", "next_hop": 58, "num": "0x233b762b", "position": {"altitude": 780, "latitude": 33.705548, "location_source": "LOC_INTERNAL", "longitude": -107.020666, "time_offset_sec": 397}, "public_key_hex": "29943b60af167d7a5b6e49efa4c95e4e8d19f9d19650a033e733ab0dab2828c3", "role": "CLIENT", "short_name": "GYWH", "snr": 3.29, "status": null, "telemetry": {"air_util_tx": 0.733, "battery_level": 62, "channel_utilization": 15.91, "uptime_seconds": 40466, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 4788, "long_name": "Sunny Juniper", "next_hop": 0, "num": "0x23614b80", "position": {"altitude": 1039, "latitude": 33.073079, "location_source": "LOC_INTERNAL", "longitude": -107.281167, "time_offset_sec": 5020}, "public_key_hex": "9d2e3dbd6ad8d398106baf2f956c1feaee11c297f12b780fc4439af4cabbf7e4", "role": "CLIENT", "short_name": "🦌", "snr": 7.94, "status": null, "telemetry": {"air_util_tx": 0.64, "battery_level": 20, "channel_utilization": 14.69, "uptime_seconds": 2125, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.84, "iaq": 7, "relative_humidity": 42.52, "temperature": 34.76}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 762, "long_name": "Sharp Colt", "next_hop": 0, "num": "0x23775753", "position": null, "public_key_hex": "8f9ecf5b51bfcca1a3ac898739a0c7e8d2555d759507ccfd0daccd2c209713ee", "role": "CLIENT", "short_name": "S3PX", "snr": 5.8, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.1, "battery_level": 92, "channel_utilization": 14.82, "uptime_seconds": 69087, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 17033, "long_name": "Shady Juniper", "next_hop": 27, "num": "0x23777ff6", "position": {"altitude": 1617, "latitude": 32.855264, "location_source": "LOC_INTERNAL", "longitude": -106.817088, "time_offset_sec": 17301}, "public_key_hex": "7a000d701f6a875ca4773577ba44f4f4f3590f71f2858727e7651df036aeb990", "role": "TRACKER", "short_name": "SF5W", "snr": 10.97, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.158, "battery_level": 84, "channel_utilization": 15.27, "uptime_seconds": 572, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 7096, "long_name": "Howling Cougar", "next_hop": 229, "num": "0x2387f60d", "position": {"altitude": 1454, "latitude": 32.577178, "location_source": "LOC_INTERNAL", "longitude": -108.352963, "time_offset_sec": 7258}, "public_key_hex": "6ebff092d279cab4a040a3103429d9dee2bdb6ebaf0916a5004e0685a4e21f6b", "role": "CLIENT", "short_name": "HOT5", "snr": -2.63, "status": null, "telemetry": {"air_util_tx": 0.224, "battery_level": 85, "channel_utilization": 7.94, "uptime_seconds": 25825, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2419, "long_name": "Copper Mole", "next_hop": 112, "num": "0x239a97f1", "position": {"altitude": 1543, "latitude": 33.116129, "location_source": "LOC_INTERNAL", "longitude": -107.439547, "time_offset_sec": 2666}, "public_key_hex": "a31a8f04d5b3b9241ba19d2e5d8062fad0b4494a5fa53bf1b1ecd1f041b52928", "role": "CLIENT", "short_name": "CC06", "snr": 5.33, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.693, "battery_level": 29, "channel_utilization": 12.64, "uptime_seconds": 1397, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 7159, "long_name": "Storm Tortoise", "next_hop": 0, "num": "0x23c85f22", "position": {"altitude": 1696, "latitude": 33.696873, "location_source": "LOC_INTERNAL", "longitude": -107.685472, "time_offset_sec": 7372}, "public_key_hex": "15a698f082921d24f7d75f63a0bef03c03ec0ccaaea012fc619ba4db2f2db906", "role": "CLIENT", "short_name": "SFGY", "snr": 3.06, "status": null, "telemetry": {"air_util_tx": 1.102, "battery_level": 48, "channel_utilization": 5.56, "uptime_seconds": 26233, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.98, "iaq": 30, "relative_humidity": 65.92, "temperature": 24.41}, "hops_away": 4, "hw_model": "RAK4631", "last_heard_offset_sec": 416, "long_name": "Wild Dolphin N54HA", "next_hop": 95, "num": "0x23eb1844", "position": {"altitude": 1109, "latitude": 32.235382, "location_source": "LOC_INTERNAL", "longitude": -106.622984, "time_offset_sec": 691}, "public_key_hex": "f4add6995e54cb682ae7effea24a6e7636425e629767eaa51f8c1ed58e5749ce", "role": "CLIENT", "short_name": "WWPP", "snr": 2.41, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.332, "battery_level": 44, "channel_utilization": 13.43, "uptime_seconds": 21652, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 603, "long_name": "Misty Mole", "next_hop": 116, "num": "0x23ff9148", "position": {"altitude": 1490, "latitude": 31.936753, "location_source": "LOC_INTERNAL", "longitude": -107.426254, "time_offset_sec": 665}, "public_key_hex": "58ff5d86ea65bdf750dcc813687e492cf0c37aa08fc35e61ff11b11330d9f960", "role": "CLIENT", "short_name": "MDY6", "snr": 6.81, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.131, "battery_level": 79, "channel_utilization": 2.22, "uptime_seconds": 6890, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.91, "iaq": 0, "relative_humidity": 23.46, "temperature": 25.47}, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 3101, "long_name": "Iron Whale", "next_hop": 0, "num": "0x23ffb878", "position": {"altitude": 1439, "latitude": 32.259623, "location_source": "LOC_INTERNAL", "longitude": -107.449965, "time_offset_sec": 3362}, "public_key_hex": "3d0f43e5e9c87dd3da2be85f969dd5069674ee1d7be1686126b597ae00b3aa66", "role": "CLIENT_MUTE", "short_name": "IV1P", "snr": 8.27, "status": null, "telemetry": {"air_util_tx": 2.021, "battery_level": 69, "channel_utilization": 12.02, "uptime_seconds": 107790, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2600, "long_name": "Silver Mamba", "next_hop": 253, "num": "0x2421cdc3", "position": {"altitude": 1738, "latitude": 33.475891, "location_source": "LOC_INTERNAL", "longitude": -107.369322, "time_offset_sec": 2654}, "public_key_hex": "31602910e94d41d2c38b2ffd05ef15fa06a7c8d9653bbea1ca5f1dac6c6f448e", "role": "CLIENT", "short_name": "SM4N", "snr": 5.54, "status": null, "telemetry": {"air_util_tx": 0.068, "battery_level": 75, "channel_utilization": 2.52, "uptime_seconds": 11457, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 4816, "long_name": "Sharp Mustang", "next_hop": 166, "num": "0x242718e2", "position": null, "public_key_hex": "527f946a510d99d1f24790d9914b9be3141bf5eb55fa24b9af25f3e5f7d0e36d", "role": "CLIENT", "short_name": "S458", "snr": 1.33, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.111, "battery_level": 18, "channel_utilization": 5.88, "uptime_seconds": 189075, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2095, "long_name": "River Whale", "next_hop": 31, "num": "0x2430293c", "position": {"altitude": 1325, "latitude": 33.110866, "location_source": "LOC_INTERNAL", "longitude": -107.398857, "time_offset_sec": 2358}, "public_key_hex": "4ab39ee24f0a37073047b225b2600e555d84678783ce2dc61827cc12efa2e55a", "role": "CLIENT", "short_name": "RBLK", "snr": 4.97, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.808, "battery_level": 50, "channel_utilization": 3.08, "uptime_seconds": 4370, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2513, "long_name": "Drifting Crane", "next_hop": 0, "num": "0x24347a80", "position": {"altitude": 1263, "latitude": 33.638214, "location_source": "LOC_INTERNAL", "longitude": -107.23967, "time_offset_sec": 2811}, "public_key_hex": "", "role": "CLIENT", "short_name": "D7FM", "snr": 7.22, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.547, "battery_level": 59, "channel_utilization": 13.24, "uptime_seconds": 20327, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 177, "long_name": "Silent Wolf", "next_hop": 0, "num": "0x243e9776", "position": {"altitude": 1920, "latitude": 33.598927, "location_source": "LOC_INTERNAL", "longitude": -107.025677, "time_offset_sec": 339}, "public_key_hex": "6dfd328df649a06fa45adb1517f30972c2ce6e5881216faaec5186b0c057c5fe", "role": "ROUTER", "short_name": "S6Y9", "snr": 7.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.53, "iaq": 10, "relative_humidity": 63.25, "temperature": 41.13}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2677, "long_name": "Dusk Mole", "next_hop": 70, "num": "0x2445e79f", "position": {"altitude": 1389, "latitude": 33.71742, "location_source": "LOC_INTERNAL", "longitude": -108.327109, "time_offset_sec": 2801}, "public_key_hex": "78980d007e5aa746feb23e4bc087892a5b4b188815ef2b32b2719548566f97cf", "role": "ROUTER", "short_name": "DX54", "snr": 6.01, "status": null, "telemetry": {"air_util_tx": 0.286, "battery_level": 36, "channel_utilization": 10.12, "uptime_seconds": 101863, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4863, "long_name": "Red Colt", "next_hop": 21, "num": "0x2455ed4a", "position": {"altitude": 1774, "latitude": 32.307053, "location_source": "LOC_INTERNAL", "longitude": -107.410097, "time_offset_sec": 5006}, "public_key_hex": "f848b589def031dcc0d0301dd267e4e729800249b2580742d6160bef82375b08", "role": "CLIENT_MUTE", "short_name": "R5JH", "snr": 0.72, "status": null, "telemetry": {"air_util_tx": 1.83, "battery_level": 41, "channel_utilization": 19.62, "uptime_seconds": 73732, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1026.72, "iaq": 41, "relative_humidity": 67.05, "temperature": 34.71}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 3361, "long_name": "Shady Lynx", "next_hop": 0, "num": "0x24801358", "position": {"altitude": 1665, "latitude": 33.250056, "location_source": "LOC_INTERNAL", "longitude": -107.253968, "time_offset_sec": 3565}, "public_key_hex": "f06cb71053b603ecfa4eeed09d48450c027319005b1ac277b07e822dad97a70e", "role": "CLIENT", "short_name": "SQXU", "snr": 6.19, "status": null, "telemetry": {"air_util_tx": 0.162, "battery_level": 92, "channel_utilization": 9.57, "uptime_seconds": 161663, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2082, "long_name": "Steel Seal", "next_hop": 0, "num": "0x2480a818", "position": {"altitude": 1081, "latitude": 33.175016, "location_source": "LOC_INTERNAL", "longitude": -106.978987, "time_offset_sec": 2249}, "public_key_hex": "16bc2b9a575793f77e90b67ecd1ad3658bb393937966845e730eae81be0a95b8", "role": "CLIENT", "short_name": "S7GJ", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 332, "long_name": "Brave Sage", "next_hop": 0, "num": "0x24996fa8", "position": {"altitude": 1025, "latitude": 32.658463, "location_source": "LOC_INTERNAL", "longitude": -106.726455, "time_offset_sec": 432}, "public_key_hex": "34f3c0aafac366f71298bf83ddd96f7cf1a09d764479675713ef62c4cf1e6b8e", "role": "CLIENT", "short_name": "BU5W", "snr": 2.55, "status": null, "telemetry": {"air_util_tx": 0.31, "battery_level": 50, "channel_utilization": 41.37, "uptime_seconds": 18527, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.31, "iaq": 79, "relative_humidity": 59.38, "temperature": 20.52}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3059, "long_name": "Whispering Hare", "next_hop": 0, "num": "0x24a200d5", "position": {"altitude": 1689, "latitude": 33.850869, "location_source": "LOC_INTERNAL", "longitude": -106.596628, "time_offset_sec": 3177}, "public_key_hex": "b4ce78c90a9266ee1bea9d55015bfd3ba917be01862d4e62b8c08db7a9858e2e", "role": "CLIENT", "short_name": "WH25", "snr": 10.17, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 722, "long_name": "Lone Cobra", "next_hop": 170, "num": "0x24a24a3d", "position": {"altitude": 1350, "latitude": 33.424313, "location_source": "LOC_INTERNAL", "longitude": -107.053996, "time_offset_sec": 969}, "public_key_hex": "7f55b268ace2ed4254fc2b906295818efff53053582cdf4d98cd262dc1ee0399", "role": "CLIENT", "short_name": "LHVX", "snr": 3.47, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.602, "battery_level": 64, "channel_utilization": 21.85, "uptime_seconds": 40665, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.36, "iaq": 0, "relative_humidity": 31.67, "temperature": 31.14}, "hops_away": 1, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 1716, "long_name": "Howling Falcon", "next_hop": 251, "num": "0x24a46768", "position": {"altitude": 1370, "latitude": 33.081849, "location_source": "LOC_INTERNAL", "longitude": -107.388601, "time_offset_sec": 1981}, "public_key_hex": "fc666384570dd361460582363e07134d7677bdf2a8e2e932c08c4a701e79c8dd", "role": "ROUTER", "short_name": "HZFI", "snr": 9.69, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.3, "iaq": 11, "relative_humidity": 26.3, "temperature": 3.81}, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 1580, "long_name": "Found Eagle", "next_hop": 0, "num": "0x24c585ed", "position": {"altitude": 1822, "latitude": 33.682918, "location_source": "LOC_INTERNAL", "longitude": -108.603452, "time_offset_sec": 1733}, "public_key_hex": "ecf284e90276b1ced720c8739aa8f13633cba33967f166170534637e0e84b6e6", "role": "CLIENT", "short_name": "FWQZ", "snr": 4.35, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.63, "iaq": 41, "relative_humidity": 29.29, "temperature": 26.77}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1514, "long_name": "Dawn Gecko", "next_hop": 0, "num": "0x24c60b1d", "position": {"altitude": 1433, "latitude": 32.240192, "location_source": "LOC_INTERNAL", "longitude": -106.353052, "time_offset_sec": 1729}, "public_key_hex": "de1006c21fe1b1c06a2b45fa905150de204c527161609e6e2dfec396ac95ad79", "role": "CLIENT", "short_name": "DQE6", "snr": -2.04, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.225, "battery_level": 46, "channel_utilization": 6.27, "uptime_seconds": 19398, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 2936, "long_name": "Desert Crow", "next_hop": 239, "num": "0x24c859ff", "position": {"altitude": 1202, "latitude": 32.844764, "location_source": "LOC_INTERNAL", "longitude": -107.277583, "time_offset_sec": 3024}, "public_key_hex": "336d7d3ecfedeb7753e4d7e729160c747ea66109ced367688222e7ca1eeb7307", "role": "ROUTER_LATE", "short_name": "DK69", "snr": 6.4, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.646, "battery_level": 69, "channel_utilization": 12.15, "uptime_seconds": 14261, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 9812, "long_name": "Loud Bear", "next_hop": 19, "num": "0x24dae3e2", "position": {"altitude": 1323, "latitude": 33.058275, "location_source": "LOC_INTERNAL", "longitude": -106.475089, "time_offset_sec": 9988}, "public_key_hex": "77e1b4883db7ffe705dbef013744c36314f604c5413fc8021885fda78c86f765", "role": "CLIENT", "short_name": "LXXH", "snr": 8.6, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.497, "battery_level": 54, "channel_utilization": 6.81, "uptime_seconds": 41585, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 7530, "long_name": "Silent Cedar", "next_hop": 0, "num": "0x24f4016b", "position": {"altitude": 1076, "latitude": 32.078031, "location_source": "LOC_INTERNAL", "longitude": -107.26698, "time_offset_sec": 7531}, "public_key_hex": "c8c4ef6367b949e827eee6c3bec242d0bb73e8d3326d52eb05ec18e977a614c4", "role": "CLIENT", "short_name": "S0S0", "snr": 4.57, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.315, "battery_level": 19, "channel_utilization": 7.51, "uptime_seconds": 244982, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 6432, "long_name": "Sky Mesa", "next_hop": 0, "num": "0x24f67062", "position": {"altitude": 1771, "latitude": 33.097354, "location_source": "LOC_INTERNAL", "longitude": -106.777111, "time_offset_sec": 6666}, "public_key_hex": "218f966f9b565a88a800fad68fa02049e52ae2b0b464a8622fa92a365e2d2882", "role": "CLIENT", "short_name": "SORN", "snr": 6.07, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.43, "battery_level": 65, "channel_utilization": 20.0, "uptime_seconds": 20426, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.79, "iaq": 69, "relative_humidity": 76.86, "temperature": 41.38}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2214, "long_name": "Sleepy Hawk", "next_hop": 120, "num": "0x25100fda", "position": {"altitude": 1215, "latitude": 33.164391, "location_source": "LOC_INTERNAL", "longitude": -106.539184, "time_offset_sec": 2389}, "public_key_hex": "8369957eaf6979deec0469696e81feef8aa917f98bf863c89edf1ddf2438d69a", "role": "CLIENT_MUTE", "short_name": "SGOP", "snr": 5.13, "status": null, "telemetry": {"air_util_tx": 1.397, "battery_level": 67, "channel_utilization": 6.88, "uptime_seconds": 188022, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 3417, "long_name": "Frozen Mole", "next_hop": 0, "num": "0x2519fb14", "position": null, "public_key_hex": "d4b798a4c85c18f1e271047f9de5a94d5cdb5acde4311bd6586448fa28f14849", "role": "CLIENT", "short_name": "FGWE", "snr": 8.47, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4319, "long_name": "Iron Viper", "next_hop": 226, "num": "0x2525a32d", "position": {"altitude": 1645, "latitude": 33.749831, "location_source": "LOC_INTERNAL", "longitude": -107.23365, "time_offset_sec": 4571}, "public_key_hex": "de7437a3438027271f0df74d6dac959e69a8ba080a6341e0f86d623806996daf", "role": "CLIENT", "short_name": "I3Y9", "snr": 8.74, "status": null, "telemetry": {"air_util_tx": 0.892, "battery_level": 11, "channel_utilization": 16.5, "uptime_seconds": 444, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 15509, "long_name": "Sneaky Cactus", "next_hop": 0, "num": "0x252d441e", "position": {"altitude": 1162, "latitude": 33.019756, "location_source": "LOC_INTERNAL", "longitude": -106.933595, "time_offset_sec": 15553}, "public_key_hex": "e152c70863f99c4e112e4e1bb58bbdc4e2d30eb5736ad0a0c71173e1a505e92f", "role": "CLIENT", "short_name": "S4A9", "snr": 7.92, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2002, "long_name": "Lone Coyote", "next_hop": 154, "num": "0x253f5726", "position": {"altitude": 1549, "latitude": 32.899339, "location_source": "LOC_INTERNAL", "longitude": -107.665475, "time_offset_sec": 2158}, "public_key_hex": "", "role": "CLIENT", "short_name": "L8LM", "snr": 6.77, "status": {"status": "low-batt"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 5837, "long_name": "Bright Elk", "next_hop": 0, "num": "0x25452b8f", "position": null, "public_key_hex": "b945a5f490119244d94a7d7761d8c7dd1848b57dfab03056e17fe69ff027dbe7", "role": "CLIENT_MUTE", "short_name": "B8DZ", "snr": -3.84, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.392, "battery_level": 62, "channel_utilization": 17.25, "uptime_seconds": 28808, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2025, "long_name": "Canyon Pike", "next_hop": 216, "num": "0x254f324a", "position": {"altitude": 1397, "latitude": 33.275139, "location_source": "LOC_INTERNAL", "longitude": -107.357344, "time_offset_sec": 2129}, "public_key_hex": "795135c1145511f52d74d5d514c093664c59f21d3e04edead88d9dd214f4b25e", "role": "SENSOR", "short_name": "C9TT", "snr": 7.9, "status": null, "telemetry": {"air_util_tx": 0.38, "battery_level": 42, "channel_utilization": 2.44, "uptime_seconds": 236563, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3234, "long_name": "Dusk Badger", "next_hop": 0, "num": "0x255305c9", "position": {"altitude": 1242, "latitude": 32.824432, "location_source": "LOC_INTERNAL", "longitude": -107.915833, "time_offset_sec": 3515}, "public_key_hex": "d37e626405d23d42bf2d2f22e5402aeffa68aafce877cfef568c6defc51c0c41", "role": "ROUTER", "short_name": "D6YP", "snr": -3.79, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.265, "battery_level": 21, "channel_utilization": 28.01, "uptime_seconds": 80253, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 399, "long_name": "Green Cactus", "next_hop": 0, "num": "0x255c1fa9", "position": {"altitude": 1605, "latitude": 33.453106, "location_source": "LOC_INTERNAL", "longitude": -107.504543, "time_offset_sec": 466}, "public_key_hex": "108137d4817e929ce3aedf5df90a7375604f2d4dcff0ede9279df4fa1b410000", "role": "CLIENT", "short_name": "GK72", "snr": 3.46, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 1593, "long_name": "Forest Moose", "next_hop": 0, "num": "0x255ce8a9", "position": {"altitude": 1108, "latitude": 33.290787, "location_source": "LOC_INTERNAL", "longitude": -106.894719, "time_offset_sec": 1618}, "public_key_hex": "374fc64a5ced3ce52e9f7050b9f475fc3413618f93e247f657c9137df68c3793", "role": "ROUTER_LATE", "short_name": "FSDV", "snr": 7.12, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.463, "battery_level": 69, "channel_utilization": 9.99, "uptime_seconds": 31809, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3416, "long_name": "Drowsy Moose", "next_hop": 181, "num": "0x2593e9dc", "position": {"altitude": 952, "latitude": 33.042726, "location_source": "LOC_INTERNAL", "longitude": -107.833348, "time_offset_sec": 3521}, "public_key_hex": "ff2e083de8b171b96d4cc8f7b4b03aacfb74fd44579d025b17c57c85e83e8994", "role": "CLIENT_BASE", "short_name": "DQFS", "snr": 3.05, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.427, "battery_level": 59, "channel_utilization": 2.66, "uptime_seconds": 94760, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 6827, "long_name": "Slow Elk", "next_hop": 0, "num": "0x259fbab9", "position": {"altitude": 1583, "latitude": 33.274984, "location_source": "LOC_INTERNAL", "longitude": -105.918352, "time_offset_sec": 7125}, "public_key_hex": "da066b27de4f2212df43fb9e13e2e7293520a414ec2dbbc71070946904465bed", "role": "CLIENT", "short_name": "SURJ", "snr": 8.37, "status": null, "telemetry": {"air_util_tx": 0.41, "battery_level": 101, "channel_utilization": 7.6, "uptime_seconds": 90926, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 350, "long_name": "Loud Seal", "next_hop": 0, "num": "0x25b8eabe", "position": {"altitude": 1657, "latitude": 32.37813, "location_source": "LOC_INTERNAL", "longitude": -106.413248, "time_offset_sec": 412}, "public_key_hex": "20cdd585203d303e3ef5b97eecde21e973f68754325290ff6c627fc6258ff6a7", "role": "CLIENT", "short_name": "L3Y9", "snr": 11.24, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1026.95, "iaq": 120, "relative_humidity": 47.95, "temperature": 36.13}, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2105, "long_name": "Frozen Dolphin", "next_hop": 76, "num": "0x25c336ba", "position": {"altitude": 1211, "latitude": 32.806639, "location_source": "LOC_INTERNAL", "longitude": -106.58969, "time_offset_sec": 2388}, "public_key_hex": "8cf633922931ff88c8bc24748d0081c8326612eef3dbae1c80094d752db432f6", "role": "CLIENT", "short_name": "FEGD", "snr": 7.37, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.992, "battery_level": 73, "channel_utilization": 5.59, "uptime_seconds": 20376, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5997, "long_name": "Dusk Mustang", "next_hop": 0, "num": "0x25d274c3", "position": null, "public_key_hex": "7c9cb4c7e274dcd9a47486a6803c286dd17df20e0ddc73209489ca8947ecbac6", "role": "CLIENT", "short_name": "🦉", "snr": 4.24, "status": null, "telemetry": {"air_util_tx": 0.287, "battery_level": 35, "channel_utilization": 15.77, "uptime_seconds": 19848, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 457, "long_name": "Smooth Lynx", "next_hop": 22, "num": "0x25deb7e6", "position": {"altitude": 1710, "latitude": 33.585498, "location_source": "LOC_INTERNAL", "longitude": -107.126279, "time_offset_sec": 638}, "public_key_hex": "97488c496951c6f113a498c3431c9a0fda8c4100adb657dd833a77aae77a3633", "role": "CLIENT", "short_name": "S6W4", "snr": 5.78, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.582, "battery_level": 101, "channel_utilization": 3.44, "uptime_seconds": 302707, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2301, "long_name": "Wandering Trout", "next_hop": 242, "num": "0x25e5eee8", "position": {"altitude": 1502, "latitude": 31.712411, "location_source": "LOC_INTERNAL", "longitude": -108.006727, "time_offset_sec": 2594}, "public_key_hex": "", "role": "CLIENT", "short_name": "WN1A", "snr": 10.56, "status": null, "telemetry": {"air_util_tx": 0.404, "battery_level": 23, "channel_utilization": 7.16, "uptime_seconds": 123565, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2809, "long_name": "Stone Cedar", "next_hop": 0, "num": "0x25f3b6b7", "position": null, "public_key_hex": "672a28873e3bf8dd8c2ab3cbdd9cd47a77d03948a685f5befd4e9e8a82b04c41", "role": "CLIENT", "short_name": "SN4S", "snr": 5.18, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 796, "long_name": "Drowsy Bronco", "next_hop": 46, "num": "0x2600943b", "position": {"altitude": 1159, "latitude": 32.984524, "location_source": "LOC_INTERNAL", "longitude": -108.424055, "time_offset_sec": 1056}, "public_key_hex": "678b13980a81ef64ea22ac78eef56376013216a850c090edef013000211f49fa", "role": "CLIENT", "short_name": "🌙", "snr": 7.93, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.93, "battery_level": 101, "channel_utilization": 3.18, "uptime_seconds": 85392, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2647, "long_name": "Happy Crow", "next_hop": 0, "num": "0x261799dd", "position": {"altitude": 1442, "latitude": 33.250556, "location_source": "LOC_INTERNAL", "longitude": -107.04468, "time_offset_sec": 2722}, "public_key_hex": "205954f15ce26a66cb67d665d8b23269bac32ef75df248317a6ad6cd5a495e6e", "role": "CLIENT", "short_name": "HIQC", "snr": 7.77, "status": null, "telemetry": {"air_util_tx": 0.843, "battery_level": 97, "channel_utilization": 12.4, "uptime_seconds": 75037, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2015, "long_name": "Sharp Wolf KE3NX", "next_hop": 95, "num": "0x2621bc79", "position": {"altitude": 1247, "latitude": 32.793256, "location_source": "LOC_INTERNAL", "longitude": -106.788851, "time_offset_sec": 2226}, "public_key_hex": "7de8bd7735676bd5645fc5319b0caaa750f2d2b990ffa365576ea7a73dac6948", "role": "CLIENT", "short_name": "SE7O", "snr": 3.29, "status": null, "telemetry": {"air_util_tx": 0.055, "battery_level": 65, "channel_utilization": 4.02, "uptime_seconds": 84475, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 763, "long_name": "Wandering Gecko", "next_hop": 0, "num": "0x26372870", "position": {"altitude": 1764, "latitude": 31.754687, "location_source": "LOC_INTERNAL", "longitude": -106.622086, "time_offset_sec": 878}, "public_key_hex": "ceecafc5074d1710d04e579a6a1b6e915d239d9fa5de4527ce371664be2cf432", "role": "CLIENT", "short_name": "W8OZ", "snr": -1.57, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.875, "battery_level": 69, "channel_utilization": 5.93, "uptime_seconds": 53974, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.49, "iaq": 47, "relative_humidity": 56.6, "temperature": 24.71}, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 2002, "long_name": "Giant Crow", "next_hop": 0, "num": "0x2648116b", "position": null, "public_key_hex": "1a9cf066e207b16ee999c3be5305efe51f052741873155ba742fd83461cfd670", "role": "CLIENT", "short_name": "GDLE", "snr": 6.26, "status": null, "telemetry": {"air_util_tx": 0.284, "battery_level": 83, "channel_utilization": 15.71, "uptime_seconds": 44517, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3421, "long_name": "Short Cobra", "next_hop": 0, "num": "0x266f503e", "position": {"altitude": 1661, "latitude": 33.621995, "location_source": "LOC_INTERNAL", "longitude": -106.88444, "time_offset_sec": 3567}, "public_key_hex": "3177ff0bac5e015f7ffd8b7bda91e7c9574abe3bd88927b4b28162da448f2edf", "role": "CLIENT", "short_name": "🐺", "snr": -1.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.56, "iaq": 68, "relative_humidity": 36.52, "temperature": 26.61}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3624, "long_name": "New Salmon", "next_hop": 0, "num": "0x2670d344", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "NP1B", "snr": 2.75, "status": null, "telemetry": {"air_util_tx": 0.48, "battery_level": 101, "channel_utilization": 4.81, "uptime_seconds": 22592, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 82, "long_name": "Loud Bluff", "next_hop": 175, "num": "0x26927834", "position": {"altitude": 1160, "latitude": 32.684556, "location_source": "LOC_INTERNAL", "longitude": -106.970786, "time_offset_sec": 93}, "public_key_hex": "9f455cc26458236c63af53e3fb8d888cbe9c55a517f12de7128a5c5bc96e91c7", "role": "CLIENT", "short_name": "L543", "snr": 5.65, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 6055, "long_name": "Storm Bronco", "next_hop": 201, "num": "0x26b12406", "position": {"altitude": 1497, "latitude": 33.009972, "location_source": "LOC_INTERNAL", "longitude": -107.823087, "time_offset_sec": 6202}, "public_key_hex": "2f8d1fae16e92738aed07b3a70e3e127adb4b69c274e524944403a1c44bd09a7", "role": "CLIENT", "short_name": "SOIQ", "snr": 8.84, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1346, "long_name": "Fast Yucca N54DO", "next_hop": 0, "num": "0x26ba34e5", "position": {"altitude": 1286, "latitude": 33.295808, "location_source": "LOC_INTERNAL", "longitude": -107.341732, "time_offset_sec": 1469}, "public_key_hex": "2e172e14da10fb822b6794406d37593e57dd76d18e6cc4dd14d855b0f7ad8b23", "role": "CLIENT", "short_name": "FAWS", "snr": 8.24, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.691, "battery_level": 11, "channel_utilization": 7.8, "uptime_seconds": 60485, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2635, "long_name": "Sneaky Ridge", "next_hop": 0, "num": "0x26c7414d", "position": {"altitude": 1359, "latitude": 33.221581, "location_source": "LOC_INTERNAL", "longitude": -106.247907, "time_offset_sec": 2853}, "public_key_hex": "5a5cec42f8cc599aeb6afb5bf9da9ddb32815f060cc4f598e74f01a36d3e2fc1", "role": "CLIENT", "short_name": "SD8N", "snr": 7.49, "status": null, "telemetry": {"air_util_tx": 0.184, "battery_level": 35, "channel_utilization": 17.94, "uptime_seconds": 63408, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 8188, "long_name": "Steel Coyote", "next_hop": 0, "num": "0x26c93090", "position": {"altitude": 1048, "latitude": 32.924213, "location_source": "LOC_INTERNAL", "longitude": -106.567192, "time_offset_sec": 8344}, "public_key_hex": "8c6a4e4d38ab6e12d091b60b5be2de90725c91ea5d0f79111e3990fde8a89176", "role": "ROUTER", "short_name": "SL7U", "snr": 4.61, "status": null, "telemetry": {"air_util_tx": 0.123, "battery_level": 64, "channel_utilization": 8.91, "uptime_seconds": 66497, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7008, "long_name": "Tiny Mustang", "next_hop": 0, "num": "0x26c9c04a", "position": {"altitude": 1413, "latitude": 33.396332, "location_source": "LOC_INTERNAL", "longitude": -107.89267, "time_offset_sec": 7195}, "public_key_hex": "aae1139d25859d2715359e7d57fc75928f2e33567fc83ac3f0c93bab1f674ab4", "role": "CLIENT", "short_name": "T52C", "snr": 2.17, "status": null, "telemetry": {"air_util_tx": 0.328, "battery_level": 18, "channel_utilization": 14.5, "uptime_seconds": 238610, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 13546, "long_name": "Frosty Turtle", "next_hop": 0, "num": "0x26e3bb43", "position": null, "public_key_hex": "49ff7fd7671cda388253d3c21c57c2acdc6d3f90d8bd6fe760d149b5940d1e80", "role": "CLIENT", "short_name": "F5AJ", "snr": 3.58, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.772, "battery_level": 35, "channel_utilization": 3.74, "uptime_seconds": 58163, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2195, "long_name": "Silver Coyote", "next_hop": 56, "num": "0x26e4b088", "position": null, "public_key_hex": "a3125e982c2a4eef97acc0464686a14da6618ad1c220954d4a517118430abd7c", "role": "CLIENT", "short_name": "🦌", "snr": 4.88, "status": null, "telemetry": {"air_util_tx": 0.658, "battery_level": 20, "channel_utilization": 11.68, "uptime_seconds": 3510, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1018.58, "iaq": 28, "relative_humidity": 40.99, "temperature": 23.02}, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 573, "long_name": "Howling Mamba", "next_hop": 82, "num": "0x27001a7e", "position": {"altitude": 1349, "latitude": 33.41083, "location_source": "LOC_INTERNAL", "longitude": -106.573846, "time_offset_sec": 659}, "public_key_hex": "4de900b7eab65aadaca5c998e2d46fb17e9b555731ebfa3c7a091f83f9d70982", "role": "CLIENT_BASE", "short_name": "HNWX", "snr": 7.37, "status": null, "telemetry": {"air_util_tx": 1.829, "battery_level": 63, "channel_utilization": 13.47, "uptime_seconds": 6389, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 2226, "long_name": "Roving Seal", "next_hop": 204, "num": "0x2705aa2a", "position": {"altitude": 1641, "latitude": 32.730722, "location_source": "LOC_INTERNAL", "longitude": -106.254687, "time_offset_sec": 2390}, "public_key_hex": "6c4f1398a7714c54775e27ccc533538c20ebfa3a4e6ff3a0553a73489f260b7b", "role": "CLIENT", "short_name": "R959", "snr": 2.5, "status": null, "telemetry": {"air_util_tx": 0.41, "battery_level": 52, "channel_utilization": 12.96, "uptime_seconds": 23144, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.41, "iaq": 11, "relative_humidity": 79.51, "temperature": 31.86}, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2502, "long_name": "Soft Pine", "next_hop": 79, "num": "0x2705b3fb", "position": {"altitude": 1098, "latitude": 33.13806, "location_source": "LOC_INTERNAL", "longitude": -107.481968, "time_offset_sec": 2569}, "public_key_hex": "", "role": "CLIENT", "short_name": "S8B5", "snr": 2.94, "status": null, "telemetry": {"air_util_tx": 0.306, "battery_level": 66, "channel_utilization": 11.93, "uptime_seconds": 110841, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 141, "long_name": "Blue Crow", "next_hop": 0, "num": "0x2707a277", "position": {"altitude": 1324, "latitude": 34.69246, "location_source": "LOC_INTERNAL", "longitude": -107.719968, "time_offset_sec": 425}, "public_key_hex": "8b864a30aa0b5a721e3f58ce2373cc501aa1f1f1b8959a113e3018597dd94d20", "role": "SENSOR", "short_name": "BHRZ", "snr": 8.35, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.675, "battery_level": 23, "channel_utilization": 11.26, "uptime_seconds": 21095, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1110, "long_name": "Lunar Bronco", "next_hop": 0, "num": "0x2708e438", "position": {"altitude": 1337, "latitude": 32.954496, "location_source": "LOC_INTERNAL", "longitude": -106.86024, "time_offset_sec": 1172}, "public_key_hex": "bacc2c12ef0a981cb4bd780d58f01e051f391539003aecd82a2fd285ec0eeea6", "role": "CLIENT", "short_name": "L5US", "snr": 0.31, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1012.86, "iaq": 82, "relative_humidity": 60.99, "temperature": 16.0}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 5716, "long_name": "Happy Sage", "next_hop": 71, "num": "0x2717579c", "position": {"altitude": 1353, "latitude": 32.385541, "location_source": "LOC_INTERNAL", "longitude": -107.98721, "time_offset_sec": 5868}, "public_key_hex": "2e890cc8b1f39685809b6ab428cbcf6496939255caa83447a1b6ba16a1e5821e", "role": "CLIENT", "short_name": "🦌", "snr": -1.35, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3509, "long_name": "Dawn Turtle", "next_hop": 229, "num": "0x2726b0cb", "position": null, "public_key_hex": "e5c338c0366f6f1cbee64e2f0d29c380cf475fecbb136bafc260b2fa5d0011b4", "role": "CLIENT", "short_name": "D6CV", "snr": 7.45, "status": null, "telemetry": {"air_util_tx": 0.264, "battery_level": 60, "channel_utilization": 11.9, "uptime_seconds": 120169, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1797, "long_name": "New Hare", "next_hop": 140, "num": "0x2731b90e", "position": {"altitude": 1323, "latitude": 32.388623, "location_source": "LOC_INTERNAL", "longitude": -107.811004, "time_offset_sec": 1960}, "public_key_hex": "62903a5fef9bcb0689898ebf6d351195ba8282b8dfd93018a57bf88b9cc295a8", "role": "CLIENT_HIDDEN", "short_name": "NUOE", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.637, "battery_level": 31, "channel_utilization": 10.52, "uptime_seconds": 240481, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 3133, "long_name": "Happy Squirrel", "next_hop": 0, "num": "0x27449586", "position": null, "public_key_hex": "a62888142f8a1163246e1539cf666a6d11e536ae214bc3874c5244b8c3006e47", "role": "CLIENT", "short_name": "HVK5", "snr": 5.67, "status": null, "telemetry": {"air_util_tx": 0.853, "battery_level": 80, "channel_utilization": 9.35, "uptime_seconds": 24726, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.63, "iaq": 67, "relative_humidity": 38.48, "temperature": 16.98}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3036, "long_name": "Shady Tortoise KE1BJ", "next_hop": 0, "num": "0x2747db4a", "position": {"altitude": 1610, "latitude": 32.275135, "location_source": "LOC_INTERNAL", "longitude": -107.561811, "time_offset_sec": 3075}, "public_key_hex": "6137e4b8b413b6a5871d082e86b43946a3441426e3f159a3600c1e727ee6f013", "role": "CLIENT", "short_name": "SBYY", "snr": 5.47, "status": null, "telemetry": {"air_util_tx": 0.612, "battery_level": 22, "channel_utilization": 8.15, "uptime_seconds": 110868, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 12157, "long_name": "Sky Doe", "next_hop": 168, "num": "0x274f9713", "position": {"altitude": 1449, "latitude": 32.658219, "location_source": "LOC_INTERNAL", "longitude": -107.003311, "time_offset_sec": 12279}, "public_key_hex": "", "role": "CLIENT", "short_name": "SD2M", "snr": 10.31, "status": null, "telemetry": {"air_util_tx": 0.639, "battery_level": 101, "channel_utilization": 7.57, "uptime_seconds": 337686, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 9036, "long_name": "Loud Mustang", "next_hop": 0, "num": "0x2796a929", "position": {"altitude": 1648, "latitude": 33.64773, "location_source": "LOC_INTERNAL", "longitude": -107.199807, "time_offset_sec": 9266}, "public_key_hex": "9438aafdb2b7990a7cd48dfb816c02de00eea7c43584a28f8686c565237e8817", "role": "CLIENT_BASE", "short_name": "LI1U", "snr": -1.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 709, "long_name": "Brave Mustang", "next_hop": 0, "num": "0x27a46ffa", "position": {"altitude": 1284, "latitude": 34.216471, "location_source": "LOC_INTERNAL", "longitude": -107.74691, "time_offset_sec": 873}, "public_key_hex": "4b56426853160c1ae50a5b1d325b113557d633c5a107513b81c6154449b6735e", "role": "TRACKER", "short_name": "BBDO", "snr": 1.99, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.303, "battery_level": 37, "channel_utilization": 19.54, "uptime_seconds": 61998, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 4200, "long_name": "Stone Fox", "next_hop": 206, "num": "0x27a9b9db", "position": {"altitude": 1569, "latitude": 32.42259, "location_source": "LOC_INTERNAL", "longitude": -107.353016, "time_offset_sec": 4291}, "public_key_hex": "", "role": "CLIENT", "short_name": "SBQA", "snr": 9.71, "status": null, "telemetry": {"air_util_tx": 0.152, "battery_level": 43, "channel_utilization": 18.23, "uptime_seconds": 255235, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 262, "long_name": "New Lynx", "next_hop": 90, "num": "0x27ab3921", "position": {"altitude": 1259, "latitude": 32.84367, "location_source": "LOC_INTERNAL", "longitude": -107.60199, "time_offset_sec": 486}, "public_key_hex": "12acda79ff517a7849864086b2b49eabfd74ee5b89e6c9b16f485ed4df6b4372", "role": "CLIENT", "short_name": "NYYQ", "snr": 1.94, "status": null, "telemetry": {"air_util_tx": 0.941, "battery_level": 15, "channel_utilization": 10.71, "uptime_seconds": 186754, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.8, "iaq": 94, "relative_humidity": 38.53, "temperature": 21.81}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 523, "long_name": "Dawn Dolphin", "next_hop": 0, "num": "0x27aece61", "position": {"altitude": 1380, "latitude": 33.263581, "location_source": "LOC_INTERNAL", "longitude": -106.629325, "time_offset_sec": 529}, "public_key_hex": "8267c89624ac5d1d492a69bd8176bffddd589cc567dc159c7ed911c1a601c35b", "role": "CLIENT", "short_name": "DD7Y", "snr": 1.45, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.792, "battery_level": 61, "channel_utilization": 4.4, "uptime_seconds": 172843, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1182, "long_name": "Fast Owl", "next_hop": 19, "num": "0x27c4042a", "position": {"altitude": 1285, "latitude": 32.993955, "location_source": "LOC_INTERNAL", "longitude": -106.638538, "time_offset_sec": 1442}, "public_key_hex": "3c7a501326cb3db4f859e35a53af9207d2a1a3346117c4cddbecfd24eadf3685", "role": "CLIENT", "short_name": "F3ID", "snr": 2.49, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.462, "battery_level": 72, "channel_utilization": 5.55, "uptime_seconds": 7938, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 2613, "long_name": "Sunny Cobra", "next_hop": 0, "num": "0x27ce2ba3", "position": {"altitude": 1504, "latitude": 33.269305, "location_source": "LOC_INTERNAL", "longitude": -107.205908, "time_offset_sec": 2686}, "public_key_hex": "4cbfa24daefc64f16569760db324c914b0c5a6931edb380a15dca6e688c265bf", "role": "CLIENT", "short_name": "S7ZT", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2198, "long_name": "Whispering Cougar", "next_hop": 60, "num": "0x27d0dbb2", "position": {"altitude": 1386, "latitude": 32.634554, "location_source": "LOC_INTERNAL", "longitude": -106.401803, "time_offset_sec": 2478}, "public_key_hex": "2226bacf503630e4d0cb8d1cebab7401ff2074adf5e0dfbe09ff0a886a898975", "role": "CLIENT", "short_name": "WJX7", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.89, "battery_level": 59, "channel_utilization": 13.85, "uptime_seconds": 83436, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1624, "long_name": "Brave Phoenix", "next_hop": 240, "num": "0x27e05bd1", "position": {"altitude": 1352, "latitude": 32.539039, "location_source": "LOC_INTERNAL", "longitude": -107.667061, "time_offset_sec": 1748}, "public_key_hex": "40b6185acf342b5d8b05fc90f091bf3fa21276e059466a984618f7242f8bf086", "role": "CLIENT", "short_name": "BDE8", "snr": 7.77, "status": null, "telemetry": {"air_util_tx": 0.298, "battery_level": 40, "channel_utilization": 28.71, "uptime_seconds": 26801, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 357, "long_name": "Wild Ridge", "next_hop": 63, "num": "0x27e2325f", "position": {"altitude": 1458, "latitude": 32.531134, "location_source": "LOC_INTERNAL", "longitude": -107.823956, "time_offset_sec": 528}, "public_key_hex": "429a81ecbea63632523565a99c426bc4b12991128e000621fe2cbb6b22d730a2", "role": "CLIENT_MUTE", "short_name": "WCNZ", "snr": 6.61, "status": null, "telemetry": {"air_util_tx": 0.242, "battery_level": 66, "channel_utilization": 12.2, "uptime_seconds": 183742, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.59, "iaq": 88, "relative_humidity": 78.29, "temperature": 18.69}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4448, "long_name": "Slow Juniper", "next_hop": 62, "num": "0x2814ab3d", "position": null, "public_key_hex": "d5a89be826d76fbff7e25d97347332bf833b9cafd6fb27861137d19883e4d95c", "role": "CLIENT", "short_name": "SOSF", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.177, "battery_level": 101, "channel_utilization": 15.04, "uptime_seconds": 16552, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.81, "iaq": 40, "relative_humidity": 100.0, "temperature": 28.86}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 591, "long_name": "Drifting Raven", "next_hop": 197, "num": "0x2835855a", "position": {"altitude": 1327, "latitude": 33.271159, "location_source": "LOC_INTERNAL", "longitude": -107.736011, "time_offset_sec": 594}, "public_key_hex": "143d9de5b94da5b51e536d68bb022455c60d4ff98208cee980de756760537b95", "role": "CLIENT_MUTE", "short_name": "DYQW", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 2.545, "battery_level": 81, "channel_utilization": 14.43, "uptime_seconds": 41203, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 718, "long_name": "Howling Yucca", "next_hop": 0, "num": "0x2845b0ca", "position": {"altitude": 1280, "latitude": 32.325516, "location_source": "LOC_INTERNAL", "longitude": -106.698859, "time_offset_sec": 872}, "public_key_hex": "512ce7822a9067c908ab9a67d19f1e45d8bc827df88bd725e7b5c77ee8e242ef", "role": "CLIENT", "short_name": "H8UX", "snr": 3.94, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1214, "long_name": "Lunar Mustang", "next_hop": 0, "num": "0x284c5f81", "position": {"altitude": 1420, "latitude": 34.318646, "location_source": "LOC_INTERNAL", "longitude": -107.544146, "time_offset_sec": 1284}, "public_key_hex": "5a164a2140fbfc23030aba33541a43e7719faaf9d5399ff8206a04d9d70fbb82", "role": "CLIENT", "short_name": "L97Y", "snr": 9.77, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.452, "battery_level": 72, "channel_utilization": 5.6, "uptime_seconds": 64836, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1509, "long_name": "Lone Mamba", "next_hop": 0, "num": "0x285746d8", "position": {"altitude": 1735, "latitude": 32.884754, "location_source": "LOC_INTERNAL", "longitude": -106.796915, "time_offset_sec": 1551}, "public_key_hex": "f18ffc578844ea93a28d7f214cd1c6a5518156f80802dd4f5891ffcbc70689b5", "role": "CLIENT", "short_name": "LRE0", "snr": 0.6, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2792, "long_name": "Happy Cobra", "next_hop": 0, "num": "0x2857b3b4", "position": {"altitude": 1807, "latitude": 34.296841, "location_source": "LOC_INTERNAL", "longitude": -107.661656, "time_offset_sec": 3015}, "public_key_hex": "e09e88b8dbcc8b42f9b1be89bb9a50bd29aedda27644f73ff6117c104b489384", "role": "CLIENT", "short_name": "HKR6", "snr": 6.76, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.532, "battery_level": 19, "channel_utilization": 24.64, "uptime_seconds": 378615, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 2774, "long_name": "Roving Gecko", "next_hop": 0, "num": "0x2876c673", "position": {"altitude": 1424, "latitude": 32.854891, "location_source": "LOC_INTERNAL", "longitude": -106.958211, "time_offset_sec": 2788}, "public_key_hex": "2326e87c9a5321689bdfa1340aaa64943ab1a8faa8f29a709c878b480231c225", "role": "CLIENT", "short_name": "RMWC", "snr": 9.48, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 4519, "long_name": "Soft Cobra AE2RW", "next_hop": 0, "num": "0x2879d2e4", "position": {"altitude": 936, "latitude": 33.748682, "location_source": "LOC_INTERNAL", "longitude": -106.59816, "time_offset_sec": 4609}, "public_key_hex": "81b18ed9ec3b631c9e049bee3bd4488f9b1bf11ae5d527fe9ac1764823fb517f", "role": "CLIENT", "short_name": "SDIE", "snr": 8.39, "status": null, "telemetry": {"air_util_tx": 0.992, "battery_level": 90, "channel_utilization": 0.18, "uptime_seconds": 13599, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 5246, "long_name": "Solar Hawk", "next_hop": 0, "num": "0x288fcbe6", "position": null, "public_key_hex": "b6fbc25b6ed7f0857abe38289486c81308c5d880da17df72f997f7951f30d82b", "role": "CLIENT", "short_name": "SHDS", "snr": 5.59, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 10641, "long_name": "Smooth Phoenix", "next_hop": 67, "num": "0x28a470e4", "position": {"altitude": 1282, "latitude": 33.72918, "location_source": "LOC_INTERNAL", "longitude": -107.187111, "time_offset_sec": 10669}, "public_key_hex": "6263794711111aa6613f22c4320ba964950af7ad0c5474b771835e790096f579", "role": "CLIENT", "short_name": "SK16", "snr": 5.26, "status": null, "telemetry": {"air_util_tx": 0.473, "battery_level": 37, "channel_utilization": 19.78, "uptime_seconds": 70172, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 7127, "long_name": "Stone Salmon W56EK", "next_hop": 83, "num": "0x28a85c1a", "position": {"altitude": 1361, "latitude": 33.03585, "location_source": "LOC_INTERNAL", "longitude": -108.251296, "time_offset_sec": 7265}, "public_key_hex": "84b2ecccdc9bf77808aae00673266414accbe4eeaf9dfb117ac2cad56432b139", "role": "CLIENT", "short_name": "S7GV", "snr": 6.13, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6844, "long_name": "Shady Wolf", "next_hop": 0, "num": "0x28be067a", "position": {"altitude": 1596, "latitude": 33.004934, "location_source": "LOC_INTERNAL", "longitude": -106.76098, "time_offset_sec": 7003}, "public_key_hex": "1874c67276293eaa4a8fd26565d5923f45fd3932d29aaba6c33de3e2d98df295", "role": "CLIENT", "short_name": "S41L", "snr": 11.84, "status": null, "telemetry": {"air_util_tx": 0.568, "battery_level": 28, "channel_utilization": 5.09, "uptime_seconds": 24951, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1664, "long_name": "Brave Fox", "next_hop": 0, "num": "0x28c421cb", "position": null, "public_key_hex": "64b0d1dde2436820df898c40cf2de6cb7a19d6d1d8b37a0c96590c2d814792d6", "role": "CLIENT", "short_name": "B8TS", "snr": 10.13, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.23, "battery_level": 47, "channel_utilization": 3.75, "uptime_seconds": 342244, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7498, "long_name": "Hidden Iguana", "next_hop": 0, "num": "0x28d92bb4", "position": {"altitude": 1256, "latitude": 33.169701, "location_source": "LOC_INTERNAL", "longitude": -107.524861, "time_offset_sec": 7786}, "public_key_hex": "e5071089c8bbc119301f3e01dc3b510250f3003df982ae2628cce915f337d304", "role": "CLIENT", "short_name": "HPDU", "snr": 2.38, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 305, "long_name": "Howling Moose", "next_hop": 0, "num": "0x28e6ec57", "position": null, "public_key_hex": "8e8d7e69114882abcd67733ffcbfb4d48c962a6627ed5fe0d254c6978bc6430b", "role": "CLIENT", "short_name": "HTWJ", "snr": -3.55, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.39, "battery_level": 53, "channel_utilization": 27.41, "uptime_seconds": 75998, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 870, "long_name": "Floating Colt", "next_hop": 0, "num": "0x28ec8ffd", "position": {"altitude": 1139, "latitude": 33.593696, "location_source": "LOC_INTERNAL", "longitude": -106.851509, "time_offset_sec": 959}, "public_key_hex": "b2e8cf410d0fbc22ca0c473c6a0022d62624609bf1fbc6ea0aaf51d588a47ce7", "role": "TRACKER", "short_name": "FOAY", "snr": 3.43, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 12248, "long_name": "Frozen Crow", "next_hop": 0, "num": "0x28ed1da6", "position": null, "public_key_hex": "f619820a47527b9aa42503bd8f5442965d8baf6334e8021e8ac55ac1bf4de927", "role": "TRACKER", "short_name": "FQO0", "snr": 8.33, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.068, "battery_level": 63, "channel_utilization": 6.67, "uptime_seconds": 52067, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 272, "long_name": "Hidden Pine", "next_hop": 47, "num": "0x28f4f78c", "position": {"altitude": 1026, "latitude": 32.426692, "location_source": "LOC_INTERNAL", "longitude": -106.802185, "time_offset_sec": 409}, "public_key_hex": "9d2ef6e5c357d5001cf41ddceda9f1f9da4a2afc84e3fc7782a2956648068b11", "role": "CLIENT", "short_name": "H9I5", "snr": 4.41, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.528, "battery_level": 101, "channel_utilization": 1.91, "uptime_seconds": 15496, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1090, "long_name": "Stone Cactus", "next_hop": 0, "num": "0x292fb7db", "position": null, "public_key_hex": "4c651daaf4f4a2cb7615058151787d3c4bc5f421346f8c672ce8190541dce5be", "role": "CLIENT", "short_name": "🦉", "snr": 6.76, "status": null, "telemetry": {"air_util_tx": 0.163, "battery_level": 54, "channel_utilization": 14.17, "uptime_seconds": 9002, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 3976, "long_name": "Rough Salmon", "next_hop": 152, "num": "0x29390c66", "position": {"altitude": 1533, "latitude": 32.709447, "location_source": "LOC_INTERNAL", "longitude": -107.433434, "time_offset_sec": 4271}, "public_key_hex": "2ebc416abc1333ac45287bbf6454655b4264600508e56d9a9b925bcd8f9dea14", "role": "CLIENT", "short_name": "RWEI", "snr": -1.49, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.087, "battery_level": 73, "channel_utilization": 12.54, "uptime_seconds": 136553, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1019.14, "iaq": 61, "relative_humidity": 100.0, "temperature": 19.13}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2641, "long_name": "Shady Oak", "next_hop": 2, "num": "0x2942e8ff", "position": {"altitude": 1629, "latitude": 32.525827, "location_source": "LOC_INTERNAL", "longitude": -106.855726, "time_offset_sec": 2895}, "public_key_hex": "ee28551a385e052535da9bd9066b54e755d55abcd128f8c52ea8f53242622794", "role": "CLIENT", "short_name": "SFT7", "snr": 4.27, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1028.38, "iaq": 56, "relative_humidity": 26.17, "temperature": 17.99}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 396, "long_name": "Stone Phoenix", "next_hop": 0, "num": "0x2978f65f", "position": {"altitude": 1241, "latitude": 33.163556, "location_source": "LOC_INTERNAL", "longitude": -107.52824, "time_offset_sec": 605}, "public_key_hex": "b72e270354da76584176e94a941de7188a65cab420002149df24a5989f1eeca3", "role": "CLIENT", "short_name": "SI1P", "snr": 2.81, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "XIAO_NRF52_KIT", "last_heard_offset_sec": 4835, "long_name": "Green Bison", "next_hop": 0, "num": "0x298d0a6d", "position": {"altitude": 1315, "latitude": 33.3376, "location_source": "LOC_INTERNAL", "longitude": -107.116081, "time_offset_sec": 5047}, "public_key_hex": "329952279714d976b05cd5d0a9a4eabe6bae9ba20b4c6cb95270767a7435bdd6", "role": "CLIENT", "short_name": "GNNL", "snr": 6.32, "status": null, "telemetry": {"air_util_tx": 0.249, "battery_level": 47, "channel_utilization": 17.31, "uptime_seconds": 6053, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 3449, "long_name": "Dusk Cobra", "next_hop": 187, "num": "0x29ab527f", "position": {"altitude": 1401, "latitude": 32.566591, "location_source": "LOC_INTERNAL", "longitude": -107.385752, "time_offset_sec": 3506}, "public_key_hex": "", "role": "CLIENT", "short_name": "DD82", "snr": 9.8, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.895, "battery_level": 62, "channel_utilization": 4.95, "uptime_seconds": 97946, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 7, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3352, "long_name": "Sunny Mole", "next_hop": 31, "num": "0x29cd0f01", "position": {"altitude": 1324, "latitude": 32.346316, "location_source": "LOC_INTERNAL", "longitude": -106.827423, "time_offset_sec": 3545}, "public_key_hex": "925331768f1a4e26dd794f09a7042313c842e00de3be896535e952fe45fb8440", "role": "CLIENT_BASE", "short_name": "S4TX", "snr": 7.77, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.001, "battery_level": 101, "channel_utilization": 8.09, "uptime_seconds": 62655, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1215, "long_name": "Floating Lynx", "next_hop": 0, "num": "0x29d82913", "position": {"altitude": 1000, "latitude": 33.164977, "location_source": "LOC_INTERNAL", "longitude": -107.522521, "time_offset_sec": 1303}, "public_key_hex": "52a81bfdd290b24b5c8d8852fe7617aab7da86a69a316a2702cd4e5033ba1487", "role": "CLIENT", "short_name": "FRUG", "snr": 6.21, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.27, "battery_level": 37, "channel_utilization": 0.68, "uptime_seconds": 89206, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 974, "long_name": "Loud Salmon", "next_hop": 203, "num": "0x2a008ed9", "position": {"altitude": 1171, "latitude": 33.114492, "location_source": "LOC_INTERNAL", "longitude": -107.330062, "time_offset_sec": 1182}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦅", "snr": 4.06, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 550, "long_name": "Silent Stag", "next_hop": 60, "num": "0x2a090563", "position": {"altitude": 1523, "latitude": 33.340555, "location_source": "LOC_INTERNAL", "longitude": -107.35908, "time_offset_sec": 580}, "public_key_hex": "560b03d2d60099e3e0c096ca474d521400ef76632c484716e8bba130160cc6b2", "role": "ROUTER", "short_name": "S3DQ", "snr": 5.66, "status": null, "telemetry": {"air_util_tx": 0.597, "battery_level": 78, "channel_utilization": 5.88, "uptime_seconds": 200721, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3347, "long_name": "Steel Shark", "next_hop": 0, "num": "0x2a469153", "position": {"altitude": 1481, "latitude": 32.617221, "location_source": "LOC_INTERNAL", "longitude": -107.53048, "time_offset_sec": 3629}, "public_key_hex": "4f42c7dcb14225f637540acc78ddab3fb3a88cc55d190dead89a61b81e2d3de9", "role": "CLIENT", "short_name": "SDCI", "snr": 1.47, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2069, "long_name": "Giant Crane WD7XT", "next_hop": 0, "num": "0x2a4b6184", "position": {"altitude": 1547, "latitude": 33.217025, "location_source": "LOC_INTERNAL", "longitude": -107.470891, "time_offset_sec": 2115}, "public_key_hex": "5a7265b7ab89995750cfa215b9f4d48d3bc32b2b82f2afd453d4adb9a0adeef3", "role": "CLIENT", "short_name": "🔥", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.558, "battery_level": 54, "channel_utilization": 4.84, "uptime_seconds": 109914, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 318, "long_name": "Canyon Heron", "next_hop": 252, "num": "0x2a4b6c0e", "position": {"altitude": 1058, "latitude": 32.977503, "location_source": "LOC_INTERNAL", "longitude": -107.363725, "time_offset_sec": 380}, "public_key_hex": "b09ccbed89b9e3e654eb98017277258d7b9f570edde5a65a6f4358b37a1f9dd8", "role": "CLIENT", "short_name": "CEUD", "snr": 6.95, "status": null, "telemetry": {"air_util_tx": 0.39, "battery_level": 64, "channel_utilization": 9.12, "uptime_seconds": 73042, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.51, "iaq": 65, "relative_humidity": 100.0, "temperature": 25.22}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 355, "long_name": "Misty Whale", "next_hop": 0, "num": "0x2a65276d", "position": {"altitude": 1527, "latitude": 33.050083, "location_source": "LOC_INTERNAL", "longitude": -107.382912, "time_offset_sec": 617}, "public_key_hex": "5450ae73f5b2e14c13a806eb3ed204f77f3de2e2b9cc6637d0f070148c60b845", "role": "CLIENT", "short_name": "MLQN", "snr": 3.8, "status": null, "telemetry": {"air_util_tx": 0.645, "battery_level": 92, "channel_utilization": 21.9, "uptime_seconds": 21967, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.14, "iaq": 20, "relative_humidity": 16.34, "temperature": 3.28}, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 432, "long_name": "Hidden Seal", "next_hop": 66, "num": "0x2a6c766d", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "H5NI", "snr": 9.37, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.264, "battery_level": 89, "channel_utilization": 9.78, "uptime_seconds": 23591, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4325, "long_name": "Burning Moose", "next_hop": 115, "num": "0x2a712542", "position": {"altitude": 1360, "latitude": 33.082566, "location_source": "LOC_INTERNAL", "longitude": -107.16224, "time_offset_sec": 4332}, "public_key_hex": "629988a5736b337ae38a54dd9964f8105cd21135c16fae7f11acad29cde5f86b", "role": "CLIENT_MUTE", "short_name": "B6UJ", "snr": 5.75, "status": null, "telemetry": {"air_util_tx": 0.058, "battery_level": 37, "channel_utilization": 14.31, "uptime_seconds": 3448, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3256, "long_name": "Whispering Salmon", "next_hop": 0, "num": "0x2a9aafbc", "position": {"altitude": 1048, "latitude": 33.736415, "location_source": "LOC_INTERNAL", "longitude": -107.472025, "time_offset_sec": 3330}, "public_key_hex": "f958c98fdb02389279164925be6d53bb6665a6fba9929ed8068b1d36bd6f3c65", "role": "ROUTER", "short_name": "W2CT", "snr": -2.06, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 860, "long_name": "Tall Hawk", "next_hop": 0, "num": "0x2a9f3826", "position": {"altitude": 1546, "latitude": 33.144563, "location_source": "LOC_INTERNAL", "longitude": -108.003274, "time_offset_sec": 1025}, "public_key_hex": "c54aa6f70e6ea178b447c72ee9b7bc9b5e0b25dc4b667f5fe7f2b5a86824ccb1", "role": "CLIENT", "short_name": "T4N7", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.287, "battery_level": 82, "channel_utilization": 13.91, "uptime_seconds": 97178, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 8216, "long_name": "Hidden Wolf", "next_hop": 243, "num": "0x2aad1c7d", "position": {"altitude": 1164, "latitude": 32.965734, "location_source": "LOC_INTERNAL", "longitude": -107.053093, "time_offset_sec": 8436}, "public_key_hex": "4d3e28a1fccfa13cb46fbebe6668f58e5582d095b53c69db06e8e034b9d62b94", "role": "CLIENT", "short_name": "H82Y", "snr": 3.87, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1597, "long_name": "Black Crane", "next_hop": 187, "num": "0x2abf7de9", "position": {"altitude": 1369, "latitude": 33.529041, "location_source": "LOC_INTERNAL", "longitude": -106.61087, "time_offset_sec": 1676}, "public_key_hex": "0d5f2d2a00431145d117b5192b65f99840758a101f6648408bc9ee5f7b156e19", "role": "CLIENT", "short_name": "B72M", "snr": 6.02, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.4, "battery_level": 69, "channel_utilization": 16.74, "uptime_seconds": 7016, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 2216, "long_name": "Storm Moose", "next_hop": 177, "num": "0x2aeb22fe", "position": {"altitude": 1596, "latitude": 32.803207, "location_source": "LOC_INTERNAL", "longitude": -106.649604, "time_offset_sec": 2398}, "public_key_hex": "8ff8ad047b0a08d3ba5e852087bd35fe2f8d9dfdc6b52a1c90cb4c4416d03984", "role": "CLIENT", "short_name": "S2N2", "snr": 4.99, "status": null, "telemetry": {"air_util_tx": 0.857, "battery_level": 79, "channel_utilization": 11.16, "uptime_seconds": 95964, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 1545, "long_name": "Misty Bear", "next_hop": 5, "num": "0x2aecc76a", "position": null, "public_key_hex": "835210ee35c1110817bf505744bd77ca1c46feb9752b4a20c7be82b74efc34b0", "role": "CLIENT", "short_name": "M6UP", "snr": 7.25, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.877, "battery_level": 21, "channel_utilization": 12.51, "uptime_seconds": 78039, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1382, "long_name": "Storm Owl", "next_hop": 0, "num": "0x2af2fe41", "position": {"altitude": 2114, "latitude": 33.745451, "location_source": "LOC_INTERNAL", "longitude": -107.404995, "time_offset_sec": 1598}, "public_key_hex": "e58c6338fe92990a45d57692f94555e460b2fe6f1acf7eb97ab679213d648dfd", "role": "CLIENT", "short_name": "S0H3", "snr": 5.89, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.675, "battery_level": 85, "channel_utilization": 2.45, "uptime_seconds": 170829, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1008.94, "iaq": 9, "relative_humidity": 54.07, "temperature": 26.44}, "hops_away": 1, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 1191, "long_name": "New Crane", "next_hop": 170, "num": "0x2b04a77e", "position": {"altitude": 1964, "latitude": 32.342715, "location_source": "LOC_INTERNAL", "longitude": -106.776791, "time_offset_sec": 1437}, "public_key_hex": "4e5e44749f4bd609d6dcd56281357b48b4ec8e724a4105ede10d2cfadd6adfbb", "role": "CLIENT", "short_name": "🗻", "snr": 0.95, "status": null, "telemetry": {"air_util_tx": 1.317, "battery_level": 39, "channel_utilization": 19.46, "uptime_seconds": 277586, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4954, "long_name": "Roving Cactus KX4RE", "next_hop": 0, "num": "0x2b0562c1", "position": {"altitude": 1132, "latitude": 31.852245, "location_source": "LOC_INTERNAL", "longitude": -107.096458, "time_offset_sec": 5034}, "public_key_hex": "b242f93f67943e95a3a9cbe6feb62a1a66d1f13286d126dee128302412cd56bf", "role": "CLIENT_MUTE", "short_name": "🔥", "snr": 9.4, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.472, "battery_level": 68, "channel_utilization": 26.57, "uptime_seconds": 78858, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1014.77, "iaq": 68, "relative_humidity": 78.11, "temperature": 20.83}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2015, "long_name": "New Bison", "next_hop": 0, "num": "0x2b102500", "position": {"altitude": 1293, "latitude": 32.471828, "location_source": "LOC_INTERNAL", "longitude": -108.154152, "time_offset_sec": 2315}, "public_key_hex": "050c36720cda79da568ee98f2b85105d2b1a136b42ac01c9593e39bdc1a3cf2e", "role": "CLIENT_MUTE", "short_name": "NOJ6", "snr": 1.43, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1656, "long_name": "Frosty Cedar", "next_hop": 0, "num": "0x2b1cbf94", "position": {"altitude": 903, "latitude": 34.237092, "location_source": "LOC_INTERNAL", "longitude": -107.455709, "time_offset_sec": 1922}, "public_key_hex": "f4ca57c969caa5205ae96521f52b208fff5ff88e728a45220f5b878325e4d09b", "role": "ROUTER_LATE", "short_name": "FVFM", "snr": 1.02, "status": null, "telemetry": {"air_util_tx": 0.445, "battery_level": 50, "channel_utilization": 11.29, "uptime_seconds": 101844, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2997, "long_name": "Hidden Moose", "next_hop": 69, "num": "0x2b247f3b", "position": {"altitude": 979, "latitude": 33.657396, "location_source": "LOC_INTERNAL", "longitude": -106.403461, "time_offset_sec": 3107}, "public_key_hex": "8c430e105b745cb2cd6860dc78d724e6fedeccc4c7918740ce28088d396d0dfe", "role": "CLIENT", "short_name": "HKNV", "snr": 8.4, "status": null, "telemetry": {"air_util_tx": 1.377, "battery_level": 65, "channel_utilization": 6.37, "uptime_seconds": 144796, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1000.04, "iaq": 29, "relative_humidity": 73.57, "temperature": 25.03}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 199, "long_name": "Drowsy Mamba", "next_hop": 0, "num": "0x2b2a875f", "position": {"altitude": 1582, "latitude": 33.912526, "location_source": "LOC_INTERNAL", "longitude": -107.698702, "time_offset_sec": 386}, "public_key_hex": "fd88a4a09c7b14ab2051739f99c593b43c62c0b20aa89739ba47a92061c198cc", "role": "CLIENT", "short_name": "DPQ1", "snr": 11.61, "status": {"status": "weak-signal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.97, "iaq": 55, "relative_humidity": 25.93, "temperature": 12.89}, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 1374, "long_name": "Found Aspen", "next_hop": 19, "num": "0x2b32ba01", "position": {"altitude": 1417, "latitude": 32.374699, "location_source": "LOC_INTERNAL", "longitude": -107.618122, "time_offset_sec": 1532}, "public_key_hex": "", "role": "CLIENT", "short_name": "FVS7", "snr": 9.8, "status": null, "telemetry": {"air_util_tx": 0.392, "battery_level": 50, "channel_utilization": 2.89, "uptime_seconds": 7389, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 16610, "long_name": "Whispering Viper", "next_hop": 0, "num": "0x2b45678e", "position": {"altitude": 1533, "latitude": 33.126593, "location_source": "LOC_INTERNAL", "longitude": -107.081264, "time_offset_sec": 16666}, "public_key_hex": "bedaf3c63de73987514b430c466232279d048e9b186135ea95c34ff0db6d40b3", "role": "CLIENT", "short_name": "🌲", "snr": 1.0, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.489, "battery_level": 97, "channel_utilization": 2.99, "uptime_seconds": 135210, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1032.92, "iaq": 51, "relative_humidity": 68.82, "temperature": 25.97}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1783, "long_name": "Blue Yucca", "next_hop": 168, "num": "0x2b477d1e", "position": {"altitude": 1540, "latitude": 33.019228, "location_source": "LOC_INTERNAL", "longitude": -107.061321, "time_offset_sec": 1883}, "public_key_hex": "0f655f4262a300af1eddd4de0ff9f61098f7746715a64a4fcfcbd83e8564a829", "role": "ROUTER", "short_name": "B3M1", "snr": 2.86, "status": null, "telemetry": {"air_util_tx": 0.637, "battery_level": 41, "channel_utilization": 13.11, "uptime_seconds": 119383, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 902, "long_name": "Hidden Gecko", "next_hop": 11, "num": "0x2b4c40d9", "position": {"altitude": 1234, "latitude": 33.391459, "location_source": "LOC_INTERNAL", "longitude": -106.649728, "time_offset_sec": 1117}, "public_key_hex": "b3b3c48ae5472e809fbb39316f73b373d45f6e1118fd6d230048271405165ce2", "role": "CLIENT", "short_name": "HVGM", "snr": 6.9, "status": null, "telemetry": {"air_util_tx": 0.019, "battery_level": 20, "channel_utilization": 26.78, "uptime_seconds": 332913, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 3672, "long_name": "Lone Seal", "next_hop": 11, "num": "0x2b4d82be", "position": {"altitude": 1261, "latitude": 33.541619, "location_source": "LOC_INTERNAL", "longitude": -106.98414, "time_offset_sec": 3709}, "public_key_hex": "1e8ff2196876d6c161e7afc4546924daa82f9544f53296e7e5a8662ebf709c41", "role": "CLIENT", "short_name": "LW96", "snr": 7.82, "status": null, "telemetry": {"air_util_tx": 0.663, "battery_level": 42, "channel_utilization": 6.02, "uptime_seconds": 142705, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.11, "iaq": 5, "relative_humidity": 39.71, "temperature": 29.75}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 11820, "long_name": "Dawn Oak", "next_hop": 152, "num": "0x2b63b961", "position": {"altitude": 1760, "latitude": 32.926845, "location_source": "LOC_INTERNAL", "longitude": -107.192374, "time_offset_sec": 11862}, "public_key_hex": "269431e5ae2b51240e037c200c92fd38aa2df7e8fff394e0d3964c11d932d3cf", "role": "CLIENT_MUTE", "short_name": "DFO7", "snr": 2.23, "status": null, "telemetry": {"air_util_tx": 1.646, "battery_level": 46, "channel_utilization": 10.8, "uptime_seconds": 94839, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 6828, "long_name": "Sharp Mustang", "next_hop": 167, "num": "0x2b700446", "position": {"altitude": 1490, "latitude": 33.625413, "location_source": "LOC_INTERNAL", "longitude": -107.229548, "time_offset_sec": 7115}, "public_key_hex": "316fea4fc0242e658031222d508ecedc7e50b629f15e769a0cf63c0bed713dd1", "role": "CLIENT", "short_name": "S8S2", "snr": 2.19, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5369, "long_name": "Rough Hare", "next_hop": 0, "num": "0x2b875a59", "position": {"altitude": 1006, "latitude": 33.451392, "location_source": "LOC_INTERNAL", "longitude": -107.021522, "time_offset_sec": 5464}, "public_key_hex": "341dce1208307b1dd129cf35a08823fc9368a80b738b0198f91e01c8f1de841f", "role": "CLIENT", "short_name": "RCO5", "snr": 4.78, "status": null, "telemetry": {"air_util_tx": 0.801, "battery_level": 16, "channel_utilization": 6.08, "uptime_seconds": 199568, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 5404, "long_name": "Green Coyote", "next_hop": 0, "num": "0x2b980a94", "position": {"altitude": 1375, "latitude": 32.560726, "location_source": "LOC_INTERNAL", "longitude": -107.280818, "time_offset_sec": 5439}, "public_key_hex": "f78f91bfa9f8a5e7d3885c12bd2c69242dc9ece0fae608369ce7fb7e088abc97", "role": "CLIENT", "short_name": "GUJJ", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.587, "battery_level": 34, "channel_utilization": 27.95, "uptime_seconds": 109397, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 999.11, "iaq": 40, "relative_humidity": 64.56, "temperature": 15.99}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 289, "long_name": "White Otter", "next_hop": 0, "num": "0x2b996450", "position": {"altitude": 1686, "latitude": 33.269608, "location_source": "LOC_INTERNAL", "longitude": -107.082442, "time_offset_sec": 450}, "public_key_hex": "bdfdddd35130419b7141a6cba4be19e9bf3d9fe1e086b4d6be9f7ad9bee3706d", "role": "CLIENT", "short_name": "W4ZD", "snr": 5.57, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1005.75, "iaq": 22, "relative_humidity": 58.61, "temperature": 25.32}, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 820, "long_name": "Lunar Squirrel", "next_hop": 34, "num": "0x2bafeb18", "position": {"altitude": 588, "latitude": 33.720056, "location_source": "LOC_INTERNAL", "longitude": -107.165393, "time_offset_sec": 881}, "public_key_hex": "cfa3cb5b0167cd06b66c4e20a57d2a182570f33d73ec688941b3da20163831a3", "role": "ROUTER", "short_name": "LRPA", "snr": 7.71, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.95, "iaq": 20, "relative_humidity": 58.67, "temperature": 29.52}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2017, "long_name": "Sunny Falcon", "next_hop": 218, "num": "0x2bc05dd3", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "ST8Y", "snr": 6.85, "status": null, "telemetry": {"air_util_tx": 1.258, "battery_level": 72, "channel_utilization": 2.42, "uptime_seconds": 154530, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.61, "iaq": 82, "relative_humidity": 39.02, "temperature": 16.3}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4190, "long_name": "Desert Bass", "next_hop": 0, "num": "0x2bc0e598", "position": {"altitude": 1363, "latitude": 32.605458, "location_source": "LOC_INTERNAL", "longitude": -107.463587, "time_offset_sec": 4447}, "public_key_hex": "", "role": "SENSOR", "short_name": "D89L", "snr": 8.46, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 379, "long_name": "Canyon Cedar NM0UL", "next_hop": 0, "num": "0x2bcfc067", "position": {"altitude": 1354, "latitude": 33.609822, "location_source": "LOC_INTERNAL", "longitude": -106.790528, "time_offset_sec": 536}, "public_key_hex": "0e05772028d0c2860a051d733c1a77546546b6cbdb45d94295cfd38438780e44", "role": "CLIENT", "short_name": "🌙", "snr": 3.67, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3205, "long_name": "Drowsy Mamba", "next_hop": 0, "num": "0x2c0ef35f", "position": {"altitude": 1531, "latitude": 32.923478, "location_source": "LOC_INTERNAL", "longitude": -107.319848, "time_offset_sec": 3339}, "public_key_hex": "11aee0eace856952b17f852c13b401090a1ed5f870a81c35971711e36bf6ea2c", "role": "CLIENT", "short_name": "D8LY", "snr": 6.21, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.205, "battery_level": 101, "channel_utilization": 27.7, "uptime_seconds": 65891, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.83, "iaq": 81, "relative_humidity": 33.09, "temperature": 36.96}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 811, "long_name": "Floating Bear", "next_hop": 0, "num": "0x2c38cbe3", "position": {"altitude": 1111, "latitude": 32.413386, "location_source": "LOC_INTERNAL", "longitude": -107.777344, "time_offset_sec": 1056}, "public_key_hex": "c90340300c8c30108d285b2f6fe6b8beddc2c726676e5ca0b947f0e1bfdcabd1", "role": "TAK", "short_name": "FLB7", "snr": 7.7, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 6811, "long_name": "Mountain Salmon", "next_hop": 201, "num": "0x2c5299a0", "position": {"altitude": 1623, "latitude": 33.432018, "location_source": "LOC_INTERNAL", "longitude": -107.747496, "time_offset_sec": 6998}, "public_key_hex": "3a9009cb0cd3c65a76ba8210fb504b0aae9a0d0bcd744f0b7d9ffd1d7be76f9b", "role": "CLIENT", "short_name": "MWM5", "snr": 2.53, "status": null, "telemetry": {"air_util_tx": 0.401, "battery_level": 57, "channel_utilization": 8.61, "uptime_seconds": 52999, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4701, "long_name": "Quick Aspen", "next_hop": 0, "num": "0x2c61dd8a", "position": {"altitude": 1226, "latitude": 32.762323, "location_source": "LOC_INTERNAL", "longitude": -107.295186, "time_offset_sec": 4753}, "public_key_hex": "a407a9904f3871cf984be5067b6c9c0b0c220a00aee32073d79824e4c98877d8", "role": "CLIENT", "short_name": "QBQ8", "snr": 11.06, "status": null, "telemetry": {"air_util_tx": 0.216, "battery_level": 11, "channel_utilization": 8.36, "uptime_seconds": 59208, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 955, "long_name": "Roving Elk", "next_hop": 0, "num": "0x2c78847f", "position": {"altitude": 1008, "latitude": 32.816608, "location_source": "LOC_INTERNAL", "longitude": -108.021523, "time_offset_sec": 1151}, "public_key_hex": "30815cbc66fccbe91d6e7dc151612f5542e826cbd817829795ca340c5f4ba3b1", "role": "CLIENT", "short_name": "R0BW", "snr": 6.83, "status": null, "telemetry": {"air_util_tx": 0.926, "battery_level": 74, "channel_utilization": 15.16, "uptime_seconds": 51945, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1864, "long_name": "Lunar Cougar", "next_hop": 254, "num": "0x2c952dd1", "position": {"altitude": 707, "latitude": 34.271148, "location_source": "LOC_INTERNAL", "longitude": -107.927224, "time_offset_sec": 1945}, "public_key_hex": "2a2547912708bc80f2bba3da0414d1eac8e20e012db521784ee9140568fd481b", "role": "CLIENT_HIDDEN", "short_name": "LTMS", "snr": 3.28, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.851, "battery_level": 65, "channel_utilization": 21.12, "uptime_seconds": 37493, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 79, "long_name": "Rough Aspen", "next_hop": 0, "num": "0x2cb2cb08", "position": {"altitude": 1307, "latitude": 33.161643, "location_source": "LOC_INTERNAL", "longitude": -107.218804, "time_offset_sec": 217}, "public_key_hex": "30684e515e7604f0588bf3e4f1bb4662722c43cee918e983eae52531ac55d206", "role": "CLIENT", "short_name": "RI3R", "snr": 10.27, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.56, "battery_level": 67, "channel_utilization": 5.17, "uptime_seconds": 78832, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 3359, "long_name": "River Mamba", "next_hop": 164, "num": "0x2cbfeb26", "position": null, "public_key_hex": "c0a45ab9b3769c9b3fb6dfe097e0d134550c64103d03e5ab16191ac3e5910383", "role": "CLIENT", "short_name": "RBKF", "snr": 3.16, "status": null, "telemetry": {"air_util_tx": 0.538, "battery_level": 21, "channel_utilization": 24.76, "uptime_seconds": 133112, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.4, "iaq": 73, "relative_humidity": 32.97, "temperature": 17.58}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3059, "long_name": "White Bass", "next_hop": 0, "num": "0x2ccf5bfa", "position": {"altitude": 1448, "latitude": 33.354963, "location_source": "LOC_INTERNAL", "longitude": -106.884737, "time_offset_sec": 3123}, "public_key_hex": "71446de4658e46a398283dae50316a93029910a84b5cf95971edcf5b682c10c1", "role": "CLIENT_MUTE", "short_name": "WHB4", "snr": 1.56, "status": null, "telemetry": {"air_util_tx": 0.543, "battery_level": 53, "channel_utilization": 15.44, "uptime_seconds": 90484, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 846, "long_name": "Misty Ridge", "next_hop": 33, "num": "0x2cd073a1", "position": {"altitude": 1493, "latitude": 33.179347, "location_source": "LOC_INTERNAL", "longitude": -106.917056, "time_offset_sec": 973}, "public_key_hex": "d2a59d091e47e88b6698b62e3bef5d21df3395f153e0116489dc653f2ace1bc3", "role": "CLIENT_HIDDEN", "short_name": "MVZH", "snr": 7.93, "status": null, "telemetry": {"air_util_tx": 0.955, "battery_level": 27, "channel_utilization": 9.04, "uptime_seconds": 95097, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 6457, "long_name": "Silent Mamba", "next_hop": 0, "num": "0x2ce91ed0", "position": {"altitude": 1014, "latitude": 33.115852, "location_source": "LOC_INTERNAL", "longitude": -106.461399, "time_offset_sec": 6606}, "public_key_hex": "dcf97bf492d65c27d84a1b9b1ae6981e000ec040e9dba3915c79f73677380491", "role": "CLIENT", "short_name": "🐺", "snr": 4.58, "status": null, "telemetry": {"air_util_tx": 0.592, "battery_level": 34, "channel_utilization": 11.09, "uptime_seconds": 84634, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1018.29, "iaq": 44, "relative_humidity": 84.55, "temperature": 17.2}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 3746, "long_name": "Blue Viper", "next_hop": 70, "num": "0x2cf43dc4", "position": {"altitude": 1408, "latitude": 33.672026, "location_source": "LOC_INTERNAL", "longitude": -107.947767, "time_offset_sec": 3824}, "public_key_hex": "1816722219442df501e95126eeb8625ad525bf8806c5fbdc667fccaba41e6f70", "role": "CLIENT", "short_name": "BHAW", "snr": 2.1, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.81, "iaq": 14, "relative_humidity": 78.77, "temperature": 14.1}, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 3832, "long_name": "Iron Mesa", "next_hop": 0, "num": "0x2d06fb84", "position": {"altitude": 1257, "latitude": 32.17065, "location_source": "LOC_INTERNAL", "longitude": -107.381514, "time_offset_sec": 4074}, "public_key_hex": "2c76ac0557acadf11fd16e3fdc43d42e6624b7c66d12cb1b948e4734cb412698", "role": "CLIENT_MUTE", "short_name": "IK26", "snr": 5.79, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.566, "battery_level": 56, "channel_utilization": 18.77, "uptime_seconds": 298116, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 3014, "long_name": "Burning Hawk", "next_hop": 207, "num": "0x2d18cbca", "position": {"altitude": 1260, "latitude": 33.531832, "location_source": "LOC_INTERNAL", "longitude": -107.371452, "time_offset_sec": 3030}, "public_key_hex": "", "role": "CLIENT", "short_name": "BSCB", "snr": 9.47, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.61, "iaq": 46, "relative_humidity": 60.73, "temperature": 8.58}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 787, "long_name": "Sneaky Otter", "next_hop": 9, "num": "0x2d1971fd", "position": {"altitude": 1416, "latitude": 32.556613, "location_source": "LOC_INTERNAL", "longitude": -107.148208, "time_offset_sec": 1075}, "public_key_hex": "7865909250f89bc760d09b6b9fbbf28a5b38006b4d9ef9ae5fb7925f01fd196e", "role": "CLIENT", "short_name": "S5ZI", "snr": 5.59, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 4200, "long_name": "Tall Moose", "next_hop": 0, "num": "0x2d1cfd97", "position": null, "public_key_hex": "a21226213dbd091862e031baa250ead4bba222c50e7da4bc7c6d0c3cc16516b2", "role": "CLIENT", "short_name": "TMXR", "snr": 10.46, "status": null, "telemetry": {"air_util_tx": 0.532, "battery_level": 34, "channel_utilization": 22.73, "uptime_seconds": 7656, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.19, "iaq": 12, "relative_humidity": 56.52, "temperature": 31.32}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 7191, "long_name": "Frozen Viper", "next_hop": 0, "num": "0x2d1f6357", "position": {"altitude": 1251, "latitude": 33.108992, "location_source": "LOC_INTERNAL", "longitude": -107.536235, "time_offset_sec": 7390}, "public_key_hex": "530a3a35432d0c6cd07684c1803a0f4ecd0f78c50719368d6a8eec0b23e6ced4", "role": "CLIENT", "short_name": "FZBI", "snr": 3.93, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.87, "iaq": 39, "relative_humidity": 95.96, "temperature": 20.51}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 456, "long_name": "Sneaky Bluff", "next_hop": 125, "num": "0x2d2488f1", "position": {"altitude": 1020, "latitude": 33.265671, "location_source": "LOC_INTERNAL", "longitude": -107.839002, "time_offset_sec": 699}, "public_key_hex": "b304f89bee8f2239ff6d5fc2e65fccfe9fe096fa5dc6852a9c1cb45a77a4ab6e", "role": "CLIENT", "short_name": "SSEF", "snr": -2.48, "status": null, "telemetry": {"air_util_tx": 0.217, "battery_level": 61, "channel_utilization": 12.95, "uptime_seconds": 45318, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.19, "iaq": 33, "relative_humidity": 100.0, "temperature": 10.05}, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1961, "long_name": "Dawn Phoenix", "next_hop": 60, "num": "0x2d2e8c05", "position": {"altitude": 1447, "latitude": 33.730992, "location_source": "LOC_INTERNAL", "longitude": -107.87729, "time_offset_sec": 2002}, "public_key_hex": "aa508a9f5aa22f0e483f1fed552f04069f44c75fab66dfb70d6141b7b4ccbe03", "role": "CLIENT", "short_name": "DQE4", "snr": 11.79, "status": null, "telemetry": {"air_util_tx": 1.11, "battery_level": 77, "channel_utilization": 8.08, "uptime_seconds": 295988, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 3846, "long_name": "Blue Wolf", "next_hop": 18, "num": "0x2d2f43e1", "position": {"altitude": 1248, "latitude": 32.550578, "location_source": "LOC_INTERNAL", "longitude": -106.638052, "time_offset_sec": 3920}, "public_key_hex": "08b5d5f8795df6e59d861a0d3a90496b6fac4a89c33e1ffb510aa4527868890c", "role": "CLIENT", "short_name": "BM3K", "snr": 10.8, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.79, "iaq": 67, "relative_humidity": 30.57, "temperature": 22.37}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2294, "long_name": "Loud Trout", "next_hop": 240, "num": "0x2d31c81c", "position": {"altitude": 1488, "latitude": 32.518395, "location_source": "LOC_INTERNAL", "longitude": -107.240104, "time_offset_sec": 2426}, "public_key_hex": "e5f2b34f0a213c6f3ea88d327cff86f117f4eb7fc15260ae0531991b0bab98e0", "role": "CLIENT", "short_name": "LOPP", "snr": 4.79, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.977, "battery_level": 30, "channel_utilization": 8.78, "uptime_seconds": 16229, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8724, "long_name": "Black Owl", "next_hop": 0, "num": "0x2d3eaeaa", "position": {"altitude": 1168, "latitude": 33.256095, "location_source": "LOC_INTERNAL", "longitude": -107.51503, "time_offset_sec": 8871}, "public_key_hex": "dd72ccd69639bfbf7087f658277695102d740653bc03080ea7ed0547bd21efb9", "role": "CLIENT", "short_name": "BVVO", "snr": 3.38, "status": null, "telemetry": {"air_util_tx": 0.314, "battery_level": 88, "channel_utilization": 9.86, "uptime_seconds": 107447, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4_R8", "last_heard_offset_sec": 7939, "long_name": "Wild Shark", "next_hop": 0, "num": "0x2d54d583", "position": null, "public_key_hex": "966408e05a98ac163be5d10b34494cef3176e2220e8061640cc0b3f86e3600f5", "role": "ROUTER", "short_name": "WPX4", "snr": 6.83, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.828, "battery_level": 97, "channel_utilization": 11.46, "uptime_seconds": 131370, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 615, "long_name": "Canyon Turtle", "next_hop": 56, "num": "0x2d5f14cb", "position": null, "public_key_hex": "87ae15ba73083c5c7fb5303854326f8e5b43da08d59987c330eacb93199ef6e8", "role": "ROUTER", "short_name": "CJNA", "snr": 5.75, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.213, "battery_level": 14, "channel_utilization": 18.05, "uptime_seconds": 116564, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2269, "long_name": "Silent Tortoise AE1JI", "next_hop": 0, "num": "0x2d76e74c", "position": {"altitude": 1065, "latitude": 33.449986, "location_source": "LOC_INTERNAL", "longitude": -108.09043, "time_offset_sec": 2323}, "public_key_hex": "afc9ebfdaaf5a5367943d9af47bc4f6127ec6e3fb438cb685a5fad81d85f5a5b", "role": "CLIENT", "short_name": "SUUX", "snr": 9.08, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.044, "battery_level": 44, "channel_utilization": 10.05, "uptime_seconds": 18148, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 591, "long_name": "Forest Gecko", "next_hop": 129, "num": "0x2d7c92c1", "position": {"altitude": 1329, "latitude": 32.406418, "location_source": "LOC_INTERNAL", "longitude": -107.34188, "time_offset_sec": 871}, "public_key_hex": "aa89a9a6e68640731045631a80834b0ff0f6c4bb9df3b12daf004ce6baa90b95", "role": "ROUTER", "short_name": "🌲", "snr": 11.64, "status": null, "telemetry": {"air_util_tx": 0.413, "battery_level": 46, "channel_utilization": 20.76, "uptime_seconds": 10090, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 12038, "long_name": "Dawn Oak", "next_hop": 0, "num": "0x2d7deb6a", "position": {"altitude": 1600, "latitude": 33.344585, "location_source": "LOC_INTERNAL", "longitude": -107.136249, "time_offset_sec": 12071}, "public_key_hex": "e2061a9619fdf0d78164bd52b9e88862c468d80997cb47528a195cddb441eb45", "role": "CLIENT_MUTE", "short_name": "D7GT", "snr": 6.94, "status": null, "telemetry": {"air_util_tx": 1.513, "battery_level": 55, "channel_utilization": 11.55, "uptime_seconds": 118373, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 19405, "long_name": "White Lynx", "next_hop": 52, "num": "0x2d939ab2", "position": {"altitude": 1394, "latitude": 32.455803, "location_source": "LOC_INTERNAL", "longitude": -107.733853, "time_offset_sec": 19704}, "public_key_hex": "782be283edd8080e072a6275931e2e56ad0211b7e839b8756e64dee63d99bce1", "role": "CLIENT", "short_name": "WEV7", "snr": 1.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1379, "long_name": "Howling Bass", "next_hop": 202, "num": "0x2da26bd9", "position": {"altitude": 1172, "latitude": 32.92119, "location_source": "LOC_INTERNAL", "longitude": -107.322487, "time_offset_sec": 1502}, "public_key_hex": "70d46d39b849f2a210edaeca224b3654952ec87b9d86aa7be87593f9a323b1a2", "role": "CLIENT", "short_name": "HQ9N", "snr": 4.88, "status": null, "telemetry": {"air_util_tx": 0.716, "battery_level": 56, "channel_utilization": 10.86, "uptime_seconds": 1266, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.26, "iaq": 51, "relative_humidity": 99.29, "temperature": 16.45}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2118, "long_name": "Floating Wolf", "next_hop": 0, "num": "0x2de0d886", "position": {"altitude": 1136, "latitude": 32.825181, "location_source": "LOC_INTERNAL", "longitude": -107.703541, "time_offset_sec": 2347}, "public_key_hex": "", "role": "CLIENT", "short_name": "FOPW", "snr": 1.91, "status": null, "telemetry": {"air_util_tx": 0.337, "battery_level": 86, "channel_utilization": 1.96, "uptime_seconds": 21971, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.8, "iaq": 30, "relative_humidity": 46.82, "temperature": 32.82}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6835, "long_name": "Solar Bison", "next_hop": 64, "num": "0x2de36eb6", "position": null, "public_key_hex": "bcbed6fc8774f5c57df55825eeb01e6ffe27b7530fcc087463f6e82b7eb81a08", "role": "CLIENT", "short_name": "SGPO", "snr": 2.24, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.313, "battery_level": 83, "channel_utilization": 7.25, "uptime_seconds": 49029, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2034, "long_name": "Misty Turtle", "next_hop": 109, "num": "0x2de8768a", "position": {"altitude": 1076, "latitude": 33.370327, "location_source": "LOC_INTERNAL", "longitude": -107.926639, "time_offset_sec": 2159}, "public_key_hex": "fa273900beaf5a0489ac6b9ed6d20538f700bfce4d0fb0f6584bd31c416c50c1", "role": "CLIENT", "short_name": "🦋", "snr": 10.76, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.39, "iaq": 60, "relative_humidity": 47.76, "temperature": 28.29}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 937, "long_name": "Tiny Cougar", "next_hop": 0, "num": "0x2e0bd666", "position": {"altitude": 1531, "latitude": 32.699944, "location_source": "LOC_INTERNAL", "longitude": -106.967859, "time_offset_sec": 1022}, "public_key_hex": "9f3ff3dd432bc586f02bbe64810226d3bf2b87ea8e1bf6bcda827a2c446ebe09", "role": "TRACKER", "short_name": "T6AN", "snr": 6.59, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 191, "long_name": "Solar Dolphin", "next_hop": 0, "num": "0x2e209681", "position": {"altitude": 1572, "latitude": 33.444236, "location_source": "LOC_INTERNAL", "longitude": -107.255416, "time_offset_sec": 242}, "public_key_hex": "4b7a6e321ca58cbda923cfe7586a1ce5764f627385dae36cccbee662aa2f7c63", "role": "CLIENT_HIDDEN", "short_name": "SP7S", "snr": 11.12, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2159, "long_name": "Solar Turtle", "next_hop": 0, "num": "0x2e237fc8", "position": null, "public_key_hex": "545c0a209ba3343072571c6a103e0a038e6ac3e891256a48a2db78d5ae23a9fb", "role": "CLIENT", "short_name": "SEUK", "snr": 5.34, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.92, "iaq": 29, "relative_humidity": 70.15, "temperature": 19.93}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1126, "long_name": "Happy Pine", "next_hop": 0, "num": "0x2e2db98d", "position": {"altitude": 1421, "latitude": 33.0468, "location_source": "LOC_INTERNAL", "longitude": -106.501996, "time_offset_sec": 1226}, "public_key_hex": "cba89335d27b1036d3f1dfe017d3902264ae9da71beeb562853216002f475dcd", "role": "CLIENT", "short_name": "HLKW", "snr": 0.73, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 890, "long_name": "Silent Owl", "next_hop": 0, "num": "0x2e30598f", "position": {"altitude": 1170, "latitude": 33.191227, "location_source": "LOC_INTERNAL", "longitude": -106.786144, "time_offset_sec": 1003}, "public_key_hex": "", "role": "CLIENT", "short_name": "SVBS", "snr": 8.26, "status": null, "telemetry": {"air_util_tx": 0.202, "battery_level": 10, "channel_utilization": 4.78, "uptime_seconds": 35598, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1652, "long_name": "Tall Oak W55AO", "next_hop": 0, "num": "0x2e37290b", "position": {"altitude": 1668, "latitude": 33.409493, "location_source": "LOC_INTERNAL", "longitude": -108.497632, "time_offset_sec": 1935}, "public_key_hex": "", "role": "ROUTER", "short_name": "TUYE", "snr": 4.94, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4752, "long_name": "Tall Adder", "next_hop": 0, "num": "0x2e39a9c8", "position": null, "public_key_hex": "709e9183319cd47885e82c10aaac1db5e75e1afd176f91adefadd84f92242b5b", "role": "CLIENT_MUTE", "short_name": "TOPB", "snr": 8.97, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.252, "battery_level": 60, "channel_utilization": 10.54, "uptime_seconds": 162371, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1473, "long_name": "Black Mesa", "next_hop": 184, "num": "0x2e50afa7", "position": {"altitude": 1526, "latitude": 32.658803, "location_source": "LOC_INTERNAL", "longitude": -107.343172, "time_offset_sec": 1577}, "public_key_hex": "5e51176b49d0dd61f4e0f07a7a6d98d36f4f7840c4db9de928fe99512fc4b88e", "role": "CLIENT", "short_name": "BG14", "snr": 9.61, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.84, "iaq": 58, "relative_humidity": 43.3, "temperature": 30.89}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 284, "long_name": "Lost Otter", "next_hop": 237, "num": "0x2e57d3f4", "position": {"altitude": 877, "latitude": 33.574574, "location_source": "LOC_INTERNAL", "longitude": -107.416048, "time_offset_sec": 457}, "public_key_hex": "0d26e259db70c7d1ee7dcfc92c2e4ac2c9f21a665b7bf2ccb55fafd8a28e257f", "role": "CLIENT", "short_name": "L8DT", "snr": 3.28, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.336, "battery_level": 24, "channel_utilization": 21.38, "uptime_seconds": 3528, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 10579, "long_name": "Found Gecko", "next_hop": 0, "num": "0x2e609416", "position": {"altitude": 1116, "latitude": 32.699678, "location_source": "LOC_INTERNAL", "longitude": -108.201415, "time_offset_sec": 10677}, "public_key_hex": "8f9d3d8d9080c52c36f72129d8546696022382b55b747b614931c554946a0fe7", "role": "CLIENT_MUTE", "short_name": "FYES", "snr": 6.76, "status": null, "telemetry": {"air_util_tx": 0.248, "battery_level": 60, "channel_utilization": 10.02, "uptime_seconds": 344645, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.6, "iaq": 56, "relative_humidity": 37.03, "temperature": 18.14}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2843, "long_name": "Frosty Bluff", "next_hop": 250, "num": "0x2e60c8f8", "position": {"altitude": 1223, "latitude": 32.347888, "location_source": "LOC_INTERNAL", "longitude": -106.106841, "time_offset_sec": 3072}, "public_key_hex": "f95195ac7b50ae78575cff2ade59e6e7055e6f746a626f9a26e132dae14bf3d6", "role": "CLIENT", "short_name": "FF89", "snr": 1.71, "status": null, "telemetry": {"air_util_tx": 2.548, "battery_level": 83, "channel_utilization": 7.04, "uptime_seconds": 50747, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 7, "hw_model": "RAK4631", "last_heard_offset_sec": 1566, "long_name": "Brave Cougar", "next_hop": 15, "num": "0x2e72544c", "position": {"altitude": 1737, "latitude": 32.612253, "location_source": "LOC_INTERNAL", "longitude": -107.645387, "time_offset_sec": 1863}, "public_key_hex": "a4d5fc13090a28a8f3f9f6841c3d115be94033d1441aab6284b148431b495536", "role": "CLIENT", "short_name": "B7P0", "snr": 2.86, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.378, "battery_level": 30, "channel_utilization": 19.17, "uptime_seconds": 91483, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4333, "long_name": "Misty Mustang", "next_hop": 0, "num": "0x2e88519c", "position": {"altitude": 1021, "latitude": 33.688941, "location_source": "LOC_INTERNAL", "longitude": -106.995794, "time_offset_sec": 4612}, "public_key_hex": "7c48f239fa466be4ed64422faca4b34339907f6b16b3cb817dd67f5e83133a8d", "role": "CLIENT_BASE", "short_name": "MHAO", "snr": 10.18, "status": null, "telemetry": {"air_util_tx": 0.311, "battery_level": 15, "channel_utilization": 2.78, "uptime_seconds": 74175, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7667, "long_name": "Lunar Doe", "next_hop": 0, "num": "0x2e89c9f4", "position": {"altitude": 1400, "latitude": 32.975474, "location_source": "LOC_INTERNAL", "longitude": -107.215631, "time_offset_sec": 7908}, "public_key_hex": "fb41eb82c23287a55ebd30ba8aa185f1a7b21fc77b613cfa4d5e5abc6858b0d0", "role": "CLIENT", "short_name": "LBBV", "snr": 8.27, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 7729, "long_name": "Loud Otter", "next_hop": 155, "num": "0x2e9a53a6", "position": {"altitude": 1279, "latitude": 33.078316, "location_source": "LOC_INTERNAL", "longitude": -108.127099, "time_offset_sec": 7935}, "public_key_hex": "ccc2059c586ff98cf26b7d48e5db04b0cc7103f41f4b8dc8e79b17c353b3c3f9", "role": "CLIENT_MUTE", "short_name": "LFN1", "snr": 11.2, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.729, "battery_level": 86, "channel_utilization": 11.55, "uptime_seconds": 96420, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 13683, "long_name": "Dawn Lion", "next_hop": 0, "num": "0x2eada408", "position": {"altitude": 1548, "latitude": 32.022586, "location_source": "LOC_INTERNAL", "longitude": -106.356765, "time_offset_sec": 13695}, "public_key_hex": "8f7e9af2a338dc19d5917087f0ea96faf335e9aabafb3a13f744eb7fd90207c8", "role": "CLIENT", "short_name": "DA9B", "snr": 4.11, "status": null, "telemetry": {"air_util_tx": 0.816, "battery_level": 50, "channel_utilization": 12.01, "uptime_seconds": 2976, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2084, "long_name": "Frosty Arroyo", "next_hop": 169, "num": "0x2eb04aa2", "position": null, "public_key_hex": "54313405397f5df1dea9739b55597d6bdae4c4203d61ab690dd4bb4ad0587770", "role": "CLIENT", "short_name": "🐢", "snr": 8.27, "status": null, "telemetry": {"air_util_tx": 0.342, "battery_level": 19, "channel_utilization": 18.69, "uptime_seconds": 369815, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2136, "long_name": "Black Bear", "next_hop": 0, "num": "0x2eb12433", "position": {"altitude": 1512, "latitude": 32.853472, "location_source": "LOC_INTERNAL", "longitude": -106.855208, "time_offset_sec": 2216}, "public_key_hex": "8b98a9800b10fd59b899a3f15bc89a44692775d0b6c6d22539071336795421c8", "role": "ROUTER", "short_name": "BMZ9", "snr": 9.68, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2588, "long_name": "Dawn Cougar", "next_hop": 158, "num": "0x2ed64e1e", "position": {"altitude": 1236, "latitude": 33.0945, "location_source": "LOC_INTERNAL", "longitude": -107.487689, "time_offset_sec": 2677}, "public_key_hex": "f3279a9e5597ab73e537f9e60280a8233a6dd1f2b426a28bab01d7896091e702", "role": "CLIENT", "short_name": "DVH7", "snr": 3.88, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.23, "iaq": 56, "relative_humidity": 65.86, "temperature": 27.82}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1593, "long_name": "Blue Owl", "next_hop": 0, "num": "0x2edbe99e", "position": {"altitude": 1348, "latitude": 33.82129, "location_source": "LOC_INTERNAL", "longitude": -107.701821, "time_offset_sec": 1706}, "public_key_hex": "97ea14e318eedd4d7f9d41ac949155b4fd0c0e9c5d03f48e6a598f92894db195", "role": "CLIENT", "short_name": "🗻", "snr": -0.05, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3214, "long_name": "Silver Hawk", "next_hop": 0, "num": "0x2ee25a1a", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "SV6K", "snr": 7.79, "status": null, "telemetry": {"air_util_tx": 0.44, "battery_level": 27, "channel_utilization": 1.05, "uptime_seconds": 77724, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 44, "long_name": "Quick Cougar", "next_hop": 170, "num": "0x2eea219b", "position": null, "public_key_hex": "31d4678dc1b31ea392ed70a5fe02a9f866b6a03f0aa6227e6122c5bfe9da1bca", "role": "CLIENT", "short_name": "QQJL", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.42, "iaq": 67, "relative_humidity": 40.17, "temperature": 14.44}, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1273, "long_name": "Lunar Bronco", "next_hop": 61, "num": "0x2eebb1ac", "position": {"altitude": 1673, "latitude": 33.006398, "location_source": "LOC_INTERNAL", "longitude": -106.4961, "time_offset_sec": 1334}, "public_key_hex": "82c6ca9e555732569cdb68744e762196ca2b14700dbef682970fbf99f70da37a", "role": "CLIENT", "short_name": "LHF1", "snr": 9.42, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 1806, "long_name": "Quick Cactus", "next_hop": 109, "num": "0x2eecc96a", "position": null, "public_key_hex": "b75d41cc111907afe99f322cfe7b360debbc9f6a499122be2c28620d765e043b", "role": "CLIENT_BASE", "short_name": "QH6U", "snr": 5.0, "status": null, "telemetry": {"air_util_tx": 1.676, "battery_level": 36, "channel_utilization": 3.44, "uptime_seconds": 42153, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 531, "long_name": "New Otter", "next_hop": 0, "num": "0x2f0f0e06", "position": {"altitude": 1769, "latitude": 33.006653, "location_source": "LOC_INTERNAL", "longitude": -107.607586, "time_offset_sec": 617}, "public_key_hex": "b0f4334822f9ce6350a8c4b2dc850e52222d02ac0d285ed193a8a043c480ef08", "role": "ROUTER", "short_name": "NMWE", "snr": 10.67, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.46, "iaq": 32, "relative_humidity": 71.5, "temperature": 16.68}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 630, "long_name": "Solar Moose", "next_hop": 0, "num": "0x2f22727e", "position": {"altitude": 1153, "latitude": 32.648275, "location_source": "LOC_INTERNAL", "longitude": -107.608368, "time_offset_sec": 857}, "public_key_hex": "4f66eb260ca767b142481e11c8c4bcf4b8cb45641d900ada755fc0fd69ff6517", "role": "CLIENT", "short_name": "S0VU", "snr": 0.21, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.14, "iaq": 35, "relative_humidity": 44.06, "temperature": 17.31}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4872, "long_name": "River Juniper", "next_hop": 202, "num": "0x2f22b5e2", "position": {"altitude": 1685, "latitude": 33.768994, "location_source": "LOC_INTERNAL", "longitude": -107.29765, "time_offset_sec": 4979}, "public_key_hex": "4d947d6679ad852604799e86ca3c1de80b21ff3958259479cf0bb34ef23253c6", "role": "CLIENT", "short_name": "🌊", "snr": 5.75, "status": null, "telemetry": {"air_util_tx": 0.098, "battery_level": 54, "channel_utilization": 14.26, "uptime_seconds": 4048, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 490, "long_name": "Lone Sage", "next_hop": 57, "num": "0x2f32dc94", "position": {"altitude": 1717, "latitude": 33.232172, "location_source": "LOC_INTERNAL", "longitude": -106.955348, "time_offset_sec": 642}, "public_key_hex": "4a521df9886fdca0a698fa682b13bd9183d571fc87f081d36f1a3789474db08a", "role": "CLIENT", "short_name": "LURG", "snr": 10.8, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5497, "long_name": "Gold Aspen", "next_hop": 0, "num": "0x2f34a278", "position": {"altitude": 1351, "latitude": 32.91376, "location_source": "LOC_INTERNAL", "longitude": -107.594738, "time_offset_sec": 5730}, "public_key_hex": "4f71afd4c47fb596d9d95d63e6957745fcd3cd68c17a38509196ed7cc30ac379", "role": "CLIENT", "short_name": "GIB6", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.539, "battery_level": 32, "channel_utilization": 10.33, "uptime_seconds": 130859, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.65, "iaq": 0, "relative_humidity": 90.54, "temperature": 29.23}, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 1697, "long_name": "Black Mustang", "next_hop": 183, "num": "0x2f41e068", "position": {"altitude": 1488, "latitude": 33.27912, "location_source": "LOC_INTERNAL", "longitude": -106.905447, "time_offset_sec": 1914}, "public_key_hex": "d18f499f00048f0f49502aa1598c9dfd2d76ba841934976ca6e2f6a3fa27b432", "role": "CLIENT", "short_name": "BRBK", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.659, "battery_level": 95, "channel_utilization": 9.45, "uptime_seconds": 90762, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1020.25, "iaq": 51, "relative_humidity": 35.77, "temperature": 27.95}, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 14441, "long_name": "Dusk Turtle", "next_hop": 216, "num": "0x2f4f4a4c", "position": {"altitude": 1290, "latitude": 32.624117, "location_source": "LOC_INTERNAL", "longitude": -107.555375, "time_offset_sec": 14727}, "public_key_hex": "ecec024cf4863e64b057e9b73d103b131400d59842a36f8197d61211189813fa", "role": "CLIENT_MUTE", "short_name": "D3MZ", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.092, "battery_level": 94, "channel_utilization": 7.6, "uptime_seconds": 68215, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 348, "long_name": "Rough Ridge", "next_hop": 210, "num": "0x2f588810", "position": {"altitude": 1626, "latitude": 33.092778, "location_source": "LOC_INTERNAL", "longitude": -107.910009, "time_offset_sec": 627}, "public_key_hex": "fd45f914d04a735ff960f262ebeb89916bd184e54f8561224c520a5cc6c72bac", "role": "CLIENT", "short_name": "R04F", "snr": -0.77, "status": null, "telemetry": {"air_util_tx": 0.606, "battery_level": 16, "channel_utilization": 18.02, "uptime_seconds": 33174, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 810, "long_name": "Mountain Bear", "next_hop": 206, "num": "0x2f5b9601", "position": {"altitude": 1103, "latitude": 32.707909, "location_source": "LOC_INTERNAL", "longitude": -107.130589, "time_offset_sec": 1032}, "public_key_hex": "f52431970c37185a83318dcff8c1cbd121df5ba2fa18f513312adc69a4b68dc3", "role": "CLIENT", "short_name": "MQ2S", "snr": 10.97, "status": null, "telemetry": {"air_util_tx": 1.09, "battery_level": 54, "channel_utilization": 7.82, "uptime_seconds": 56069, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.38, "iaq": 26, "relative_humidity": 37.36, "temperature": 32.01}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3292, "long_name": "Sneaky Beaver", "next_hop": 0, "num": "0x2f6b35a9", "position": {"altitude": 1034, "latitude": 33.083412, "location_source": "LOC_INTERNAL", "longitude": -106.880985, "time_offset_sec": 3483}, "public_key_hex": "e5aea77f2ad03783344f2c6b2872a0760032f6cf0db74cc89d7430a962a76204", "role": "TRACKER", "short_name": "S2PL", "snr": 4.39, "status": null, "telemetry": {"air_util_tx": 0.089, "battery_level": 52, "channel_utilization": 14.23, "uptime_seconds": 47975, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.84, "iaq": 34, "relative_humidity": 74.58, "temperature": 47.87}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1069, "long_name": "Misty Pine", "next_hop": 57, "num": "0x2f6d81df", "position": {"altitude": 1060, "latitude": 33.141789, "location_source": "LOC_INTERNAL", "longitude": -107.855945, "time_offset_sec": 1317}, "public_key_hex": "", "role": "CLIENT", "short_name": "MAYY", "snr": 4.67, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 185, "long_name": "Floating Tortoise", "next_hop": 0, "num": "0x2f9d99a4", "position": {"altitude": 1306, "latitude": 33.152014, "location_source": "LOC_INTERNAL", "longitude": -107.95358, "time_offset_sec": 244}, "public_key_hex": "c4c422e9167e5f65af7cc8ce39e5dfd234f5285893a4eccb9d129006722fc053", "role": "CLIENT", "short_name": "FCUI", "snr": 4.94, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.243, "battery_level": 25, "channel_utilization": 12.03, "uptime_seconds": 90493, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.51, "iaq": 60, "relative_humidity": 49.99, "temperature": 21.64}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 861, "long_name": "Whispering Elk", "next_hop": 0, "num": "0x2fa41e45", "position": {"altitude": 882, "latitude": 33.341639, "location_source": "LOC_INTERNAL", "longitude": -108.049222, "time_offset_sec": 1023}, "public_key_hex": "d101b49648f6cca24f36de0a3705931107cf54a5a420a9c3eafad1273093e174", "role": "CLIENT", "short_name": "🦌", "snr": 7.54, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.6, "iaq": 37, "relative_humidity": 60.99, "temperature": 22.4}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1_EINK", "last_heard_offset_sec": 4897, "long_name": "Drifting Adder", "next_hop": 0, "num": "0x2fb24284", "position": {"altitude": 1160, "latitude": 32.705658, "location_source": "LOC_INTERNAL", "longitude": -106.790317, "time_offset_sec": 4930}, "public_key_hex": "7c74132bf0ab458f422a943f2c4a76bea1e08d14c09eafa8a7950d91f53ad461", "role": "CLIENT", "short_name": "DCYV", "snr": 3.87, "status": null, "telemetry": {"air_util_tx": 0.177, "battery_level": 61, "channel_utilization": 6.34, "uptime_seconds": 20904, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 585, "long_name": "Storm Mole", "next_hop": 51, "num": "0x2fd3628c", "position": {"altitude": 1347, "latitude": 32.360354, "location_source": "LOC_INTERNAL", "longitude": -107.717129, "time_offset_sec": 825}, "public_key_hex": "a5bae5dabf3a2ce6415e682d9224c06513d28fe560e9767cb63ec7b2ac7c12cc", "role": "CLIENT", "short_name": "SZH7", "snr": 7.13, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1707, "long_name": "Hidden Adder", "next_hop": 0, "num": "0x2fe8e3da", "position": {"altitude": 1031, "latitude": 34.296181, "location_source": "LOC_INTERNAL", "longitude": -107.488909, "time_offset_sec": 1988}, "public_key_hex": "ee7b9bceb30a256bb76a58d053cec06c6c0c4b07ba41417fb2ecc9ce08f2703b", "role": "CLIENT", "short_name": "HKFW", "snr": 5.6, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.494, "battery_level": 83, "channel_utilization": 32.74, "uptime_seconds": 228199, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 815, "long_name": "Soft Gecko", "next_hop": 174, "num": "0x2ff1c82e", "position": {"altitude": 1529, "latitude": 33.01542, "location_source": "LOC_INTERNAL", "longitude": -107.706005, "time_offset_sec": 913}, "public_key_hex": "d20eb1bc7dda3899c4cd780a4741838d067826cbe1c5a1f0d82e5cd94f92c159", "role": "ROUTER", "short_name": "S4M3", "snr": 3.88, "status": null, "telemetry": {"air_util_tx": 0.89, "battery_level": 60, "channel_utilization": 14.32, "uptime_seconds": 147184, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 3781, "long_name": "Sharp Moose", "next_hop": 84, "num": "0x2ff4844f", "position": {"altitude": 1653, "latitude": 33.047858, "location_source": "LOC_INTERNAL", "longitude": -107.998785, "time_offset_sec": 4068}, "public_key_hex": "722c695756e1126ea47420d8af0f29580dc6b91c0cc470fc5751c12b9f660147", "role": "CLIENT_HIDDEN", "short_name": "SCJP", "snr": 5.16, "status": null, "telemetry": {"air_util_tx": 0.354, "battery_level": 10, "channel_utilization": 6.17, "uptime_seconds": 44863, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.33, "iaq": 51, "relative_humidity": 69.53, "temperature": 4.82}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1586, "long_name": "Steel Colt", "next_hop": 129, "num": "0x2ff6ce81", "position": {"altitude": 1771, "latitude": 33.763882, "location_source": "LOC_INTERNAL", "longitude": -107.093327, "time_offset_sec": 1873}, "public_key_hex": "89cee756ffd60d6cbe96dbedc1ff041a5652c707969c56c107e5546f9399357c", "role": "CLIENT", "short_name": "SD9L", "snr": 9.1, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.812, "battery_level": 77, "channel_utilization": 40.1, "uptime_seconds": 39348, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1462, "long_name": "Tall Bear", "next_hop": 0, "num": "0x2ff6e520", "position": {"altitude": 1274, "latitude": 33.116641, "location_source": "LOC_INTERNAL", "longitude": -107.655306, "time_offset_sec": 1586}, "public_key_hex": "", "role": "CLIENT", "short_name": "TY1U", "snr": -1.88, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.696, "battery_level": 57, "channel_utilization": 18.91, "uptime_seconds": 57076, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 82, "long_name": "Drifting Tortoise", "next_hop": 178, "num": "0x2ffb403d", "position": {"altitude": 1172, "latitude": 32.784027, "location_source": "LOC_INTERNAL", "longitude": -106.872521, "time_offset_sec": 382}, "public_key_hex": "302ffa3a5c8a3187073b0ab45449b8d890b3f10b60f008f98b92896a8edf7775", "role": "SENSOR", "short_name": "🐢", "snr": 11.46, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 6314, "long_name": "White Moose", "next_hop": 150, "num": "0x302bf156", "position": {"altitude": 1182, "latitude": 33.19085, "location_source": "LOC_INTERNAL", "longitude": -107.240271, "time_offset_sec": 6333}, "public_key_hex": "611d0aa0b15f8f8cf6529656d5d2ae13536222c29303aa2c1db546025a7455cf", "role": "CLIENT", "short_name": "WF63", "snr": 5.3, "status": null, "telemetry": {"air_util_tx": 0.198, "battery_level": 97, "channel_utilization": 1.92, "uptime_seconds": 24706, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4380, "long_name": "Loud Lynx", "next_hop": 0, "num": "0x304bbcdc", "position": null, "public_key_hex": "2715bb7155a7d4784c8df04b2cdfd5933c92d578650ac2fc5ed8b1461fdfd01b", "role": "CLIENT", "short_name": "LD1V", "snr": 2.75, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.66, "iaq": 56, "relative_humidity": 28.68, "temperature": 29.07}, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 883, "long_name": "Brave Hawk", "next_hop": 180, "num": "0x30502d3d", "position": {"altitude": 1683, "latitude": 32.846739, "location_source": "LOC_INTERNAL", "longitude": -107.711706, "time_offset_sec": 973}, "public_key_hex": "01eb7c9d46bcf0a90b54ec09a2d546db1e0ed68bfabccedcfda1802cda1298f2", "role": "ROUTER", "short_name": "B0NP", "snr": 5.88, "status": null, "telemetry": {"air_util_tx": 0.678, "battery_level": 85, "channel_utilization": 7.03, "uptime_seconds": 42208, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4491, "long_name": "Loud Raven", "next_hop": 0, "num": "0x307259ec", "position": {"altitude": 1541, "latitude": 33.476758, "location_source": "LOC_INTERNAL", "longitude": -106.821799, "time_offset_sec": 4492}, "public_key_hex": "90ec91a34af2e4a2a1d80cdc7766781e5eeb5c520bf9a182a70ef392d8092d1b", "role": "CLIENT", "short_name": "LJQR", "snr": 10.3, "status": {"status": "offline-soon"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.97, "iaq": 34, "relative_humidity": 67.34, "temperature": 18.85}, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5295, "long_name": "Black Dolphin", "next_hop": 128, "num": "0x30757ed5", "position": {"altitude": 1939, "latitude": 33.573691, "location_source": "LOC_INTERNAL", "longitude": -107.552546, "time_offset_sec": 5511}, "public_key_hex": "5b1b4214df74a57fabf324653f03cc9597b11c0638224bea778f3b9c8ec9dfa4", "role": "CLIENT", "short_name": "B6UJ", "snr": 3.4, "status": null, "telemetry": {"air_util_tx": 0.284, "battery_level": 49, "channel_utilization": 9.12, "uptime_seconds": 6094, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 9323, "long_name": "Dusk Phoenix", "next_hop": 0, "num": "0x3083d8f8", "position": {"altitude": 1108, "latitude": 33.668042, "location_source": "LOC_INTERNAL", "longitude": -107.361951, "time_offset_sec": 9434}, "public_key_hex": "29550358e841c7eb17286017f1a81a953182cbdbbfaf598f24464e198ef61163", "role": "CLIENT", "short_name": "D78X", "snr": 6.67, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.112, "battery_level": 46, "channel_utilization": 30.09, "uptime_seconds": 128052, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.42, "iaq": 111, "relative_humidity": 51.89, "temperature": 25.14}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 778, "long_name": "Giant Phoenix", "next_hop": 0, "num": "0x3086b42c", "position": {"altitude": 1630, "latitude": 33.42896, "location_source": "LOC_INTERNAL", "longitude": -107.819367, "time_offset_sec": 877}, "public_key_hex": "4b3d1d1971d53930e2a7f4f4da5772e6d1b4fea3206686e39a1e5c0511ff5605", "role": "CLIENT", "short_name": "GK3M", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.324, "battery_level": 92, "channel_utilization": 5.48, "uptime_seconds": 18768, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 7583, "long_name": "Iron Hawk", "next_hop": 0, "num": "0x3091320b", "position": {"altitude": 1433, "latitude": 32.477619, "location_source": "LOC_INTERNAL", "longitude": -107.251926, "time_offset_sec": 7842}, "public_key_hex": "fcb344afcd7139f7bf44d06df6b214a0ec5e2546efd0ed5c09608dca961e33d0", "role": "CLIENT", "short_name": "IP0M", "snr": 7.7, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1161, "long_name": "Tiny Fox", "next_hop": 252, "num": "0x30916e7d", "position": null, "public_key_hex": "a069b4de8aea60862b0ba26d5f15607faac3dd7a1490c307eaaf30d8d37fbd63", "role": "ROUTER", "short_name": "TNQ6", "snr": 8.51, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.101, "battery_level": 92, "channel_utilization": 10.43, "uptime_seconds": 28272, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 940, "long_name": "Shady Cactus", "next_hop": 0, "num": "0x309d5734", "position": null, "public_key_hex": "23cbb421773c5f2241a9c795ad7f04216629bad30976bc83075c91155023e66f", "role": "TRACKER", "short_name": "🦅", "snr": -0.12, "status": null, "telemetry": {"air_util_tx": 0.696, "battery_level": 22, "channel_utilization": 5.88, "uptime_seconds": 26757, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.21, "iaq": 41, "relative_humidity": 55.46, "temperature": 21.81}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2793, "long_name": "River Pony", "next_hop": 0, "num": "0x309df8e5", "position": {"altitude": 1266, "latitude": 32.75153, "location_source": "LOC_INTERNAL", "longitude": -107.76248, "time_offset_sec": 3014}, "public_key_hex": "", "role": "ROUTER", "short_name": "R0RN", "snr": 1.31, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 2, "long_name": "Slow Elk", "next_hop": 0, "num": "0x30a5b4af", "position": {"altitude": 1500, "latitude": 33.024039, "location_source": "LOC_INTERNAL", "longitude": -107.391484, "time_offset_sec": 21}, "public_key_hex": "9d990c9d211b2ab367160fe806a7d4fabc9b2720479c1862f3687e8223ff9090", "role": "TRACKER", "short_name": "SZRH", "snr": 6.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1857, "long_name": "Short Oak", "next_hop": 66, "num": "0x30b146e5", "position": null, "public_key_hex": "a5e30d835aaba424710cd919f48d919b170ad90280234db8975a9da0742caa31", "role": "CLIENT", "short_name": "SQBI", "snr": 10.03, "status": null, "telemetry": {"air_util_tx": 0.365, "battery_level": 14, "channel_utilization": 9.67, "uptime_seconds": 54253, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1000.36, "iaq": 41, "relative_humidity": 64.84, "temperature": 21.64}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4883, "long_name": "Short Otter", "next_hop": 0, "num": "0x30c6419e", "position": {"altitude": 1264, "latitude": 32.460084, "location_source": "LOC_INTERNAL", "longitude": -106.683746, "time_offset_sec": 5090}, "public_key_hex": "9ef7d5982f73152da8cbb29ac17238a7c31efe52bb4b8f867df52096e60c4ab0", "role": "CLIENT", "short_name": "SZYH", "snr": 6.39, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.123, "battery_level": 53, "channel_utilization": 11.61, "uptime_seconds": 23826, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 9665, "long_name": "Copper Pine", "next_hop": 58, "num": "0x30ec7e2a", "position": null, "public_key_hex": "1c3c6aa90662af0d5d0c0cfc1d52434275e7e37422764d71aa73b371b3ba028c", "role": "CLIENT", "short_name": "COIL", "snr": 4.39, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.81, "iaq": 55, "relative_humidity": 41.61, "temperature": 10.26}, "hops_away": 2, "hw_model": "MUZI_R1_NEO", "last_heard_offset_sec": 1191, "long_name": "Brave Hare", "next_hop": 236, "num": "0x30ef47b0", "position": {"altitude": 1092, "latitude": 32.763639, "location_source": "LOC_INTERNAL", "longitude": -108.369876, "time_offset_sec": 1440}, "public_key_hex": "de80fc46ddddafe78831199c9bcab68575e4c542dbb82a7904c7c5992fe2a0f9", "role": "ROUTER_LATE", "short_name": "BMTZ", "snr": 5.29, "status": null, "telemetry": {"air_util_tx": 1.188, "battery_level": 28, "channel_utilization": 3.44, "uptime_seconds": 160631, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3826, "long_name": "Sharp Crane", "next_hop": 179, "num": "0x3112d2db", "position": {"altitude": 1392, "latitude": 32.841202, "location_source": "LOC_INTERNAL", "longitude": -107.589844, "time_offset_sec": 3843}, "public_key_hex": "6f6e2633f6cf626595db2803018a8a4398d52dd6a1ea8e73416405086f667237", "role": "CLIENT", "short_name": "🦋", "snr": -0.73, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.51, "iaq": 22, "relative_humidity": 36.17, "temperature": 21.38}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 799, "long_name": "Frozen Tortoise", "next_hop": 0, "num": "0x31154a30", "position": null, "public_key_hex": "532937edcb5948a036bc58c761393bcd8c062deb17d0542f3a4cf21f09e301bb", "role": "CLIENT", "short_name": "F86P", "snr": 7.89, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.069, "battery_level": 52, "channel_utilization": 9.24, "uptime_seconds": 13400, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 8869, "long_name": "Lunar Bison", "next_hop": 144, "num": "0x3122123a", "position": {"altitude": 1504, "latitude": 33.743796, "location_source": "LOC_INTERNAL", "longitude": -107.23231, "time_offset_sec": 8926}, "public_key_hex": "abc2fb4d899bf7da7e777fc7a77ca4c41ecad24271fdab40e4a65f1e37a8033c", "role": "CLIENT", "short_name": "LG88", "snr": 6.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 19027, "long_name": "Green Sage KX6YJ", "next_hop": 0, "num": "0x31300a91", "position": {"altitude": 1235, "latitude": 32.652472, "location_source": "LOC_INTERNAL", "longitude": -106.92875, "time_offset_sec": 19061}, "public_key_hex": "bc75db840227b86354c892b93c950fa4df4bbba729c34092dfe38e665b4f8400", "role": "CLIENT", "short_name": "G3WW", "snr": 4.22, "status": null, "telemetry": {"air_util_tx": 1.003, "battery_level": 33, "channel_utilization": 9.46, "uptime_seconds": 138405, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2432, "long_name": "Mountain Eagle", "next_hop": 76, "num": "0x313c9447", "position": {"altitude": 1076, "latitude": 31.907689, "location_source": "LOC_INTERNAL", "longitude": -106.959204, "time_offset_sec": 2593}, "public_key_hex": "d15c3c49a338b88da1b0d0942f74ed4c1a08b874a3569f165d5606848a589ba3", "role": "CLIENT", "short_name": "MYCE", "snr": 11.32, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.246, "battery_level": 27, "channel_utilization": 6.02, "uptime_seconds": 18307, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.33, "iaq": 65, "relative_humidity": 43.15, "temperature": 22.87}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 11333, "long_name": "Bright Otter", "next_hop": 150, "num": "0x3143b47e", "position": {"altitude": 965, "latitude": 33.871504, "location_source": "LOC_INTERNAL", "longitude": -106.966345, "time_offset_sec": 11487}, "public_key_hex": "c3ba14658d86ab9beb10caed35ba8df92f78e1d7fc8b3119f489197d0372cd8c", "role": "CLIENT", "short_name": "B6O7", "snr": 7.86, "status": null, "telemetry": {"air_util_tx": 0.126, "battery_level": 27, "channel_utilization": 3.65, "uptime_seconds": 322618, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 105, "long_name": "Whispering Pike", "next_hop": 9, "num": "0x316963f9", "position": {"altitude": 1315, "latitude": 32.705394, "location_source": "LOC_INTERNAL", "longitude": -106.880391, "time_offset_sec": 163}, "public_key_hex": "0dab8af6fdd5fdcd4bdf6c4eebb7d7e4d556e520f874b6f3a06b632a3a285057", "role": "SENSOR", "short_name": "W5G2", "snr": 5.27, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.099, "battery_level": 99, "channel_utilization": 9.75, "uptime_seconds": 35137, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1033, "long_name": "Sky Heron", "next_hop": 19, "num": "0x316fddf2", "position": {"altitude": 1310, "latitude": 32.765681, "location_source": "LOC_INTERNAL", "longitude": -108.215013, "time_offset_sec": 1288}, "public_key_hex": "bf010fca28f16e02b9d563e26135e5f6c64614ebccd3dadec0036e73a63b9848", "role": "TAK_TRACKER", "short_name": "SXAP", "snr": 3.9, "status": null, "telemetry": {"air_util_tx": 0.493, "battery_level": 35, "channel_utilization": 7.92, "uptime_seconds": 73798, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 1218, "long_name": "Silent Squirrel", "next_hop": 5, "num": "0x31809eee", "position": {"altitude": 1373, "latitude": 32.908628, "location_source": "LOC_INTERNAL", "longitude": -107.423552, "time_offset_sec": 1428}, "public_key_hex": "af5767a189a77459cc5cf4734a52d5c69120802712ea9d9861f0878d458e3930", "role": "CLIENT", "short_name": "SWRP", "snr": 8.9, "status": null, "telemetry": {"air_util_tx": 0.536, "battery_level": 74, "channel_utilization": 31.63, "uptime_seconds": 69664, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 10943, "long_name": "Brave Colt", "next_hop": 81, "num": "0x31b02b58", "position": {"altitude": 1408, "latitude": 33.657786, "location_source": "LOC_INTERNAL", "longitude": -106.998188, "time_offset_sec": 10989}, "public_key_hex": "a49214db809af11ce3cbb38680678b83b75ba9677d54d228c94294ca20f38772", "role": "CLIENT", "short_name": "B903", "snr": 9.64, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 4509, "long_name": "White Cougar", "next_hop": 0, "num": "0x31b6568d", "position": {"altitude": 1333, "latitude": 33.636837, "location_source": "LOC_INTERNAL", "longitude": -106.368389, "time_offset_sec": 4692}, "public_key_hex": "adb95fb0ab7125c4bfbfcc2327fe417ccdc13b204c29ed62a30ab0f81d6307a0", "role": "CLIENT", "short_name": "WWKH", "snr": 1.96, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.236, "battery_level": 96, "channel_utilization": 8.46, "uptime_seconds": 49720, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1606, "long_name": "Slow Wolf", "next_hop": 0, "num": "0x31e7fffc", "position": {"altitude": 1462, "latitude": 33.219224, "location_source": "LOC_INTERNAL", "longitude": -107.042032, "time_offset_sec": 1725}, "public_key_hex": "54b17aa9171c5d772a99569775d24647e25ff5e59dc115317eac9ec3e8e6b2d9", "role": "CLIENT_MUTE", "short_name": "SGM3", "snr": 0.16, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 13128, "long_name": "Dawn Cedar", "next_hop": 110, "num": "0x3221a803", "position": {"altitude": 1457, "latitude": 33.112757, "location_source": "LOC_INTERNAL", "longitude": -108.360795, "time_offset_sec": 13278}, "public_key_hex": "d42ce805237547d96258a211ee4a54eef646bd42150743581cfe0720b631fc75", "role": "CLIENT", "short_name": "DMH7", "snr": 5.07, "status": null, "telemetry": {"air_util_tx": 0.521, "battery_level": 62, "channel_utilization": 12.73, "uptime_seconds": 61968, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 6957, "long_name": "Burning Phoenix", "next_hop": 2, "num": "0x3254f5de", "position": {"altitude": 1554, "latitude": 33.132941, "location_source": "LOC_INTERNAL", "longitude": -106.915786, "time_offset_sec": 7053}, "public_key_hex": "4c15f576f7898ef60c1012371d00c66a58a53ffd6448e929e3900460826094a0", "role": "CLIENT", "short_name": "BSI9", "snr": 6.99, "status": null, "telemetry": {"air_util_tx": 0.694, "battery_level": 45, "channel_utilization": 7.32, "uptime_seconds": 46307, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 306, "long_name": "Wild Colt", "next_hop": 0, "num": "0x325f1a04", "position": {"altitude": 1630, "latitude": 34.046563, "location_source": "LOC_INTERNAL", "longitude": -107.83165, "time_offset_sec": 453}, "public_key_hex": "5f49a3ff33dfe92431ba8cebf14363b8c8f28ad379e019be085adeac4b9df490", "role": "CLIENT", "short_name": "WP8U", "snr": 9.9, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 4231, "long_name": "Desert Cobra", "next_hop": 0, "num": "0x325f36e7", "position": {"altitude": 1103, "latitude": 33.835309, "location_source": "LOC_INTERNAL", "longitude": -106.642833, "time_offset_sec": 4438}, "public_key_hex": "6f88af7b9794d8f92187fbac7f812e34e86de8aea43377675396b8a18d9c31df", "role": "CLIENT_BASE", "short_name": "D514", "snr": 8.54, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.77, "battery_level": 54, "channel_utilization": 11.31, "uptime_seconds": 26307, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7904, "long_name": "Forest Hare", "next_hop": 97, "num": "0x3269c63e", "position": {"altitude": 1382, "latitude": 33.368216, "location_source": "LOC_INTERNAL", "longitude": -107.6987, "time_offset_sec": 7957}, "public_key_hex": "eaaba0f882665299329eb7ec6b03027b21e8a61820aaf5e7d09fec49c6207bdd", "role": "CLIENT", "short_name": "FKYW", "snr": 9.41, "status": null, "telemetry": {"air_util_tx": 0.887, "battery_level": 76, "channel_utilization": 12.56, "uptime_seconds": 93977, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.69, "iaq": 50, "relative_humidity": 75.57, "temperature": 14.08}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 564, "long_name": "Solar Aspen", "next_hop": 196, "num": "0x326e43d0", "position": {"altitude": 1364, "latitude": 33.109072, "location_source": "LOC_INTERNAL", "longitude": -106.343504, "time_offset_sec": 744}, "public_key_hex": "c91278485c6f9c2cb375f9383dcfe08cca6d1684daed247d2d50cf6e754819a0", "role": "CLIENT", "short_name": "SQGY", "snr": 5.79, "status": null, "telemetry": {"air_util_tx": 0.772, "battery_level": 48, "channel_utilization": 17.69, "uptime_seconds": 1681, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1656, "long_name": "Sharp Cobra", "next_hop": 0, "num": "0x326ee5f6", "position": {"altitude": 965, "latitude": 33.124175, "location_source": "LOC_INTERNAL", "longitude": -108.031709, "time_offset_sec": 1709}, "public_key_hex": "af5efc09ee33e7d80abc15bff84a7640477b520d5a3ea29f3542e78b250f780a", "role": "CLIENT", "short_name": "SHNK", "snr": 7.73, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 8732, "long_name": "Rough Cougar KQ6IX", "next_hop": 0, "num": "0x3273f32c", "position": {"altitude": 1541, "latitude": 34.132621, "location_source": "LOC_INTERNAL", "longitude": -107.059353, "time_offset_sec": 8756}, "public_key_hex": "8d54429a7e9cc25cb087011221c64ccef22d80e4d112620dd80cb8af9d0a9485", "role": "CLIENT", "short_name": "RK63", "snr": 2.17, "status": null, "telemetry": {"air_util_tx": 2.001, "battery_level": 59, "channel_utilization": 5.07, "uptime_seconds": 99480, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 2147, "long_name": "Short Pony", "next_hop": 0, "num": "0x328c1524", "position": {"altitude": 1635, "latitude": 33.167431, "location_source": "LOC_INTERNAL", "longitude": -106.598147, "time_offset_sec": 2435}, "public_key_hex": "03d8a078d4a5a3ecd0dbc55133830bba2faaf1e3cff1dbec9482b925557cd520", "role": "CLIENT", "short_name": "SVOM", "snr": 5.8, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.005, "battery_level": 48, "channel_utilization": 8.89, "uptime_seconds": 290488, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 333, "long_name": "Sky Ridge", "next_hop": 184, "num": "0x32908e98", "position": {"altitude": 1595, "latitude": 31.824468, "location_source": "LOC_INTERNAL", "longitude": -107.933232, "time_offset_sec": 561}, "public_key_hex": "1ba133b9a7828ec5e75d9cdddcf08464e878f7b78d944e51dd05b61439743886", "role": "CLIENT", "short_name": "SDK0", "snr": 8.98, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.678, "battery_level": 43, "channel_utilization": 34.72, "uptime_seconds": 35595, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 365, "long_name": "Canyon Heron", "next_hop": 141, "num": "0x329cdbb5", "position": {"altitude": 1447, "latitude": 33.172641, "location_source": "LOC_INTERNAL", "longitude": -107.605622, "time_offset_sec": 599}, "public_key_hex": "5c2b049573e041e710625b5a983cfe9484ec9ba892521dbeb8016383f9e7c85e", "role": "CLIENT", "short_name": "CM91", "snr": 1.15, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.247, "battery_level": 13, "channel_utilization": 21.9, "uptime_seconds": 44667, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.21, "iaq": 75, "relative_humidity": 53.45, "temperature": 16.61}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 10821, "long_name": "White Turtle K18TI", "next_hop": 0, "num": "0x32a3227d", "position": {"altitude": 1205, "latitude": 33.501979, "location_source": "LOC_INTERNAL", "longitude": -106.814274, "time_offset_sec": 11018}, "public_key_hex": "40d8c758410596806561ae13c55bed78a68151fc227e1b29aa012495f722c0e8", "role": "CLIENT", "short_name": "🦅", "snr": 2.41, "status": null, "telemetry": {"air_util_tx": 0.448, "battery_level": 31, "channel_utilization": 6.58, "uptime_seconds": 1939, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 4267, "long_name": "Burning Turtle", "next_hop": 51, "num": "0x32a86e88", "position": {"altitude": 1495, "latitude": 33.436369, "location_source": "LOC_INTERNAL", "longitude": -107.426122, "time_offset_sec": 4534}, "public_key_hex": "a7a6f7ef29aadec31c451f4ff94089e5f12c0e16f1fe6c7fe3a9c3b1fa360fee", "role": "ROUTER", "short_name": "BLJP", "snr": 5.08, "status": null, "telemetry": {"air_util_tx": 0.386, "battery_level": 91, "channel_utilization": 17.87, "uptime_seconds": 139737, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 151, "long_name": "Mountain Fox", "next_hop": 91, "num": "0x32ab844e", "position": {"altitude": 1385, "latitude": 32.681178, "location_source": "LOC_INTERNAL", "longitude": -105.701873, "time_offset_sec": 189}, "public_key_hex": "968fe10c1cfaf6e29e4ec04888209dbf4c4085af5211c37a288f12032764803e", "role": "CLIENT", "short_name": "M81M", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7136, "long_name": "Roving Pine", "next_hop": 20, "num": "0x32af9aab", "position": null, "public_key_hex": "be33b0e17c210900ee5c3f0191179e5f42072fe57faea531e1f2bcc3c2d1f515", "role": "CLIENT", "short_name": "RRKM", "snr": 5.47, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.496, "battery_level": 66, "channel_utilization": 8.44, "uptime_seconds": 5261, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.34, "iaq": 50, "relative_humidity": 73.61, "temperature": 15.81}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1640, "long_name": "Desert Cobra", "next_hop": 5, "num": "0x32d884e6", "position": {"altitude": 2244, "latitude": 33.507485, "location_source": "LOC_INTERNAL", "longitude": -107.475198, "time_offset_sec": 1831}, "public_key_hex": "0d502c9114df83a1710475153cd8f05a2d86d9e32de6851ff174efbc18a24b13", "role": "CLIENT", "short_name": "D845", "snr": 6.0, "status": null, "telemetry": {"air_util_tx": 0.548, "battery_level": 45, "channel_utilization": 18.92, "uptime_seconds": 174362, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 160, "long_name": "Silver Falcon", "next_hop": 145, "num": "0x32e78761", "position": {"altitude": 1801, "latitude": 33.229238, "location_source": "LOC_INTERNAL", "longitude": -107.246934, "time_offset_sec": 255}, "public_key_hex": "98df3363b582dedbb7a9131fc5c48dc293ca37ee0c280640d0671129ea2c9b77", "role": "CLIENT_MUTE", "short_name": "SKIA", "snr": 6.4, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1014.06, "iaq": 6, "relative_humidity": 66.92, "temperature": 16.35}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1161, "long_name": "Smooth Lion", "next_hop": 0, "num": "0x32f0cd87", "position": {"altitude": 1771, "latitude": 34.288538, "location_source": "LOC_INTERNAL", "longitude": -107.105088, "time_offset_sec": 1185}, "public_key_hex": "", "role": "CLIENT", "short_name": "SVCA", "snr": 4.15, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 1.736, "battery_level": 70, "channel_utilization": 3.26, "uptime_seconds": 1012, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 410, "long_name": "Howling Dolphin", "next_hop": 234, "num": "0x33088546", "position": {"altitude": 1242, "latitude": 32.905204, "location_source": "LOC_INTERNAL", "longitude": -107.267046, "time_offset_sec": 462}, "public_key_hex": "928be9e6168e846e9dddf9826d6f0044b804e7a50684bb9fb3976f9eded7d020", "role": "CLIENT_MUTE", "short_name": "🔥", "snr": 10.12, "status": null, "telemetry": {"air_util_tx": 0.269, "battery_level": 32, "channel_utilization": 9.11, "uptime_seconds": 7381, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.31, "iaq": 88, "relative_humidity": 77.12, "temperature": 20.44}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1_EINK", "last_heard_offset_sec": 2061, "long_name": "Gold Viper", "next_hop": 0, "num": "0x331d9a70", "position": {"altitude": 1211, "latitude": 31.936229, "location_source": "LOC_INTERNAL", "longitude": -106.752888, "time_offset_sec": 2122}, "public_key_hex": "e24d4ac32bb0764749b994cfacc92f36b9277d9b316e85086dd10c4cc8c2e617", "role": "CLIENT", "short_name": "GHJD", "snr": 8.7, "status": null, "telemetry": {"air_util_tx": 0.486, "battery_level": 53, "channel_utilization": 9.55, "uptime_seconds": 34902, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6344, "long_name": "Steel Otter", "next_hop": 0, "num": "0x3334e3c0", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "SWPC", "snr": 1.96, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.029, "battery_level": 22, "channel_utilization": 9.15, "uptime_seconds": 12082, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1673, "long_name": "Sunny Marmot", "next_hop": 85, "num": "0x333bab34", "position": {"altitude": 1839, "latitude": 33.033996, "location_source": "LOC_INTERNAL", "longitude": -107.152799, "time_offset_sec": 1771}, "public_key_hex": "27b84cc6b4f17b0a79b919fd3c8204705cbe321175432fd5aa5b4d5a52b71579", "role": "CLIENT", "short_name": "SG7F", "snr": 1.15, "status": null, "telemetry": {"air_util_tx": 0.384, "battery_level": 65, "channel_utilization": 5.88, "uptime_seconds": 28788, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.12, "iaq": 48, "relative_humidity": 39.11, "temperature": 27.44}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4173, "long_name": "Frozen Badger", "next_hop": 128, "num": "0x33509a96", "position": {"altitude": 1553, "latitude": 33.002112, "location_source": "LOC_INTERNAL", "longitude": -107.165977, "time_offset_sec": 4394}, "public_key_hex": "d76969f68d8346123ff5ef3049fa0ac9331aefc5b93955ecc60a07b63194cce1", "role": "LOST_AND_FOUND", "short_name": "FS9Y", "snr": 5.61, "status": null, "telemetry": {"air_util_tx": 1.045, "battery_level": 49, "channel_utilization": 33.38, "uptime_seconds": 96451, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2555, "long_name": "Soft Lynx", "next_hop": 31, "num": "0x33606d2c", "position": null, "public_key_hex": "aa69b3087e357ace3486ee973b51981f6b158f2238ce538cb99563e4a6ab9e6a", "role": "CLIENT", "short_name": "SXMM", "snr": 9.48, "status": null, "telemetry": {"air_util_tx": 0.326, "battery_level": 96, "channel_utilization": 9.92, "uptime_seconds": 3551, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 454, "long_name": "White Bass", "next_hop": 0, "num": "0x33622d50", "position": {"altitude": 1474, "latitude": 32.910837, "location_source": "LOC_INTERNAL", "longitude": -107.977862, "time_offset_sec": 656}, "public_key_hex": "46a74231a11c52a33ce77f9dc95167bcbf939453f63ce522cacfb8bafb72a9ef", "role": "CLIENT", "short_name": "WDM7", "snr": 10.1, "status": null, "telemetry": {"air_util_tx": 0.27, "battery_level": 96, "channel_utilization": 16.23, "uptime_seconds": 45176, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 3319, "long_name": "Hidden Seal", "next_hop": 124, "num": "0x3363ec9c", "position": {"altitude": 1938, "latitude": 34.16258, "location_source": "LOC_INTERNAL", "longitude": -106.797597, "time_offset_sec": 3600}, "public_key_hex": "9717c7fc62f25230fcb9b705885fdaeb4e2517ceb8f3989b342082d7ad9424ce", "role": "CLIENT", "short_name": "H3CN", "snr": 4.21, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 6944, "long_name": "Forest Tortoise", "next_hop": 64, "num": "0x3366e1e2", "position": {"altitude": 1428, "latitude": 32.646161, "location_source": "LOC_INTERNAL", "longitude": -107.370992, "time_offset_sec": 7119}, "public_key_hex": "", "role": "CLIENT", "short_name": "FS0M", "snr": 3.94, "status": null, "telemetry": {"air_util_tx": 1.262, "battery_level": 35, "channel_utilization": 4.14, "uptime_seconds": 61810, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 2121, "long_name": "Drifting Sage", "next_hop": 234, "num": "0x3372bb36", "position": {"altitude": 1338, "latitude": 33.151627, "location_source": "LOC_INTERNAL", "longitude": -107.281519, "time_offset_sec": 2287}, "public_key_hex": "fda377ccbe644005c39797bbfd07ccde381c0c1e07a93b38c1de81adbe36873c", "role": "TRACKER", "short_name": "DTLR", "snr": 8.16, "status": null, "telemetry": {"air_util_tx": 0.051, "battery_level": 84, "channel_utilization": 15.01, "uptime_seconds": 116599, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 9745, "long_name": "Desert Coyote", "next_hop": 30, "num": "0x337e9775", "position": {"altitude": 1598, "latitude": 32.344482, "location_source": "LOC_INTERNAL", "longitude": -107.392374, "time_offset_sec": 9839}, "public_key_hex": "5e2e0bcf417dceddaff9fe284c1fd16ed9fcc94c34c95cde8c884f83b444aa55", "role": "CLIENT", "short_name": "DBCA", "snr": 4.7, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.049, "battery_level": 31, "channel_utilization": 12.03, "uptime_seconds": 130443, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2156, "long_name": "Lunar Salmon", "next_hop": 68, "num": "0x33975f4a", "position": {"altitude": 1064, "latitude": 33.586346, "location_source": "LOC_INTERNAL", "longitude": -107.032721, "time_offset_sec": 2283}, "public_key_hex": "7118681e99c24025733b94a1e4327fd6a4c712e9581c9577f7b4ae7ca2008b63", "role": "TRACKER", "short_name": "LSLX", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.724, "battery_level": 38, "channel_utilization": 11.81, "uptime_seconds": 28205, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1328, "long_name": "Silent Yucca", "next_hop": 0, "num": "0x33a31eea", "position": {"altitude": 887, "latitude": 33.60551, "location_source": "LOC_INTERNAL", "longitude": -107.564308, "time_offset_sec": 1368}, "public_key_hex": "cc00fb037fb454362d8e6197257384ec3b4e610594ff68549e7a4c958ecd7c05", "role": "CLIENT", "short_name": "SHTN", "snr": 2.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 3779, "long_name": "Found Colt", "next_hop": 129, "num": "0x33cc0725", "position": {"altitude": 1130, "latitude": 32.825471, "location_source": "LOC_INTERNAL", "longitude": -107.703036, "time_offset_sec": 3862}, "public_key_hex": "0d4aa53347f2630bb2b31839593f4c650a3e2b9c59d42c66fab651844ec3d0a8", "role": "CLIENT", "short_name": "FZNB", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.704, "battery_level": 101, "channel_utilization": 8.06, "uptime_seconds": 32568, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 10575, "long_name": "Drowsy Viper", "next_hop": 0, "num": "0x33e6f421", "position": {"altitude": 1288, "latitude": 33.483053, "location_source": "LOC_INTERNAL", "longitude": -107.388041, "time_offset_sec": 10586}, "public_key_hex": "", "role": "CLIENT", "short_name": "DZJE", "snr": 7.12, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.068, "battery_level": 54, "channel_utilization": 11.91, "uptime_seconds": 73870, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4521, "long_name": "Wild Badger", "next_hop": 0, "num": "0x3415e804", "position": {"altitude": 1188, "latitude": 32.749514, "location_source": "LOC_INTERNAL", "longitude": -107.799252, "time_offset_sec": 4539}, "public_key_hex": "eb72e324db9b71897a754c465daec14ea34d4b4acad529417b0c32d00f4dd06a", "role": "CLIENT", "short_name": "WDM8", "snr": 7.63, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.548, "battery_level": 101, "channel_utilization": 8.17, "uptime_seconds": 38349, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2261, "long_name": "Green Raven", "next_hop": 56, "num": "0x341d626f", "position": {"altitude": 1386, "latitude": 32.97312, "location_source": "LOC_INTERNAL", "longitude": -107.090688, "time_offset_sec": 2333}, "public_key_hex": "0bd9000b258c3a7ad25c2669a02251d0af79cdee9c6bc830cbec47f90d9e9d79", "role": "ROUTER_LATE", "short_name": "GFN1", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.199, "battery_level": 84, "channel_utilization": 3.37, "uptime_seconds": 8278, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3909, "long_name": "Drifting Squirrel", "next_hop": 0, "num": "0x3446947d", "position": null, "public_key_hex": "07e9a9bc696e9be196e2ea61862faed0b3062d45c3c72f09e84390465dd1b1bb", "role": "CLIENT", "short_name": "DYX2", "snr": 6.18, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2619, "long_name": "Sky Aspen", "next_hop": 94, "num": "0x34514c84", "position": {"altitude": 1454, "latitude": 33.072334, "location_source": "LOC_INTERNAL", "longitude": -107.042055, "time_offset_sec": 2780}, "public_key_hex": "4654686e5ad2e6bc3f38d3dd84507d73ced98a1a80e0b3b0258a66fec6d9addc", "role": "CLIENT", "short_name": "S6C1", "snr": 9.83, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 323, "long_name": "Slow Whale", "next_hop": 141, "num": "0x3457cd3d", "position": {"altitude": 1678, "latitude": 33.541101, "location_source": "LOC_INTERNAL", "longitude": -107.636937, "time_offset_sec": 490}, "public_key_hex": "5ff8d15aef87a677dd5fcc5e5ed912dd58c4d15c49b33a76b61cb6c2e67e8261", "role": "CLIENT", "short_name": "S1DF", "snr": -3.12, "status": null, "telemetry": {"air_util_tx": 0.061, "battery_level": 44, "channel_utilization": 6.16, "uptime_seconds": 67470, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 4078, "long_name": "Roving Shark", "next_hop": 91, "num": "0x34a5ec70", "position": {"altitude": 1985, "latitude": 33.071305, "location_source": "LOC_INTERNAL", "longitude": -107.05835, "time_offset_sec": 4104}, "public_key_hex": "", "role": "CLIENT", "short_name": "🌙", "snr": 8.74, "status": null, "telemetry": {"air_util_tx": 0.839, "battery_level": 95, "channel_utilization": 10.22, "uptime_seconds": 18224, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2602, "long_name": "Blue Owl", "next_hop": 0, "num": "0x34aedaeb", "position": {"altitude": 1040, "latitude": 34.257466, "location_source": "LOC_INTERNAL", "longitude": -107.674028, "time_offset_sec": 2712}, "public_key_hex": "31fa39837d104bf02bec97bef8c882af870f49741d618218d6c135ab488d745a", "role": "CLIENT", "short_name": "BJ8C", "snr": 3.66, "status": null, "telemetry": {"air_util_tx": 0.303, "battery_level": 59, "channel_utilization": 25.16, "uptime_seconds": 124847, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3436, "long_name": "New Sage", "next_hop": 0, "num": "0x34d0cb57", "position": {"altitude": 1868, "latitude": 33.221107, "location_source": "LOC_INTERNAL", "longitude": -107.339195, "time_offset_sec": 3518}, "public_key_hex": "7db20e172801848857eeaff0f637c32553f458711d38434a7e1fb3bbba9459a4", "role": "CLIENT", "short_name": "NHHT", "snr": 6.54, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.175, "battery_level": 33, "channel_utilization": 4.0, "uptime_seconds": 144791, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 8691, "long_name": "Smooth Turtle", "next_hop": 42, "num": "0x34eb343f", "position": {"altitude": 1517, "latitude": 32.576223, "location_source": "LOC_INTERNAL", "longitude": -107.049932, "time_offset_sec": 8872}, "public_key_hex": "229b22a7b429f696735c2992d423f45dcb8494a9ecd2fd66abd56ae54c63ad20", "role": "CLIENT", "short_name": "SKX5", "snr": 0.83, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1017.52, "iaq": 0, "relative_humidity": 53.76, "temperature": 16.0}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1858, "long_name": "Tall Eagle", "next_hop": 0, "num": "0x34f042cd", "position": {"altitude": 1514, "latitude": 33.063367, "location_source": "LOC_INTERNAL", "longitude": -107.52719, "time_offset_sec": 1933}, "public_key_hex": "63d5984d5f22633e923cfd5471f244eb21acdbf986d83ced9e4378a37f1bf863", "role": "CLIENT", "short_name": "TEGO", "snr": 10.05, "status": null, "telemetry": {"air_util_tx": 1.186, "battery_level": 25, "channel_utilization": 3.69, "uptime_seconds": 4826, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 35, "long_name": "Slow Phoenix", "next_hop": 114, "num": "0x34f30f61", "position": {"altitude": 1348, "latitude": 33.116745, "location_source": "LOC_INTERNAL", "longitude": -107.705246, "time_offset_sec": 100}, "public_key_hex": "ae5ade0f2c35a0fa0107d0e680e8016901c17155a767baf40b01e87418536337", "role": "CLIENT", "short_name": "SFAU", "snr": 3.72, "status": null, "telemetry": {"air_util_tx": 1.312, "battery_level": 77, "channel_utilization": 13.24, "uptime_seconds": 9470, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 2042, "long_name": "Copper Pony", "next_hop": 177, "num": "0x35217dce", "position": {"altitude": 1345, "latitude": 32.359656, "location_source": "LOC_INTERNAL", "longitude": -106.944418, "time_offset_sec": 2177}, "public_key_hex": "64690eb3929b4c9b2d1c4e594f32ff764512f37307010c7e3d401974a1329b29", "role": "CLIENT", "short_name": "COWQ", "snr": 7.03, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.62, "iaq": 76, "relative_humidity": 54.5, "temperature": 27.95}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 20033, "long_name": "Wild Mustang", "next_hop": 185, "num": "0x3526d9c2", "position": {"altitude": 1447, "latitude": 31.809414, "location_source": "LOC_INTERNAL", "longitude": -107.082915, "time_offset_sec": 20328}, "public_key_hex": "b8f48b89b6fe4050f7e7448d010e233e71aed162bcb0c6e18d1eda651251de24", "role": "CLIENT", "short_name": "W1D4", "snr": 6.37, "status": null, "telemetry": {"air_util_tx": 0.26, "battery_level": 94, "channel_utilization": 6.04, "uptime_seconds": 26509, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3109, "long_name": "Tall Squirrel", "next_hop": 0, "num": "0x352cb51a", "position": {"altitude": 1517, "latitude": 32.558113, "location_source": "LOC_INTERNAL", "longitude": -106.477683, "time_offset_sec": 3359}, "public_key_hex": "6b373ed1cd6a924b8c5f551db4bd8dcd60ba241d04d65ed3b5b9ab2b5265106e", "role": "CLIENT", "short_name": "TD9D", "snr": 10.33, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.191, "battery_level": 61, "channel_utilization": 2.33, "uptime_seconds": 49978, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.45, "iaq": 45, "relative_humidity": 51.84, "temperature": 21.34}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4168, "long_name": "Brave Viper", "next_hop": 0, "num": "0x3548cbec", "position": {"altitude": 1030, "latitude": 31.973025, "location_source": "LOC_INTERNAL", "longitude": -107.69068, "time_offset_sec": 4186}, "public_key_hex": "96914f169f2f726b398bb4e9e4186cdf5c8ae8cdc3d9bfcb72f876c0b8b060bb", "role": "CLIENT", "short_name": "B9GL", "snr": 8.66, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.824, "battery_level": 74, "channel_utilization": 14.07, "uptime_seconds": 88402, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 654, "long_name": "Short Marmot", "next_hop": 63, "num": "0x35574aa8", "position": {"altitude": 1359, "latitude": 32.995009, "location_source": "LOC_INTERNAL", "longitude": -107.318563, "time_offset_sec": 683}, "public_key_hex": "4a0e0da9c6107e0936feb8e149ec558518b777ad63dbf31821ba11037ea990b8", "role": "CLIENT", "short_name": "S1EH", "snr": 4.69, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 1913, "long_name": "Mountain Wolf", "next_hop": 144, "num": "0x3558f8bc", "position": null, "public_key_hex": "0f05d51c094fdff77c4d882f38347faa9623a36a7b57b134336d464b05d97e4d", "role": "CLIENT", "short_name": "MQ0C", "snr": 7.87, "status": null, "telemetry": {"air_util_tx": 0.529, "battery_level": 72, "channel_utilization": 15.67, "uptime_seconds": 16891, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4684, "long_name": "Floating Stag", "next_hop": 0, "num": "0x355cd579", "position": {"altitude": 1902, "latitude": 33.912577, "location_source": "LOC_INTERNAL", "longitude": -107.637112, "time_offset_sec": 4758}, "public_key_hex": "45978c2d7056b32f101ffc8e59ebf895537c9c61fcfa9d21b111003ac8c42ced", "role": "CLIENT", "short_name": "F9D4", "snr": 8.97, "status": null, "telemetry": {"air_util_tx": 0.562, "battery_level": 14, "channel_utilization": 6.88, "uptime_seconds": 163039, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 996.73, "iaq": 61, "relative_humidity": 40.32, "temperature": 29.33}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 7445, "long_name": "Silver Bison NM4QJ", "next_hop": 0, "num": "0x356975de", "position": {"altitude": 1081, "latitude": 34.06059, "location_source": "LOC_INTERNAL", "longitude": -107.040865, "time_offset_sec": 7670}, "public_key_hex": "9f777ed0229f6ad63a504b94ca0eeb4d92e119b4e295d3b8554a1cb4507941b6", "role": "CLIENT", "short_name": "S812", "snr": 4.4, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1018.16, "iaq": 0, "relative_humidity": 62.98, "temperature": 32.41}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 5259, "long_name": "Copper Moose", "next_hop": 180, "num": "0x35998bdf", "position": {"altitude": 1802, "latitude": 33.478361, "location_source": "LOC_INTERNAL", "longitude": -108.216813, "time_offset_sec": 5308}, "public_key_hex": "9a6dd3af4cbe6743c901ac0e78ed9c6d04053f6e3f3c8f4f9f0bb269274ce0c1", "role": "CLIENT", "short_name": "CI9H", "snr": 9.39, "status": null, "telemetry": {"air_util_tx": 0.388, "battery_level": 50, "channel_utilization": 3.65, "uptime_seconds": 34370, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 2920, "long_name": "Quick Tortoise", "next_hop": 85, "num": "0x359cd27a", "position": {"altitude": 1421, "latitude": 33.289101, "location_source": "LOC_INTERNAL", "longitude": -108.131765, "time_offset_sec": 2976}, "public_key_hex": "a13a46fd445aaa921d8b00e5df4d4c4d6154fc01c59674cab606e10b57255db3", "role": "CLIENT", "short_name": "Q2H0", "snr": 4.7, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "XIAO_NRF52_KIT", "last_heard_offset_sec": 3707, "long_name": "Wild Juniper", "next_hop": 0, "num": "0x35b3ec3a", "position": {"altitude": 1344, "latitude": 33.473413, "location_source": "LOC_INTERNAL", "longitude": -107.007987, "time_offset_sec": 3739}, "public_key_hex": "abd2c56a61d4f51423eb31e13812979a7df906b62545f84e8ea319f7eba7aadc", "role": "CLIENT", "short_name": "WZF4", "snr": 9.03, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1520, "long_name": "Old Crow", "next_hop": 0, "num": "0x35bb985b", "position": {"altitude": 1277, "latitude": 32.763935, "location_source": "LOC_INTERNAL", "longitude": -107.617196, "time_offset_sec": 1576}, "public_key_hex": "9693b197d278806a2ae9307701e151c7d35adc02d071b3bdcda025acb2fc523a", "role": "CLIENT", "short_name": "OFOF", "snr": 5.7, "status": null, "telemetry": {"air_util_tx": 0.098, "battery_level": 21, "channel_utilization": 11.35, "uptime_seconds": 1111, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.75, "iaq": 72, "relative_humidity": 64.31, "temperature": 18.29}, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 5218, "long_name": "Canyon Owl", "next_hop": 167, "num": "0x35f07be4", "position": {"altitude": 1210, "latitude": 33.317904, "location_source": "LOC_INTERNAL", "longitude": -106.003853, "time_offset_sec": 5377}, "public_key_hex": "f11cae637af0dd3e430e54a334a05fa86a765d594d8b5cd9ee722e437bb6f31d", "role": "CLIENT", "short_name": "C1TF", "snr": 3.15, "status": null, "telemetry": {"air_util_tx": 0.633, "battery_level": 41, "channel_utilization": 8.52, "uptime_seconds": 84177, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3199, "long_name": "Whispering Tortoise", "next_hop": 0, "num": "0x3620ff7a", "position": {"altitude": 1398, "latitude": 32.57868, "location_source": "LOC_INTERNAL", "longitude": -107.166172, "time_offset_sec": 3459}, "public_key_hex": "b94671f63913dbada2957c069b284a71cbfd0f2b969c828489f4f0a7f0519d0a", "role": "CLIENT", "short_name": "WCPL", "snr": 5.3, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 5894, "long_name": "Sky Badger", "next_hop": 0, "num": "0x3627861e", "position": {"altitude": 1453, "latitude": 32.731602, "location_source": "LOC_INTERNAL", "longitude": -106.638312, "time_offset_sec": 5944}, "public_key_hex": "a5154a9e5bf8bde2c8c8f4ae534bd59ed349bdaf5cc81b21c8596fec7babb9f7", "role": "TRACKER", "short_name": "S7YB", "snr": 1.19, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.356, "battery_level": 101, "channel_utilization": 5.19, "uptime_seconds": 4005, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 542, "long_name": "Hidden Bluff K14GH", "next_hop": 31, "num": "0x3641c0c5", "position": null, "public_key_hex": "bf259910200cf660aad163f91869e880844aa3aed407acbfb3ed6f58d810c793", "role": "CLIENT_MUTE", "short_name": "HCTN", "snr": 4.49, "status": null, "telemetry": {"air_util_tx": 0.875, "battery_level": 64, "channel_utilization": 20.68, "uptime_seconds": 142080, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 6862, "long_name": "Howling Falcon", "next_hop": 0, "num": "0x3641dba3", "position": {"altitude": 1724, "latitude": 32.527684, "location_source": "LOC_INTERNAL", "longitude": -107.319983, "time_offset_sec": 6947}, "public_key_hex": "a31d6b3bd39d953e8e34740fd644e08aef4ccb616561ce05cb464092bc5a57a6", "role": "CLIENT", "short_name": "H9ZK", "snr": 7.29, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.69, "iaq": 50, "relative_humidity": 68.25, "temperature": 10.9}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2451, "long_name": "Loud Viper", "next_hop": 0, "num": "0x3656750f", "position": {"altitude": 1247, "latitude": 33.096737, "location_source": "LOC_INTERNAL", "longitude": -108.017492, "time_offset_sec": 2681}, "public_key_hex": "b0e318b0aa5458117d7cdcb276cc79ed0c9a2bb3d52695096bd694e86790a8fb", "role": "CLIENT", "short_name": "LM3R", "snr": 9.22, "status": null, "telemetry": {"air_util_tx": 1.244, "battery_level": 48, "channel_utilization": 3.19, "uptime_seconds": 15788, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 943, "long_name": "Blue Wolf", "next_hop": 161, "num": "0x365f0aa8", "position": null, "public_key_hex": "a151f8e4021b69278412e87f0aae400fe1800f3df3ed4ca8034a26b56d136322", "role": "CLIENT", "short_name": "BONG", "snr": 9.29, "status": null, "telemetry": {"air_util_tx": 0.089, "battery_level": 54, "channel_utilization": 6.04, "uptime_seconds": 108758, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1006.37, "iaq": 28, "relative_humidity": 77.69, "temperature": 30.16}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 442, "long_name": "Misty Heron", "next_hop": 0, "num": "0x3666ccbd", "position": {"altitude": 1878, "latitude": 34.104984, "location_source": "LOC_INTERNAL", "longitude": -107.45594, "time_offset_sec": 702}, "public_key_hex": "c3bfda3ae179ba4858da0877e26be1af3111025db6beae37deacced7e94df8bc", "role": "ROUTER", "short_name": "M0VU", "snr": 9.76, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.909, "battery_level": 73, "channel_utilization": 9.97, "uptime_seconds": 8481, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1382, "long_name": "Mountain Pony", "next_hop": 0, "num": "0x366bfe92", "position": {"altitude": 1232, "latitude": 33.483698, "location_source": "LOC_INTERNAL", "longitude": -106.876886, "time_offset_sec": 1682}, "public_key_hex": "1a56dd799e19f529cdc5630beeb77547953c738e13c17fc4bde7f1fea457e9f6", "role": "CLIENT", "short_name": "MWJJ", "snr": 8.17, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.75, "battery_level": 24, "channel_utilization": 20.49, "uptime_seconds": 77617, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2275, "long_name": "New Coyote", "next_hop": 0, "num": "0x36806c45", "position": {"altitude": 1436, "latitude": 34.291026, "location_source": "LOC_INTERNAL", "longitude": -106.784084, "time_offset_sec": 2351}, "public_key_hex": "11ae36d52049ffa972ac6190cba746b775c2ea81bad6d8a4ebc9dc39f8a94947", "role": "CLIENT", "short_name": "NHM1", "snr": 3.81, "status": null, "telemetry": {"air_util_tx": 0.188, "battery_level": 100, "channel_utilization": 25.83, "uptime_seconds": 88072, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 2879, "long_name": "Slow Whale", "next_hop": 212, "num": "0x368a4dec", "position": {"altitude": 772, "latitude": 32.280882, "location_source": "LOC_INTERNAL", "longitude": -106.803003, "time_offset_sec": 3147}, "public_key_hex": "a8395968ea3bfcfa142164445876c3df40ea4b519814098a33ddf4ec840b07ac", "role": "CLIENT", "short_name": "SQE2", "snr": 7.22, "status": null, "telemetry": {"air_util_tx": 0.793, "battery_level": 29, "channel_utilization": 4.2, "uptime_seconds": 62697, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 3553, "long_name": "Fast Badger", "next_hop": 32, "num": "0x369fa657", "position": {"altitude": 1286, "latitude": 32.79539, "location_source": "LOC_INTERNAL", "longitude": -107.979096, "time_offset_sec": 3599}, "public_key_hex": "2f60d1343575325d792aaf9437c2675999c26f95291b024b6bf6e05d0a4a864b", "role": "CLIENT", "short_name": "FR1A", "snr": -1.72, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 486, "long_name": "New Marmot", "next_hop": 0, "num": "0x36d0433b", "position": {"altitude": 1231, "latitude": 32.571721, "location_source": "LOC_INTERNAL", "longitude": -106.635672, "time_offset_sec": 539}, "public_key_hex": "34a12555fe4fbba1fff2ec58ef1407277b8704f7ddfc3f90607d79eeb68f702e", "role": "CLIENT", "short_name": "NOQF", "snr": 5.04, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.834, "battery_level": 30, "channel_utilization": 20.56, "uptime_seconds": 105545, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 979, "long_name": "Whispering Mesa", "next_hop": 0, "num": "0x36d2a7f8", "position": null, "public_key_hex": "b469441d7c6879b9978b406390cc3e9fea9abc8f8bc06224bd6ae9b707d5a14d", "role": "CLIENT", "short_name": "W0P6", "snr": 4.37, "status": null, "telemetry": {"air_util_tx": 0.5, "battery_level": 16, "channel_utilization": 5.21, "uptime_seconds": 31074, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 1501, "long_name": "Burning Phoenix", "next_hop": 0, "num": "0x36e097e6", "position": null, "public_key_hex": "ed278bcabcb49a404595d6a7b8455dab9d33fdd2dd257d427544263c6326dcfc", "role": "CLIENT_HIDDEN", "short_name": "B3O1", "snr": 6.93, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 1, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2493, "long_name": "Dusk Raven", "next_hop": 7, "num": "0x3743f26a", "position": {"altitude": 1512, "latitude": 32.03448, "location_source": "LOC_INTERNAL", "longitude": -107.196042, "time_offset_sec": 2647}, "public_key_hex": "7bc99b6dbe44f1c5bee828cd73928de5493de2090d1c33b4c3de6335e2c640dd", "role": "CLIENT", "short_name": "DSMN", "snr": 8.44, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.219, "battery_level": 101, "channel_utilization": 13.76, "uptime_seconds": 62341, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3218, "long_name": "Floating Moose", "next_hop": 0, "num": "0x374d502b", "position": {"altitude": 1718, "latitude": 33.906878, "location_source": "LOC_INTERNAL", "longitude": -107.687024, "time_offset_sec": 3317}, "public_key_hex": "142ce9fb0e3f18675afc8d42cf6b53b094ea98c3449f59b63c3eaa920a3275e1", "role": "ROUTER_LATE", "short_name": "FBV4", "snr": 6.78, "status": null, "telemetry": {"air_util_tx": 0.377, "battery_level": 65, "channel_utilization": 10.3, "uptime_seconds": 20879, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.4, "iaq": 0, "relative_humidity": 57.44, "temperature": 33.31}, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2831, "long_name": "Forest Cobra", "next_hop": 182, "num": "0x374d6cd4", "position": null, "public_key_hex": "f113feecc0d3a27fa8144e2984c168b0cff9cafa350d4be3502d5dec7b8cfc86", "role": "CLIENT_MUTE", "short_name": "FPU3", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1166, "long_name": "Gold Oak", "next_hop": 0, "num": "0x3777b818", "position": {"altitude": 1360, "latitude": 33.202825, "location_source": "LOC_INTERNAL", "longitude": -107.258619, "time_offset_sec": 1237}, "public_key_hex": "7665ea439d54dbde5e22b48eed08ba017d0dd2946aec6db78beefb03eaecd1d0", "role": "SENSOR", "short_name": "G0BF", "snr": 3.59, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.724, "battery_level": 90, "channel_utilization": 6.1, "uptime_seconds": 32455, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 819, "long_name": "Sharp Aspen", "next_hop": 0, "num": "0x377e015d", "position": {"altitude": 1238, "latitude": 33.558082, "location_source": "LOC_INTERNAL", "longitude": -107.772664, "time_offset_sec": 831}, "public_key_hex": "92a2daa8b5f11aa35d8538630b18cc56a183060487df6644dac2a44e9e701183", "role": "CLIENT", "short_name": "SNTS", "snr": 8.29, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.147, "battery_level": 91, "channel_utilization": 17.46, "uptime_seconds": 28816, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.15, "iaq": 67, "relative_humidity": 50.28, "temperature": 33.16}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2104, "long_name": "Storm Phoenix", "next_hop": 169, "num": "0x3780ef61", "position": null, "public_key_hex": "d3133ce69cbed5b7b5ad3b23a8749988f71d6da7e63f24e65d76de94f2b081b1", "role": "ROUTER", "short_name": "S7V8", "snr": 1.62, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.528, "battery_level": 86, "channel_utilization": 19.59, "uptime_seconds": 91027, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 493, "long_name": "Short Oak", "next_hop": 0, "num": "0x3782cff6", "position": {"altitude": 1591, "latitude": 33.397177, "location_source": "LOC_INTERNAL", "longitude": -107.109316, "time_offset_sec": 495}, "public_key_hex": "6be741ded2abd6aa26a38083fa7a435f8c2d40db8456d3f34011efe737e001ea", "role": "ROUTER", "short_name": "SMXR", "snr": 6.12, "status": null, "telemetry": {"air_util_tx": 0.739, "battery_level": 19, "channel_utilization": 15.01, "uptime_seconds": 51721, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2890, "long_name": "Bright Otter", "next_hop": 153, "num": "0x37b0025d", "position": {"altitude": 1435, "latitude": 33.532624, "location_source": "LOC_INTERNAL", "longitude": -107.273748, "time_offset_sec": 3013}, "public_key_hex": "", "role": "CLIENT", "short_name": "BTWQ", "snr": 2.25, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 5325, "long_name": "Quick Sage", "next_hop": 0, "num": "0x37b42f3b", "position": {"altitude": 1322, "latitude": 33.149899, "location_source": "LOC_INTERNAL", "longitude": -107.449754, "time_offset_sec": 5528}, "public_key_hex": "cb4b637125bfe1ef179350fe1b777774d95a55f58917a3defbad60108865a9c0", "role": "ROUTER", "short_name": "QZSV", "snr": 7.41, "status": null, "telemetry": {"air_util_tx": 1.782, "battery_level": 14, "channel_utilization": 8.68, "uptime_seconds": 33786, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 4753, "long_name": "Rough Cougar", "next_hop": 0, "num": "0x37bd3f3c", "position": null, "public_key_hex": "3bacd05256113bd37a783e4451b3fd1fa426c2fb9e80d372011e95b52bab493d", "role": "ROUTER", "short_name": "🦇", "snr": -1.01, "status": null, "telemetry": {"air_util_tx": 0.789, "battery_level": 75, "channel_utilization": 11.8, "uptime_seconds": 184436, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.6, "iaq": 92, "relative_humidity": 35.07, "temperature": 32.94}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1600, "long_name": "Steel Mustang", "next_hop": 0, "num": "0x37c163a5", "position": {"altitude": 1827, "latitude": 32.850602, "location_source": "LOC_INTERNAL", "longitude": -107.407592, "time_offset_sec": 1739}, "public_key_hex": "2576f58a76c82d1b266778d27665dd5e95f229cc0f9cb28885740b0602014f14", "role": "CLIENT_BASE", "short_name": "SUUR", "snr": 0.54, "status": null, "telemetry": {"air_util_tx": 0.63, "battery_level": 88, "channel_utilization": 4.15, "uptime_seconds": 294974, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 900, "long_name": "White Eagle", "next_hop": 197, "num": "0x37d36612", "position": {"altitude": 1243, "latitude": 32.665252, "location_source": "LOC_INTERNAL", "longitude": -108.460929, "time_offset_sec": 1026}, "public_key_hex": "e54464f87b1f8544fb8c54ae6775514a3aa5a9482707db037d479a21d7cb782d", "role": "CLIENT", "short_name": "WUTN", "snr": 12.0, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 10978, "long_name": "Iron Iguana", "next_hop": 0, "num": "0x37d4055c", "position": {"altitude": 971, "latitude": 33.014142, "location_source": "LOC_INTERNAL", "longitude": -106.136993, "time_offset_sec": 10985}, "public_key_hex": "71154f789e11292a3b48f64ad4663ba6ab3c1cd95f5f96d4003cac633ba64e4f", "role": "CLIENT_MUTE", "short_name": "🌙", "snr": 7.8, "status": {"status": "rebooted"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 90, "long_name": "Howling Otter", "next_hop": 0, "num": "0x37e0a63c", "position": null, "public_key_hex": "1560b802d1c799e3d32a015859c2a213548b2f825012019d31753613832ad984", "role": "CLIENT", "short_name": "🌊", "snr": -0.68, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.166, "battery_level": 65, "channel_utilization": 8.46, "uptime_seconds": 308803, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1283, "long_name": "Floating Fox", "next_hop": 126, "num": "0x3834b280", "position": {"altitude": 1282, "latitude": 33.668235, "location_source": "LOC_INTERNAL", "longitude": -108.106853, "time_offset_sec": 1461}, "public_key_hex": "55955d3a1f2e742726dc4f18cf16193a0cc42eeb433df983d22dcfc1b2c7e73d", "role": "CLIENT", "short_name": "F360", "snr": 4.33, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.71, "iaq": 66, "relative_humidity": 63.24, "temperature": 27.58}, "hops_away": 7, "hw_model": "RAK4631", "last_heard_offset_sec": 6815, "long_name": "Rough Falcon", "next_hop": 229, "num": "0x38513236", "position": {"altitude": 1297, "latitude": 33.287631, "location_source": "LOC_INTERNAL", "longitude": -107.384309, "time_offset_sec": 7072}, "public_key_hex": "f9e7847055ce39fa632cde1c1d082ec673fa423fdabebc7cd19b61bd92ea2ffc", "role": "CLIENT", "short_name": "REEN", "snr": 3.54, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.079, "battery_level": 97, "channel_utilization": 8.19, "uptime_seconds": 136144, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.22, "iaq": 63, "relative_humidity": 68.77, "temperature": 19.16}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 881, "long_name": "Sleepy Sage", "next_hop": 0, "num": "0x3853ea45", "position": {"altitude": 1376, "latitude": 33.554938, "location_source": "LOC_INTERNAL", "longitude": -106.549887, "time_offset_sec": 1053}, "public_key_hex": "90a2b3681e84de58644aa573a8eb84b6533b4336057a5e0ef7a8e387d0fb5fae", "role": "CLIENT", "short_name": "SP22", "snr": 9.52, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.351, "battery_level": 24, "channel_utilization": 16.6, "uptime_seconds": 16621, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 12731, "long_name": "Blue Ridge", "next_hop": 0, "num": "0x38632e8b", "position": {"altitude": 1880, "latitude": 32.602314, "location_source": "LOC_INTERNAL", "longitude": -107.294264, "time_offset_sec": 12782}, "public_key_hex": "820227fe6d73d5a77922963e88e832f70a7d81b3ab3ad97ef4d519976d80a7e5", "role": "CLIENT", "short_name": "BLTN", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.387, "battery_level": 48, "channel_utilization": 3.63, "uptime_seconds": 45651, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 13, "long_name": "Tall Dolphin", "next_hop": 0, "num": "0x38715531", "position": null, "public_key_hex": "ce6be6a61c0c2b2193befd35bba9be31b63a27aab88f010b350dc828fc1906b6", "role": "CLIENT", "short_name": "TKLK", "snr": 10.41, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5250, "long_name": "Black Bear", "next_hop": 0, "num": "0x3871ebfd", "position": {"altitude": 1035, "latitude": 33.094134, "location_source": "LOC_INTERNAL", "longitude": -106.915258, "time_offset_sec": 5305}, "public_key_hex": "ff603d262d01ff01c4bb3629876e52764caf3374b055a639f3d27b283e197ce5", "role": "CLIENT", "short_name": "BR36", "snr": 9.54, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.651, "battery_level": 27, "channel_utilization": 3.14, "uptime_seconds": 41752, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.06, "iaq": 32, "relative_humidity": 67.29, "temperature": 28.98}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1550, "long_name": "Fast Ridge NM0WZ", "next_hop": 0, "num": "0x38796328", "position": {"altitude": 812, "latitude": 33.565252, "location_source": "LOC_INTERNAL", "longitude": -106.713965, "time_offset_sec": 1813}, "public_key_hex": "83f8f6db05246e039f7f4e031730dde81e2d61fa916484abbca8d1ab0220c85e", "role": "CLIENT", "short_name": "FLK4", "snr": 6.26, "status": null, "telemetry": {"air_util_tx": 0.716, "battery_level": 70, "channel_utilization": 7.6, "uptime_seconds": 38488, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 6765, "long_name": "Bright Crane AE1QQ", "next_hop": 0, "num": "0x3885780c", "position": {"altitude": 1475, "latitude": 33.208932, "location_source": "LOC_INTERNAL", "longitude": -107.654474, "time_offset_sec": 6851}, "public_key_hex": "b6b21831745779275bc3e738f1daa43e0aed6c92fc8631184333db6cea89c04e", "role": "CLIENT", "short_name": "BLIM", "snr": 3.51, "status": null, "telemetry": {"air_util_tx": 0.267, "battery_level": 99, "channel_utilization": 5.2, "uptime_seconds": 96135, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.47, "iaq": 27, "relative_humidity": 40.88, "temperature": 27.7}, "hops_away": 2, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 1609, "long_name": "Sky Arroyo", "next_hop": 108, "num": "0x38861b2f", "position": {"altitude": 1661, "latitude": 33.515564, "location_source": "LOC_INTERNAL", "longitude": -108.212658, "time_offset_sec": 1675}, "public_key_hex": "d514c1053cc706544722615bea73a4f12427455843f2f3b0ad55ae5ef6c91c14", "role": "SENSOR", "short_name": "S27L", "snr": 0.45, "status": null, "telemetry": {"air_util_tx": 0.688, "battery_level": 91, "channel_utilization": 4.8, "uptime_seconds": 129803, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2238, "long_name": "Bright Cedar", "next_hop": 191, "num": "0x38985442", "position": {"altitude": 1546, "latitude": 32.504936, "location_source": "LOC_INTERNAL", "longitude": -108.348921, "time_offset_sec": 2533}, "public_key_hex": "137357b814a25a0842ebeb9251955f45b54161c533f73a57e6da8280a25c8d7c", "role": "CLIENT", "short_name": "B742", "snr": 7.58, "status": null, "telemetry": {"air_util_tx": 0.255, "battery_level": 72, "channel_utilization": 6.85, "uptime_seconds": 50174, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 1776, "long_name": "Slow Oak", "next_hop": 146, "num": "0x38b95a7c", "position": {"altitude": 1481, "latitude": 32.016429, "location_source": "LOC_INTERNAL", "longitude": -106.729658, "time_offset_sec": 1936}, "public_key_hex": "7ac954733b56bca5982542e65d6ee4f38339ecbfe05967730253c84b108e1184", "role": "CLIENT", "short_name": "S4C9", "snr": 7.72, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1561, "long_name": "Desert Cactus", "next_hop": 0, "num": "0x38c5864a", "position": {"altitude": 1033, "latitude": 32.290331, "location_source": "LOC_INTERNAL", "longitude": -106.6903, "time_offset_sec": 1860}, "public_key_hex": "721ef20b759a14b386c3cb8ac64a5731a255034d2e61181b964135144f9bc2af", "role": "CLIENT", "short_name": "D0NI", "snr": 5.11, "status": null, "telemetry": {"air_util_tx": 2.012, "battery_level": 23, "channel_utilization": 13.35, "uptime_seconds": 90079, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4746, "long_name": "Steel Yucca", "next_hop": 186, "num": "0x38d768ac", "position": {"altitude": 1339, "latitude": 33.171668, "location_source": "LOC_INTERNAL", "longitude": -106.971941, "time_offset_sec": 4822}, "public_key_hex": "78e178824ed19759a117e615d7971cedd3fc2776cabf4881d6719a97d136933c", "role": "ROUTER", "short_name": "SX9U", "snr": 1.16, "status": null, "telemetry": {"air_util_tx": 0.61, "battery_level": 42, "channel_utilization": 1.29, "uptime_seconds": 62907, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 5896, "long_name": "Lunar Crane", "next_hop": 147, "num": "0x38db7ec9", "position": {"altitude": 1285, "latitude": 33.463872, "location_source": "LOC_INTERNAL", "longitude": -107.185638, "time_offset_sec": 6127}, "public_key_hex": "b72da9c8fc3cad0752f57d4c3f7e66fa2d6b56390d22a7ab9883fc2a7c980488", "role": "CLIENT", "short_name": "LU8M", "snr": 8.72, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 3789, "long_name": "Lone Owl", "next_hop": 163, "num": "0x38f01057", "position": {"altitude": 1474, "latitude": 33.427777, "location_source": "LOC_INTERNAL", "longitude": -107.577961, "time_offset_sec": 3799}, "public_key_hex": "6368c18cb6775074e85c0a2f322124b2108317b7c724537db5a1c70d8b3904ab", "role": "CLIENT", "short_name": "L3GS", "snr": 8.92, "status": null, "telemetry": {"air_util_tx": 1.987, "battery_level": 81, "channel_utilization": 14.97, "uptime_seconds": 162555, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3722, "long_name": "Gold Lynx", "next_hop": 0, "num": "0x39030b39", "position": {"altitude": 1554, "latitude": 33.114634, "location_source": "LOC_INTERNAL", "longitude": -106.539104, "time_offset_sec": 3810}, "public_key_hex": "", "role": "TAK_TRACKER", "short_name": "GCCD", "snr": 2.49, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.713, "battery_level": 27, "channel_utilization": 1.15, "uptime_seconds": 27942, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAG", "last_heard_offset_sec": 720, "long_name": "Blue Owl", "next_hop": 0, "num": "0x3919e914", "position": {"altitude": 1003, "latitude": 33.324421, "location_source": "LOC_INTERNAL", "longitude": -107.248174, "time_offset_sec": 802}, "public_key_hex": "febbfa50e59b1fd4a2d63a5fcf5d155e31957db5083d3905874bb5a3aceb6e54", "role": "CLIENT", "short_name": "B1XH", "snr": 0.26, "status": null, "telemetry": {"air_util_tx": 1.779, "battery_level": 19, "channel_utilization": 40.17, "uptime_seconds": 139697, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 26909, "long_name": "White Cougar", "next_hop": 0, "num": "0x39219ae4", "position": {"altitude": 1017, "latitude": 33.487694, "location_source": "LOC_INTERNAL", "longitude": -108.174844, "time_offset_sec": 26915}, "public_key_hex": "860b77027d108f65a723811454764b19ceaeccce692e2ff38346c7b7bbec9fa7", "role": "CLIENT", "short_name": "W9GH", "snr": 7.55, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 6067, "long_name": "Fast Owl", "next_hop": 0, "num": "0x3927b685", "position": {"altitude": 958, "latitude": 33.534844, "location_source": "LOC_INTERNAL", "longitude": -107.226094, "time_offset_sec": 6183}, "public_key_hex": "177d01eb9e49895c22c9413f42c675bc3ca1ca8945c23e7e9d3b891c2380b3ca", "role": "TRACKER", "short_name": "FBD8", "snr": 7.01, "status": null, "telemetry": {"air_util_tx": 0.64, "battery_level": 82, "channel_utilization": 13.99, "uptime_seconds": 16746, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.83, "iaq": 31, "relative_humidity": 44.4, "temperature": 22.52}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 84, "long_name": "Wandering Juniper", "next_hop": 0, "num": "0x392c05f0", "position": null, "public_key_hex": "f0f1fd6600fd1367ebbcab5fbf3c6cfef12d373e0a805b8114d28805c7876774", "role": "CLIENT", "short_name": "WMXV", "snr": 3.08, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.606, "battery_level": 96, "channel_utilization": 9.75, "uptime_seconds": 246974, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.5, "iaq": 36, "relative_humidity": 91.21, "temperature": 26.8}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 3000, "long_name": "Copper Stag", "next_hop": 205, "num": "0x395d1b26", "position": {"altitude": 2076, "latitude": 32.166023, "location_source": "LOC_INTERNAL", "longitude": -107.653062, "time_offset_sec": 3147}, "public_key_hex": "530d66b60798d348732966c8dd79e6721d6e9d2fce72156adec66f60e1f26d9b", "role": "CLIENT_HIDDEN", "short_name": "CZHF", "snr": 7.58, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2382, "long_name": "Mountain Arroyo", "next_hop": 0, "num": "0x3982bf11", "position": {"altitude": 1454, "latitude": 33.404671, "location_source": "LOC_INTERNAL", "longitude": -107.936975, "time_offset_sec": 2657}, "public_key_hex": "eabe65eb64e80f0b846e2d2228654d25dfda3240287ab941a6f8b9d4459defb4", "role": "LOST_AND_FOUND", "short_name": "MVN2", "snr": 11.41, "status": null, "telemetry": {"air_util_tx": 3.155, "battery_level": 97, "channel_utilization": 26.18, "uptime_seconds": 20410, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.38, "iaq": 55, "relative_humidity": 58.3, "temperature": 33.49}, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 488, "long_name": "Green Pike", "next_hop": 0, "num": "0x39862a49", "position": null, "public_key_hex": "157297265a56319badd6d252ad151bf0259bcdf0d4eb7c536deb6058ed991d77", "role": "CLIENT_MUTE", "short_name": "G4EO", "snr": 7.41, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.721, "battery_level": 94, "channel_utilization": 19.65, "uptime_seconds": 29381, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.89, "iaq": 82, "relative_humidity": 72.89, "temperature": 26.58}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2339, "long_name": "Desert Bluff", "next_hop": 0, "num": "0x398f0684", "position": {"altitude": 1574, "latitude": 32.44146, "location_source": "LOC_INTERNAL", "longitude": -106.260748, "time_offset_sec": 2596}, "public_key_hex": "314e7d81cc4d54b3e006b7819e060e0a6c92de3ab5ce423555649c8effa8937a", "role": "CLIENT", "short_name": "DF33", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4323, "long_name": "Rough Iguana", "next_hop": 80, "num": "0x399ea209", "position": null, "public_key_hex": "831b0837c483538caa7a7dbbe2f129764215db4ae38fac7aef016443f393cf7f", "role": "CLIENT", "short_name": "R0AQ", "snr": 10.02, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.452, "battery_level": 82, "channel_utilization": 9.71, "uptime_seconds": 85872, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3992, "long_name": "Tall Coyote", "next_hop": 0, "num": "0x39ac594d", "position": {"altitude": 1402, "latitude": 33.017218, "location_source": "LOC_INTERNAL", "longitude": -107.849515, "time_offset_sec": 4181}, "public_key_hex": "d7f54ff9fd9bf864dccb3da1cff27cccfa47658fcbdbcaaaf3b539afe7a9c9a3", "role": "CLIENT_MUTE", "short_name": "TIZR", "snr": 7.24, "status": null, "telemetry": {"air_util_tx": 0.208, "battery_level": 93, "channel_utilization": 16.15, "uptime_seconds": 140532, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 14848, "long_name": "Desert Raven", "next_hop": 0, "num": "0x39d96f5b", "position": {"altitude": 1665, "latitude": 32.299284, "location_source": "LOC_INTERNAL", "longitude": -106.926922, "time_offset_sec": 15074}, "public_key_hex": "157c1f80c4161b997447f546bf9ceb0d21f3c07fd183839ad10b37341c2f9256", "role": "CLIENT", "short_name": "DYNK", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 23043, "long_name": "Iron Shark", "next_hop": 0, "num": "0x39fbefdc", "position": {"altitude": 1410, "latitude": 33.624878, "location_source": "LOC_INTERNAL", "longitude": -107.042371, "time_offset_sec": 23213}, "public_key_hex": "3569e7298fa9a77b3afb57b15595e100b71a22b7959c5e6a7dcb463c8e5ca4ac", "role": "CLIENT_MUTE", "short_name": "IP3H", "snr": 9.03, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.741, "battery_level": 34, "channel_utilization": 5.05, "uptime_seconds": 7034, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 9721, "long_name": "Fast Yucca", "next_hop": 0, "num": "0x3a249aa3", "position": {"altitude": 1298, "latitude": 32.864953, "location_source": "LOC_INTERNAL", "longitude": -107.460932, "time_offset_sec": 9896}, "public_key_hex": "9df5a8e3ef765f13c9a5fb3cf1ed6a37e1a88a282b01b62f7d60640cfe3663e5", "role": "CLIENT", "short_name": "F0X7", "snr": -0.01, "status": {"status": "rebooted"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 693, "long_name": "Sunny Sage", "next_hop": 49, "num": "0x3a36b3f3", "position": null, "public_key_hex": "ae3aaed7fd39188a644dc2e9652fe24af01dc9f3da96e36561b7e14ba92be165", "role": "ROUTER", "short_name": "SF1I", "snr": 4.63, "status": null, "telemetry": {"air_util_tx": 0.453, "battery_level": 12, "channel_utilization": 23.57, "uptime_seconds": 125246, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1150, "long_name": "Mountain Adder", "next_hop": 0, "num": "0x3a38aaab", "position": {"altitude": 1222, "latitude": 34.090147, "location_source": "LOC_INTERNAL", "longitude": -107.260429, "time_offset_sec": 1263}, "public_key_hex": "eab5e80714e858d4897346ec3b7fa85418b55c7d1b13e80af0395d24e3c97e02", "role": "CLIENT", "short_name": "MO2M", "snr": 3.34, "status": null, "telemetry": {"air_util_tx": 0.105, "battery_level": 30, "channel_utilization": 11.71, "uptime_seconds": 174810, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 250, "long_name": "River Cougar AE0IO", "next_hop": 63, "num": "0x3aa2cfdf", "position": {"altitude": 1418, "latitude": 32.883354, "location_source": "LOC_INTERNAL", "longitude": -106.874377, "time_offset_sec": 294}, "public_key_hex": "", "role": "CLIENT", "short_name": "RAQR", "snr": 9.01, "status": null, "telemetry": {"air_util_tx": 1.357, "battery_level": 101, "channel_utilization": 19.14, "uptime_seconds": 86437, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1384, "long_name": "Slow Seal", "next_hop": 139, "num": "0x3aba9dab", "position": {"altitude": 1349, "latitude": 33.361901, "location_source": "LOC_INTERNAL", "longitude": -107.296906, "time_offset_sec": 1575}, "public_key_hex": "da029d5a534ac50ebcdd2f5c36ed49da381029b1c31f025c8c3e676faf666ec7", "role": "CLIENT", "short_name": "S7WU", "snr": 0.43, "status": null, "telemetry": {"air_util_tx": 0.869, "battery_level": 101, "channel_utilization": 3.05, "uptime_seconds": 66792, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1019.89, "iaq": 22, "relative_humidity": 40.89, "temperature": 26.4}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 9860, "long_name": "Quick Pike", "next_hop": 58, "num": "0x3adb9b8b", "position": {"altitude": 1196, "latitude": 33.8989, "location_source": "LOC_INTERNAL", "longitude": -106.883229, "time_offset_sec": 9995}, "public_key_hex": "", "role": "CLIENT", "short_name": "🌲", "snr": 6.39, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.888, "battery_level": 50, "channel_utilization": 3.73, "uptime_seconds": 19925, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": {"barometric_pressure": 1015.32, "iaq": 0, "relative_humidity": 42.58, "temperature": 23.45}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4572, "long_name": "Happy Squirrel", "next_hop": 0, "num": "0x3aecefb1", "position": {"altitude": 1335, "latitude": 33.425785, "location_source": "LOC_INTERNAL", "longitude": -107.243951, "time_offset_sec": 4611}, "public_key_hex": "9e21349e5a522fe74a7a2556e7b0bf877116ded311c3b34493870ee27018ece9", "role": "CLIENT", "short_name": "HXWS", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1627, "long_name": "Red Coyote", "next_hop": 41, "num": "0x3af7fa15", "position": {"altitude": 1420, "latitude": 33.574154, "location_source": "LOC_INTERNAL", "longitude": -106.581974, "time_offset_sec": 1816}, "public_key_hex": "c426e492b3728f41aa6e970bdb6bbbc7a2c88eca6840fc395a2d2693dc634b2b", "role": "CLIENT", "short_name": "RUDT", "snr": 9.05, "status": null, "telemetry": {"air_util_tx": 1.932, "battery_level": 49, "channel_utilization": 3.85, "uptime_seconds": 137545, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "RAK4631", "last_heard_offset_sec": 2506, "long_name": "Happy Crow", "next_hop": 13, "num": "0x3b018894", "position": {"altitude": 1160, "latitude": 33.46971, "location_source": "LOC_INTERNAL", "longitude": -106.358573, "time_offset_sec": 2685}, "public_key_hex": "15d674b3b2ac90f51717ce0a5889aed24abf19ab1f11ae2f84aad82b3f10055d", "role": "SENSOR", "short_name": "H54N", "snr": 4.14, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 600, "long_name": "Wandering Squirrel AB7PV", "next_hop": 0, "num": "0x3b1002ff", "position": {"altitude": 1712, "latitude": 33.459123, "location_source": "LOC_INTERNAL", "longitude": -106.959606, "time_offset_sec": 767}, "public_key_hex": "3b000916e19818b2b997752d73661554af0a9ada7ab154307f987a828a1265f9", "role": "CLIENT", "short_name": "W9BN", "snr": 12.0, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 4307, "long_name": "Short Colt", "next_hop": 0, "num": "0x3b122168", "position": {"altitude": 1786, "latitude": 32.106056, "location_source": "LOC_INTERNAL", "longitude": -107.120691, "time_offset_sec": 4536}, "public_key_hex": "5fc15d7c1d9d4b8c2ea99884d27587e0cf12c120b4ab73a95c6dfadc3e15244b", "role": "CLIENT", "short_name": "🐝", "snr": 1.5, "status": null, "telemetry": {"air_util_tx": 0.511, "battery_level": 23, "channel_utilization": 21.79, "uptime_seconds": 106790, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3922, "long_name": "Gold Bison", "next_hop": 0, "num": "0x3b3190a4", "position": {"altitude": 992, "latitude": 32.517946, "location_source": "LOC_INTERNAL", "longitude": -107.401154, "time_offset_sec": 4155}, "public_key_hex": "e86037df216f5cf0b52d44267f64aed861140b326ddb86aaf631a3b83c643ebe", "role": "CLIENT", "short_name": "G75W", "snr": 6.17, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.034, "battery_level": 101, "channel_utilization": 2.56, "uptime_seconds": 15047, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 5921, "long_name": "Tiny Hawk", "next_hop": 0, "num": "0x3b520cd5", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "🐝", "snr": 6.94, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.181, "battery_level": 44, "channel_utilization": 20.71, "uptime_seconds": 48220, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.56, "iaq": 0, "relative_humidity": 48.7, "temperature": 18.17}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1548, "long_name": "Slow Owl", "next_hop": 0, "num": "0x3b7f5e03", "position": {"altitude": 1282, "latitude": 32.031868, "location_source": "LOC_INTERNAL", "longitude": -107.655063, "time_offset_sec": 1732}, "public_key_hex": "cec5da4808a0ce64566f1f818fedbecbdc623ccaaa99e81e551eed8ca82bc849", "role": "CLIENT", "short_name": "SW23", "snr": 4.54, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.84, "iaq": 19, "relative_humidity": 38.85, "temperature": 36.66}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 11099, "long_name": "Fast Pony KE9PM", "next_hop": 0, "num": "0x3ba048e8", "position": {"altitude": 1404, "latitude": 32.658827, "location_source": "LOC_INTERNAL", "longitude": -107.606265, "time_offset_sec": 11256}, "public_key_hex": "2c633017fd4d3e869590b6a95505b4b359c083720d0af672cea003da94b19f6b", "role": "CLIENT", "short_name": "F0JL", "snr": 3.95, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.016, "battery_level": 101, "channel_utilization": 7.44, "uptime_seconds": 154288, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "RAK4631", "last_heard_offset_sec": 3401, "long_name": "Frosty Whale", "next_hop": 216, "num": "0x3bb5da4f", "position": {"altitude": 1397, "latitude": 33.671001, "location_source": "LOC_INTERNAL", "longitude": -106.560561, "time_offset_sec": 3584}, "public_key_hex": "08bbabc2b5c6c538dcaecad925e71ced565851c1f58b01cc58a1a37c77033f11", "role": "CLIENT", "short_name": "F4X2", "snr": -6.32, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.447, "battery_level": 27, "channel_utilization": 3.72, "uptime_seconds": 14773, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.97, "iaq": 72, "relative_humidity": 33.0, "temperature": 7.85}, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6198, "long_name": "Howling Pony", "next_hop": 70, "num": "0x3bc7a16a", "position": {"altitude": 1524, "latitude": 32.97648, "location_source": "LOC_INTERNAL", "longitude": -106.587549, "time_offset_sec": 6497}, "public_key_hex": "b9bdb18da54ae1774c6b36693505dbaba2a6bf0fc2797760af8ae04b5c4dd28b", "role": "CLIENT", "short_name": "HQF9", "snr": 2.64, "status": null, "telemetry": {"air_util_tx": 0.662, "battery_level": 59, "channel_utilization": 25.08, "uptime_seconds": 36716, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 346, "long_name": "Whispering Pike", "next_hop": 0, "num": "0x3bc7da1f", "position": {"altitude": 1012, "latitude": 33.669764, "location_source": "LOC_INTERNAL", "longitude": -107.193423, "time_offset_sec": 644}, "public_key_hex": "b774569f18c1128d9170a19802df7398f800368cd21563a08a7dd7d2a7ffcb62", "role": "CLIENT", "short_name": "W30X", "snr": 9.01, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.014, "battery_level": 61, "channel_utilization": 17.95, "uptime_seconds": 31903, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 5776, "long_name": "Tall Trout W58JI", "next_hop": 0, "num": "0x3bcc9655", "position": {"altitude": 1378, "latitude": 33.301603, "location_source": "LOC_INTERNAL", "longitude": -107.228236, "time_offset_sec": 5849}, "public_key_hex": "21db64af631a9b61bc63c4bbcc667dfce9643deca179acddefad5535ace9d44f", "role": "CLIENT", "short_name": "TWQT", "snr": 12.0, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 375, "long_name": "Drifting Bass", "next_hop": 0, "num": "0x3bebc395", "position": {"altitude": 1582, "latitude": 32.90732, "location_source": "LOC_INTERNAL", "longitude": -107.105865, "time_offset_sec": 481}, "public_key_hex": "dcc7c36b891c6e69b2a6cdc18c0dd31a7f7d8b993bf1554852dfacc9d08cd00b", "role": "CLIENT", "short_name": "D2PC", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.156, "battery_level": 28, "channel_utilization": 5.74, "uptime_seconds": 8137, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.8, "iaq": 10, "relative_humidity": 28.06, "temperature": 20.18}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1_EINK", "last_heard_offset_sec": 6915, "long_name": "Canyon Hawk", "next_hop": 0, "num": "0x3c079255", "position": {"altitude": 1211, "latitude": 32.930621, "location_source": "LOC_INTERNAL", "longitude": -107.69148, "time_offset_sec": 7164}, "public_key_hex": "7ff6d6bb97562ec2fcb11e0763eff229533ed83f24fe6bdd0a95d98142414389", "role": "ROUTER", "short_name": "CCOI", "snr": 12.0, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.682, "battery_level": 47, "channel_utilization": 7.35, "uptime_seconds": 8505, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1950, "long_name": "Silver Mamba", "next_hop": 0, "num": "0x3c31e941", "position": {"altitude": 1391, "latitude": 32.296262, "location_source": "LOC_INTERNAL", "longitude": -106.63089, "time_offset_sec": 1993}, "public_key_hex": "3fcc9b2f7fa1d06dadaf19df797571ac87c4dbc91d4463870ac38a85ad175123", "role": "CLIENT", "short_name": "SQ3N", "snr": 4.04, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 170, "long_name": "Silver Iguana", "next_hop": 0, "num": "0x3c375f3f", "position": {"altitude": 1428, "latitude": 32.660123, "location_source": "LOC_INTERNAL", "longitude": -107.956909, "time_offset_sec": 287}, "public_key_hex": "ad131b58d82a47388d2efce95a1e337e3e0cc9ac24ff850a20743e16a710f106", "role": "CLIENT", "short_name": "SHWI", "snr": 6.2, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.926, "battery_level": 21, "channel_utilization": 27.41, "uptime_seconds": 90973, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 1, "long_name": "Whispering Trout", "next_hop": 209, "num": "0x3c377129", "position": {"altitude": 1456, "latitude": 33.339465, "location_source": "LOC_INTERNAL", "longitude": -107.001852, "time_offset_sec": 92}, "public_key_hex": "375c906f3a974467151ce6fd29d1b866c08c0b2b579ea6a1ad0be1cb6bf9dd8c", "role": "TRACKER", "short_name": "WD48", "snr": 11.52, "status": null, "telemetry": {"air_util_tx": 0.968, "battery_level": 74, "channel_utilization": 7.47, "uptime_seconds": 19770, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 11796, "long_name": "Rough Shark", "next_hop": 28, "num": "0x3c4cd990", "position": {"altitude": 1226, "latitude": 33.836608, "location_source": "LOC_INTERNAL", "longitude": -106.572064, "time_offset_sec": 11845}, "public_key_hex": "3ca5952b23bfeb677dd6dba2f1397557063a9336adb1165bc585b9b61ba8c959", "role": "SENSOR", "short_name": "RZCS", "snr": 9.55, "status": null, "telemetry": {"air_util_tx": 1.915, "battery_level": 41, "channel_utilization": 25.32, "uptime_seconds": 101110, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1371, "long_name": "Solar Bison", "next_hop": 0, "num": "0x3c4fae84", "position": {"altitude": 1111, "latitude": 33.308645, "location_source": "LOC_INTERNAL", "longitude": -107.472581, "time_offset_sec": 1433}, "public_key_hex": "14ccea4ed63755b250cae91bea6c45ca3cc9c141f7c17ba54c99b91514404905", "role": "CLIENT", "short_name": "SN9T", "snr": 3.13, "status": null, "telemetry": {"air_util_tx": 0.798, "battery_level": 31, "channel_utilization": 17.26, "uptime_seconds": 81835, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1077, "long_name": "Smooth Trout", "next_hop": 0, "num": "0x3c4fdb1f", "position": {"altitude": 1094, "latitude": 32.848538, "location_source": "LOC_INTERNAL", "longitude": -106.656357, "time_offset_sec": 1364}, "public_key_hex": "df1b32b814f0e9752bec4639ae3263e804a3a4d7f404b1ba87b9e606240ef9b3", "role": "CLIENT", "short_name": "SQZG", "snr": 9.36, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.195, "battery_level": 80, "channel_utilization": 7.61, "uptime_seconds": 83375, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5911, "long_name": "Burning Crow", "next_hop": 92, "num": "0x3c5fa309", "position": null, "public_key_hex": "3e755feb38ea017da726d83687be4914a8488945b15a9c4b79d86568ccdf000d", "role": "CLIENT", "short_name": "BG4S", "snr": 0.47, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.484, "battery_level": 17, "channel_utilization": 18.17, "uptime_seconds": 19160, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 866, "long_name": "Short Falcon", "next_hop": 245, "num": "0x3c651846", "position": {"altitude": 829, "latitude": 32.983123, "location_source": "LOC_INTERNAL", "longitude": -106.801436, "time_offset_sec": 870}, "public_key_hex": "425eeb05fc0cb94667dc62c6836e756ca3273863c0e1ad787712c52d6de5b88e", "role": "CLIENT", "short_name": "🦌", "snr": 0.57, "status": null, "telemetry": {"air_util_tx": 0.314, "battery_level": 100, "channel_utilization": 5.79, "uptime_seconds": 327781, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 8286, "long_name": "Mountain Badger", "next_hop": 0, "num": "0x3c65e5a6", "position": {"altitude": 955, "latitude": 34.01819, "location_source": "LOC_INTERNAL", "longitude": -107.430176, "time_offset_sec": 8585}, "public_key_hex": "", "role": "TRACKER", "short_name": "MVX0", "snr": 6.18, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.579, "battery_level": 99, "channel_utilization": 3.99, "uptime_seconds": 116931, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 14487, "long_name": "Fast Elk", "next_hop": 134, "num": "0x3c6e6395", "position": {"altitude": 839, "latitude": 33.936983, "location_source": "LOC_INTERNAL", "longitude": -107.250972, "time_offset_sec": 14692}, "public_key_hex": "", "role": "CLIENT", "short_name": "FISV", "snr": 4.14, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5969, "long_name": "Old Pony", "next_hop": 144, "num": "0x3c7231c1", "position": {"altitude": 1005, "latitude": 31.819763, "location_source": "LOC_INTERNAL", "longitude": -108.084557, "time_offset_sec": 6250}, "public_key_hex": "1bba26f12c6c13495badbf8a04bdc8f2a8a621b78085a2519b2049e083ad9845", "role": "CLIENT", "short_name": "🐢", "snr": 6.29, "status": null, "telemetry": {"air_util_tx": 0.405, "battery_level": 59, "channel_utilization": 2.61, "uptime_seconds": 111353, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 173, "long_name": "Dawn Badger", "next_hop": 0, "num": "0x3cbfadd4", "position": {"altitude": 1715, "latitude": 32.270214, "location_source": "LOC_INTERNAL", "longitude": -106.559593, "time_offset_sec": 473}, "public_key_hex": "90f9e5d7c9b17da724a6ecbef79462a8c22bf1f35b40b31250b88c42f6e76cbc", "role": "CLIENT", "short_name": "D7H3", "snr": -0.54, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 281, "long_name": "Black Colt", "next_hop": 0, "num": "0x3cc1b3fb", "position": {"altitude": 733, "latitude": 33.317529, "location_source": "LOC_INTERNAL", "longitude": -107.089844, "time_offset_sec": 452}, "public_key_hex": "de4d5ad7253e60e4e82903d95e147afb5942ba4936a1bc1b0da95e6531836605", "role": "CLIENT", "short_name": "BTV1", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.67, "battery_level": 92, "channel_utilization": 4.79, "uptime_seconds": 23883, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.63, "iaq": 0, "relative_humidity": 50.38, "temperature": 23.92}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 266, "long_name": "Quick Juniper", "next_hop": 218, "num": "0x3cd379c3", "position": {"altitude": 1207, "latitude": 34.200865, "location_source": "LOC_INTERNAL", "longitude": -107.61184, "time_offset_sec": 381}, "public_key_hex": "13aa5825abff95cc0f3d19869924aa6282b0cc970fad011c72a75d92bab2e231", "role": "CLIENT", "short_name": "QKEU", "snr": 4.4, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.802, "battery_level": 22, "channel_utilization": 5.66, "uptime_seconds": 102987, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 2237, "long_name": "Roving Falcon", "next_hop": 146, "num": "0x3cdd9161", "position": null, "public_key_hex": "fd4934bc0551d189f70c0552550cd634905ec46283ae71ed3863f810950563fc", "role": "CLIENT", "short_name": "🦊", "snr": 6.23, "status": null, "telemetry": {"air_util_tx": 0.029, "battery_level": 67, "channel_utilization": 23.42, "uptime_seconds": 13335, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 178, "long_name": "Sky Fox", "next_hop": 196, "num": "0x3cf0cdd1", "position": {"altitude": 1806, "latitude": 33.5045, "location_source": "LOC_INTERNAL", "longitude": -106.609549, "time_offset_sec": 368}, "public_key_hex": "34660b5fd4bce08a029d6ac68c11a12cfc8d5d66d441396d5cbcbf1e87b4b3a3", "role": "CLIENT", "short_name": "S0LT", "snr": 10.26, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4161, "long_name": "Drifting Tortoise", "next_hop": 123, "num": "0x3cfee10c", "position": {"altitude": 1177, "latitude": 33.530214, "location_source": "LOC_INTERNAL", "longitude": -107.528434, "time_offset_sec": 4267}, "public_key_hex": "4c217edb9d5d831cf4ed69435e700071f791faffe83c30e0e28eb9e209564db5", "role": "CLIENT", "short_name": "DS7I", "snr": 1.01, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.769, "battery_level": 44, "channel_utilization": 17.26, "uptime_seconds": 54269, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2994, "long_name": "Tall Falcon", "next_hop": 0, "num": "0x3d0a7bd8", "position": null, "public_key_hex": "3f656899faa7f13a13a16c8d8615621fb4acbe0f1fe8f065c572a6ed9215f478", "role": "CLIENT", "short_name": "TI6F", "snr": 10.69, "status": null, "telemetry": {"air_util_tx": 0.551, "battery_level": 18, "channel_utilization": 24.15, "uptime_seconds": 9480, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.8, "iaq": 96, "relative_humidity": 55.69, "temperature": 21.58}, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 8041, "long_name": "Red Viper", "next_hop": 138, "num": "0x3d263e92", "position": null, "public_key_hex": "a31baca75e9ae9b724e8fc6f3c11fe0e813df9d95074d572eeb65645c6c28d81", "role": "CLIENT", "short_name": "RO05", "snr": 6.27, "status": null, "telemetry": {"air_util_tx": 0.308, "battery_level": 29, "channel_utilization": 13.78, "uptime_seconds": 199324, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.06, "iaq": 88, "relative_humidity": 27.39, "temperature": 20.08}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 3287, "long_name": "Floating Dolphin", "next_hop": 226, "num": "0x3d29e040", "position": {"altitude": 1909, "latitude": 32.990871, "location_source": "LOC_INTERNAL", "longitude": -107.696403, "time_offset_sec": 3437}, "public_key_hex": "e17ae25602bf64e4e19947776ab8c605fa7cec75b04fce4dae249a1a1727a548", "role": "CLIENT", "short_name": "🗻", "snr": 3.9, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.15, "battery_level": 40, "channel_utilization": 31.82, "uptime_seconds": 57718, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.67, "iaq": 89, "relative_humidity": 32.42, "temperature": 20.48}, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 2005, "long_name": "Soft Heron", "next_hop": 0, "num": "0x3d343098", "position": {"altitude": 1373, "latitude": 32.204711, "location_source": "LOC_INTERNAL", "longitude": -107.683467, "time_offset_sec": 2075}, "public_key_hex": "07715c36a942af649b61bbffb7d35171c7afbbb9e0dbe9558a063c978b9b3898", "role": "CLIENT", "short_name": "S44X", "snr": 6.95, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.963, "battery_level": 51, "channel_utilization": 8.82, "uptime_seconds": 11147, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3960, "long_name": "Drowsy Hare", "next_hop": 0, "num": "0x3d34bc86", "position": {"altitude": 1119, "latitude": 32.988401, "location_source": "LOC_INTERNAL", "longitude": -106.787194, "time_offset_sec": 4129}, "public_key_hex": "e9b80546a3e8385b6b2b29f9df25c3536992a15f6bdc4115134c46080601bfac", "role": "CLIENT", "short_name": "DHK7", "snr": 10.54, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.601, "battery_level": 57, "channel_utilization": 16.52, "uptime_seconds": 66856, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2970, "long_name": "Tiny Viper KQ4MA", "next_hop": 0, "num": "0x3d426552", "position": {"altitude": 1303, "latitude": 32.838803, "location_source": "LOC_INTERNAL", "longitude": -107.39949, "time_offset_sec": 3229}, "public_key_hex": "a01004b421dde2c9ce788c81d7c12c2d0e054f234dff76dbebbc90f487ff32c6", "role": "CLIENT", "short_name": "T5CM", "snr": 10.04, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2022, "long_name": "Soft Tortoise", "next_hop": 0, "num": "0x3d479348", "position": {"altitude": 1206, "latitude": 33.49457, "location_source": "LOC_INTERNAL", "longitude": -107.600848, "time_offset_sec": 2151}, "public_key_hex": "3701149551a161e6e50eeb644a1e99a42206bd8db94df0a3aa312953023a8041", "role": "CLIENT", "short_name": "🦅", "snr": -3.22, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.673, "battery_level": 51, "channel_utilization": 14.35, "uptime_seconds": 29073, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 4, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 417, "long_name": "Iron Arroyo", "next_hop": 3, "num": "0x3d4796b5", "position": {"altitude": 1290, "latitude": 32.784114, "location_source": "LOC_INTERNAL", "longitude": -106.671344, "time_offset_sec": 669}, "public_key_hex": "3185165a3a60033cc82e8e0ffc507dcaec32ea7c10c330131f86eb33ce4263e0", "role": "CLIENT", "short_name": "IGJA", "snr": 4.98, "status": null, "telemetry": {"air_util_tx": 1.888, "battery_level": 98, "channel_utilization": 6.18, "uptime_seconds": 94360, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4, "long_name": "Frosty Lion", "next_hop": 0, "num": "0x3d520945", "position": {"altitude": 1462, "latitude": 32.5714, "location_source": "LOC_INTERNAL", "longitude": -107.566359, "time_offset_sec": 113}, "public_key_hex": "9aeb883ec36a3e511e7af2045f3430f3479f7ec38bef6a3e608b0bed9cd40703", "role": "CLIENT", "short_name": "FDXI", "snr": 8.49, "status": {"status": "online"}, "telemetry": {"air_util_tx": 2.025, "battery_level": 73, "channel_utilization": 5.3, "uptime_seconds": 27116, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1021.69, "iaq": 76, "relative_humidity": 70.54, "temperature": 34.32}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 1000, "long_name": "Sunny Falcon", "next_hop": 0, "num": "0x3d52f85d", "position": {"altitude": 1119, "latitude": 33.067031, "location_source": "LOC_INTERNAL", "longitude": -107.364616, "time_offset_sec": 1075}, "public_key_hex": "c036af08bc4ccf3617346e28f29a1bb061652d6de1d27a6276c11d2e8c20d4be", "role": "CLIENT", "short_name": "S9HX", "snr": 5.38, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 2.146, "battery_level": 34, "channel_utilization": 20.24, "uptime_seconds": 29451, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2612, "long_name": "Sneaky Mesa", "next_hop": 91, "num": "0x3d5c87d3", "position": null, "public_key_hex": "67296495eda9eedd6b9d048c6eda7f10b9aef52d6bd8497f00d9b3c143dbfa4b", "role": "CLIENT", "short_name": "SG3Q", "snr": 8.22, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.941, "battery_level": 80, "channel_utilization": 5.22, "uptime_seconds": 329616, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.17, "iaq": 36, "relative_humidity": 48.97, "temperature": 22.32}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2337, "long_name": "Mountain Phoenix", "next_hop": 0, "num": "0x3d5e5650", "position": null, "public_key_hex": "1311cf2c785b454180a7a20b6f289d58d53fd6202509190ae41df2cbd51e4308", "role": "ROUTER", "short_name": "M89U", "snr": -2.79, "status": {"status": "rebooted"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 5214, "long_name": "Dawn Mamba", "next_hop": 48, "num": "0x3d62daed", "position": {"altitude": 1571, "latitude": 33.100821, "location_source": "LOC_INTERNAL", "longitude": -106.520191, "time_offset_sec": 5490}, "public_key_hex": "55be5162cb5abb5405f46457223fc3ed874d72d561f47b100e9fce3095efda58", "role": "CLIENT", "short_name": "DFUS", "snr": 8.96, "status": null, "telemetry": {"air_util_tx": 0.16, "battery_level": 89, "channel_utilization": 8.63, "uptime_seconds": 109524, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2915, "long_name": "Frosty Mole", "next_hop": 0, "num": "0x3d8803ac", "position": {"altitude": 1797, "latitude": 33.079736, "location_source": "LOC_INTERNAL", "longitude": -106.715662, "time_offset_sec": 3047}, "public_key_hex": "d4482b419ddec59b5993da5830b5b7a04f79be8e9d225ac4917ef41dc56382ff", "role": "CLIENT", "short_name": "FS4U", "snr": 6.91, "status": null, "telemetry": {"air_util_tx": 0.425, "battery_level": 74, "channel_utilization": 18.04, "uptime_seconds": 64923, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.38, "iaq": 14, "relative_humidity": 28.85, "temperature": 23.56}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2784, "long_name": "Black Salmon", "next_hop": 91, "num": "0x3d8b4697", "position": {"altitude": 1246, "latitude": 33.004257, "location_source": "LOC_INTERNAL", "longitude": -106.704786, "time_offset_sec": 3056}, "public_key_hex": "7bb6bf43750f130d5d175c9006dc09d5bdd475faa11764563cf69a1a69291c90", "role": "CLIENT", "short_name": "BIE7", "snr": 10.47, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.748, "battery_level": 18, "channel_utilization": 13.97, "uptime_seconds": 316111, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.04, "iaq": 85, "relative_humidity": 50.66, "temperature": 11.54}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 5359, "long_name": "Solar Mustang", "next_hop": 0, "num": "0x3d992d44", "position": {"altitude": 1541, "latitude": 33.816717, "location_source": "LOC_INTERNAL", "longitude": -106.660083, "time_offset_sec": 5521}, "public_key_hex": "a2b3eadd1f64d8fbcc57979e05ebe9887fc328ff5d9c660e33145ba9dcc984fe", "role": "CLIENT_MUTE", "short_name": "S696", "snr": -1.0, "status": null, "telemetry": {"air_util_tx": 0.2, "battery_level": 25, "channel_utilization": 9.69, "uptime_seconds": 68269, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2397, "long_name": "Soft Moose", "next_hop": 98, "num": "0x3d9efaf8", "position": {"altitude": 1475, "latitude": 33.451169, "location_source": "LOC_INTERNAL", "longitude": -108.617033, "time_offset_sec": 2631}, "public_key_hex": "3a07518759fd4a8ffd7a32f3da274076ffece96faa25aaea6a705008201b08e3", "role": "CLIENT", "short_name": "S8T2", "snr": 2.35, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 2957, "long_name": "Shady Tortoise", "next_hop": 0, "num": "0x3da10e7f", "position": {"altitude": 1134, "latitude": 32.442809, "location_source": "LOC_INTERNAL", "longitude": -107.698494, "time_offset_sec": 2977}, "public_key_hex": "a95dd2510332b36ef6f6579277b61b624ea85a07abc0e9dbd84d89c8ff7a5bb6", "role": "CLIENT", "short_name": "SX9K", "snr": -0.34, "status": null, "telemetry": {"air_util_tx": 1.155, "battery_level": 21, "channel_utilization": 8.91, "uptime_seconds": 92828, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2389, "long_name": "Steel Iguana", "next_hop": 128, "num": "0x3dc0fe1d", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "S4RH", "snr": 7.95, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2000, "long_name": "Solar Tortoise", "next_hop": 0, "num": "0x3dd926b4", "position": null, "public_key_hex": "e6463ec8819abd426d03da230e6bba3f0546fa179bfc6ab0b01d9ce39f580463", "role": "CLIENT", "short_name": "SXEW", "snr": 7.51, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.094, "battery_level": 66, "channel_utilization": 16.31, "uptime_seconds": 26950, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2359, "long_name": "Smooth Turtle", "next_hop": 119, "num": "0x3de3c7e7", "position": {"altitude": 1637, "latitude": 33.298056, "location_source": "LOC_INTERNAL", "longitude": -108.248065, "time_offset_sec": 2646}, "public_key_hex": "9f16b4390eec2fa24632ccedc812dc3b2a20eb2f9b4fa0d391268ab78d50f134", "role": "ROUTER", "short_name": "SF29", "snr": 6.35, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2341, "long_name": "Black Coyote", "next_hop": 0, "num": "0x3dec8153", "position": {"altitude": 1248, "latitude": 33.029003, "location_source": "LOC_INTERNAL", "longitude": -108.055109, "time_offset_sec": 2362}, "public_key_hex": "317800a33990db00786407aa2811019e7cb7960bea2b4d42bd31483bca631beb", "role": "CLIENT", "short_name": "BSOQ", "snr": -6.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.541, "battery_level": 57, "channel_utilization": 10.39, "uptime_seconds": 103810, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": {"barometric_pressure": 1007.29, "iaq": 24, "relative_humidity": 39.99, "temperature": 11.73}, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 185, "long_name": "Whispering Mamba", "next_hop": 225, "num": "0x3e313d8c", "position": null, "public_key_hex": "ae04259940d84a10ee5aa9beca42620e11528e0a1e0f7f9217c69a3c8578a3e3", "role": "CLIENT", "short_name": "WGNX", "snr": 3.68, "status": null, "telemetry": {"air_util_tx": 1.233, "battery_level": 23, "channel_utilization": 28.96, "uptime_seconds": 94796, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 267, "long_name": "Hidden Adder", "next_hop": 227, "num": "0x3e3bc0c5", "position": {"altitude": 1103, "latitude": 33.718403, "location_source": "LOC_INTERNAL", "longitude": -107.132319, "time_offset_sec": 453}, "public_key_hex": "945338cc10a594fc189be8ed05f9dfceeb043e1c2c8576c7feb6889b9f64e5c8", "role": "CLIENT", "short_name": "HA3T", "snr": 7.36, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.08, "iaq": 88, "relative_humidity": 67.52, "temperature": 27.32}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 3176, "long_name": "Sneaky Hawk", "next_hop": 0, "num": "0x3e45db29", "position": {"altitude": 1288, "latitude": 32.803584, "location_source": "LOC_INTERNAL", "longitude": -106.946098, "time_offset_sec": 3435}, "public_key_hex": "ca9d8fe9703dd9911bfa471f1471286c8325528ab9d00a906bab04f19accab83", "role": "CLIENT", "short_name": "S1O5", "snr": 2.9, "status": null, "telemetry": {"air_util_tx": 2.059, "battery_level": 60, "channel_utilization": 1.84, "uptime_seconds": 52078, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.28, "iaq": 58, "relative_humidity": 81.23, "temperature": 19.6}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 2721, "long_name": "Whispering Mole", "next_hop": 0, "num": "0x3e5e3431", "position": {"altitude": 1655, "latitude": 33.184602, "location_source": "LOC_INTERNAL", "longitude": -106.405635, "time_offset_sec": 2805}, "public_key_hex": "6756be69dd6e66f054a6cad2aebae1143fe70f67fd37ad9a90a2754091f4e020", "role": "CLIENT", "short_name": "🌲", "snr": 0.54, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 3699, "long_name": "White Shark", "next_hop": 255, "num": "0x3e758b52", "position": {"altitude": 1294, "latitude": 32.590151, "location_source": "LOC_INTERNAL", "longitude": -107.114938, "time_offset_sec": 3876}, "public_key_hex": "311f187311f5f894ff4c91e0e1f54752924417cffbbce8b3264decf2fdbbc699", "role": "CLIENT", "short_name": "WL2H", "snr": 11.01, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.148, "battery_level": 30, "channel_utilization": 3.95, "uptime_seconds": 63716, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4324, "long_name": "Storm Whale", "next_hop": 96, "num": "0x3e8902a6", "position": {"altitude": 1309, "latitude": 32.923685, "location_source": "LOC_INTERNAL", "longitude": -107.517288, "time_offset_sec": 4488}, "public_key_hex": "0b20900cce8a3e3edcfd43a79ed3099bae3fd4d8d616a3ba6afffbd49f6167dd", "role": "CLIENT", "short_name": "S4ZU", "snr": 10.76, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 1142, "long_name": "Blue Cougar", "next_hop": 0, "num": "0x3ebcd60e", "position": {"altitude": 1218, "latitude": 32.913477, "location_source": "LOC_INTERNAL", "longitude": -108.012179, "time_offset_sec": 1354}, "public_key_hex": "c60f0531aac3a4d37e3fda8d9c40ce9ae6dc4ff5513dd019a47eb08be7f1528c", "role": "CLIENT", "short_name": "BDMN", "snr": 10.71, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.82, "iaq": 75, "relative_humidity": 31.56, "temperature": 24.25}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 786, "long_name": "Slow Mesa", "next_hop": 30, "num": "0x3ec9cee6", "position": {"altitude": 1447, "latitude": 33.66264, "location_source": "LOC_INTERNAL", "longitude": -107.655062, "time_offset_sec": 1057}, "public_key_hex": "2ea9227faeaff5dea570ced9f90c24a0c24b4438af9c4d511ce4ba593821f641", "role": "CLIENT", "short_name": "SGU7", "snr": 2.88, "status": null, "telemetry": {"air_util_tx": 0.163, "battery_level": 65, "channel_utilization": 27.22, "uptime_seconds": 856, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 636, "long_name": "Misty Dolphin", "next_hop": 0, "num": "0x3ed1307b", "position": null, "public_key_hex": "d13d9fbd98702140fad968b9eb5f302d8041183011aec21be4d347a06a6be127", "role": "CLIENT", "short_name": "MBOE", "snr": 5.44, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.468, "battery_level": 55, "channel_utilization": 9.69, "uptime_seconds": 402662, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 788, "long_name": "Burning Crow WD1BJ", "next_hop": 215, "num": "0x3ed2f49f", "position": {"altitude": 1383, "latitude": 32.955514, "location_source": "LOC_INTERNAL", "longitude": -107.122236, "time_offset_sec": 852}, "public_key_hex": "443d121a52b2217c2a683e244a979a6926d0fcd8c9e46312d01976846e8d3f4c", "role": "CLIENT", "short_name": "BQIK", "snr": 8.01, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.369, "battery_level": 71, "channel_utilization": 5.13, "uptime_seconds": 141872, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.71, "iaq": 76, "relative_humidity": 33.1, "temperature": 23.02}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1297, "long_name": "Tiny Sage", "next_hop": 0, "num": "0x3ee1d970", "position": {"altitude": 1369, "latitude": 32.005802, "location_source": "LOC_INTERNAL", "longitude": -106.952398, "time_offset_sec": 1382}, "public_key_hex": "93284c42ffc7f96d483768584bee5270364d50e814618f08606b02960a549fed", "role": "CLIENT", "short_name": "T8AQ", "snr": 7.81, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5190, "long_name": "Gold Bear", "next_hop": 0, "num": "0x3ee6631e", "position": {"altitude": 954, "latitude": 32.866934, "location_source": "LOC_INTERNAL", "longitude": -106.983791, "time_offset_sec": 5327}, "public_key_hex": "084fb04f16c3d54db84d3c6ca8866434e6dd53905f97b7fbb96cfa4f2bad1a6e", "role": "CLIENT_HIDDEN", "short_name": "GDBS", "snr": 9.4, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.164, "battery_level": 40, "channel_utilization": 4.42, "uptime_seconds": 110218, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2049, "long_name": "Solar Hare", "next_hop": 0, "num": "0x3ef57374", "position": {"altitude": 1525, "latitude": 33.944481, "location_source": "LOC_INTERNAL", "longitude": -106.868133, "time_offset_sec": 2221}, "public_key_hex": "25b4de01cf8c1a177755dcc1d9441738f4594ef8e2c818f4204a5ddae76b38f9", "role": "CLIENT", "short_name": "SO6F", "snr": 7.6, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1694, "long_name": "River Aspen", "next_hop": 113, "num": "0x3efb65f2", "position": {"altitude": 1341, "latitude": 32.439102, "location_source": "LOC_INTERNAL", "longitude": -107.700805, "time_offset_sec": 1832}, "public_key_hex": "b3348269697d995e09b89a04eb47c16ed5de064554fd8a830d94ec7f4738f866", "role": "CLIENT", "short_name": "R8RX", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.328, "battery_level": 15, "channel_utilization": 19.13, "uptime_seconds": 1145, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7862, "long_name": "Quick Coyote", "next_hop": 0, "num": "0x3f650435", "position": {"altitude": 1517, "latitude": 33.488696, "location_source": "LOC_INTERNAL", "longitude": -107.422278, "time_offset_sec": 8112}, "public_key_hex": "b3f9247364ab5a18abcd708e4b2b947aebacf314e657fa510ad76f1bf36a4e06", "role": "CLIENT", "short_name": "Q4YT", "snr": 8.98, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.447, "battery_level": 64, "channel_utilization": 13.88, "uptime_seconds": 204622, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "MUZI_R1_NEO", "last_heard_offset_sec": 1434, "long_name": "Copper Whale", "next_hop": 0, "num": "0x3f7ed709", "position": {"altitude": 1668, "latitude": 33.802847, "location_source": "LOC_INTERNAL", "longitude": -107.7954, "time_offset_sec": 1554}, "public_key_hex": "ff6c5d190f88f3e37b490f6c3b189a252389456d9e41e3a0baeeedcb9a69c742", "role": "CLIENT", "short_name": "CX27", "snr": 11.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.16, "iaq": 47, "relative_humidity": 61.37, "temperature": 26.83}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2524, "long_name": "Frozen Gecko", "next_hop": 0, "num": "0x3f92eaf4", "position": {"altitude": 1654, "latitude": 32.270991, "location_source": "LOC_INTERNAL", "longitude": -107.276581, "time_offset_sec": 2824}, "public_key_hex": "79b7af70455290fa7d9e01b16369a4e8d8a985d1943d8d9276a4c858bfa6e301", "role": "CLIENT", "short_name": "FGY5", "snr": 9.75, "status": null, "telemetry": {"air_util_tx": 0.975, "battery_level": 45, "channel_utilization": 21.41, "uptime_seconds": 104444, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.39, "iaq": 52, "relative_humidity": 29.47, "temperature": 9.6}, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2006, "long_name": "Sharp Iguana", "next_hop": 39, "num": "0x3f9fb695", "position": {"altitude": 1661, "latitude": 32.85606, "location_source": "LOC_INTERNAL", "longitude": -106.816996, "time_offset_sec": 2195}, "public_key_hex": "23ec1ecd58c8b8c418d8518ab69eb674f8bfdde7cc1ede6cfbb10e852f6dd343", "role": "CLIENT", "short_name": "🔥", "snr": 9.93, "status": null, "telemetry": {"air_util_tx": 1.832, "battery_level": 39, "channel_utilization": 5.71, "uptime_seconds": 64059, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 15196, "long_name": "Red Adder", "next_hop": 51, "num": "0x3fa67a05", "position": {"altitude": 1481, "latitude": 33.086636, "location_source": "LOC_INTERNAL", "longitude": -107.364948, "time_offset_sec": 15319}, "public_key_hex": "", "role": "CLIENT", "short_name": "RZXG", "snr": 2.27, "status": null, "telemetry": {"air_util_tx": 0.424, "battery_level": 69, "channel_utilization": 2.68, "uptime_seconds": 64825, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1016.53, "iaq": 101, "relative_humidity": 59.28, "temperature": 29.37}, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 5630, "long_name": "Found Tortoise W59TB", "next_hop": 90, "num": "0x3fa8d3fc", "position": {"altitude": 1246, "latitude": 33.434862, "location_source": "LOC_INTERNAL", "longitude": -106.608271, "time_offset_sec": 5788}, "public_key_hex": "1f0453cfe3a0bb12258e378946d0784c524bfe1a06d09dcc05c3e492686cff10", "role": "CLIENT", "short_name": "FQIF", "snr": 0.73, "status": null, "telemetry": {"air_util_tx": 1.169, "battery_level": 57, "channel_utilization": 7.77, "uptime_seconds": 37421, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 4100, "long_name": "Old Moose", "next_hop": 149, "num": "0x3fb4cdef", "position": {"altitude": 1298, "latitude": 33.396485, "location_source": "LOC_INTERNAL", "longitude": -107.267315, "time_offset_sec": 4180}, "public_key_hex": "3d44c38c01f413558b09924704d3a9eabc5721d28ffcb5f500f43c14ff4ca68b", "role": "CLIENT", "short_name": "OTAN", "snr": 1.3, "status": null, "telemetry": {"air_util_tx": 0.669, "battery_level": 28, "channel_utilization": 8.94, "uptime_seconds": 9396, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.6, "iaq": 25, "relative_humidity": 88.51, "temperature": 19.9}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 6784, "long_name": "Silent Moose", "next_hop": 92, "num": "0x3fba5945", "position": {"altitude": 1332, "latitude": 33.471446, "location_source": "LOC_INTERNAL", "longitude": -107.021772, "time_offset_sec": 6824}, "public_key_hex": "9c305fe71d7931c7598ba287fda5ee50ec2b8244e0801462449ed803b5e507b5", "role": "CLIENT", "short_name": "ST48", "snr": 10.13, "status": null, "telemetry": {"air_util_tx": 0.812, "battery_level": 58, "channel_utilization": 8.01, "uptime_seconds": 54234, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1791, "long_name": "Storm Sage", "next_hop": 200, "num": "0x3fbc09d4", "position": {"altitude": 1136, "latitude": 32.448889, "location_source": "LOC_INTERNAL", "longitude": -107.533807, "time_offset_sec": 2068}, "public_key_hex": "a4d9df4899d5d23a3a5a93286fda322d042e2e6612d75c4002c277486302ae97", "role": "CLIENT", "short_name": "SPOG", "snr": 7.18, "status": null, "telemetry": {"air_util_tx": 0.496, "battery_level": 58, "channel_utilization": 3.55, "uptime_seconds": 71615, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 926, "long_name": "Canyon Crane", "next_hop": 0, "num": "0x3fc63a0c", "position": {"altitude": 1777, "latitude": 33.51381, "location_source": "LOC_INTERNAL", "longitude": -108.088225, "time_offset_sec": 1122}, "public_key_hex": "e9655ca12b9973eeb6965f6c11bec8602f7281f81548ff165b1234306b7fb996", "role": "CLIENT", "short_name": "🌵", "snr": 3.51, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 758, "long_name": "Frosty Viper", "next_hop": 0, "num": "0x3fc8e937", "position": {"altitude": 1515, "latitude": 32.316153, "location_source": "LOC_INTERNAL", "longitude": -107.176556, "time_offset_sec": 956}, "public_key_hex": "1942ba1ed00112247645c0d07ada3bfbf706f9f7885d359708b803f339f04451", "role": "CLIENT", "short_name": "F3TA", "snr": 5.68, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 463, "long_name": "Mountain Iguana", "next_hop": 201, "num": "0x3fd47a8c", "position": {"altitude": 1914, "latitude": 32.760109, "location_source": "LOC_INTERNAL", "longitude": -105.905753, "time_offset_sec": 628}, "public_key_hex": "f361381ac271e65777f4a37e77b5af15048603dc1f3867d9e1b690c7b78d5874", "role": "TRACKER", "short_name": "MLL7", "snr": 6.47, "status": null, "telemetry": {"air_util_tx": 0.972, "battery_level": 52, "channel_utilization": 32.18, "uptime_seconds": 128292, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2363, "long_name": "Dawn Stag", "next_hop": 187, "num": "0x3fd5184e", "position": {"altitude": 768, "latitude": 31.984157, "location_source": "LOC_INTERNAL", "longitude": -107.710268, "time_offset_sec": 2620}, "public_key_hex": "268b117715a5769eed04ec5cd99871e12acd68a3a3ba34f8b633115f165c1451", "role": "CLIENT", "short_name": "DMT7", "snr": 9.25, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.534, "battery_level": 86, "channel_utilization": 6.25, "uptime_seconds": 109274, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1025.05, "iaq": 71, "relative_humidity": 67.4, "temperature": 14.75}, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 4148, "long_name": "Silent Cedar", "next_hop": 0, "num": "0x3fd764b2", "position": {"altitude": 1372, "latitude": 32.973906, "location_source": "LOC_INTERNAL", "longitude": -108.186644, "time_offset_sec": 4390}, "public_key_hex": "aa68c9bd2617b66af12a4c7fd8b9f7b4de4dcd9342bac0c309dd31ccd208ceb4", "role": "CLIENT", "short_name": "SMAA", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.216, "battery_level": 37, "channel_utilization": 2.15, "uptime_seconds": 85366, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 6624, "long_name": "Copper Otter", "next_hop": 0, "num": "0x3fe4966a", "position": {"altitude": 1169, "latitude": 34.034464, "location_source": "LOC_INTERNAL", "longitude": -107.267996, "time_offset_sec": 6867}, "public_key_hex": "ddeb65f84bdb2df44be65c887df74234faafeaa34684fe63b814b86d096cf3a7", "role": "CLIENT", "short_name": "CMGH", "snr": 8.81, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 9337, "long_name": "Desert Iguana", "next_hop": 0, "num": "0x3ff55da7", "position": null, "public_key_hex": "ddcc8629d0c4a6845696beb48e79175ac0e8c0be6ac402e0a147494f051dc7ba", "role": "CLIENT", "short_name": "🗻", "snr": 5.16, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_HT62", "last_heard_offset_sec": 1188, "long_name": "Fast Doe", "next_hop": 0, "num": "0x4029dfa0", "position": {"altitude": 1902, "latitude": 32.842391, "location_source": "LOC_INTERNAL", "longitude": -106.45974, "time_offset_sec": 1280}, "public_key_hex": "4e5385c8439d2a7c0af57cb21cc833f8052200c795c4940687894ea2ab9778ef", "role": "CLIENT", "short_name": "FU57", "snr": 5.46, "status": null, "telemetry": {"air_util_tx": 0.953, "battery_level": 101, "channel_utilization": 26.03, "uptime_seconds": 223053, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2066, "long_name": "Sleepy Moose", "next_hop": 84, "num": "0x4036305e", "position": {"altitude": 1540, "latitude": 33.346883, "location_source": "LOC_INTERNAL", "longitude": -107.029982, "time_offset_sec": 2164}, "public_key_hex": "6c58f46f97e1ff7a5fe653eb1b4e73258d82fc195d78bc25fcbb9642d42b796f", "role": "CLIENT", "short_name": "S6HV", "snr": 4.69, "status": null, "telemetry": {"air_util_tx": 0.552, "battery_level": 84, "channel_utilization": 10.44, "uptime_seconds": 186209, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 491, "long_name": "Desert Bronco", "next_hop": 102, "num": "0x4084b475", "position": {"altitude": 853, "latitude": 32.754059, "location_source": "LOC_INTERNAL", "longitude": -107.578365, "time_offset_sec": 771}, "public_key_hex": "2a9e69d77c4fb3f4715f070f451de85acb82399a5511477cfe18092f448995a4", "role": "CLIENT", "short_name": "DOWP", "snr": -0.11, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 1.745, "battery_level": 81, "channel_utilization": 11.22, "uptime_seconds": 70644, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 996.1, "iaq": 12, "relative_humidity": 66.19, "temperature": 9.59}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1055, "long_name": "Silent Cedar", "next_hop": 56, "num": "0x4084e9b5", "position": {"altitude": 1430, "latitude": 32.694049, "location_source": "LOC_INTERNAL", "longitude": -107.260802, "time_offset_sec": 1170}, "public_key_hex": "5137607dda269dbc77ab6509883903f7e7206f3e1c3bc6618202a2e6d3150083", "role": "ROUTER", "short_name": "SCO0", "snr": 4.33, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.282, "battery_level": 95, "channel_utilization": 13.18, "uptime_seconds": 120431, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2723, "long_name": "Soft Badger", "next_hop": 0, "num": "0x4087d15b", "position": {"altitude": 2309, "latitude": 32.504793, "location_source": "LOC_INTERNAL", "longitude": -107.565664, "time_offset_sec": 2805}, "public_key_hex": "4f265779d88a5ed98cd1f3e86364c202ad1754ed63a442394f1f9f203bd6dc5e", "role": "CLIENT", "short_name": "SAM3", "snr": -4.69, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.606, "battery_level": 80, "channel_utilization": 15.52, "uptime_seconds": 5065, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.98, "iaq": 70, "relative_humidity": 50.43, "temperature": 13.27}, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 11583, "long_name": "Sharp Pony", "next_hop": 185, "num": "0x40888b60", "position": {"altitude": 1191, "latitude": 32.742848, "location_source": "LOC_INTERNAL", "longitude": -105.687539, "time_offset_sec": 11790}, "public_key_hex": "0a69dab8c510b5a3d059900c5e407fff355c993f4440beb2702d14287ecca8c7", "role": "CLIENT_MUTE", "short_name": "🦉", "snr": 11.23, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.72, "iaq": 22, "relative_humidity": 67.3, "temperature": 8.77}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1900, "long_name": "Dawn Cedar", "next_hop": 208, "num": "0x408a5404", "position": null, "public_key_hex": "802d931bbca81bcf1402cae42188d041128b4e637cfab478f1573e74307411e6", "role": "CLIENT", "short_name": "DW2W", "snr": -2.76, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.744, "battery_level": 26, "channel_utilization": 3.09, "uptime_seconds": 245375, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1904, "long_name": "Roving Yucca", "next_hop": 0, "num": "0x4095179f", "position": {"altitude": 1238, "latitude": 33.651269, "location_source": "LOC_INTERNAL", "longitude": -106.960256, "time_offset_sec": 2052}, "public_key_hex": "fa190dbd88b53392c9f2ee22b8075b6f7e1f5e24dddd016752fa1859d94e1a4d", "role": "TRACKER", "short_name": "RF5Y", "snr": 3.6, "status": null, "telemetry": {"air_util_tx": 0.041, "battery_level": 59, "channel_utilization": 27.12, "uptime_seconds": 15263, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 4815, "long_name": "Blue Heron", "next_hop": 83, "num": "0x40a06e17", "position": {"altitude": 1364, "latitude": 33.634635, "location_source": "LOC_INTERNAL", "longitude": -107.283082, "time_offset_sec": 5113}, "public_key_hex": "", "role": "CLIENT", "short_name": "B09G", "snr": 5.32, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.568, "battery_level": 94, "channel_utilization": 8.46, "uptime_seconds": 16462, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 894, "long_name": "Blue Owl", "next_hop": 213, "num": "0x40a79688", "position": null, "public_key_hex": "982e2cce7150c9e9edbf96963b64e9298575906aa4a4ac67faf9ae7900948815", "role": "CLIENT", "short_name": "BRMC", "snr": 8.99, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 229, "long_name": "Roving Seal", "next_hop": 236, "num": "0x40bdff96", "position": null, "public_key_hex": "d046c575ff995243636fd1a4cfb43780b5d5f17cb8bcdada51172e80c3520fde", "role": "CLIENT", "short_name": "RB0S", "snr": 7.5, "status": null, "telemetry": {"air_util_tx": 0.145, "battery_level": 26, "channel_utilization": 14.4, "uptime_seconds": 345129, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 156, "long_name": "Floating Viper", "next_hop": 0, "num": "0x40c0ece5", "position": {"altitude": 1349, "latitude": 32.297515, "location_source": "LOC_INTERNAL", "longitude": -106.513333, "time_offset_sec": 447}, "public_key_hex": "c65c0c592fc7a98d1a3a67a0d3e81282421596426f7faa6439af899686966775", "role": "CLIENT", "short_name": "FRKW", "snr": 7.2, "status": null, "telemetry": {"air_util_tx": 0.319, "battery_level": 101, "channel_utilization": 9.99, "uptime_seconds": 52988, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2649, "long_name": "Brave Coyote", "next_hop": 109, "num": "0x40cd3612", "position": {"altitude": 1227, "latitude": 33.55994, "location_source": "LOC_INTERNAL", "longitude": -106.712142, "time_offset_sec": 2836}, "public_key_hex": "4fe0159bf564f5b9f6d9af4662961dbb7ff4b4ca555560b8e283b30ddeb9c33d", "role": "CLIENT", "short_name": "BD8Z", "snr": 6.36, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 6996, "long_name": "Misty Ridge", "next_hop": 133, "num": "0x40d813ca", "position": null, "public_key_hex": "211696316ba9f929cb22cd60cf9c2428278369ef226ca85f2ec977e5cab963ae", "role": "CLIENT", "short_name": "MDZO", "snr": 0.98, "status": null, "telemetry": {"air_util_tx": 0.554, "battery_level": 80, "channel_utilization": 8.44, "uptime_seconds": 82037, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.01, "iaq": 55, "relative_humidity": 48.16, "temperature": 28.71}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 978, "long_name": "Forest Pony", "next_hop": 123, "num": "0x41015fc4", "position": {"altitude": 1463, "latitude": 33.683365, "location_source": "LOC_INTERNAL", "longitude": -106.82086, "time_offset_sec": 1168}, "public_key_hex": "d02d1702bdd15f735353177460b3ae2614b86a21cf8645dedd1941c01d7de61e", "role": "CLIENT", "short_name": "FW4B", "snr": 11.29, "status": null, "telemetry": {"air_util_tx": 0.373, "battery_level": 79, "channel_utilization": 12.74, "uptime_seconds": 338314, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 299, "long_name": "Iron Cactus", "next_hop": 0, "num": "0x4110595b", "position": {"altitude": 1318, "latitude": 34.375135, "location_source": "LOC_INTERNAL", "longitude": -107.44811, "time_offset_sec": 490}, "public_key_hex": "a706a42fddefebf3a738ebc3664aa905988bfba1a3949d2ce71f6cc829b47032", "role": "CLIENT", "short_name": "IYKQ", "snr": 8.37, "status": null, "telemetry": {"air_util_tx": 0.49, "battery_level": 46, "channel_utilization": 29.26, "uptime_seconds": 135658, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 5704, "long_name": "Rough Mesa", "next_hop": 52, "num": "0x411c0083", "position": {"altitude": 1253, "latitude": 32.871975, "location_source": "LOC_INTERNAL", "longitude": -107.137614, "time_offset_sec": 5785}, "public_key_hex": "e6774d394a31314f6c99947fc9dd205113b545c19668b1b89a1169b1a8496917", "role": "CLIENT", "short_name": "RRQX", "snr": 5.52, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.589, "battery_level": 24, "channel_utilization": 8.19, "uptime_seconds": 7097, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 4629, "long_name": "Tiny Heron", "next_hop": 0, "num": "0x411ed8d5", "position": {"altitude": 1501, "latitude": 32.789486, "location_source": "LOC_INTERNAL", "longitude": -107.044553, "time_offset_sec": 4839}, "public_key_hex": "0e7798af7b628aa099f1ff32a9f558d6452a01c488231c10401274a81289a1cb", "role": "CLIENT", "short_name": "🦊", "snr": 7.31, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4257, "long_name": "Dusk Beaver", "next_hop": 0, "num": "0x4122e875", "position": {"altitude": 1362, "latitude": 32.584592, "location_source": "LOC_INTERNAL", "longitude": -107.979002, "time_offset_sec": 4385}, "public_key_hex": "62682ba5c5f473184590e901d3702121ef69b2fb9e9d4b7cd374b6a8c91212b1", "role": "CLIENT", "short_name": "DJQ3", "snr": 4.26, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 1327, "long_name": "Sneaky Mamba", "next_hop": 114, "num": "0x41256a2c", "position": {"altitude": 1377, "latitude": 33.475872, "location_source": "LOC_INTERNAL", "longitude": -107.286531, "time_offset_sec": 1391}, "public_key_hex": "308c10a417a7fae62aa62029c4d2bbd299157807e7c78a467af0e2e140ff1d10", "role": "CLIENT", "short_name": "SJOE", "snr": 1.93, "status": null, "telemetry": {"air_util_tx": 0.957, "battery_level": 34, "channel_utilization": 8.42, "uptime_seconds": 134446, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.43, "iaq": 58, "relative_humidity": 98.48, "temperature": 29.31}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4494, "long_name": "Sharp Salmon", "next_hop": 0, "num": "0x412679c5", "position": null, "public_key_hex": "7eabf2d3a28f5fbb9aa6edb44464f174f5f6236af3535a5d50d6308d3ab9cc3c", "role": "ROUTER", "short_name": "SMBZ", "snr": 7.73, "status": null, "telemetry": {"air_util_tx": 1.053, "battery_level": 23, "channel_utilization": 7.3, "uptime_seconds": 88350, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2529, "long_name": "Smooth Coyote", "next_hop": 0, "num": "0x4128e74b", "position": {"altitude": 1877, "latitude": 32.460594, "location_source": "LOC_INTERNAL", "longitude": -106.656809, "time_offset_sec": 2572}, "public_key_hex": "fd47e41eb418c9f1a11370135ab673a83316ffe4f592bbb75f485e46642cdf7f", "role": "CLIENT", "short_name": "SKPO", "snr": 2.5, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.441, "battery_level": 37, "channel_utilization": 3.03, "uptime_seconds": 194948, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 746, "long_name": "Bright Gecko", "next_hop": 62, "num": "0x4148e743", "position": null, "public_key_hex": "99a1f08cb0dad87b1de1aefe971327cb33ae8d3e1be44cbdbb7757e757dca6da", "role": "CLIENT", "short_name": "BII9", "snr": 10.04, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.849, "battery_level": 40, "channel_utilization": 7.64, "uptime_seconds": 206523, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1015.59, "iaq": 73, "relative_humidity": 34.45, "temperature": 17.08}, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 1329, "long_name": "Lunar Heron", "next_hop": 224, "num": "0x415cd158", "position": {"altitude": 1395, "latitude": 32.88275, "location_source": "LOC_INTERNAL", "longitude": -108.15645, "time_offset_sec": 1357}, "public_key_hex": "9c87b21fd2d5fd86bcac34778b1fa5ff292f45c09f62988953213f7160c94425", "role": "CLIENT", "short_name": "L0BE", "snr": 4.3, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2674, "long_name": "Drifting Cobra", "next_hop": 4, "num": "0x4161001b", "position": {"altitude": 1772, "latitude": 32.668975, "location_source": "LOC_INTERNAL", "longitude": -106.678823, "time_offset_sec": 2889}, "public_key_hex": "c190c7220588b3fd7a558ae8842c809c10bdd0697ab4b1f6048266dfd369d7d9", "role": "CLIENT", "short_name": "DI87", "snr": 7.24, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.064, "battery_level": 30, "channel_utilization": 5.34, "uptime_seconds": 28123, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3468, "long_name": "Lost Mustang", "next_hop": 91, "num": "0x4169afe4", "position": {"altitude": 1654, "latitude": 33.027338, "location_source": "LOC_INTERNAL", "longitude": -107.233641, "time_offset_sec": 3720}, "public_key_hex": "9aa3148895d0792ceaf74607708a326aa18c49dc42600b43b38782c0a18a8c5e", "role": "CLIENT", "short_name": "LYJ3", "snr": 5.09, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1029.95, "iaq": 92, "relative_humidity": 77.86, "temperature": 23.04}, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 6626, "long_name": "Sleepy Pine", "next_hop": 0, "num": "0x41a57b2f", "position": {"altitude": 1422, "latitude": 32.741921, "location_source": "LOC_INTERNAL", "longitude": -106.359059, "time_offset_sec": 6743}, "public_key_hex": "3c6807c38cd7a01f47f7fe50cb48acae134db24456a1956251d0279cf5c0e7c8", "role": "CLIENT", "short_name": "SRB8", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.126, "battery_level": 77, "channel_utilization": 8.1, "uptime_seconds": 38982, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 616, "long_name": "Smooth Doe", "next_hop": 0, "num": "0x41a60f0a", "position": {"altitude": 1768, "latitude": 33.307089, "location_source": "LOC_INTERNAL", "longitude": -107.043523, "time_offset_sec": 871}, "public_key_hex": "bf07fa3ea4162ef15c9c817f77fd15fa9538ac90197403e71b12b2bcab6c8d68", "role": "LOST_AND_FOUND", "short_name": "SRBQ", "snr": 6.37, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1689, "long_name": "Quick Crow", "next_hop": 201, "num": "0x41c39e69", "position": {"altitude": 1445, "latitude": 33.196974, "location_source": "LOC_INTERNAL", "longitude": -106.115777, "time_offset_sec": 1837}, "public_key_hex": "7df91ded1a02d48dd2dcb26aa98d9dfa8ffe10e7cc5b0b22ef35a78c1e4b923c", "role": "CLIENT", "short_name": "QRSV", "snr": 0.77, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.409, "battery_level": 76, "channel_utilization": 16.97, "uptime_seconds": 42413, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 949, "long_name": "Whispering Pike", "next_hop": 217, "num": "0x41c5c837", "position": {"altitude": 1391, "latitude": 33.059288, "location_source": "LOC_INTERNAL", "longitude": -106.756719, "time_offset_sec": 1003}, "public_key_hex": "24dff3a6abf5765edc1b96c179c7ecf74ea0201a46b343c0cef171d16977bf4e", "role": "CLIENT", "short_name": "🦊", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.269, "battery_level": 101, "channel_utilization": 14.63, "uptime_seconds": 353101, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 9960, "long_name": "Sharp Falcon K11EX", "next_hop": 0, "num": "0x41d2710a", "position": {"altitude": 1516, "latitude": 32.794253, "location_source": "LOC_INTERNAL", "longitude": -106.281745, "time_offset_sec": 9970}, "public_key_hex": "ef4c4dfe1462f519d556be8adf8bec3860acf81f2306785e76bb19228fc4ae43", "role": "CLIENT", "short_name": "SVPJ", "snr": 11.23, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.086, "battery_level": 50, "channel_utilization": 4.33, "uptime_seconds": 2573, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 2571, "long_name": "Frosty Lion", "next_hop": 54, "num": "0x4203c5ac", "position": {"altitude": 1226, "latitude": 33.083732, "location_source": "LOC_INTERNAL", "longitude": -106.6555, "time_offset_sec": 2632}, "public_key_hex": "4d1cd9e42d4edfb1af0935ebb5259cab14b4841b98049572e7e8defddf706324", "role": "CLIENT", "short_name": "FJ85", "snr": 6.08, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 8998, "long_name": "Lost Mole", "next_hop": 0, "num": "0x420b1cf1", "position": {"altitude": 1695, "latitude": 32.405547, "location_source": "LOC_INTERNAL", "longitude": -107.090698, "time_offset_sec": 9251}, "public_key_hex": "be6e7170842867ab61281f7b93dafdd76a8ff51fb5f4d6bd14aff2be178782e5", "role": "TRACKER", "short_name": "LSE4", "snr": 4.34, "status": null, "telemetry": {"air_util_tx": 0.281, "battery_level": 46, "channel_utilization": 9.85, "uptime_seconds": 102083, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3425, "long_name": "Short Salmon", "next_hop": 19, "num": "0x421c8e15", "position": {"altitude": 1223, "latitude": 32.655039, "location_source": "LOC_INTERNAL", "longitude": -108.027016, "time_offset_sec": 3426}, "public_key_hex": "05e023e6fff8f5e37c0e58ea6ec28258945e2a21e4c3d6b9f8c7eb817becdcf9", "role": "CLIENT", "short_name": "S4FU", "snr": 4.21, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 0.041, "battery_level": 28, "channel_utilization": 1.6, "uptime_seconds": 12352, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2549, "long_name": "Green Hawk", "next_hop": 0, "num": "0x421ccb62", "position": {"altitude": 1590, "latitude": 32.540463, "location_source": "LOC_INTERNAL", "longitude": -105.65329, "time_offset_sec": 2803}, "public_key_hex": "23e52a089ffeded65909794e12568353d5b4a922ee7f23de0fbd99983e31f799", "role": "CLIENT", "short_name": "GX02", "snr": 4.67, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 11676, "long_name": "Tall Whale", "next_hop": 0, "num": "0x424e4135", "position": null, "public_key_hex": "755b4c97fdca03faf98c5a6471a32d7d5b36bccaf7cc8f3c4ce8c4cc244317a9", "role": "CLIENT", "short_name": "TUFQ", "snr": 2.32, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.09, "battery_level": 37, "channel_utilization": 16.05, "uptime_seconds": 65034, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 532, "long_name": "Floating Cactus", "next_hop": 185, "num": "0x42660396", "position": {"altitude": 1640, "latitude": 32.472824, "location_source": "LOC_INTERNAL", "longitude": -107.477274, "time_offset_sec": 687}, "public_key_hex": "6ba62a6f23bd9eb3c34a85f56c21e8e66da294ba292422fd634174c7255f8b0c", "role": "CLIENT", "short_name": "🌲", "snr": 0.15, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.053, "battery_level": 99, "channel_utilization": 36.33, "uptime_seconds": 515940, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 20, "long_name": "Soft Bass", "next_hop": 0, "num": "0x426972dd", "position": {"altitude": 1037, "latitude": 33.308526, "location_source": "LOC_INTERNAL", "longitude": -107.681795, "time_offset_sec": 229}, "public_key_hex": "42ae848020ff167eaec086f43ef19c05b52e9df46512d06a4f8d5debedc4e8a8", "role": "CLIENT_MUTE", "short_name": "SN0Y", "snr": 2.13, "status": null, "telemetry": {"air_util_tx": 0.275, "battery_level": 101, "channel_utilization": 16.37, "uptime_seconds": 12614, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 359, "long_name": "Whispering Juniper", "next_hop": 195, "num": "0x427ad85f", "position": {"altitude": 832, "latitude": 32.815095, "location_source": "LOC_INTERNAL", "longitude": -106.672484, "time_offset_sec": 589}, "public_key_hex": "31063ea4c6d2499a567b2cfb4b410166a7bb5cf1be9fa31dc36cf5d29e893acb", "role": "CLIENT_HIDDEN", "short_name": "WFEF", "snr": 7.82, "status": null, "telemetry": {"air_util_tx": 0.333, "battery_level": 59, "channel_utilization": 3.57, "uptime_seconds": 84578, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1275, "long_name": "Frozen Viper", "next_hop": 151, "num": "0x427da6f2", "position": {"altitude": 1236, "latitude": 32.861576, "location_source": "LOC_INTERNAL", "longitude": -107.202743, "time_offset_sec": 1406}, "public_key_hex": "960149bf50bdb247a906bc7e0eab4a768546fef4e37fc5dce81eb1bee820c2d6", "role": "CLIENT", "short_name": "FR0P", "snr": 1.2, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.425, "battery_level": 10, "channel_utilization": 18.9, "uptime_seconds": 258807, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 7446, "long_name": "Tall Heron", "next_hop": 0, "num": "0x4299a89e", "position": {"altitude": 1236, "latitude": 33.145896, "location_source": "LOC_INTERNAL", "longitude": -106.943852, "time_offset_sec": 7622}, "public_key_hex": "f4a953f83fbf9a2d51c5bdb6c217e25ff1ef4c98b8f8d844a3b1e95db240be93", "role": "CLIENT", "short_name": "TAT3", "snr": 9.13, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 5499, "long_name": "Storm Aspen", "next_hop": 0, "num": "0x42a94eec", "position": {"altitude": 1688, "latitude": 32.996474, "location_source": "LOC_INTERNAL", "longitude": -107.090668, "time_offset_sec": 5777}, "public_key_hex": "4a1d69cca965caad01cb7d27873a07250d9bb582a119fa78fa0496684210c88e", "role": "CLIENT", "short_name": "SJTP", "snr": -2.6, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 6060, "long_name": "Steel Cougar K14ME", "next_hop": 0, "num": "0x42b87e9c", "position": {"altitude": 1268, "latitude": 33.027309, "location_source": "LOC_INTERNAL", "longitude": -108.146521, "time_offset_sec": 6291}, "public_key_hex": "4dcf6cc27b55b22d788d3a8d170eca8b3f0c1258211822b0788fc886ecfadbe9", "role": "ROUTER", "short_name": "SQXF", "snr": 4.14, "status": null, "telemetry": {"air_util_tx": 1.61, "battery_level": 91, "channel_utilization": 9.07, "uptime_seconds": 112763, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 4505, "long_name": "Bright Seal", "next_hop": 0, "num": "0x42c7f82d", "position": {"altitude": 766, "latitude": 34.333315, "location_source": "LOC_INTERNAL", "longitude": -106.812786, "time_offset_sec": 4541}, "public_key_hex": "3edacdbc508ded6ee7f877edeec2540b2712b2ec27f0c2a2105abd7e0af0e174", "role": "CLIENT", "short_name": "B35Q", "snr": 10.46, "status": null, "telemetry": {"air_util_tx": 0.22, "battery_level": 60, "channel_utilization": 23.14, "uptime_seconds": 30211, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 368, "long_name": "Floating Juniper", "next_hop": 0, "num": "0x42c9e1f3", "position": {"altitude": 1248, "latitude": 32.562968, "location_source": "LOC_INTERNAL", "longitude": -107.241193, "time_offset_sec": 606}, "public_key_hex": "cda2641347b9ebb766399d1c2d05e4347eec1e45bc54b0acdb65cbf9143e4601", "role": "TRACKER", "short_name": "FV9N", "snr": 3.46, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.511, "battery_level": 91, "channel_utilization": 12.08, "uptime_seconds": 336860, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5918, "long_name": "Howling Bluff", "next_hop": 161, "num": "0x42cdb474", "position": {"altitude": 1795, "latitude": 34.113365, "location_source": "LOC_INTERNAL", "longitude": -106.902322, "time_offset_sec": 5966}, "public_key_hex": "4ae88f38b1c1dec3ae41b5303f5103748c68c5ebc50a56dd47b3a1cf6383afa4", "role": "LOST_AND_FOUND", "short_name": "H4F0", "snr": 3.72, "status": null, "telemetry": {"air_util_tx": 0.415, "battery_level": 22, "channel_utilization": 11.25, "uptime_seconds": 81154, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 2838, "long_name": "Soft Lynx", "next_hop": 128, "num": "0x4303a6b3", "position": null, "public_key_hex": "", "role": "ROUTER", "short_name": "SAJR", "snr": 10.65, "status": null, "telemetry": {"air_util_tx": 0.514, "battery_level": 42, "channel_utilization": 12.74, "uptime_seconds": 2408, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2093, "long_name": "Tall Bison", "next_hop": 0, "num": "0x430589d0", "position": {"altitude": 1543, "latitude": 33.576066, "location_source": "LOC_INTERNAL", "longitude": -107.267537, "time_offset_sec": 2289}, "public_key_hex": "e658b3a0b0552b41508a731fef55ed58db4016366692c7f2e3951847208882f1", "role": "CLIENT", "short_name": "T47I", "snr": 12.0, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2590, "long_name": "Green Bronco", "next_hop": 0, "num": "0x431f74a3", "position": {"altitude": 924, "latitude": 32.997504, "location_source": "LOC_INTERNAL", "longitude": -107.176156, "time_offset_sec": 2841}, "public_key_hex": "ab8c4c8897ac6ebdaff3d89087d154a5f933188c8a3e7b51a3bebd94e21bf10b", "role": "CLIENT", "short_name": "🦊", "snr": 10.27, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.214, "battery_level": 14, "channel_utilization": 27.8, "uptime_seconds": 217344, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1474, "long_name": "Green Owl", "next_hop": 254, "num": "0x4357597b", "position": {"altitude": 1362, "latitude": 33.419088, "location_source": "LOC_INTERNAL", "longitude": -106.607431, "time_offset_sec": 1581}, "public_key_hex": "44dc8d0e2d3eb208f7654d55489b1240693b1784fdf98034528b374ace3b6de0", "role": "ROUTER", "short_name": "🐢", "snr": 5.38, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2994, "long_name": "Blue Oak", "next_hop": 0, "num": "0x4358b7fc", "position": {"altitude": 1156, "latitude": 33.483911, "location_source": "LOC_INTERNAL", "longitude": -107.196845, "time_offset_sec": 3213}, "public_key_hex": "c6257eb88293f442508b6c6674d2dac7723752ad2eabb9f754ed2d81171348b3", "role": "CLIENT", "short_name": "🦊", "snr": 7.23, "status": null, "telemetry": {"air_util_tx": 1.352, "battery_level": 99, "channel_utilization": 23.55, "uptime_seconds": 53157, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 998.4, "iaq": 24, "relative_humidity": 40.16, "temperature": 17.08}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 9478, "long_name": "Sunny Colt", "next_hop": 110, "num": "0x435ec9d8", "position": null, "public_key_hex": "1e6786f8f5e10ca3fc315c465a39751ccb4f73bc492aed4f04822754e127b2a0", "role": "CLIENT", "short_name": "🌵", "snr": 9.85, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.6, "iaq": 40, "relative_humidity": 54.89, "temperature": 14.12}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3088, "long_name": "Silent Oak", "next_hop": 0, "num": "0x437846a5", "position": {"altitude": 1620, "latitude": 32.250343, "location_source": "LOC_INTERNAL", "longitude": -108.092205, "time_offset_sec": 3199}, "public_key_hex": "71d711baf6be9fd4331e3e95e555b0ebac32a2e857ce82d37c969f0c74c0b5c1", "role": "CLIENT", "short_name": "SIMD", "snr": 8.14, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.662, "battery_level": 41, "channel_utilization": 1.0, "uptime_seconds": 349427, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 430, "long_name": "Whispering Yucca", "next_hop": 0, "num": "0x43945b45", "position": {"altitude": 1671, "latitude": 33.563743, "location_source": "LOC_INTERNAL", "longitude": -107.200348, "time_offset_sec": 544}, "public_key_hex": "7d33c52e483626d2495fe7c1de0da732c3aa4aa435c9abebbb2962b559c2620b", "role": "CLIENT_MUTE", "short_name": "🐢", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.726, "battery_level": 89, "channel_utilization": 16.2, "uptime_seconds": 56529, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 6312, "long_name": "Silver Fox", "next_hop": 193, "num": "0x439c1f6e", "position": {"altitude": 1803, "latitude": 32.497494, "location_source": "LOC_INTERNAL", "longitude": -106.556837, "time_offset_sec": 6371}, "public_key_hex": "298682a5114a17385153d49241a4013269faf20d6206f59796042f2028e174b7", "role": "ROUTER", "short_name": "SB4I", "snr": 4.23, "status": null, "telemetry": {"air_util_tx": 1.766, "battery_level": 72, "channel_utilization": 15.22, "uptime_seconds": 113044, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 548, "long_name": "Gold Pike", "next_hop": 0, "num": "0x43d3fb6c", "position": null, "public_key_hex": "700610d0ae489be29a7af2606a1d55b359d721bcd8fd709a0a0bcf6546e00aa0", "role": "CLIENT", "short_name": "G1H0", "snr": 6.71, "status": null, "telemetry": {"air_util_tx": 0.455, "battery_level": 43, "channel_utilization": 8.05, "uptime_seconds": 30357, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 275, "long_name": "Drowsy Crane", "next_hop": 0, "num": "0x43dc3ad2", "position": {"altitude": 1270, "latitude": 34.02178, "location_source": "LOC_INTERNAL", "longitude": -107.23247, "time_offset_sec": 450}, "public_key_hex": "522052b9749a750f27c99acd2c41d460deb8edf3aa3951237b3991adaab1e49e", "role": "CLIENT", "short_name": "DXOE", "snr": 4.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7527, "long_name": "Hidden Raven", "next_hop": 30, "num": "0x43f1a413", "position": {"altitude": 1750, "latitude": 33.01639, "location_source": "LOC_INTERNAL", "longitude": -106.690921, "time_offset_sec": 7578}, "public_key_hex": "", "role": "ROUTER", "short_name": "HZOG", "snr": 4.39, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 1149, "long_name": "Solar Elk", "next_hop": 130, "num": "0x441844f2", "position": {"altitude": 1360, "latitude": 32.903101, "location_source": "LOC_INTERNAL", "longitude": -107.642771, "time_offset_sec": 1251}, "public_key_hex": "d66432bd498b635d4b03d3d0c5dd1747353f2802ace6033d9c9ed082a112c50e", "role": "CLIENT_MUTE", "short_name": "SAAO", "snr": 8.16, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.912, "battery_level": 74, "channel_utilization": 6.01, "uptime_seconds": 273165, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 425, "long_name": "Floating Viper", "next_hop": 0, "num": "0x442ccd09", "position": {"altitude": 1153, "latitude": 33.948302, "location_source": "LOC_INTERNAL", "longitude": -107.57042, "time_offset_sec": 557}, "public_key_hex": "6df0be67a7e43c36f41f27c7acf64f66ed42336ec4ec943134310dc04541bdb2", "role": "CLIENT_MUTE", "short_name": "FRI9", "snr": 6.42, "status": null, "telemetry": {"air_util_tx": 0.794, "battery_level": 87, "channel_utilization": 15.08, "uptime_seconds": 126347, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 1835, "long_name": "Black Crow", "next_hop": 0, "num": "0x44378385", "position": {"altitude": 1423, "latitude": 33.357837, "location_source": "LOC_INTERNAL", "longitude": -106.846201, "time_offset_sec": 2055}, "public_key_hex": "465861bb8e07dc7cbda368055f0923e0c3a59f5adbd533bc15841182d6ac8195", "role": "CLIENT", "short_name": "BXVC", "snr": 10.53, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 4742, "long_name": "Burning Mesa", "next_hop": 0, "num": "0x443b45ca", "position": null, "public_key_hex": "2a7f38f6373dfb8dafbb670f24f33a8120729e90a24130cf12a1b1288ff0f561", "role": "CLIENT", "short_name": "BIFT", "snr": 10.89, "status": null, "telemetry": {"air_util_tx": 1.177, "battery_level": 71, "channel_utilization": 14.19, "uptime_seconds": 13425, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 6246, "long_name": "New Pony", "next_hop": 48, "num": "0x4442a00d", "position": {"altitude": 1460, "latitude": 32.123041, "location_source": "LOC_INTERNAL", "longitude": -107.312938, "time_offset_sec": 6476}, "public_key_hex": "a8d7cf701c922736b5f6d1a13177e7b3ea83818115b5a91a52d9548390a0b7bb", "role": "CLIENT", "short_name": "NDDQ", "snr": 11.75, "status": null, "telemetry": {"air_util_tx": 0.977, "battery_level": 99, "channel_utilization": 9.39, "uptime_seconds": 192944, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.96, "iaq": 80, "relative_humidity": 38.48, "temperature": 5.42}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1877, "long_name": "Black Moose", "next_hop": 249, "num": "0x4457c061", "position": {"altitude": 1596, "latitude": 33.532278, "location_source": "LOC_INTERNAL", "longitude": -107.415469, "time_offset_sec": 2155}, "public_key_hex": "b2412154c2b15dfec272d1aadb6600022e1300a7720016ff74457d5b429b4247", "role": "CLIENT", "short_name": "BI69", "snr": 7.55, "status": null, "telemetry": {"air_util_tx": 0.65, "battery_level": 101, "channel_utilization": 4.69, "uptime_seconds": 10661, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 100, "long_name": "Frozen Ridge", "next_hop": 0, "num": "0x4474999b", "position": {"altitude": 1531, "latitude": 33.916871, "location_source": "LOC_INTERNAL", "longitude": -106.340822, "time_offset_sec": 306}, "public_key_hex": "8a5079936e3f5da0d4cb8413f9186c293e52c18211d293eab7f9faec5f300776", "role": "CLIENT", "short_name": "FVEO", "snr": 6.51, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1010.19, "iaq": 21, "relative_humidity": 25.6, "temperature": 28.9}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1021, "long_name": "Forest Owl", "next_hop": 0, "num": "0x4480510d", "position": {"altitude": 1500, "latitude": 32.927503, "location_source": "LOC_INTERNAL", "longitude": -106.834524, "time_offset_sec": 1296}, "public_key_hex": "9804b50077079007442fe49c27083c4b3478f6f03a8633e937818e2b027f58b9", "role": "CLIENT_HIDDEN", "short_name": "FBPP", "snr": 7.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1359, "long_name": "Blue Cactus", "next_hop": 0, "num": "0x44824748", "position": {"altitude": 1231, "latitude": 32.988926, "location_source": "LOC_INTERNAL", "longitude": -107.294915, "time_offset_sec": 1437}, "public_key_hex": "ac9df28f3cd57769000907c26785e1cf393ffdb17d8ac9b8db9e7a741d04177e", "role": "CLIENT", "short_name": "BPCE", "snr": 6.26, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 6771, "long_name": "Silent Turtle", "next_hop": 119, "num": "0x44861f87", "position": {"altitude": 1610, "latitude": 32.984644, "location_source": "LOC_INTERNAL", "longitude": -107.44871, "time_offset_sec": 7028}, "public_key_hex": "708af4f97ddd9ff36a4ae0b99e2d538c48fbffa8f7379f402fc801fedc447e9b", "role": "CLIENT", "short_name": "S6IV", "snr": 5.43, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.556, "battery_level": 58, "channel_utilization": 29.03, "uptime_seconds": 119628, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5667, "long_name": "New Mesa", "next_hop": 0, "num": "0x448837c5", "position": {"altitude": 1597, "latitude": 33.93615, "location_source": "LOC_INTERNAL", "longitude": -107.537567, "time_offset_sec": 5802}, "public_key_hex": "ef6977cec88d43866f155a861512a4761eb0c491a8ef47e318eb90a3cfa6d5a3", "role": "CLIENT", "short_name": "N05N", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.459, "battery_level": 32, "channel_utilization": 5.14, "uptime_seconds": 23771, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 6996, "long_name": "Found Bison", "next_hop": 2, "num": "0x44b6e720", "position": {"altitude": 1432, "latitude": 33.429025, "location_source": "LOC_INTERNAL", "longitude": -106.552641, "time_offset_sec": 7011}, "public_key_hex": "ef4e363156811e92c2e7506d2a588266208c8ae3b8bab294b835d173070818d4", "role": "CLIENT", "short_name": "FLHM", "snr": 7.21, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.639, "battery_level": 69, "channel_utilization": 14.88, "uptime_seconds": 12753, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 120, "long_name": "Storm Gecko", "next_hop": 143, "num": "0x44c86d17", "position": {"altitude": 1807, "latitude": 33.648324, "location_source": "LOC_INTERNAL", "longitude": -107.409517, "time_offset_sec": 144}, "public_key_hex": "6d2257e8f48b9cdc00dec7008b190165e83faf6a5691a3dababed7129fe6fae8", "role": "CLIENT", "short_name": "STJC", "snr": 10.01, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.165, "battery_level": 19, "channel_utilization": 20.15, "uptime_seconds": 64021, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1992, "long_name": "Silent Juniper", "next_hop": 0, "num": "0x44ca2162", "position": {"altitude": 1767, "latitude": 33.616277, "location_source": "LOC_INTERNAL", "longitude": -107.442744, "time_offset_sec": 2107}, "public_key_hex": "05bd0b9c03bb4c04f65832541dc082e7ab6dc41728e4ee77d4f1687b4afa1e64", "role": "ROUTER_LATE", "short_name": "S9HE", "snr": 4.53, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 7577, "long_name": "Tiny Falcon", "next_hop": 116, "num": "0x44ea03ff", "position": {"altitude": 1489, "latitude": 33.244928, "location_source": "LOC_INTERNAL", "longitude": -107.491322, "time_offset_sec": 7602}, "public_key_hex": "", "role": "CLIENT", "short_name": "TSZN", "snr": 2.71, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 993.98, "iaq": 76, "relative_humidity": 92.46, "temperature": 25.21}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 684, "long_name": "Green Pine", "next_hop": 0, "num": "0x44f80ebd", "position": {"altitude": 970, "latitude": 33.432559, "location_source": "LOC_INTERNAL", "longitude": -106.554994, "time_offset_sec": 821}, "public_key_hex": "6fe6ceea53f9895d2e92a2ebccbdc99e14bff8b5f65d3ae8a780e6f0a6f66351", "role": "CLIENT", "short_name": "GSBX", "snr": 6.43, "status": null, "telemetry": {"air_util_tx": 0.498, "battery_level": 52, "channel_utilization": 12.88, "uptime_seconds": 3699, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 4464, "long_name": "Slow Marmot", "next_hop": 0, "num": "0x451b70c2", "position": {"altitude": 1796, "latitude": 32.912157, "location_source": "LOC_INTERNAL", "longitude": -106.765639, "time_offset_sec": 4496}, "public_key_hex": "ea1d4b35f119a37f949cef967b7f9c30a1df58898e236dcb0512dfd0481c8d72", "role": "CLIENT", "short_name": "SNJ1", "snr": 6.91, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.209, "battery_level": 31, "channel_utilization": 1.23, "uptime_seconds": 125958, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.95, "iaq": 25, "relative_humidity": 50.73, "temperature": 20.5}, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1124, "long_name": "Tall Lynx", "next_hop": 136, "num": "0x45299452", "position": {"altitude": 1138, "latitude": 33.571783, "location_source": "LOC_INTERNAL", "longitude": -107.067398, "time_offset_sec": 1333}, "public_key_hex": "c836d88d05684a58a5da35f68233878816d82fe374ce2d351c217a1a58ec693e", "role": "CLIENT", "short_name": "TX0L", "snr": 0.13, "status": null, "telemetry": {"air_util_tx": 0.114, "battery_level": 95, "channel_utilization": 12.57, "uptime_seconds": 25128, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 663, "long_name": "Frosty Cedar", "next_hop": 0, "num": "0x45554ee3", "position": {"altitude": 1716, "latitude": 33.876998, "location_source": "LOC_INTERNAL", "longitude": -107.442256, "time_offset_sec": 962}, "public_key_hex": "1619fa0ee1c60d46e1f4bd976213c6ca82ca186e3d696b2281333d0bb8138aa1", "role": "CLIENT", "short_name": "🌲", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.119, "battery_level": 50, "channel_utilization": 4.14, "uptime_seconds": 53523, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1481, "long_name": "Drowsy Eagle", "next_hop": 0, "num": "0x45555dbd", "position": {"altitude": 1567, "latitude": 32.90734, "location_source": "LOC_INTERNAL", "longitude": -107.25434, "time_offset_sec": 1626}, "public_key_hex": "aa6e5105d8508087ea450fca46b1d8747a63a4a08a3eac3a1dd1e240e27d4db1", "role": "CLIENT", "short_name": "DYDO", "snr": 8.22, "status": null, "telemetry": {"air_util_tx": 0.673, "battery_level": 36, "channel_utilization": 26.13, "uptime_seconds": 340527, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 4249, "long_name": "Sharp Pike", "next_hop": 99, "num": "0x4559c707", "position": {"altitude": 1500, "latitude": 34.256609, "location_source": "LOC_INTERNAL", "longitude": -107.117919, "time_offset_sec": 4380}, "public_key_hex": "83c44acd018656c60e2caddfb45af1032d5cdfc7ba65f43bbf308d45623ebbe8", "role": "CLIENT", "short_name": "SQ5L", "snr": 6.75, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2612, "long_name": "Tall Squirrel", "next_hop": 0, "num": "0x455ed737", "position": {"altitude": 1284, "latitude": 33.519403, "location_source": "LOC_INTERNAL", "longitude": -107.388268, "time_offset_sec": 2696}, "public_key_hex": "82239e40d311413b0eacc8fca4045564fdf0798185ac7fff6be3061bccea411c", "role": "CLIENT", "short_name": "🔥", "snr": 2.01, "status": null, "telemetry": {"air_util_tx": 0.524, "battery_level": 41, "channel_utilization": 4.83, "uptime_seconds": 42062, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 1515, "long_name": "Green Aspen", "next_hop": 0, "num": "0x45649495", "position": {"altitude": 1340, "latitude": 33.41603, "location_source": "LOC_INTERNAL", "longitude": -107.115215, "time_offset_sec": 1800}, "public_key_hex": "8edf16cae0e45c01485c7a287d916272959a199b68f74bc31450741c6cd69884", "role": "CLIENT", "short_name": "GE1W", "snr": 3.85, "status": null, "telemetry": {"air_util_tx": 0.784, "battery_level": 101, "channel_utilization": 5.73, "uptime_seconds": 362080, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 5260, "long_name": "Soft Colt", "next_hop": 0, "num": "0x457747ab", "position": {"altitude": 1694, "latitude": 33.552255, "location_source": "LOC_INTERNAL", "longitude": -106.496469, "time_offset_sec": 5314}, "public_key_hex": "262d90c7b2ed1ca31bb04ac579e672a42779ef3a65b52e5b45983a4fdaea6654", "role": "CLIENT", "short_name": "🌙", "snr": 3.52, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.528, "battery_level": 80, "channel_utilization": 16.72, "uptime_seconds": 1351, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1253, "long_name": "Wandering Squirrel", "next_hop": 117, "num": "0x458162e4", "position": null, "public_key_hex": "caab99ea43c35df89bcd4c4a5ac0ab155208b093feedb2ff862bd4722bb88b71", "role": "CLIENT_MUTE", "short_name": "🐢", "snr": 4.14, "status": null, "telemetry": {"air_util_tx": 1.375, "battery_level": 62, "channel_utilization": 9.97, "uptime_seconds": 154657, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1006.27, "iaq": 15, "relative_humidity": 39.87, "temperature": 13.25}, "hops_away": 2, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 8367, "long_name": "Storm Badger", "next_hop": 180, "num": "0x4586218e", "position": {"altitude": 1606, "latitude": 33.482743, "location_source": "LOC_INTERNAL", "longitude": -108.06662, "time_offset_sec": 8466}, "public_key_hex": "9404dfaf00540b9119d78467d37cd48c6f28f8fa94ad0cf920eabd392b460f4f", "role": "CLIENT_MUTE", "short_name": "SMKX", "snr": 4.11, "status": null, "telemetry": {"air_util_tx": 0.472, "battery_level": 11, "channel_utilization": 10.82, "uptime_seconds": 95036, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.93, "iaq": 30, "relative_humidity": 48.27, "temperature": 20.11}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2760, "long_name": "Sleepy Eagle", "next_hop": 144, "num": "0x45899df4", "position": {"altitude": 1246, "latitude": 32.623254, "location_source": "LOC_INTERNAL", "longitude": -107.083181, "time_offset_sec": 2857}, "public_key_hex": "6e2800fa58fe42c6b4c741d5ef5342857f7e7d74b801fcf3ac9ca05027dc38de", "role": "ROUTER_LATE", "short_name": "SXX2", "snr": 9.61, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.333, "battery_level": 84, "channel_utilization": 5.48, "uptime_seconds": 38199, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 2936, "long_name": "Lost Wolf", "next_hop": 0, "num": "0x45941320", "position": {"altitude": 1440, "latitude": 33.27194, "location_source": "LOC_INTERNAL", "longitude": -107.689454, "time_offset_sec": 3126}, "public_key_hex": "b31474ffffb78605aed0a125b2ba7d8ebc08762f041a116da38e85c3158c3f7a", "role": "CLIENT", "short_name": "L0CS", "snr": 5.84, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 751, "long_name": "Floating Iguana", "next_hop": 0, "num": "0x45942c1a", "position": {"altitude": 1832, "latitude": 33.737701, "location_source": "LOC_INTERNAL", "longitude": -106.626549, "time_offset_sec": 868}, "public_key_hex": "1ca2a0f3f30683a342b28f4f43dad48247ae2e8a4a0ec6ef017e27074508ef07", "role": "CLIENT_MUTE", "short_name": "FKYU", "snr": 12.0, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 3866, "long_name": "Wild Lion", "next_hop": 0, "num": "0x45a09db6", "position": {"altitude": 1160, "latitude": 34.106481, "location_source": "LOC_INTERNAL", "longitude": -106.619419, "time_offset_sec": 3920}, "public_key_hex": "de74e777083af448ffc3e3036ccb6efe0c9345e072856307889e43fafc39ff05", "role": "CLIENT_MUTE", "short_name": "W09N", "snr": 6.62, "status": null, "telemetry": {"air_util_tx": 0.676, "battery_level": 13, "channel_utilization": 12.63, "uptime_seconds": 32538, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.65, "iaq": 57, "relative_humidity": 47.32, "temperature": 32.15}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2076, "long_name": "Rough Bison", "next_hop": 0, "num": "0x45a1fc97", "position": null, "public_key_hex": "344d1ba807efbc639840cca62b592c5ffc49fbf2e47f07a005a6d55d2c9c9c76", "role": "CLIENT", "short_name": "RX4S", "snr": 10.02, "status": null, "telemetry": {"air_util_tx": 1.284, "battery_level": 42, "channel_utilization": 11.76, "uptime_seconds": 71215, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 13184, "long_name": "Blue Phoenix", "next_hop": 0, "num": "0x45adfdec", "position": {"altitude": 1416, "latitude": 33.340768, "location_source": "LOC_INTERNAL", "longitude": -105.881793, "time_offset_sec": 13392}, "public_key_hex": "d729e50be645d75340553f3669782884b7eb4fe789ecd7950ebb045d254e1537", "role": "CLIENT", "short_name": "B45R", "snr": 7.61, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1931, "long_name": "Red Elk", "next_hop": 0, "num": "0x45e52449", "position": {"altitude": 996, "latitude": 32.552823, "location_source": "LOC_INTERNAL", "longitude": -107.657844, "time_offset_sec": 2227}, "public_key_hex": "28679ac9ded1bc0157e48d856efa7eec494b04b329cbb5d86f35c87bfab773ba", "role": "CLIENT", "short_name": "RERE", "snr": 3.01, "status": null, "telemetry": {"air_util_tx": 1.193, "battery_level": 33, "channel_utilization": 16.68, "uptime_seconds": 31253, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 7344, "long_name": "Whispering Heron", "next_hop": 0, "num": "0x45ffeb72", "position": {"altitude": 1091, "latitude": 33.251038, "location_source": "LOC_INTERNAL", "longitude": -108.48114, "time_offset_sec": 7559}, "public_key_hex": "", "role": "CLIENT", "short_name": "WFRW", "snr": 6.99, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 5, "hw_model": "T_DECK", "last_heard_offset_sec": 2738, "long_name": "Smooth Iguana", "next_hop": 126, "num": "0x460f97d1", "position": {"altitude": 1548, "latitude": 32.854686, "location_source": "LOC_INTERNAL", "longitude": -107.043196, "time_offset_sec": 3028}, "public_key_hex": "a8968d4042738f6827a614e626ca7c926ef7099cf98457e92f9fb719f96f95d1", "role": "TRACKER", "short_name": "🔥", "snr": 6.94, "status": null, "telemetry": {"air_util_tx": 0.948, "battery_level": 52, "channel_utilization": 14.48, "uptime_seconds": 25625, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 2549, "long_name": "Lone Falcon", "next_hop": 26, "num": "0x4610d15b", "position": {"altitude": 1559, "latitude": 34.120115, "location_source": "LOC_INTERNAL", "longitude": -108.158303, "time_offset_sec": 2651}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦉", "snr": 8.68, "status": null, "telemetry": {"air_util_tx": 0.759, "battery_level": 45, "channel_utilization": 4.06, "uptime_seconds": 20190, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 2162, "long_name": "Wild Crane", "next_hop": 0, "num": "0x463cd55b", "position": {"altitude": 1325, "latitude": 33.133792, "location_source": "LOC_INTERNAL", "longitude": -107.692381, "time_offset_sec": 2243}, "public_key_hex": "004fa7c2403e5d82d605b24467c1b62aa5a41cbd99c9301363b0f9598d2e32ae", "role": "CLIENT", "short_name": "W1D3", "snr": 7.7, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 16139, "long_name": "Short Lion", "next_hop": 0, "num": "0x464a5c31", "position": {"altitude": 1586, "latitude": 32.981343, "location_source": "LOC_INTERNAL", "longitude": -107.228199, "time_offset_sec": 16242}, "public_key_hex": "cca6caf0e54a16561352c0ee447b9c5f0f9bcabf6ea110daf6734062aa72408d", "role": "CLIENT", "short_name": "S31A", "snr": 5.22, "status": null, "telemetry": {"air_util_tx": 1.334, "battery_level": 80, "channel_utilization": 7.03, "uptime_seconds": 23108, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1434, "long_name": "Iron Eagle", "next_hop": 0, "num": "0x464bf2aa", "position": {"altitude": 1593, "latitude": 32.957992, "location_source": "LOC_INTERNAL", "longitude": -107.66714, "time_offset_sec": 1612}, "public_key_hex": "043a902f8e776187fb6951b2c129aa157d25764f57b34c938b2f2eb94cf6cb8e", "role": "CLIENT_MUTE", "short_name": "I7RS", "snr": -0.13, "status": null, "telemetry": {"air_util_tx": 1.503, "battery_level": 89, "channel_utilization": 5.89, "uptime_seconds": 133596, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.7, "iaq": 47, "relative_humidity": 69.02, "temperature": 40.59}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1926, "long_name": "Drifting Oak", "next_hop": 0, "num": "0x46661476", "position": {"altitude": 1343, "latitude": 32.653767, "location_source": "LOC_INTERNAL", "longitude": -108.026175, "time_offset_sec": 2144}, "public_key_hex": "c20429f9b0f5fc6e5c8e38d846624d2455e7381864223de1a90b6612a93a1340", "role": "CLIENT", "short_name": "DS3R", "snr": 7.28, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.21, "iaq": 26, "relative_humidity": 42.2, "temperature": 23.04}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 716, "long_name": "Whispering Otter", "next_hop": 0, "num": "0x4666f59b", "position": {"altitude": 1039, "latitude": 33.476953, "location_source": "LOC_INTERNAL", "longitude": -107.7824, "time_offset_sec": 970}, "public_key_hex": "a38c86bd220670426c241fa16aade4e474496aa0b1c02b3565507150b1769544", "role": "CLIENT", "short_name": "WE3V", "snr": 10.0, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 8244, "long_name": "Storm Gecko", "next_hop": 0, "num": "0x4675288c", "position": {"altitude": 1372, "latitude": 32.368138, "location_source": "LOC_INTERNAL", "longitude": -106.348625, "time_offset_sec": 8353}, "public_key_hex": "e762410a9180d112f4abcac69004bc690057cfcb8029a53abc6a6361ed47edf4", "role": "CLIENT", "short_name": "S167", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 7, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 184, "long_name": "Drifting Mesa", "next_hop": 40, "num": "0x467b8c11", "position": {"altitude": 1459, "latitude": 33.541491, "location_source": "LOC_INTERNAL", "longitude": -107.648048, "time_offset_sec": 378}, "public_key_hex": "5b3f8eb8abc745a113f92771b5b9d1bc090c0c7c36e00e4b9795d85c0584fc8b", "role": "CLIENT", "short_name": "DWDJ", "snr": 11.4, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.89, "battery_level": 29, "channel_utilization": 9.17, "uptime_seconds": 555, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1000.36, "iaq": 45, "relative_humidity": 24.44, "temperature": 23.74}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 422, "long_name": "Bright Seal", "next_hop": 61, "num": "0x467c7c77", "position": {"altitude": 1024, "latitude": 33.020132, "location_source": "LOC_INTERNAL", "longitude": -106.301355, "time_offset_sec": 601}, "public_key_hex": "33cc3282f92b064a241e730f7d1a07ece2e24f9ef19f7b799cb446f755ceab73", "role": "CLIENT", "short_name": "BI7X", "snr": 3.56, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5317, "long_name": "Sharp Yucca", "next_hop": 0, "num": "0x468534f6", "position": {"altitude": 1359, "latitude": 33.640434, "location_source": "LOC_INTERNAL", "longitude": -107.103113, "time_offset_sec": 5371}, "public_key_hex": "", "role": "CLIENT", "short_name": "SK85", "snr": 4.5, "status": null, "telemetry": {"air_util_tx": 0.155, "battery_level": 85, "channel_utilization": 6.42, "uptime_seconds": 30679, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1610, "long_name": "Brave Sage", "next_hop": 113, "num": "0x4698b893", "position": null, "public_key_hex": "72499290b37ccb59703ba90be647dff011fbeac40413bedb44cc43e58c1d51b3", "role": "ROUTER", "short_name": "B3WG", "snr": 5.97, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1125, "long_name": "River Bison", "next_hop": 170, "num": "0x46a2f603", "position": {"altitude": 1161, "latitude": 32.631507, "location_source": "LOC_INTERNAL", "longitude": -107.491892, "time_offset_sec": 1225}, "public_key_hex": "1b03f97bb420786b1b3e5acde79fc8a6e8a354b3009c3b816c90575a8a546a9c", "role": "CLIENT_BASE", "short_name": "RJ8J", "snr": -3.29, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 6376, "long_name": "Bright Lion", "next_hop": 0, "num": "0x46a50355", "position": {"altitude": 1286, "latitude": 33.384462, "location_source": "LOC_INTERNAL", "longitude": -107.145579, "time_offset_sec": 6455}, "public_key_hex": "d988668c1c07e558be5a331930092a7fb051778efa6e18dce250ac06a6c99cee", "role": "CLIENT", "short_name": "B1LG", "snr": -0.1, "status": null, "telemetry": {"air_util_tx": 0.12, "battery_level": 39, "channel_utilization": 24.03, "uptime_seconds": 35909, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1020.32, "iaq": 94, "relative_humidity": 89.6, "temperature": 19.57}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3559, "long_name": "Happy Juniper", "next_hop": 66, "num": "0x46b89865", "position": {"altitude": 1059, "latitude": 31.877077, "location_source": "LOC_INTERNAL", "longitude": -106.52746, "time_offset_sec": 3796}, "public_key_hex": "2ba7f28378df8a2266dabf1d27c8af2183eaa437ca0f6b25f800965e5a813de4", "role": "CLIENT", "short_name": "HRIL", "snr": 2.19, "status": null, "telemetry": {"air_util_tx": 1.466, "battery_level": 73, "channel_utilization": 11.29, "uptime_seconds": 137821, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1012.37, "iaq": 42, "relative_humidity": 53.61, "temperature": 34.85}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 166, "long_name": "Sneaky Mole", "next_hop": 0, "num": "0x46bc46a3", "position": {"altitude": 2029, "latitude": 33.479051, "location_source": "LOC_INTERNAL", "longitude": -107.797267, "time_offset_sec": 330}, "public_key_hex": "2209969c18ea30dac779ca84bb9cb580ed1a1d6282fc3c376565dd6f7096fe4e", "role": "LOST_AND_FOUND", "short_name": "SPCY", "snr": 5.81, "status": null, "telemetry": {"air_util_tx": 0.397, "battery_level": 15, "channel_utilization": 6.9, "uptime_seconds": 38649, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3659, "long_name": "Frosty Arroyo", "next_hop": 0, "num": "0x46c83e07", "position": {"altitude": 944, "latitude": 32.353274, "location_source": "LOC_INTERNAL", "longitude": -106.854014, "time_offset_sec": 3937}, "public_key_hex": "6000cae180eb68b45da9b78f9da68cad6d6cfb4d0e65180453c3db800ad29576", "role": "CLIENT", "short_name": "FTRH", "snr": 0.45, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.161, "battery_level": 54, "channel_utilization": 12.82, "uptime_seconds": 23817, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2216, "long_name": "Red Sage", "next_hop": 0, "num": "0x46e0c721", "position": {"altitude": 1532, "latitude": 33.513771, "location_source": "LOC_INTERNAL", "longitude": -106.770953, "time_offset_sec": 2325}, "public_key_hex": "8f6021f73b3e2b3851789b75ca0e34c0a5cfaad23f97623dfe13a2649557bc08", "role": "ROUTER", "short_name": "RDSA", "snr": 8.13, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.53, "battery_level": 93, "channel_utilization": 20.45, "uptime_seconds": 27754, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 6234, "long_name": "Stone Colt", "next_hop": 0, "num": "0x46e386c5", "position": {"altitude": 1262, "latitude": 33.577479, "location_source": "LOC_INTERNAL", "longitude": -107.830796, "time_offset_sec": 6423}, "public_key_hex": "8522448f46a3bdfd3747cacc12e113cd04738a1e8842c2eea070e576b56b34ee", "role": "CLIENT", "short_name": "SR3Q", "snr": 2.28, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.293, "battery_level": 42, "channel_utilization": 2.27, "uptime_seconds": 39044, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1009.39, "iaq": 40, "relative_humidity": 30.85, "temperature": 28.03}, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 676, "long_name": "Rough Adder", "next_hop": 177, "num": "0x46f6e18b", "position": {"altitude": 1316, "latitude": 33.123841, "location_source": "LOC_INTERNAL", "longitude": -107.559814, "time_offset_sec": 905}, "public_key_hex": "5100be3edc2b08a4a448fd94539475806ba3cb418792ac820f4cc4cd218cba02", "role": "CLIENT", "short_name": "R2J8", "snr": 7.8, "status": null, "telemetry": {"air_util_tx": 0.577, "battery_level": 27, "channel_utilization": 12.88, "uptime_seconds": 9188, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7493, "long_name": "Sneaky Seal", "next_hop": 0, "num": "0x46fc98ac", "position": null, "public_key_hex": "7032d8115b0e8ba0539713995f5dd096dfec229b9bda8e9181f90e92c260263d", "role": "CLIENT", "short_name": "SA39", "snr": 5.16, "status": null, "telemetry": {"air_util_tx": 0.196, "battery_level": 35, "channel_utilization": 14.58, "uptime_seconds": 13033, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3461, "long_name": "Storm Badger", "next_hop": 0, "num": "0x470211b0", "position": {"altitude": 1403, "latitude": 33.281945, "location_source": "LOC_INTERNAL", "longitude": -108.027636, "time_offset_sec": 3673}, "public_key_hex": "0c14f24b9a572eebfc810ac75ecbe2e7c117ee2983b3cae02d37de2ca28a2d7f", "role": "CLIENT", "short_name": "🦋", "snr": 6.66, "status": null, "telemetry": {"air_util_tx": 0.3, "battery_level": 19, "channel_utilization": 2.92, "uptime_seconds": 67133, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 6604, "long_name": "Black Seal", "next_hop": 237, "num": "0x471bf138", "position": {"altitude": 1586, "latitude": 33.076397, "location_source": "LOC_INTERNAL", "longitude": -106.99977, "time_offset_sec": 6633}, "public_key_hex": "ee1c1d1fb0d93e578e7cce18011583a3f0186ad067ce7e70d33a7eb1b5437510", "role": "CLIENT", "short_name": "BFXI", "snr": 8.37, "status": null, "telemetry": {"air_util_tx": 0.975, "battery_level": 53, "channel_utilization": 6.21, "uptime_seconds": 44774, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 116, "long_name": "Floating Colt", "next_hop": 0, "num": "0x4737e1a4", "position": {"altitude": 1864, "latitude": 33.427287, "location_source": "LOC_INTERNAL", "longitude": -106.279405, "time_offset_sec": 257}, "public_key_hex": "8ba95839557deb4a65b0ccede21b937602aab7e987ce7ac10bfaa17ec2822bad", "role": "TRACKER", "short_name": "F5F3", "snr": 8.43, "status": null, "telemetry": {"air_util_tx": 0.953, "battery_level": 66, "channel_utilization": 8.67, "uptime_seconds": 43068, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1024.63, "iaq": 41, "relative_humidity": 62.83, "temperature": 24.87}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 937, "long_name": "Black Lion", "next_hop": 213, "num": "0x4739b045", "position": {"altitude": 1401, "latitude": 33.113324, "location_source": "LOC_INTERNAL", "longitude": -106.611719, "time_offset_sec": 1093}, "public_key_hex": "bb565ba918962bc0f187ef38945736d6c88fe21c9338470b820ac4cecd824127", "role": "CLIENT", "short_name": "BDB2", "snr": 4.57, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1810, "long_name": "Howling Arroyo", "next_hop": 0, "num": "0x4739f349", "position": null, "public_key_hex": "502dee5ce2cf54fd5ceb0e2a7b590d9c591a924cdcd846ad161e67feee25135e", "role": "ROUTER", "short_name": "HWVV", "snr": 0.34, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2443, "long_name": "Black Hawk", "next_hop": 198, "num": "0x47490a04", "position": {"altitude": 1238, "latitude": 32.256152, "location_source": "LOC_INTERNAL", "longitude": -108.282866, "time_offset_sec": 2449}, "public_key_hex": "2e867e7c5a6e44f58ca6e5fd4546bdbf0857ad5dece70d6ccc0f522514aa70bc", "role": "TRACKER", "short_name": "BBF6", "snr": 3.95, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.276, "battery_level": 59, "channel_utilization": 6.13, "uptime_seconds": 68886, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 8050, "long_name": "Green Mamba", "next_hop": 0, "num": "0x475131e9", "position": {"altitude": 1399, "latitude": 32.865185, "location_source": "LOC_INTERNAL", "longitude": -107.339092, "time_offset_sec": 8246}, "public_key_hex": "", "role": "CLIENT", "short_name": "G5AK", "snr": 3.11, "status": null, "telemetry": {"air_util_tx": 0.079, "battery_level": 91, "channel_utilization": 16.22, "uptime_seconds": 68354, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.51, "iaq": 67, "relative_humidity": 53.88, "temperature": 25.5}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 126, "long_name": "Sneaky Badger", "next_hop": 87, "num": "0x475165b2", "position": {"altitude": 1605, "latitude": 33.17486, "location_source": "LOC_INTERNAL", "longitude": -106.947803, "time_offset_sec": 347}, "public_key_hex": "", "role": "CLIENT", "short_name": "S294", "snr": 4.7, "status": null, "telemetry": {"air_util_tx": 0.748, "battery_level": 47, "channel_utilization": 17.56, "uptime_seconds": 190870, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 352, "long_name": "Gold Bass", "next_hop": 0, "num": "0x4756d5b9", "position": {"altitude": 1581, "latitude": 33.775022, "location_source": "LOC_INTERNAL", "longitude": -106.804046, "time_offset_sec": 443}, "public_key_hex": "c4e393fd5d7d7433dd516ad39f0e6064178675660d08bdefa2ed58c34821e46c", "role": "CLIENT", "short_name": "GZG5", "snr": 11.46, "status": null, "telemetry": {"air_util_tx": 0.236, "battery_level": 82, "channel_utilization": 13.77, "uptime_seconds": 117283, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1179, "long_name": "Green Oak", "next_hop": 0, "num": "0x47689e6c", "position": {"altitude": 1240, "latitude": 33.02401, "location_source": "LOC_INTERNAL", "longitude": -107.09615, "time_offset_sec": 1190}, "public_key_hex": "be09536a981b34a44702342dc9d846883ef0ee73a71f18145bc2673e5696c119", "role": "CLIENT_MUTE", "short_name": "GHCZ", "snr": 2.8, "status": null, "telemetry": {"air_util_tx": 0.25, "battery_level": 61, "channel_utilization": 10.24, "uptime_seconds": 50674, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.6, "iaq": 67, "relative_humidity": 73.47, "temperature": 32.7}, "hops_away": 0, "hw_model": "RAK3312", "last_heard_offset_sec": 1965, "long_name": "Storm Mamba WD7QU", "next_hop": 0, "num": "0x4768ada4", "position": {"altitude": 1405, "latitude": 33.57202, "location_source": "LOC_INTERNAL", "longitude": -107.565977, "time_offset_sec": 2000}, "public_key_hex": "1b384b608c0288a914b2f944b3a9bf28b088f0c5ae697d6f059346472224c5d5", "role": "CLIENT_MUTE", "short_name": "S957", "snr": 0.23, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 2428, "long_name": "Mountain Pony", "next_hop": 116, "num": "0x47709b0b", "position": {"altitude": 1524, "latitude": 33.787526, "location_source": "LOC_INTERNAL", "longitude": -107.556762, "time_offset_sec": 2569}, "public_key_hex": "7de1a9ba0c463dff6a0e56e5dfad3620ed4a85c1d8038f44e1b0a004112d6cf9", "role": "CLIENT_BASE", "short_name": "MQTP", "snr": 10.07, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 0.301, "battery_level": 74, "channel_utilization": 14.74, "uptime_seconds": 160440, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 8728, "long_name": "Hidden Badger", "next_hop": 0, "num": "0x478b43c8", "position": {"altitude": 1464, "latitude": 33.371824, "location_source": "LOC_INTERNAL", "longitude": -107.427708, "time_offset_sec": 8992}, "public_key_hex": "15c40332ba77afbd47966a28aafac087401abc457e849d75750fddc709cfcc3e", "role": "CLIENT", "short_name": "HIAR", "snr": -4.24, "status": null, "telemetry": {"air_util_tx": 0.652, "battery_level": 28, "channel_utilization": 0.46, "uptime_seconds": 95893, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 6588, "long_name": "Steel Raven", "next_hop": 43, "num": "0x478f6862", "position": {"altitude": 1596, "latitude": 33.402915, "location_source": "LOC_INTERNAL", "longitude": -107.06536, "time_offset_sec": 6729}, "public_key_hex": "85b5945175645f05ab23f30ac29623daf18d41c2236f0a778c9d662f4b77223d", "role": "CLIENT", "short_name": "SOR3", "snr": 10.29, "status": null, "telemetry": {"air_util_tx": 0.26, "battery_level": 59, "channel_utilization": 3.07, "uptime_seconds": 12584, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 254, "long_name": "Tall Turtle", "next_hop": 23, "num": "0x4795add3", "position": {"altitude": 1495, "latitude": 32.52147, "location_source": "LOC_INTERNAL", "longitude": -106.828101, "time_offset_sec": 477}, "public_key_hex": "a164e7c632cb18e8b40df0d9439a01d73c8fa1e633c6cfca222d16c77bd6804b", "role": "CLIENT", "short_name": "TCB6", "snr": 8.86, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.069, "battery_level": 55, "channel_utilization": 2.53, "uptime_seconds": 102003, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6220, "long_name": "Misty Crane", "next_hop": 0, "num": "0x479c12f0", "position": {"altitude": 1488, "latitude": 33.691578, "location_source": "LOC_INTERNAL", "longitude": -107.90531, "time_offset_sec": 6309}, "public_key_hex": "04ecc2bbf5a29850de23276d5a9895e0f9758900acbd898689bbeb694b9757c6", "role": "CLIENT", "short_name": "MZQ4", "snr": -2.73, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.407, "battery_level": 21, "channel_utilization": 39.99, "uptime_seconds": 71561, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1014.23, "iaq": 102, "relative_humidity": 59.8, "temperature": 25.01}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 1603, "long_name": "Stone Mesa", "next_hop": 46, "num": "0x47b7867f", "position": {"altitude": 1220, "latitude": 33.147095, "location_source": "LOC_INTERNAL", "longitude": -107.617597, "time_offset_sec": 1872}, "public_key_hex": "9229cd5823661402d29fd899d499533c83e89e42b078f6813c25c5ca71611b3d", "role": "CLIENT", "short_name": "S8A0", "snr": 10.7, "status": null, "telemetry": {"air_util_tx": 1.551, "battery_level": 101, "channel_utilization": 11.38, "uptime_seconds": 83181, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5309, "long_name": "New Crane", "next_hop": 0, "num": "0x47bbfffb", "position": {"altitude": 1377, "latitude": 33.208898, "location_source": "LOC_INTERNAL", "longitude": -108.31049, "time_offset_sec": 5337}, "public_key_hex": "8d711d947ccfac5d645516252b4d83874ca2e92f8da55e0d547a77ed5a05b472", "role": "CLIENT", "short_name": "NJZ3", "snr": 7.33, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2902, "long_name": "Silver Pike", "next_hop": 242, "num": "0x47d9ae51", "position": {"altitude": 1477, "latitude": 34.016639, "location_source": "LOC_INTERNAL", "longitude": -106.332194, "time_offset_sec": 3100}, "public_key_hex": "83737272c5f620136449c9af52309b76bc3dba2b45c50ecb6a2e632acfe52915", "role": "CLIENT", "short_name": "S2LZ", "snr": 8.87, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1189, "long_name": "Whispering Phoenix", "next_hop": 0, "num": "0x47ea715c", "position": {"altitude": 1195, "latitude": 33.47696, "location_source": "LOC_INTERNAL", "longitude": -108.25031, "time_offset_sec": 1357}, "public_key_hex": "e13e5d9e0bbfe13702a3e50b7264890ace7a2fbfca64b9b1b5652cca178aca60", "role": "TRACKER", "short_name": "WBR8", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 1027, "long_name": "Tiny Lion", "next_hop": 206, "num": "0x47ecb9c7", "position": {"altitude": 1305, "latitude": 32.535217, "location_source": "LOC_INTERNAL", "longitude": -106.551983, "time_offset_sec": 1093}, "public_key_hex": "4b49d3b543c157ff9cfd3928726045dcc4d172913f956f6fa3add15c07939d7c", "role": "CLIENT_MUTE", "short_name": "🦋", "snr": 4.86, "status": null, "telemetry": {"air_util_tx": 0.373, "battery_level": 39, "channel_utilization": 6.62, "uptime_seconds": 47354, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 5749, "long_name": "Sneaky Mole", "next_hop": 0, "num": "0x4807de71", "position": null, "public_key_hex": "85fc2b0177e8eb8040f08cff4d8614ff33cc4e1f32445d8fdb7433017873334c", "role": "CLIENT", "short_name": "SX8F", "snr": 3.38, "status": null, "telemetry": {"air_util_tx": 0.261, "battery_level": 78, "channel_utilization": 12.0, "uptime_seconds": 113654, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 601, "long_name": "Solar Phoenix", "next_hop": 143, "num": "0x482290be", "position": {"altitude": 1623, "latitude": 32.943533, "location_source": "LOC_INTERNAL", "longitude": -106.889297, "time_offset_sec": 694}, "public_key_hex": "b7a74373847c0a8b344f42ff2cae3ed7366c1c101b80cbcd8e22124875e9163b", "role": "CLIENT_MUTE", "short_name": "S3UW", "snr": 1.85, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 15681, "long_name": "Silent Wolf", "next_hop": 0, "num": "0x48309ef8", "position": {"altitude": 1618, "latitude": 32.270376, "location_source": "LOC_INTERNAL", "longitude": -106.113096, "time_offset_sec": 15784}, "public_key_hex": "21cca011762234594d982e03e86cf56edd4246dbf0f124a5fed6471d9c85d74a", "role": "CLIENT", "short_name": "S2AG", "snr": 9.84, "status": null, "telemetry": {"air_util_tx": 1.166, "battery_level": 55, "channel_utilization": 14.43, "uptime_seconds": 107398, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 414, "long_name": "Floating Bronco", "next_hop": 126, "num": "0x4832a5a4", "position": null, "public_key_hex": "12661f5895e21b466bbd71ea8cb80c5b1a36c72719ff8bed5321b4d3a226e629", "role": "CLIENT", "short_name": "F3SD", "snr": 5.58, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.036, "battery_level": 101, "channel_utilization": 33.93, "uptime_seconds": 73039, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 12436, "long_name": "Steel Lynx W58NY", "next_hop": 0, "num": "0x4837d819", "position": {"altitude": 935, "latitude": 33.293944, "location_source": "LOC_INTERNAL", "longitude": -106.340812, "time_offset_sec": 12702}, "public_key_hex": "89383279b1c4c3a84bbfe11c72f445936419ed1b014e7e7d0cf5015aa6981c7e", "role": "TRACKER", "short_name": "SQR5", "snr": 8.28, "status": null, "telemetry": {"air_util_tx": 1.973, "battery_level": 75, "channel_utilization": 6.1, "uptime_seconds": 153266, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3491, "long_name": "Sneaky Arroyo", "next_hop": 0, "num": "0x4847afba", "position": null, "public_key_hex": "391a7b8c1cd97acd4e4e029f7c4744cb1433f461aa7a1ea1a830e55d900148ea", "role": "CLIENT", "short_name": "SP85", "snr": 4.52, "status": null, "telemetry": {"air_util_tx": 0.226, "battery_level": 25, "channel_utilization": 5.97, "uptime_seconds": 5054, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2794, "long_name": "Tiny Coyote", "next_hop": 0, "num": "0x4853e811", "position": {"altitude": 1598, "latitude": 32.865149, "location_source": "LOC_INTERNAL", "longitude": -106.359508, "time_offset_sec": 3076}, "public_key_hex": "cadd0efcb8fa090a0a545c808c24c0b528c825317290678997f386ae7ea4931a", "role": "CLIENT", "short_name": "🦅", "snr": 10.13, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.672, "battery_level": 61, "channel_utilization": 10.97, "uptime_seconds": 106753, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4380, "long_name": "Shady Raven", "next_hop": 0, "num": "0x485c6a49", "position": {"altitude": 1357, "latitude": 33.218231, "location_source": "LOC_INTERNAL", "longitude": -106.575739, "time_offset_sec": 4502}, "public_key_hex": "892a71d921561384e813d78bc511714af89e27ada42037f90f8b09940facbab8", "role": "CLIENT", "short_name": "S0WY", "snr": 1.8, "status": null, "telemetry": {"air_util_tx": 0.339, "battery_level": 28, "channel_utilization": 7.83, "uptime_seconds": 48845, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2376, "long_name": "Storm Mesa", "next_hop": 0, "num": "0x487640dc", "position": null, "public_key_hex": "25c81d389fbf3abcf7240f527b5a5fbe590b3eb10835dde2889aad02544055d3", "role": "CLIENT", "short_name": "SV3H", "snr": 6.38, "status": null, "telemetry": {"air_util_tx": 1.521, "battery_level": 101, "channel_utilization": 4.64, "uptime_seconds": 255556, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1026.3, "iaq": 15, "relative_humidity": 25.04, "temperature": 22.05}, "hops_away": 4, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 2496, "long_name": "Stone Coyote", "next_hop": 35, "num": "0x4890b2c9", "position": {"altitude": 1099, "latitude": 33.962165, "location_source": "LOC_INTERNAL", "longitude": -107.554202, "time_offset_sec": 2667}, "public_key_hex": "4cfbd32805c481b814034c4fc9527a0afc5ba7af97d92b8b81537fce6da12621", "role": "ROUTER", "short_name": "SVCJ", "snr": 2.91, "status": null, "telemetry": {"air_util_tx": 0.224, "battery_level": 76, "channel_utilization": 1.57, "uptime_seconds": 107790, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 355, "long_name": "Sky Coyote", "next_hop": 144, "num": "0x48910e76", "position": {"altitude": 944, "latitude": 33.437438, "location_source": "LOC_INTERNAL", "longitude": -107.983984, "time_offset_sec": 624}, "public_key_hex": "6fa0ebd1fbe1d8d82644e2e6294616c90fa76860c3196d901f2ed2e4e2211937", "role": "CLIENT", "short_name": "SWEC", "snr": 5.69, "status": null, "telemetry": {"air_util_tx": 0.673, "battery_level": 88, "channel_utilization": 37.37, "uptime_seconds": 34511, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 7052, "long_name": "Frozen Pony", "next_hop": 92, "num": "0x489198da", "position": {"altitude": 1092, "latitude": 32.632491, "location_source": "LOC_INTERNAL", "longitude": -106.321052, "time_offset_sec": 7224}, "public_key_hex": "8d52bd480282b2b134f1160a7e9ad7518fd0b8e1371d0e5b0a6ebc1fad4defe8", "role": "CLIENT", "short_name": "FNLS", "snr": 5.17, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 656, "long_name": "Iron Turtle", "next_hop": 207, "num": "0x48951a9d", "position": {"altitude": 1209, "latitude": 33.526596, "location_source": "LOC_INTERNAL", "longitude": -107.947529, "time_offset_sec": 910}, "public_key_hex": "49cfc8560efdb9d3aae1159fc97200964e9455b073ad8bdc3485596fdf53ca8d", "role": "CLIENT", "short_name": "I8A6", "snr": 8.22, "status": null, "telemetry": {"air_util_tx": 0.659, "battery_level": 96, "channel_utilization": 15.37, "uptime_seconds": 132164, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3724, "long_name": "New Hawk", "next_hop": 0, "num": "0x489fc68c", "position": {"altitude": 1347, "latitude": 33.712429, "location_source": "LOC_INTERNAL", "longitude": -107.545227, "time_offset_sec": 3796}, "public_key_hex": "cebbe264281961f6f18b6df8a96c8d672532872de9b3fef4b78a7649bf8c4f8f", "role": "CLIENT", "short_name": "NCN4", "snr": 5.96, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.08, "iaq": 37, "relative_humidity": 44.91, "temperature": 9.88}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1303, "long_name": "Sleepy Iguana", "next_hop": 0, "num": "0x48bb1d44", "position": {"altitude": 1402, "latitude": 33.932246, "location_source": "LOC_INTERNAL", "longitude": -107.114127, "time_offset_sec": 1462}, "public_key_hex": "27516062721b0f6ea4a341626b3535fb9350ff5eacd7a6744641cc8fa185d3d6", "role": "CLIENT", "short_name": "SLWO", "snr": 1.62, "status": null, "telemetry": {"air_util_tx": 0.252, "battery_level": 76, "channel_utilization": 21.61, "uptime_seconds": 112362, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1017.16, "iaq": 120, "relative_humidity": 75.02, "temperature": 20.37}, "hops_away": 0, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 3972, "long_name": "Found Raven", "next_hop": 0, "num": "0x48c50915", "position": null, "public_key_hex": "f66bfd06053eca767329ef3da7875d0f0d89edca5c39d72095c8c4ab76eff9e3", "role": "CLIENT", "short_name": "FLEZ", "snr": 4.85, "status": null, "telemetry": {"air_util_tx": 0.826, "battery_level": 82, "channel_utilization": 8.34, "uptime_seconds": 72477, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3546, "long_name": "Blue Moose", "next_hop": 102, "num": "0x48d5a8c3", "position": {"altitude": 1465, "latitude": 32.203875, "location_source": "LOC_INTERNAL", "longitude": -107.17133, "time_offset_sec": 3633}, "public_key_hex": "edf459ba9250460f0dd72dc2457dfdc59f940f8de0e51e07ef11936973e2f019", "role": "CLIENT", "short_name": "BHMQ", "snr": 3.98, "status": null, "telemetry": {"air_util_tx": 0.271, "battery_level": 19, "channel_utilization": 13.1, "uptime_seconds": 61360, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3755, "long_name": "Floating Falcon", "next_hop": 0, "num": "0x48dac1cb", "position": null, "public_key_hex": "65a106ec96c78228efe090ae74506d70ef1526d98a0ef6abfa238ddec7f2d9c4", "role": "ROUTER_LATE", "short_name": "FFRD", "snr": 3.33, "status": null, "telemetry": {"air_util_tx": 0.466, "battery_level": 39, "channel_utilization": 9.66, "uptime_seconds": 14070, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1992, "long_name": "Giant Cobra", "next_hop": 0, "num": "0x48e17754", "position": null, "public_key_hex": "fb1cde68e80700f0b7115b15e05e734623b20e639d62b9a67e8063dc5cc9908d", "role": "CLIENT_MUTE", "short_name": "GFO5", "snr": 7.49, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.941, "battery_level": 101, "channel_utilization": 2.28, "uptime_seconds": 26900, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "M5STACK_C6L", "last_heard_offset_sec": 5897, "long_name": "Dawn Mamba", "next_hop": 0, "num": "0x48f714d2", "position": {"altitude": 1767, "latitude": 32.818613, "location_source": "LOC_INTERNAL", "longitude": -107.639437, "time_offset_sec": 5976}, "public_key_hex": "0747f5ab19a5d1fbe3f42b3692bf402c43983d0e30ce8757379d0f685a2eebf2", "role": "CLIENT", "short_name": "D5KK", "snr": 10.34, "status": null, "telemetry": {"air_util_tx": 0.692, "battery_level": 43, "channel_utilization": 12.22, "uptime_seconds": 136495, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.99, "iaq": 0, "relative_humidity": 88.85, "temperature": 32.43}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1638, "long_name": "Desert Bronco", "next_hop": 0, "num": "0x490efd38", "position": {"altitude": 1434, "latitude": 33.109018, "location_source": "LOC_INTERNAL", "longitude": -107.078519, "time_offset_sec": 1841}, "public_key_hex": "c5fd20e43fd0a5b364f094ffd530f435a507f65abb644c5095c25e020d5d3e09", "role": "TRACKER", "short_name": "DDZQ", "snr": 2.09, "status": null, "telemetry": {"air_util_tx": 1.306, "battery_level": 68, "channel_utilization": 7.28, "uptime_seconds": 95500, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 936, "long_name": "Sharp Lynx", "next_hop": 201, "num": "0x491af6c8", "position": {"altitude": 1094, "latitude": 32.72221, "location_source": "LOC_INTERNAL", "longitude": -106.690105, "time_offset_sec": 1169}, "public_key_hex": "7f2cad25e67edc8415ed74a6fc3db92e6e51467dc449ecbbd2564d07038ae7ef", "role": "ROUTER", "short_name": "S1IJ", "snr": 4.47, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1079, "long_name": "Frosty Mamba", "next_hop": 0, "num": "0x493bd14d", "position": {"altitude": 1721, "latitude": 33.415035, "location_source": "LOC_INTERNAL", "longitude": -107.170296, "time_offset_sec": 1200}, "public_key_hex": "608f5fab82c0a011930ff7a59a626eb0b3904e8871abb52277817ecafc8d2632", "role": "CLIENT_BASE", "short_name": "FC16", "snr": 2.76, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.49, "battery_level": 28, "channel_utilization": 6.26, "uptime_seconds": 559192, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 997.16, "iaq": 4, "relative_humidity": 76.09, "temperature": 21.48}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4337, "long_name": "White Seal", "next_hop": 0, "num": "0x495c6e69", "position": {"altitude": 1331, "latitude": 32.557617, "location_source": "LOC_INTERNAL", "longitude": -107.292301, "time_offset_sec": 4511}, "public_key_hex": "c1234d6026e27c6cfc9bb0f322a0337eab6fa7a34a99c2f55f7c42fc86a857d0", "role": "CLIENT_MUTE", "short_name": "WL9F", "snr": 6.48, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.557, "battery_level": 95, "channel_utilization": 4.68, "uptime_seconds": 18627, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3041, "long_name": "Wild Raven", "next_hop": 0, "num": "0x495d1cb4", "position": {"altitude": 1354, "latitude": 32.969613, "location_source": "LOC_INTERNAL", "longitude": -106.775868, "time_offset_sec": 3233}, "public_key_hex": "11fa0d97786ef951d7480cf5e2c9bd4202466a0de7e8a8ffcc8feb1dd585b013", "role": "CLIENT_MUTE", "short_name": "WXAQ", "snr": 8.24, "status": {"status": "no-gps"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.32, "iaq": 21, "relative_humidity": 53.98, "temperature": 29.34}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 10009, "long_name": "Whispering Lynx", "next_hop": 0, "num": "0x498ec560", "position": {"altitude": 1747, "latitude": 33.960021, "location_source": "LOC_INTERNAL", "longitude": -107.836921, "time_offset_sec": 10303}, "public_key_hex": "46959a13854c7c800faa3a0e05fcdbab0254d46849f4feca9bba34208e085651", "role": "ROUTER_LATE", "short_name": "WT9T", "snr": 4.97, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.496, "battery_level": 41, "channel_utilization": 18.98, "uptime_seconds": 99696, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.03, "iaq": 48, "relative_humidity": 68.29, "temperature": 13.15}, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 4419, "long_name": "Copper Bison", "next_hop": 0, "num": "0x49939ec2", "position": {"altitude": 2223, "latitude": 33.205901, "location_source": "LOC_INTERNAL", "longitude": -106.994651, "time_offset_sec": 4644}, "public_key_hex": "c2430a76c67f1d186160c71a2632c35d259e3237764c1033834d3749c65ece18", "role": "CLIENT", "short_name": "CSRI", "snr": -5.64, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1343, "long_name": "Shady Heron", "next_hop": 0, "num": "0x499a77e1", "position": {"altitude": 1343, "latitude": 32.903802, "location_source": "LOC_INTERNAL", "longitude": -107.965778, "time_offset_sec": 1500}, "public_key_hex": "e1155eabff5f262b3b10b41442b3246b7441b29bf27d227657e7d65ed5e2f322", "role": "CLIENT", "short_name": "S5G8", "snr": 1.57, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5201, "long_name": "Smooth Wolf", "next_hop": 0, "num": "0x49b4b80c", "position": null, "public_key_hex": "6e45a2d025a941ceb64dec1f03d90250f1d09da3e8bc0331fe12b87f1ebee6ab", "role": "CLIENT", "short_name": "SDXY", "snr": 11.78, "status": null, "telemetry": {"air_util_tx": 1.143, "battery_level": 32, "channel_utilization": 21.5, "uptime_seconds": 151886, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 2903, "long_name": "Forest Crow", "next_hop": 0, "num": "0x49bcb9c6", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "FXR2", "snr": 6.24, "status": null, "telemetry": {"air_util_tx": 1.443, "battery_level": 98, "channel_utilization": 5.13, "uptime_seconds": 118925, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.29, "iaq": 13, "relative_humidity": 36.32, "temperature": 21.34}, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 469, "long_name": "Frosty Colt", "next_hop": 0, "num": "0x49d04580", "position": null, "public_key_hex": "ae0051e683a1ba3ec3d8033bb8f7513fc7b8b1b609a8b92f7adeb62824c65fac", "role": "CLIENT_MUTE", "short_name": "FGNN", "snr": 2.96, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.493, "battery_level": 85, "channel_utilization": 12.94, "uptime_seconds": 99136, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAG", "last_heard_offset_sec": 8375, "long_name": "Wandering Owl", "next_hop": 0, "num": "0x49d26a29", "position": {"altitude": 1350, "latitude": 33.440418, "location_source": "LOC_INTERNAL", "longitude": -106.673157, "time_offset_sec": 8597}, "public_key_hex": "93af172d3e5889d88e89723d5d57270b5c3bb9362e1718dd0f1f6c59e5100232", "role": "CLIENT", "short_name": "W5ZN", "snr": 6.98, "status": null, "telemetry": {"air_util_tx": 1.033, "battery_level": 68, "channel_utilization": 36.97, "uptime_seconds": 125430, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 9922, "long_name": "Loud Hawk", "next_hop": 151, "num": "0x49ed0137", "position": {"altitude": 1330, "latitude": 33.222728, "location_source": "LOC_INTERNAL", "longitude": -106.542783, "time_offset_sec": 10090}, "public_key_hex": "", "role": "CLIENT", "short_name": "LYOP", "snr": 4.46, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4356, "long_name": "Sky Seal", "next_hop": 0, "num": "0x49f79ee0", "position": null, "public_key_hex": "d22f3761c883344cbb21d14c561aa2845b324d9c5b600ee8afc2c4c0034d6dab", "role": "CLIENT", "short_name": "SFIV", "snr": 4.72, "status": null, "telemetry": {"air_util_tx": 0.704, "battery_level": 40, "channel_utilization": 5.2, "uptime_seconds": 1327, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 6623, "long_name": "Soft Lynx", "next_hop": 0, "num": "0x49fb1426", "position": {"altitude": 1218, "latitude": 32.505028, "location_source": "LOC_INTERNAL", "longitude": -107.531869, "time_offset_sec": 6829}, "public_key_hex": "33ee16f0818b4845a9aabc798a0b53396925f3b15773cf9f6eb0cacb60a82273", "role": "CLIENT", "short_name": "S6KK", "snr": 1.8, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1755, "long_name": "Old Turtle", "next_hop": 0, "num": "0x4a068446", "position": {"altitude": 956, "latitude": 33.536828, "location_source": "LOC_INTERNAL", "longitude": -107.375782, "time_offset_sec": 1992}, "public_key_hex": "72500843e5f6018143cc137500fc8085b1e0bcf54cdf041ccb3e6cddaa3a6265", "role": "CLIENT", "short_name": "OC2S", "snr": 8.55, "status": null, "telemetry": {"air_util_tx": 0.836, "battery_level": 39, "channel_utilization": 19.43, "uptime_seconds": 107878, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 591, "long_name": "Lone Ridge", "next_hop": 218, "num": "0x4a22a4e9", "position": {"altitude": 1685, "latitude": 33.097208, "location_source": "LOC_INTERNAL", "longitude": -107.475856, "time_offset_sec": 884}, "public_key_hex": "4a563f2c34fd9cadb90142edb318151c5aa831d0ce346cc66b415645708cdc08", "role": "CLIENT", "short_name": "LQDB", "snr": 7.39, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.85, "battery_level": 67, "channel_utilization": 15.3, "uptime_seconds": 906, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 2274, "long_name": "Sharp Whale", "next_hop": 0, "num": "0x4a259f0a", "position": null, "public_key_hex": "6e89091f1a14852b0f413b2c2021404a09bb6b3f950a441b1ff968e4b82b1ba9", "role": "CLIENT", "short_name": "S46C", "snr": 6.52, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.362, "battery_level": 77, "channel_utilization": 20.15, "uptime_seconds": 24341, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4298, "long_name": "New Eagle", "next_hop": 150, "num": "0x4a28a887", "position": {"altitude": 1402, "latitude": 32.425474, "location_source": "LOC_INTERNAL", "longitude": -106.744278, "time_offset_sec": 4423}, "public_key_hex": "a7746dad62372b770b4a919a949b9ea454d85c79bf21e9f0bbdb3a912efdc21c", "role": "TAK", "short_name": "NXH6", "snr": 7.13, "status": null, "telemetry": {"air_util_tx": 0.761, "battery_level": 91, "channel_utilization": 25.25, "uptime_seconds": 34699, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1011.51, "iaq": 55, "relative_humidity": 44.58, "temperature": 21.76}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1321, "long_name": "Steel Mesa", "next_hop": 153, "num": "0x4a30e598", "position": {"altitude": 910, "latitude": 32.184141, "location_source": "LOC_INTERNAL", "longitude": -108.028299, "time_offset_sec": 1595}, "public_key_hex": "db872d418ce9b7f0b70cf1d0c56505f1ff8d15dd27d556f7443a4b2269be2351", "role": "CLIENT_HIDDEN", "short_name": "SIIS", "snr": 1.48, "status": null, "telemetry": {"air_util_tx": 0.529, "battery_level": 48, "channel_utilization": 15.94, "uptime_seconds": 39263, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.95, "iaq": 59, "relative_humidity": 49.3, "temperature": 16.17}, "hops_away": 3, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 6306, "long_name": "Desert Gecko", "next_hop": 248, "num": "0x4a31948b", "position": {"altitude": 1467, "latitude": 33.330137, "location_source": "LOC_INTERNAL", "longitude": -106.828593, "time_offset_sec": 6339}, "public_key_hex": "2a926664f4cbd9bd4df2211aff6591ed72d644f5a1c90efef83d332a95c5e3ef", "role": "CLIENT", "short_name": "D961", "snr": 1.1, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.305, "battery_level": 10, "channel_utilization": 12.0, "uptime_seconds": 30658, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 2342, "long_name": "Drowsy Crane", "next_hop": 70, "num": "0x4a552182", "position": {"altitude": 1275, "latitude": 33.230986, "location_source": "LOC_INTERNAL", "longitude": -107.264265, "time_offset_sec": 2554}, "public_key_hex": "02afe8f15f1ac9bee3567064e27bf877bc6737060be9ae4140640742d35b9d70", "role": "CLIENT", "short_name": "DYHU", "snr": 6.52, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.944, "battery_level": 71, "channel_utilization": 5.67, "uptime_seconds": 465898, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 250, "long_name": "Floating Whale", "next_hop": 0, "num": "0x4a558820", "position": null, "public_key_hex": "264571f31adae2157c972bc68d4d4f5ee108a161857bc15c13b462fb565304a0", "role": "CLIENT", "short_name": "FX1M", "snr": 1.85, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.401, "battery_level": 27, "channel_utilization": 23.35, "uptime_seconds": 26570, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 182, "long_name": "Lost Marmot", "next_hop": 74, "num": "0x4a71cefd", "position": {"altitude": 1670, "latitude": 33.164215, "location_source": "LOC_INTERNAL", "longitude": -107.391517, "time_offset_sec": 206}, "public_key_hex": "0aba3ffa72914f0423bc5513dced5475fcfa050d737d2cda2a63bf953647e31f", "role": "CLIENT", "short_name": "LOT8", "snr": -0.84, "status": null, "telemetry": {"air_util_tx": 0.195, "battery_level": 74, "channel_utilization": 3.72, "uptime_seconds": 44736, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.69, "iaq": 0, "relative_humidity": 55.82, "temperature": 19.05}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 4113, "long_name": "Lone Coyote", "next_hop": 135, "num": "0x4a7f1263", "position": {"altitude": 1531, "latitude": 33.658784, "location_source": "LOC_INTERNAL", "longitude": -107.308013, "time_offset_sec": 4360}, "public_key_hex": "dd01f33854309b3308bceba2f5a30ceadd4b44c25903cf4c2acdaad149e2583b", "role": "CLIENT_MUTE", "short_name": "LRZM", "snr": 5.68, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 367, "long_name": "Quick Cobra", "next_hop": 203, "num": "0x4a7f66b1", "position": {"altitude": 1218, "latitude": 33.618244, "location_source": "LOC_INTERNAL", "longitude": -106.648702, "time_offset_sec": 657}, "public_key_hex": "e564b490965f55c0ef52e235bab02b673e527cec9ee18dc1b9ec408f0953edf1", "role": "CLIENT", "short_name": "Q8RO", "snr": 8.57, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 202, "long_name": "Lone Arroyo", "next_hop": 0, "num": "0x4a80ccde", "position": {"altitude": 1294, "latitude": 32.123915, "location_source": "LOC_INTERNAL", "longitude": -107.298719, "time_offset_sec": 499}, "public_key_hex": "5527e148fcda0878539e015842d4e1a675fe4c4f9baed2048f4521c47e0093ee", "role": "CLIENT", "short_name": "LABX", "snr": 4.31, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.403, "battery_level": 35, "channel_utilization": 2.68, "uptime_seconds": 202253, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 264, "long_name": "Bright Moose", "next_hop": 0, "num": "0x4a81629f", "position": {"altitude": 1534, "latitude": 34.059524, "location_source": "LOC_INTERNAL", "longitude": -107.177646, "time_offset_sec": 312}, "public_key_hex": "8d4c7d2d740b2b1f0fc1ab780cbebda89892a10bee91b0f916cb3b9526b1a757", "role": "CLIENT", "short_name": "BPCH", "snr": 7.73, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.413, "battery_level": 82, "channel_utilization": 19.44, "uptime_seconds": 122508, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.37, "iaq": 32, "relative_humidity": 71.65, "temperature": 28.36}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1125, "long_name": "Sneaky Marmot", "next_hop": 0, "num": "0x4a912354", "position": {"altitude": 1759, "latitude": 32.767415, "location_source": "LOC_INTERNAL", "longitude": -107.513491, "time_offset_sec": 1239}, "public_key_hex": "5ab19a7980449a97f13111ff9aca8bbb550d70a4eae38ac0d6eaecfff5d0f06e", "role": "CLIENT", "short_name": "SYW3", "snr": 8.57, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.023, "battery_level": 51, "channel_utilization": 16.92, "uptime_seconds": 12930, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2897, "long_name": "Drifting Trout", "next_hop": 12, "num": "0x4a940ef4", "position": {"altitude": 1345, "latitude": 32.598989, "location_source": "LOC_INTERNAL", "longitude": -107.295679, "time_offset_sec": 2936}, "public_key_hex": "36c441a8280ee970d643a4a125649521995701d642ee66d8aa18dcf46334de9e", "role": "CLIENT", "short_name": "DQU6", "snr": 3.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1031.06, "iaq": 13, "relative_humidity": 41.69, "temperature": 19.29}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 555, "long_name": "Smooth Stag AB9AT", "next_hop": 0, "num": "0x4a9c3b95", "position": null, "public_key_hex": "f0830c8f4162df70cd6960a9316af3b6af22fd53b46e263f49d4024dcbdc32b0", "role": "CLIENT", "short_name": "SVCH", "snr": -3.84, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.555, "battery_level": 36, "channel_utilization": 8.64, "uptime_seconds": 84264, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2523, "long_name": "Smooth Hawk K17CB", "next_hop": 0, "num": "0x4aab2c1a", "position": {"altitude": 1170, "latitude": 33.794155, "location_source": "LOC_INTERNAL", "longitude": -107.353446, "time_offset_sec": 2783}, "public_key_hex": "5be4ff13f1c93ad566614c69a59e66245c551ce45bbc427e525eb9c54a219a31", "role": "CLIENT", "short_name": "SL9T", "snr": 1.8, "status": null, "telemetry": {"air_util_tx": 0.736, "battery_level": 36, "channel_utilization": 7.72, "uptime_seconds": 36478, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4622, "long_name": "Short Doe", "next_hop": 53, "num": "0x4aad094e", "position": {"altitude": 1427, "latitude": 33.107819, "location_source": "LOC_INTERNAL", "longitude": -107.382632, "time_offset_sec": 4641}, "public_key_hex": "c60a64168222efe897f348a4f0203258ad51b3ef7f925483b2c97ba50593d1aa", "role": "CLIENT", "short_name": "ST9M", "snr": 3.58, "status": null, "telemetry": {"air_util_tx": 0.963, "battery_level": 47, "channel_utilization": 15.29, "uptime_seconds": 27454, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.73, "iaq": 56, "relative_humidity": 55.16, "temperature": 6.58}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7687, "long_name": "Bright Crane", "next_hop": 0, "num": "0x4aae61e5", "position": {"altitude": 1290, "latitude": 33.248826, "location_source": "LOC_INTERNAL", "longitude": -106.730519, "time_offset_sec": 7872}, "public_key_hex": "7c124a67888218fd391196b4473674002eefe5c46f89768547d814b7a8da6554", "role": "CLIENT", "short_name": "🌲", "snr": 3.81, "status": null, "telemetry": {"air_util_tx": 0.199, "battery_level": 34, "channel_utilization": 20.38, "uptime_seconds": 89086, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 2502, "long_name": "Gold Arroyo", "next_hop": 228, "num": "0x4ac3973a", "position": {"altitude": 1799, "latitude": 32.405518, "location_source": "LOC_INTERNAL", "longitude": -107.447861, "time_offset_sec": 2605}, "public_key_hex": "5535fc017c13728e36ec1512e046da0fe9e0c4aa22a91d8b63b53eef97cbd151", "role": "SENSOR", "short_name": "GUMT", "snr": 6.69, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 640, "long_name": "Sharp Arroyo", "next_hop": 149, "num": "0x4ac9b37a", "position": null, "public_key_hex": "8be7d227bfe3fd4419dd850301145ecf10fd0fad42e79207b4d9b905f1c48253", "role": "ROUTER", "short_name": "SH92", "snr": 10.4, "status": null, "telemetry": {"air_util_tx": 1.164, "battery_level": 101, "channel_utilization": 13.91, "uptime_seconds": 83270, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1, "long_name": "Green Cedar", "next_hop": 0, "num": "0x4ad13f88", "position": {"altitude": 1635, "latitude": 33.018256, "location_source": "LOC_INTERNAL", "longitude": -107.179773, "time_offset_sec": 219}, "public_key_hex": "8b59ea03546d123ffd898bc579854cafff3fe4331692ddd1b72d551871eae7b5", "role": "CLIENT", "short_name": "G1F9", "snr": 8.48, "status": null, "telemetry": {"air_util_tx": 0.536, "battery_level": 101, "channel_utilization": 10.15, "uptime_seconds": 91274, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.16, "iaq": 0, "relative_humidity": 72.99, "temperature": 25.05}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4096, "long_name": "Sleepy Marmot", "next_hop": 0, "num": "0x4aec6875", "position": {"altitude": 957, "latitude": 33.708492, "location_source": "LOC_INTERNAL", "longitude": -107.079364, "time_offset_sec": 4116}, "public_key_hex": "47d976139cb81bf2befe39f6509f4b863c7895719ec4f3edb5d08dd6229e50de", "role": "CLIENT", "short_name": "SYD1", "snr": 8.02, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.266, "battery_level": 21, "channel_utilization": 10.8, "uptime_seconds": 23134, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2457, "long_name": "Giant Juniper", "next_hop": 0, "num": "0x4b0c3344", "position": {"altitude": 1685, "latitude": 33.638158, "location_source": "LOC_INTERNAL", "longitude": -108.107369, "time_offset_sec": 2745}, "public_key_hex": "badc8135639031495d1262ccbf8fdf61556858a780389b8ef146c28dcc9c6d33", "role": "ROUTER_LATE", "short_name": "GIBD", "snr": 5.39, "status": null, "telemetry": {"air_util_tx": 0.465, "battery_level": 63, "channel_utilization": 9.27, "uptime_seconds": 3620, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3212, "long_name": "Quick Cedar", "next_hop": 0, "num": "0x4b0d2d40", "position": {"altitude": 1214, "latitude": 33.305924, "location_source": "LOC_INTERNAL", "longitude": -106.744849, "time_offset_sec": 3400}, "public_key_hex": "9366876229f9e5b7f1d5e8340b769e132e3e039ed7c7b02ddf9bb84232c82005", "role": "CLIENT", "short_name": "🦂", "snr": 10.04, "status": null, "telemetry": {"air_util_tx": 0.617, "battery_level": 36, "channel_utilization": 19.46, "uptime_seconds": 4049, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 141, "long_name": "Giant Dolphin", "next_hop": 0, "num": "0x4b1bf8dc", "position": {"altitude": 1626, "latitude": 33.594468, "location_source": "LOC_INTERNAL", "longitude": -106.547172, "time_offset_sec": 203}, "public_key_hex": "22c9467e6b5754537dfac8aca858cdee1b5647aff96a44c4cde809634576c6c4", "role": "CLIENT", "short_name": "GQ7S", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7539, "long_name": "Happy Pine", "next_hop": 26, "num": "0x4b23a6e2", "position": {"altitude": 1310, "latitude": 33.425651, "location_source": "LOC_INTERNAL", "longitude": -107.263981, "time_offset_sec": 7759}, "public_key_hex": "fa256a39e67c531903ccdc7c45726d2f75f30d653286f3a525da44c3243890e2", "role": "CLIENT", "short_name": "HYD9", "snr": 3.92, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.91, "iaq": 64, "relative_humidity": 62.5, "temperature": 28.74}, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 8214, "long_name": "Sharp Trout W57GK", "next_hop": 95, "num": "0x4b2bdd96", "position": {"altitude": 1084, "latitude": 34.498481, "location_source": "LOC_INTERNAL", "longitude": -107.846633, "time_offset_sec": 8321}, "public_key_hex": "0cf43c2ca32ff95a15b0186b8c93894ff403bc144b7e46deb299ea6772a2d9e2", "role": "ROUTER_LATE", "short_name": "SZA5", "snr": 5.56, "status": null, "telemetry": {"air_util_tx": 0.067, "battery_level": 101, "channel_utilization": 9.5, "uptime_seconds": 66084, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3850, "long_name": "Wild Cobra", "next_hop": 28, "num": "0x4b2feb2d", "position": {"altitude": 1417, "latitude": 33.002456, "location_source": "LOC_INTERNAL", "longitude": -107.161616, "time_offset_sec": 3972}, "public_key_hex": "25edc8b32e8ffa0a507113c0ccbbbb1959c8f3e6548bc5c7ce75ae96e608617f", "role": "CLIENT", "short_name": "W0GC", "snr": 1.14, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3095, "long_name": "Stone Seal AB0SX", "next_hop": 15, "num": "0x4b4ff68b", "position": {"altitude": 1678, "latitude": 33.301185, "location_source": "LOC_INTERNAL", "longitude": -108.071108, "time_offset_sec": 3118}, "public_key_hex": "9daccc7db55f6a30b8b22f273c833f79036633847fc8918c0e5e67fa442fa67c", "role": "CLIENT", "short_name": "SRF4", "snr": 1.25, "status": null, "telemetry": {"air_util_tx": 0.651, "battery_level": 56, "channel_utilization": 8.17, "uptime_seconds": 43896, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 8586, "long_name": "Copper Lynx NM2TZ", "next_hop": 0, "num": "0x4b507e68", "position": {"altitude": 1399, "latitude": 33.186655, "location_source": "LOC_INTERNAL", "longitude": -107.404093, "time_offset_sec": 8616}, "public_key_hex": "d3a752b4446fba64c97006720b8895df97c72fba5115054492d456d4ac156e37", "role": "CLIENT", "short_name": "CKR4", "snr": 3.6, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 0.486, "battery_level": 101, "channel_utilization": 7.98, "uptime_seconds": 84444, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 8887, "long_name": "Brave Lynx", "next_hop": 48, "num": "0x4b7ae2eb", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "BL7I", "snr": 8.19, "status": null, "telemetry": {"air_util_tx": 0.774, "battery_level": 59, "channel_utilization": 6.86, "uptime_seconds": 6392, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 1732, "long_name": "Wandering Marmot", "next_hop": 217, "num": "0x4b82ddbb", "position": null, "public_key_hex": "616145b5a2cacd073ebb17502eb18d4baea515220ac72ec4530175970a06d983", "role": "CLIENT", "short_name": "WWHJ", "snr": 7.94, "status": null, "telemetry": {"air_util_tx": 0.753, "battery_level": 35, "channel_utilization": 19.29, "uptime_seconds": 77507, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 254, "long_name": "Copper Marmot", "next_hop": 0, "num": "0x4b89cf2f", "position": {"altitude": 1197, "latitude": 32.737817, "location_source": "LOC_INTERNAL", "longitude": -106.857068, "time_offset_sec": 554}, "public_key_hex": "feb1dd74133427ea5ff2f2fb72ff6850a550019601c3deb8ba8e8dfafc24d476", "role": "CLIENT", "short_name": "CYXF", "snr": 9.11, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.6, "iaq": 39, "relative_humidity": 42.25, "temperature": 21.76}, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 5007, "long_name": "Iron Shark", "next_hop": 129, "num": "0x4ba80fd3", "position": {"altitude": 1422, "latitude": 33.460938, "location_source": "LOC_INTERNAL", "longitude": -108.004445, "time_offset_sec": 5133}, "public_key_hex": "4970e83b5e23a27e61958b4d8c3de71d2d8fbccc6c43b0b8790e25f6540ce225", "role": "ROUTER", "short_name": "IR2Z", "snr": 7.27, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 1.292, "battery_level": 36, "channel_utilization": 9.88, "uptime_seconds": 31814, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 697, "long_name": "Whispering Sage", "next_hop": 137, "num": "0x4baa4d58", "position": {"altitude": 1725, "latitude": 32.761261, "location_source": "LOC_INTERNAL", "longitude": -106.790352, "time_offset_sec": 755}, "public_key_hex": "4f538ac5a94094aef817d1bfed0c91aad50720e0018052117893e0bd8b759066", "role": "CLIENT", "short_name": "WAOJ", "snr": 6.06, "status": null, "telemetry": {"air_util_tx": 0.615, "battery_level": 51, "channel_utilization": 10.31, "uptime_seconds": 64032, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": {"barometric_pressure": 1015.6, "iaq": 72, "relative_humidity": 45.93, "temperature": 16.52}, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 4047, "long_name": "Tall Squirrel KE1FE", "next_hop": 98, "num": "0x4bc1a62b", "position": {"altitude": 1151, "latitude": 33.154749, "location_source": "LOC_INTERNAL", "longitude": -108.027582, "time_offset_sec": 4085}, "public_key_hex": "868f4609a424df97767029eb771ad395b7304afcf272c9034e91d2db99bb1df4", "role": "CLIENT", "short_name": "🐢", "snr": 1.89, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.214, "battery_level": 34, "channel_utilization": 3.93, "uptime_seconds": 212731, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.41, "iaq": 60, "relative_humidity": 68.77, "temperature": 23.61}, "hops_away": 5, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 999, "long_name": "Misty Raven", "next_hop": 128, "num": "0x4bd003bd", "position": {"altitude": 1243, "latitude": 33.235968, "location_source": "LOC_INTERNAL", "longitude": -106.857992, "time_offset_sec": 1183}, "public_key_hex": "c1a5f61df4e3d2a4dd4b984fe3983f096a14bbf5d37e3c183cfeadec4e2b330d", "role": "CLIENT", "short_name": "M7XZ", "snr": 5.15, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2716, "long_name": "Happy Crane", "next_hop": 0, "num": "0x4bd0a59c", "position": {"altitude": 1301, "latitude": 33.303876, "location_source": "LOC_INTERNAL", "longitude": -107.918519, "time_offset_sec": 2859}, "public_key_hex": "b3a385d1a1f242c0cebd1b93acac3fdd58705fa43860c8ce80cd120aea692b6c", "role": "CLIENT", "short_name": "H2D7", "snr": 4.88, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 7534, "long_name": "Solar Beaver", "next_hop": 0, "num": "0x4bffd63a", "position": {"altitude": 1306, "latitude": 33.086949, "location_source": "LOC_INTERNAL", "longitude": -107.094987, "time_offset_sec": 7795}, "public_key_hex": "9f546259c31b64f029f41a9fd42c3d8640d7ef06f54ddf83e9fdeb2d09781c2d", "role": "CLIENT", "short_name": "SLXT", "snr": 0.9, "status": null, "telemetry": {"air_util_tx": 1.064, "battery_level": 30, "channel_utilization": 18.88, "uptime_seconds": 2445, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 17, "long_name": "Happy Coyote", "next_hop": 18, "num": "0x4c0300c0", "position": {"altitude": 1453, "latitude": 32.907046, "location_source": "LOC_INTERNAL", "longitude": -108.006461, "time_offset_sec": 77}, "public_key_hex": "230a815d97ac7e529ccf667f66d0d05fbf5fbdf307a2c3842f03ea8925d61ae1", "role": "TRACKER", "short_name": "🐝", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.767, "battery_level": 82, "channel_utilization": 6.73, "uptime_seconds": 42798, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4562, "long_name": "White Cedar", "next_hop": 0, "num": "0x4c17e00e", "position": null, "public_key_hex": "08d45114623e1357a8f092f212ed00518860993abe3cf10ee93f460d2bf8d1f7", "role": "CLIENT", "short_name": "WUYY", "snr": 9.27, "status": null, "telemetry": {"air_util_tx": 0.071, "battery_level": 55, "channel_utilization": 9.58, "uptime_seconds": 81556, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4791, "long_name": "Happy Pony", "next_hop": 0, "num": "0x4c3436de", "position": {"altitude": 992, "latitude": 33.826571, "location_source": "LOC_INTERNAL", "longitude": -107.886779, "time_offset_sec": 4933}, "public_key_hex": "12263a0358728ab2a1ba3075e58c1cf6726598b51c42cdf88e08820815d9f44d", "role": "CLIENT", "short_name": "🦇", "snr": 8.09, "status": null, "telemetry": {"air_util_tx": 0.783, "battery_level": 42, "channel_utilization": 17.38, "uptime_seconds": 193036, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.46, "iaq": 69, "relative_humidity": 47.29, "temperature": 24.3}, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3124, "long_name": "Whispering Pine", "next_hop": 16, "num": "0x4c750251", "position": {"altitude": 1395, "latitude": 33.500323, "location_source": "LOC_INTERNAL", "longitude": -107.665279, "time_offset_sec": 3128}, "public_key_hex": "ed99fce91c5c0f3a2495b7dd798ee583d3c094978c3aec9dee7c11998d4c5fa3", "role": "CLIENT", "short_name": "🦅", "snr": 6.37, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 7127, "long_name": "Bright Yucca", "next_hop": 146, "num": "0x4c80e1fd", "position": {"altitude": 1006, "latitude": 33.568345, "location_source": "LOC_INTERNAL", "longitude": -108.647292, "time_offset_sec": 7175}, "public_key_hex": "7a16ebc0229163936c1399bfb5bca7bf2d23a7896ed51590b987022f8cf42dfe", "role": "CLIENT", "short_name": "BPHF", "snr": 9.88, "status": null, "telemetry": {"air_util_tx": 0.409, "battery_level": 30, "channel_utilization": 4.83, "uptime_seconds": 38386, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.32, "iaq": 48, "relative_humidity": 87.25, "temperature": 30.02}, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2007, "long_name": "Sleepy Pony", "next_hop": 133, "num": "0x4c82374c", "position": {"altitude": 1095, "latitude": 32.805933, "location_source": "LOC_INTERNAL", "longitude": -107.19475, "time_offset_sec": 2205}, "public_key_hex": "90e85cb67b85b5fdc2ae9b4028e80494263e435ed0771c61601abb002fa82830", "role": "TAK", "short_name": "S9AT", "snr": 2.97, "status": null, "telemetry": {"air_util_tx": 0.372, "battery_level": 57, "channel_utilization": 4.59, "uptime_seconds": 69077, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 7330, "long_name": "Black Hawk", "next_hop": 182, "num": "0x4c8b9a11", "position": {"altitude": 1858, "latitude": 35.059064, "location_source": "LOC_INTERNAL", "longitude": -107.778234, "time_offset_sec": 7554}, "public_key_hex": "2d95779f1cdc38c7fc97757c8540512b1afbadfffab2d14c466d29aa1020912b", "role": "ROUTER", "short_name": "BVOV", "snr": 6.7, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 8427, "long_name": "Stone Bear", "next_hop": 0, "num": "0x4ca1927e", "position": null, "public_key_hex": "d6a39fe6f9d6b3496a2f78db21709cd3bba04f5364815b991e107de19f7a0bb5", "role": "CLIENT", "short_name": "SS81", "snr": 3.37, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.92, "iaq": 58, "relative_humidity": 52.17, "temperature": 10.52}, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2353, "long_name": "Silent Turtle", "next_hop": 93, "num": "0x4ca1ccb5", "position": {"altitude": 1193, "latitude": 32.929852, "location_source": "LOC_INTERNAL", "longitude": -106.875608, "time_offset_sec": 2424}, "public_key_hex": "7e1e10141a5d7b8f35ca87265c768cea854c6d57bb1d16e9a332e1d24232aa9d", "role": "CLIENT", "short_name": "SZY9", "snr": 8.52, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1028.13, "iaq": 35, "relative_humidity": 85.74, "temperature": 27.24}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4638, "long_name": "Sleepy Pike", "next_hop": 0, "num": "0x4ca4a492", "position": {"altitude": 1658, "latitude": 32.919772, "location_source": "LOC_INTERNAL", "longitude": -106.892347, "time_offset_sec": 4793}, "public_key_hex": "e99479b9524304dd9c11a67ee137245312d04442bd907db188ab5a8af99b8480", "role": "CLIENT", "short_name": "🦋", "snr": 4.11, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.04, "iaq": 45, "relative_humidity": 78.68, "temperature": 21.98}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1228, "long_name": "Steel Ridge", "next_hop": 115, "num": "0x4cd06a41", "position": {"altitude": 1599, "latitude": 33.980715, "location_source": "LOC_INTERNAL", "longitude": -107.522714, "time_offset_sec": 1237}, "public_key_hex": "4627e1674d8986e4e5378aa9cb2ddd2fbe1459b52bdd370209ed9c5061a59c04", "role": "CLIENT", "short_name": "SRZS", "snr": 11.01, "status": null, "telemetry": {"air_util_tx": 0.321, "battery_level": 27, "channel_utilization": 18.44, "uptime_seconds": 68731, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 9259, "long_name": "Lunar Owl", "next_hop": 49, "num": "0x4cd230bb", "position": {"altitude": 1434, "latitude": 33.034773, "location_source": "LOC_INTERNAL", "longitude": -107.572248, "time_offset_sec": 9436}, "public_key_hex": "", "role": "LOST_AND_FOUND", "short_name": "LQL3", "snr": 3.38, "status": {"status": "weak-signal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1013, "long_name": "Green Eagle", "next_hop": 164, "num": "0x4ce0d435", "position": {"altitude": 1729, "latitude": 32.100748, "location_source": "LOC_INTERNAL", "longitude": -106.457902, "time_offset_sec": 1111}, "public_key_hex": "d3fdb9f08a27a38825fee95efaadfbd4d276b166df23edc259ae1d3e6f0854ae", "role": "CLIENT", "short_name": "🐝", "snr": 3.73, "status": null, "telemetry": {"air_util_tx": 0.685, "battery_level": 72, "channel_utilization": 14.39, "uptime_seconds": 248114, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1298, "long_name": "Slow Badger", "next_hop": 254, "num": "0x4d14860d", "position": {"altitude": 1894, "latitude": 33.492609, "location_source": "LOC_INTERNAL", "longitude": -107.708778, "time_offset_sec": 1586}, "public_key_hex": "ea38c49bc87e64e36010decad3d70d58cfa80fbbc8cc08ff96a8f1eeb9cc142c", "role": "CLIENT", "short_name": "SF9H", "snr": 5.84, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 546, "long_name": "Sneaky Tortoise", "next_hop": 0, "num": "0x4d3f77fd", "position": {"altitude": 1350, "latitude": 33.184531, "location_source": "LOC_INTERNAL", "longitude": -107.69464, "time_offset_sec": 672}, "public_key_hex": "677de1178987a48a361bbb4603edc25a48241be9ca232c91411ff24a0d7a6ebd", "role": "CLIENT", "short_name": "S8R6", "snr": 5.62, "status": null, "telemetry": {"air_util_tx": 0.061, "battery_level": 47, "channel_utilization": 13.33, "uptime_seconds": 31257, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2174, "long_name": "Happy Bison", "next_hop": 0, "num": "0x4d829b77", "position": {"altitude": 1418, "latitude": 33.01483, "location_source": "LOC_INTERNAL", "longitude": -108.074296, "time_offset_sec": 2334}, "public_key_hex": "7d8443a31556ba873fd9a64773983ded26e06d786d58b2c467dcb7b835a0eb2e", "role": "CLIENT", "short_name": "HJ8X", "snr": 6.22, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.57, "battery_level": 38, "channel_utilization": 8.7, "uptime_seconds": 236810, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.87, "iaq": 34, "relative_humidity": 55.61, "temperature": 15.96}, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1673, "long_name": "Copper Hawk", "next_hop": 238, "num": "0x4d93ac70", "position": null, "public_key_hex": "540a435b7ebc87a056263aeebd12114ead02f9da5a6aae67bc56041e14c7b4f2", "role": "CLIENT", "short_name": "CFCL", "snr": 4.24, "status": null, "telemetry": {"air_util_tx": 0.711, "battery_level": 79, "channel_utilization": 12.37, "uptime_seconds": 11451, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 482, "long_name": "Brave Viper", "next_hop": 167, "num": "0x4daee7dd", "position": null, "public_key_hex": "71e89073b50b989bcc87dabb5dc5297af61eb2f915870b1e5292d35fbcbcbd96", "role": "CLIENT", "short_name": "B0K6", "snr": 10.9, "status": null, "telemetry": {"air_util_tx": 0.996, "battery_level": 17, "channel_utilization": 7.84, "uptime_seconds": 249669, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.6, "iaq": 0, "relative_humidity": 86.79, "temperature": 8.43}, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 2050, "long_name": "Desert Bronco", "next_hop": 180, "num": "0x4dd31906", "position": {"altitude": 1273, "latitude": 32.295091, "location_source": "LOC_INTERNAL", "longitude": -106.978265, "time_offset_sec": 2197}, "public_key_hex": "fef2421771fd04aafc211081bf6a80c19672e42ff56740af9a8ea5350e4fb5da", "role": "CLIENT", "short_name": "DUJH", "snr": 4.32, "status": null, "telemetry": {"air_util_tx": 1.312, "battery_level": 97, "channel_utilization": 5.11, "uptime_seconds": 264043, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 134, "long_name": "Whispering Hare", "next_hop": 0, "num": "0x4df1f937", "position": {"altitude": 1413, "latitude": 33.279367, "location_source": "LOC_INTERNAL", "longitude": -106.990757, "time_offset_sec": 339}, "public_key_hex": "ec5f85fbd3f0c81c81c16893e234563cb681ca043016b3fa8818d2388c4f5a6e", "role": "CLIENT", "short_name": "WV80", "snr": 1.38, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3251, "long_name": "Dawn Adder", "next_hop": 247, "num": "0x4e08bb71", "position": {"altitude": 1520, "latitude": 33.48487, "location_source": "LOC_INTERNAL", "longitude": -106.759341, "time_offset_sec": 3443}, "public_key_hex": "06a946b1d728c87a3ee9ff3370328b09f1811c3fb50085035f2e4f84858b1a9c", "role": "TRACKER", "short_name": "DFXI", "snr": 4.56, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.844, "battery_level": 14, "channel_utilization": 9.26, "uptime_seconds": 266939, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK3312", "last_heard_offset_sec": 7732, "long_name": "Frozen Eagle", "next_hop": 21, "num": "0x4e12512b", "position": {"altitude": 1049, "latitude": 33.084635, "location_source": "LOC_INTERNAL", "longitude": -108.047947, "time_offset_sec": 7963}, "public_key_hex": "f7d9982e375b9dbdac1b8b8fd01d8ec9675c3e98befe0062d1abd9227e1c65c9", "role": "CLIENT", "short_name": "F3YI", "snr": 4.7, "status": null, "telemetry": {"air_util_tx": 0.748, "battery_level": 39, "channel_utilization": 5.68, "uptime_seconds": 11279, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.41, "iaq": 70, "relative_humidity": 67.52, "temperature": 27.92}, "hops_away": 2, "hw_model": "WISMESH_TAG", "last_heard_offset_sec": 9881, "long_name": "Smooth Aspen", "next_hop": 132, "num": "0x4e12f5c6", "position": {"altitude": 1154, "latitude": 33.040253, "location_source": "LOC_INTERNAL", "longitude": -106.811354, "time_offset_sec": 10136}, "public_key_hex": "e751b954c2cea0253c4412d105b01e3d34c2114ba1e1c286ac89089b0c2dbe82", "role": "CLIENT", "short_name": "SWVP", "snr": 8.36, "status": null, "telemetry": {"air_util_tx": 0.288, "battery_level": 21, "channel_utilization": 23.69, "uptime_seconds": 22488, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7297, "long_name": "River Arroyo", "next_hop": 0, "num": "0x4e182d3e", "position": {"altitude": 1539, "latitude": 32.77367, "location_source": "LOC_INTERNAL", "longitude": -106.665929, "time_offset_sec": 7568}, "public_key_hex": "b1e3d684c86696b7ae52f200939cf967dec78869dd313337c51b42cef9fac8b2", "role": "CLIENT", "short_name": "R4A9", "snr": 5.53, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 838, "long_name": "Brave Trout", "next_hop": 0, "num": "0x4e3cfca1", "position": {"altitude": 1610, "latitude": 33.339005, "location_source": "LOC_INTERNAL", "longitude": -106.658865, "time_offset_sec": 1005}, "public_key_hex": "2b32790dd01b4031322972faa25f5ab0611454f8b08941337dee186329afa7ca", "role": "CLIENT", "short_name": "🌙", "snr": 9.24, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.507, "battery_level": 59, "channel_utilization": 5.02, "uptime_seconds": 17102, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 429, "long_name": "Found Salmon", "next_hop": 0, "num": "0x4e707e0e", "position": {"altitude": 1095, "latitude": 32.68255, "location_source": "LOC_INTERNAL", "longitude": -107.974876, "time_offset_sec": 478}, "public_key_hex": "64ea6f6e6d7262ecb598f8b9e62284d235d6d8a26061aae1b102399319598b9f", "role": "CLIENT", "short_name": "FOYE", "snr": 6.94, "status": null, "telemetry": {"air_util_tx": 1.07, "battery_level": 47, "channel_utilization": 7.31, "uptime_seconds": 295588, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.32, "iaq": 31, "relative_humidity": 24.57, "temperature": 24.81}, "hops_away": 3, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 3289, "long_name": "Solar Mamba", "next_hop": 82, "num": "0x4e7f63e3", "position": {"altitude": 1328, "latitude": 32.830382, "location_source": "LOC_INTERNAL", "longitude": -108.098563, "time_offset_sec": 3546}, "public_key_hex": "0c09519a98d3ba8e62a97965a61286e2044aca677fc625f819ecec4eb46accde", "role": "CLIENT", "short_name": "SKTE", "snr": 9.35, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 16754, "long_name": "Floating Mesa", "next_hop": 253, "num": "0x4e94acf0", "position": {"altitude": 1249, "latitude": 33.434865, "location_source": "LOC_INTERNAL", "longitude": -107.16166, "time_offset_sec": 16984}, "public_key_hex": "e0635e66f7a9a064bd62adfa79b403cc46034a9e7a38c1423d3e847ed439f93d", "role": "CLIENT", "short_name": "FNQV", "snr": 3.52, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.357, "battery_level": 25, "channel_utilization": 3.88, "uptime_seconds": 23103, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2498, "long_name": "Dawn Yucca", "next_hop": 46, "num": "0x4ea5c847", "position": null, "public_key_hex": "5a719dc6c7133611e8afa7aa1531aaf1a5052ef54749a5b1cee54e9cc16a18dd", "role": "CLIENT", "short_name": "D5HN", "snr": 5.09, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 85, "long_name": "Loud Pike", "next_hop": 0, "num": "0x4ec9da52", "position": {"altitude": 1567, "latitude": 32.748747, "location_source": "LOC_INTERNAL", "longitude": -108.02885, "time_offset_sec": 306}, "public_key_hex": "06a640c906ba0f76fa0660d4a96525d925e515aa69019f78de4f59b52f682b44", "role": "CLIENT", "short_name": "LL30", "snr": 8.57, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.411, "battery_level": 53, "channel_utilization": 11.73, "uptime_seconds": 19175, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 499, "long_name": "Shady Bass", "next_hop": 139, "num": "0x4ecb5eb5", "position": {"altitude": 1398, "latitude": 34.236783, "location_source": "LOC_INTERNAL", "longitude": -107.278338, "time_offset_sec": 564}, "public_key_hex": "5b02833939f94d18820edd135e1e0f22d9a85a529ff54a3ce376dc9774ede927", "role": "CLIENT", "short_name": "SWRU", "snr": 2.95, "status": null, "telemetry": {"air_util_tx": 0.618, "battery_level": 88, "channel_utilization": 16.82, "uptime_seconds": 77762, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": {"barometric_pressure": 1008.59, "iaq": 22, "relative_humidity": 52.84, "temperature": 31.88}, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 81, "long_name": "New Tortoise", "next_hop": 50, "num": "0x4ecffca9", "position": {"altitude": 1705, "latitude": 32.815876, "location_source": "LOC_INTERNAL", "longitude": -106.856917, "time_offset_sec": 350}, "public_key_hex": "e2c83b74958e0cb9cdf74031fe201af77ba05d1b86ddb90cbeb6df3b9301cc57", "role": "CLIENT", "short_name": "N06P", "snr": -2.1, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.93, "iaq": 69, "relative_humidity": 58.43, "temperature": 20.19}, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 857, "long_name": "Green Moose", "next_hop": 7, "num": "0x4ede718b", "position": {"altitude": 1082, "latitude": 33.540215, "location_source": "LOC_INTERNAL", "longitude": -108.542384, "time_offset_sec": 980}, "public_key_hex": "192e3d08408db404ff7f87b4572e8d79975911e26c86c0feeff16e02c0d2976a", "role": "ROUTER", "short_name": "GEJV", "snr": 6.33, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.821, "battery_level": 65, "channel_utilization": 24.18, "uptime_seconds": 12693, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6184, "long_name": "Solar Whale", "next_hop": 24, "num": "0x4ef7bc1c", "position": null, "public_key_hex": "fbdab9ab560a5106f8d61d8c1dd84f93369ddc2e6d896a644bdc21276554dafb", "role": "CLIENT_BASE", "short_name": "SWXL", "snr": 5.45, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 1.056, "battery_level": 27, "channel_utilization": 5.69, "uptime_seconds": 687, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.14, "iaq": 38, "relative_humidity": 62.18, "temperature": 27.24}, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 3734, "long_name": "Forest Aspen", "next_hop": 0, "num": "0x4f0d34fc", "position": {"altitude": 1722, "latitude": 32.754461, "location_source": "LOC_INTERNAL", "longitude": -107.866977, "time_offset_sec": 4017}, "public_key_hex": "09c5af45145d3a83df5b59880c7a15fe9f7635d3c7893877fe0ddbc0a309ff32", "role": "ROUTER", "short_name": "F97H", "snr": 11.06, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.612, "battery_level": 59, "channel_utilization": 11.72, "uptime_seconds": 20385, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 2942, "long_name": "White Fox KE3IP", "next_hop": 0, "num": "0x4f13957b", "position": {"altitude": 1200, "latitude": 33.634938, "location_source": "LOC_INTERNAL", "longitude": -106.949341, "time_offset_sec": 3112}, "public_key_hex": "a0c8ffc53f14cfcea285edff632d4c263da2bbbdbddb5e235250b10b5e94d828", "role": "CLIENT", "short_name": "W0QC", "snr": 3.2, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 2931, "long_name": "Wandering Iguana", "next_hop": 91, "num": "0x4f35c6d6", "position": {"altitude": 1431, "latitude": 33.050212, "location_source": "LOC_INTERNAL", "longitude": -107.08682, "time_offset_sec": 3205}, "public_key_hex": "fa5e9bee858678e65305c5f9cb3e35d2877887a605c68511415c65778662b6fd", "role": "CLIENT", "short_name": "WM1G", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.048, "battery_level": 19, "channel_utilization": 9.69, "uptime_seconds": 34387, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 5, "environment": {"barometric_pressure": 1013.53, "iaq": 42, "relative_humidity": 37.21, "temperature": 32.73}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1310, "long_name": "Lunar Heron", "next_hop": 0, "num": "0x4f3ab6aa", "position": {"altitude": 1498, "latitude": 32.898594, "location_source": "LOC_INTERNAL", "longitude": -107.051032, "time_offset_sec": 1509}, "public_key_hex": "ed2dc3764881bd813c678a3dfc4009c87a961b5942bf4c0a2b993ac2d4cf7f4f", "role": "TRACKER", "short_name": "L1XW", "snr": 2.16, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 66, "long_name": "Silent Gecko", "next_hop": 162, "num": "0x4f41c7f5", "position": {"altitude": 1773, "latitude": 33.589695, "location_source": "LOC_INTERNAL", "longitude": -107.477723, "time_offset_sec": 160}, "public_key_hex": "d017f5bdf745e11a2959fcf4a8c82de14c252af4dd08d3fe6f7baeee0fed6ce4", "role": "ROUTER", "short_name": "🐝", "snr": 7.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 5433, "long_name": "Smooth Wolf", "next_hop": 120, "num": "0x4f46b608", "position": {"altitude": 1359, "latitude": 33.354776, "location_source": "LOC_INTERNAL", "longitude": -108.217749, "time_offset_sec": 5506}, "public_key_hex": "97b959135ad4841cd898e841b368b7fa0c2da6ef47846792bbda66392c5e4d02", "role": "CLIENT", "short_name": "SODR", "snr": 9.84, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1105, "long_name": "Floating Lynx", "next_hop": 0, "num": "0x4f63f3c3", "position": {"altitude": 1323, "latitude": 33.500842, "location_source": "LOC_INTERNAL", "longitude": -107.389757, "time_offset_sec": 1296}, "public_key_hex": "af12e96e64c80f6e6367ec988aa0c0b0aeda4c8289542c324136b76dbe56af90", "role": "CLIENT_BASE", "short_name": "FUHO", "snr": 6.48, "status": null, "telemetry": {"air_util_tx": 0.769, "battery_level": 46, "channel_utilization": 1.53, "uptime_seconds": 104571, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.63, "iaq": 0, "relative_humidity": 57.19, "temperature": 32.13}, "hops_away": 2, "hw_model": "M5STACK_C6L", "last_heard_offset_sec": 2162, "long_name": "Shady Tortoise KE7YN", "next_hop": 246, "num": "0x4f810f7f", "position": {"altitude": 1623, "latitude": 33.000889, "location_source": "LOC_INTERNAL", "longitude": -107.437816, "time_offset_sec": 2346}, "public_key_hex": "79034fab190a5b4e06190f497acae029a32c21a5fa2f4747ad9c2bba27849f48", "role": "SENSOR", "short_name": "SRAR", "snr": 0.13, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.081, "battery_level": 81, "channel_utilization": 5.09, "uptime_seconds": 87782, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 656, "long_name": "White Heron", "next_hop": 38, "num": "0x4f846486", "position": {"altitude": 1332, "latitude": 33.002879, "location_source": "LOC_INTERNAL", "longitude": -108.268873, "time_offset_sec": 779}, "public_key_hex": "1d44df1a21327dc0a959874b80a053fcf4d428e35a1a9643d7abea22e3757da7", "role": "CLIENT", "short_name": "WCI8", "snr": 8.63, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.008, "battery_level": 13, "channel_utilization": 7.98, "uptime_seconds": 116595, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1996, "long_name": "Steel Bluff", "next_hop": 0, "num": "0x4f8822d6", "position": {"altitude": 1080, "latitude": 32.750836, "location_source": "LOC_INTERNAL", "longitude": -106.943161, "time_offset_sec": 2045}, "public_key_hex": "4356ddf52df23de6113bbeac2cfd63b960599dedc59485dcfdde49356bab58c8", "role": "CLIENT", "short_name": "S13O", "snr": 0.61, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1009.95, "iaq": 47, "relative_humidity": 57.21, "temperature": 29.32}, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 1889, "long_name": "Black Pony", "next_hop": 154, "num": "0x4fa70d71", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "BYQR", "snr": 2.5, "status": null, "telemetry": {"air_util_tx": 0.324, "battery_level": 18, "channel_utilization": 23.82, "uptime_seconds": 3449, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.18, "iaq": 32, "relative_humidity": 15.56, "temperature": 25.23}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7926, "long_name": "Shady Beaver", "next_hop": 56, "num": "0x4fb076ac", "position": null, "public_key_hex": "8c664206a166e45e6772916cb470c71be72133f62c5133c8165373257b9bec74", "role": "CLIENT", "short_name": "SR1B", "snr": 5.02, "status": null, "telemetry": {"air_util_tx": 0.655, "battery_level": 60, "channel_utilization": 19.18, "uptime_seconds": 26225, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.99, "iaq": 84, "relative_humidity": 56.58, "temperature": 11.41}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 336, "long_name": "Dusk Juniper", "next_hop": 0, "num": "0x4fd37910", "position": {"altitude": 1094, "latitude": 32.930004, "location_source": "LOC_INTERNAL", "longitude": -107.807932, "time_offset_sec": 457}, "public_key_hex": "cb0746a8f13f51d415b1423e6af72d57092b4c3dff91a1312788618449fb781c", "role": "TRACKER", "short_name": "D95U", "snr": 2.57, "status": null, "telemetry": {"air_util_tx": 0.169, "battery_level": 68, "channel_utilization": 10.85, "uptime_seconds": 52856, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.53, "iaq": 0, "relative_humidity": 33.57, "temperature": 27.16}, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 1464, "long_name": "Lone Pony", "next_hop": 110, "num": "0x5014f381", "position": {"altitude": 1562, "latitude": 33.743698, "location_source": "LOC_INTERNAL", "longitude": -108.421075, "time_offset_sec": 1515}, "public_key_hex": "1d5f431ae330934eba75b4ea05326d03150e1fd1387b7cb9c3901961034c91cc", "role": "CLIENT", "short_name": "LF9F", "snr": 7.73, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.505, "battery_level": 19, "channel_utilization": 8.17, "uptime_seconds": 125921, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1662, "long_name": "Stone Lion", "next_hop": 80, "num": "0x502a974b", "position": {"altitude": 1712, "latitude": 33.931809, "location_source": "LOC_INTERNAL", "longitude": -107.568033, "time_offset_sec": 1677}, "public_key_hex": "e79594d6255d9f8aa1aaf5420fe3b038c3756366d4c9f520e9d6205f0c5940a2", "role": "CLIENT", "short_name": "S17T", "snr": 10.02, "status": null, "telemetry": {"air_util_tx": 1.393, "battery_level": 68, "channel_utilization": 18.66, "uptime_seconds": 34473, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 29336, "long_name": "Forest Arroyo", "next_hop": 0, "num": "0x502ee82c", "position": null, "public_key_hex": "eb2f56999b666c59d0e22071fc9129a8d9b3a1370ceb5289f5cdd884cd5d364d", "role": "CLIENT", "short_name": "FV1K", "snr": 5.03, "status": null, "telemetry": {"air_util_tx": 0.988, "battery_level": 60, "channel_utilization": 10.27, "uptime_seconds": 43555, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 12152, "long_name": "Floating Crane", "next_hop": 134, "num": "0x503aebad", "position": {"altitude": 1237, "latitude": 32.951691, "location_source": "LOC_INTERNAL", "longitude": -107.606057, "time_offset_sec": 12379}, "public_key_hex": "6543175740d184ec7cb817859eda729ef487cd9d970aae4b612546d5e8d2b256", "role": "CLIENT", "short_name": "FFUP", "snr": 3.33, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.35, "iaq": 16, "relative_humidity": 63.81, "temperature": 25.72}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 223, "long_name": "Silent Colt", "next_hop": 108, "num": "0x504642b2", "position": {"altitude": 1204, "latitude": 32.67463, "location_source": "LOC_INTERNAL", "longitude": -107.087638, "time_offset_sec": 471}, "public_key_hex": "dfdf158f749988190a87c58b695ccdd5a1567199e01ba5ec3244b9593070ae4a", "role": "CLIENT", "short_name": "S65O", "snr": 3.6, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.543, "battery_level": 47, "channel_utilization": 20.51, "uptime_seconds": 186340, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 1977, "long_name": "Copper Mesa", "next_hop": 130, "num": "0x50967ec8", "position": {"altitude": 1263, "latitude": 33.3743, "location_source": "LOC_INTERNAL", "longitude": -107.108698, "time_offset_sec": 2030}, "public_key_hex": "8a23fa1fce6f3e2b44356fbfcb2e2f7f9112d5ac8cdfd62c4bf0bc0d3c828e42", "role": "CLIENT", "short_name": "CEZG", "snr": 5.4, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.011, "battery_level": 41, "channel_utilization": 20.15, "uptime_seconds": 123486, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 1935, "long_name": "Old Hare", "next_hop": 15, "num": "0x50a06eea", "position": {"altitude": 1913, "latitude": 34.535214, "location_source": "LOC_INTERNAL", "longitude": -107.294586, "time_offset_sec": 2216}, "public_key_hex": "94117dba7012723cd998994ff828c276375b6af00befd4aab0faa84f8a8304f7", "role": "ROUTER", "short_name": "OHPW", "snr": 11.18, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.458, "battery_level": 35, "channel_utilization": 1.28, "uptime_seconds": 41470, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3035, "long_name": "Iron Bear", "next_hop": 0, "num": "0x50b08207", "position": {"altitude": 1550, "latitude": 33.335447, "location_source": "LOC_INTERNAL", "longitude": -107.689083, "time_offset_sec": 3078}, "public_key_hex": "8be164535e1b3f79fce141d3d08cbc7d08e784fc7b0a9372c7aca9bfbc2f92e2", "role": "CLIENT", "short_name": "ITRL", "snr": 5.19, "status": null, "telemetry": {"air_util_tx": 0.341, "battery_level": 82, "channel_utilization": 7.29, "uptime_seconds": 154919, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1977, "long_name": "Hidden Aspen", "next_hop": 0, "num": "0x50b9faf0", "position": {"altitude": 1205, "latitude": 34.064397, "location_source": "LOC_INTERNAL", "longitude": -107.407965, "time_offset_sec": 2250}, "public_key_hex": "23549e2e3f70c24c19f7164f2b46fb7909fe0df38f8aea9296effc845285ae14", "role": "ROUTER_LATE", "short_name": "H2LI", "snr": 3.55, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1964, "long_name": "Burning Squirrel", "next_hop": 0, "num": "0x50bd89af", "position": {"altitude": 1125, "latitude": 33.742319, "location_source": "LOC_INTERNAL", "longitude": -107.134554, "time_offset_sec": 2142}, "public_key_hex": "", "role": "CLIENT", "short_name": "B8E8", "snr": 1.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 6162, "long_name": "Tiny Juniper", "next_hop": 192, "num": "0x50cf0cd0", "position": {"altitude": 1539, "latitude": 33.157586, "location_source": "LOC_INTERNAL", "longitude": -106.841997, "time_offset_sec": 6436}, "public_key_hex": "69f4cc5960d7de55b6620068a54c58507be896af08389edf69b111a9da3aaf43", "role": "CLIENT", "short_name": "TBAH", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.417, "battery_level": 60, "channel_utilization": 8.48, "uptime_seconds": 8887, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 10768, "long_name": "Drowsy Pony", "next_hop": 212, "num": "0x50e1dd95", "position": null, "public_key_hex": "5747b67c15cc9cf5b6982874433207ca51c37456bb2fafa2dd36793387042b97", "role": "CLIENT", "short_name": "D5KB", "snr": 10.92, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.284, "battery_level": 82, "channel_utilization": 15.63, "uptime_seconds": 44807, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.74, "iaq": 39, "relative_humidity": 58.57, "temperature": 26.44}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 1771, "long_name": "Sunny Shark", "next_hop": 0, "num": "0x50e215fa", "position": {"altitude": 1061, "latitude": 33.061084, "location_source": "LOC_INTERNAL", "longitude": -107.043681, "time_offset_sec": 1976}, "public_key_hex": "d9201d8e4a3061f26a5cb29f7cc0250fb28d6026b2927411826a1e7c5fd4e8ff", "role": "CLIENT", "short_name": "SOTD", "snr": 5.86, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.019, "battery_level": 49, "channel_utilization": 1.89, "uptime_seconds": 6898, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 10731, "long_name": "Sky Cactus", "next_hop": 0, "num": "0x50e50400", "position": {"altitude": 1676, "latitude": 32.328163, "location_source": "LOC_INTERNAL", "longitude": -107.474272, "time_offset_sec": 10993}, "public_key_hex": "86ecec634fd57d453be83715a53ea44f2a443c6f0d8e95ec00b5c13baaeb9b93", "role": "CLIENT", "short_name": "SYEU", "snr": 5.24, "status": null, "telemetry": {"air_util_tx": 0.308, "battery_level": 27, "channel_utilization": 19.55, "uptime_seconds": 26536, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 1539, "long_name": "New Hare", "next_hop": 107, "num": "0x50fcbc02", "position": {"altitude": 1250, "latitude": 33.129344, "location_source": "LOC_INTERNAL", "longitude": -107.334432, "time_offset_sec": 1576}, "public_key_hex": "085b83ebcbe7145255163188f394deda8d3ca44a8bcf3a0b60146462d84c4ac4", "role": "CLIENT", "short_name": "NGLM", "snr": 7.75, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.917, "battery_level": 43, "channel_utilization": 5.89, "uptime_seconds": 7736, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.98, "iaq": 83, "relative_humidity": 64.18, "temperature": 28.47}, "hops_away": 0, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 9329, "long_name": "Roving Whale", "next_hop": 0, "num": "0x50fdab43", "position": {"altitude": 1389, "latitude": 32.999004, "location_source": "LOC_INTERNAL", "longitude": -106.557067, "time_offset_sec": 9546}, "public_key_hex": "496c74a590150f132fa74b1b8c3786e0393cc863f97372463c6f75ca4aea6345", "role": "TAK", "short_name": "R6O7", "snr": 4.2, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 62, "long_name": "Lunar Bass", "next_hop": 60, "num": "0x511ee594", "position": {"altitude": 1029, "latitude": 33.477974, "location_source": "LOC_INTERNAL", "longitude": -106.831208, "time_offset_sec": 188}, "public_key_hex": "", "role": "CLIENT", "short_name": "L0VP", "snr": 9.21, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.594, "battery_level": 88, "channel_utilization": 4.38, "uptime_seconds": 37997, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 9524, "long_name": "Short Oak", "next_hop": 0, "num": "0x51247362", "position": {"altitude": 1116, "latitude": 31.82744, "location_source": "LOC_INTERNAL", "longitude": -106.829136, "time_offset_sec": 9690}, "public_key_hex": "3c77d19ba717bb10cb15c7a45bcd53ebf1171ce00182422afcf82fc8d5c7f772", "role": "CLIENT", "short_name": "S824", "snr": 5.03, "status": null, "telemetry": {"air_util_tx": 0.878, "battery_level": 29, "channel_utilization": 19.78, "uptime_seconds": 44744, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.96, "iaq": 23, "relative_humidity": 67.7, "temperature": 28.06}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 914, "long_name": "Lunar Doe", "next_hop": 0, "num": "0x512a01a5", "position": null, "public_key_hex": "d105f01fbf5aa0fdc58905fbf871a70c9f4941e0a9d9b41692cb69989ca6ebe5", "role": "CLIENT", "short_name": "LGG5", "snr": 7.89, "status": null, "telemetry": {"air_util_tx": 0.342, "battery_level": 92, "channel_utilization": 3.54, "uptime_seconds": 3162, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.39, "iaq": 64, "relative_humidity": 84.14, "temperature": 22.95}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4251, "long_name": "Iron Phoenix", "next_hop": 106, "num": "0x515bacb2", "position": {"altitude": 1407, "latitude": 32.727851, "location_source": "LOC_INTERNAL", "longitude": -107.234717, "time_offset_sec": 4307}, "public_key_hex": "993094a5aff4d84111f38b9ef1f7a6a0b642ba5b12b6ba4cbf6b16ce569b8563", "role": "CLIENT", "short_name": "I60R", "snr": 8.72, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7817, "long_name": "Frozen Hawk", "next_hop": 37, "num": "0x515c105f", "position": {"altitude": 1688, "latitude": 33.618418, "location_source": "LOC_INTERNAL", "longitude": -107.669781, "time_offset_sec": 8087}, "public_key_hex": "674ac42e3ecd4970fa42ded8d8fb3b300577d4d6e24c97a04882316bf60039d0", "role": "CLIENT", "short_name": "F46Z", "snr": 6.68, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.152, "battery_level": 37, "channel_utilization": 7.13, "uptime_seconds": 59041, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 617, "long_name": "Mountain Turtle", "next_hop": 0, "num": "0x516c2a11", "position": {"altitude": 1505, "latitude": 33.6245, "location_source": "LOC_INTERNAL", "longitude": -107.403835, "time_offset_sec": 760}, "public_key_hex": "b3915c6feb6d38a36854ec69c8cd5428690d929f3b7048f6a1b98d223bee676e", "role": "CLIENT", "short_name": "🔥", "snr": 3.59, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.0, "battery_level": 47, "channel_utilization": 10.36, "uptime_seconds": 109993, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 675, "long_name": "Rough Lion", "next_hop": 165, "num": "0x516c4894", "position": {"altitude": 1621, "latitude": 33.74993, "location_source": "LOC_INTERNAL", "longitude": -108.009191, "time_offset_sec": 769}, "public_key_hex": "a8161755e0f7b84f37f3ca47debf0502b202e552e38df545cacf112bdfbd97af", "role": "CLIENT", "short_name": "RPA4", "snr": 11.38, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 145, "long_name": "Iron Bison", "next_hop": 0, "num": "0x5171d266", "position": {"altitude": 1281, "latitude": 33.016838, "location_source": "LOC_INTERNAL", "longitude": -107.049625, "time_offset_sec": 434}, "public_key_hex": "ec85656d378aa634b154a6ce882dcfcb3e4688d60e58cba751504ef7f3909599", "role": "CLIENT_BASE", "short_name": "IXPB", "snr": 6.65, "status": null, "telemetry": {"air_util_tx": 0.211, "battery_level": 90, "channel_utilization": 42.26, "uptime_seconds": 164410, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 13851, "long_name": "Copper Ridge", "next_hop": 233, "num": "0x5172c41a", "position": {"altitude": 1197, "latitude": 33.358993, "location_source": "LOC_INTERNAL", "longitude": -107.006177, "time_offset_sec": 14142}, "public_key_hex": "8abc6ebffac4ef775ecfbbdcc8502bde6478466c5a7308046b644c3a9f609c7e", "role": "CLIENT", "short_name": "CDCX", "snr": 9.22, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.366, "battery_level": 68, "channel_utilization": 20.71, "uptime_seconds": 5671, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 492, "long_name": "Frosty Dolphin", "next_hop": 0, "num": "0x51790490", "position": {"altitude": 1425, "latitude": 33.491552, "location_source": "LOC_INTERNAL", "longitude": -108.496007, "time_offset_sec": 599}, "public_key_hex": "d7fdfe87dcf87bbd19085c071b96fb1399d505cc7c7cff65393fe516c65e42f3", "role": "CLIENT", "short_name": "F9SF", "snr": 5.3, "status": null, "telemetry": {"air_util_tx": 0.346, "battery_level": 89, "channel_utilization": 5.76, "uptime_seconds": 18503, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 1367, "long_name": "Wandering Oak", "next_hop": 0, "num": "0x51920c3b", "position": {"altitude": 1450, "latitude": 33.347535, "location_source": "LOC_INTERNAL", "longitude": -107.528659, "time_offset_sec": 1557}, "public_key_hex": "e56f5f5429fc955fd76a9602e963f0ad4e2e221a0faa2bd7a3a7cf450f78d596", "role": "CLIENT", "short_name": "WXL5", "snr": 4.06, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.015, "battery_level": 101, "channel_utilization": 0.72, "uptime_seconds": 221318, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.86, "iaq": 35, "relative_humidity": 60.6, "temperature": 15.39}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 9477, "long_name": "Gold Pine", "next_hop": 0, "num": "0x519282f7", "position": {"altitude": 1183, "latitude": 32.940631, "location_source": "LOC_INTERNAL", "longitude": -106.077744, "time_offset_sec": 9675}, "public_key_hex": "42174776375641e8b13707a7fa4ef3580eeaf9e964f3727c21a23d6a80f5fd4b", "role": "CLIENT", "short_name": "G69N", "snr": 4.9, "status": null, "telemetry": {"air_util_tx": 0.097, "battery_level": 11, "channel_utilization": 24.69, "uptime_seconds": 39981, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 859, "long_name": "New Trout", "next_hop": 0, "num": "0x51a2c4a1", "position": {"altitude": 1751, "latitude": 33.015944, "location_source": "LOC_INTERNAL", "longitude": -107.961733, "time_offset_sec": 1000}, "public_key_hex": "954724d0b20bdc166cd9f1abc3f93647d2c8b56d7ab6690f910ba630edcccf6c", "role": "CLIENT", "short_name": "NX5V", "snr": 0.38, "status": null, "telemetry": {"air_util_tx": 1.223, "battery_level": 12, "channel_utilization": 19.47, "uptime_seconds": 179225, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.81, "iaq": 14, "relative_humidity": 70.06, "temperature": 21.51}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 5350, "long_name": "Howling Hare", "next_hop": 0, "num": "0x51bcc14d", "position": {"altitude": 1302, "latitude": 33.060638, "location_source": "LOC_INTERNAL", "longitude": -107.19394, "time_offset_sec": 5396}, "public_key_hex": "5fe2e79d5d90da2b3632778ce7f92268e582a2f6aaea46f395dded5230e9c220", "role": "CLIENT", "short_name": "HY4P", "snr": 2.57, "status": null, "telemetry": {"air_util_tx": 0.541, "battery_level": 61, "channel_utilization": 9.01, "uptime_seconds": 119204, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 689, "long_name": "Wandering Badger", "next_hop": 0, "num": "0x51cd7781", "position": null, "public_key_hex": "6bcc87f41bb1f6aa48395e864646e4e5db9eaab847d6a2260caa4782c1bd25f6", "role": "CLIENT", "short_name": "WAG2", "snr": 4.93, "status": null, "telemetry": {"air_util_tx": 1.938, "battery_level": 63, "channel_utilization": 5.24, "uptime_seconds": 227893, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2173, "long_name": "Silver Elk", "next_hop": 7, "num": "0x51f8fe7c", "position": {"altitude": 1615, "latitude": 33.178207, "location_source": "LOC_INTERNAL", "longitude": -107.191252, "time_offset_sec": 2330}, "public_key_hex": "dde87669b9933ec6bea1d2d16a736ac5e1d11b04fcc7fa857bc634bb7a346a13", "role": "CLIENT", "short_name": "S7DL", "snr": 10.28, "status": null, "telemetry": {"air_util_tx": 1.398, "battery_level": 71, "channel_utilization": 24.12, "uptime_seconds": 131874, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1014.43, "iaq": 49, "relative_humidity": 50.82, "temperature": 22.04}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1585, "long_name": "Found Beaver", "next_hop": 0, "num": "0x520576d4", "position": {"altitude": 1317, "latitude": 34.445834, "location_source": "LOC_INTERNAL", "longitude": -106.85718, "time_offset_sec": 1812}, "public_key_hex": "11a975ed65181b2c1639ae5ffff25b84e8e77cb7980721126dddcb84d1b1c7cc", "role": "CLIENT_BASE", "short_name": "FFZR", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.316, "battery_level": 52, "channel_utilization": 6.25, "uptime_seconds": 4985, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.04, "iaq": 32, "relative_humidity": 51.77, "temperature": 33.16}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1083, "long_name": "River Tortoise", "next_hop": 0, "num": "0x5226f4fa", "position": null, "public_key_hex": "f1d4b260326b814efc56a03d050fcab41338908f17bb4acea3181013c82d201a", "role": "CLIENT", "short_name": "RQA0", "snr": 4.3, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1008.49, "iaq": 65, "relative_humidity": 44.84, "temperature": 13.64}, "hops_away": 0, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 399, "long_name": "Solar Otter", "next_hop": 0, "num": "0x5245a0d0", "position": {"altitude": 1740, "latitude": 33.321885, "location_source": "LOC_INTERNAL", "longitude": -107.678871, "time_offset_sec": 645}, "public_key_hex": "a510f9fa2424cd41f08f6e8e755ffc957ee82d00358e5cd06dd1810b384416e0", "role": "CLIENT", "short_name": "S207", "snr": 8.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1030, "long_name": "Old Phoenix", "next_hop": 0, "num": "0x5258610f", "position": {"altitude": 1644, "latitude": 32.681245, "location_source": "LOC_INTERNAL", "longitude": -106.603641, "time_offset_sec": 1173}, "public_key_hex": "d35da0a3643d6107a0de02fe46e074993acd57bbe7dfff3bcb12bc99d738b7d3", "role": "CLIENT", "short_name": "OLF5", "snr": 8.52, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 3376, "long_name": "Frozen Juniper", "next_hop": 200, "num": "0x527884a4", "position": {"altitude": 1342, "latitude": 33.670457, "location_source": "LOC_INTERNAL", "longitude": -108.282249, "time_offset_sec": 3463}, "public_key_hex": "d06f4b1eadd909228f6df11c13cb2c9a8fc5fe3f2e715585ee3910d06b7187f3", "role": "ROUTER", "short_name": "FZBP", "snr": -3.29, "status": null, "telemetry": {"air_util_tx": 0.198, "battery_level": 89, "channel_utilization": 15.51, "uptime_seconds": 31363, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.19, "iaq": 26, "relative_humidity": 47.94, "temperature": 8.37}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 5497, "long_name": "Howling Wolf", "next_hop": 51, "num": "0x527ad983", "position": {"altitude": 1593, "latitude": 32.581933, "location_source": "LOC_INTERNAL", "longitude": -107.602658, "time_offset_sec": 5767}, "public_key_hex": "a61f5652d567b458466217bb7244deb8d33e0ea88823a90321e79dbc97d33a2d", "role": "CLIENT", "short_name": "HINR", "snr": 4.31, "status": null, "telemetry": {"air_util_tx": 0.356, "battery_level": 57, "channel_utilization": 8.4, "uptime_seconds": 97233, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4_R8", "last_heard_offset_sec": 11358, "long_name": "Mountain Trout", "next_hop": 0, "num": "0x528d3c49", "position": {"altitude": 1516, "latitude": 32.713213, "location_source": "LOC_INTERNAL", "longitude": -106.82891, "time_offset_sec": 11389}, "public_key_hex": "4902af3203e721d4446fcdfae64464518035c30e49919533e6f4359cb571c045", "role": "CLIENT", "short_name": "MQJR", "snr": 10.89, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.56, "iaq": 53, "relative_humidity": 80.69, "temperature": 18.48}, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 492, "long_name": "Sky Raven", "next_hop": 222, "num": "0x52a38979", "position": {"altitude": 1147, "latitude": 33.49136, "location_source": "LOC_INTERNAL", "longitude": -107.240667, "time_offset_sec": 647}, "public_key_hex": "9fcdd10361407120f81f0dd0bd08d093ca239cb8940b518da73dcd416d0528af", "role": "CLIENT_HIDDEN", "short_name": "S2WO", "snr": 4.43, "status": null, "telemetry": {"air_util_tx": 0.031, "battery_level": 38, "channel_utilization": 10.1, "uptime_seconds": 1806, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 13, "long_name": "Frosty Arroyo", "next_hop": 41, "num": "0x52a5ea1b", "position": {"altitude": 1065, "latitude": 32.53403, "location_source": "LOC_INTERNAL", "longitude": -108.384118, "time_offset_sec": 15}, "public_key_hex": "b1eb9ca87551f94079531734202737c6781a5fcead370a0e8461188b73ffc432", "role": "CLIENT", "short_name": "F2OS", "snr": -0.92, "status": null, "telemetry": {"air_util_tx": 0.018, "battery_level": 21, "channel_utilization": 5.72, "uptime_seconds": 24941, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 987.2, "iaq": 47, "relative_humidity": 24.05, "temperature": 29.89}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 9471, "long_name": "Dawn Oak", "next_hop": 0, "num": "0x52abe547", "position": {"altitude": 1277, "latitude": 33.14784, "location_source": "LOC_INTERNAL", "longitude": -106.02805, "time_offset_sec": 9617}, "public_key_hex": "afa8ec6b2071d3cfff59876bd8b672a169d270d996308b79953ed98a5f9e53f2", "role": "CLIENT", "short_name": "D185", "snr": 0.26, "status": null, "telemetry": {"air_util_tx": 0.561, "battery_level": 20, "channel_utilization": 3.22, "uptime_seconds": 193942, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1594, "long_name": "Steel Salmon", "next_hop": 224, "num": "0x52c6028e", "position": {"altitude": 1431, "latitude": 32.544191, "location_source": "LOC_INTERNAL", "longitude": -107.677098, "time_offset_sec": 1749}, "public_key_hex": "a2fa9c1494f1b63d601af47e4961bfac1908c02f514bda8f7a1db6d1260329c2", "role": "TRACKER", "short_name": "SWPU", "snr": 2.89, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1303, "long_name": "Frosty Squirrel", "next_hop": 0, "num": "0x52d237c4", "position": {"altitude": 1672, "latitude": 32.69802, "location_source": "LOC_INTERNAL", "longitude": -107.344889, "time_offset_sec": 1483}, "public_key_hex": "8d794d1388a117c3e3c73945cdbce1151b9ea2c42d49d051e2623d270fb42364", "role": "CLIENT", "short_name": "F836", "snr": 4.66, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.24, "battery_level": 72, "channel_utilization": 3.8, "uptime_seconds": 271943, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1027.82, "iaq": 94, "relative_humidity": 79.23, "temperature": 24.29}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 145, "long_name": "Frozen Bass", "next_hop": 0, "num": "0x52ee7597", "position": {"altitude": 1482, "latitude": 33.119917, "location_source": "LOC_INTERNAL", "longitude": -106.876536, "time_offset_sec": 400}, "public_key_hex": "7c3dc02f36752a1123ffbf8205a74a2f755f3b3f0fcc475b9135533731a787c2", "role": "CLIENT", "short_name": "FN8C", "snr": 4.15, "status": null, "telemetry": {"air_util_tx": 0.028, "battery_level": 47, "channel_utilization": 3.21, "uptime_seconds": 54891, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 819, "long_name": "Drowsy Cobra", "next_hop": 162, "num": "0x52fcbb25", "position": {"altitude": 1275, "latitude": 33.770093, "location_source": "LOC_INTERNAL", "longitude": -107.419742, "time_offset_sec": 920}, "public_key_hex": "f800cf6635b7fd6da1735a1f4c59828d8c28c25b17bf5e39f20c18c7feab2b57", "role": "CLIENT", "short_name": "D6VP", "snr": 3.39, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2275, "long_name": "Sky Falcon", "next_hop": 212, "num": "0x531697b8", "position": {"altitude": 1314, "latitude": 33.1871, "location_source": "LOC_INTERNAL", "longitude": -107.473452, "time_offset_sec": 2372}, "public_key_hex": "451dc01c0b40b9126ac3d50d0abce650238f51a106f63d2fbbc58a1f0088ad1e", "role": "CLIENT", "short_name": "🦊", "snr": 9.45, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.09, "iaq": 73, "relative_humidity": 68.6, "temperature": 27.85}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4227, "long_name": "Wild Heron", "next_hop": 0, "num": "0x53309711", "position": {"altitude": 1484, "latitude": 32.994285, "location_source": "LOC_INTERNAL", "longitude": -107.276082, "time_offset_sec": 4512}, "public_key_hex": "42b734d39a57556fda87cee3a99d00c82384026f581b5b5afd74fb42905fddb4", "role": "CLIENT", "short_name": "🦌", "snr": 11.02, "status": null, "telemetry": {"air_util_tx": 1.127, "battery_level": 17, "channel_utilization": 24.84, "uptime_seconds": 40611, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3741, "long_name": "Red Mole", "next_hop": 199, "num": "0x534557ba", "position": {"altitude": 1419, "latitude": 32.262028, "location_source": "LOC_INTERNAL", "longitude": -106.271923, "time_offset_sec": 3906}, "public_key_hex": "ee1ec6246a2cf3616abdab63f3499321fea26184d4b190a7306285215f9acad2", "role": "ROUTER_LATE", "short_name": "RL4Q", "snr": 5.61, "status": null, "telemetry": {"air_util_tx": 1.209, "battery_level": 20, "channel_utilization": 14.08, "uptime_seconds": 14203, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 839, "long_name": "Canyon Aspen", "next_hop": 49, "num": "0x53739756", "position": null, "public_key_hex": "440527edbe20257511813b85289a53893978ffefc2f01723f4069893f90f54d1", "role": "CLIENT", "short_name": "CXSA", "snr": 1.65, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.8, "battery_level": 79, "channel_utilization": 8.88, "uptime_seconds": 214937, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 1558, "long_name": "Brave Mole", "next_hop": 2, "num": "0x5388df4f", "position": {"altitude": 1684, "latitude": 33.646747, "location_source": "LOC_INTERNAL", "longitude": -107.705955, "time_offset_sec": 1842}, "public_key_hex": "5353b79e801f73e01c097238df4b0c2bc3f4cae3a0f90b409f8992592b880c1b", "role": "CLIENT_MUTE", "short_name": "BSXE", "snr": 5.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5879, "long_name": "Drowsy Crow", "next_hop": 0, "num": "0x53a53427", "position": {"altitude": 1667, "latitude": 34.068197, "location_source": "LOC_INTERNAL", "longitude": -107.054797, "time_offset_sec": 6139}, "public_key_hex": "a38592dbf9b37c846c1619ea710359351b0ca59329f36407b6267b6460554184", "role": "CLIENT_BASE", "short_name": "DU5U", "snr": 2.3, "status": null, "telemetry": {"air_util_tx": 0.33, "battery_level": 49, "channel_utilization": 13.91, "uptime_seconds": 13907, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 15377, "long_name": "Lunar Cedar", "next_hop": 0, "num": "0x53ce0c58", "position": null, "public_key_hex": "11232cd23bd516a81d9562901eee8788ea0b245ef96973a3371e109fc1b4200d", "role": "CLIENT", "short_name": "LXOZ", "snr": 5.31, "status": null, "telemetry": {"air_util_tx": 0.385, "battery_level": 101, "channel_utilization": 1.19, "uptime_seconds": 66002, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1080, "long_name": "Storm Bluff", "next_hop": 0, "num": "0x53f9fb1c", "position": null, "public_key_hex": "286664fc32da794233d5d5f62b20fa4de603337f2112274fb372498d29d04d47", "role": "CLIENT", "short_name": "S60C", "snr": 6.94, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.293, "battery_level": 95, "channel_utilization": 4.11, "uptime_seconds": 101509, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.21, "iaq": 4, "relative_humidity": 43.2, "temperature": 24.16}, "hops_away": 2, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 8915, "long_name": "Tiny Stag", "next_hop": 210, "num": "0x54224420", "position": {"altitude": 1217, "latitude": 33.688716, "location_source": "LOC_INTERNAL", "longitude": -107.500745, "time_offset_sec": 9033}, "public_key_hex": "42b7e3398830e65220e1d1d692997cb76ee33c07194b4e24b88a14741a2fcb87", "role": "CLIENT_MUTE", "short_name": "TKLN", "snr": 10.08, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.419, "battery_level": 54, "channel_utilization": 11.79, "uptime_seconds": 8380, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 713, "long_name": "Slow Bass", "next_hop": 28, "num": "0x54263fb2", "position": null, "public_key_hex": "17497a5c4b213c215acfccb0fa87f5ef6dedb50be09ee0c229192f445d2f6ba5", "role": "LOST_AND_FOUND", "short_name": "SWL8", "snr": 3.77, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.132, "battery_level": 69, "channel_utilization": 26.27, "uptime_seconds": 41676, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.06, "iaq": 108, "relative_humidity": 39.5, "temperature": 22.7}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3981, "long_name": "Forest Mesa", "next_hop": 0, "num": "0x543501db", "position": null, "public_key_hex": "3f3f9658beb5d9c57ee7736f668b95927436ecab526c6046c46d6c4d60d7166a", "role": "CLIENT", "short_name": "F9K2", "snr": 12.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.421, "battery_level": 72, "channel_utilization": 6.37, "uptime_seconds": 26009, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 7131, "long_name": "Stone Doe", "next_hop": 93, "num": "0x5445a38c", "position": {"altitude": 1279, "latitude": 33.365284, "location_source": "LOC_INTERNAL", "longitude": -107.506807, "time_offset_sec": 7312}, "public_key_hex": "6b2e89cc4c9ce5ebfb1b0366ca303ee89246694cca601ea08454c3e02fb64cc1", "role": "CLIENT", "short_name": "🦌", "snr": 12.0, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.011, "battery_level": 39, "channel_utilization": 18.13, "uptime_seconds": 147557, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 3695, "long_name": "Steel Gecko", "next_hop": 0, "num": "0x544e69ca", "position": {"altitude": 1665, "latitude": 33.616148, "location_source": "LOC_INTERNAL", "longitude": -107.300086, "time_offset_sec": 3988}, "public_key_hex": "70085af39dbf9139ade92cd582c47d206dc94ebc0b9b5ad5b9d6e7143257a7c1", "role": "CLIENT", "short_name": "SWBM", "snr": 3.69, "status": null, "telemetry": {"air_util_tx": 0.745, "battery_level": 44, "channel_utilization": 9.84, "uptime_seconds": 17199, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1943, "long_name": "Desert Mamba", "next_hop": 160, "num": "0x54671be3", "position": null, "public_key_hex": "e9f39f0da162c13f89e1ad0f691a92e2e31575e5afb11100abe3682db7935817", "role": "CLIENT", "short_name": "D4YW", "snr": 6.5, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.094, "battery_level": 48, "channel_utilization": 3.01, "uptime_seconds": 59125, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 976, "long_name": "Smooth Tortoise", "next_hop": 0, "num": "0x547ffe23", "position": {"altitude": 1759, "latitude": 33.612125, "location_source": "LOC_INTERNAL", "longitude": -106.780434, "time_offset_sec": 1070}, "public_key_hex": "6a06b4631f7669fe7846a141cc30c49cf615304b24739933c493f36e32b5deb3", "role": "CLIENT", "short_name": "SRIT", "snr": 0.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2547, "long_name": "Slow Doe", "next_hop": 141, "num": "0x54a38dab", "position": null, "public_key_hex": "a5e9eb0910b966db64b5a17fff8c6a389f3a39b66f6aaf8b8ce7af9c7c2ab082", "role": "CLIENT", "short_name": "SSWM", "snr": 7.94, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 2.546, "battery_level": 98, "channel_utilization": 24.74, "uptime_seconds": 254404, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3369, "long_name": "Happy Wolf", "next_hop": 0, "num": "0x54afbed9", "position": {"altitude": 1398, "latitude": 33.454786, "location_source": "LOC_INTERNAL", "longitude": -106.909375, "time_offset_sec": 3379}, "public_key_hex": "2361399f4469a4164115cde6573ecc72e194d476b310c797537cc59eaef8902a", "role": "CLIENT", "short_name": "🌵", "snr": 9.64, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1415, "long_name": "Stone Bluff", "next_hop": 20, "num": "0x54bad40c", "position": {"altitude": 1235, "latitude": 32.543009, "location_source": "LOC_INTERNAL", "longitude": -107.756674, "time_offset_sec": 1641}, "public_key_hex": "2565af36e8b57fd43f79afd8df9fd20f41a5d418b267275ff0c59aabe3a5a56f", "role": "CLIENT", "short_name": "S1J1", "snr": 5.87, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1381, "long_name": "Silver Eagle", "next_hop": 0, "num": "0x54df5396", "position": {"altitude": 1874, "latitude": 32.08779, "location_source": "LOC_INTERNAL", "longitude": -107.656427, "time_offset_sec": 1468}, "public_key_hex": "c5ee903d99c51b5e0b2fd1d691f568826d40a32984ecc1f73a0bee379541fd72", "role": "ROUTER", "short_name": "S5B0", "snr": 2.16, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.204, "battery_level": 85, "channel_utilization": 6.37, "uptime_seconds": 33755, "voltage": 4.065}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 4517, "long_name": "Happy Dolphin", "next_hop": 58, "num": "0x55111464", "position": null, "public_key_hex": "1ee1831f9774aaa5889884ded4eefde1dae5293d0383c2adf5e41acb89ed4834", "role": "TRACKER", "short_name": "HKMD", "snr": 4.95, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 2.303, "battery_level": 19, "channel_utilization": 14.59, "uptime_seconds": 27997, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5562, "long_name": "Brave Lion", "next_hop": 253, "num": "0x55158385", "position": {"altitude": 1273, "latitude": 33.264743, "location_source": "LOC_INTERNAL", "longitude": -107.709456, "time_offset_sec": 5721}, "public_key_hex": "7d024beab9f4041eac4628c84936a84c2658cdaf9ee988ca86fb785162f672f8", "role": "CLIENT", "short_name": "BJD5", "snr": 9.6, "status": null, "telemetry": {"air_util_tx": 0.413, "battery_level": 20, "channel_utilization": 0.26, "uptime_seconds": 3816, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.33, "iaq": 26, "relative_humidity": 100.0, "temperature": 30.99}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2862, "long_name": "Green Shark AB6AY", "next_hop": 0, "num": "0x551b0600", "position": {"altitude": 1444, "latitude": 35.241546, "location_source": "LOC_INTERNAL", "longitude": -107.34419, "time_offset_sec": 3070}, "public_key_hex": "", "role": "CLIENT_HIDDEN", "short_name": "GZXF", "snr": 0.04, "status": null, "telemetry": {"air_util_tx": 0.325, "battery_level": 23, "channel_utilization": 14.12, "uptime_seconds": 36807, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5036, "long_name": "Wild Pike", "next_hop": 7, "num": "0x552a7cb8", "position": {"altitude": 1447, "latitude": 33.174137, "location_source": "LOC_INTERNAL", "longitude": -108.072462, "time_offset_sec": 5279}, "public_key_hex": "b6416230a7bf40ff6dc4be20488152646cf21759dbd715028c5299194a628745", "role": "CLIENT", "short_name": "WIGL", "snr": 8.6, "status": null, "telemetry": {"air_util_tx": 0.181, "battery_level": 62, "channel_utilization": 13.32, "uptime_seconds": 23416, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.82, "iaq": 83, "relative_humidity": 83.24, "temperature": 15.13}, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1495, "long_name": "Green Salmon", "next_hop": 145, "num": "0x552d42fa", "position": {"altitude": 1611, "latitude": 33.089135, "location_source": "LOC_INTERNAL", "longitude": -106.298797, "time_offset_sec": 1557}, "public_key_hex": "4c5f1e88522076be07ae2f9059e7f1d4bb3643445595ff768c021b320f74c5f0", "role": "CLIENT", "short_name": "GAYD", "snr": 11.35, "status": null, "telemetry": {"air_util_tx": 0.073, "battery_level": 70, "channel_utilization": 21.04, "uptime_seconds": 118748, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1627, "long_name": "Drifting Sage", "next_hop": 0, "num": "0x553f51d4", "position": {"altitude": 1629, "latitude": 32.759449, "location_source": "LOC_INTERNAL", "longitude": -106.786123, "time_offset_sec": 1683}, "public_key_hex": "8bc0f8a4eeb012dcf62337c7e1b79b877550405a730b1dc8a8b52f029865793e", "role": "CLIENT", "short_name": "DMFO", "snr": 7.43, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.38, "iaq": 18, "relative_humidity": 69.62, "temperature": 24.42}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1_EINK", "last_heard_offset_sec": 968, "long_name": "Green Cougar", "next_hop": 0, "num": "0x55406923", "position": {"altitude": 1674, "latitude": 32.960441, "location_source": "LOC_INTERNAL", "longitude": -108.317571, "time_offset_sec": 1030}, "public_key_hex": "b2f713a7b2b4f24e7f48734af14f45a383387679d36c9465cf2ad9b94ff8cd36", "role": "CLIENT", "short_name": "GB4Q", "snr": 9.22, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 54, "long_name": "Lone Ridge", "next_hop": 0, "num": "0x5544a8c3", "position": {"altitude": 1150, "latitude": 33.226352, "location_source": "LOC_INTERNAL", "longitude": -106.840649, "time_offset_sec": 260}, "public_key_hex": "1e6eb19782959abc93b6784dc12051c4ee49f2d0fb34573e83ae8f3642936fde", "role": "CLIENT", "short_name": "LLAN", "snr": 5.09, "status": null, "telemetry": {"air_util_tx": 1.397, "battery_level": 89, "channel_utilization": 8.55, "uptime_seconds": 87957, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5313, "long_name": "Fast Colt", "next_hop": 0, "num": "0x554a4c37", "position": {"altitude": 1968, "latitude": 33.390952, "location_source": "LOC_INTERNAL", "longitude": -107.663788, "time_offset_sec": 5374}, "public_key_hex": "", "role": "TAK", "short_name": "🗻", "snr": 12.0, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.185, "battery_level": 97, "channel_utilization": 7.81, "uptime_seconds": 35143, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.91, "iaq": 47, "relative_humidity": 44.47, "temperature": -4.52}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4227, "long_name": "Forest Badger", "next_hop": 252, "num": "0x555ac08d", "position": null, "public_key_hex": "4f17f001855c78c7074b15bbc316e286f3bc85797a72c0de497dc14fe5fa98c6", "role": "ROUTER", "short_name": "🦊", "snr": 11.89, "status": null, "telemetry": {"air_util_tx": 0.867, "battery_level": 37, "channel_utilization": 3.28, "uptime_seconds": 95723, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 4500, "long_name": "White Sage", "next_hop": 0, "num": "0x55646f91", "position": {"altitude": 1039, "latitude": 32.956363, "location_source": "LOC_INTERNAL", "longitude": -107.023992, "time_offset_sec": 4518}, "public_key_hex": "1e81517ba878c804044567b8c3d5f370c715fdd9d511f8c5c25742f9164219dd", "role": "CLIENT", "short_name": "WWSB", "snr": 4.95, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 945, "long_name": "Misty Bison", "next_hop": 218, "num": "0x5567164b", "position": {"altitude": 1720, "latitude": 33.442196, "location_source": "LOC_INTERNAL", "longitude": -106.317654, "time_offset_sec": 1152}, "public_key_hex": "90cdd2b7748775e87ada52cbb7ecd2dcf35a33409b645e9c61f6a18dc93cad72", "role": "CLIENT_BASE", "short_name": "MUHA", "snr": 3.25, "status": null, "telemetry": {"air_util_tx": 0.087, "battery_level": 16, "channel_utilization": 5.41, "uptime_seconds": 245565, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3878, "long_name": "Hidden Mamba", "next_hop": 0, "num": "0x557b8d4c", "position": {"altitude": 1699, "latitude": 32.955709, "location_source": "LOC_INTERNAL", "longitude": -107.36856, "time_offset_sec": 4172}, "public_key_hex": "72adbdb84e8926632f5d5073b9782fa12dc5a2b79cedfe7e96905710ac2f8181", "role": "CLIENT", "short_name": "HQH2", "snr": 5.9, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.208, "battery_level": 81, "channel_utilization": 0.82, "uptime_seconds": 35285, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 5, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 793, "long_name": "Howling Pike", "next_hop": 170, "num": "0x559238f6", "position": {"altitude": 1660, "latitude": 32.908699, "location_source": "LOC_INTERNAL", "longitude": -107.708328, "time_offset_sec": 1010}, "public_key_hex": "f30c3d21b268fb8523c58b7322ead2df023a76c85171af16bee37e0904f5a58e", "role": "TAK", "short_name": "H45F", "snr": 5.49, "status": null, "telemetry": {"air_util_tx": 1.469, "battery_level": 77, "channel_utilization": 24.82, "uptime_seconds": 61503, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.27, "iaq": 48, "relative_humidity": 56.6, "temperature": 24.52}, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 2597, "long_name": "Red Heron", "next_hop": 0, "num": "0x55b4fe25", "position": null, "public_key_hex": "0e9c51d5f0cae4df84399588c46f9835d0748b763f7cbc4e97649d209e508844", "role": "CLIENT", "short_name": "RU41", "snr": 3.92, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.02, "iaq": 64, "relative_humidity": 41.85, "temperature": 28.63}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 5287, "long_name": "Steel Moose", "next_hop": 186, "num": "0x55c9614e", "position": {"altitude": 1977, "latitude": 33.451559, "location_source": "LOC_INTERNAL", "longitude": -106.912143, "time_offset_sec": 5526}, "public_key_hex": "476165e32c998cd3db6620eec8528f59b66c07f7230a18bc56a4528d39ca4b67", "role": "CLIENT", "short_name": "SPAW", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.117, "battery_level": 43, "channel_utilization": 14.5, "uptime_seconds": 6556, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.75, "iaq": 32, "relative_humidity": 97.45, "temperature": 16.69}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 7096, "long_name": "Desert Viper", "next_hop": 0, "num": "0x55cfe344", "position": {"altitude": 1588, "latitude": 33.705826, "location_source": "LOC_INTERNAL", "longitude": -107.445891, "time_offset_sec": 7257}, "public_key_hex": "", "role": "CLIENT", "short_name": "DOUP", "snr": 6.19, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3214, "long_name": "Gold Mole", "next_hop": 120, "num": "0x5618242b", "position": {"altitude": 1292, "latitude": 33.397476, "location_source": "LOC_INTERNAL", "longitude": -107.210292, "time_offset_sec": 3262}, "public_key_hex": "f871e74496325f7bf12bc4c640ef84dcae6fd230650906aa3e85d4b07f3b6548", "role": "CLIENT", "short_name": "G8FW", "snr": 10.28, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.36, "iaq": 75, "relative_humidity": 57.94, "temperature": 13.95}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3641, "long_name": "White Badger K10PF", "next_hop": 0, "num": "0x56198cf5", "position": {"altitude": 420, "latitude": 33.201562, "location_source": "LOC_INTERNAL", "longitude": -108.357945, "time_offset_sec": 3933}, "public_key_hex": "fda6878425336e01c8996972952765aedaa72fea30501217a492cbd1c1b6c416", "role": "CLIENT", "short_name": "🦌", "snr": 4.88, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.166, "battery_level": 77, "channel_utilization": 5.81, "uptime_seconds": 57528, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 3820, "long_name": "Loud Bear", "next_hop": 6, "num": "0x561cfc6f", "position": {"altitude": 1307, "latitude": 33.512692, "location_source": "LOC_INTERNAL", "longitude": -106.501941, "time_offset_sec": 3983}, "public_key_hex": "a0b1922b7b093757ed22112952e48c182810e708c046ed3bff09b800fb66fadd", "role": "CLIENT", "short_name": "LRFH", "snr": 4.68, "status": null, "telemetry": {"air_util_tx": 0.216, "battery_level": 84, "channel_utilization": 3.04, "uptime_seconds": 159173, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 13077, "long_name": "Misty Fox", "next_hop": 82, "num": "0x562cb528", "position": {"altitude": 819, "latitude": 32.615452, "location_source": "LOC_INTERNAL", "longitude": -107.152157, "time_offset_sec": 13234}, "public_key_hex": "0ee97c086cf782f7f2c8ca89eb225d703d82b3dad78420ce805e50dcf5b2e23f", "role": "CLIENT", "short_name": "🦅", "snr": 5.23, "status": null, "telemetry": {"air_util_tx": 0.564, "battery_level": 64, "channel_utilization": 3.26, "uptime_seconds": 18593, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2832, "long_name": "Silver Viper", "next_hop": 219, "num": "0x5635a1af", "position": {"altitude": 1495, "latitude": 33.341581, "location_source": "LOC_INTERNAL", "longitude": -107.556963, "time_offset_sec": 3017}, "public_key_hex": "", "role": "CLIENT", "short_name": "S2FN", "snr": 5.78, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.237, "battery_level": 74, "channel_utilization": 11.26, "uptime_seconds": 345913, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3366, "long_name": "Lone Adder", "next_hop": 198, "num": "0x5636d18e", "position": null, "public_key_hex": "3781fc525393ca0d6ea175060914b782af4daa0af4b0d26cafbb78e2dacc6039", "role": "CLIENT", "short_name": "L4UT", "snr": -0.39, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2791, "long_name": "Tiny Mamba", "next_hop": 28, "num": "0x565250bf", "position": null, "public_key_hex": "059f9d5b87a3ddac23d0073dd71e97c4343061a5904564dd086e9a596c05c647", "role": "CLIENT", "short_name": "T7T7", "snr": 0.98, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.89, "battery_level": 11, "channel_utilization": 8.45, "uptime_seconds": 107148, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.44, "iaq": 62, "relative_humidity": 65.41, "temperature": 31.71}, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 6319, "long_name": "White Marmot", "next_hop": 223, "num": "0x56640f93", "position": {"altitude": 1415, "latitude": 32.452213, "location_source": "LOC_INTERNAL", "longitude": -107.009154, "time_offset_sec": 6501}, "public_key_hex": "a62e40d1aacee2ea314a5de5f2f5f8cf5b08db56e337147851df1b08e66f4a52", "role": "CLIENT", "short_name": "WS46", "snr": 4.89, "status": null, "telemetry": {"air_util_tx": 0.564, "battery_level": 47, "channel_utilization": 7.88, "uptime_seconds": 187971, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2179, "long_name": "Soft Marmot", "next_hop": 0, "num": "0x5666d4f4", "position": {"altitude": 1673, "latitude": 33.588877, "location_source": "LOC_INTERNAL", "longitude": -107.134677, "time_offset_sec": 2402}, "public_key_hex": "415bc545cb996100132419a1758ff21f737765338b679d9cefe40e4a2a96750f", "role": "CLIENT", "short_name": "SV7P", "snr": 4.36, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.105, "battery_level": 84, "channel_utilization": 2.67, "uptime_seconds": 36582, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 991, "long_name": "Copper Stag", "next_hop": 98, "num": "0x5667c5bf", "position": {"altitude": 1266, "latitude": 33.084618, "location_source": "LOC_INTERNAL", "longitude": -107.348621, "time_offset_sec": 1237}, "public_key_hex": "1b674525bd6b9eb3e1dfb9c81d43b3f6ab17a9811bf8dc41f9663e7daf83e1c4", "role": "CLIENT_HIDDEN", "short_name": "CLKY", "snr": 3.24, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.265, "battery_level": 51, "channel_utilization": 1.36, "uptime_seconds": 14890, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 7659, "long_name": "Drifting Pony", "next_hop": 26, "num": "0x56800f28", "position": {"altitude": 1013, "latitude": 33.313686, "location_source": "LOC_INTERNAL", "longitude": -107.669967, "time_offset_sec": 7959}, "public_key_hex": "606b7bf4e4185a18d33c2e0182afff673fd0b0e23607274a8cbf5b065f057eb0", "role": "CLIENT", "short_name": "D4H6", "snr": -0.83, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2596, "long_name": "Found Tortoise", "next_hop": 0, "num": "0x56a84801", "position": {"altitude": 1521, "latitude": 31.815224, "location_source": "LOC_INTERNAL", "longitude": -107.911335, "time_offset_sec": 2813}, "public_key_hex": "b3e3c99cb16902c85d7713ee60130ad435c10d52d06a44e6928b7068156cb0b7", "role": "ROUTER", "short_name": "FL2N", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.651, "battery_level": 19, "channel_utilization": 18.36, "uptime_seconds": 94134, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.12, "iaq": 63, "relative_humidity": 83.03, "temperature": 9.95}, "hops_away": 0, "hw_model": "HELTEC_V4_R8", "last_heard_offset_sec": 4499, "long_name": "Frosty Badger", "next_hop": 0, "num": "0x56ad98ad", "position": {"altitude": 1465, "latitude": 32.154431, "location_source": "LOC_INTERNAL", "longitude": -106.548659, "time_offset_sec": 4752}, "public_key_hex": "3c72fbb40f3435b4667e7aad13b3cb1a32af41365f8e0efc8f345b97bb9313bd", "role": "CLIENT", "short_name": "F6WC", "snr": 9.27, "status": null, "telemetry": {"air_util_tx": 0.682, "battery_level": 56, "channel_utilization": 6.17, "uptime_seconds": 24075, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 979, "long_name": "Copper Pine KE5ID", "next_hop": 196, "num": "0x56af0100", "position": null, "public_key_hex": "b6e804caa025a1fc37bcbde78e4ca51366f3fd38d7893e55a13537fa9b1d1179", "role": "CLIENT", "short_name": "CKCQ", "snr": 3.79, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 8349, "long_name": "Mountain Cactus", "next_hop": 4, "num": "0x56b7bc8b", "position": {"altitude": 1236, "latitude": 33.058198, "location_source": "LOC_INTERNAL", "longitude": -107.436314, "time_offset_sec": 8482}, "public_key_hex": "b5d8c4e6f770b478059ccbc90b2426d438c58f4f9f058db5afdcce9f0951c7bd", "role": "CLIENT", "short_name": "M6V4", "snr": 4.7, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.291, "battery_level": 77, "channel_utilization": 6.19, "uptime_seconds": 16023, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1007.52, "iaq": 36, "relative_humidity": 49.1, "temperature": 29.13}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 78, "long_name": "Lone Arroyo", "next_hop": 0, "num": "0x56bbd77e", "position": {"altitude": 1592, "latitude": 32.500965, "location_source": "LOC_INTERNAL", "longitude": -107.009694, "time_offset_sec": 141}, "public_key_hex": "8fe41f98b8e61f679ced6364504c539fda1bcb9d358a7ee9f77e5317dd387b0b", "role": "CLIENT", "short_name": "🦅", "snr": 7.61, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2909, "long_name": "Sunny Aspen", "next_hop": 0, "num": "0x56d5de04", "position": {"altitude": 1785, "latitude": 32.755543, "location_source": "LOC_INTERNAL", "longitude": -106.759087, "time_offset_sec": 3173}, "public_key_hex": "48e42d81012f0bd66d1d3fc69d4b834224629d34b80a37f3aa9a814bb3650449", "role": "CLIENT", "short_name": "SVZ2", "snr": 4.98, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.23, "battery_level": 68, "channel_utilization": 5.88, "uptime_seconds": 313764, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.7, "iaq": 17, "relative_humidity": 58.69, "temperature": 19.9}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 10856, "long_name": "Frosty Crane", "next_hop": 0, "num": "0x56d97250", "position": {"altitude": 1516, "latitude": 33.981516, "location_source": "LOC_INTERNAL", "longitude": -107.466541, "time_offset_sec": 10964}, "public_key_hex": "3e429dfd6a2a295b5b17f7453652861be5247dd6259ccd0223124d92d51b7e25", "role": "CLIENT_MUTE", "short_name": "FNVO", "snr": 11.89, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.13, "iaq": 31, "relative_humidity": 49.52, "temperature": 21.28}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 492, "long_name": "Black Yucca", "next_hop": 252, "num": "0x56dc7325", "position": {"altitude": 1438, "latitude": 32.768295, "location_source": "LOC_INTERNAL", "longitude": -106.817906, "time_offset_sec": 670}, "public_key_hex": "091a8ef43ab6b2e031fade3ffefae16a81cbce8c588802a3f0ebe29d785a72fb", "role": "CLIENT", "short_name": "BBFE", "snr": 7.04, "status": null, "telemetry": {"air_util_tx": 0.494, "battery_level": 44, "channel_utilization": 9.09, "uptime_seconds": 11864, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 632, "long_name": "Sky Pony", "next_hop": 0, "num": "0x56f62268", "position": null, "public_key_hex": "", "role": "ROUTER", "short_name": "SQQD", "snr": 4.56, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.094, "battery_level": 17, "channel_utilization": 8.98, "uptime_seconds": 164301, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2834, "long_name": "Brave Mole", "next_hop": 0, "num": "0x571e7b76", "position": {"altitude": 1375, "latitude": 33.46007, "location_source": "LOC_INTERNAL", "longitude": -106.725628, "time_offset_sec": 2848}, "public_key_hex": "", "role": "CLIENT", "short_name": "BIO6", "snr": 10.31, "status": null, "telemetry": {"air_util_tx": 0.399, "battery_level": 14, "channel_utilization": 12.79, "uptime_seconds": 182991, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1509, "long_name": "Desert Salmon", "next_hop": 0, "num": "0x5721251a", "position": {"altitude": 1851, "latitude": 32.372556, "location_source": "LOC_INTERNAL", "longitude": -107.743016, "time_offset_sec": 1553}, "public_key_hex": "25e88e9b03737d6063e9f43dc75463381121eb232f3612c157a56d43974732ee", "role": "CLIENT", "short_name": "DGQD", "snr": 7.82, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1901, "long_name": "Lost Pony", "next_hop": 0, "num": "0x57216d50", "position": {"altitude": 1008, "latitude": 32.406004, "location_source": "LOC_INTERNAL", "longitude": -107.106326, "time_offset_sec": 1929}, "public_key_hex": "d46d39ead0adf26b59bb5ac4471e56a1a6791854d1f5719c2f2aee6cda02a4ea", "role": "CLIENT", "short_name": "L7VU", "snr": 5.29, "status": null, "telemetry": {"air_util_tx": 1.235, "battery_level": 16, "channel_utilization": 8.41, "uptime_seconds": 135126, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 9932, "long_name": "Red Mamba", "next_hop": 18, "num": "0x57277d19", "position": {"altitude": 1378, "latitude": 33.244216, "location_source": "LOC_INTERNAL", "longitude": -107.182075, "time_offset_sec": 10118}, "public_key_hex": "18b3844364d1c8cbedae11b4383ce7a3851e6a24b73079897fb44eec6eb874b4", "role": "CLIENT", "short_name": "🦇", "snr": 0.43, "status": null, "telemetry": {"air_util_tx": 0.494, "battery_level": 101, "channel_utilization": 15.08, "uptime_seconds": 305035, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1023.5, "iaq": 38, "relative_humidity": 48.73, "temperature": 20.73}, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 234, "long_name": "Giant Cedar", "next_hop": 0, "num": "0x5740fb17", "position": {"altitude": 1478, "latitude": 32.546939, "location_source": "LOC_INTERNAL", "longitude": -107.351093, "time_offset_sec": 532}, "public_key_hex": "fade837f60965b132b1676f17f38246ffc1ef10fa02c7fb5b64e6a5a6dbe713a", "role": "CLIENT", "short_name": "GX1E", "snr": 0.08, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7549, "long_name": "Lost Pike", "next_hop": 0, "num": "0x574b9785", "position": {"altitude": 1404, "latitude": 32.362763, "location_source": "LOC_INTERNAL", "longitude": -106.236611, "time_offset_sec": 7749}, "public_key_hex": "20312a28169961d5e5bc2ba06bb3f0302402b5dd76b344255181cb7f7f627c87", "role": "CLIENT", "short_name": "LIZA", "snr": 3.2, "status": null, "telemetry": {"air_util_tx": 0.09, "battery_level": 91, "channel_utilization": 15.63, "uptime_seconds": 114446, "voltage": 4.119}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 6340, "long_name": "Soft Moose", "next_hop": 0, "num": "0x5766a627", "position": {"altitude": 1174, "latitude": 33.267079, "location_source": "LOC_INTERNAL", "longitude": -106.646438, "time_offset_sec": 6492}, "public_key_hex": "7b1f871d5aaeb5d9371bb6f2da5e1548b4d27d4521918e51c444ddc62e1a6753", "role": "CLIENT", "short_name": "SZTM", "snr": -3.11, "status": null, "telemetry": {"air_util_tx": 0.328, "battery_level": 54, "channel_utilization": 17.87, "uptime_seconds": 15550, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 4172, "long_name": "Drowsy Shark", "next_hop": 99, "num": "0x57759a5b", "position": null, "public_key_hex": "a291410b201dde4862ab49883b6d6cb30fa7ef77310a1bc743733b2458f77ac8", "role": "CLIENT", "short_name": "🌙", "snr": 6.09, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 4175, "long_name": "Slow Bison", "next_hop": 0, "num": "0x5788df8e", "position": {"altitude": 1380, "latitude": 33.378486, "location_source": "LOC_INTERNAL", "longitude": -106.713583, "time_offset_sec": 4473}, "public_key_hex": "61fc75a2b0ed90945e1b1cd90ed1802b5e355239989d677b7dc6437240afc391", "role": "CLIENT", "short_name": "SLLL", "snr": -4.39, "status": null, "telemetry": {"air_util_tx": 0.566, "battery_level": 10, "channel_utilization": 18.63, "uptime_seconds": 67307, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_HT62", "last_heard_offset_sec": 6488, "long_name": "White Turtle", "next_hop": 83, "num": "0x578d0bd6", "position": {"altitude": 1411, "latitude": 33.221285, "location_source": "LOC_INTERNAL", "longitude": -106.328818, "time_offset_sec": 6546}, "public_key_hex": "e1b567a7e278c5f9dfb8e64eaaade42920af4e098a24256660d82d9daab41c5e", "role": "CLIENT", "short_name": "WPRJ", "snr": 10.59, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.36, "battery_level": 30, "channel_utilization": 7.4, "uptime_seconds": 48530, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 999.69, "iaq": 65, "relative_humidity": 65.39, "temperature": 18.4}, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 4122, "long_name": "Rough Pony", "next_hop": 82, "num": "0x578e47c2", "position": {"altitude": 1625, "latitude": 32.543631, "location_source": "LOC_INTERNAL", "longitude": -107.178359, "time_offset_sec": 4257}, "public_key_hex": "2db9ea4f1e05ff7625a94e5ea5f563fde4becd34f3695001d4398cb32a66d7fc", "role": "CLIENT", "short_name": "R669", "snr": -2.38, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4628, "long_name": "Roving Cougar", "next_hop": 180, "num": "0x5793c95b", "position": {"altitude": 1723, "latitude": 32.785936, "location_source": "LOC_INTERNAL", "longitude": -106.533375, "time_offset_sec": 4691}, "public_key_hex": "fdf1e2115e05b42b660ee8080cedd937ff9238b933f9bfb4c30c18b4fd783497", "role": "CLIENT", "short_name": "RUQD", "snr": 8.95, "status": null, "telemetry": {"air_util_tx": 1.921, "battery_level": 46, "channel_utilization": 0.89, "uptime_seconds": 49936, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3286, "long_name": "Storm Beaver", "next_hop": 184, "num": "0x57e3b4b3", "position": {"altitude": 1485, "latitude": 33.938685, "location_source": "LOC_INTERNAL", "longitude": -106.869407, "time_offset_sec": 3458}, "public_key_hex": "199352a6283ce159f712d337823176a0d7e914755791df22a2a2f056ee748c84", "role": "CLIENT", "short_name": "SH1T", "snr": 5.98, "status": null, "telemetry": {"air_util_tx": 0.268, "battery_level": 90, "channel_utilization": 11.64, "uptime_seconds": 27218, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 767, "long_name": "Storm Elk", "next_hop": 218, "num": "0x57ea150a", "position": {"altitude": 1672, "latitude": 33.139872, "location_source": "LOC_INTERNAL", "longitude": -107.601553, "time_offset_sec": 1059}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦅", "snr": 7.44, "status": null, "telemetry": {"air_util_tx": 0.49, "battery_level": 12, "channel_utilization": 16.07, "uptime_seconds": 65688, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 86, "long_name": "Drowsy Colt", "next_hop": 0, "num": "0x58019194", "position": null, "public_key_hex": "83d44695213a6c2e3e7b8199d667cc4422aa1f105a06606e8ad735f0f31fad3d", "role": "CLIENT", "short_name": "DOTX", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.789, "battery_level": 41, "channel_utilization": 8.52, "uptime_seconds": 40428, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.55, "iaq": 11, "relative_humidity": 52.67, "temperature": 17.55}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 282, "long_name": "Silent Eagle", "next_hop": 193, "num": "0x58120c20", "position": {"altitude": 1506, "latitude": 34.156483, "location_source": "LOC_INTERNAL", "longitude": -107.844297, "time_offset_sec": 534}, "public_key_hex": "9e28736ff9ccd1f7033aa70be981cc60122bcd59a864556342e4439192f7e2c0", "role": "CLIENT_HIDDEN", "short_name": "S4BP", "snr": 6.46, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 569, "long_name": "Silver Bison", "next_hop": 153, "num": "0x5834a12f", "position": {"altitude": 1573, "latitude": 33.428672, "location_source": "LOC_INTERNAL", "longitude": -106.865299, "time_offset_sec": 803}, "public_key_hex": "42d8535e455511418d790d1b98febc5a3f3e06ac7f11f7d30cac86e68eefcf28", "role": "TRACKER", "short_name": "SKGB", "snr": 2.66, "status": null, "telemetry": {"air_util_tx": 0.422, "battery_level": 19, "channel_utilization": 7.35, "uptime_seconds": 42645, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 177, "long_name": "New Cactus", "next_hop": 202, "num": "0x583944c9", "position": {"altitude": 1551, "latitude": 32.391009, "location_source": "LOC_INTERNAL", "longitude": -107.310304, "time_offset_sec": 191}, "public_key_hex": "7fa8a26505db0e3a190d77febd34c51cb53c841802f4d86277707ee5d3c5612f", "role": "CLIENT", "short_name": "N7M1", "snr": 7.36, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.167, "battery_level": 44, "channel_utilization": 3.68, "uptime_seconds": 138357, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4269, "long_name": "Roving Cougar", "next_hop": 0, "num": "0x584898c9", "position": null, "public_key_hex": "e4f3bcc7513f57f5d0b05c7eca5efab3b567b72105af3b63af74d5734b7eaa00", "role": "CLIENT_MUTE", "short_name": "RI72", "snr": 6.29, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.23, "iaq": 71, "relative_humidity": 60.5, "temperature": 37.32}, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 7746, "long_name": "Solar Bass", "next_hop": 0, "num": "0x5858d4ae", "position": {"altitude": 1829, "latitude": 33.312278, "location_source": "LOC_INTERNAL", "longitude": -107.496528, "time_offset_sec": 8031}, "public_key_hex": "1176dc5378430aac8c2087f4f5ce62846a5dd3ed8f91901ae1965d0348dd347c", "role": "CLIENT_MUTE", "short_name": "SE1H", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.289, "battery_level": 78, "channel_utilization": 19.87, "uptime_seconds": 107768, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.75, "iaq": 61, "relative_humidity": 78.58, "temperature": 22.03}, "hops_away": 0, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 7361, "long_name": "Smooth Oak", "next_hop": 0, "num": "0x585ba710", "position": {"altitude": 1182, "latitude": 33.132912, "location_source": "LOC_INTERNAL", "longitude": -107.329961, "time_offset_sec": 7459}, "public_key_hex": "beee70b7f207dfa937cad56cd126764177946f4bfb94c1b3d439306f240f7f7a", "role": "CLIENT", "short_name": "SBXU", "snr": -0.25, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 797, "long_name": "Dawn Wolf", "next_hop": 0, "num": "0x58801022", "position": {"altitude": 1323, "latitude": 32.624453, "location_source": "LOC_INTERNAL", "longitude": -106.826962, "time_offset_sec": 888}, "public_key_hex": "f232ab4f800688f278e833f27e04bd4a8d9fd948d8ba63ac0db319938651cfc7", "role": "ROUTER_LATE", "short_name": "DCYR", "snr": 9.48, "status": null, "telemetry": {"air_util_tx": 0.499, "battery_level": 29, "channel_utilization": 15.2, "uptime_seconds": 80004, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4240, "long_name": "Gold Pine", "next_hop": 0, "num": "0x58997529", "position": {"altitude": 1554, "latitude": 33.773066, "location_source": "LOC_INTERNAL", "longitude": -107.22931, "time_offset_sec": 4294}, "public_key_hex": "a60ed6f8ac1b1eda99f8cbddbe59359904d646c8abcc1dd4adb85bbb3d9f2bda", "role": "CLIENT", "short_name": "GSLV", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 8562, "long_name": "Tall Falcon", "next_hop": 147, "num": "0x58a252da", "position": {"altitude": 1356, "latitude": 32.080146, "location_source": "LOC_INTERNAL", "longitude": -106.85084, "time_offset_sec": 8582}, "public_key_hex": "8aedd8facf3224847483c49ef8614faf1a3e9d096fd3e5182b721f104bb7e7f2", "role": "CLIENT", "short_name": "🌊", "snr": 8.74, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.49, "iaq": 7, "relative_humidity": 23.35, "temperature": 21.85}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1163, "long_name": "Smooth Raven", "next_hop": 0, "num": "0x58a787d8", "position": null, "public_key_hex": "cde1699284fc93dbc1a1c8ac99081abfb80750ca12f760d79e5f68f02dda0db8", "role": "CLIENT", "short_name": "SQ7F", "snr": 9.07, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 12561, "long_name": "Silent Turtle", "next_hop": 0, "num": "0x58adfb48", "position": {"altitude": 1327, "latitude": 33.134898, "location_source": "LOC_INTERNAL", "longitude": -107.929845, "time_offset_sec": 12614}, "public_key_hex": "b4a8663326e94b672bdec827e42ee52ff1432520f30a8b19c82e3ad7ee8f41c1", "role": "SENSOR", "short_name": "SST2", "snr": 6.25, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.004, "battery_level": 46, "channel_utilization": 1.55, "uptime_seconds": 247945, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3921, "long_name": "Blue Bison", "next_hop": 0, "num": "0x58d78a5b", "position": {"altitude": 1583, "latitude": 33.667938, "location_source": "LOC_INTERNAL", "longitude": -107.376501, "time_offset_sec": 4044}, "public_key_hex": "651a2daabbbbb85d57040554a933143a8ff0f4c08a606b83ad8e1332b8e3082b", "role": "CLIENT", "short_name": "B9CE", "snr": 4.42, "status": null, "telemetry": {"air_util_tx": 0.853, "battery_level": 59, "channel_utilization": 6.26, "uptime_seconds": 75463, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 2985, "long_name": "Happy Adder", "next_hop": 133, "num": "0x58dc7149", "position": {"altitude": 1883, "latitude": 32.921584, "location_source": "LOC_INTERNAL", "longitude": -107.827258, "time_offset_sec": 3263}, "public_key_hex": "", "role": "CLIENT", "short_name": "HNWN", "snr": 6.46, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2707, "long_name": "Dusk Beaver", "next_hop": 0, "num": "0x58f2d9d1", "position": {"altitude": 1538, "latitude": 34.017564, "location_source": "LOC_INTERNAL", "longitude": -107.037412, "time_offset_sec": 2898}, "public_key_hex": "7a6accb1dc2c9ceeac4a5e78559a4e553585a9b089b61d04201c9fad76208130", "role": "CLIENT", "short_name": "🌲", "snr": 4.26, "status": null, "telemetry": {"air_util_tx": 0.512, "battery_level": 43, "channel_utilization": 4.89, "uptime_seconds": 77322, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 5162, "long_name": "Sunny Falcon", "next_hop": 183, "num": "0x58ff28a2", "position": null, "public_key_hex": "c2678a105c4094ec835c9b2d26efce09baac283135f3a212cf3dcc04db88e5a0", "role": "CLIENT", "short_name": "SGSA", "snr": 0.56, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.269, "battery_level": 67, "channel_utilization": 8.63, "uptime_seconds": 7855, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 832, "long_name": "Floating Doe", "next_hop": 0, "num": "0x591510b1", "position": {"altitude": 1784, "latitude": 32.331029, "location_source": "LOC_INTERNAL", "longitude": -107.855433, "time_offset_sec": 1086}, "public_key_hex": "af9325182e057fea1a3eef53a7865a1ad814343cff61f7c433ae1adf60f8a501", "role": "ROUTER", "short_name": "FBD5", "snr": 1.4, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1371, "long_name": "Sleepy Cedar", "next_hop": 64, "num": "0x59375d89", "position": {"altitude": 1179, "latitude": 33.728187, "location_source": "LOC_INTERNAL", "longitude": -107.452644, "time_offset_sec": 1403}, "public_key_hex": "e7493a239cf22747ba3ead4c0e0c25b91c34eab12fabd3f4f7cdc222ed2a0923", "role": "CLIENT_MUTE", "short_name": "🐺", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1666, "long_name": "Lunar Wolf K19YG", "next_hop": 184, "num": "0x5973e4d5", "position": {"altitude": 938, "latitude": 33.050502, "location_source": "LOC_INTERNAL", "longitude": -106.868198, "time_offset_sec": 1679}, "public_key_hex": "507b0957cfb2445fb8e96d88e07b9539847ba108d972d40827c1d30c11e783a1", "role": "SENSOR", "short_name": "LVDT", "snr": 9.74, "status": null, "telemetry": {"air_util_tx": 0.463, "battery_level": 67, "channel_utilization": 12.0, "uptime_seconds": 268533, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1804, "long_name": "Copper Seal", "next_hop": 0, "num": "0x5985eb84", "position": {"altitude": 1861, "latitude": 32.79457, "location_source": "LOC_INTERNAL", "longitude": -107.602084, "time_offset_sec": 1938}, "public_key_hex": "", "role": "CLIENT", "short_name": "CV4I", "snr": -2.44, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 830, "long_name": "Happy Owl", "next_hop": 0, "num": "0x599e40fa", "position": {"altitude": 1548, "latitude": 33.692239, "location_source": "LOC_INTERNAL", "longitude": -107.226339, "time_offset_sec": 836}, "public_key_hex": "91cae143084c1ff01713114beaf2fd40124f45fde592c7f7e99fbce4cd4f7e27", "role": "CLIENT", "short_name": "HY2W", "snr": 0.63, "status": null, "telemetry": {"air_util_tx": 0.442, "battery_level": 63, "channel_utilization": 25.16, "uptime_seconds": 23683, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4946, "long_name": "Short Adder", "next_hop": 0, "num": "0x599e6f35", "position": {"altitude": 1776, "latitude": 33.685694, "location_source": "LOC_INTERNAL", "longitude": -107.439804, "time_offset_sec": 5086}, "public_key_hex": "87279ae416a3553867fdddf093420c962c37c8d0151be7a6055aa93214884f03", "role": "CLIENT", "short_name": "SQ4W", "snr": 7.09, "status": null, "telemetry": {"air_util_tx": 0.422, "battery_level": 87, "channel_utilization": 24.2, "uptime_seconds": 976, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1283, "long_name": "Sky Tortoise", "next_hop": 0, "num": "0x59a217c6", "position": {"altitude": 1406, "latitude": 32.948806, "location_source": "LOC_INTERNAL", "longitude": -107.000903, "time_offset_sec": 1540}, "public_key_hex": "5c7e58751e04e3e701ebfcfbb50c2b48a1a1226ca74e94f52460528ed2008a7b", "role": "CLIENT", "short_name": "SXJO", "snr": -0.52, "status": null, "telemetry": {"air_util_tx": 0.877, "battery_level": 11, "channel_utilization": 3.36, "uptime_seconds": 47198, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 11529, "long_name": "Hidden Aspen", "next_hop": 66, "num": "0x59a8174d", "position": {"altitude": 996, "latitude": 33.416316, "location_source": "LOC_INTERNAL", "longitude": -106.433464, "time_offset_sec": 11781}, "public_key_hex": "6d68f878a04c14d965d3cb3c9dab991509beaf90f2badbc8933c8d487120ebb4", "role": "CLIENT", "short_name": "HSRR", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 647, "long_name": "Floating Hawk", "next_hop": 0, "num": "0x59b5c8c3", "position": {"altitude": 1713, "latitude": 34.53796, "location_source": "LOC_INTERNAL", "longitude": -106.974797, "time_offset_sec": 767}, "public_key_hex": "d423c5eeb4bd83e5fee994638b233feecb2ef0dfe65063d12a69becdc1495f81", "role": "CLIENT", "short_name": "🦊", "snr": 1.33, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.526, "battery_level": 38, "channel_utilization": 23.48, "uptime_seconds": 94207, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 3677, "long_name": "Lost Lynx", "next_hop": 192, "num": "0x59b8523f", "position": {"altitude": 1499, "latitude": 33.270722, "location_source": "LOC_INTERNAL", "longitude": -107.183842, "time_offset_sec": 3719}, "public_key_hex": "74c586007c069b89c831384efb692bc0883b402acf6e6482c207834dda13b322", "role": "CLIENT", "short_name": "LJ7H", "snr": 3.71, "status": null, "telemetry": {"air_util_tx": 0.259, "battery_level": 47, "channel_utilization": 11.6, "uptime_seconds": 14476, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2087, "long_name": "Dawn Shark", "next_hop": 0, "num": "0x59df464b", "position": null, "public_key_hex": "65e12f2b63bfef22f6b15c5671303d8bb6b0ae2a5c084b645502ee1d976c03d0", "role": "CLIENT", "short_name": "D2K6", "snr": 4.93, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 3, "environment": null, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 1244, "long_name": "Lone Bass", "next_hop": 2, "num": "0x59eab0a8", "position": {"altitude": 1055, "latitude": 34.019619, "location_source": "LOC_INTERNAL", "longitude": -106.847776, "time_offset_sec": 1517}, "public_key_hex": "8d7d0c95b3dd99976f61eaebd8a350d4e42299552a4728e0ea9020a3e6f7a02d", "role": "CLIENT", "short_name": "LCQ1", "snr": 6.99, "status": null, "telemetry": {"air_util_tx": 1.271, "battery_level": 94, "channel_utilization": 6.45, "uptime_seconds": 80336, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4744, "long_name": "Howling Falcon", "next_hop": 93, "num": "0x59f47caa", "position": {"altitude": 1122, "latitude": 33.153048, "location_source": "LOC_INTERNAL", "longitude": -107.586137, "time_offset_sec": 4928}, "public_key_hex": "cdf50263a03da2cffd3ef22e4455bfe933ad024e0c0f06d26cc5e01dace1ff2d", "role": "CLIENT", "short_name": "HCLS", "snr": 2.5, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4253, "long_name": "Brave Crane", "next_hop": 94, "num": "0x59f54727", "position": {"altitude": 1386, "latitude": 34.261151, "location_source": "LOC_INTERNAL", "longitude": -107.352924, "time_offset_sec": 4549}, "public_key_hex": "e44d0ae5352f1aae263b0911669f85c3950613d3ab3f66f6c6771abd7950fbb1", "role": "CLIENT", "short_name": "BE9H", "snr": 4.87, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.136, "battery_level": 45, "channel_utilization": 3.95, "uptime_seconds": 101134, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 253, "long_name": "Giant Bronco", "next_hop": 0, "num": "0x59f8f9ec", "position": {"altitude": 1718, "latitude": 33.582462, "location_source": "LOC_INTERNAL", "longitude": -107.19666, "time_offset_sec": 295}, "public_key_hex": "9dce5b91e7a0bd4f19dc467c47afb89ca2fac32a7829d32dd929b094c97b6a29", "role": "CLIENT", "short_name": "GHAF", "snr": 7.03, "status": null, "telemetry": {"air_util_tx": 0.334, "battery_level": 17, "channel_utilization": 7.59, "uptime_seconds": 80697, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3694, "long_name": "Found Gecko", "next_hop": 11, "num": "0x5a072878", "position": {"altitude": 1131, "latitude": 33.893708, "location_source": "LOC_INTERNAL", "longitude": -106.579708, "time_offset_sec": 3901}, "public_key_hex": "33e6de87b3b717cade27f6c4f96cd2f09d394634dcd268ed314548ddb8eba317", "role": "CLIENT", "short_name": "FS1I", "snr": -0.17, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.435, "battery_level": 63, "channel_utilization": 14.13, "uptime_seconds": 89064, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5571, "long_name": "Blue Shark", "next_hop": 22, "num": "0x5a21a1b1", "position": {"altitude": 1424, "latitude": 32.970007, "location_source": "LOC_INTERNAL", "longitude": -107.490756, "time_offset_sec": 5739}, "public_key_hex": "7a14aacc578e6f14b46f15fb029faa654af5f2fdd14f72fc9ea9c047730f7754", "role": "CLIENT", "short_name": "BELE", "snr": 7.99, "status": null, "telemetry": {"air_util_tx": 1.826, "battery_level": 42, "channel_utilization": 12.37, "uptime_seconds": 29230, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2993, "long_name": "Hidden Adder", "next_hop": 11, "num": "0x5a37ad9a", "position": null, "public_key_hex": "9310ad7506de234e70559d49facc3f344eb66d924a7690ec9073217c66f10a2a", "role": "CLIENT", "short_name": "H5PH", "snr": 1.74, "status": null, "telemetry": {"air_util_tx": 0.379, "battery_level": 69, "channel_utilization": 22.63, "uptime_seconds": 123453, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1026.08, "iaq": 0, "relative_humidity": 25.42, "temperature": 25.68}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1405, "long_name": "Frozen Bear", "next_hop": 7, "num": "0x5a43076d", "position": {"altitude": 1497, "latitude": 32.790063, "location_source": "LOC_INTERNAL", "longitude": -107.947417, "time_offset_sec": 1582}, "public_key_hex": "24dae56c5a3108219d286891a38f801ffbfd265a22d3554d8be5a47273e390c2", "role": "CLIENT", "short_name": "F278", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.451, "battery_level": 10, "channel_utilization": 11.18, "uptime_seconds": 21227, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 6604, "long_name": "Drifting Fox", "next_hop": 0, "num": "0x5a471e26", "position": {"altitude": 1317, "latitude": 33.078202, "location_source": "LOC_INTERNAL", "longitude": -107.293272, "time_offset_sec": 6675}, "public_key_hex": "877de5c0cf88008e3d92b577e992b22e26d1b2a46e6b19a5f33cb60b18b2fed2", "role": "CLIENT", "short_name": "DLSE", "snr": 3.41, "status": null, "telemetry": {"air_util_tx": 0.549, "battery_level": 61, "channel_utilization": 18.93, "uptime_seconds": 227283, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 8653, "long_name": "Found Sage", "next_hop": 0, "num": "0x5a589c8b", "position": null, "public_key_hex": "c8263ce75625f3ea5cdc7368d67721ab53615954cfcb049e82f72f2d92774aaf", "role": "CLIENT", "short_name": "FG1M", "snr": 6.13, "status": null, "telemetry": {"air_util_tx": 0.107, "battery_level": 26, "channel_utilization": 8.43, "uptime_seconds": 31581, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2212, "long_name": "Burning Lynx", "next_hop": 0, "num": "0x5a65af04", "position": {"altitude": 1543, "latitude": 33.608109, "location_source": "LOC_INTERNAL", "longitude": -106.579091, "time_offset_sec": 2228}, "public_key_hex": "706e9d3f2f6a99303257a0e0489a74530589568b69ba6b883141058b006a2b46", "role": "CLIENT", "short_name": "🐝", "snr": 8.39, "status": null, "telemetry": {"air_util_tx": 1.172, "battery_level": 69, "channel_utilization": 11.26, "uptime_seconds": 58231, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 424, "long_name": "Shady Mamba", "next_hop": 0, "num": "0x5a6c456a", "position": {"altitude": 1178, "latitude": 33.74272, "location_source": "LOC_INTERNAL", "longitude": -107.43322, "time_offset_sec": 553}, "public_key_hex": "f5b71b4ce4ee89ec55489e2e9816ea9a76fad220d11d848b5f18bb0c7557089d", "role": "TAK_TRACKER", "short_name": "S6T5", "snr": 0.76, "status": null, "telemetry": {"air_util_tx": 0.032, "battery_level": 31, "channel_utilization": 3.13, "uptime_seconds": 28463, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1877, "long_name": "Smooth Cougar", "next_hop": 0, "num": "0x5a709531", "position": {"altitude": 1083, "latitude": 32.438358, "location_source": "LOC_INTERNAL", "longitude": -107.177367, "time_offset_sec": 1949}, "public_key_hex": "312f52b7f49430c7534b70b6566dd9a4d5550d99f2c17ed6e04325e6c8ecc8e1", "role": "CLIENT", "short_name": "SS0G", "snr": 6.24, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.112, "battery_level": 18, "channel_utilization": 6.26, "uptime_seconds": 6698, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 95, "long_name": "Iron Lynx", "next_hop": 0, "num": "0x5a732731", "position": null, "public_key_hex": "265bed2b9b5585780b567a3149e7703e21eb74731d716d335fa51d2f5706cb7e", "role": "CLIENT", "short_name": "IK00", "snr": 9.29, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3508, "long_name": "Silver Seal", "next_hop": 0, "num": "0x5a914936", "position": {"altitude": 1138, "latitude": 33.289485, "location_source": "LOC_INTERNAL", "longitude": -107.029907, "time_offset_sec": 3639}, "public_key_hex": "9d93b08394cec2e090132cda6aceeaa9819693ebf869a7bd35a06312446d8346", "role": "CLIENT", "short_name": "SBNV", "snr": -0.42, "status": null, "telemetry": {"air_util_tx": 0.794, "battery_level": 22, "channel_utilization": 0.66, "uptime_seconds": 75189, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 999.8, "iaq": 84, "relative_humidity": 66.72, "temperature": 17.76}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3200, "long_name": "Slow Coyote", "next_hop": 0, "num": "0x5ae27f5c", "position": {"altitude": 1185, "latitude": 33.035911, "location_source": "LOC_INTERNAL", "longitude": -107.347549, "time_offset_sec": 3279}, "public_key_hex": "cf6735dc10475729ad5802b8262f665fcb8c7802a565febe34a1d05d04431029", "role": "TRACKER", "short_name": "SH2D", "snr": 11.33, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.13, "battery_level": 96, "channel_utilization": 3.5, "uptime_seconds": 369, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1418, "long_name": "Iron Shark", "next_hop": 171, "num": "0x5aee37fd", "position": {"altitude": 1343, "latitude": 34.003409, "location_source": "LOC_INTERNAL", "longitude": -108.168339, "time_offset_sec": 1534}, "public_key_hex": "61f7ee4f02ee2fb17065fc4b4d12d504bfa0e95c0df7f0b9a960be530f049018", "role": "ROUTER", "short_name": "IQNJ", "snr": -1.24, "status": null, "telemetry": {"air_util_tx": 0.322, "battery_level": 52, "channel_utilization": 5.76, "uptime_seconds": 55254, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 1882, "long_name": "Smooth Owl", "next_hop": 129, "num": "0x5af234b7", "position": {"altitude": 1551, "latitude": 32.779714, "location_source": "LOC_INTERNAL", "longitude": -107.853521, "time_offset_sec": 2000}, "public_key_hex": "8f352e3bab86294ec2c9d479cee686c4fb8b86162b691645846170ec2f5a90fe", "role": "CLIENT", "short_name": "S3LQ", "snr": 4.96, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.192, "battery_level": 14, "channel_utilization": 8.75, "uptime_seconds": 84217, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.05, "iaq": 57, "relative_humidity": 64.56, "temperature": 28.29}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 3704, "long_name": "Dusk Crane", "next_hop": 0, "num": "0x5af8bbef", "position": null, "public_key_hex": "b7225c9383f713265a4662b8a262c11e10cf76951cafb7315fb0bd7ffcbd11a2", "role": "CLIENT_MUTE", "short_name": "DAYN", "snr": 7.14, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 5208, "long_name": "Sneaky Mustang", "next_hop": 22, "num": "0x5afc3817", "position": {"altitude": 1445, "latitude": 32.858035, "location_source": "LOC_INTERNAL", "longitude": -107.768384, "time_offset_sec": 5256}, "public_key_hex": "49c512a8f8c6097ffcda93438b6a150ec7de4d9c7396e125fb1f779b8eb148bb", "role": "CLIENT", "short_name": "SG8Z", "snr": 5.89, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.05, "iaq": 82, "relative_humidity": 80.09, "temperature": 23.78}, "hops_away": 1, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 708, "long_name": "New Cougar", "next_hop": 154, "num": "0x5b008c58", "position": null, "public_key_hex": "0f558d7dfaa237ab35d7eb91c81e94d3ae484a18becd4fe907326c618a7e7792", "role": "CLIENT_BASE", "short_name": "NGLL", "snr": 9.54, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2102, "long_name": "Shady Owl", "next_hop": 0, "num": "0x5b09b8ee", "position": null, "public_key_hex": "e260be0e2c3cd8efdc6dabbcb82a5b8261e3b9faa439fc621f6a2545fc962e13", "role": "CLIENT", "short_name": "SLEC", "snr": 3.06, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7343, "long_name": "Dawn Stag", "next_hop": 0, "num": "0x5b0afcbe", "position": {"altitude": 1063, "latitude": 33.164976, "location_source": "LOC_INTERNAL", "longitude": -106.938749, "time_offset_sec": 7440}, "public_key_hex": "a918e5ed46fffeb370e9a86523d92fbfc66c49422a2e8951f9f34b9035189058", "role": "CLIENT", "short_name": "D4MA", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.577, "battery_level": 31, "channel_utilization": 8.78, "uptime_seconds": 94720, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.3, "iaq": 28, "relative_humidity": 63.86, "temperature": 15.12}, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 4899, "long_name": "Dusk Wolf", "next_hop": 155, "num": "0x5b0cf7ec", "position": {"altitude": 1175, "latitude": 33.879708, "location_source": "LOC_INTERNAL", "longitude": -106.839525, "time_offset_sec": 5164}, "public_key_hex": "2fce889fea3ba94d84c073ac85f1fe3018cb37c2eb12c83b6fcb10dbcb711a02", "role": "CLIENT", "short_name": "DXXA", "snr": 4.64, "status": null, "telemetry": {"air_util_tx": 0.841, "battery_level": 18, "channel_utilization": 19.22, "uptime_seconds": 95489, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2221, "long_name": "Frosty Salmon", "next_hop": 0, "num": "0x5b155a42", "position": {"altitude": 1028, "latitude": 32.264989, "location_source": "LOC_INTERNAL", "longitude": -106.842787, "time_offset_sec": 2412}, "public_key_hex": "b9cbe8c31ff3e97dae927cc884987d56d93d63c715c281d7a05004e3bd5f97f2", "role": "CLIENT", "short_name": "F68M", "snr": 4.91, "status": null, "telemetry": {"air_util_tx": 1.313, "battery_level": 22, "channel_utilization": 8.92, "uptime_seconds": 177824, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 4252, "long_name": "Roving Trout", "next_hop": 0, "num": "0x5b62768c", "position": {"altitude": 1682, "latitude": 34.066271, "location_source": "LOC_INTERNAL", "longitude": -107.893429, "time_offset_sec": 4471}, "public_key_hex": "4e53de607ebf864e886b31087c60a13eb3e59c5f70df0f5dbd9c77be47cccc0d", "role": "CLIENT", "short_name": "R44O", "snr": 0.81, "status": null, "telemetry": {"air_util_tx": 0.141, "battery_level": 93, "channel_utilization": 6.05, "uptime_seconds": 122874, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "M5STACK_C6L", "last_heard_offset_sec": 2759, "long_name": "Green Hare", "next_hop": 0, "num": "0x5b6e6800", "position": {"altitude": 1285, "latitude": 33.414752, "location_source": "LOC_INTERNAL", "longitude": -106.478904, "time_offset_sec": 2760}, "public_key_hex": "241b31ff9e51519aa96a430119d9976f0134f2b6fe52e4d05a34fe85b1aeb329", "role": "CLIENT", "short_name": "GX6P", "snr": 7.98, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 344, "long_name": "Frozen Mustang", "next_hop": 66, "num": "0x5b77a103", "position": null, "public_key_hex": "56d2c3560fba09ac2af0d5044dad514bfdee57a4128ccbf8a1a43a0dc91ae964", "role": "CLIENT", "short_name": "FS53", "snr": 1.66, "status": null, "telemetry": {"air_util_tx": 0.42, "battery_level": 71, "channel_utilization": 17.48, "uptime_seconds": 134931, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 1235, "long_name": "Steel Salmon", "next_hop": 55, "num": "0x5b931679", "position": {"altitude": 1443, "latitude": 32.928843, "location_source": "LOC_INTERNAL", "longitude": -107.737236, "time_offset_sec": 1417}, "public_key_hex": "064d4fb315d0ebabffe8da6c254f5082a8da800e31b4365c18ed7005ad67fe29", "role": "CLIENT", "short_name": "SEHC", "snr": 10.12, "status": null, "telemetry": {"air_util_tx": 0.37, "battery_level": 51, "channel_utilization": 15.4, "uptime_seconds": 131345, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 455, "long_name": "Bright Wolf", "next_hop": 0, "num": "0x5ba1a2ae", "position": {"altitude": 1499, "latitude": 32.804847, "location_source": "LOC_INTERNAL", "longitude": -105.80722, "time_offset_sec": 632}, "public_key_hex": "278b3d2ed10e35d473ea0823be3ee85f3d8ffb851c31f7324d275b1bc688e44b", "role": "CLIENT", "short_name": "BL44", "snr": 8.55, "status": null, "telemetry": {"air_util_tx": 1.136, "battery_level": 52, "channel_utilization": 2.65, "uptime_seconds": 36264, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 891, "long_name": "White Pike", "next_hop": 129, "num": "0x5bb18b68", "position": {"altitude": 1434, "latitude": 33.123595, "location_source": "LOC_INTERNAL", "longitude": -108.314567, "time_offset_sec": 1103}, "public_key_hex": "5de4766378f4b76dad9ff31413fa7b1baa9d302c8e604e0c5aefc436b18923e0", "role": "CLIENT", "short_name": "WTYT", "snr": 2.32, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.501, "battery_level": 22, "channel_utilization": 10.97, "uptime_seconds": 39279, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 2533, "long_name": "Dusk Squirrel", "next_hop": 71, "num": "0x5bb7cde2", "position": {"altitude": 1320, "latitude": 33.794568, "location_source": "LOC_INTERNAL", "longitude": -106.598002, "time_offset_sec": 2593}, "public_key_hex": "21509334b039e52388cf408ba5832441dc312e55ac2a68855b8e7890a823450a", "role": "ROUTER", "short_name": "DETL", "snr": 2.88, "status": null, "telemetry": {"air_util_tx": 0.264, "battery_level": 20, "channel_utilization": 28.76, "uptime_seconds": 58486, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3745, "long_name": "New Bison", "next_hop": 0, "num": "0x5be470c4", "position": {"altitude": 1512, "latitude": 34.42956, "location_source": "LOC_INTERNAL", "longitude": -106.390332, "time_offset_sec": 3891}, "public_key_hex": "6705bfb819ce6303a2743383d599b41aaa1e759529877fe2843324a1378a5751", "role": "CLIENT", "short_name": "NVQP", "snr": 8.83, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.53, "iaq": 117, "relative_humidity": 49.13, "temperature": 19.0}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1835, "long_name": "Old Mole", "next_hop": 0, "num": "0x5bf482b3", "position": {"altitude": 1555, "latitude": 33.619766, "location_source": "LOC_INTERNAL", "longitude": -107.20319, "time_offset_sec": 1867}, "public_key_hex": "166f65ac781715a87f3c5a1b337f3daad854b15751b6748db283ccdf81596632", "role": "CLIENT", "short_name": "OEML", "snr": 7.06, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8135, "long_name": "Shady Wolf KX7AM", "next_hop": 0, "num": "0x5c0d6199", "position": {"altitude": 1485, "latitude": 32.555279, "location_source": "LOC_INTERNAL", "longitude": -107.129074, "time_offset_sec": 8388}, "public_key_hex": "dc1bd791a8438cebd9d3833b73c2c33399c8eaaebba1ff57d7beaa788cc8877a", "role": "CLIENT", "short_name": "SJTL", "snr": 5.78, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.664, "battery_level": 13, "channel_utilization": 23.5, "uptime_seconds": 294124, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 1578, "long_name": "Shady Wolf", "next_hop": 0, "num": "0x5c176787", "position": {"altitude": 1193, "latitude": 33.885453, "location_source": "LOC_INTERNAL", "longitude": -107.591486, "time_offset_sec": 1705}, "public_key_hex": "efd82ba19967332952a1a653cd3609de9e62b86a7017a872f8d7a70054d1baca", "role": "CLIENT", "short_name": "SDSR", "snr": 2.74, "status": null, "telemetry": {"air_util_tx": 0.411, "battery_level": 36, "channel_utilization": 11.95, "uptime_seconds": 123894, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "NOMADSTAR_METEOR_PRO", "last_heard_offset_sec": 10505, "long_name": "Sneaky Cougar", "next_hop": 31, "num": "0x5c1d1b3a", "position": {"altitude": 1334, "latitude": 32.847893, "location_source": "LOC_INTERNAL", "longitude": -107.535607, "time_offset_sec": 10706}, "public_key_hex": "489905becbf40fb6af19360d9863095a3b773dc8bcbabed0f1af6482d4781751", "role": "CLIENT", "short_name": "S404", "snr": -1.17, "status": null, "telemetry": {"air_util_tx": 1.256, "battery_level": 50, "channel_utilization": 22.61, "uptime_seconds": 103398, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1957, "long_name": "Wandering Bison", "next_hop": 0, "num": "0x5c27546e", "position": {"altitude": 906, "latitude": 33.141951, "location_source": "LOC_INTERNAL", "longitude": -107.158245, "time_offset_sec": 2065}, "public_key_hex": "39fa1b95e3c2a51798c811448dbb58b73014ffad4c3c9d26932a099aa63b4a2d", "role": "CLIENT_HIDDEN", "short_name": "WAYC", "snr": 5.18, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.967, "battery_level": 68, "channel_utilization": 3.25, "uptime_seconds": 48120, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.21, "iaq": 57, "relative_humidity": 58.23, "temperature": 25.66}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4069, "long_name": "Blue Mustang", "next_hop": 0, "num": "0x5c2ed804", "position": {"altitude": 1727, "latitude": 32.958096, "location_source": "LOC_INTERNAL", "longitude": -107.405432, "time_offset_sec": 4215}, "public_key_hex": "", "role": "CLIENT", "short_name": "🐝", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.437, "battery_level": 44, "channel_utilization": 1.47, "uptime_seconds": 81404, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 846, "long_name": "Silver Mustang", "next_hop": 60, "num": "0x5c3ddc8c", "position": {"altitude": 1150, "latitude": 34.757952, "location_source": "LOC_INTERNAL", "longitude": -106.892131, "time_offset_sec": 1060}, "public_key_hex": "54f03df4e8b06092b9208d3a741f90508e3b989ebe00c4053082f7f1d8ece96f", "role": "CLIENT", "short_name": "SI3X", "snr": 1.52, "status": null, "telemetry": {"air_util_tx": 0.684, "battery_level": 101, "channel_utilization": 10.32, "uptime_seconds": 39991, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 7348, "long_name": "Silver Mole", "next_hop": 0, "num": "0x5c41c1ac", "position": {"altitude": 1124, "latitude": 32.513133, "location_source": "LOC_INTERNAL", "longitude": -105.891691, "time_offset_sec": 7514}, "public_key_hex": "e55060e2669ed42f60ccea8d14f09add17425879c2051764f2c184c1cb03e1bf", "role": "CLIENT", "short_name": "S50E", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.519, "battery_level": 42, "channel_utilization": 31.19, "uptime_seconds": 6643, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2453, "long_name": "Silver Cactus KX4EV", "next_hop": 0, "num": "0x5c61c8c7", "position": {"altitude": 879, "latitude": 33.915389, "location_source": "LOC_INTERNAL", "longitude": -106.474286, "time_offset_sec": 2667}, "public_key_hex": "b6d3a94019a580bf038ab9e1000c869460c2fddb9b1cf27c67f8d1f2fb312233", "role": "CLIENT", "short_name": "SCDH", "snr": 5.43, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.218, "battery_level": 31, "channel_utilization": 6.59, "uptime_seconds": 82467, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1735, "long_name": "Hidden Hawk", "next_hop": 0, "num": "0x5c62118a", "position": {"altitude": 1628, "latitude": 33.704149, "location_source": "LOC_INTERNAL", "longitude": -107.701874, "time_offset_sec": 1805}, "public_key_hex": "a38fae5f7b6e318450dca4cc90c75dbe7bc4a976845acb43b4ece5a643af20b7", "role": "CLIENT", "short_name": "HT2A", "snr": 10.01, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.711, "battery_level": 84, "channel_utilization": 14.03, "uptime_seconds": 71413, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 9127, "long_name": "Blue Bronco", "next_hop": 0, "num": "0x5c9181e3", "position": {"altitude": 889, "latitude": 32.865413, "location_source": "LOC_INTERNAL", "longitude": -107.120894, "time_offset_sec": 9260}, "public_key_hex": "41974c7f76f7b11068abcadc8b75495e233ace7aa2f521ebde831ecf1ebcb641", "role": "CLIENT", "short_name": "B0UK", "snr": 8.86, "status": null, "telemetry": {"air_util_tx": 0.962, "battery_level": 12, "channel_utilization": 24.03, "uptime_seconds": 14116, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1035, "long_name": "Happy Beaver", "next_hop": 0, "num": "0x5cb5115f", "position": {"altitude": 919, "latitude": 33.850824, "location_source": "LOC_INTERNAL", "longitude": -106.958517, "time_offset_sec": 1311}, "public_key_hex": "682605f8586fd7b5fd52008c700884e4ee250a3000f974ca11b27e8d5f923435", "role": "CLIENT", "short_name": "H79I", "snr": 9.97, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.102, "battery_level": 101, "channel_utilization": 13.06, "uptime_seconds": 6126, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 3630, "long_name": "Dawn Dolphin", "next_hop": 0, "num": "0x5cbdae47", "position": {"altitude": 1293, "latitude": 33.094553, "location_source": "LOC_INTERNAL", "longitude": -107.03027, "time_offset_sec": 3797}, "public_key_hex": "ff1d8cde8fc5821f10235448914d9e3e2cd1d95320f18374bb154f76f5756c7a", "role": "CLIENT", "short_name": "D38R", "snr": 12.0, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.391, "battery_level": 55, "channel_utilization": 7.46, "uptime_seconds": 145778, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 3733, "long_name": "Solar Trout", "next_hop": 0, "num": "0x5cbe0ad8", "position": null, "public_key_hex": "2688d02132c8909658c5607c664d38114eaff600ab0fca570f15c93f0aa390b7", "role": "CLIENT", "short_name": "SOF1", "snr": 5.41, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.853, "battery_level": 50, "channel_utilization": 10.32, "uptime_seconds": 6155, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "XIAO_NRF52_KIT", "last_heard_offset_sec": 400, "long_name": "Quick Bear", "next_hop": 35, "num": "0x5cc52f4e", "position": {"altitude": 1393, "latitude": 32.575017, "location_source": "LOC_INTERNAL", "longitude": -107.136511, "time_offset_sec": 609}, "public_key_hex": "64822b5bf3f58f9075f3fd80ca42150655dd23e52caec981a1b190581934c749", "role": "CLIENT", "short_name": "QJ43", "snr": 8.35, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.877, "battery_level": 56, "channel_utilization": 4.78, "uptime_seconds": 94047, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_ECHO", "last_heard_offset_sec": 1001, "long_name": "Dusk Trout", "next_hop": 56, "num": "0x5cd45776", "position": {"altitude": 1517, "latitude": 33.180741, "location_source": "LOC_INTERNAL", "longitude": -107.5838, "time_offset_sec": 1299}, "public_key_hex": "a960101d24eaf3e4471b22e0425d0ba216bafc3a17de70329effd76f3d839417", "role": "CLIENT", "short_name": "DQ17", "snr": 3.42, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.14, "iaq": 56, "relative_humidity": 79.11, "temperature": 21.22}, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 6682, "long_name": "Smooth Phoenix", "next_hop": 126, "num": "0x5ce38cc1", "position": null, "public_key_hex": "fe64f44876fd20c78842530169054d20664444e4a9043a59a0172639618d3f5f", "role": "CLIENT", "short_name": "S6UT", "snr": 10.47, "status": null, "telemetry": {"air_util_tx": 0.084, "battery_level": 18, "channel_utilization": 2.51, "uptime_seconds": 25612, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 3, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 208, "long_name": "Lone Marmot", "next_hop": 116, "num": "0x5cef61b5", "position": {"altitude": 1806, "latitude": 33.071913, "location_source": "LOC_INTERNAL", "longitude": -107.085985, "time_offset_sec": 282}, "public_key_hex": "6a6e7d186a9a0159fcc275a895636f99fa1d9b80598ef857b7ea2dc29ad08cc6", "role": "CLIENT", "short_name": "LI15", "snr": 10.41, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 2414, "long_name": "Brave Wolf", "next_hop": 0, "num": "0x5d025a62", "position": {"altitude": 1654, "latitude": 33.452956, "location_source": "LOC_INTERNAL", "longitude": -107.218409, "time_offset_sec": 2697}, "public_key_hex": "dbf467aeba8bd88bb32de9b9811c34835e4b7aeb85a7dc385c05482d45240fb1", "role": "CLIENT", "short_name": "BX5Z", "snr": 4.71, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.36, "iaq": 79, "relative_humidity": 40.82, "temperature": 15.47}, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 1571, "long_name": "Dusk Moose", "next_hop": 0, "num": "0x5d09faf4", "position": {"altitude": 1329, "latitude": 33.524389, "location_source": "LOC_INTERNAL", "longitude": -107.704905, "time_offset_sec": 1702}, "public_key_hex": "96d2d48ee60ebe6fb461a0afd4cd8e92992aaf5cc531bcee3dc4943424713674", "role": "CLIENT", "short_name": "DMJJ", "snr": 9.36, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 236, "long_name": "Short Lynx", "next_hop": 0, "num": "0x5d0d9723", "position": {"altitude": 1415, "latitude": 32.972617, "location_source": "LOC_INTERNAL", "longitude": -107.807107, "time_offset_sec": 266}, "public_key_hex": "532d6a65a649543c649f2ae0120f04da0764e2f5cfe6954f25a2934c3c2e79fc", "role": "CLIENT", "short_name": "SXWF", "snr": -0.18, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.831, "battery_level": 42, "channel_utilization": 6.22, "uptime_seconds": 108203, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK3401", "last_heard_offset_sec": 1249, "long_name": "Tiny Bronco", "next_hop": 94, "num": "0x5d1cba78", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "TAQF", "snr": 11.82, "status": null, "telemetry": {"air_util_tx": 0.27, "battery_level": 42, "channel_utilization": 3.11, "uptime_seconds": 136671, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4875, "long_name": "Lone Lion", "next_hop": 217, "num": "0x5d21e799", "position": {"altitude": 1374, "latitude": 32.200671, "location_source": "LOC_INTERNAL", "longitude": -107.711701, "time_offset_sec": 5121}, "public_key_hex": "22230562f1aa0430ce78be54b85cac543ca9fc745bca2dee532c43799c0bf8d7", "role": "CLIENT", "short_name": "LWDR", "snr": 1.51, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 44, "long_name": "Old Cactus", "next_hop": 0, "num": "0x5d26d47e", "position": {"altitude": 1592, "latitude": 33.410593, "location_source": "LOC_INTERNAL", "longitude": -106.685637, "time_offset_sec": 149}, "public_key_hex": "872b0037ac1dd0378183832509978f21606a6987c7da4c7efcc5737546c4d88c", "role": "CLIENT", "short_name": "O5E3", "snr": 3.84, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.06, "iaq": 112, "relative_humidity": 61.79, "temperature": 18.04}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 245, "long_name": "White Bass", "next_hop": 0, "num": "0x5d7a798c", "position": {"altitude": 1388, "latitude": 33.004938, "location_source": "LOC_INTERNAL", "longitude": -107.75017, "time_offset_sec": 270}, "public_key_hex": "96c173d1b14100d1f82a6b6717567a51b89f92e8caf25e39c82f7275d6565316", "role": "TAK", "short_name": "🦇", "snr": 5.71, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.481, "battery_level": 92, "channel_utilization": 32.71, "uptime_seconds": 10198, "voltage": 4.128}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.41, "iaq": 24, "relative_humidity": 56.67, "temperature": 35.67}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 518, "long_name": "Misty Colt", "next_hop": 0, "num": "0x5d7d3153", "position": {"altitude": 1412, "latitude": 32.993766, "location_source": "LOC_INTERNAL", "longitude": -107.109073, "time_offset_sec": 656}, "public_key_hex": "27c9334d182b19aa664dc4121d040f42ecb4d00257a919813dbcd02e13fc5245", "role": "CLIENT_HIDDEN", "short_name": "M2ON", "snr": 9.14, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2174, "long_name": "Mountain Eagle", "next_hop": 0, "num": "0x5d8c3172", "position": {"altitude": 1268, "latitude": 32.853116, "location_source": "LOC_INTERNAL", "longitude": -106.257407, "time_offset_sec": 2218}, "public_key_hex": "38a93de3679011c9a30fd925db89a1a4b4375ced5bb51404c26de4ea7ef09ed6", "role": "CLIENT", "short_name": "MMLG", "snr": 2.17, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4629, "long_name": "Red Heron", "next_hop": 0, "num": "0x5d9aff8b", "position": {"altitude": 1422, "latitude": 34.542881, "location_source": "LOC_INTERNAL", "longitude": -107.853623, "time_offset_sec": 4841}, "public_key_hex": "5b135d3a52d116c9cd54a34e369a0a83b73281a0a8e37641d3d23a714b04c96c", "role": "CLIENT", "short_name": "RB2Q", "snr": 5.94, "status": null, "telemetry": {"air_util_tx": 0.098, "battery_level": 97, "channel_utilization": 1.18, "uptime_seconds": 19909, "voltage": 4.173}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4089, "long_name": "Desert Salmon", "next_hop": 0, "num": "0x5db43e0e", "position": {"altitude": 1479, "latitude": 32.374171, "location_source": "LOC_INTERNAL", "longitude": -107.55317, "time_offset_sec": 4120}, "public_key_hex": "f97428b8d9fdbbbebece135af990405c33767bfd870562238e4c4338d256e0a7", "role": "CLIENT", "short_name": "D8M5", "snr": 5.71, "status": null, "telemetry": {"air_util_tx": 0.83, "battery_level": 55, "channel_utilization": 4.61, "uptime_seconds": 11655, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 3011, "long_name": "Silver Trout", "next_hop": 206, "num": "0x5dd2ff8a", "position": {"altitude": 1106, "latitude": 32.267711, "location_source": "LOC_INTERNAL", "longitude": -107.605971, "time_offset_sec": 3147}, "public_key_hex": "033d03242635234a1ba0d6bfaa209c01fbbb8f1e29c87fbf482f75e41188a928", "role": "CLIENT", "short_name": "SUOX", "snr": 11.82, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 325, "long_name": "Frosty Tortoise", "next_hop": 0, "num": "0x5de41d68", "position": null, "public_key_hex": "c1287f889a7bf3b5e67a558495b37bc9f786290c68a29363ed6db738402be21e", "role": "ROUTER_LATE", "short_name": "FXIF", "snr": 1.92, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.222, "battery_level": 63, "channel_utilization": 8.01, "uptime_seconds": 28305, "voltage": 3.867}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.43, "iaq": 41, "relative_humidity": 47.48, "temperature": 22.53}, "hops_away": 2, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 3860, "long_name": "Frosty Eagle", "next_hop": 81, "num": "0x5de4da06", "position": {"altitude": 1196, "latitude": 33.056099, "location_source": "LOC_INTERNAL", "longitude": -107.618857, "time_offset_sec": 4008}, "public_key_hex": "26258a55eda267b4c50cd3564e925190a4cde1358ff3a8e8d54c66163ee3c165", "role": "CLIENT_BASE", "short_name": "FJ42", "snr": 7.51, "status": null, "telemetry": {"air_util_tx": 1.62, "battery_level": 65, "channel_utilization": 5.29, "uptime_seconds": 40130, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1794, "long_name": "Soft Moose", "next_hop": 0, "num": "0x5df74b5f", "position": null, "public_key_hex": "7d307a5be6f7700de564b6245defa78b94d5fbd456e2bd3601e4c4583087b811", "role": "CLIENT", "short_name": "S62H", "snr": 10.34, "status": null, "telemetry": {"air_util_tx": 1.487, "battery_level": 22, "channel_utilization": 22.01, "uptime_seconds": 6284, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 994.47, "iaq": 35, "relative_humidity": 40.94, "temperature": 12.6}, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 3172, "long_name": "Sky Cactus KX8GW", "next_hop": 139, "num": "0x5df8f080", "position": {"altitude": 972, "latitude": 33.250315, "location_source": "LOC_INTERNAL", "longitude": -107.093937, "time_offset_sec": 3377}, "public_key_hex": "cc1b13f8990894f9a216d779f3517622517159a2aeefc1c37f39d35ea4b68986", "role": "CLIENT", "short_name": "🦋", "snr": 4.68, "status": null, "telemetry": {"air_util_tx": 1.214, "battery_level": 28, "channel_utilization": 7.07, "uptime_seconds": 172862, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1314, "long_name": "Howling Phoenix", "next_hop": 0, "num": "0x5dff4926", "position": {"altitude": 897, "latitude": 32.171174, "location_source": "LOC_INTERNAL", "longitude": -106.147486, "time_offset_sec": 1390}, "public_key_hex": "", "role": "CLIENT", "short_name": "H5W6", "snr": 10.44, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5509, "long_name": "Sky Crane", "next_hop": 106, "num": "0x5e049755", "position": {"altitude": 959, "latitude": 32.916238, "location_source": "LOC_INTERNAL", "longitude": -108.334555, "time_offset_sec": 5766}, "public_key_hex": "", "role": "CLIENT", "short_name": "STW9", "snr": 10.6, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.292, "battery_level": 62, "channel_utilization": 7.42, "uptime_seconds": 38557, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 4624, "long_name": "Wild Cedar", "next_hop": 236, "num": "0x5e0be8e1", "position": {"altitude": 915, "latitude": 33.665375, "location_source": "LOC_INTERNAL", "longitude": -107.290137, "time_offset_sec": 4911}, "public_key_hex": "3526c4045d871c01f04c900a91a68478c936aceb7a5dea65355f13d4c8099d69", "role": "CLIENT", "short_name": "W6PK", "snr": 0.3, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 1723, "long_name": "New Trout WD1ER", "next_hop": 114, "num": "0x5e0d9951", "position": null, "public_key_hex": "7dcce30db5cabe275b9197d6acd91a5f465de7ef6285e3bb479e4defc151f1c6", "role": "CLIENT", "short_name": "NUNE", "snr": 1.91, "status": null, "telemetry": {"air_util_tx": 0.759, "battery_level": 75, "channel_utilization": 36.01, "uptime_seconds": 57231, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1192, "long_name": "Silver Lynx", "next_hop": 0, "num": "0x5e40eedf", "position": {"altitude": 1605, "latitude": 33.01929, "location_source": "LOC_INTERNAL", "longitude": -107.009695, "time_offset_sec": 1385}, "public_key_hex": "", "role": "CLIENT", "short_name": "SVHH", "snr": 9.53, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 6, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8301, "long_name": "Floating Turtle", "next_hop": 220, "num": "0x5e437d80", "position": {"altitude": 1154, "latitude": 34.073021, "location_source": "LOC_INTERNAL", "longitude": -107.236733, "time_offset_sec": 8536}, "public_key_hex": "45246df7658f23451c1a01e66090e7a754229353b1975d6748deae899ff8de91", "role": "CLIENT", "short_name": "F2LN", "snr": 5.4, "status": null, "telemetry": {"air_util_tx": 1.586, "battery_level": 34, "channel_utilization": 29.78, "uptime_seconds": 84303, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 1777, "long_name": "Sky Sage", "next_hop": 0, "num": "0x5e487e8e", "position": {"altitude": 1203, "latitude": 33.766458, "location_source": "LOC_INTERNAL", "longitude": -107.275451, "time_offset_sec": 1900}, "public_key_hex": "561c891509cca81c3bc1247bcdb8ad5629eee8d32c43c50c6a1b934d76ac5e64", "role": "CLIENT", "short_name": "SH8I", "snr": 2.52, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 991.51, "iaq": 23, "relative_humidity": 54.0, "temperature": 17.03}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5141, "long_name": "Iron Stag", "next_hop": 0, "num": "0x5e54bc40", "position": {"altitude": 1589, "latitude": 33.205865, "location_source": "LOC_INTERNAL", "longitude": -107.796197, "time_offset_sec": 5158}, "public_key_hex": "", "role": "CLIENT", "short_name": "I2MR", "snr": 8.28, "status": null, "telemetry": {"air_util_tx": 1.881, "battery_level": 79, "channel_utilization": 9.81, "uptime_seconds": 47631, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2242, "long_name": "Shady Doe", "next_hop": 0, "num": "0x5e64bc09", "position": {"altitude": 947, "latitude": 32.32241, "location_source": "LOC_INTERNAL", "longitude": -106.957343, "time_offset_sec": 2454}, "public_key_hex": "e1477efe7cb361745fd54f3142e45e35a5ba29d85ba2221b47d728af7f943d87", "role": "CLIENT", "short_name": "SX20", "snr": 6.67, "status": null, "telemetry": {"air_util_tx": 1.184, "battery_level": 64, "channel_utilization": 31.33, "uptime_seconds": 41818, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 784, "long_name": "Desert Hawk", "next_hop": 69, "num": "0x5e72781e", "position": {"altitude": 1231, "latitude": 33.44893, "location_source": "LOC_INTERNAL", "longitude": -107.041724, "time_offset_sec": 852}, "public_key_hex": "3f83c5a91e027d11ca6931a6bb77fbe60f77fdbe4b8c114e05d77e4167a9ec74", "role": "CLIENT", "short_name": "D9RJ", "snr": 8.48, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1025.26, "iaq": 59, "relative_humidity": 50.17, "temperature": 13.88}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4393, "long_name": "Dawn Dolphin", "next_hop": 0, "num": "0x5e821548", "position": {"altitude": 1017, "latitude": 33.82867, "location_source": "LOC_INTERNAL", "longitude": -107.92468, "time_offset_sec": 4629}, "public_key_hex": "0fc2e58f58b88fc70bba764b9a29f39ed52b2d8bb1aecb256128f59c028a6ef2", "role": "CLIENT_BASE", "short_name": "D71K", "snr": 6.32, "status": null, "telemetry": {"air_util_tx": 1.479, "battery_level": 48, "channel_utilization": 8.62, "uptime_seconds": 39601, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 12014, "long_name": "Silver Iguana", "next_hop": 183, "num": "0x5e93e0bc", "position": {"altitude": 1370, "latitude": 32.296551, "location_source": "LOC_INTERNAL", "longitude": -107.113463, "time_offset_sec": 12167}, "public_key_hex": "31225d02b412cc690792f04f2a2415a5ce6806edc369bed7735be17e1930191b", "role": "CLIENT", "short_name": "SPEV", "snr": 3.65, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.983, "battery_level": 48, "channel_utilization": 12.3, "uptime_seconds": 25917, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 505, "long_name": "Misty Bluff", "next_hop": 0, "num": "0x5e94de6c", "position": {"altitude": 1112, "latitude": 33.758799, "location_source": "LOC_INTERNAL", "longitude": -108.180904, "time_offset_sec": 715}, "public_key_hex": "311cd9b0f412866081a10ee20a1913519066eea85253a5985350cb0958ec9a97", "role": "ROUTER", "short_name": "M6FW", "snr": 2.59, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.229, "battery_level": 45, "channel_utilization": 16.99, "uptime_seconds": 28222, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.74, "iaq": 58, "relative_humidity": 42.5, "temperature": 27.28}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 15004, "long_name": "Misty Whale", "next_hop": 0, "num": "0x5eb0b581", "position": {"altitude": 1467, "latitude": 33.678142, "location_source": "LOC_INTERNAL", "longitude": -107.735993, "time_offset_sec": 15287}, "public_key_hex": "4beed592b157dc5ec62e618430748a6934d470fd2269b3cbe6e7a07d09f3a309", "role": "CLIENT", "short_name": "MRCI", "snr": 9.41, "status": null, "telemetry": {"air_util_tx": 1.318, "battery_level": 32, "channel_utilization": 16.22, "uptime_seconds": 36011, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 5, "environment": null, "hops_away": 4, "hw_model": "T_ECHO", "last_heard_offset_sec": 5279, "long_name": "Lone Fox", "next_hop": 45, "num": "0x5eb64a43", "position": {"altitude": 1518, "latitude": 33.668635, "location_source": "LOC_INTERNAL", "longitude": -107.234235, "time_offset_sec": 5309}, "public_key_hex": "0a6e67f88ba332790bbb0a20eac8d443172776a6d44021256704d121fb0bb22f", "role": "CLIENT", "short_name": "LDUV", "snr": 4.84, "status": null, "telemetry": {"air_util_tx": 0.11, "battery_level": 21, "channel_utilization": 17.05, "uptime_seconds": 195115, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 6829, "long_name": "Silent Cobra", "next_hop": 153, "num": "0x5ecbf9c0", "position": {"altitude": 1434, "latitude": 33.888964, "location_source": "LOC_INTERNAL", "longitude": -106.984424, "time_offset_sec": 6908}, "public_key_hex": "8c6c1505a7b4a26672d2eacf10b0496928770c4ec915524dc88b52b722596d31", "role": "CLIENT", "short_name": "SJSF", "snr": -0.89, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 599, "long_name": "Giant Seal", "next_hop": 119, "num": "0x5eee3e98", "position": null, "public_key_hex": "c8f6378e2810207862f1b109fb734101f7f9b791c790eb76b4c4b82d59ea2e91", "role": "CLIENT", "short_name": "G3OM", "snr": 8.99, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.74, "iaq": 5, "relative_humidity": 82.24, "temperature": 31.65}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 11370, "long_name": "Roving Cobra", "next_hop": 0, "num": "0x5ef1cce4", "position": {"altitude": 986, "latitude": 32.280906, "location_source": "LOC_INTERNAL", "longitude": -106.918532, "time_offset_sec": 11642}, "public_key_hex": "1ac895826ef18d76990d9adb82a356c01e7f066c143df8e5b3ed314764a054c7", "role": "CLIENT", "short_name": "R49V", "snr": 4.74, "status": null, "telemetry": {"air_util_tx": 1.249, "battery_level": 74, "channel_utilization": 9.47, "uptime_seconds": 117370, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.27, "iaq": 71, "relative_humidity": 51.06, "temperature": 20.1}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 1357, "long_name": "Soft Cougar", "next_hop": 0, "num": "0x5efb3cc7", "position": {"altitude": 1352, "latitude": 33.546975, "location_source": "LOC_INTERNAL", "longitude": -107.568907, "time_offset_sec": 1567}, "public_key_hex": "a1c3e990ac852423b6b71424d9bf67af8f333e662fac3bbad90b668822ee222b", "role": "CLIENT", "short_name": "🦂", "snr": 6.49, "status": null, "telemetry": {"air_util_tx": 0.742, "battery_level": 34, "channel_utilization": 8.59, "uptime_seconds": 74586, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.14, "iaq": 88, "relative_humidity": 45.86, "temperature": 24.62}, "hops_away": 0, "hw_model": "HELTEC_V4_R8", "last_heard_offset_sec": 3429, "long_name": "Happy Pike", "next_hop": 0, "num": "0x5f00a68d", "position": {"altitude": 1230, "latitude": 32.971751, "location_source": "LOC_INTERNAL", "longitude": -106.878758, "time_offset_sec": 3717}, "public_key_hex": "84c72f12c5a0057560f6d7d90f859c92d2de9dbf7fa5e6f6841ebaf3dbd4032f", "role": "CLIENT", "short_name": "H15Q", "snr": 2.63, "status": null, "telemetry": {"air_util_tx": 0.232, "battery_level": 44, "channel_utilization": 22.29, "uptime_seconds": 92552, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1929, "long_name": "Canyon Phoenix", "next_hop": 191, "num": "0x5f0bec9d", "position": {"altitude": 1009, "latitude": 34.997255, "location_source": "LOC_INTERNAL", "longitude": -106.578707, "time_offset_sec": 2163}, "public_key_hex": "1e2a996d16e8587f9e74da91f5abe7ab082aa637b19e4963bbf987f417ce8bd7", "role": "CLIENT", "short_name": "CI8O", "snr": 9.56, "status": null, "telemetry": {"air_util_tx": 0.782, "battery_level": 84, "channel_utilization": 22.03, "uptime_seconds": 47145, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 15033, "long_name": "Slow Moose", "next_hop": 0, "num": "0x5f1494cd", "position": {"altitude": 1582, "latitude": 33.473334, "location_source": "LOC_INTERNAL", "longitude": -107.611831, "time_offset_sec": 15122}, "public_key_hex": "8fff41cf9cdc81c23e5457eb263265decfd3d9bbb6b26d294ab4efa2e20c5fd3", "role": "CLIENT", "short_name": "SQFA", "snr": 10.19, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.795, "battery_level": 51, "channel_utilization": 12.9, "uptime_seconds": 21402, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 998.65, "iaq": 0, "relative_humidity": 57.67, "temperature": 17.35}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 193, "long_name": "Drifting Hawk", "next_hop": 0, "num": "0x5f2d6efc", "position": {"altitude": 1200, "latitude": 33.843815, "location_source": "LOC_INTERNAL", "longitude": -107.092202, "time_offset_sec": 465}, "public_key_hex": "e43b2fa0c120a2d88e4237fa3baa951e6348c341c7dfa4f1b74e334546aa2573", "role": "CLIENT", "short_name": "DINI", "snr": 2.41, "status": null, "telemetry": {"air_util_tx": 1.28, "battery_level": 90, "channel_utilization": 13.82, "uptime_seconds": 28911, "voltage": 4.11}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.55, "iaq": 19, "relative_humidity": 86.64, "temperature": 13.12}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2795, "long_name": "Wandering Tortoise", "next_hop": 0, "num": "0x5f359712", "position": {"altitude": 1339, "latitude": 33.80149, "location_source": "LOC_INTERNAL", "longitude": -107.474063, "time_offset_sec": 3027}, "public_key_hex": "068a0652c8fe3bcd85b6e01e592956e114c3c51692b6daa3d13151828e6c56fd", "role": "TAK", "short_name": "WUKB", "snr": 7.75, "status": null, "telemetry": {"air_util_tx": 0.079, "battery_level": 48, "channel_utilization": 14.87, "uptime_seconds": 126712, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 2818, "long_name": "Dawn Aspen", "next_hop": 137, "num": "0x5f376c9a", "position": {"altitude": 1620, "latitude": 32.506429, "location_source": "LOC_INTERNAL", "longitude": -106.290643, "time_offset_sec": 3013}, "public_key_hex": "9094ad0dfb40f77fbc5be655ca199c3ae4fedc235825e34d80982ae3b1ea5769", "role": "CLIENT", "short_name": "DFRT", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.463, "battery_level": 13, "channel_utilization": 18.16, "uptime_seconds": 223383, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 9415, "long_name": "Lone Juniper", "next_hop": 63, "num": "0x5f3e264a", "position": {"altitude": 1275, "latitude": 33.356826, "location_source": "LOC_INTERNAL", "longitude": -106.979413, "time_offset_sec": 9708}, "public_key_hex": "6026303f576d97f0594e17629a281eaca29658730f9d84b458447fa8f5defb1d", "role": "CLIENT", "short_name": "LJWU", "snr": 5.67, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.213, "battery_level": 32, "channel_utilization": 13.84, "uptime_seconds": 149364, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 8975, "long_name": "Bright Lynx", "next_hop": 0, "num": "0x5f4fd752", "position": {"altitude": 1529, "latitude": 32.531104, "location_source": "LOC_INTERNAL", "longitude": -106.687471, "time_offset_sec": 9109}, "public_key_hex": "c245c24e9f777b22467becc271abb7825cc81c32b7e1f84282825249e9a1896b", "role": "CLIENT", "short_name": "BV4B", "snr": 6.8, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 7634, "long_name": "Wandering Badger", "next_hop": 0, "num": "0x5f5a5326", "position": {"altitude": 1570, "latitude": 32.703241, "location_source": "LOC_INTERNAL", "longitude": -108.624487, "time_offset_sec": 7920}, "public_key_hex": "747a3b022097f6067e7b1c122c421d6955eee16f0b4209a588635100723e0973", "role": "CLIENT_HIDDEN", "short_name": "WH5X", "snr": 7.98, "status": null, "telemetry": {"air_util_tx": 0.127, "battery_level": 72, "channel_utilization": 9.71, "uptime_seconds": 55377, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 495, "long_name": "Hidden Oak", "next_hop": 168, "num": "0x5f658e3e", "position": {"altitude": 1598, "latitude": 33.350435, "location_source": "LOC_INTERNAL", "longitude": -107.127689, "time_offset_sec": 720}, "public_key_hex": "6e37a9aa8be8a7b3d1a6a9f3ed1ade5df08fd025aceab10305661100cc4c46ac", "role": "CLIENT_MUTE", "short_name": "HMLQ", "snr": 8.52, "status": null, "telemetry": {"air_util_tx": 0.373, "battery_level": 101, "channel_utilization": 8.91, "uptime_seconds": 10810, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 133, "long_name": "River Crow", "next_hop": 0, "num": "0x5f68b958", "position": {"altitude": 1658, "latitude": 33.549067, "location_source": "LOC_INTERNAL", "longitude": -106.923385, "time_offset_sec": 356}, "public_key_hex": "2a604ae9fd9e11910ffced88e91cc06b611494485af42f6f685c38cd5e303206", "role": "CLIENT", "short_name": "REM1", "snr": 9.38, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 1223, "long_name": "Quick Cougar", "next_hop": 43, "num": "0x5f6b38cd", "position": {"altitude": 1976, "latitude": 33.437004, "location_source": "LOC_INTERNAL", "longitude": -107.732651, "time_offset_sec": 1455}, "public_key_hex": "4aeefd7053a84e886f64c7f4f48dd3120a665c0dacb12b651227349a83feb46e", "role": "CLIENT", "short_name": "🔥", "snr": 8.97, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 393, "long_name": "Blue Arroyo", "next_hop": 0, "num": "0x5f7c3bb1", "position": null, "public_key_hex": "6b010cf533a39196b107b964bdf29b72305e132e3029454e023a61b5ece3cba0", "role": "CLIENT", "short_name": "B6L1", "snr": 1.67, "status": null, "telemetry": {"air_util_tx": 1.972, "battery_level": 74, "channel_utilization": 3.32, "uptime_seconds": 106118, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4011, "long_name": "Silent Owl", "next_hop": 0, "num": "0x5f89bf50", "position": {"altitude": 1399, "latitude": 33.469858, "location_source": "LOC_INTERNAL", "longitude": -106.993756, "time_offset_sec": 4129}, "public_key_hex": "80b2d50b6da86555bf4231d4989f6caf7c808af82843fa9b2ce6b3398c617c36", "role": "CLIENT", "short_name": "SVQF", "snr": 2.86, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1589, "long_name": "Green Hawk", "next_hop": 0, "num": "0x5f9f5df6", "position": {"altitude": 1015, "latitude": 33.087171, "location_source": "LOC_INTERNAL", "longitude": -106.204548, "time_offset_sec": 1811}, "public_key_hex": "88e3ed0cb08983178f6af1e4f5118a1ef0b48883511fc9f0141f3b61153d7069", "role": "CLIENT", "short_name": "GBYB", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.403, "battery_level": 50, "channel_utilization": 5.37, "uptime_seconds": 138064, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1244, "long_name": "Lunar Whale", "next_hop": 0, "num": "0x5fa243d4", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "L6Z8", "snr": 3.68, "status": null, "telemetry": {"air_util_tx": 0.09, "battery_level": 50, "channel_utilization": 15.94, "uptime_seconds": 7592, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1016.43, "iaq": 52, "relative_humidity": 56.63, "temperature": 11.53}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 4573, "long_name": "Wild Heron W55UF", "next_hop": 0, "num": "0x5fae3fd5", "position": {"altitude": 1777, "latitude": 33.015978, "location_source": "LOC_INTERNAL", "longitude": -106.746619, "time_offset_sec": 4592}, "public_key_hex": "db6019032a05fb826acd3019b7343135567031b1a5a6ee86d4e01f2c8a95d7a2", "role": "ROUTER_LATE", "short_name": "W5KR", "snr": 2.7, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.713, "battery_level": 71, "channel_utilization": 11.64, "uptime_seconds": 53031, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2328, "long_name": "Solar Whale WD2ZT", "next_hop": 0, "num": "0x5fb00dc0", "position": {"altitude": 1444, "latitude": 33.195728, "location_source": "LOC_INTERNAL", "longitude": -107.218193, "time_offset_sec": 2611}, "public_key_hex": "1f87036581b254971ddc19d53e77f0952ac2cd23d339605ec4e4b260025b96ae", "role": "CLIENT", "short_name": "S9O2", "snr": 2.58, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2096, "long_name": "Forest Lion", "next_hop": 0, "num": "0x5fb576d8", "position": {"altitude": 1600, "latitude": 33.539362, "location_source": "LOC_INTERNAL", "longitude": -106.928989, "time_offset_sec": 2121}, "public_key_hex": "f5b3b42770a630d6d2d7658582bc9f40439b3edf2c2e1f602af46beb562a3863", "role": "CLIENT", "short_name": "FP28", "snr": 7.99, "status": null, "telemetry": {"air_util_tx": 0.486, "battery_level": 61, "channel_utilization": 2.92, "uptime_seconds": 78016, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.99, "iaq": 75, "relative_humidity": 52.48, "temperature": 19.99}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 219, "long_name": "Found Lion", "next_hop": 0, "num": "0x5fc9ed06", "position": {"altitude": 1622, "latitude": 32.789529, "location_source": "LOC_INTERNAL", "longitude": -107.16179, "time_offset_sec": 424}, "public_key_hex": "61f12baa5b2b13630542d665a0aa526a53bbafaff5145c993c0ce667295b227b", "role": "CLIENT", "short_name": "F8GK", "snr": 0.91, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 492, "long_name": "Solar Turtle", "next_hop": 179, "num": "0x5fd96262", "position": {"altitude": 1040, "latitude": 32.531113, "location_source": "LOC_INTERNAL", "longitude": -106.782813, "time_offset_sec": 577}, "public_key_hex": "40f73989f095540534f3dbb2e07d09f945ac03355d8cf3d71b24d132f94807b0", "role": "CLIENT_MUTE", "short_name": "S0FA", "snr": 5.77, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.846, "battery_level": 73, "channel_utilization": 17.46, "uptime_seconds": 175785, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1020.76, "iaq": 7, "relative_humidity": 61.2, "temperature": 17.73}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 5752, "long_name": "Green Bass", "next_hop": 0, "num": "0x5ff1454c", "position": null, "public_key_hex": "e13720f2ab1d91284d52b28f058f88edece038f9cfe519a06fae7a2befc08e02", "role": "CLIENT", "short_name": "G6B7", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.386, "battery_level": 16, "channel_utilization": 3.61, "uptime_seconds": 68609, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 3827, "long_name": "Smooth Bison NM2BG", "next_hop": 0, "num": "0x600f72ec", "position": {"altitude": 1726, "latitude": 33.389933, "location_source": "LOC_INTERNAL", "longitude": -107.32842, "time_offset_sec": 4083}, "public_key_hex": "161bdb0d9f5fad8f375dd7875fd2f2c3de5e519582189a8679485174998e1943", "role": "CLIENT", "short_name": "SCZT", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 7865, "long_name": "Found Fox", "next_hop": 221, "num": "0x601a0be6", "position": {"altitude": 1317, "latitude": 33.108654, "location_source": "LOC_INTERNAL", "longitude": -107.611473, "time_offset_sec": 8044}, "public_key_hex": "50fb4dd2b7681ed8666aba7a35778a4d95f8f18a1a104ce63758c647c5243469", "role": "CLIENT", "short_name": "FCCM", "snr": 9.87, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.454, "battery_level": 57, "channel_utilization": 4.59, "uptime_seconds": 1438, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.72, "iaq": 58, "relative_humidity": 55.76, "temperature": 21.41}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 2548, "long_name": "Found Gecko", "next_hop": 0, "num": "0x601f1ef6", "position": {"altitude": 1351, "latitude": 32.639436, "location_source": "LOC_INTERNAL", "longitude": -107.17001, "time_offset_sec": 2731}, "public_key_hex": "", "role": "CLIENT", "short_name": "FV7Z", "snr": 3.8, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.428, "battery_level": 49, "channel_utilization": 14.74, "uptime_seconds": 57252, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 15859, "long_name": "Green Mole", "next_hop": 0, "num": "0x60291770", "position": {"altitude": 1306, "latitude": 32.485518, "location_source": "LOC_INTERNAL", "longitude": -107.676387, "time_offset_sec": 15887}, "public_key_hex": "990cb18113ca2d13c1faa43f53f6ff6d7059e2271b1dafb2380cedbdf698f91f", "role": "ROUTER", "short_name": "🦋", "snr": 4.36, "status": null, "telemetry": {"air_util_tx": 0.957, "battery_level": 55, "channel_utilization": 14.84, "uptime_seconds": 24181, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 994.02, "iaq": 68, "relative_humidity": 35.79, "temperature": 26.83}, "hops_away": 0, "hw_model": "RAK3401", "last_heard_offset_sec": 4129, "long_name": "Stone Pine", "next_hop": 0, "num": "0x6031b421", "position": {"altitude": 1268, "latitude": 32.536094, "location_source": "LOC_INTERNAL", "longitude": -107.053396, "time_offset_sec": 4213}, "public_key_hex": "1b9f21df41e45e953158a92d5328f7c88a470afb453406aae5a3a71810b65fb8", "role": "ROUTER", "short_name": "SJ1R", "snr": 6.5, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1072, "long_name": "Sharp Turtle", "next_hop": 0, "num": "0x6032cdc1", "position": {"altitude": 1345, "latitude": 32.573835, "location_source": "LOC_INTERNAL", "longitude": -107.526217, "time_offset_sec": 1356}, "public_key_hex": "81a6bbf8b8b26664aa3c612a8c04f9a9efdbda237efd8faf707b3c2786e82d2b", "role": "CLIENT", "short_name": "S904", "snr": 5.21, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.313, "battery_level": 65, "channel_utilization": 11.95, "uptime_seconds": 15844, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 1543, "long_name": "Misty Gecko", "next_hop": 148, "num": "0x605bd321", "position": {"altitude": 1025, "latitude": 34.334343, "location_source": "LOC_INTERNAL", "longitude": -106.686896, "time_offset_sec": 1553}, "public_key_hex": "e25a1fbefc6654644d461228d23c34b185e4e9769ac947fcb0b7a1606fa86647", "role": "ROUTER_LATE", "short_name": "ME35", "snr": 8.09, "status": null, "telemetry": {"air_util_tx": 1.053, "battery_level": 36, "channel_utilization": 26.95, "uptime_seconds": 87816, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1250, "long_name": "Silent Hawk", "next_hop": 8, "num": "0x607bf284", "position": {"altitude": 1435, "latitude": 33.570647, "location_source": "LOC_INTERNAL", "longitude": -107.784024, "time_offset_sec": 1509}, "public_key_hex": "1e50912845022fb5e2db0182f82c322c71e1a2a9743ebbd144e91b2bcf76b8a7", "role": "CLIENT", "short_name": "SAN7", "snr": 2.19, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6227, "long_name": "Lost Trout", "next_hop": 58, "num": "0x60828048", "position": null, "public_key_hex": "f1f8d57f785381be23d090bf2ce3ce4adc8e2b9937bf15d8d2e41f6999f6ffca", "role": "CLIENT_BASE", "short_name": "L91H", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.315, "battery_level": 80, "channel_utilization": 15.87, "uptime_seconds": 20246, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 9801, "long_name": "Misty Aspen", "next_hop": 0, "num": "0x60b3bb76", "position": {"altitude": 1219, "latitude": 33.606361, "location_source": "LOC_INTERNAL", "longitude": -107.717879, "time_offset_sec": 9931}, "public_key_hex": "", "role": "CLIENT", "short_name": "MWQ2", "snr": 0.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3432, "long_name": "Canyon Badger", "next_hop": 0, "num": "0x612e4aea", "position": null, "public_key_hex": "ff9487c7a2843853597bc11b345fca11b3309dfa224cb0417da3b297bc52329b", "role": "CLIENT", "short_name": "CRRL", "snr": 3.81, "status": null, "telemetry": {"air_util_tx": 0.915, "battery_level": 45, "channel_utilization": 19.39, "uptime_seconds": 14271, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 3392, "long_name": "Brave Cougar", "next_hop": 101, "num": "0x613599a3", "position": {"altitude": 1235, "latitude": 32.948451, "location_source": "LOC_INTERNAL", "longitude": -107.774602, "time_offset_sec": 3680}, "public_key_hex": "", "role": "CLIENT", "short_name": "🦊", "snr": 4.75, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.12, "battery_level": 55, "channel_utilization": 6.51, "uptime_seconds": 30835, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 4268, "long_name": "Quick Tortoise K12SE", "next_hop": 0, "num": "0x61447174", "position": {"altitude": 1353, "latitude": 32.951415, "location_source": "LOC_INTERNAL", "longitude": -106.939011, "time_offset_sec": 4465}, "public_key_hex": "", "role": "CLIENT", "short_name": "Q4S7", "snr": 9.49, "status": null, "telemetry": {"air_util_tx": 0.134, "battery_level": 69, "channel_utilization": 33.31, "uptime_seconds": 433303, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 567, "long_name": "Wild Whale", "next_hop": 0, "num": "0x615c81e7", "position": {"altitude": 1467, "latitude": 33.151563, "location_source": "LOC_INTERNAL", "longitude": -107.094452, "time_offset_sec": 729}, "public_key_hex": "331320c62a3652f17e91dae13557eda2a9f8ab41cade7e1aaca1db120556918a", "role": "CLIENT_MUTE", "short_name": "WHLT", "snr": 4.3, "status": null, "telemetry": {"air_util_tx": 0.971, "battery_level": 81, "channel_utilization": 11.81, "uptime_seconds": 22197, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.37, "iaq": 94, "relative_humidity": 77.26, "temperature": 17.46}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1016, "long_name": "Fast Raven", "next_hop": 0, "num": "0x61635620", "position": {"altitude": 1032, "latitude": 33.748649, "location_source": "LOC_INTERNAL", "longitude": -107.287845, "time_offset_sec": 1294}, "public_key_hex": "7ef59bd491fb6500637e654f3fbee2a8a717e8682d69cc5d534bf3e5179022c2", "role": "CLIENT", "short_name": "FEX6", "snr": 10.77, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 2641, "long_name": "White Phoenix", "next_hop": 0, "num": "0x6175ee79", "position": {"altitude": 1397, "latitude": 32.998566, "location_source": "LOC_INTERNAL", "longitude": -108.423587, "time_offset_sec": 2880}, "public_key_hex": "92718d6f7d396a03a9b24e933402a19ad5357087208800c2d7c42f1660cfa5fc", "role": "CLIENT", "short_name": "🐺", "snr": 9.78, "status": null, "telemetry": {"air_util_tx": 0.716, "battery_level": 61, "channel_utilization": 5.28, "uptime_seconds": 80260, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3839, "long_name": "River Bronco", "next_hop": 0, "num": "0x61771180", "position": {"altitude": 1502, "latitude": 32.905442, "location_source": "LOC_INTERNAL", "longitude": -107.801766, "time_offset_sec": 3899}, "public_key_hex": "ac0a5a260f71618858c4c470abbdfeb7680d8fbe14bbd0087c8d0d9bb4afcdf5", "role": "CLIENT", "short_name": "RO64", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.87, "iaq": 33, "relative_humidity": 50.96, "temperature": 18.83}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 10248, "long_name": "Loud Sage", "next_hop": 0, "num": "0x61809d98", "position": {"altitude": 1440, "latitude": 32.169462, "location_source": "LOC_INTERNAL", "longitude": -107.180209, "time_offset_sec": 10380}, "public_key_hex": "606bf84e38c4ffcc1bcbc3fdff08ed3cf86dc3710a885a27d3a91fe5dcfb7b1e", "role": "CLIENT", "short_name": "LJYW", "snr": 8.16, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1034, "long_name": "Shady Adder", "next_hop": 0, "num": "0x618101b5", "position": {"altitude": 1062, "latitude": 32.91382, "location_source": "LOC_INTERNAL", "longitude": -107.872693, "time_offset_sec": 1137}, "public_key_hex": "62ed5cc9190f3751aefff83de7ab6d599015632eedfcceed19a74b55d45e69d5", "role": "CLIENT", "short_name": "SY6W", "snr": 8.65, "status": null, "telemetry": {"air_util_tx": 0.295, "battery_level": 26, "channel_utilization": 12.39, "uptime_seconds": 15531, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.53, "iaq": 44, "relative_humidity": 54.97, "temperature": 23.41}, "hops_away": 0, "hw_model": "T_ECHO_LITE", "last_heard_offset_sec": 3186, "long_name": "Drifting Adder", "next_hop": 0, "num": "0x6195aad4", "position": {"altitude": 1144, "latitude": 33.128408, "location_source": "LOC_INTERNAL", "longitude": -107.618443, "time_offset_sec": 3421}, "public_key_hex": "a29e9feb0aa4b55c41f995f9504cd16d0114ef22d4f504a24f84dfcbd3119992", "role": "CLIENT", "short_name": "DTTY", "snr": 2.71, "status": null, "telemetry": {"air_util_tx": 0.715, "battery_level": 41, "channel_utilization": 3.6, "uptime_seconds": 29323, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 9237, "long_name": "Howling Arroyo", "next_hop": 101, "num": "0x61989222", "position": {"altitude": 1307, "latitude": 33.47846, "location_source": "LOC_INTERNAL", "longitude": -107.296964, "time_offset_sec": 9397}, "public_key_hex": "f97b8f13617f69ad52fe5fc118126c468393f308f478311a7cd5c9cf8128b223", "role": "CLIENT", "short_name": "HC71", "snr": 5.74, "status": null, "telemetry": {"air_util_tx": 1.096, "battery_level": 21, "channel_utilization": 8.67, "uptime_seconds": 6074, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 191, "long_name": "Wild Seal", "next_hop": 244, "num": "0x61a1f760", "position": {"altitude": 1398, "latitude": 33.237666, "location_source": "LOC_INTERNAL", "longitude": -107.273089, "time_offset_sec": 206}, "public_key_hex": "bb387f1e16c6e3a410fc563762691bf127a684d59c9517a173b06e378cb57049", "role": "CLIENT", "short_name": "W7QH", "snr": -1.63, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 993.49, "iaq": 26, "relative_humidity": 61.25, "temperature": 21.27}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 626, "long_name": "Sneaky Hawk", "next_hop": 0, "num": "0x61bb0570", "position": {"altitude": 1068, "latitude": 32.559875, "location_source": "LOC_INTERNAL", "longitude": -107.627852, "time_offset_sec": 717}, "public_key_hex": "", "role": "CLIENT", "short_name": "SRFK", "snr": 10.66, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.39, "iaq": 63, "relative_humidity": 74.07, "temperature": 20.91}, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 4825, "long_name": "Sharp Lynx AE5BC", "next_hop": 129, "num": "0x61e789ac", "position": {"altitude": 1329, "latitude": 33.220807, "location_source": "LOC_INTERNAL", "longitude": -107.835804, "time_offset_sec": 5003}, "public_key_hex": "b92c7b6e6367ec7b70bb1688f29d2188b14868e37131c363428f43752ec8a76d", "role": "CLIENT", "short_name": "SY3J", "snr": 8.02, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 612, "long_name": "Short Iguana", "next_hop": 0, "num": "0x61f01361", "position": {"altitude": 1213, "latitude": 33.686938, "location_source": "LOC_INTERNAL", "longitude": -107.286284, "time_offset_sec": 756}, "public_key_hex": "e5e0ff1153779206c9d3fedb455ee8f199ef3d67bd8883219735323c1c9ea721", "role": "CLIENT", "short_name": "S55I", "snr": 5.45, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1755, "long_name": "Desert Lynx", "next_hop": 0, "num": "0x61fc4ce7", "position": {"altitude": 1166, "latitude": 32.619247, "location_source": "LOC_INTERNAL", "longitude": -105.345916, "time_offset_sec": 2050}, "public_key_hex": "170cd81a3eed16cc5a3e0b31164e22c1e7d72fda2b4178893a2a646a4580cf28", "role": "CLIENT", "short_name": "DFXA", "snr": 9.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2866, "long_name": "Old Beaver", "next_hop": 92, "num": "0x622bcbca", "position": {"altitude": 1034, "latitude": 32.646547, "location_source": "LOC_INTERNAL", "longitude": -107.347839, "time_offset_sec": 2893}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "🦂", "snr": 3.18, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1007.75, "iaq": 104, "relative_humidity": 66.1, "temperature": 15.06}, "hops_away": 3, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 13851, "long_name": "Happy Pike", "next_hop": 219, "num": "0x622d0c7e", "position": {"altitude": 1337, "latitude": 33.48415, "location_source": "LOC_INTERNAL", "longitude": -107.898998, "time_offset_sec": 14040}, "public_key_hex": "a404e2a43ea2afa8d5d94440a3e69c4e47e83c592c2b92942b76e0a6ecdee91a", "role": "CLIENT", "short_name": "HTJG", "snr": 5.01, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 124, "long_name": "Green Doe", "next_hop": 0, "num": "0x622dbaf2", "position": null, "public_key_hex": "01af74bcbe8b489dc0c67257085c6cf5ef4a051f5bc84f78a0d748941351e5e8", "role": "CLIENT", "short_name": "G6FH", "snr": 8.59, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.467, "battery_level": 70, "channel_utilization": 7.87, "uptime_seconds": 34640, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 1744, "long_name": "Blue Phoenix", "next_hop": 212, "num": "0x625eb5ac", "position": {"altitude": 1294, "latitude": 33.106308, "location_source": "LOC_INTERNAL", "longitude": -107.45401, "time_offset_sec": 1945}, "public_key_hex": "a8ec4364bd620002a5e7fddd13c58f0018bd2f1f4b5206f11bb99e61a3f19f55", "role": "CLIENT", "short_name": "🌊", "snr": 8.29, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.988, "battery_level": 24, "channel_utilization": 13.05, "uptime_seconds": 99808, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 6, "hw_model": "T_DECK", "last_heard_offset_sec": 213, "long_name": "Floating Cobra", "next_hop": 150, "num": "0x626e26a5", "position": {"altitude": 1781, "latitude": 33.259225, "location_source": "LOC_INTERNAL", "longitude": -107.208307, "time_offset_sec": 247}, "public_key_hex": "68fd36e7ce6b3c797efb739c5fd5edbd1556d0e6ec8ba644f2b9920ed5e3e011", "role": "TRACKER", "short_name": "FL3Y", "snr": 7.29, "status": null, "telemetry": {"air_util_tx": 0.864, "battery_level": 89, "channel_utilization": 3.25, "uptime_seconds": 35523, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 1334, "long_name": "Sunny Adder", "next_hop": 215, "num": "0x62782775", "position": {"altitude": 1377, "latitude": 33.52419, "location_source": "LOC_INTERNAL", "longitude": -107.936147, "time_offset_sec": 1563}, "public_key_hex": "81109657d14f1168c50fcdf2bffb1ae591eb9e4a9ef33dd026733e3605620583", "role": "CLIENT", "short_name": "SWE5", "snr": -0.09, "status": null, "telemetry": {"air_util_tx": 0.202, "battery_level": 68, "channel_utilization": 8.89, "uptime_seconds": 28255, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 900, "long_name": "Shady Badger", "next_hop": 0, "num": "0x6285362f", "position": {"altitude": 1434, "latitude": 32.828742, "location_source": "LOC_INTERNAL", "longitude": -107.363082, "time_offset_sec": 997}, "public_key_hex": "bcb9a0a33b0e631c219fbbfbde51408350ad33b37953bea1dc5a33a93dba0b14", "role": "CLIENT", "short_name": "SDIM", "snr": 1.71, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 9387, "long_name": "Rough Bluff", "next_hop": 0, "num": "0x629df44f", "position": {"altitude": 1706, "latitude": 32.971149, "location_source": "LOC_INTERNAL", "longitude": -107.519462, "time_offset_sec": 9617}, "public_key_hex": "b90c6f076feb2d44bedcddcfdec87089ac232da84d34c34d39c7bf59e0220121", "role": "CLIENT", "short_name": "R97R", "snr": 6.88, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.769, "battery_level": 42, "channel_utilization": 32.2, "uptime_seconds": 191434, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1114, "long_name": "Loud Squirrel", "next_hop": 0, "num": "0x62ba295d", "position": {"altitude": 1456, "latitude": 32.931013, "location_source": "LOC_INTERNAL", "longitude": -106.963154, "time_offset_sec": 1309}, "public_key_hex": "0f3057e9c73d9898f452ed6f3e77e21013863065ca8b6148a097913d2ce47b28", "role": "CLIENT", "short_name": "LV5J", "snr": 4.93, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.351, "battery_level": 27, "channel_utilization": 6.46, "uptime_seconds": 95117, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 167, "long_name": "Stone Dolphin", "next_hop": 0, "num": "0x62e2843c", "position": {"altitude": 1707, "latitude": 32.625244, "location_source": "LOC_INTERNAL", "longitude": -107.466687, "time_offset_sec": 459}, "public_key_hex": "279f99ac721317db078606bd498baf2c18bb812b8a9fd0d4b3d69c62c4667e34", "role": "CLIENT", "short_name": "STIC", "snr": 3.53, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.542, "battery_level": 11, "channel_utilization": 14.41, "uptime_seconds": 12978, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.66, "iaq": 32, "relative_humidity": 70.31, "temperature": 2.99}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3588, "long_name": "Drifting Ridge", "next_hop": 0, "num": "0x62e643d8", "position": {"altitude": 1224, "latitude": 32.574941, "location_source": "LOC_INTERNAL", "longitude": -108.101142, "time_offset_sec": 3615}, "public_key_hex": "", "role": "CLIENT_MUTE", "short_name": "DV07", "snr": 6.15, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.298, "battery_level": 67, "channel_utilization": 1.56, "uptime_seconds": 48729, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1212, "long_name": "Forest Trout", "next_hop": 0, "num": "0x62f515b0", "position": {"altitude": 1012, "latitude": 33.829864, "location_source": "LOC_INTERNAL", "longitude": -107.548651, "time_offset_sec": 1331}, "public_key_hex": "0e663c720d5ef5ca9bf436a8a7c1aef47b122b67b1b3fb165bc797fa95fc83c4", "role": "ROUTER", "short_name": "FV3W", "snr": 10.38, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 34758, "long_name": "Drowsy Hare", "next_hop": 0, "num": "0x632acef9", "position": null, "public_key_hex": "efd6ae4e8f0d399af1277a5181fb596eb1e9e9efebf639651d373d7b912a3614", "role": "CLIENT", "short_name": "DGSA", "snr": 1.76, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.144, "battery_level": 32, "channel_utilization": 2.86, "uptime_seconds": 59439, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 378, "long_name": "Storm Pike", "next_hop": 0, "num": "0x63341d2f", "position": {"altitude": 1156, "latitude": 32.713916, "location_source": "LOC_INTERNAL", "longitude": -107.576736, "time_offset_sec": 395}, "public_key_hex": "e201101abf03161f3c227d17911ed524a2dcaeb3ebc6b286ff69f68559d3066a", "role": "CLIENT", "short_name": "SXDZ", "snr": 5.38, "status": null, "telemetry": {"air_util_tx": 1.433, "battery_level": 37, "channel_utilization": 1.94, "uptime_seconds": 30929, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 4580, "long_name": "Rough Falcon", "next_hop": 6, "num": "0x633add93", "position": {"altitude": 1232, "latitude": 32.786027, "location_source": "LOC_INTERNAL", "longitude": -106.944313, "time_offset_sec": 4753}, "public_key_hex": "5e3a1c3ce8cb3d88aedba295f35a2384f8711971ea35f65cbecfdc8ee9347b5c", "role": "CLIENT_BASE", "short_name": "RW73", "snr": 5.33, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "MINI_EPAPER_S3", "last_heard_offset_sec": 5101, "long_name": "Copper Bison", "next_hop": 162, "num": "0x63414b45", "position": {"altitude": 1946, "latitude": 32.752423, "location_source": "LOC_INTERNAL", "longitude": -107.323541, "time_offset_sec": 5330}, "public_key_hex": "61789d0c019fd98d0fa56ecf15c885503ef553fa4f43454ef4a0e7ae7a573834", "role": "CLIENT", "short_name": "🦌", "snr": -4.72, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 5364, "long_name": "Silent Marmot", "next_hop": 0, "num": "0x6353afc3", "position": {"altitude": 933, "latitude": 32.702075, "location_source": "LOC_INTERNAL", "longitude": -107.566001, "time_offset_sec": 5402}, "public_key_hex": "d928a3932497e9be47674ac2801d636931afc04db78c23acc0a6021f06ad28a9", "role": "CLIENT_MUTE", "short_name": "SQQJ", "snr": 6.06, "status": null, "telemetry": {"air_util_tx": 0.661, "battery_level": 64, "channel_utilization": 27.31, "uptime_seconds": 49994, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 893, "long_name": "Wild Elk", "next_hop": 243, "num": "0x6355a7c5", "position": {"altitude": 1197, "latitude": 33.55011, "location_source": "LOC_INTERNAL", "longitude": -106.776633, "time_offset_sec": 917}, "public_key_hex": "0bdc2bfa88f2e89490a6fe4bdc8431057b199839d0558ff04ff0d38d514cb1f9", "role": "CLIENT", "short_name": "🌙", "snr": 1.09, "status": null, "telemetry": {"air_util_tx": 0.881, "battery_level": 68, "channel_utilization": 17.37, "uptime_seconds": 126959, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1020.49, "iaq": 45, "relative_humidity": 66.93, "temperature": 15.41}, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 4751, "long_name": "Giant Cougar", "next_hop": 3, "num": "0x637b4529", "position": {"altitude": 687, "latitude": 33.088762, "location_source": "LOC_INTERNAL", "longitude": -107.169103, "time_offset_sec": 5015}, "public_key_hex": "6442500d0613ee0e1ce6d5f87832d1dcc39f4a1369058dfbe0e2245eafc0d2a3", "role": "CLIENT", "short_name": "🦋", "snr": 4.01, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.433, "battery_level": 24, "channel_utilization": 4.63, "uptime_seconds": 218964, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": {"barometric_pressure": 1015.47, "iaq": 50, "relative_humidity": 59.22, "temperature": 30.95}, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 75, "long_name": "Sunny Phoenix", "next_hop": 12, "num": "0x638648ee", "position": {"altitude": 1374, "latitude": 32.68702, "location_source": "LOC_INTERNAL", "longitude": -107.330235, "time_offset_sec": 228}, "public_key_hex": "b13cc7c6f1c67101b575f112c1650030400b7276869d4c6f8dd333481f56374a", "role": "CLIENT", "short_name": "S4U6", "snr": 11.16, "status": null, "telemetry": {"air_util_tx": 1.727, "battery_level": 31, "channel_utilization": 8.92, "uptime_seconds": 25143, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2248, "long_name": "Giant Raven", "next_hop": 0, "num": "0x63896444", "position": null, "public_key_hex": "aadb1662056291aba01e6497c65ea65e18c4606432f055dfadce50f6e2047793", "role": "CLIENT", "short_name": "🐢", "snr": 10.08, "status": null, "telemetry": {"air_util_tx": 0.874, "battery_level": 13, "channel_utilization": 1.93, "uptime_seconds": 2043, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 968, "long_name": "Sneaky Pine", "next_hop": 139, "num": "0x638bfe4f", "position": {"altitude": 1510, "latitude": 33.623121, "location_source": "LOC_INTERNAL", "longitude": -108.008415, "time_offset_sec": 1081}, "public_key_hex": "", "role": "ROUTER", "short_name": "SYJ8", "snr": 5.52, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2913, "long_name": "Mountain Hawk", "next_hop": 0, "num": "0x639a2001", "position": {"altitude": 1329, "latitude": 32.957278, "location_source": "LOC_INTERNAL", "longitude": -107.537863, "time_offset_sec": 2956}, "public_key_hex": "af8a4716c93cce4a1b9ea93e44813f52c2e146f7c2ce9419753b336e8ed44fbd", "role": "CLIENT", "short_name": "MS6C", "snr": 10.9, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 523, "long_name": "Black Cactus", "next_hop": 0, "num": "0x63a72067", "position": {"altitude": 1117, "latitude": 32.633937, "location_source": "LOC_INTERNAL", "longitude": -106.852086, "time_offset_sec": 756}, "public_key_hex": "6c66bb90399f4c00f314cfc676ac82fe1e7ab696b250a612e3ce359fe4d6ae9d", "role": "CLIENT", "short_name": "B287", "snr": 6.98, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1033, "long_name": "New Bison", "next_hop": 134, "num": "0x63bafddb", "position": {"altitude": 1549, "latitude": 33.409383, "location_source": "LOC_INTERNAL", "longitude": -107.670348, "time_offset_sec": 1048}, "public_key_hex": "907ad82b999deb32cdbb86bb831bc7fae31dc55e702985a88b2c2c1360654676", "role": "LOST_AND_FOUND", "short_name": "N5AX", "snr": 6.77, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.897, "battery_level": 41, "channel_utilization": 5.11, "uptime_seconds": 50438, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 11258, "long_name": "Whispering Phoenix", "next_hop": 0, "num": "0x63d5b1ee", "position": {"altitude": 1663, "latitude": 32.496112, "location_source": "LOC_INTERNAL", "longitude": -106.904419, "time_offset_sec": 11497}, "public_key_hex": "07cd7bdb20c8c951543c896936e095407598b946056c62656eadcec6fe494f88", "role": "CLIENT", "short_name": "WV2V", "snr": 1.78, "status": null, "telemetry": {"air_util_tx": 0.47, "battery_level": 86, "channel_utilization": 20.53, "uptime_seconds": 48034, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 913, "long_name": "Quick Dolphin", "next_hop": 0, "num": "0x63d5d12d", "position": {"altitude": 1517, "latitude": 33.571967, "location_source": "LOC_INTERNAL", "longitude": -107.580757, "time_offset_sec": 981}, "public_key_hex": "17cf276e6c2a3e0f902d05652514d3b124007a2a36b5ddbeeb16f4267b9cb0ca", "role": "CLIENT", "short_name": "QLW8", "snr": 7.11, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.7, "iaq": 82, "relative_humidity": 96.52, "temperature": 28.32}, "hops_away": 4, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 6432, "long_name": "White Cobra K19PF", "next_hop": 137, "num": "0x63df67e8", "position": {"altitude": 1635, "latitude": 32.848979, "location_source": "LOC_INTERNAL", "longitude": -108.107274, "time_offset_sec": 6588}, "public_key_hex": "b51e5af86bda079bbfac62da311062ea3b3eaf49c7df6a1b9f39f182842cdc17", "role": "CLIENT", "short_name": "WEER", "snr": 10.59, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.1, "iaq": 41, "relative_humidity": 34.5, "temperature": 13.0}, "hops_away": 4, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 5320, "long_name": "Desert Stag", "next_hop": 33, "num": "0x63e3f467", "position": {"altitude": 1106, "latitude": 33.106543, "location_source": "LOC_INTERNAL", "longitude": -107.119063, "time_offset_sec": 5422}, "public_key_hex": "1467b3cfdf795b103ae5fb243bcbb7ec0d4bc0d47106cb1b1eaa9f2259c7a140", "role": "CLIENT", "short_name": "DL2U", "snr": 4.51, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.348, "battery_level": 101, "channel_utilization": 33.5, "uptime_seconds": 203933, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2908, "long_name": "Drowsy Dolphin", "next_hop": 221, "num": "0x640a71d3", "position": {"altitude": 1437, "latitude": 33.018347, "location_source": "LOC_INTERNAL", "longitude": -107.501828, "time_offset_sec": 2909}, "public_key_hex": "0e3142c25b02621eba24f7f8372c7a0cdcbd81a3ecfcf925c397aeac04b1007e", "role": "CLIENT", "short_name": "DYQ6", "snr": 4.38, "status": null, "telemetry": {"air_util_tx": 1.248, "battery_level": 68, "channel_utilization": 16.69, "uptime_seconds": 244092, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 999.3, "iaq": 73, "relative_humidity": 43.09, "temperature": 34.71}, "hops_away": 0, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 311, "long_name": "Stone Elk", "next_hop": 0, "num": "0x641484eb", "position": {"altitude": 1267, "latitude": 32.544282, "location_source": "LOC_INTERNAL", "longitude": -107.556106, "time_offset_sec": 335}, "public_key_hex": "b75065c87086ac66dfe74607605102a308c3b8db664097d0d36383df169b1c71", "role": "CLIENT", "short_name": "SDMB", "snr": 5.44, "status": null, "telemetry": {"air_util_tx": 0.564, "battery_level": 31, "channel_utilization": 7.54, "uptime_seconds": 159879, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 366, "long_name": "River Shark", "next_hop": 0, "num": "0x6416d57d", "position": {"altitude": 1429, "latitude": 33.118555, "location_source": "LOC_INTERNAL", "longitude": -107.298777, "time_offset_sec": 425}, "public_key_hex": "18bc66321717b3210e093da3e30555a4834489e5ba66ca5e4afba3bf1db08385", "role": "CLIENT_MUTE", "short_name": "RPKV", "snr": 3.49, "status": null, "telemetry": {"air_util_tx": 0.453, "battery_level": 89, "channel_utilization": 1.94, "uptime_seconds": 16645, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 127, "long_name": "Solar Salmon", "next_hop": 41, "num": "0x642f7492", "position": {"altitude": 1131, "latitude": 32.883987, "location_source": "LOC_INTERNAL", "longitude": -106.588932, "time_offset_sec": 193}, "public_key_hex": "08e90eaa59ab4d9feb7d5f398e79a9905aac74920688ec16a98a6358a32a3611", "role": "ROUTER", "short_name": "🌲", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.337, "battery_level": 21, "channel_utilization": 6.99, "uptime_seconds": 28171, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 8051, "long_name": "Solar Doe", "next_hop": 246, "num": "0x644c644a", "position": null, "public_key_hex": "0842b98c3ff9eeff00a6ebf1c2979b420d898203131ff0e6cb0a00f1cfd25c4b", "role": "CLIENT", "short_name": "S7WY", "snr": 4.02, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.343, "battery_level": 65, "channel_utilization": 11.98, "uptime_seconds": 15252, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.71, "iaq": 57, "relative_humidity": 83.49, "temperature": 17.37}, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1598, "long_name": "Fast Mole", "next_hop": 119, "num": "0x644f2cec", "position": {"altitude": 1354, "latitude": 33.365444, "location_source": "LOC_INTERNAL", "longitude": -107.063109, "time_offset_sec": 1832}, "public_key_hex": "ebefc2b9dd29b33e12b205048043beb81e2255e173c95998e12bc4edcdcf879c", "role": "ROUTER", "short_name": "🐺", "snr": 2.16, "status": null, "telemetry": {"air_util_tx": 0.233, "battery_level": 48, "channel_utilization": 8.34, "uptime_seconds": 79834, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1948, "long_name": "Sleepy Pony", "next_hop": 103, "num": "0x6494b110", "position": {"altitude": 1594, "latitude": 32.061333, "location_source": "LOC_INTERNAL", "longitude": -106.396642, "time_offset_sec": 2094}, "public_key_hex": "1cfad40092f12b7fa1efd71281b76181c2bd8e2ebc4267fc00ec79722228d393", "role": "CLIENT", "short_name": "SR6R", "snr": 7.66, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.355, "battery_level": 73, "channel_utilization": 15.76, "uptime_seconds": 109594, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 367, "long_name": "Bright Marmot", "next_hop": 0, "num": "0x64c10896", "position": null, "public_key_hex": "06100e2a7ca1a9890b823333a952fcf5f1b81201f98fbeb0457522a770acf55f", "role": "TAK", "short_name": "BJS8", "snr": -1.82, "status": null, "telemetry": {"air_util_tx": 1.317, "battery_level": 74, "channel_utilization": 10.46, "uptime_seconds": 45179, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.61, "iaq": 0, "relative_humidity": 54.84, "temperature": 26.14}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 7654, "long_name": "Blue Wolf", "next_hop": 0, "num": "0x64c15a2f", "position": {"altitude": 755, "latitude": 34.243754, "location_source": "LOC_INTERNAL", "longitude": -106.557298, "time_offset_sec": 7852}, "public_key_hex": "77636c8c31428ca7b1a3d35428839b5e6689aa8b0ac16b3590934cda1a35968b", "role": "CLIENT", "short_name": "BG0M", "snr": 9.3, "status": null, "telemetry": {"air_util_tx": 0.115, "battery_level": 10, "channel_utilization": 11.12, "uptime_seconds": 63872, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 8591, "long_name": "Black Cedar", "next_hop": 79, "num": "0x64c24d32", "position": {"altitude": 1107, "latitude": 33.007717, "location_source": "LOC_INTERNAL", "longitude": -107.22904, "time_offset_sec": 8652}, "public_key_hex": "8892e32d649e5cfa1bc507da74ad88ecbde13b48eae6da2ffb573e0852dea618", "role": "ROUTER", "short_name": "B33S", "snr": 6.12, "status": null, "telemetry": {"air_util_tx": 0.144, "battery_level": 61, "channel_utilization": 8.15, "uptime_seconds": 33586, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_T190", "last_heard_offset_sec": 123, "long_name": "Old Elk", "next_hop": 0, "num": "0x64c2e29f", "position": {"altitude": 1230, "latitude": 32.740524, "location_source": "LOC_INTERNAL", "longitude": -107.458223, "time_offset_sec": 361}, "public_key_hex": "07c32aec51e11f804e5f4f9cf5924611e7488baf3e79fbe8e148927beced64a5", "role": "ROUTER", "short_name": "OLWF", "snr": 12.0, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 1.603, "battery_level": 37, "channel_utilization": 16.47, "uptime_seconds": 25767, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 19767, "long_name": "Forest Squirrel", "next_hop": 242, "num": "0x64cf1755", "position": {"altitude": 1272, "latitude": 33.910845, "location_source": "LOC_INTERNAL", "longitude": -107.350384, "time_offset_sec": 19868}, "public_key_hex": "f6ec4cae6d445e454a3da7b046e2de77610a2ca6e8d813c025d0c5e94be00c42", "role": "CLIENT", "short_name": "F3H4", "snr": 6.03, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 477, "long_name": "Roving Ridge", "next_hop": 0, "num": "0x64d976c9", "position": {"altitude": 1519, "latitude": 33.714981, "location_source": "LOC_INTERNAL", "longitude": -107.338041, "time_offset_sec": 764}, "public_key_hex": "718bf4a02f9abbe0b702dbfd9639597dae4e46046180ffd45833cfad3b4f8f3a", "role": "CLIENT", "short_name": "RHKP", "snr": 7.02, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.839, "battery_level": 44, "channel_utilization": 8.96, "uptime_seconds": 98451, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1011.23, "iaq": 94, "relative_humidity": 26.51, "temperature": 9.67}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3785, "long_name": "Whispering Bass", "next_hop": 0, "num": "0x64db5e17", "position": {"altitude": 1621, "latitude": 32.906098, "location_source": "LOC_INTERNAL", "longitude": -107.115834, "time_offset_sec": 3996}, "public_key_hex": "", "role": "CLIENT", "short_name": "WESJ", "snr": 6.64, "status": null, "telemetry": {"air_util_tx": 0.543, "battery_level": 71, "channel_utilization": 7.54, "uptime_seconds": 72666, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2039, "long_name": "Iron Badger", "next_hop": 168, "num": "0x64eb40a3", "position": {"altitude": 1410, "latitude": 33.39459, "location_source": "LOC_INTERNAL", "longitude": -106.475036, "time_offset_sec": 2146}, "public_key_hex": "0f76f7f0d2befe74aa0918d6b3249e13f343a761cce2ee69383588707ca3f0ed", "role": "CLIENT", "short_name": "🐺", "snr": 6.86, "status": null, "telemetry": {"air_util_tx": 0.538, "battery_level": 87, "channel_utilization": 2.48, "uptime_seconds": 21441, "voltage": 4.083}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 989, "long_name": "Silver Sage N56UD", "next_hop": 0, "num": "0x650b1187", "position": {"altitude": 1410, "latitude": 32.842855, "location_source": "LOC_INTERNAL", "longitude": -107.261646, "time_offset_sec": 1028}, "public_key_hex": "98ff048bceb8994cda1bf5136d6cb0838d7c813a21649d6da8e85b643c4112db", "role": "CLIENT", "short_name": "SFEN", "snr": 12.0, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.78, "battery_level": 17, "channel_utilization": 9.34, "uptime_seconds": 2997, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.95, "iaq": 18, "relative_humidity": 52.54, "temperature": 20.32}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4523, "long_name": "Stone Beaver", "next_hop": 0, "num": "0x650c3775", "position": {"altitude": 982, "latitude": 33.431141, "location_source": "LOC_INTERNAL", "longitude": -106.927568, "time_offset_sec": 4755}, "public_key_hex": "095bf4ff3bd37e78077b919d6cbf99c2b35f8bee3c65b28bab1f2ab27af2bcd8", "role": "CLIENT", "short_name": "SQRN", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.92, "iaq": 49, "relative_humidity": 43.76, "temperature": 21.37}, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2227, "long_name": "Misty Hawk", "next_hop": 33, "num": "0x651f40f0", "position": {"altitude": 1233, "latitude": 33.86859, "location_source": "LOC_INTERNAL", "longitude": -106.848396, "time_offset_sec": 2238}, "public_key_hex": "7dbc9c4ce83bee5f1a22d65c7ff122586f569ed29ff33359e91503a74fdeaebe", "role": "CLIENT", "short_name": "M222", "snr": 8.8, "status": null, "telemetry": {"air_util_tx": 0.361, "battery_level": 14, "channel_utilization": 10.67, "uptime_seconds": 48748, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 6717, "long_name": "Slow Wolf", "next_hop": 253, "num": "0x652cdfe2", "position": {"altitude": 1294, "latitude": 32.89843, "location_source": "LOC_INTERNAL", "longitude": -107.609782, "time_offset_sec": 6888}, "public_key_hex": "fe9271d014ed59eeb72d4a552d5ef9eacd947819214c045dffeb4be0a190ba46", "role": "TRACKER", "short_name": "SG8S", "snr": 8.42, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.111, "battery_level": 48, "channel_utilization": 20.62, "uptime_seconds": 29557, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1027.67, "iaq": 0, "relative_humidity": 58.23, "temperature": 29.46}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2763, "long_name": "River Hawk", "next_hop": 0, "num": "0x65306fba", "position": {"altitude": 1366, "latitude": 32.605835, "location_source": "LOC_INTERNAL", "longitude": -107.363706, "time_offset_sec": 2960}, "public_key_hex": "c80fd02afce050b793c8038f356c35d296f0f493d8ba6c59ee950c73c0f4cba5", "role": "CLIENT", "short_name": "RNX0", "snr": 8.64, "status": null, "telemetry": {"air_util_tx": 2.582, "battery_level": 55, "channel_utilization": 4.96, "uptime_seconds": 135243, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2502, "long_name": "Misty Adder", "next_hop": 90, "num": "0x653cec3e", "position": {"altitude": 1278, "latitude": 32.275357, "location_source": "LOC_INTERNAL", "longitude": -107.274355, "time_offset_sec": 2765}, "public_key_hex": "3104ffc0a1b3144e0d1c4d16a2b29e3a28b43b1a3d78c1df46ae252ba06f473e", "role": "CLIENT", "short_name": "MHSG", "snr": 2.16, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.678, "battery_level": 93, "channel_utilization": 17.06, "uptime_seconds": 57631, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.82, "iaq": 80, "relative_humidity": 40.45, "temperature": 9.04}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 389, "long_name": "Stone Bluff", "next_hop": 147, "num": "0x65436096", "position": {"altitude": 1426, "latitude": 32.889769, "location_source": "LOC_INTERNAL", "longitude": -107.426928, "time_offset_sec": 535}, "public_key_hex": "", "role": "CLIENT", "short_name": "🐝", "snr": 11.07, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.597, "battery_level": 31, "channel_utilization": 8.2, "uptime_seconds": 33615, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4502, "long_name": "Solar Salmon", "next_hop": 0, "num": "0x6548ffd3", "position": {"altitude": 1895, "latitude": 33.162543, "location_source": "LOC_INTERNAL", "longitude": -107.293227, "time_offset_sec": 4748}, "public_key_hex": "e2c622b963d3d3a05cd0d2f1add20c412e94fbb9a61ea5d052ac65fedcb8259e", "role": "CLIENT", "short_name": "SO7M", "snr": 6.59, "status": null, "telemetry": {"air_util_tx": 0.191, "battery_level": 25, "channel_utilization": 3.73, "uptime_seconds": 15553, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 942, "long_name": "Storm Pony", "next_hop": 0, "num": "0x65534db2", "position": {"altitude": 1418, "latitude": 33.207848, "location_source": "LOC_INTERNAL", "longitude": -106.913498, "time_offset_sec": 1096}, "public_key_hex": "", "role": "CLIENT", "short_name": "S7WC", "snr": 3.03, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.55, "battery_level": 101, "channel_utilization": 12.73, "uptime_seconds": 32103, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.36, "iaq": 34, "relative_humidity": 34.34, "temperature": 31.24}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1587, "long_name": "Iron Bronco", "next_hop": 0, "num": "0x655cfed6", "position": {"altitude": 1208, "latitude": 34.022395, "location_source": "LOC_INTERNAL", "longitude": -107.885594, "time_offset_sec": 1804}, "public_key_hex": "6c356c05dc7b32653cf0160fcc1cc826c797f02523191204ce71dd548da2ca4a", "role": "CLIENT", "short_name": "IJCI", "snr": 11.65, "status": null, "telemetry": {"air_util_tx": 0.381, "battery_level": 60, "channel_utilization": 9.32, "uptime_seconds": 53694, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.27, "iaq": 18, "relative_humidity": 56.04, "temperature": 22.41}, "hops_away": 2, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 3242, "long_name": "Bright Shark", "next_hop": 34, "num": "0x65621119", "position": {"altitude": 1661, "latitude": 32.559962, "location_source": "LOC_INTERNAL", "longitude": -106.973766, "time_offset_sec": 3482}, "public_key_hex": "a7be4a5a45e5aad05795d2ceb385e5f35eea1849b18fbc6238b65b6f2ec6731e", "role": "CLIENT", "short_name": "BO2C", "snr": 5.05, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.93, "iaq": 21, "relative_humidity": 70.68, "temperature": 25.21}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 91, "long_name": "Happy Crow", "next_hop": 211, "num": "0x657c9e8a", "position": {"altitude": 1208, "latitude": 33.739903, "location_source": "LOC_INTERNAL", "longitude": -107.303381, "time_offset_sec": 95}, "public_key_hex": "c48ceeb1a98b0eb5dd2ea4ef295288517ff0f4742296f366847631402f0ef7e7", "role": "ROUTER", "short_name": "H98W", "snr": 0.53, "status": null, "telemetry": {"air_util_tx": 0.022, "battery_level": 71, "channel_utilization": 4.48, "uptime_seconds": 51918, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.37, "iaq": 54, "relative_humidity": 61.39, "temperature": 19.39}, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7726, "long_name": "Desert Pine", "next_hop": 206, "num": "0x658e7979", "position": {"altitude": 1674, "latitude": 33.308656, "location_source": "LOC_INTERNAL", "longitude": -107.290512, "time_offset_sec": 7953}, "public_key_hex": "b0b575a368bd08b991b4957db8fdb2a055b2208e12fcf0e313705be5c4c49312", "role": "CLIENT", "short_name": "DIL6", "snr": 11.42, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.13, "battery_level": 51, "channel_utilization": 3.86, "uptime_seconds": 120107, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1754, "long_name": "Wandering Elk", "next_hop": 0, "num": "0x659cd426", "position": {"altitude": 1314, "latitude": 33.500753, "location_source": "LOC_INTERNAL", "longitude": -107.315128, "time_offset_sec": 1927}, "public_key_hex": "75f90bcd3c10dd3ef94f4c8f6e9a71a2fc55ff38f6410e0940911a87436f0947", "role": "CLIENT", "short_name": "🗻", "snr": 6.25, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.36, "battery_level": 78, "channel_utilization": 6.76, "uptime_seconds": 140547, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.15, "iaq": 39, "relative_humidity": 65.16, "temperature": 11.73}, "hops_away": 2, "hw_model": "HELTEC_V4_R8", "last_heard_offset_sec": 607, "long_name": "Dawn Adder", "next_hop": 239, "num": "0x65bc9b56", "position": {"altitude": 1476, "latitude": 33.455638, "location_source": "LOC_INTERNAL", "longitude": -107.373336, "time_offset_sec": 648}, "public_key_hex": "7db693da6b316c610c6911d0daedd237e32bd100e9ae29486e6ee1c14f76e7cc", "role": "CLIENT", "short_name": "DO2I", "snr": 4.6, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 9053, "long_name": "Rough Owl", "next_hop": 98, "num": "0x65c07885", "position": {"altitude": 1233, "latitude": 32.409816, "location_source": "LOC_INTERNAL", "longitude": -107.413395, "time_offset_sec": 9117}, "public_key_hex": "b0c3a517b14be4ee4ffc3fb392309fa21e7b1fe53c6575d2497cef091b0dc2c2", "role": "CLIENT", "short_name": "🦌", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 2.361, "battery_level": 101, "channel_utilization": 13.97, "uptime_seconds": 86509, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 998.42, "iaq": 35, "relative_humidity": 51.77, "temperature": 20.07}, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 815, "long_name": "Desert Mamba", "next_hop": 41, "num": "0x65e8aece", "position": {"altitude": 1366, "latitude": 33.719561, "location_source": "LOC_INTERNAL", "longitude": -107.272021, "time_offset_sec": 905}, "public_key_hex": "ad8d7657a6b200ab9448f865cd3752b61e2f661749ab2f05e1ec9b918465f14c", "role": "CLIENT_BASE", "short_name": "🌊", "snr": 10.48, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.068, "battery_level": 74, "channel_utilization": 7.98, "uptime_seconds": 65198, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6050, "long_name": "Misty Seal", "next_hop": 0, "num": "0x65f56c4b", "position": null, "public_key_hex": "84e0ac89ec8f052b5168f4220ec2f1ed3dab613e7bb0bd85d3ca8751db1304f3", "role": "CLIENT", "short_name": "MAZZ", "snr": 10.12, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.86, "battery_level": 52, "channel_utilization": 11.02, "uptime_seconds": 17545, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1512, "long_name": "Mountain Pike", "next_hop": 0, "num": "0x6602c376", "position": {"altitude": 1785, "latitude": 33.175337, "location_source": "LOC_INTERNAL", "longitude": -107.419109, "time_offset_sec": 1571}, "public_key_hex": "99b0931bdd7b9ee88320941d1e298e191daaeccb1ccd201ace4e706047a4acf9", "role": "CLIENT", "short_name": "MCCD", "snr": 2.24, "status": null, "telemetry": {"air_util_tx": 0.207, "battery_level": 73, "channel_utilization": 6.38, "uptime_seconds": 43927, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1834, "long_name": "Floating Gecko", "next_hop": 0, "num": "0x660ec1cd", "position": {"altitude": 1285, "latitude": 33.753076, "location_source": "LOC_INTERNAL", "longitude": -107.248759, "time_offset_sec": 2075}, "public_key_hex": "95202ee8a69ee30e0573ee393ccdeef48de7dcd3fa305d443b7174900a5100d5", "role": "CLIENT_MUTE", "short_name": "🔥", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.761, "battery_level": 23, "channel_utilization": 26.65, "uptime_seconds": 159867, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 246, "long_name": "Silver Turtle", "next_hop": 0, "num": "0x6612b4f4", "position": {"altitude": 1519, "latitude": 33.561002, "location_source": "LOC_INTERNAL", "longitude": -107.925983, "time_offset_sec": 279}, "public_key_hex": "04bab69030e64c537eece2fc6d3191ad3683fc394cb2f51d1f08f29cb9ac7233", "role": "TRACKER", "short_name": "SUYT", "snr": 2.66, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.553, "battery_level": 44, "channel_utilization": 10.49, "uptime_seconds": 78232, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 5594, "long_name": "Fast Bear", "next_hop": 0, "num": "0x6614d7d4", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "F4TR", "snr": 6.89, "status": null, "telemetry": {"air_util_tx": 0.106, "battery_level": 100, "channel_utilization": 13.79, "uptime_seconds": 216901, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1025.93, "iaq": 89, "relative_humidity": 86.06, "temperature": 18.86}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 3874, "long_name": "Sunny Cougar", "next_hop": 0, "num": "0x6626e422", "position": null, "public_key_hex": "8bd63a9108fe6755a13a0a1841e2964e00d8e8c8ec22d50454099af6d89f7ae2", "role": "CLIENT", "short_name": "🗻", "snr": 4.72, "status": null, "telemetry": {"air_util_tx": 3.34, "battery_level": 21, "channel_utilization": 1.79, "uptime_seconds": 25945, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1480, "long_name": "Dusk Turtle", "next_hop": 67, "num": "0x6637617a", "position": {"altitude": 1647, "latitude": 33.512048, "location_source": "LOC_INTERNAL", "longitude": -108.337262, "time_offset_sec": 1693}, "public_key_hex": "", "role": "CLIENT", "short_name": "DIY9", "snr": 3.29, "status": null, "telemetry": {"air_util_tx": 1.338, "battery_level": 48, "channel_utilization": 6.21, "uptime_seconds": 44684, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.85, "iaq": 65, "relative_humidity": 55.14, "temperature": 0.21}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 6746, "long_name": "Rough Trout AE4ZB", "next_hop": 0, "num": "0x664299df", "position": {"altitude": 1253, "latitude": 32.774653, "location_source": "LOC_INTERNAL", "longitude": -106.631886, "time_offset_sec": 6916}, "public_key_hex": "62bd51bf30ffaa7e619ab94d758bd6d0572e8247765856f1d456071042fb34b4", "role": "CLIENT", "short_name": "RI6T", "snr": 8.44, "status": null, "telemetry": {"air_util_tx": 1.064, "battery_level": 98, "channel_utilization": 9.39, "uptime_seconds": 148664, "voltage": 4.182}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": {"barometric_pressure": 1009.82, "iaq": 72, "relative_humidity": 40.55, "temperature": 27.79}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7260, "long_name": "Loud Stag", "next_hop": 0, "num": "0x664576b5", "position": {"altitude": 1378, "latitude": 33.881154, "location_source": "LOC_INTERNAL", "longitude": -107.807586, "time_offset_sec": 7533}, "public_key_hex": "09250480a145ad79c23c8a7cc9da038b340e8b2fe89c4ff67b7522be2d0bd0cc", "role": "CLIENT_HIDDEN", "short_name": "LXJM", "snr": 5.79, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.146, "battery_level": 33, "channel_utilization": 21.64, "uptime_seconds": 66024, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1663, "long_name": "Sky Gecko", "next_hop": 149, "num": "0x665e25b9", "position": {"altitude": 1051, "latitude": 33.070588, "location_source": "LOC_INTERNAL", "longitude": -108.036836, "time_offset_sec": 1686}, "public_key_hex": "63b6e579c262718d30bccd7a4d64fd98d224acea5846624576cbf08b13536350", "role": "CLIENT", "short_name": "S3QW", "snr": 9.55, "status": null, "telemetry": {"air_util_tx": 0.603, "battery_level": 86, "channel_utilization": 17.68, "uptime_seconds": 45897, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 959, "long_name": "White Otter", "next_hop": 0, "num": "0x666203e1", "position": {"altitude": 1316, "latitude": 33.265492, "location_source": "LOC_INTERNAL", "longitude": -107.01913, "time_offset_sec": 1072}, "public_key_hex": "d4756b55d3a4d58b18fb439d46d4a2e988059dceb73b5acda8487d96f548c039", "role": "CLIENT", "short_name": "WTP9", "snr": -3.67, "status": {"status": "rebooted"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "CROWPANEL", "last_heard_offset_sec": 9531, "long_name": "Canyon Bluff", "next_hop": 117, "num": "0x666c8f09", "position": {"altitude": 1493, "latitude": 32.049681, "location_source": "LOC_INTERNAL", "longitude": -107.249425, "time_offset_sec": 9815}, "public_key_hex": "", "role": "CLIENT", "short_name": "CRA2", "snr": -0.77, "status": null, "telemetry": {"air_util_tx": 0.166, "battery_level": 80, "channel_utilization": 7.25, "uptime_seconds": 151208, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK3312", "last_heard_offset_sec": 2918, "long_name": "Green Heron", "next_hop": 0, "num": "0x667b0bc1", "position": {"altitude": 1580, "latitude": 32.449689, "location_source": "LOC_INTERNAL", "longitude": -107.287777, "time_offset_sec": 3004}, "public_key_hex": "4281dfcad0b52616bcc937f1232a8545e5cf2a1909342eeb080ece73e4e9b785", "role": "CLIENT", "short_name": "G4ZP", "snr": 6.01, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 7864, "long_name": "Blue Fox", "next_hop": 0, "num": "0x6685355f", "position": {"altitude": 1591, "latitude": 34.108479, "location_source": "LOC_INTERNAL", "longitude": -107.685382, "time_offset_sec": 8127}, "public_key_hex": "02f34327521112602edae4224200c0818dffd32cdffa8b25726f7e47413c0ac7", "role": "ROUTER_LATE", "short_name": "BVZW", "snr": 7.03, "status": null, "telemetry": {"air_util_tx": 0.306, "battery_level": 89, "channel_utilization": 4.28, "uptime_seconds": 68969, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 522, "long_name": "Shady Gecko", "next_hop": 0, "num": "0x66875e76", "position": {"altitude": 1241, "latitude": 32.506949, "location_source": "LOC_INTERNAL", "longitude": -106.833158, "time_offset_sec": 527}, "public_key_hex": "8ff5a28b693133a8b87560bda02c3a4a2ddef48a0ff83f7ec6f711aeef337440", "role": "CLIENT_HIDDEN", "short_name": "S4PP", "snr": 6.11, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.918, "battery_level": 101, "channel_utilization": 5.36, "uptime_seconds": 4110, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1000.85, "iaq": 6, "relative_humidity": 53.35, "temperature": 14.7}, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 5357, "long_name": "Roving Dolphin", "next_hop": 66, "num": "0x668851d3", "position": {"altitude": 1164, "latitude": 32.957699, "location_source": "LOC_INTERNAL", "longitude": -107.465691, "time_offset_sec": 5656}, "public_key_hex": "b8830ea5ae13165ec6eb8f718bdd246584dd9c8e5faad2bd2371a0c681af25a1", "role": "CLIENT", "short_name": "REZA", "snr": 6.62, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.071, "battery_level": 80, "channel_utilization": 1.98, "uptime_seconds": 28926, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3140, "long_name": "Howling Crane", "next_hop": 163, "num": "0x66945b7f", "position": {"altitude": 1326, "latitude": 33.529525, "location_source": "LOC_INTERNAL", "longitude": -107.438571, "time_offset_sec": 3306}, "public_key_hex": "de4084465679b9214e104ea0005469843555c3a4a80526b98146b1ff1e0571a5", "role": "CLIENT", "short_name": "HIQ7", "snr": 6.81, "status": null, "telemetry": {"air_util_tx": 0.091, "battery_level": 73, "channel_utilization": 6.71, "uptime_seconds": 13493, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 6794, "long_name": "Steel Bronco", "next_hop": 0, "num": "0x6698bc37", "position": {"altitude": 1421, "latitude": 33.090928, "location_source": "LOC_INTERNAL", "longitude": -108.052936, "time_offset_sec": 6839}, "public_key_hex": "949a7fe794d116ce0751fa09972bdb876937e63cbad90e92e13372b027fa6de1", "role": "CLIENT", "short_name": "🦇", "snr": 4.75, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.181, "battery_level": 57, "channel_utilization": 18.1, "uptime_seconds": 170625, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 993.52, "iaq": 0, "relative_humidity": 73.03, "temperature": 27.43}, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 1593, "long_name": "Stone Cougar", "next_hop": 93, "num": "0x669e650e", "position": {"altitude": 963, "latitude": 32.525671, "location_source": "LOC_INTERNAL", "longitude": -106.180656, "time_offset_sec": 1817}, "public_key_hex": "", "role": "CLIENT", "short_name": "🌲", "snr": 9.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 6, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 2798, "long_name": "Wandering Fox", "next_hop": 232, "num": "0x66b090fc", "position": {"altitude": 1425, "latitude": 33.165897, "location_source": "LOC_INTERNAL", "longitude": -107.149052, "time_offset_sec": 2996}, "public_key_hex": "8b25f45a8c1a6e00b20e77956f1cf82a1972b58f1ab76a872cac3221370bd69a", "role": "CLIENT", "short_name": "W4FP", "snr": 6.41, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 760, "long_name": "Floating Otter", "next_hop": 135, "num": "0x66be1dc1", "position": {"altitude": 1318, "latitude": 33.274463, "location_source": "LOC_INTERNAL", "longitude": -107.655479, "time_offset_sec": 768}, "public_key_hex": "eadbe8e2689f33528cac866363026dfe8be22081bf7d547d5db732137866c132", "role": "CLIENT", "short_name": "F1LB", "snr": 4.3, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1002.7, "iaq": 31, "relative_humidity": 69.1, "temperature": 15.7}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2775, "long_name": "Giant Squirrel", "next_hop": 0, "num": "0x66cbabb9", "position": {"altitude": 1179, "latitude": 32.877197, "location_source": "LOC_INTERNAL", "longitude": -108.160994, "time_offset_sec": 2888}, "public_key_hex": "09d2aaa935a53df7ab043984059c9c1e9cb8736e3551590a6fd4cc8a4a96fbb5", "role": "ROUTER", "short_name": "🦌", "snr": -0.76, "status": null, "telemetry": {"air_util_tx": 0.173, "battery_level": 52, "channel_utilization": 13.67, "uptime_seconds": 92838, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 8602, "long_name": "Blue Pine AB5RQ", "next_hop": 0, "num": "0x66d03a8d", "position": {"altitude": 1336, "latitude": 33.529257, "location_source": "LOC_INTERNAL", "longitude": -106.708056, "time_offset_sec": 8751}, "public_key_hex": "264f26f9ac951362ad5c98f8791c8fb14fac47117d7af8c8e58ff3e64436c818", "role": "ROUTER", "short_name": "B4NU", "snr": 6.66, "status": null, "telemetry": {"air_util_tx": 0.303, "battery_level": 101, "channel_utilization": 22.04, "uptime_seconds": 76756, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.93, "iaq": 63, "relative_humidity": 29.08, "temperature": 25.6}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6498, "long_name": "Canyon Pike", "next_hop": 158, "num": "0x66d32f1c", "position": {"altitude": 1247, "latitude": 32.184647, "location_source": "LOC_INTERNAL", "longitude": -107.42933, "time_offset_sec": 6529}, "public_key_hex": "", "role": "CLIENT", "short_name": "C5VV", "snr": -1.43, "status": null, "telemetry": {"air_util_tx": 0.877, "battery_level": 37, "channel_utilization": 9.78, "uptime_seconds": 166987, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1660, "long_name": "Forest Turtle", "next_hop": 0, "num": "0x66eb311b", "position": {"altitude": 1743, "latitude": 32.686444, "location_source": "LOC_INTERNAL", "longitude": -107.109837, "time_offset_sec": 1665}, "public_key_hex": "", "role": "CLIENT", "short_name": "F5NL", "snr": -0.07, "status": null, "telemetry": {"air_util_tx": 0.834, "battery_level": 29, "channel_utilization": 10.26, "uptime_seconds": 75766, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 243, "long_name": "Wild Elk", "next_hop": 0, "num": "0x670adefc", "position": {"altitude": 1537, "latitude": 34.041243, "location_source": "LOC_INTERNAL", "longitude": -106.479116, "time_offset_sec": 445}, "public_key_hex": "924d386cc7d8a4568eda354d0769be1763b192ed5372e3335e19749f5526fa18", "role": "CLIENT", "short_name": "W6HH", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.575, "battery_level": 95, "channel_utilization": 11.5, "uptime_seconds": 5267, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.05, "iaq": 43, "relative_humidity": 64.48, "temperature": 15.82}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 507, "long_name": "Quick Sage", "next_hop": 0, "num": "0x67146ca6", "position": {"altitude": 1623, "latitude": 32.447805, "location_source": "LOC_INTERNAL", "longitude": -106.35376, "time_offset_sec": 658}, "public_key_hex": "5ee1c4da65548f1f4e0048777bed6af2e4565638e8d18cca1ecc81c0e8ddcf9e", "role": "ROUTER", "short_name": "QRIJ", "snr": 6.65, "status": null, "telemetry": {"air_util_tx": 0.312, "battery_level": 52, "channel_utilization": 21.95, "uptime_seconds": 11336, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 232, "long_name": "Rough Eagle", "next_hop": 0, "num": "0x67292215", "position": {"altitude": 898, "latitude": 33.936749, "location_source": "LOC_INTERNAL", "longitude": -107.531691, "time_offset_sec": 374}, "public_key_hex": "cc4711f593d7bdf7f0f743973a338807e38364617c14e47f4af08ebcadf7e385", "role": "CLIENT", "short_name": "RC7A", "snr": -1.12, "status": null, "telemetry": {"air_util_tx": 0.907, "battery_level": 29, "channel_utilization": 13.42, "uptime_seconds": 60042, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1023.19, "iaq": 44, "relative_humidity": 61.17, "temperature": 25.99}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3273, "long_name": "Storm Bass", "next_hop": 40, "num": "0x673bfb92", "position": {"altitude": 1669, "latitude": 32.426155, "location_source": "LOC_INTERNAL", "longitude": -107.876855, "time_offset_sec": 3504}, "public_key_hex": "68bccc7a000f202e31f3bc9fa6ef7bca694f10269a920fe0baac9792671c599a", "role": "CLIENT", "short_name": "S48Z", "snr": 1.82, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.686, "battery_level": 66, "channel_utilization": 21.15, "uptime_seconds": 75782, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2259, "long_name": "Silver Beaver", "next_hop": 199, "num": "0x67623dd5", "position": {"altitude": 1237, "latitude": 33.907106, "location_source": "LOC_INTERNAL", "longitude": -106.035125, "time_offset_sec": 2385}, "public_key_hex": "51f13cc81a735e4cf44e32bd406907d3699ef19ff402c1334fe5926d6f5c3705", "role": "CLIENT", "short_name": "SO6I", "snr": 7.71, "status": null, "telemetry": {"air_util_tx": 1.468, "battery_level": 27, "channel_utilization": 13.18, "uptime_seconds": 135898, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK3401", "last_heard_offset_sec": 275, "long_name": "Tiny Seal", "next_hop": 65, "num": "0x67780c39", "position": {"altitude": 1340, "latitude": 33.400752, "location_source": "LOC_INTERNAL", "longitude": -107.104892, "time_offset_sec": 364}, "public_key_hex": "070ab2d7546177ccb8f6e28a8cd74a0def381d28f127b4535a30ea130a7ab3fa", "role": "CLIENT", "short_name": "TCVO", "snr": 12.0, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.205, "battery_level": 28, "channel_utilization": 16.62, "uptime_seconds": 215565, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.31, "iaq": 65, "relative_humidity": 31.09, "temperature": 26.24}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4515, "long_name": "Dusk Adder", "next_hop": 0, "num": "0x677ff0e3", "position": {"altitude": 1044, "latitude": 32.744704, "location_source": "LOC_INTERNAL", "longitude": -107.519782, "time_offset_sec": 4577}, "public_key_hex": "", "role": "CLIENT", "short_name": "DVBE", "snr": 1.33, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.184, "battery_level": 34, "channel_utilization": 7.69, "uptime_seconds": 2989, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 671, "long_name": "Quick Hawk", "next_hop": 191, "num": "0x6790f90b", "position": {"altitude": 1437, "latitude": 32.553072, "location_source": "LOC_INTERNAL", "longitude": -106.310626, "time_offset_sec": 886}, "public_key_hex": "85e8d51ffd9bde5f086693817004d3623337fcef33ccd197914870e2928c5bf7", "role": "CLIENT", "short_name": "QFZW", "snr": 6.54, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.459, "battery_level": 22, "channel_utilization": 5.53, "uptime_seconds": 93079, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 188, "long_name": "Loud Phoenix", "next_hop": 29, "num": "0x67972ade", "position": {"altitude": 1912, "latitude": 33.040649, "location_source": "LOC_INTERNAL", "longitude": -106.008349, "time_offset_sec": 386}, "public_key_hex": "761bdff1831dcf6bb9a025f54e7d888dc9d55fcfee9f001bf43e004f56a9becb", "role": "CLIENT", "short_name": "LVA4", "snr": 1.98, "status": null, "telemetry": {"air_util_tx": 0.194, "battery_level": 25, "channel_utilization": 20.32, "uptime_seconds": 70805, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4493, "long_name": "Desert Hawk", "next_hop": 0, "num": "0x67ae9379", "position": {"altitude": 1052, "latitude": 33.130373, "location_source": "LOC_INTERNAL", "longitude": -108.656293, "time_offset_sec": 4643}, "public_key_hex": "3ca4bb1bcecb7d0bcbcd30ea4ac3bb1ef33ba4e803efd8a997a66e3bc59e40ca", "role": "CLIENT", "short_name": "DRIG", "snr": 5.3, "status": null, "telemetry": {"air_util_tx": 0.451, "battery_level": 72, "channel_utilization": 7.0, "uptime_seconds": 177871, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 559, "long_name": "Solar Crane", "next_hop": 0, "num": "0x67c59e05", "position": null, "public_key_hex": "3b7cfe3c2fcde16a3c67e75ef4462fdf44f486c16e44e41ba8ca24a4bbc73d2a", "role": "ROUTER", "short_name": "S4HF", "snr": -4.57, "status": null, "telemetry": {"air_util_tx": 0.523, "battery_level": 41, "channel_utilization": 7.59, "uptime_seconds": 363144, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1930, "long_name": "Black Colt", "next_hop": 31, "num": "0x67e6ffe6", "position": null, "public_key_hex": "def856536b52b44c4119f013893b794962e4489c48973a8dba725d18542d6fd8", "role": "CLIENT", "short_name": "BCAZ", "snr": 6.56, "status": null, "telemetry": {"air_util_tx": 0.372, "battery_level": 72, "channel_utilization": 10.01, "uptime_seconds": 38679, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 14127, "long_name": "Short Doe", "next_hop": 0, "num": "0x6811eeba", "position": {"altitude": 1600, "latitude": 32.427341, "location_source": "LOC_INTERNAL", "longitude": -106.657816, "time_offset_sec": 14288}, "public_key_hex": "", "role": "CLIENT", "short_name": "SSDY", "snr": -1.16, "status": null, "telemetry": {"air_util_tx": 1.447, "battery_level": 30, "channel_utilization": 6.16, "uptime_seconds": 52070, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3284, "long_name": "Lone Adder", "next_hop": 0, "num": "0x682f4345", "position": null, "public_key_hex": "56fffdd75f4a0eb217401fb0b30095cda4c0da5e6a45fbbce1eb8a527df63e0d", "role": "CLIENT", "short_name": "LV17", "snr": 6.63, "status": null, "telemetry": {"air_util_tx": 0.294, "battery_level": 39, "channel_utilization": 5.23, "uptime_seconds": 141727, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1141, "long_name": "Dusk Mesa", "next_hop": 0, "num": "0x683d0bf1", "position": {"altitude": 1516, "latitude": 32.790814, "location_source": "LOC_INTERNAL", "longitude": -107.893009, "time_offset_sec": 1241}, "public_key_hex": "fb73ed388ef21bab4fedd8ca2227299d6d8f0e80da588be8c0d36390f0d9df02", "role": "CLIENT", "short_name": "DIU7", "snr": 2.57, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 5, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 942, "long_name": "Bright Stag", "next_hop": 49, "num": "0x683e8e2e", "position": {"altitude": 1247, "latitude": 33.112238, "location_source": "LOC_INTERNAL", "longitude": -107.020415, "time_offset_sec": 1092}, "public_key_hex": "17c3da8a86b08cbcb2514b5f91f3d4e3538f4ff599352195a3112338dd11c241", "role": "CLIENT", "short_name": "BQ7W", "snr": 0.74, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 987.98, "iaq": 49, "relative_humidity": 53.42, "temperature": 16.06}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 12534, "long_name": "White Crane", "next_hop": 0, "num": "0x684cdd2c", "position": {"altitude": 1354, "latitude": 33.339016, "location_source": "LOC_INTERNAL", "longitude": -107.324595, "time_offset_sec": 12618}, "public_key_hex": "634262250c4ee93a22313ea7a389dee2b76c30603f3d6dd4ee130395a099b7cd", "role": "CLIENT", "short_name": "WMRX", "snr": 5.34, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.178, "battery_level": 67, "channel_utilization": 14.15, "uptime_seconds": 137013, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2053, "long_name": "Dawn Viper", "next_hop": 0, "num": "0x686f2034", "position": null, "public_key_hex": "72d0456a2e21b45f3012ca38cfa0d0fcbbe77f84808f9923805a4813dcc83775", "role": "CLIENT", "short_name": "DOVP", "snr": 10.9, "status": null, "telemetry": {"air_util_tx": 1.181, "battery_level": 10, "channel_utilization": 3.63, "uptime_seconds": 174830, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 1594, "long_name": "Sneaky Mole", "next_hop": 84, "num": "0x686fc776", "position": {"altitude": 1475, "latitude": 33.786326, "location_source": "LOC_INTERNAL", "longitude": -107.688022, "time_offset_sec": 1703}, "public_key_hex": "4b8d22e519436f732cd09fd08aa252c11bd0d56c191e30bdd08bd8b075142c7a", "role": "CLIENT", "short_name": "SQKS", "snr": 10.77, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 1, "hw_model": "RAK3312", "last_heard_offset_sec": 567, "long_name": "Tall Viper", "next_hop": 16, "num": "0x6894a430", "position": null, "public_key_hex": "7e54900c3842415646f83efe75e41fdcd556531277d69dbc503e30ab26245578", "role": "ROUTER", "short_name": "TDOC", "snr": -2.04, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.69, "iaq": 41, "relative_humidity": 14.72, "temperature": 26.02}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 728, "long_name": "Canyon Mustang", "next_hop": 0, "num": "0x689cb6fe", "position": {"altitude": 1169, "latitude": 33.113305, "location_source": "LOC_INTERNAL", "longitude": -107.630865, "time_offset_sec": 957}, "public_key_hex": "e07e41d7bdf9035934013f9ff00e098d8d3cf56254623f65df9b70cc79e213cd", "role": "TRACKER", "short_name": "C1NY", "snr": 7.99, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK3401", "last_heard_offset_sec": 18115, "long_name": "Silent Trout", "next_hop": 0, "num": "0x68aedab8", "position": {"altitude": 1168, "latitude": 33.056609, "location_source": "LOC_INTERNAL", "longitude": -107.326548, "time_offset_sec": 18239}, "public_key_hex": "0d9680e50a590fcb662e731a467406861e3009001d3c849f0df1f7f85b3ea040", "role": "CLIENT", "short_name": "SMYX", "snr": 0.79, "status": null, "telemetry": {"air_util_tx": 0.073, "battery_level": 35, "channel_utilization": 16.94, "uptime_seconds": 23169, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1013.95, "iaq": 0, "relative_humidity": 11.44, "temperature": 26.86}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 10992, "long_name": "Howling Elk", "next_hop": 0, "num": "0x68b547bd", "position": {"altitude": 1092, "latitude": 32.463489, "location_source": "LOC_INTERNAL", "longitude": -107.778879, "time_offset_sec": 11102}, "public_key_hex": "5d75b3f6e12d23d75402259833213db2e10a42be0f9af6548578ffc5ab530936", "role": "CLIENT", "short_name": "H2DU", "snr": 4.11, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1069, "long_name": "Iron Badger", "next_hop": 104, "num": "0x68c7f460", "position": {"altitude": 1295, "latitude": 33.606062, "location_source": "LOC_INTERNAL", "longitude": -106.789821, "time_offset_sec": 1121}, "public_key_hex": "2f3d6ded4ff4c79a0f23198979bef84ea8effb290daf13ed14f0f4aa66866d99", "role": "CLIENT", "short_name": "IW8Q", "snr": 10.48, "status": null, "telemetry": {"air_util_tx": 0.743, "battery_level": 57, "channel_utilization": 17.05, "uptime_seconds": 115203, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 8423, "long_name": "Short Dolphin", "next_hop": 0, "num": "0x68c88829", "position": {"altitude": 1468, "latitude": 33.872813, "location_source": "LOC_INTERNAL", "longitude": -107.500772, "time_offset_sec": 8546}, "public_key_hex": "c0096e1e30d024382e867ac52335b3460dea70a9f0df98cfb91edc3c17659c5e", "role": "CLIENT", "short_name": "STJS", "snr": 3.93, "status": null, "telemetry": {"air_util_tx": 0.267, "battery_level": 69, "channel_utilization": 24.68, "uptime_seconds": 7908, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 3098, "long_name": "Mountain Bass", "next_hop": 248, "num": "0x68d1218a", "position": {"altitude": 1480, "latitude": 33.258807, "location_source": "LOC_INTERNAL", "longitude": -107.688326, "time_offset_sec": 3172}, "public_key_hex": "62d4d074a5d7cd910293b2f2bdd16b449dc57167b857a2b0caaa47f49778f838", "role": "CLIENT", "short_name": "MKVO", "snr": 0.82, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1006.1, "iaq": 51, "relative_humidity": 63.2, "temperature": 26.74}, "hops_away": 1, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 105, "long_name": "Rough Bluff", "next_hop": 41, "num": "0x68f1db77", "position": {"altitude": 1762, "latitude": 33.730497, "location_source": "LOC_INTERNAL", "longitude": -107.621425, "time_offset_sec": 349}, "public_key_hex": "25f18d4bfc098753dadf4b2be3e7f33fe58b29733aea3599796aa15afa706db3", "role": "CLIENT", "short_name": "RVI1", "snr": -2.1, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.07, "iaq": 86, "relative_humidity": 31.53, "temperature": 23.83}, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 7808, "long_name": "Sneaky Falcon", "next_hop": 17, "num": "0x69204546", "position": {"altitude": 1017, "latitude": 33.410783, "location_source": "LOC_INTERNAL", "longitude": -107.568694, "time_offset_sec": 7838}, "public_key_hex": "b773f391a12012d9f4974f44dd6c229c3c5adfd81f29199309f86623e3ab5b6f", "role": "CLIENT", "short_name": "🔥", "snr": 0.36, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.311, "battery_level": 30, "channel_utilization": 7.34, "uptime_seconds": 63220, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.29, "iaq": 40, "relative_humidity": 24.12, "temperature": 18.18}, "hops_away": 3, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 11499, "long_name": "Dusk Stag", "next_hop": 148, "num": "0x6920fd64", "position": {"altitude": 1663, "latitude": 34.040171, "location_source": "LOC_INTERNAL", "longitude": -107.644101, "time_offset_sec": 11758}, "public_key_hex": "7e9ec0f48319a48b98b145a6e7850597ac69d78b111b512cf884695caf9658d5", "role": "CLIENT", "short_name": "DUNV", "snr": 7.94, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.228, "battery_level": 83, "channel_utilization": 14.01, "uptime_seconds": 50357, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4743, "long_name": "Stone Whale", "next_hop": 173, "num": "0x693f2f0b", "position": {"altitude": 1512, "latitude": 33.026601, "location_source": "LOC_INTERNAL", "longitude": -107.771025, "time_offset_sec": 4898}, "public_key_hex": "", "role": "CLIENT", "short_name": "SOXV", "snr": 7.39, "status": null, "telemetry": {"air_util_tx": 0.891, "battery_level": 38, "channel_utilization": 18.02, "uptime_seconds": 75203, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_HT62", "last_heard_offset_sec": 3020, "long_name": "Steel Cactus", "next_hop": 0, "num": "0x694d801f", "position": {"altitude": 1504, "latitude": 32.493923, "location_source": "LOC_INTERNAL", "longitude": -107.670955, "time_offset_sec": 3180}, "public_key_hex": "8976ba02d8851d6c65bf8107b5d2028a4c0930673ef0b087a1811441da59f039", "role": "CLIENT", "short_name": "S1EJ", "snr": 7.58, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.663, "battery_level": 65, "channel_utilization": 19.28, "uptime_seconds": 13232, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.84, "iaq": 109, "relative_humidity": 56.45, "temperature": 22.6}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 14264, "long_name": "Blue Dolphin N50RH", "next_hop": 0, "num": "0x69823420", "position": {"altitude": 1491, "latitude": 33.70955, "location_source": "LOC_INTERNAL", "longitude": -107.198308, "time_offset_sec": 14493}, "public_key_hex": "44962c16766d84a5f600e0d0d55cb1b4fe6d97cded75529fdb0b645f5c2face1", "role": "CLIENT", "short_name": "BWF9", "snr": -2.46, "status": null, "telemetry": {"air_util_tx": 0.059, "battery_level": 95, "channel_utilization": 11.78, "uptime_seconds": 123767, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4122, "long_name": "Howling Yucca", "next_hop": 0, "num": "0x6992443d", "position": {"altitude": 1836, "latitude": 33.131394, "location_source": "LOC_INTERNAL", "longitude": -106.844457, "time_offset_sec": 4358}, "public_key_hex": "503cd945281a27a5b7c80a66663bfbe082d00ef587a183cc60b05ea590fbf453", "role": "CLIENT", "short_name": "HI41", "snr": 10.41, "status": null, "telemetry": {"air_util_tx": 0.372, "battery_level": 80, "channel_utilization": 11.58, "uptime_seconds": 116773, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.27, "iaq": 59, "relative_humidity": 64.25, "temperature": 31.96}, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5058, "long_name": "Slow Bass", "next_hop": 101, "num": "0x6996263a", "position": {"altitude": 1519, "latitude": 32.939779, "location_source": "LOC_INTERNAL", "longitude": -108.258355, "time_offset_sec": 5358}, "public_key_hex": "9da41c98b77a6a43a58324b692cbba69647fcb991154f4971436535a6cd683db", "role": "CLIENT", "short_name": "S78Z", "snr": 4.24, "status": null, "telemetry": {"air_util_tx": 2.479, "battery_level": 31, "channel_utilization": 25.64, "uptime_seconds": 26074, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4827, "long_name": "Wild Bluff", "next_hop": 174, "num": "0x69962df7", "position": {"altitude": 1429, "latitude": 33.385601, "location_source": "LOC_INTERNAL", "longitude": -108.076344, "time_offset_sec": 4930}, "public_key_hex": "ba8387a520d8f973b03af4ee2a1510f9e725d2cd8cc5c1e1a2612d90e366c627", "role": "CLIENT_MUTE", "short_name": "W5U6", "snr": 6.91, "status": null, "telemetry": {"air_util_tx": 0.595, "battery_level": 12, "channel_utilization": 8.87, "uptime_seconds": 178522, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7282, "long_name": "Sharp Whale", "next_hop": 0, "num": "0x699dbb4e", "position": {"altitude": 1962, "latitude": 33.350891, "location_source": "LOC_INTERNAL", "longitude": -107.397053, "time_offset_sec": 7490}, "public_key_hex": "1665e97136972d211f05a85d006065cdaa27dd44c006db84504c8e3f5c045542", "role": "TRACKER", "short_name": "SI6Y", "snr": 5.35, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.092, "battery_level": 36, "channel_utilization": 8.08, "uptime_seconds": 25695, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4443, "long_name": "Giant Badger", "next_hop": 0, "num": "0x69aad137", "position": {"altitude": 1424, "latitude": 33.494604, "location_source": "LOC_INTERNAL", "longitude": -107.044767, "time_offset_sec": 4467}, "public_key_hex": "ed704945b9331a8e804c0116c9c06f61a566b953baddb9bb19d231b2e8cdfb47", "role": "CLIENT", "short_name": "GHCG", "snr": 0.69, "status": null, "telemetry": {"air_util_tx": 3.06, "battery_level": 76, "channel_utilization": 12.55, "uptime_seconds": 4741, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5197, "long_name": "New Otter", "next_hop": 149, "num": "0x69ab97d4", "position": {"altitude": 1004, "latitude": 33.904665, "location_source": "LOC_INTERNAL", "longitude": -107.072381, "time_offset_sec": 5472}, "public_key_hex": "", "role": "CLIENT", "short_name": "NDO8", "snr": 2.51, "status": null, "telemetry": {"air_util_tx": 0.81, "battery_level": 48, "channel_utilization": 14.33, "uptime_seconds": 87285, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 3123, "long_name": "Stone Bluff", "next_hop": 193, "num": "0x69d14ed1", "position": {"altitude": 1304, "latitude": 32.746906, "location_source": "LOC_INTERNAL", "longitude": -107.248046, "time_offset_sec": 3317}, "public_key_hex": "3e8b8f28cf6f1cc45c0c8fda02f728f6279d02c5f547545e4feff349b57beb70", "role": "SENSOR", "short_name": "SSIX", "snr": 5.16, "status": null, "telemetry": {"air_util_tx": 0.509, "battery_level": 81, "channel_utilization": 4.61, "uptime_seconds": 129923, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1521, "long_name": "Frozen Lynx", "next_hop": 107, "num": "0x69d3e6e3", "position": null, "public_key_hex": "977207670126862ed7fe56178735d9ef06792434f22e155bcf1b98c0affc641f", "role": "CLIENT", "short_name": "FX80", "snr": 0.77, "status": null, "telemetry": {"air_util_tx": 0.725, "battery_level": 99, "channel_utilization": 16.04, "uptime_seconds": 140525, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 3100, "long_name": "Steel Elk", "next_hop": 83, "num": "0x69e086b3", "position": {"altitude": 1356, "latitude": 32.201572, "location_source": "LOC_INTERNAL", "longitude": -108.037938, "time_offset_sec": 3117}, "public_key_hex": "", "role": "CLIENT", "short_name": "SAJG", "snr": 5.03, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4356, "long_name": "Copper Sage", "next_hop": 0, "num": "0x69e714b8", "position": {"altitude": 1241, "latitude": 32.753187, "location_source": "LOC_INTERNAL", "longitude": -106.307074, "time_offset_sec": 4416}, "public_key_hex": "d401470402df0135121069dfde008d43df5f3f99900dec9cf7c55775853ab68c", "role": "CLIENT", "short_name": "🦉", "snr": 1.24, "status": null, "telemetry": {"air_util_tx": 0.701, "battery_level": 77, "channel_utilization": 16.07, "uptime_seconds": 93274, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.23, "iaq": 64, "relative_humidity": 90.51, "temperature": 20.76}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 11748, "long_name": "Roving Bronco KQ3JD", "next_hop": 0, "num": "0x6a036b70", "position": {"altitude": 1405, "latitude": 34.259234, "location_source": "LOC_INTERNAL", "longitude": -107.74456, "time_offset_sec": 11850}, "public_key_hex": "40bb798ea2376744ef75d9449e1019cd3843d382f81cd628bd3c83f6922b7121", "role": "CLIENT", "short_name": "RNBM", "snr": 4.92, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.087, "battery_level": 89, "channel_utilization": 3.75, "uptime_seconds": 18175, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 29, "long_name": "Shady Crow", "next_hop": 70, "num": "0x6a0c7726", "position": {"altitude": 1156, "latitude": 33.48791, "location_source": "LOC_INTERNAL", "longitude": -107.813642, "time_offset_sec": 35}, "public_key_hex": "b68aca7abbcd9ea09eddcadbc9360617f05ffbe5d861ddc2fd26c2e3dd7024e8", "role": "CLIENT", "short_name": "SQIR", "snr": 6.2, "status": null, "telemetry": {"air_util_tx": 0.224, "battery_level": 101, "channel_utilization": 6.4, "uptime_seconds": 42975, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 8475, "long_name": "Tall Owl K18BG", "next_hop": 0, "num": "0x6a1b270f", "position": {"altitude": 1586, "latitude": 32.250698, "location_source": "LOC_INTERNAL", "longitude": -106.604051, "time_offset_sec": 8521}, "public_key_hex": "7dfb5046b96406f23608d0a30f2b1725ed26c4a6949094718fcb3ede0664340c", "role": "CLIENT", "short_name": "TW5P", "snr": 6.66, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.263, "battery_level": 60, "channel_utilization": 2.8, "uptime_seconds": 115422, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 8067, "long_name": "Rough Bass", "next_hop": 44, "num": "0x6a1dfb80", "position": {"altitude": 1187, "latitude": 33.481801, "location_source": "LOC_INTERNAL", "longitude": -107.178131, "time_offset_sec": 8181}, "public_key_hex": "cfe50fd07e31a7a00cfcb5db7ec3cd62686e03ca6d95d520f932fbf27413b8ce", "role": "ROUTER", "short_name": "RVE3", "snr": 6.47, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4743, "long_name": "New Otter", "next_hop": 174, "num": "0x6a224df7", "position": null, "public_key_hex": "547ea0ae4db197ececc82d1a9af1bcf748fe4a9bb58b1174dbdc663d98c36871", "role": "TRACKER", "short_name": "NB5L", "snr": 6.38, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.646, "battery_level": 70, "channel_utilization": 27.32, "uptime_seconds": 141065, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 8191, "long_name": "Wild Bass", "next_hop": 0, "num": "0x6a458c72", "position": {"altitude": 1389, "latitude": 33.301301, "location_source": "LOC_INTERNAL", "longitude": -107.664869, "time_offset_sec": 8200}, "public_key_hex": "4167ff90b65eef986c3def082c982fc7bfb5fdfc85055dc38c77f37bdc90137f", "role": "CLIENT", "short_name": "WVRK", "snr": 1.24, "status": null, "telemetry": {"air_util_tx": 2.405, "battery_level": 23, "channel_utilization": 4.55, "uptime_seconds": 15760, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3357, "long_name": "Brave Salmon", "next_hop": 0, "num": "0x6a635615", "position": {"altitude": 836, "latitude": 34.039047, "location_source": "LOC_INTERNAL", "longitude": -106.733654, "time_offset_sec": 3537}, "public_key_hex": "1f669488cbb385b28cffc005a53f1f8123fe4ce854767951d9ac667ef340332d", "role": "CLIENT", "short_name": "BJSZ", "snr": 8.96, "status": null, "telemetry": {"air_util_tx": 0.687, "battery_level": 55, "channel_utilization": 8.86, "uptime_seconds": 55502, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 4280, "long_name": "Loud Trout", "next_hop": 183, "num": "0x6a6742a8", "position": {"altitude": 1040, "latitude": 32.44791, "location_source": "LOC_INTERNAL", "longitude": -107.446258, "time_offset_sec": 4492}, "public_key_hex": "6887cbc1c8316a0a64852bb7dd386d847e70254e372379682b42c822d9c1efff", "role": "CLIENT", "short_name": "🦋", "snr": 4.67, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 1.392, "battery_level": 48, "channel_utilization": 19.19, "uptime_seconds": 43202, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 4858, "long_name": "Storm Badger NM5WF", "next_hop": 0, "num": "0x6a6771ec", "position": {"altitude": 1254, "latitude": 32.524634, "location_source": "LOC_INTERNAL", "longitude": -107.226881, "time_offset_sec": 5008}, "public_key_hex": "0ace772d9c92e38411c52e4c6063f71f841ef506cbf2213f8da83e8912803539", "role": "CLIENT_BASE", "short_name": "SDDS", "snr": 6.42, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": {"barometric_pressure": 1011.88, "iaq": 63, "relative_humidity": 41.74, "temperature": 15.6}, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 12152, "long_name": "Green Doe", "next_hop": 120, "num": "0x6aa1d678", "position": {"altitude": 1533, "latitude": 33.182044, "location_source": "LOC_INTERNAL", "longitude": -107.158973, "time_offset_sec": 12194}, "public_key_hex": "194d34ef3fc26097c9639520465e18d583128a68425c00d52ec32d95db5cff65", "role": "CLIENT", "short_name": "🔥", "snr": 1.88, "status": null, "telemetry": {"air_util_tx": 0.638, "battery_level": 95, "channel_utilization": 5.64, "uptime_seconds": 31423, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 6052, "long_name": "Mountain Whale", "next_hop": 0, "num": "0x6aa90a28", "position": null, "public_key_hex": "acc2ac32d4dfb2d761158a05b890092ca512592713aae2b2f7b0a55999e55d2d", "role": "CLIENT", "short_name": "MG0L", "snr": 5.25, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 6662, "long_name": "Lunar Crane", "next_hop": 0, "num": "0x6ab47fc9", "position": {"altitude": 1334, "latitude": 33.037466, "location_source": "LOC_INTERNAL", "longitude": -107.03726, "time_offset_sec": 6701}, "public_key_hex": "4c9871c5abe4bc279385437df5651489f5c0de54ad515a7020c4e63b4991e5d2", "role": "CLIENT", "short_name": "🦇", "snr": 4.82, "status": null, "telemetry": {"air_util_tx": 0.504, "battery_level": 54, "channel_utilization": 11.43, "uptime_seconds": 9981, "voltage": 3.786}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.31, "iaq": 0, "relative_humidity": 63.1, "temperature": 35.25}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 608, "long_name": "Shady Bear", "next_hop": 0, "num": "0x6ab94033", "position": {"altitude": 1228, "latitude": 34.376074, "location_source": "LOC_INTERNAL", "longitude": -107.397043, "time_offset_sec": 624}, "public_key_hex": "461476dfdd4fee1dd9a5ee20167f50f6abc86df0634565b330e5a28e566c2d28", "role": "CLIENT", "short_name": "SY77", "snr": 7.56, "status": null, "telemetry": {"air_util_tx": 0.296, "battery_level": 61, "channel_utilization": 5.41, "uptime_seconds": 107720, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 475, "long_name": "Giant Heron", "next_hop": 0, "num": "0x6ac5097f", "position": {"altitude": 889, "latitude": 32.983826, "location_source": "LOC_INTERNAL", "longitude": -107.09141, "time_offset_sec": 726}, "public_key_hex": "", "role": "CLIENT", "short_name": "GA7P", "snr": 5.96, "status": null, "telemetry": {"air_util_tx": 0.578, "battery_level": 46, "channel_utilization": 14.03, "uptime_seconds": 89822, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1002.1, "iaq": 21, "relative_humidity": 54.06, "temperature": 15.89}, "hops_away": 4, "hw_model": "RAK4631", "last_heard_offset_sec": 344, "long_name": "Lost Owl", "next_hop": 26, "num": "0x6ae70b3a", "position": {"altitude": 1904, "latitude": 32.660264, "location_source": "LOC_INTERNAL", "longitude": -106.998159, "time_offset_sec": 576}, "public_key_hex": "028d9c039fe6c78d9d8f4f42d3a85a02eef5207f6ed1bf03403073d6d567aaae", "role": "ROUTER", "short_name": "LAR5", "snr": 10.82, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 64, "long_name": "Howling Crow", "next_hop": 73, "num": "0x6b2575ff", "position": {"altitude": 1070, "latitude": 32.614821, "location_source": "LOC_INTERNAL", "longitude": -107.378664, "time_offset_sec": 197}, "public_key_hex": "", "role": "CLIENT", "short_name": "H4WP", "snr": 2.91, "status": null, "telemetry": {"air_util_tx": 2.024, "battery_level": 45, "channel_utilization": 20.26, "uptime_seconds": 64714, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1146, "long_name": "Old Wolf", "next_hop": 0, "num": "0x6b2dc961", "position": {"altitude": 1477, "latitude": 32.624514, "location_source": "LOC_INTERNAL", "longitude": -106.923433, "time_offset_sec": 1316}, "public_key_hex": "cc3048f1dd38f75d4701b225d28605c4f0251d89d447325d29337c72c8febebb", "role": "CLIENT", "short_name": "ORHM", "snr": 1.63, "status": null, "telemetry": {"air_util_tx": 0.402, "battery_level": 25, "channel_utilization": 4.2, "uptime_seconds": 39829, "voltage": 3.525}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1032.59, "iaq": 28, "relative_humidity": 53.42, "temperature": 26.37}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2123, "long_name": "Bright Bluff AE7DP", "next_hop": 0, "num": "0x6b4e357e", "position": {"altitude": 1329, "latitude": 33.787963, "location_source": "LOC_INTERNAL", "longitude": -106.913195, "time_offset_sec": 2268}, "public_key_hex": "704ab3b195b2570d72f522a6c0f1b86c4f26f611bd45edc986eb42ad2b9a4b1a", "role": "CLIENT", "short_name": "BKIG", "snr": 5.92, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.653, "battery_level": 21, "channel_utilization": 7.48, "uptime_seconds": 77844, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.21, "iaq": 57, "relative_humidity": 31.08, "temperature": 35.86}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1597, "long_name": "Happy Hawk", "next_hop": 0, "num": "0x6b6618e8", "position": {"altitude": 1391, "latitude": 33.262946, "location_source": "LOC_INTERNAL", "longitude": -106.584797, "time_offset_sec": 1826}, "public_key_hex": "f84b3e0f3a02d5f7b291c6f5bcf421a500314623ff5fdddb6ab8a34a530f761c", "role": "CLIENT", "short_name": "🐺", "snr": 1.22, "status": null, "telemetry": {"air_util_tx": 0.715, "battery_level": 101, "channel_utilization": 10.4, "uptime_seconds": 9669, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 258, "long_name": "Sharp Tortoise", "next_hop": 0, "num": "0x6b66abae", "position": {"altitude": 1334, "latitude": 33.472451, "location_source": "LOC_INTERNAL", "longitude": -107.206705, "time_offset_sec": 486}, "public_key_hex": "8f227ff8ad1d53be51f2a9904b2ecad66d5aae14c333c69698248f5b7092fab5", "role": "CLIENT", "short_name": "SWZ1", "snr": -1.1, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 219, "long_name": "Drowsy Crane", "next_hop": 223, "num": "0x6b75206f", "position": {"altitude": 1408, "latitude": 33.384394, "location_source": "LOC_INTERNAL", "longitude": -106.640872, "time_offset_sec": 268}, "public_key_hex": "cb203c55dd6d34fd8ccd73fd9df7e5d987cf96a444a87afd6e15cd8fde8b8c98", "role": "CLIENT", "short_name": "DXMD", "snr": 5.94, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.493, "battery_level": 32, "channel_utilization": 4.67, "uptime_seconds": 67123, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 498, "long_name": "Hidden Bluff", "next_hop": 0, "num": "0x6b7cfca4", "position": {"altitude": 1237, "latitude": 32.659606, "location_source": "LOC_INTERNAL", "longitude": -106.379262, "time_offset_sec": 789}, "public_key_hex": "907820dafe1382a79f9c50c52ee91e20c7085dc91d17186088712d7a8fa77dd4", "role": "CLIENT", "short_name": "HX12", "snr": 3.23, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 4539, "long_name": "Hidden Lynx", "next_hop": 0, "num": "0x6b7dec4e", "position": {"altitude": 1765, "latitude": 32.921448, "location_source": "LOC_INTERNAL", "longitude": -107.272905, "time_offset_sec": 4633}, "public_key_hex": "2ab921487926b7ac0f55a7bbd35f219ba72c3a137cc15d376f8ce7b252276ea6", "role": "CLIENT", "short_name": "HEIZ", "snr": 2.58, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.678, "battery_level": 53, "channel_utilization": 14.62, "uptime_seconds": 32337, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.03, "iaq": 121, "relative_humidity": 41.23, "temperature": 31.77}, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5300, "long_name": "Frosty Adder", "next_hop": 0, "num": "0x6b7e13be", "position": {"altitude": 1380, "latitude": 33.040461, "location_source": "LOC_INTERNAL", "longitude": -107.343792, "time_offset_sec": 5472}, "public_key_hex": "", "role": "CLIENT", "short_name": "F29Y", "snr": 4.99, "status": null, "telemetry": {"air_util_tx": 1.197, "battery_level": 66, "channel_utilization": 7.91, "uptime_seconds": 25841, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 767, "long_name": "Tall Shark", "next_hop": 0, "num": "0x6b86b0f0", "position": {"altitude": 1201, "latitude": 32.985975, "location_source": "LOC_INTERNAL", "longitude": -107.860709, "time_offset_sec": 845}, "public_key_hex": "c9019aaf79809158faeea591711c197571093f015ea456a93d9adc48a1c476fe", "role": "CLIENT", "short_name": "TOXG", "snr": 8.45, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 1242, "long_name": "Dusk Doe", "next_hop": 142, "num": "0x6baac392", "position": {"altitude": 1681, "latitude": 33.36685, "location_source": "LOC_INTERNAL", "longitude": -106.173285, "time_offset_sec": 1405}, "public_key_hex": "9d103c26f796c7040876be8bce3c9c54030ae41fd33e58f0f3074734249eb246", "role": "CLIENT", "short_name": "D1FK", "snr": 11.64, "status": null, "telemetry": {"air_util_tx": 0.43, "battery_level": 11, "channel_utilization": 9.96, "uptime_seconds": 57416, "voltage": 3.399}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 948, "long_name": "Short Heron", "next_hop": 18, "num": "0x6bc30ef1", "position": {"altitude": 1506, "latitude": 32.837432, "location_source": "LOC_INTERNAL", "longitude": -106.409115, "time_offset_sec": 1246}, "public_key_hex": "915a2ee58898ba7600b6a631248200789c429334d9027cc7be492a3cf95b2356", "role": "CLIENT", "short_name": "SOP1", "snr": 9.97, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.102, "battery_level": 100, "channel_utilization": 5.59, "uptime_seconds": 294331, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.51, "iaq": 65, "relative_humidity": 53.28, "temperature": 4.01}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7176, "long_name": "Misty Lion", "next_hop": 0, "num": "0x6bc42d91", "position": null, "public_key_hex": "2a4234a1231fae3ca9b21999b9f7dea93c8b2d587d3fb7b495779c4ca45720da", "role": "CLIENT", "short_name": "MPKF", "snr": 2.9, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1575, "long_name": "Howling Bass", "next_hop": 0, "num": "0x6bca6a79", "position": {"altitude": 1909, "latitude": 33.413456, "location_source": "LOC_INTERNAL", "longitude": -107.039691, "time_offset_sec": 1811}, "public_key_hex": "8b0e97c741044bbcc41166758a0ab1ea7d84b9ba6dd0e6905d03bb8b1e375b35", "role": "TRACKER", "short_name": "H35Y", "snr": 8.92, "status": null, "telemetry": {"air_util_tx": 0.364, "battery_level": 71, "channel_utilization": 10.02, "uptime_seconds": 238713, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1315, "long_name": "Happy Seal", "next_hop": 0, "num": "0x6bfc8412", "position": {"altitude": 1216, "latitude": 32.718476, "location_source": "LOC_INTERNAL", "longitude": -107.365206, "time_offset_sec": 1521}, "public_key_hex": "c2ce80d98ad98b567465ec42943b4fecaac344230672db38c78a98fc6271919f", "role": "CLIENT", "short_name": "HFLK", "snr": 8.57, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.168, "battery_level": 36, "channel_utilization": 6.67, "uptime_seconds": 130401, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 1651, "long_name": "Blue Mustang", "next_hop": 29, "num": "0x6c058b23", "position": {"altitude": 1128, "latitude": 33.33532, "location_source": "LOC_INTERNAL", "longitude": -105.965476, "time_offset_sec": 1804}, "public_key_hex": "1826f7403cfce92c11e3ccf0685d6a490d03661da2022d3ec56d65a92c75c9d6", "role": "CLIENT", "short_name": "B8AB", "snr": 6.66, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.7, "battery_level": 48, "channel_utilization": 7.96, "uptime_seconds": 215003, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.32, "iaq": 72, "relative_humidity": 44.86, "temperature": 29.03}, "hops_away": 3, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 6576, "long_name": "Iron Salmon", "next_hop": 224, "num": "0x6c09338c", "position": {"altitude": 1207, "latitude": 32.351973, "location_source": "LOC_INTERNAL", "longitude": -106.864047, "time_offset_sec": 6748}, "public_key_hex": "7ac19b64ff227f33db4b7eed85e475568a0e3f2118cfc8ca614d7509437a20d1", "role": "CLIENT", "short_name": "IDCY", "snr": 8.41, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 2937, "long_name": "Burning Doe", "next_hop": 204, "num": "0x6c17ab9f", "position": {"altitude": 1571, "latitude": 33.171443, "location_source": "LOC_INTERNAL", "longitude": -106.679041, "time_offset_sec": 3111}, "public_key_hex": "8a7e5d21a1882aaca8845a4c2e3fb153b0c1fa9f5250a8426393376b7b75ecff", "role": "CLIENT", "short_name": "BB56", "snr": 5.69, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.732, "battery_level": 37, "channel_utilization": 10.62, "uptime_seconds": 62880, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 10381, "long_name": "Smooth Elk", "next_hop": 0, "num": "0x6c34e281", "position": {"altitude": 1506, "latitude": 33.14264, "location_source": "LOC_INTERNAL", "longitude": -106.742273, "time_offset_sec": 10550}, "public_key_hex": "ce694c27684a864637f9f719446bc759d1a92aa8162e722e8b0267085bd2e913", "role": "CLIENT", "short_name": "S1UJ", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.165, "battery_level": 77, "channel_utilization": 8.65, "uptime_seconds": 17161, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 4652, "long_name": "Drifting Trout KE0CW", "next_hop": 205, "num": "0x6c430242", "position": {"altitude": 2075, "latitude": 33.124073, "location_source": "LOC_INTERNAL", "longitude": -107.54355, "time_offset_sec": 4659}, "public_key_hex": "0aa98f668d08c628f72bdc77830a96951e02758df629eb6befa87ac2c378b287", "role": "CLIENT", "short_name": "DVBO", "snr": 6.92, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.39, "iaq": 56, "relative_humidity": 76.01, "temperature": 10.01}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1514, "long_name": "Tall Hare", "next_hop": 0, "num": "0x6c57b85c", "position": {"altitude": 1620, "latitude": 33.421925, "location_source": "LOC_INTERNAL", "longitude": -106.415227, "time_offset_sec": 1659}, "public_key_hex": "965f312d86ea2921b28ee37211d473a21545bca6f00c2cd5f8e62e27fe05e02c", "role": "CLIENT", "short_name": "TIG0", "snr": 2.24, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.331, "battery_level": 36, "channel_utilization": 10.47, "uptime_seconds": 55558, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 4549, "long_name": "Lone Stag", "next_hop": 0, "num": "0x6c5e22d5", "position": {"altitude": 1071, "latitude": 33.680832, "location_source": "LOC_INTERNAL", "longitude": -107.556991, "time_offset_sec": 4706}, "public_key_hex": "7ce2bf9de282ce56e8ac13f225ac0496e1839f00352b1765d62564570ebdb61d", "role": "CLIENT", "short_name": "LI8O", "snr": 4.97, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 3905, "long_name": "Old Falcon", "next_hop": 101, "num": "0x6c8b509a", "position": {"altitude": 1240, "latitude": 33.373464, "location_source": "LOC_INTERNAL", "longitude": -107.471425, "time_offset_sec": 4079}, "public_key_hex": "eee61409a9ebf60ebbc7de47cf59a57d8c07fa2c1ab2104d422d40b10bdde9fa", "role": "CLIENT", "short_name": "OZRI", "snr": 9.9, "status": null, "telemetry": {"air_util_tx": 0.042, "battery_level": 72, "channel_utilization": 9.84, "uptime_seconds": 78843, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 3294, "long_name": "Howling Juniper", "next_hop": 0, "num": "0x6ca45553", "position": null, "public_key_hex": "1c420a25eaa15b6da0905e6c072d521b333117c2bdb0844be5ee7e45a04b038e", "role": "TAK_TRACKER", "short_name": "HQ8Z", "snr": 2.22, "status": null, "telemetry": {"air_util_tx": 0.677, "battery_level": 101, "channel_utilization": 5.28, "uptime_seconds": 45650, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 7084, "long_name": "Loud Bison", "next_hop": 0, "num": "0x6cb8991d", "position": {"altitude": 855, "latitude": 33.521949, "location_source": "LOC_INTERNAL", "longitude": -108.230205, "time_offset_sec": 7134}, "public_key_hex": "0ede7e182c13aebee6dcba8ac23e40e14a8cb0425c5a391a2eabe59292dfe17b", "role": "CLIENT", "short_name": "LD7I", "snr": 3.89, "status": null, "telemetry": {"air_util_tx": 0.316, "battery_level": 95, "channel_utilization": 7.46, "uptime_seconds": 57169, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 3964, "long_name": "Happy Bronco", "next_hop": 0, "num": "0x6cba5daf", "position": {"altitude": 1384, "latitude": 32.973227, "location_source": "LOC_INTERNAL", "longitude": -108.012336, "time_offset_sec": 4017}, "public_key_hex": "c90fbe517066542d07dbcebb81816729ac958f7e2796102dde2c7fd056540ae5", "role": "CLIENT", "short_name": "HEHC", "snr": 6.7, "status": null, "telemetry": {"air_util_tx": 0.161, "battery_level": 77, "channel_utilization": 11.23, "uptime_seconds": 21740, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2300, "long_name": "Tiny Coyote", "next_hop": 131, "num": "0x6cc25b04", "position": {"altitude": 1204, "latitude": 32.947311, "location_source": "LOC_INTERNAL", "longitude": -108.126207, "time_offset_sec": 2525}, "public_key_hex": "cc947bdf4c37db9afedd45a70794bad3bad21bbd907f4608e031d97b1660d18b", "role": "CLIENT_MUTE", "short_name": "TKEH", "snr": 8.17, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6277, "long_name": "Desert Stag", "next_hop": 74, "num": "0x6ccedc09", "position": {"altitude": 1208, "latitude": 33.93298, "location_source": "LOC_INTERNAL", "longitude": -107.592905, "time_offset_sec": 6433}, "public_key_hex": "90f4cf91c8c39a01a5abf29c708b8637db9fb0235575f9e38244e20144e627d6", "role": "CLIENT", "short_name": "D51C", "snr": 5.55, "status": null, "telemetry": {"air_util_tx": 0.84, "battery_level": 53, "channel_utilization": 6.44, "uptime_seconds": 25423, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 166, "long_name": "Red Coyote", "next_hop": 13, "num": "0x6cd15a7c", "position": {"altitude": 952, "latitude": 31.938692, "location_source": "LOC_INTERNAL", "longitude": -107.546029, "time_offset_sec": 354}, "public_key_hex": "0a8437e52e608675623f0dab43e358c09b568df013b5869f9235d6c6f3792270", "role": "CLIENT", "short_name": "RV8W", "snr": 6.22, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.495, "battery_level": 43, "channel_utilization": 28.32, "uptime_seconds": 16591, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 7452, "long_name": "Happy Raven", "next_hop": 0, "num": "0x6d09d15e", "position": {"altitude": 1600, "latitude": 31.013508, "location_source": "LOC_INTERNAL", "longitude": -106.582607, "time_offset_sec": 7509}, "public_key_hex": "e95e5347fed8085a2e2397a4ab1ec350a1e40b0a435173ceaaa01d4a510ae5e8", "role": "CLIENT", "short_name": "HXM8", "snr": 9.92, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TBEAM_1_WATT", "last_heard_offset_sec": 2020, "long_name": "Found Oak", "next_hop": 0, "num": "0x6d1209ae", "position": null, "public_key_hex": "870ea52ac0bf228fcfe7f23d56201426c43dbe5f0b66b8e23ec47ccbe4a402b7", "role": "CLIENT", "short_name": "FZD1", "snr": 5.18, "status": null, "telemetry": {"air_util_tx": 0.314, "battery_level": 43, "channel_utilization": 3.52, "uptime_seconds": 20831, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.27, "iaq": 35, "relative_humidity": 47.23, "temperature": 25.8}, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 2962, "long_name": "Canyon Seal", "next_hop": 247, "num": "0x6d1f4ab5", "position": {"altitude": 1527, "latitude": 33.215931, "location_source": "LOC_INTERNAL", "longitude": -107.581894, "time_offset_sec": 2996}, "public_key_hex": "be10673547d3e1ba171946f6095de59d1060e37f363444df1ab858bc63aa257a", "role": "CLIENT", "short_name": "C7MG", "snr": -1.72, "status": null, "telemetry": {"air_util_tx": 0.676, "battery_level": 84, "channel_utilization": 12.54, "uptime_seconds": 15952, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 853, "long_name": "Sharp Hawk", "next_hop": 234, "num": "0x6d319091", "position": {"altitude": 1471, "latitude": 33.133128, "location_source": "LOC_INTERNAL", "longitude": -107.740002, "time_offset_sec": 1071}, "public_key_hex": "2417629f3e38d10d5680ba731672ecd9591ab64e2bee4f608de00187436ee7e1", "role": "CLIENT", "short_name": "S0Q0", "snr": 3.3, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 255, "long_name": "Lost Trout", "next_hop": 80, "num": "0x6d3cd20d", "position": null, "public_key_hex": "f448f64a2b15c7f719906817589cb9c0fed3fd6d643062da1a87f7fbd95b734e", "role": "CLIENT", "short_name": "LLOU", "snr": 11.31, "status": null, "telemetry": {"air_util_tx": 0.122, "battery_level": 21, "channel_utilization": 33.1, "uptime_seconds": 151601, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 700, "long_name": "Lone Beaver", "next_hop": 234, "num": "0x6d56b5b2", "position": {"altitude": 1608, "latitude": 33.818014, "location_source": "LOC_INTERNAL", "longitude": -107.377141, "time_offset_sec": 736}, "public_key_hex": "4fa1c85576a95f827515e9754886e9dac8a77083d808444220d446dc17ea5c94", "role": "ROUTER", "short_name": "LV0M", "snr": 4.56, "status": null, "telemetry": {"air_util_tx": 0.574, "battery_level": 50, "channel_utilization": 14.38, "uptime_seconds": 56528, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1621, "long_name": "Quick Bluff", "next_hop": 0, "num": "0x6d5bd3a2", "position": {"altitude": 1337, "latitude": 33.037351, "location_source": "LOC_INTERNAL", "longitude": -106.733149, "time_offset_sec": 1690}, "public_key_hex": "1363c4cd766740356d451227f568fdd601fd5549325aaf6be919786d6155e635", "role": "CLIENT_MUTE", "short_name": "QS7C", "snr": 8.61, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.431, "battery_level": 12, "channel_utilization": 23.7, "uptime_seconds": 19907, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1924, "long_name": "Quick Lion", "next_hop": 0, "num": "0x6d5c4fc0", "position": {"altitude": 1630, "latitude": 32.441426, "location_source": "LOC_INTERNAL", "longitude": -106.386612, "time_offset_sec": 1977}, "public_key_hex": "363136fe718d8aa73396b38e6774bb32063a5d62971afd3dd57aa6bd4db87700", "role": "CLIENT", "short_name": "QL22", "snr": 5.99, "status": null, "telemetry": {"air_util_tx": 0.34, "battery_level": 101, "channel_utilization": 22.68, "uptime_seconds": 71468, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1010.55, "iaq": 26, "relative_humidity": 23.6, "temperature": 16.12}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1488, "long_name": "Red Cougar", "next_hop": 0, "num": "0x6d7957ed", "position": {"altitude": 1590, "latitude": 33.418827, "location_source": "LOC_INTERNAL", "longitude": -108.093973, "time_offset_sec": 1642}, "public_key_hex": "f27d0ad57a7d48cd92cdcf43dcac7b63777ab93dee29498138a6ed375ed9f024", "role": "CLIENT", "short_name": "RGX3", "snr": 7.21, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 995.05, "iaq": 56, "relative_humidity": 47.11, "temperature": 24.33}, "hops_away": 2, "hw_model": "T_ECHO", "last_heard_offset_sec": 1706, "long_name": "Desert Arroyo", "next_hop": 227, "num": "0x6d79daf3", "position": {"altitude": 1389, "latitude": 32.690439, "location_source": "LOC_INTERNAL", "longitude": -106.470944, "time_offset_sec": 1851}, "public_key_hex": "ef6aad47c6f0257e0a044a079f5e014f0f4da472ac01de70253bd0adfc254bbb", "role": "CLIENT", "short_name": "D1Q9", "snr": 1.54, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 542, "long_name": "Tiny Sage", "next_hop": 128, "num": "0x6d7a4002", "position": {"altitude": 1475, "latitude": 32.559213, "location_source": "LOC_INTERNAL", "longitude": -106.534267, "time_offset_sec": 760}, "public_key_hex": "b5694e66bbcbb88a2a4dd66e3481aadb13a0d37997f31de412c359ebf4ed2453", "role": "CLIENT", "short_name": "TD70", "snr": 8.71, "status": null, "telemetry": {"air_util_tx": 0.335, "battery_level": 82, "channel_utilization": 5.91, "uptime_seconds": 98384, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1_EINK", "last_heard_offset_sec": 297, "long_name": "Canyon Ridge", "next_hop": 186, "num": "0x6d8ca2b8", "position": null, "public_key_hex": "f4bcf4153ca9415716fbd37dd542aa036ebf1bbf7ab1c22db6c68fcb27bacd60", "role": "ROUTER", "short_name": "🦂", "snr": 4.97, "status": null, "telemetry": {"air_util_tx": 0.126, "battery_level": 93, "channel_utilization": 10.01, "uptime_seconds": 45280, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.06, "iaq": 71, "relative_humidity": 41.6, "temperature": 21.92}, "hops_away": 1, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 548, "long_name": "Storm Dolphin", "next_hop": 155, "num": "0x6d999a1a", "position": {"altitude": 1471, "latitude": 33.595456, "location_source": "LOC_INTERNAL", "longitude": -107.305297, "time_offset_sec": 620}, "public_key_hex": "28ab66a4859676aea27fb06ecb8ccdd2e08fc3f671b0213d2e2aed1086e1a69f", "role": "CLIENT", "short_name": "SM67", "snr": 2.71, "status": null, "telemetry": {"air_util_tx": 0.28, "battery_level": 42, "channel_utilization": 2.64, "uptime_seconds": 276125, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 4285, "long_name": "Sky Mesa", "next_hop": 7, "num": "0x6db318ee", "position": {"altitude": 1292, "latitude": 33.437362, "location_source": "LOC_INTERNAL", "longitude": -107.1571, "time_offset_sec": 4531}, "public_key_hex": "60254606020e5e02cf6d0483a549efa16fda6947d4e18106ed388b8b9f137aac", "role": "CLIENT", "short_name": "S0G7", "snr": 7.13, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": {"barometric_pressure": 1009.84, "iaq": 78, "relative_humidity": 73.81, "temperature": 22.09}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 10564, "long_name": "River Seal", "next_hop": 0, "num": "0x6dc32e1b", "position": null, "public_key_hex": "f65cb2798a53cf48a3fc9c0e9a4c3a954a90110ff99a48bb69fde2690e6f079b", "role": "CLIENT", "short_name": "RTJW", "snr": 6.77, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1017.73, "iaq": 69, "relative_humidity": 10.42, "temperature": 15.02}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2935, "long_name": "Wild Crane", "next_hop": 0, "num": "0x6ddfd9b4", "position": {"altitude": 1599, "latitude": 33.392721, "location_source": "LOC_INTERNAL", "longitude": -106.484534, "time_offset_sec": 3156}, "public_key_hex": "36e17bb03b901a174e8da0b6d1ba41fe5e52d579c1c0fdcc9283e02a5755cc9e", "role": "ROUTER_LATE", "short_name": "WR36", "snr": 8.38, "status": null, "telemetry": {"air_util_tx": 1.296, "battery_level": 84, "channel_utilization": 15.93, "uptime_seconds": 134262, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.15, "iaq": 38, "relative_humidity": 98.05, "temperature": 23.23}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1701, "long_name": "Gold Mesa", "next_hop": 0, "num": "0x6ded1c0b", "position": {"altitude": 1279, "latitude": 32.938097, "location_source": "LOC_INTERNAL", "longitude": -106.730152, "time_offset_sec": 1751}, "public_key_hex": "8c49143f733db0baa53423c12ba33f33284ade79c15a08a17fc285d37127eb6d", "role": "CLIENT", "short_name": "GUBB", "snr": 7.98, "status": null, "telemetry": {"air_util_tx": 2.505, "battery_level": 69, "channel_utilization": 5.6, "uptime_seconds": 111737, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 1002, "long_name": "Whispering Dolphin", "next_hop": 0, "num": "0x6dfc5ffe", "position": {"altitude": 1221, "latitude": 32.727059, "location_source": "LOC_INTERNAL", "longitude": -107.110454, "time_offset_sec": 1089}, "public_key_hex": "997cdf367004e4354774cac6d3fa398783e31d4ad1bb48e4adeeab3ba1f7b597", "role": "CLIENT", "short_name": "WYJ0", "snr": 7.23, "status": {"status": "online"}, "telemetry": {"air_util_tx": 2.362, "battery_level": 71, "channel_utilization": 7.47, "uptime_seconds": 96073, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 14731, "long_name": "Frozen Mamba", "next_hop": 0, "num": "0x6dfc9c5d", "position": {"altitude": 1802, "latitude": 33.96352, "location_source": "LOC_INTERNAL", "longitude": -106.919151, "time_offset_sec": 14879}, "public_key_hex": "dc95428479c9e5d42b277ff07938c3342bc8f5583e9952b66c445b3553360b91", "role": "CLIENT", "short_name": "F00O", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.229, "battery_level": 47, "channel_utilization": 11.17, "uptime_seconds": 43888, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.99, "iaq": 10, "relative_humidity": 9.24, "temperature": 27.43}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1997, "long_name": "Dawn Sage", "next_hop": 0, "num": "0x6e03a7f1", "position": {"altitude": 1913, "latitude": 33.073529, "location_source": "LOC_INTERNAL", "longitude": -106.80514, "time_offset_sec": 2160}, "public_key_hex": "a7166ecb8e5eb387be232d946014722840d37f4080a27a3a496df99af76ec045", "role": "CLIENT", "short_name": "DMMS", "snr": 7.91, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.25, "iaq": 45, "relative_humidity": 25.12, "temperature": 27.73}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2587, "long_name": "Loud Yucca", "next_hop": 0, "num": "0x6e0be2cb", "position": {"altitude": 1308, "latitude": 33.057419, "location_source": "LOC_INTERNAL", "longitude": -107.160226, "time_offset_sec": 2727}, "public_key_hex": "273ef5faa257d2762815db02b00015fd46a389744e1377038bbab7eff2d30691", "role": "CLIENT", "short_name": "L9GU", "snr": 8.21, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1118, "long_name": "Lost Cougar", "next_hop": 0, "num": "0x6e41a59e", "position": null, "public_key_hex": "99441aed507fdc70190e56dbfde56bb527150d3f9283ae90c5efc43a1cd32c7a", "role": "CLIENT", "short_name": "LX5Q", "snr": -0.34, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 2.131, "battery_level": 23, "channel_utilization": 6.89, "uptime_seconds": 15497, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.31, "iaq": 53, "relative_humidity": 84.42, "temperature": 35.93}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 560, "long_name": "Wild Pine", "next_hop": 100, "num": "0x6e5c8157", "position": {"altitude": 1684, "latitude": 32.320283, "location_source": "LOC_INTERNAL", "longitude": -107.250163, "time_offset_sec": 673}, "public_key_hex": "4d372d81f75ffc63afbe81d17efc5cbd4c06ed6d8a7f0968d23b4f2238c89fdf", "role": "CLIENT", "short_name": "WRX6", "snr": 5.96, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1072, "long_name": "Drifting Mesa", "next_hop": 0, "num": "0x6e5cf635", "position": {"altitude": 1496, "latitude": 34.042101, "location_source": "LOC_INTERNAL", "longitude": -107.005704, "time_offset_sec": 1261}, "public_key_hex": "28298d5f01612f0a4bd06c1601688f0fcc37c2e0bcaf77223dbe693b3bacc2fd", "role": "CLIENT", "short_name": "DEDH", "snr": 4.33, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 243, "long_name": "Dusk Pony", "next_hop": 13, "num": "0x6e7e44cc", "position": {"altitude": 1063, "latitude": 33.591261, "location_source": "LOC_INTERNAL", "longitude": -107.447125, "time_offset_sec": 522}, "public_key_hex": "b66a751227294cbeed984ec967d2ff07c76e9ece97122de83c10a9fbf88fb359", "role": "CLIENT", "short_name": "🐢", "snr": 8.77, "status": null, "telemetry": {"air_util_tx": 0.242, "battery_level": 101, "channel_utilization": 10.6, "uptime_seconds": 58835, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 1164, "long_name": "Forest Adder", "next_hop": 0, "num": "0x6e9cd595", "position": {"altitude": 1563, "latitude": 32.789576, "location_source": "LOC_INTERNAL", "longitude": -107.102295, "time_offset_sec": 1293}, "public_key_hex": "8de9479c49060f21d9c1d4ee679f3b52102aa6c536a8cf252ca6dc8a5622ed90", "role": "CLIENT", "short_name": "FSQM", "snr": 6.66, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.492, "battery_level": 32, "channel_utilization": 2.67, "uptime_seconds": 79283, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 6356, "long_name": "Lone Shark", "next_hop": 0, "num": "0x6ebb258b", "position": {"altitude": 1440, "latitude": 31.735897, "location_source": "LOC_INTERNAL", "longitude": -106.951858, "time_offset_sec": 6636}, "public_key_hex": "af088dbdfd177c23c8dccd87e826814d88b7344a6f82460089a5426015fa65ed", "role": "CLIENT", "short_name": "LY6F", "snr": -0.14, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2118, "long_name": "Fast Seal W52HG", "next_hop": 0, "num": "0x6ec60662", "position": {"altitude": 1814, "latitude": 33.482931, "location_source": "LOC_INTERNAL", "longitude": -107.132128, "time_offset_sec": 2339}, "public_key_hex": "50492b88bd97c2761ccb8bca06e47e65e3f96d66ead1cd6a7d020647bb579fc7", "role": "CLIENT", "short_name": "FPON", "snr": 8.75, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.19, "battery_level": 82, "channel_utilization": 8.28, "uptime_seconds": 173924, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 2206, "long_name": "Rough Juniper", "next_hop": 241, "num": "0x6ed1bd54", "position": {"altitude": 889, "latitude": 32.532383, "location_source": "LOC_INTERNAL", "longitude": -107.157401, "time_offset_sec": 2426}, "public_key_hex": "290e2b5c93e10140b42b76aef6b050b122369e92ed7ffec31f91a28f4ac64544", "role": "CLIENT", "short_name": "🐝", "snr": 11.69, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.41, "iaq": 53, "relative_humidity": 65.51, "temperature": 34.57}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1707, "long_name": "Gold Doe", "next_hop": 0, "num": "0x6ee17b52", "position": {"altitude": 1589, "latitude": 32.495165, "location_source": "LOC_INTERNAL", "longitude": -107.79951, "time_offset_sec": 1776}, "public_key_hex": "6d5e148f911fbfc23b7c01edc49adddcf167a555c4d88bcb90373146dbb266f6", "role": "CLIENT", "short_name": "G7LO", "snr": 2.68, "status": null, "telemetry": {"air_util_tx": 0.827, "battery_level": 36, "channel_utilization": 3.56, "uptime_seconds": 224915, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.76, "iaq": 63, "relative_humidity": 68.2, "temperature": 22.18}, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 3391, "long_name": "Silver Bass", "next_hop": 113, "num": "0x6ee91b6f", "position": {"altitude": 1800, "latitude": 32.919771, "location_source": "LOC_INTERNAL", "longitude": -107.468466, "time_offset_sec": 3436}, "public_key_hex": "52d660e2f25fdfd31d23adee150b04c49a5c16a1bdd648e384636dbe5f17e064", "role": "CLIENT", "short_name": "🦊", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.435, "battery_level": 71, "channel_utilization": 9.37, "uptime_seconds": 9468, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 922, "long_name": "Sunny Bronco", "next_hop": 93, "num": "0x6eeb2b2c", "position": {"altitude": 1191, "latitude": 33.518166, "location_source": "LOC_INTERNAL", "longitude": -107.972567, "time_offset_sec": 1182}, "public_key_hex": "ba67191c0df4cf15309d2ef5d8cc3e039248777886b599a5129755d616c16824", "role": "CLIENT", "short_name": "SC4U", "snr": 7.79, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.558, "battery_level": 65, "channel_utilization": 4.91, "uptime_seconds": 44032, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.73, "iaq": 0, "relative_humidity": 68.6, "temperature": 21.22}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 395, "long_name": "Whispering Mustang", "next_hop": 0, "num": "0x6eec3544", "position": {"altitude": 1281, "latitude": 32.259884, "location_source": "LOC_INTERNAL", "longitude": -107.643223, "time_offset_sec": 658}, "public_key_hex": "9e83d26d90f3dc70ab3f9f236ae9e4e0aa38994f6dd474bfc1d826d4275bc86d", "role": "TRACKER", "short_name": "W320", "snr": 9.8, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.071, "battery_level": 48, "channel_utilization": 8.54, "uptime_seconds": 200505, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 10087, "long_name": "Silent Adder", "next_hop": 188, "num": "0x6eed69a8", "position": null, "public_key_hex": "4f935579d64a7cb06ebbcb68296fc00dc848042b031f03e6ffbaf50b2f173f26", "role": "CLIENT", "short_name": "SGOM", "snr": 7.35, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.728, "battery_level": 35, "channel_utilization": 4.02, "uptime_seconds": 105324, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": {"barometric_pressure": 1000.87, "iaq": 62, "relative_humidity": 60.36, "temperature": 11.38}, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 13919, "long_name": "Canyon Oak", "next_hop": 0, "num": "0x6effdf49", "position": {"altitude": 1619, "latitude": 32.450421, "location_source": "LOC_INTERNAL", "longitude": -107.936962, "time_offset_sec": 14188}, "public_key_hex": "54799cb35030d85cd203d695bb49564e177546c79d238512a056ed754438c77b", "role": "CLIENT", "short_name": "CLD7", "snr": 7.51, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1028.95, "iaq": 129, "relative_humidity": 57.94, "temperature": 20.84}, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2250, "long_name": "Quick Cedar", "next_hop": 68, "num": "0x6f0537b9", "position": null, "public_key_hex": "", "role": "CLIENT", "short_name": "QBCX", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.234, "battery_level": 75, "channel_utilization": 1.83, "uptime_seconds": 102453, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 804, "long_name": "Bright Stag", "next_hop": 0, "num": "0x6f239731", "position": {"altitude": 1284, "latitude": 33.310148, "location_source": "LOC_INTERNAL", "longitude": -107.723015, "time_offset_sec": 1099}, "public_key_hex": "c5f7efacd6a6e022c3a2c82904fcf3d75908b034ff8195f84eb4b2d79f973f73", "role": "CLIENT", "short_name": "BM8R", "snr": 4.75, "status": null, "telemetry": {"air_util_tx": 0.156, "battery_level": 101, "channel_utilization": 10.55, "uptime_seconds": 126449, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 164, "long_name": "Smooth Yucca", "next_hop": 0, "num": "0x6f54d705", "position": {"altitude": 1614, "latitude": 31.934059, "location_source": "LOC_INTERNAL", "longitude": -107.245029, "time_offset_sec": 314}, "public_key_hex": "0e1f655528822244b3e9495133cf9a38745bbfe0935dcbeda53ba5de05f5b8c0", "role": "CLIENT", "short_name": "🌙", "snr": 5.33, "status": null, "telemetry": {"air_util_tx": 1.048, "battery_level": 72, "channel_utilization": 7.36, "uptime_seconds": 23115, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 117, "long_name": "River Tortoise", "next_hop": 29, "num": "0x6f6ad0fc", "position": null, "public_key_hex": "667467fe73c26b167ba02e5a68381a11715297a6e39507b1894030c54d9564b9", "role": "CLIENT", "short_name": "R7ZM", "snr": 9.78, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1003.36, "iaq": 72, "relative_humidity": 42.54, "temperature": 17.44}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3587, "long_name": "Gold Sage", "next_hop": 0, "num": "0x6f728dfb", "position": {"altitude": 1189, "latitude": 33.928265, "location_source": "LOC_INTERNAL", "longitude": -106.946915, "time_offset_sec": 3665}, "public_key_hex": "fdffdf6cc6f796b0105f64b0a38c4e91762bc42bcad768c12aab80b86b639b67", "role": "CLIENT", "short_name": "GSA5", "snr": 11.56, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.742, "battery_level": 40, "channel_utilization": 13.15, "uptime_seconds": 133784, "voltage": 3.66}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1_EINK", "last_heard_offset_sec": 3216, "long_name": "Howling Arroyo WD2UP", "next_hop": 0, "num": "0x6f79e327", "position": null, "public_key_hex": "ebbef24f13a164729bba8a06ba569affa360a1c8285be6408a64383866fa7bf3", "role": "CLIENT_MUTE", "short_name": "HVRK", "snr": 9.78, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2652, "long_name": "Dusk Pony", "next_hop": 148, "num": "0x6f7f45ef", "position": null, "public_key_hex": "1db7d13b92654afb62448082044fc831a3ca0ba7deaab10ef824d8121fa306e5", "role": "CLIENT", "short_name": "DL7G", "snr": 9.58, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2299, "long_name": "Drowsy Pony", "next_hop": 203, "num": "0x6fb35425", "position": {"altitude": 1899, "latitude": 33.398902, "location_source": "LOC_INTERNAL", "longitude": -106.868675, "time_offset_sec": 2477}, "public_key_hex": "55f511e28bbc9509155eb207a2d3525a678d40f7ceb8dc44d8b73962e578b0aa", "role": "CLIENT", "short_name": "DXBW", "snr": 3.2, "status": null, "telemetry": {"air_util_tx": 0.24, "battery_level": 27, "channel_utilization": 4.97, "uptime_seconds": 31853, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 8578, "long_name": "Loud Elk", "next_hop": 150, "num": "0x6fbba067", "position": {"altitude": 933, "latitude": 32.808518, "location_source": "LOC_INTERNAL", "longitude": -106.74286, "time_offset_sec": 8668}, "public_key_hex": "2e572f83de9a5d5e7c44fd9af7e39c4ad5219d5ff545ed983d341f6a1333d08a", "role": "CLIENT", "short_name": "LO42", "snr": 6.2, "status": null, "telemetry": {"air_util_tx": 0.267, "battery_level": 47, "channel_utilization": 27.11, "uptime_seconds": 223322, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 3459, "long_name": "Rough Marmot", "next_hop": 181, "num": "0x6fbe6dc2", "position": {"altitude": 1527, "latitude": 32.277839, "location_source": "LOC_INTERNAL", "longitude": -107.658031, "time_offset_sec": 3592}, "public_key_hex": "2984d22ba459a09640641bc2763d98328ec7253d3fdfa2324df2735ff4979e43", "role": "CLIENT", "short_name": "RHSW", "snr": 10.66, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 1956, "long_name": "Quick Bronco", "next_hop": 0, "num": "0x6fdc9676", "position": null, "public_key_hex": "4a735d4304a040a97466ec05ca2c400e5b12d27c434b601bd2919267c7dc7a0c", "role": "CLIENT", "short_name": "QZCQ", "snr": 4.54, "status": null, "telemetry": {"air_util_tx": 2.115, "battery_level": 18, "channel_utilization": 5.22, "uptime_seconds": 106728, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1026.48, "iaq": 0, "relative_humidity": 11.62, "temperature": 19.11}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 323, "long_name": "Tall Cactus", "next_hop": 0, "num": "0x6fe0ceb2", "position": {"altitude": 1359, "latitude": 33.277629, "location_source": "LOC_INTERNAL", "longitude": -107.323464, "time_offset_sec": 477}, "public_key_hex": "f392a942a604ca2eb41b90385c91903b6e613bbe9c81d479f35b13916b2a15b7", "role": "CLIENT", "short_name": "TF2E", "snr": 6.87, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.329, "battery_level": 57, "channel_utilization": 1.91, "uptime_seconds": 134215, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M1", "last_heard_offset_sec": 2818, "long_name": "Lone Shark", "next_hop": 193, "num": "0x6ff0206c", "position": {"altitude": 1601, "latitude": 33.262547, "location_source": "LOC_INTERNAL", "longitude": -107.131902, "time_offset_sec": 2931}, "public_key_hex": "5af474f32eabf57022bd323cd8051fd9a41bdc21f7f98e92a22ed930c12686ae", "role": "CLIENT", "short_name": "LTJQ", "snr": 1.88, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 1.052, "battery_level": 60, "channel_utilization": 16.15, "uptime_seconds": 10205, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4205, "long_name": "White Otter", "next_hop": 9, "num": "0x700ee5cd", "position": {"altitude": 1582, "latitude": 32.417055, "location_source": "LOC_INTERNAL", "longitude": -106.200973, "time_offset_sec": 4380}, "public_key_hex": "56583fc7984b5984450b1e78db54d329631b08c6c40452c0b11dc9e22a9ba12e", "role": "CLIENT", "short_name": "WRHD", "snr": 6.42, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.239, "battery_level": 10, "channel_utilization": 9.11, "uptime_seconds": 101508, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4011, "long_name": "Drowsy Fox", "next_hop": 27, "num": "0x70201408", "position": {"altitude": 1520, "latitude": 33.079605, "location_source": "LOC_INTERNAL", "longitude": -106.616936, "time_offset_sec": 4076}, "public_key_hex": "07641354125eb82ec9f5082bb2542b4c0377fee1099bf4eb0f9ba76055f9e57d", "role": "CLIENT", "short_name": "DJM6", "snr": 5.37, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.626, "battery_level": 30, "channel_utilization": 5.04, "uptime_seconds": 123614, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 861, "long_name": "Frosty Pine", "next_hop": 163, "num": "0x7024659f", "position": {"altitude": 1360, "latitude": 31.891812, "location_source": "LOC_INTERNAL", "longitude": -107.76027, "time_offset_sec": 1079}, "public_key_hex": "59a7d17fff5660b2d8734b139ed79656de58b42db57dc53aeed3c9d3560fb674", "role": "CLIENT", "short_name": "FNWE", "snr": 10.28, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.324, "battery_level": 33, "channel_utilization": 5.14, "uptime_seconds": 174637, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "M5STACK_C6L", "last_heard_offset_sec": 592, "long_name": "Old Arroyo", "next_hop": 0, "num": "0x70285634", "position": {"altitude": 1415, "latitude": 32.410896, "location_source": "LOC_INTERNAL", "longitude": -107.243054, "time_offset_sec": 746}, "public_key_hex": "a02e4b57b9ac8c85ee653b3a337ebe48386ae30557d3f2bdec056ae1c0fb9294", "role": "CLIENT", "short_name": "OQCZ", "snr": 10.45, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.491, "battery_level": 35, "channel_utilization": 20.35, "uptime_seconds": 25249, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2368, "long_name": "Dawn Pike", "next_hop": 14, "num": "0x7028c692", "position": {"altitude": 959, "latitude": 33.049127, "location_source": "LOC_INTERNAL", "longitude": -106.997852, "time_offset_sec": 2635}, "public_key_hex": "bd71d4c955d67bbc1bada16ffb10c747840f3928f0b1e3e16ffd66a80ce94fb1", "role": "ROUTER", "short_name": "D1KE", "snr": 1.6, "status": null, "telemetry": {"air_util_tx": 0.587, "battery_level": 81, "channel_utilization": 5.61, "uptime_seconds": 172114, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1756, "long_name": "Green Dolphin", "next_hop": 108, "num": "0x702989de", "position": {"altitude": 1654, "latitude": 32.420989, "location_source": "LOC_INTERNAL", "longitude": -108.464325, "time_offset_sec": 1807}, "public_key_hex": "8ac5d5899335ec2ad833d5986771474b37c252c8264dc93923c3b1555e6eff2a", "role": "CLIENT", "short_name": "G82P", "snr": -1.85, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 217, "long_name": "Iron Bear N56WH", "next_hop": 0, "num": "0x70440c46", "position": {"altitude": 932, "latitude": 32.997221, "location_source": "LOC_INTERNAL", "longitude": -106.918716, "time_offset_sec": 312}, "public_key_hex": "a4a1a4699f23949c5c51b2b5efc10329f4a017a2e6eb58a9f9193a54d0611664", "role": "CLIENT", "short_name": "IDVK", "snr": 5.52, "status": null, "telemetry": {"air_util_tx": 1.223, "battery_level": 100, "channel_utilization": 3.12, "uptime_seconds": 156600, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 592, "long_name": "Mountain Hare", "next_hop": 0, "num": "0x705ba807", "position": null, "public_key_hex": "8c432998858c7880daac5db9139955b8cf6a9c29639aaf94d8c53dc32015360c", "role": "CLIENT", "short_name": "MJDV", "snr": 11.14, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.842, "battery_level": 100, "channel_utilization": 14.07, "uptime_seconds": 3489, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3601, "long_name": "Sunny Bluff", "next_hop": 11, "num": "0x7060f144", "position": null, "public_key_hex": "91e956fa7c13049acde8af3d0e6097b494eee3a8d35e2130471347d80c3a1aea", "role": "CLIENT", "short_name": "🦅", "snr": 11.78, "status": null, "telemetry": {"air_util_tx": 0.508, "battery_level": 81, "channel_utilization": 8.62, "uptime_seconds": 1478, "voltage": 4.029}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.88, "iaq": 46, "relative_humidity": 22.03, "temperature": 15.82}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3082, "long_name": "Steel Pike", "next_hop": 0, "num": "0x70662eaa", "position": {"altitude": 1258, "latitude": 32.913958, "location_source": "LOC_INTERNAL", "longitude": -107.615263, "time_offset_sec": 3350}, "public_key_hex": "231f57d3ef48c0fe21cb796bc2d0a3fc7a4ebe5a886e34a6134fa5ac9aa602db", "role": "CLIENT", "short_name": "SLLH", "snr": 6.38, "status": null, "telemetry": {"air_util_tx": 0.526, "battery_level": 77, "channel_utilization": 12.69, "uptime_seconds": 51395, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 289, "long_name": "Sky Lion", "next_hop": 50, "num": "0x7076b137", "position": {"altitude": 1131, "latitude": 32.936339, "location_source": "LOC_INTERNAL", "longitude": -107.930734, "time_offset_sec": 434}, "public_key_hex": "7fc73c2bde17b00c0ca258754cd2b96d011fd282ee2f248b9a1928ba01c1179b", "role": "CLIENT", "short_name": "SHMS", "snr": 7.73, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.679, "battery_level": 58, "channel_utilization": 15.8, "uptime_seconds": 8386, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 23114, "long_name": "Sharp Aspen", "next_hop": 0, "num": "0x707f2d41", "position": {"altitude": 1884, "latitude": 33.469051, "location_source": "LOC_INTERNAL", "longitude": -107.410479, "time_offset_sec": 23205}, "public_key_hex": "", "role": "CLIENT", "short_name": "SUMA", "snr": 7.59, "status": null, "telemetry": {"air_util_tx": 1.195, "battery_level": 27, "channel_utilization": 14.56, "uptime_seconds": 10084, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.02, "iaq": 53, "relative_humidity": 37.47, "temperature": 17.48}, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 9826, "long_name": "Red Seal", "next_hop": 51, "num": "0x708a5a3c", "position": {"altitude": 1895, "latitude": 33.465715, "location_source": "LOC_INTERNAL", "longitude": -108.237323, "time_offset_sec": 9899}, "public_key_hex": "4dce0ba01413980832a6619133cfabbd9da7c1070e2355fabfae92eb7a6e5c39", "role": "CLIENT", "short_name": "ROEH", "snr": 6.26, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1022.83, "iaq": 30, "relative_humidity": 47.69, "temperature": 24.38}, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2945, "long_name": "Sky Eagle", "next_hop": 219, "num": "0x709ad142", "position": {"altitude": 1296, "latitude": 33.208346, "location_source": "LOC_INTERNAL", "longitude": -106.787612, "time_offset_sec": 3054}, "public_key_hex": "e70138ad43829db8d31f369430d01dde8ff4aba1c7478bf0bdefe744a3e90049", "role": "CLIENT", "short_name": "S5WN", "snr": 2.72, "status": null, "telemetry": {"air_util_tx": 1.157, "battery_level": 101, "channel_utilization": 10.36, "uptime_seconds": 59160, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_HT62", "last_heard_offset_sec": 8402, "long_name": "Storm Lion", "next_hop": 0, "num": "0x70ad92c7", "position": {"altitude": 1571, "latitude": 33.08326, "location_source": "LOC_INTERNAL", "longitude": -106.326387, "time_offset_sec": 8619}, "public_key_hex": "41ea266c2778ac64601b93fffec388148ae349591b86b040df67d1388fc1955a", "role": "CLIENT", "short_name": "SNPM", "snr": 4.15, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.249, "battery_level": 56, "channel_utilization": 15.74, "uptime_seconds": 77010, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 209, "long_name": "Iron Bison", "next_hop": 150, "num": "0x70b4eea1", "position": {"altitude": 786, "latitude": 33.38052, "location_source": "LOC_INTERNAL", "longitude": -107.511612, "time_offset_sec": 498}, "public_key_hex": "d9ba6b21e963874b02e184cf88924fd0814f2858129ee9eaa36334470ada6e5e", "role": "CLIENT", "short_name": "IZ6X", "snr": 6.51, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.407, "battery_level": 15, "channel_utilization": 4.36, "uptime_seconds": 48486, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.27, "iaq": 0, "relative_humidity": 75.07, "temperature": 30.78}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 7849, "long_name": "Desert Bass", "next_hop": 0, "num": "0x70ce8895", "position": {"altitude": 1306, "latitude": 33.481628, "location_source": "LOC_INTERNAL", "longitude": -108.174101, "time_offset_sec": 7860}, "public_key_hex": "326f0488048015ba4c1e35c8a70f5e742a4437f619e7434ef036d955a273ca28", "role": "CLIENT", "short_name": "D6BR", "snr": 9.93, "status": null, "telemetry": {"air_util_tx": 1.758, "battery_level": 53, "channel_utilization": 34.04, "uptime_seconds": 10717, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4509, "long_name": "Whispering Aspen", "next_hop": 0, "num": "0x70ce9092", "position": {"altitude": 1458, "latitude": 33.172458, "location_source": "LOC_INTERNAL", "longitude": -107.242946, "time_offset_sec": 4729}, "public_key_hex": "16878d8eec0e0706585a9b2c458e3729ee623af268499d4c935649c61b477a56", "role": "CLIENT", "short_name": "WB0B", "snr": 12.0, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.737, "battery_level": 89, "channel_utilization": 2.88, "uptime_seconds": 51538, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 4762, "long_name": "Gold Coyote", "next_hop": 216, "num": "0x70f60180", "position": {"altitude": 1756, "latitude": 32.867378, "location_source": "LOC_INTERNAL", "longitude": -106.976282, "time_offset_sec": 5017}, "public_key_hex": "91b9bb53568e177cc4dda14d9858a9ca39e436088c53acc279b735fee748db20", "role": "CLIENT", "short_name": "GDAN", "snr": 9.22, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 1.322, "battery_level": 101, "channel_utilization": 5.84, "uptime_seconds": 48832, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 50, "long_name": "Hidden Hare", "next_hop": 174, "num": "0x70fed4ae", "position": {"altitude": 1643, "latitude": 32.588177, "location_source": "LOC_INTERNAL", "longitude": -107.493534, "time_offset_sec": 319}, "public_key_hex": "3fbd6fe31173eb71aa2180f8f0529e2b6f7eac515c60c7b6d4f62cf9d76edd1c", "role": "CLIENT", "short_name": "HHRT", "snr": 4.92, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 2526, "long_name": "Roving Seal", "next_hop": 0, "num": "0x7110e75d", "position": null, "public_key_hex": "79674ca0133fb2b210d9549e35971499085fcfefb73b652deaf9ebda609bee09", "role": "CLIENT", "short_name": "R94Q", "snr": 1.27, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 1906, "long_name": "Old Fox", "next_hop": 0, "num": "0x714413f9", "position": {"altitude": 1538, "latitude": 33.580835, "location_source": "LOC_INTERNAL", "longitude": -108.275139, "time_offset_sec": 2098}, "public_key_hex": "", "role": "CLIENT", "short_name": "OU8Q", "snr": 4.11, "status": null, "telemetry": {"air_util_tx": 0.4, "battery_level": 62, "channel_utilization": 26.36, "uptime_seconds": 17586, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 375, "long_name": "Dawn Seal", "next_hop": 0, "num": "0x716863f8", "position": {"altitude": 856, "latitude": 33.434664, "location_source": "LOC_INTERNAL", "longitude": -107.923954, "time_offset_sec": 434}, "public_key_hex": "91c867936a5c5ec2ea547469b14fd08e911c51b00e3e495348fafa1c11f310f5", "role": "CLIENT", "short_name": "DLOV", "snr": 6.93, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.59, "iaq": 60, "relative_humidity": 61.73, "temperature": 22.05}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 7169, "long_name": "White Sage", "next_hop": 0, "num": "0x717c137e", "position": {"altitude": 1052, "latitude": 33.313743, "location_source": "LOC_INTERNAL", "longitude": -106.93942, "time_offset_sec": 7358}, "public_key_hex": "13dbedb09846d3dc29b2667d4d074ac24e50d738e522924201f5536da8b45199", "role": "CLIENT", "short_name": "WCQC", "snr": 7.03, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": {"barometric_pressure": 1019.9, "iaq": 68, "relative_humidity": 36.01, "temperature": 27.35}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 4531, "long_name": "Iron Crane", "next_hop": 0, "num": "0x717e52cc", "position": {"altitude": 1365, "latitude": 32.497861, "location_source": "LOC_INTERNAL", "longitude": -107.155825, "time_offset_sec": 4822}, "public_key_hex": "739c6c1e553d8d763caf309e41b5dfe7031dd4fa7c537c23be25ff7bb7162e06", "role": "CLIENT", "short_name": "I645", "snr": 10.09, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.393, "battery_level": 70, "channel_utilization": 2.09, "uptime_seconds": 92247, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.09, "iaq": 68, "relative_humidity": 73.54, "temperature": 22.2}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3756, "long_name": "Copper Mole", "next_hop": 0, "num": "0x7188c6a7", "position": {"altitude": 1249, "latitude": 32.852743, "location_source": "LOC_INTERNAL", "longitude": -107.239685, "time_offset_sec": 3961}, "public_key_hex": "4080dbc48fa409ff9cc1cb9e8b00df25d3a2c482d3c2d90ca8f27cffb49046e8", "role": "CLIENT", "short_name": "CVOS", "snr": 10.34, "status": null, "telemetry": {"air_util_tx": 0.152, "battery_level": 50, "channel_utilization": 8.22, "uptime_seconds": 92347, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 18302, "long_name": "Silver Juniper", "next_hop": 97, "num": "0x718954ee", "position": {"altitude": 1015, "latitude": 32.868693, "location_source": "LOC_INTERNAL", "longitude": -107.698118, "time_offset_sec": 18548}, "public_key_hex": "f3a348defd352b39f76b919e4daf8a24ae087b7167a8c1c8d69bfe60be69b067", "role": "CLIENT", "short_name": "S2TJ", "snr": 5.99, "status": null, "telemetry": {"air_util_tx": 1.49, "battery_level": 10, "channel_utilization": 3.69, "uptime_seconds": 87229, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.47, "iaq": 71, "relative_humidity": 38.42, "temperature": 23.58}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2781, "long_name": "Floating Cedar", "next_hop": 222, "num": "0x71a22e96", "position": {"altitude": 1436, "latitude": 32.061088, "location_source": "LOC_INTERNAL", "longitude": -107.31721, "time_offset_sec": 2874}, "public_key_hex": "", "role": "CLIENT", "short_name": "F33Z", "snr": 7.25, "status": {"status": "online"}, "telemetry": {"air_util_tx": 2.167, "battery_level": 101, "channel_utilization": 25.24, "uptime_seconds": 265717, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 3237, "long_name": "Wandering Mole", "next_hop": 0, "num": "0x71a56c9c", "position": {"altitude": 1059, "latitude": 33.300293, "location_source": "LOC_INTERNAL", "longitude": -106.602655, "time_offset_sec": 3334}, "public_key_hex": "020a0faa2a1e9ba9bbfcc75313595e2badd6790a166c06cff9ccd1fdeb26ef6a", "role": "CLIENT", "short_name": "W7MD", "snr": 1.21, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2869, "long_name": "Wandering Mesa", "next_hop": 17, "num": "0x71b6bdea", "position": {"altitude": 1330, "latitude": 33.420808, "location_source": "LOC_INTERNAL", "longitude": -106.650823, "time_offset_sec": 3147}, "public_key_hex": "ef7e0413e27c95087708031817e599517133317c8100fbb83a7001c2684a7f1f", "role": "CLIENT", "short_name": "WIPL", "snr": 8.72, "status": null, "telemetry": {"air_util_tx": 0.324, "battery_level": 72, "channel_utilization": 16.78, "uptime_seconds": 36820, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1003.09, "iaq": 79, "relative_humidity": 36.28, "temperature": 20.36}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 9946, "long_name": "Bright Mustang", "next_hop": 0, "num": "0x71c961e6", "position": {"altitude": 1397, "latitude": 34.109923, "location_source": "LOC_INTERNAL", "longitude": -107.86076, "time_offset_sec": 9982}, "public_key_hex": "d1ced1c4180dec0a73c0024c4e359d267c69bced0da0ae5e113a3ba93c36a8ab", "role": "CLIENT", "short_name": "B8MI", "snr": 5.3, "status": null, "telemetry": {"air_util_tx": 1.033, "battery_level": 30, "channel_utilization": 4.22, "uptime_seconds": 84296, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6353, "long_name": "Frosty Ridge", "next_hop": 35, "num": "0x720e656d", "position": {"altitude": 1034, "latitude": 33.165045, "location_source": "LOC_INTERNAL", "longitude": -106.994521, "time_offset_sec": 6573}, "public_key_hex": "da20dfe61bafe4cb5003cb1fc42d1cfdb3d4e7986da46e9653beec340fcf29a0", "role": "CLIENT", "short_name": "FU19", "snr": 9.83, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.59, "battery_level": 44, "channel_utilization": 10.18, "uptime_seconds": 23972, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 1433, "long_name": "Brave Yucca", "next_hop": 85, "num": "0x721e6b6b", "position": {"altitude": 1265, "latitude": 33.069368, "location_source": "LOC_INTERNAL", "longitude": -106.771769, "time_offset_sec": 1660}, "public_key_hex": "78c4d09c607292a222107a3e411a00a4d50946590508fda83e00cbcbefc19407", "role": "CLIENT", "short_name": "BHLI", "snr": 6.64, "status": null, "telemetry": {"air_util_tx": 0.714, "battery_level": 82, "channel_utilization": 9.46, "uptime_seconds": 132389, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.87, "iaq": 72, "relative_humidity": 26.6, "temperature": 20.72}, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 2418, "long_name": "Short Fox", "next_hop": 0, "num": "0x723e8d50", "position": {"altitude": 1331, "latitude": 34.776842, "location_source": "LOC_INTERNAL", "longitude": -107.353976, "time_offset_sec": 2418}, "public_key_hex": "f7ea1e4ed4bed025289a60161ebbfb3060f3995c5a7765011fc183528c4a4667", "role": "CLIENT", "short_name": "SGI9", "snr": -1.41, "status": null, "telemetry": {"air_util_tx": 0.435, "battery_level": 43, "channel_utilization": 9.05, "uptime_seconds": 142891, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5409, "long_name": "River Yucca", "next_hop": 0, "num": "0x7243c9eb", "position": null, "public_key_hex": "81f0cf174c4eae771ac9ae90c9960e07cbc8a24e34aedaba7933d32bee67fcbd", "role": "ROUTER", "short_name": "RKMN", "snr": 3.75, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.064, "battery_level": 101, "channel_utilization": 3.91, "uptime_seconds": 20181, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 172, "long_name": "Lost Arroyo KQ3PA", "next_hop": 50, "num": "0x72580614", "position": {"altitude": 1456, "latitude": 32.765638, "location_source": "LOC_INTERNAL", "longitude": -107.094562, "time_offset_sec": 346}, "public_key_hex": "", "role": "CLIENT", "short_name": "LADV", "snr": 3.66, "status": null, "telemetry": {"air_util_tx": 0.851, "battery_level": 33, "channel_utilization": 11.94, "uptime_seconds": 43416, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 698, "long_name": "Floating Lynx", "next_hop": 31, "num": "0x726ae925", "position": {"altitude": 1478, "latitude": 32.718892, "location_source": "LOC_INTERNAL", "longitude": -106.874732, "time_offset_sec": 955}, "public_key_hex": "9e0e6d93a6546bc86d8a8920d9b958403e77da66ee47446023975875bbad0a57", "role": "CLIENT", "short_name": "F1PD", "snr": 6.19, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.714, "battery_level": 58, "channel_utilization": 7.74, "uptime_seconds": 87908, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1027.01, "iaq": 2, "relative_humidity": 7.97, "temperature": 24.19}, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 2305, "long_name": "New Falcon", "next_hop": 74, "num": "0x72773729", "position": {"altitude": 1135, "latitude": 32.832307, "location_source": "LOC_INTERNAL", "longitude": -106.440065, "time_offset_sec": 2523}, "public_key_hex": "53b43fb18a88729b6a4c495f9c2bb56e907fe2471bc0910f1768cd72958b2f04", "role": "CLIENT", "short_name": "NEOF", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.331, "battery_level": 50, "channel_utilization": 14.26, "uptime_seconds": 157118, "voltage": 3.75}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 6, "hw_model": "WISMESH_TAG", "last_heard_offset_sec": 1331, "long_name": "Steel Seal", "next_hop": 83, "num": "0x727a7fcd", "position": {"altitude": 1420, "latitude": 32.89511, "location_source": "LOC_INTERNAL", "longitude": -107.015341, "time_offset_sec": 1398}, "public_key_hex": "dd8caf559a5b194a1425eed4c2ec363d43565413afdcc47b480aea6dc1fc0835", "role": "CLIENT", "short_name": "SUZA", "snr": 1.53, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 13137, "long_name": "Rough Aspen", "next_hop": 0, "num": "0x7292a03d", "position": {"altitude": 1553, "latitude": 33.23324, "location_source": "LOC_INTERNAL", "longitude": -106.614386, "time_offset_sec": 13353}, "public_key_hex": "a24efc443e41c3ddb6d8bb0cd3897d9a1d01c7c2256f8a3863c1f6f90c1cf244", "role": "CLIENT", "short_name": "RYGX", "snr": 12.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 14350, "long_name": "Desert Mesa", "next_hop": 203, "num": "0x72941909", "position": {"altitude": 1239, "latitude": 32.483742, "location_source": "LOC_INTERNAL", "longitude": -107.650391, "time_offset_sec": 14446}, "public_key_hex": "f7b245a4e17a1c4a874068760b4b8886525eb65285f4c28278c493cc79dbfacb", "role": "CLIENT", "short_name": "D8NC", "snr": 0.28, "status": null, "telemetry": {"air_util_tx": 0.885, "battery_level": 43, "channel_utilization": 11.01, "uptime_seconds": 38598, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 548, "long_name": "Wandering Cedar", "next_hop": 34, "num": "0x729f7b93", "position": {"altitude": 1323, "latitude": 33.206333, "location_source": "LOC_INTERNAL", "longitude": -106.92693, "time_offset_sec": 771}, "public_key_hex": "c0969f5a1181d32f38d6be0a1c91203e7315f96f5250ee3131d9823b426fcf62", "role": "CLIENT", "short_name": "WOUN", "snr": 8.52, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 2092, "long_name": "Sharp Cougar", "next_hop": 0, "num": "0x72bed265", "position": {"altitude": 1944, "latitude": 33.630481, "location_source": "LOC_INTERNAL", "longitude": -107.401352, "time_offset_sec": 2170}, "public_key_hex": "be4a4d2907751fce4116261b8ed98ef8d442ee60c757a24c9b10ba1284f1d7e5", "role": "CLIENT", "short_name": "S8MO", "snr": -1.62, "status": null, "telemetry": {"air_util_tx": 0.626, "battery_level": 93, "channel_utilization": 7.06, "uptime_seconds": 72080, "voltage": 4.137}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 99, "long_name": "Slow Fox", "next_hop": 107, "num": "0x72c894f0", "position": {"altitude": 869, "latitude": 32.473487, "location_source": "LOC_INTERNAL", "longitude": -108.252932, "time_offset_sec": 206}, "public_key_hex": "be82d2dc76fa133d397440e18c811240e0ecdffbe5075fcaa0eddab6e8bcae6a", "role": "CLIENT", "short_name": "SY4P", "snr": 4.52, "status": null, "telemetry": {"air_util_tx": 1.604, "battery_level": 62, "channel_utilization": 19.75, "uptime_seconds": 2344, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 215, "long_name": "Green Squirrel", "next_hop": 0, "num": "0x72ca6970", "position": {"altitude": 1112, "latitude": 33.008728, "location_source": "LOC_INTERNAL", "longitude": -106.943675, "time_offset_sec": 340}, "public_key_hex": "c75f06e1108282a01c752597b9a05413560043413437571ed63e4c04bf1e0c1e", "role": "ROUTER", "short_name": "GIF6", "snr": 4.78, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.609, "battery_level": 18, "channel_utilization": 11.07, "uptime_seconds": 13351, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 604, "long_name": "Hidden Mamba", "next_hop": 212, "num": "0x72d618a5", "position": {"altitude": 1295, "latitude": 33.33802, "location_source": "LOC_INTERNAL", "longitude": -107.292967, "time_offset_sec": 719}, "public_key_hex": "f28c348d2f5804bff5764f9102abfd7c4d2b6b553fb1ae1cb09fc82fff3c5d08", "role": "CLIENT", "short_name": "H60U", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 1.24, "battery_level": 75, "channel_utilization": 5.81, "uptime_seconds": 78505, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SENSECAP_INDICATOR", "last_heard_offset_sec": 1741, "long_name": "Found Pike", "next_hop": 0, "num": "0x72dc3903", "position": {"altitude": 1749, "latitude": 33.973544, "location_source": "LOC_INTERNAL", "longitude": -107.611128, "time_offset_sec": 1847}, "public_key_hex": "221f65d8369c4614bc27afb98fc68a27bc05b0e6b1b59cd32ebb06ddbed0b76d", "role": "CLIENT", "short_name": "FUHR", "snr": 1.95, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 37, "long_name": "Lost Crane", "next_hop": 0, "num": "0x72dea0ee", "position": {"altitude": 725, "latitude": 32.397807, "location_source": "LOC_INTERNAL", "longitude": -107.690646, "time_offset_sec": 107}, "public_key_hex": "", "role": "CLIENT", "short_name": "L73K", "snr": 4.98, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.014, "battery_level": 10, "channel_utilization": 10.15, "uptime_seconds": 28438, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3349, "long_name": "Misty Falcon", "next_hop": 0, "num": "0x73001a72", "position": null, "public_key_hex": "8c91f0143b9d136f7262ba31250e2eb4c1e0ab76416d5ad93d008d4459931a55", "role": "CLIENT", "short_name": "MV39", "snr": 2.34, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 1688, "long_name": "Slow Whale", "next_hop": 175, "num": "0x730a82ca", "position": {"altitude": 1259, "latitude": 33.047893, "location_source": "LOC_INTERNAL", "longitude": -106.860852, "time_offset_sec": 1802}, "public_key_hex": "d994225fbe9c33b84f6b77e71b5cdea75dfa753066a6ac8ccb4e73c9b432dd87", "role": "CLIENT", "short_name": "SOEI", "snr": 6.9, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.018, "battery_level": 94, "channel_utilization": 12.48, "uptime_seconds": 213506, "voltage": 4.146}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4305, "long_name": "Tiny Cedar", "next_hop": 0, "num": "0x730d09d2", "position": {"altitude": 1643, "latitude": 33.606029, "location_source": "LOC_INTERNAL", "longitude": -106.530948, "time_offset_sec": 4365}, "public_key_hex": "a9add2f8e10a3a867ba04cebfca8ecdf78c4e5e1fb660966fc483efffbbffc76", "role": "CLIENT", "short_name": "TCYV", "snr": 5.04, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.309, "battery_level": 101, "channel_utilization": 19.59, "uptime_seconds": 26147, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2026, "long_name": "Floating Dolphin", "next_hop": 0, "num": "0x731ccd47", "position": {"altitude": 814, "latitude": 33.30094, "location_source": "LOC_INTERNAL", "longitude": -107.136375, "time_offset_sec": 2233}, "public_key_hex": "7c27942e43e276b65d88c3436225fbd0dae0a5d73658688d55277b48495b2e29", "role": "SENSOR", "short_name": "FUDP", "snr": 6.54, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6813, "long_name": "Red Cedar", "next_hop": 0, "num": "0x731e0e23", "position": {"altitude": 1479, "latitude": 34.338301, "location_source": "LOC_INTERNAL", "longitude": -107.018017, "time_offset_sec": 7046}, "public_key_hex": "c02d76c92af629c7e22dc5255b4ab3514e20bd70885ef159275bc5e796877d49", "role": "CLIENT", "short_name": "RNR0", "snr": 5.87, "status": null, "telemetry": {"air_util_tx": 1.329, "battery_level": 55, "channel_utilization": 15.21, "uptime_seconds": 48570, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 2413, "long_name": "Misty Hare", "next_hop": 106, "num": "0x733a2e17", "position": null, "public_key_hex": "966e1d3953578e3a213e6be0a6785aebb8f368d558606bdcf42ab2d9a51228a3", "role": "CLIENT", "short_name": "M172", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.793, "battery_level": 82, "channel_utilization": 7.42, "uptime_seconds": 81240, "voltage": 4.038}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1001.66, "iaq": 0, "relative_humidity": 25.95, "temperature": 19.23}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2613, "long_name": "Gold Cactus", "next_hop": 0, "num": "0x733e9dea", "position": {"altitude": 1114, "latitude": 33.392049, "location_source": "LOC_INTERNAL", "longitude": -107.763535, "time_offset_sec": 2685}, "public_key_hex": "b81d25e1ba5778c5941eec914ac6782d2f782b84782a2d68f28008e81883ecfd", "role": "CLIENT", "short_name": "GAM9", "snr": 8.63, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 14009, "long_name": "Silent Whale", "next_hop": 1, "num": "0x733f5202", "position": {"altitude": 1593, "latitude": 33.555923, "location_source": "LOC_INTERNAL", "longitude": -106.970296, "time_offset_sec": 14218}, "public_key_hex": "0571eb6630e0de3d9c1d74bfba40c69d5437294389d7e69d7ba063de185f3915", "role": "CLIENT_MUTE", "short_name": "S1GT", "snr": 9.62, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.526, "battery_level": 26, "channel_utilization": 9.41, "uptime_seconds": 296691, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1024.17, "iaq": 65, "relative_humidity": 71.13, "temperature": 18.37}, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 6099, "long_name": "Old Mamba", "next_hop": 0, "num": "0x7342634c", "position": null, "public_key_hex": "fda83cf4b6febee3632840ccc0bd5b3801dd283587bb976b46ca3672d628e3ab", "role": "CLIENT", "short_name": "🐝", "snr": 3.86, "status": null, "telemetry": {"air_util_tx": 0.244, "battery_level": 86, "channel_utilization": 5.69, "uptime_seconds": 43211, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 6249, "long_name": "Silver Cobra", "next_hop": 0, "num": "0x735eb26c", "position": {"altitude": 1515, "latitude": 33.703298, "location_source": "LOC_INTERNAL", "longitude": -107.33658, "time_offset_sec": 6372}, "public_key_hex": "cc308e8c7b432af0cab6121bed4a8f963f91dcabd451083a867fc338e9e258aa", "role": "CLIENT_HIDDEN", "short_name": "SIWP", "snr": 8.57, "status": null, "telemetry": {"air_util_tx": 0.362, "battery_level": 69, "channel_utilization": 6.18, "uptime_seconds": 50937, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 11258, "long_name": "River Moose", "next_hop": 0, "num": "0x7368a8cd", "position": null, "public_key_hex": "8cb0344580884fe5d70a23003aab5f32fcfe2e5d5f6901e4b7d3583c658c2df0", "role": "ROUTER", "short_name": "R1X8", "snr": 5.12, "status": null, "telemetry": {"air_util_tx": 1.026, "battery_level": 51, "channel_utilization": 5.47, "uptime_seconds": 735, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 12402, "long_name": "Fast Cactus", "next_hop": 114, "num": "0x7374f776", "position": {"altitude": 1386, "latitude": 33.51329, "location_source": "LOC_INTERNAL", "longitude": -106.729898, "time_offset_sec": 12519}, "public_key_hex": "e47e6319dc08eec962660406dc253e447e629370d7a9d3a7cc9640fe59af8746", "role": "CLIENT", "short_name": "FLQ2", "snr": 6.64, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.221, "battery_level": 24, "channel_utilization": 3.96, "uptime_seconds": 42686, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK", "last_heard_offset_sec": 919, "long_name": "Sharp Bluff", "next_hop": 95, "num": "0x7379c7e6", "position": {"altitude": 762, "latitude": 33.258545, "location_source": "LOC_INTERNAL", "longitude": -107.036484, "time_offset_sec": 1177}, "public_key_hex": "6091a3cc3e00e4b300fb8fe05e19a4d796ac57a8b0f93cd64a44589ee7e551b7", "role": "CLIENT", "short_name": "SABX", "snr": 11.5, "status": null, "telemetry": {"air_util_tx": 0.695, "battery_level": 78, "channel_utilization": 15.27, "uptime_seconds": 183496, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 4735, "long_name": "Misty Pony", "next_hop": 0, "num": "0x737b813e", "position": {"altitude": 1705, "latitude": 33.836651, "location_source": "LOC_INTERNAL", "longitude": -107.139096, "time_offset_sec": 4803}, "public_key_hex": "8c1b368f7e70cbe986d73b780e58c01714f3aad5d66acbe22124d79e90ae869d", "role": "CLIENT", "short_name": "M39K", "snr": 0.75, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.398, "battery_level": 49, "channel_utilization": 4.51, "uptime_seconds": 32866, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": true, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 4496, "long_name": "Found Bison", "next_hop": 131, "num": "0x737eee11", "position": null, "public_key_hex": "00faac89331a330132aaa57c46e94aa57715dceb512e25a3c0a1ef0587749259", "role": "CLIENT_MUTE", "short_name": "FF3N", "snr": 5.78, "status": null, "telemetry": {"air_util_tx": 0.372, "battery_level": 101, "channel_utilization": 13.64, "uptime_seconds": 186451, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 7, "hw_model": "RAK4631", "last_heard_offset_sec": 915, "long_name": "Black Lynx", "next_hop": 207, "num": "0x73809661", "position": {"altitude": 1540, "latitude": 33.79181, "location_source": "LOC_INTERNAL", "longitude": -107.292287, "time_offset_sec": 947}, "public_key_hex": "e2bd57b4e3dc2844199a190c1ccddddc78d4abac12d351f58a745f52326ddad2", "role": "CLIENT", "short_name": "BN37", "snr": 8.29, "status": {"status": "active"}, "telemetry": {"air_util_tx": 2.39, "battery_level": 47, "channel_utilization": 6.74, "uptime_seconds": 149481, "voltage": 3.723}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1028, "long_name": "Soft Dolphin", "next_hop": 0, "num": "0x73a38bdc", "position": {"altitude": 1292, "latitude": 32.859471, "location_source": "LOC_INTERNAL", "longitude": -107.240554, "time_offset_sec": 1078}, "public_key_hex": "13fd223be4d101876fc3a780e1bdacd278d76b6ffcfe843d8e9f8b91323f0592", "role": "TAK_TRACKER", "short_name": "SK1P", "snr": 5.32, "status": null, "telemetry": {"air_util_tx": 0.589, "battery_level": 78, "channel_utilization": 14.35, "uptime_seconds": 95073, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.0, "iaq": 61, "relative_humidity": 26.9, "temperature": 7.54}, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 9443, "long_name": "Brave Juniper", "next_hop": 0, "num": "0x73be1718", "position": {"altitude": 1521, "latitude": 33.387464, "location_source": "LOC_INTERNAL", "longitude": -107.689551, "time_offset_sec": 9708}, "public_key_hex": "263f7410c181793c1e0a487add0accdd0abe6295abd33841ef3f9bad027af952", "role": "CLIENT", "short_name": "BYFE", "snr": 4.79, "status": null, "telemetry": {"air_util_tx": 0.31, "battery_level": 100, "channel_utilization": 6.25, "uptime_seconds": 2198, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 5439, "long_name": "Storm Beaver", "next_hop": 58, "num": "0x73c20ca4", "position": {"altitude": 1141, "latitude": 34.085887, "location_source": "LOC_INTERNAL", "longitude": -107.466778, "time_offset_sec": 5714}, "public_key_hex": "0880c737b7b19c186a140524c649cb22637c8cdd0b64e3357995be51d8c32b2c", "role": "CLIENT", "short_name": "SGQ1", "snr": 6.12, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 553, "long_name": "Steel Bluff", "next_hop": 36, "num": "0x73d78e95", "position": {"altitude": 1167, "latitude": 32.413367, "location_source": "LOC_INTERNAL", "longitude": -106.476942, "time_offset_sec": 674}, "public_key_hex": "2458c651c249245c2fc428618f9b9ed4cdf96a44b43b35b9438525ec825195ba", "role": "CLIENT", "short_name": "SWUT", "snr": 1.44, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.643, "battery_level": 58, "channel_utilization": 3.63, "uptime_seconds": 16252, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.28, "iaq": 47, "relative_humidity": 82.91, "temperature": 14.26}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1893, "long_name": "New Wolf", "next_hop": 0, "num": "0x73dec5b1", "position": {"altitude": 1214, "latitude": 32.880966, "location_source": "LOC_INTERNAL", "longitude": -107.182592, "time_offset_sec": 1897}, "public_key_hex": "c952ae52e478dc658b58e7b3c7e58cf0e30ec371808939a9202c4e28e2860e2a", "role": "CLIENT", "short_name": "N087", "snr": 4.56, "status": null, "telemetry": {"air_util_tx": 0.211, "battery_level": 88, "channel_utilization": 12.21, "uptime_seconds": 209892, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1015.28, "iaq": 95, "relative_humidity": 29.29, "temperature": 3.82}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 700, "long_name": "Soft Cactus", "next_hop": 0, "num": "0x73df33fc", "position": {"altitude": 1129, "latitude": 33.792679, "location_source": "LOC_INTERNAL", "longitude": -107.63228, "time_offset_sec": 827}, "public_key_hex": "cccaa2fb62f49e80a5d4b168a8fc1f5ee158722443c395dfcce1aaa820bf8db4", "role": "CLIENT", "short_name": "S7SL", "snr": 7.97, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 9330, "long_name": "Happy Gecko", "next_hop": 105, "num": "0x73e46f2c", "position": null, "public_key_hex": "ead1933631fed14a07c53424550413e2c0da4849c9dba4bf3d71e4df1c0b7510", "role": "CLIENT", "short_name": "HRQC", "snr": 11.97, "status": null, "telemetry": {"air_util_tx": 0.976, "battery_level": 68, "channel_utilization": 7.54, "uptime_seconds": 50965, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 454, "long_name": "Sky Falcon", "next_hop": 0, "num": "0x73ee3d1c", "position": {"altitude": 1520, "latitude": 32.121431, "location_source": "LOC_INTERNAL", "longitude": -107.504274, "time_offset_sec": 671}, "public_key_hex": "8375eb9b2678092444f740d66cc9a50742f06df0b217c5b0351719e11cc8a9e7", "role": "ROUTER_LATE", "short_name": "SJ8Y", "snr": 1.94, "status": null, "telemetry": {"air_util_tx": 1.833, "battery_level": 38, "channel_utilization": 7.22, "uptime_seconds": 238637, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 15337, "long_name": "Fast Marmot", "next_hop": 167, "num": "0x73ef2829", "position": null, "public_key_hex": "8798c8a5f20c8e10fe82b99fa706210185d6f75c91822f6182ba9da39f2c7e2a", "role": "CLIENT", "short_name": "FR59", "snr": 8.26, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.903, "battery_level": 57, "channel_utilization": 0.73, "uptime_seconds": 87018, "voltage": 3.813}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4055, "long_name": "Storm Trout", "next_hop": 221, "num": "0x7415a117", "position": {"altitude": 1587, "latitude": 32.462993, "location_source": "LOC_INTERNAL", "longitude": -107.538344, "time_offset_sec": 4172}, "public_key_hex": "", "role": "TRACKER", "short_name": "SL7T", "snr": 9.83, "status": null, "telemetry": {"air_util_tx": 0.627, "battery_level": 18, "channel_utilization": 8.96, "uptime_seconds": 5891, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 8803, "long_name": "Sneaky Pony", "next_hop": 0, "num": "0x741b8e97", "position": {"altitude": 1798, "latitude": 33.717928, "location_source": "LOC_INTERNAL", "longitude": -107.137352, "time_offset_sec": 8995}, "public_key_hex": "e737437c9b2b8918db3aed1617121c11dd38c69697824b54e588f1a8ad1ac43e", "role": "CLIENT", "short_name": "SRJ2", "snr": 7.48, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.374, "battery_level": 45, "channel_utilization": 17.54, "uptime_seconds": 2294, "voltage": 3.705}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 4, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 756, "long_name": "Desert Marmot KQ4CT", "next_hop": 90, "num": "0x744d1bca", "position": {"altitude": 1047, "latitude": 32.51306, "location_source": "LOC_INTERNAL", "longitude": -107.261658, "time_offset_sec": 782}, "public_key_hex": "146dfb8328d6fc2c89d41a69e9c77566507384b56dfb79bf4e77bef5b2e9c9dc", "role": "CLIENT", "short_name": "DCSX", "snr": 2.94, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 1.699, "battery_level": 38, "channel_utilization": 2.31, "uptime_seconds": 120124, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1012.86, "iaq": 61, "relative_humidity": 49.8, "temperature": 28.77}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2854, "long_name": "Canyon Arroyo N52DS", "next_hop": 175, "num": "0x7463ccbf", "position": {"altitude": 1209, "latitude": 32.825917, "location_source": "LOC_INTERNAL", "longitude": -107.600842, "time_offset_sec": 3004}, "public_key_hex": "e336684891ade22d604463aabeb24fcc9f1cbc48b96c705348afdf448a47670f", "role": "CLIENT_BASE", "short_name": "CALE", "snr": 1.47, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2928, "long_name": "New Owl", "next_hop": 226, "num": "0x746ff7c6", "position": null, "public_key_hex": "90cb4df304148f8066ce8e79a1d67d55a45e2307edb1adb207bf4d596578ffd8", "role": "CLIENT", "short_name": "NVOC", "snr": 6.36, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4360, "long_name": "Frosty Crane", "next_hop": 26, "num": "0x74788f7d", "position": {"altitude": 1651, "latitude": 33.886799, "location_source": "LOC_INTERNAL", "longitude": -106.708271, "time_offset_sec": 4563}, "public_key_hex": "57e89ef68887d77382448dfb85977357f1bf2cc5b641f58f79e9006164f99ca5", "role": "CLIENT", "short_name": "F58V", "snr": 5.45, "status": null, "telemetry": {"air_util_tx": 0.335, "battery_level": 67, "channel_utilization": 29.4, "uptime_seconds": 364631, "voltage": 3.903}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1020.44, "iaq": 66, "relative_humidity": 76.24, "temperature": 8.25}, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 413, "long_name": "Tiny Tortoise", "next_hop": 174, "num": "0x747d02e7", "position": {"altitude": 1890, "latitude": 33.614927, "location_source": "LOC_INTERNAL", "longitude": -107.326791, "time_offset_sec": 638}, "public_key_hex": "93134098376f1387d495b20e4a30ca1968635d200bd2b7df285f4e33d0ebee41", "role": "CLIENT", "short_name": "TFO9", "snr": 4.2, "status": null, "telemetry": {"air_util_tx": 1.652, "battery_level": 56, "channel_utilization": 18.26, "uptime_seconds": 246, "voltage": 3.804}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2555, "long_name": "Black Heron", "next_hop": 97, "num": "0x74a5ffd9", "position": {"altitude": 756, "latitude": 32.953377, "location_source": "LOC_INTERNAL", "longitude": -107.518498, "time_offset_sec": 2808}, "public_key_hex": "8a16e0bff5732366b0cfdb21a1c5de05a5456fcc8e80883f4b887ddad813a62f", "role": "CLIENT", "short_name": "B3EF", "snr": 7.91, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 1.297, "battery_level": 27, "channel_utilization": 13.84, "uptime_seconds": 139968, "voltage": 3.543}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8215, "long_name": "Red Cactus", "next_hop": 111, "num": "0x74b43cc7", "position": {"altitude": 1481, "latitude": 32.769916, "location_source": "LOC_INTERNAL", "longitude": -107.88027, "time_offset_sec": 8343}, "public_key_hex": "cd66492199391b6905f0c194869ea46f3ac4eaabe323f4512b0ef4f3e3b3c1f3", "role": "CLIENT", "short_name": "🌊", "snr": 9.25, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 3888, "long_name": "Tall Cougar", "next_hop": 84, "num": "0x74c02b8f", "position": {"altitude": 1293, "latitude": 33.317205, "location_source": "LOC_INTERNAL", "longitude": -107.018806, "time_offset_sec": 3983}, "public_key_hex": "93a6e247bd39342d6f42659a223cc791c2f48cb1c5c0e8e7774ed5c304c09d1f", "role": "CLIENT", "short_name": "TDPJ", "snr": 10.16, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.76, "iaq": 65, "relative_humidity": 69.02, "temperature": 21.25}, "hops_away": 4, "hw_model": "T_DECK", "last_heard_offset_sec": 3988, "long_name": "Hidden Viper", "next_hop": 197, "num": "0x74f125c5", "position": {"altitude": 1438, "latitude": 32.983584, "location_source": "LOC_INTERNAL", "longitude": -106.315318, "time_offset_sec": 4271}, "public_key_hex": "84e1b5439fa2eb751758247aa4028736a503f118b7d666de46b410226d40bbf9", "role": "LOST_AND_FOUND", "short_name": "H9B7", "snr": 4.28, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1007.3, "iaq": 32, "relative_humidity": 30.91, "temperature": 12.67}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1875, "long_name": "Roving Bluff", "next_hop": 0, "num": "0x74f270b1", "position": {"altitude": 1581, "latitude": 32.631459, "location_source": "LOC_INTERNAL", "longitude": -107.466609, "time_offset_sec": 2015}, "public_key_hex": "", "role": "CLIENT", "short_name": "R2SX", "snr": 0.24, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 8895, "long_name": "Found Yucca", "next_hop": 249, "num": "0x752a8d85", "position": {"altitude": 1169, "latitude": 33.084479, "location_source": "LOC_INTERNAL", "longitude": -107.488419, "time_offset_sec": 8927}, "public_key_hex": "7b8ba913f7ba34d1e20b82dac4eb4f7671d0da96e33ed0b837b6cf0903ec55cb", "role": "CLIENT_MUTE", "short_name": "FGMC", "snr": 7.03, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.885, "battery_level": 74, "channel_utilization": 14.28, "uptime_seconds": 167294, "voltage": 3.966}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1114, "long_name": "Whispering Seal", "next_hop": 0, "num": "0x7538ec9d", "position": {"altitude": 1110, "latitude": 33.781991, "location_source": "LOC_INTERNAL", "longitude": -107.63881, "time_offset_sec": 1238}, "public_key_hex": "299ec4cec7c28e6c930dfa07cefe078db6810e0e65243e0ba5e5ca6d7ed7b17b", "role": "CLIENT", "short_name": "WUHZ", "snr": 10.87, "status": null, "telemetry": {"air_util_tx": 0.669, "battery_level": 48, "channel_utilization": 5.31, "uptime_seconds": 7947, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1021.6, "iaq": 51, "relative_humidity": 35.68, "temperature": 21.15}, "hops_away": 1, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 1348, "long_name": "Quick Sage", "next_hop": 92, "num": "0x753e9197", "position": {"altitude": 1200, "latitude": 34.198494, "location_source": "LOC_INTERNAL", "longitude": -106.681023, "time_offset_sec": 1646}, "public_key_hex": "03928981efd1dc23c769a13e9f1a47126b2cf7f04a511df7e3548e65f4474afe", "role": "CLIENT", "short_name": "Q6RU", "snr": 2.99, "status": {"status": "rebooted"}, "telemetry": {"air_util_tx": 0.656, "battery_level": 79, "channel_utilization": 15.54, "uptime_seconds": 47309, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.85, "iaq": 60, "relative_humidity": 32.55, "temperature": 12.45}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 286, "long_name": "Smooth Dolphin", "next_hop": 182, "num": "0x756d2ba1", "position": {"altitude": 1719, "latitude": 33.210556, "location_source": "LOC_INTERNAL", "longitude": -106.776998, "time_offset_sec": 583}, "public_key_hex": "8197539f412bae0f453980a210606c773691a860a77f97a8b973ff8724fc6232", "role": "CLIENT", "short_name": "SLOO", "snr": 8.74, "status": null, "telemetry": {"air_util_tx": 0.429, "battery_level": 73, "channel_utilization": 3.65, "uptime_seconds": 42246, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 7850, "long_name": "Burning Aspen", "next_hop": 248, "num": "0x7575c58a", "position": {"altitude": 1199, "latitude": 32.861597, "location_source": "LOC_INTERNAL", "longitude": -107.577209, "time_offset_sec": 8149}, "public_key_hex": "2233e5fb5670120f5df9ed5ddb5270a7a288884653a5b99a11f8680cd7292665", "role": "CLIENT", "short_name": "BAS4", "snr": 5.83, "status": null, "telemetry": {"air_util_tx": 0.309, "battery_level": 29, "channel_utilization": 8.61, "uptime_seconds": 170741, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.04, "iaq": 36, "relative_humidity": 43.76, "temperature": 18.63}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 5109, "long_name": "Fast Pike", "next_hop": 218, "num": "0x75796c0e", "position": {"altitude": 1806, "latitude": 32.780206, "location_source": "LOC_INTERNAL", "longitude": -108.49019, "time_offset_sec": 5213}, "public_key_hex": "cbe6ef06e945bd58f9b8bb1c1c6e4bb372b207250205820195713ade0b14b736", "role": "CLIENT", "short_name": "F6Z6", "snr": 7.83, "status": null, "telemetry": {"air_util_tx": 0.396, "battery_level": 49, "channel_utilization": 23.11, "uptime_seconds": 116148, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2008, "long_name": "Solar Tortoise", "next_hop": 194, "num": "0x757adc01", "position": null, "public_key_hex": "67085afdac0a3e5252063fd6cd5280d35b50b5edad0cbc5a97eaf68b923f2bbe", "role": "CLIENT", "short_name": "SAIH", "snr": 5.06, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.252, "battery_level": 34, "channel_utilization": 8.54, "uptime_seconds": 60276, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1023.25, "iaq": 83, "relative_humidity": 35.79, "temperature": 22.87}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 2438, "long_name": "Happy Mole", "next_hop": 0, "num": "0x75815fae", "position": null, "public_key_hex": "877657455015fce4e4a35f0b2d55046ccc8cdd8584ec3b6f187513616721e930", "role": "ROUTER", "short_name": "HS0Q", "snr": 5.88, "status": null, "telemetry": {"air_util_tx": 0.449, "battery_level": 23, "channel_utilization": 4.52, "uptime_seconds": 91378, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 303, "long_name": "Steel Mustang", "next_hop": 0, "num": "0x758d3fb9", "position": {"altitude": 1083, "latitude": 33.383907, "location_source": "LOC_INTERNAL", "longitude": -106.421421, "time_offset_sec": 512}, "public_key_hex": "a36a659e82487c129f0fbea099cb6d7a83eb97a564f2238a8437bdb2ef2782b6", "role": "CLIENT_HIDDEN", "short_name": "SLGX", "snr": 8.51, "status": null, "telemetry": {"air_util_tx": 1.12, "battery_level": 14, "channel_utilization": 10.56, "uptime_seconds": 58752, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1008.15, "iaq": 73, "relative_humidity": 39.5, "temperature": 24.72}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1303, "long_name": "Rough Raven", "next_hop": 0, "num": "0x75e1b21b", "position": {"altitude": 1706, "latitude": 33.112908, "location_source": "LOC_INTERNAL", "longitude": -107.708675, "time_offset_sec": 1362}, "public_key_hex": "4dabfa0261e3a939b6532fb6022d1d1a9e542939d234db0fe6eca83f3ad0b15d", "role": "CLIENT", "short_name": "R56Q", "snr": 8.59, "status": null, "telemetry": {"air_util_tx": 0.685, "battery_level": 101, "channel_utilization": 14.25, "uptime_seconds": 78425, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 3, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 2284, "long_name": "Loud Shark AB3NJ", "next_hop": 73, "num": "0x75e79ba3", "position": {"altitude": 1219, "latitude": 33.675634, "location_source": "LOC_INTERNAL", "longitude": -107.451128, "time_offset_sec": 2435}, "public_key_hex": "1cf6985dd5915b367711fce443fc89065f729329e137c958c3b7151c8ec77d03", "role": "CLIENT", "short_name": "LHKM", "snr": 2.97, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 11285, "long_name": "Steel Trout", "next_hop": 0, "num": "0x7604c73c", "position": {"altitude": 1131, "latitude": 32.862219, "location_source": "LOC_INTERNAL", "longitude": -107.440747, "time_offset_sec": 11546}, "public_key_hex": "34f333649655110a7046cb44bcb1a02113a4611fdd5ea9846559eb10d2aa38ec", "role": "CLIENT_MUTE", "short_name": "🐝", "snr": 4.96, "status": null, "telemetry": {"air_util_tx": 0.03, "battery_level": 12, "channel_utilization": 19.79, "uptime_seconds": 284428, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4468, "long_name": "Black Viper", "next_hop": 0, "num": "0x7612b78e", "position": {"altitude": 1780, "latitude": 32.900796, "location_source": "LOC_INTERNAL", "longitude": -107.607642, "time_offset_sec": 4491}, "public_key_hex": "419e19260ffe7926c8476ca7d9524b055f9e1201dd0138e8d8b9bf39b579a2e3", "role": "ROUTER", "short_name": "B2HK", "snr": 6.16, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.036, "battery_level": 48, "channel_utilization": 1.31, "uptime_seconds": 90991, "voltage": 3.732}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1324, "long_name": "Canyon Wolf", "next_hop": 0, "num": "0x7659ba24", "position": {"altitude": 1346, "latitude": 33.951045, "location_source": "LOC_INTERNAL", "longitude": -107.247728, "time_offset_sec": 1468}, "public_key_hex": "5b1f3f7de04ce9a212f609a7af81d62e8247f9ca73c156d67b536c034b8d5e74", "role": "CLIENT", "short_name": "🐺", "snr": 4.38, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 3912, "long_name": "New Falcon", "next_hop": 30, "num": "0x765ee5c2", "position": {"altitude": 1768, "latitude": 33.953787, "location_source": "LOC_INTERNAL", "longitude": -107.438206, "time_offset_sec": 4098}, "public_key_hex": "2b0f42484f751892da08441f6e7cbf186c3d3ab87210d7f3d785f64ab1b26a60", "role": "CLIENT", "short_name": "🦉", "snr": 1.15, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.668, "battery_level": 10, "channel_utilization": 22.6, "uptime_seconds": 63185, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 425, "long_name": "Frosty Seal", "next_hop": 0, "num": "0x76659b8d", "position": null, "public_key_hex": "215256dcd6806eba9dc9ec52920a28d5beca8cb7a40f59278db3f80759abdf43", "role": "SENSOR", "short_name": "FSMO", "snr": 6.32, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.146, "battery_level": 22, "channel_utilization": 5.96, "uptime_seconds": 51736, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.66, "iaq": 47, "relative_humidity": 86.88, "temperature": 15.34}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 79, "long_name": "Happy Crow AB6XR", "next_hop": 0, "num": "0x76763b91", "position": {"altitude": 1351, "latitude": 33.303742, "location_source": "LOC_INTERNAL", "longitude": -106.614, "time_offset_sec": 338}, "public_key_hex": "3ca0549bda960eef719b6687b9de329610d0773715d95a11cf518d8be47efa98", "role": "CLIENT", "short_name": "🗻", "snr": -2.1, "status": null, "telemetry": {"air_util_tx": 1.473, "battery_level": 35, "channel_utilization": 3.6, "uptime_seconds": 4625, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M3", "last_heard_offset_sec": 5208, "long_name": "Brave Cobra", "next_hop": 0, "num": "0x76815b31", "position": {"altitude": 1270, "latitude": 33.067777, "location_source": "LOC_INTERNAL", "longitude": -107.296096, "time_offset_sec": 5238}, "public_key_hex": "c112978d7b8a9a9aaeceac8337d151b9dc56696c6a3d13d77580dd9dd3315f2a", "role": "CLIENT", "short_name": "B936", "snr": 12.0, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 826, "long_name": "Sharp Phoenix", "next_hop": 0, "num": "0x768434cd", "position": {"altitude": 1773, "latitude": 33.935039, "location_source": "LOC_INTERNAL", "longitude": -105.610321, "time_offset_sec": 1058}, "public_key_hex": "7cfcbd8d0cfc64894ac9895cacf223a53731df149ab163efec373d21fa1e1aed", "role": "CLIENT", "short_name": "🐺", "snr": 8.26, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1014.15, "iaq": 83, "relative_humidity": 54.89, "temperature": 32.1}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 581, "long_name": "Tall Mole", "next_hop": 147, "num": "0x76867cd5", "position": {"altitude": 1387, "latitude": 33.584995, "location_source": "LOC_INTERNAL", "longitude": -107.613377, "time_offset_sec": 696}, "public_key_hex": "31b641943e2f98a8a4c3c2425ac282b4988c6fa3bf950e52960076b3d9fc91b0", "role": "CLIENT_MUTE", "short_name": "TWI5", "snr": 2.42, "status": null, "telemetry": {"air_util_tx": 0.385, "battery_level": 61, "channel_utilization": 6.05, "uptime_seconds": 189996, "voltage": 3.849}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.39, "iaq": 30, "relative_humidity": 44.84, "temperature": 32.98}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 568, "long_name": "Misty Bass NM5QB", "next_hop": 0, "num": "0x7687e90a", "position": {"altitude": 1253, "latitude": 33.453964, "location_source": "LOC_INTERNAL", "longitude": -106.937815, "time_offset_sec": 831}, "public_key_hex": "a3189fb6d87629c6e8a3c523321175c8d2ba8162119871bb176cce261459f5b3", "role": "CLIENT", "short_name": "M9X2", "snr": 6.96, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3687, "long_name": "Quick Hawk", "next_hop": 0, "num": "0x7688e407", "position": {"altitude": 1367, "latitude": 33.062004, "location_source": "LOC_INTERNAL", "longitude": -107.717175, "time_offset_sec": 3817}, "public_key_hex": "782296c1f059de6fe9e53203af2615cdc1ae0b3538fd69d9253d84025d19bcc0", "role": "CLIENT", "short_name": "🐝", "snr": 5.23, "status": null, "telemetry": {"air_util_tx": 3.205, "battery_level": 44, "channel_utilization": 10.19, "uptime_seconds": 244092, "voltage": 3.696}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 1485, "long_name": "Slow Doe", "next_hop": 247, "num": "0x768ded68", "position": {"altitude": 1414, "latitude": 33.165403, "location_source": "LOC_INTERNAL", "longitude": -107.887493, "time_offset_sec": 1620}, "public_key_hex": "d63ee747972ea61a6860ba213db439fed6a4ff7fb85d9970841ba11a3d1b5181", "role": "CLIENT", "short_name": "S414", "snr": 7.27, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.327, "battery_level": 96, "channel_utilization": 2.72, "uptime_seconds": 26881, "voltage": 4.164}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.76, "iaq": 51, "relative_humidity": 32.12, "temperature": 15.59}, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 315, "long_name": "Dusk Heron", "next_hop": 0, "num": "0x769a5ac1", "position": {"altitude": 1197, "latitude": 34.107508, "location_source": "LOC_INTERNAL", "longitude": -107.399033, "time_offset_sec": 498}, "public_key_hex": "224f074e082bc602cbc3adca59d057ffa5972f0099805d19cfab220c5b9e62e3", "role": "CLIENT_HIDDEN", "short_name": "D4F9", "snr": 9.32, "status": null, "telemetry": {"air_util_tx": 1.007, "battery_level": 35, "channel_utilization": 9.01, "uptime_seconds": 100652, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1395, "long_name": "Wandering Lion", "next_hop": 21, "num": "0x76d3fd57", "position": {"altitude": 1541, "latitude": 33.775593, "location_source": "LOC_INTERNAL", "longitude": -107.530737, "time_offset_sec": 1462}, "public_key_hex": "5ee43236c73bfd46a34313efb3cf75022e24068ef7808d9faa750874a9399574", "role": "CLIENT_MUTE", "short_name": "W99I", "snr": 2.65, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 1471, "long_name": "Hidden Cobra", "next_hop": 0, "num": "0x76d8db80", "position": {"altitude": 1558, "latitude": 32.088521, "location_source": "LOC_INTERNAL", "longitude": -107.535521, "time_offset_sec": 1680}, "public_key_hex": "59448b07429cc8fe594f0c81a7cdc7eba41d50be2df75ab0ee8088b468f0b64f", "role": "CLIENT", "short_name": "H46Z", "snr": -3.11, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.352, "battery_level": 95, "channel_utilization": 9.01, "uptime_seconds": 120088, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 6441, "long_name": "Sky Mole NM3LC", "next_hop": 0, "num": "0x76df0f16", "position": {"altitude": 1068, "latitude": 31.812189, "location_source": "LOC_INTERNAL", "longitude": -107.719953, "time_offset_sec": 6697}, "public_key_hex": "b2d0497b0be04bb9fa74ff6f705ac839943a2edeea3806a73ff52f1095a4f3d3", "role": "CLIENT", "short_name": "SKN8", "snr": 5.92, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.988, "battery_level": 62, "channel_utilization": 37.17, "uptime_seconds": 83662, "voltage": 3.858}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 10216, "long_name": "Red Yucca", "next_hop": 226, "num": "0x76f6618b", "position": {"altitude": 1110, "latitude": 32.962922, "location_source": "LOC_INTERNAL", "longitude": -106.997762, "time_offset_sec": 10390}, "public_key_hex": "2900d13a2956f229438ee6b3ece4418913b36c7c0c5cd281680b6290eeef8ff4", "role": "CLIENT", "short_name": "R2BT", "snr": 7.79, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 1, "environment": {"barometric_pressure": 1026.45, "iaq": 0, "relative_humidity": 89.67, "temperature": 24.43}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1818, "long_name": "Shady Heron", "next_hop": 0, "num": "0x76fbf991", "position": {"altitude": 1037, "latitude": 32.822729, "location_source": "LOC_INTERNAL", "longitude": -108.052036, "time_offset_sec": 1980}, "public_key_hex": "", "role": "CLIENT", "short_name": "SI33", "snr": -1.82, "status": null, "telemetry": {"air_util_tx": 0.849, "battery_level": 99, "channel_utilization": 6.58, "uptime_seconds": 19556, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_VISION_MASTER_E290", "last_heard_offset_sec": 318, "long_name": "Roving Bison", "next_hop": 0, "num": "0x77013cd8", "position": {"altitude": 1186, "latitude": 33.589649, "location_source": "LOC_INTERNAL", "longitude": -107.473409, "time_offset_sec": 433}, "public_key_hex": "e62ca9835e1118783ff32e6028025c8057521e01685440d967b54fb1a8d289dd", "role": "CLIENT", "short_name": "REWK", "snr": 4.79, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 5070, "long_name": "Soft Yucca", "next_hop": 116, "num": "0x773e1223", "position": {"altitude": 1416, "latitude": 33.903354, "location_source": "LOC_INTERNAL", "longitude": -107.58656, "time_offset_sec": 5277}, "public_key_hex": "f1529cae38319a10dddd104e4d0d14300baf5791b634d2df4633a5400b55249a", "role": "LOST_AND_FOUND", "short_name": "🦅", "snr": 1.78, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.771, "battery_level": 39, "channel_utilization": 10.39, "uptime_seconds": 37532, "voltage": 3.651}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 3, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_NODE_T096", "last_heard_offset_sec": 5452, "long_name": "Silent Cobra", "next_hop": 136, "num": "0x773fc933", "position": {"altitude": 1158, "latitude": 32.059206, "location_source": "LOC_INTERNAL", "longitude": -107.614331, "time_offset_sec": 5551}, "public_key_hex": "eb88297714a268bed15768762b5ebdd4018f90bb675fcd5608f8567970f47ed0", "role": "CLIENT", "short_name": "🌊", "snr": 9.87, "status": null, "telemetry": {"air_util_tx": 2.326, "battery_level": 17, "channel_utilization": 6.74, "uptime_seconds": 113703, "voltage": 3.453}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 2602, "long_name": "Desert Owl", "next_hop": 0, "num": "0x775839ce", "position": {"altitude": 1475, "latitude": 32.889952, "location_source": "LOC_INTERNAL", "longitude": -106.107457, "time_offset_sec": 2828}, "public_key_hex": "", "role": "ROUTER", "short_name": "DC6O", "snr": 5.11, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 45, "long_name": "Howling Shark", "next_hop": 0, "num": "0x7760750b", "position": {"altitude": 1315, "latitude": 32.950895, "location_source": "LOC_INTERNAL", "longitude": -107.990633, "time_offset_sec": 208}, "public_key_hex": "5c20cbc33b14e61158b4454b9b402285cee88b5bd0f19ba372a24de23174661a", "role": "CLIENT", "short_name": "H2SA", "snr": 2.7, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 993.33, "iaq": 42, "relative_humidity": 29.82, "temperature": 8.01}, "hops_away": 3, "hw_model": "RAK4631", "last_heard_offset_sec": 199, "long_name": "White Bear", "next_hop": 43, "num": "0x77610d80", "position": null, "public_key_hex": "c2b59e41adb6326bc7cc16c66e34150ba61975e8fa909556862122fc51f428dd", "role": "CLIENT", "short_name": "🌊", "snr": 11.29, "status": null, "telemetry": {"air_util_tx": 0.195, "battery_level": 66, "channel_utilization": 24.79, "uptime_seconds": 78586, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 1651, "long_name": "Frozen Viper", "next_hop": 244, "num": "0x776d5fce", "position": {"altitude": 1010, "latitude": 33.67683, "location_source": "LOC_INTERNAL", "longitude": -107.071653, "time_offset_sec": 1710}, "public_key_hex": "21f6267fbaca5182d3d13c81a355dffe020e74914509151a51b73c696123dce3", "role": "CLIENT", "short_name": "FC44", "snr": 6.04, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.34, "iaq": 0, "relative_humidity": 91.9, "temperature": 13.99}, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2620, "long_name": "Steel Turtle", "next_hop": 228, "num": "0x776eb955", "position": {"altitude": 1319, "latitude": 32.018661, "location_source": "LOC_INTERNAL", "longitude": -106.456603, "time_offset_sec": 2660}, "public_key_hex": "48a718b5e7f7e27d96ebcf48ce4029a72877ad594c40755bad87e7fb7d70d858", "role": "CLIENT", "short_name": "SQ4D", "snr": 3.17, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 677, "long_name": "Tiny Salmon", "next_hop": 54, "num": "0x77b51f6e", "position": {"altitude": 1937, "latitude": 32.489477, "location_source": "LOC_INTERNAL", "longitude": -106.693974, "time_offset_sec": 795}, "public_key_hex": "7545ab701efbb142876bb2728258c5ce4333aa6ec7fcc0704ab848013650dd80", "role": "CLIENT", "short_name": "TCTW", "snr": 5.41, "status": null, "telemetry": {"air_util_tx": 0.25, "battery_level": 31, "channel_utilization": 4.16, "uptime_seconds": 68293, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 600, "long_name": "Loud Oak K10KQ", "next_hop": 0, "num": "0x77b85063", "position": {"altitude": 1704, "latitude": 34.141112, "location_source": "LOC_INTERNAL", "longitude": -108.061746, "time_offset_sec": 825}, "public_key_hex": "", "role": "CLIENT", "short_name": "LWKM", "snr": 9.79, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.56, "battery_level": 33, "channel_utilization": 13.75, "uptime_seconds": 87092, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 961, "long_name": "Sneaky Oak", "next_hop": 0, "num": "0x77bb4661", "position": {"altitude": 1132, "latitude": 32.642261, "location_source": "LOC_INTERNAL", "longitude": -107.057496, "time_offset_sec": 1232}, "public_key_hex": "214525f2d1403e445eeca8e8ff64e8439d8163ed3d1dfdff791b316b34411bc0", "role": "CLIENT", "short_name": "SBE4", "snr": 4.57, "status": null, "telemetry": {"air_util_tx": 0.232, "battery_level": 77, "channel_utilization": 10.03, "uptime_seconds": 10620, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "RAK4631", "last_heard_offset_sec": 866, "long_name": "Desert Badger", "next_hop": 215, "num": "0x77c8888e", "position": {"altitude": 1544, "latitude": 31.980444, "location_source": "LOC_INTERNAL", "longitude": -106.679918, "time_offset_sec": 946}, "public_key_hex": "6de1b6508683f3feea3764ad9d4102e22c6bb77c829c2fc5cb27a2586057c730", "role": "CLIENT", "short_name": "DA86", "snr": 5.39, "status": null, "telemetry": {"air_util_tx": 1.82, "battery_level": 66, "channel_utilization": 6.01, "uptime_seconds": 12685, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1011.23, "iaq": 46, "relative_humidity": 69.18, "temperature": 17.26}, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 254, "long_name": "White Stag", "next_hop": 0, "num": "0x77e46ad1", "position": {"altitude": 1465, "latitude": 33.964566, "location_source": "LOC_INTERNAL", "longitude": -106.87044, "time_offset_sec": 311}, "public_key_hex": "8739f995c28bb159fca5864b553d7ffb93082fdbd62755dfd64eb2e70c1debeb", "role": "CLIENT", "short_name": "WQQS", "snr": 3.89, "status": null, "telemetry": {"air_util_tx": 0.69, "battery_level": 60, "channel_utilization": 3.06, "uptime_seconds": 20519, "voltage": 3.84}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4586, "long_name": "Frozen Crane", "next_hop": 0, "num": "0x7802a0d5", "position": null, "public_key_hex": "5ce58cea92385ee0db4550472e67b34b5377956209284fc3a189f2b49069c08f", "role": "CLIENT", "short_name": "FKJH", "snr": 7.44, "status": null, "telemetry": {"air_util_tx": 0.325, "battery_level": 31, "channel_utilization": 11.29, "uptime_seconds": 359015, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1888, "long_name": "Giant Cedar", "next_hop": 0, "num": "0x7805de1b", "position": null, "public_key_hex": "94d53a0e297427c2287c24d44f549c324c03ffff829892a9419a678c0d6908c4", "role": "CLIENT", "short_name": "G3R9", "snr": 6.07, "status": null, "telemetry": {"air_util_tx": 0.896, "battery_level": 19, "channel_utilization": 31.85, "uptime_seconds": 92063, "voltage": 3.471}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "MUZI_BASE", "last_heard_offset_sec": 901, "long_name": "Solar Hawk", "next_hop": 65, "num": "0x780e246b", "position": {"altitude": 1059, "latitude": 33.227987, "location_source": "LOC_INTERNAL", "longitude": -106.881813, "time_offset_sec": 1166}, "public_key_hex": "c22f7f2d3875c03c36dbe0c2b16fdf6bb2ab73dd837bee18fd7ea30eaff3df84", "role": "CLIENT", "short_name": "SHZT", "snr": 6.89, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 291, "long_name": "Tall Iguana", "next_hop": 30, "num": "0x781022d1", "position": null, "public_key_hex": "af0b6236f68f935890a301b0e58753c242016788b2f5cc6f3266544f970d498f", "role": "CLIENT", "short_name": "T820", "snr": 2.76, "status": null, "telemetry": {"air_util_tx": 1.395, "battery_level": 43, "channel_utilization": 11.41, "uptime_seconds": 737, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 10502, "long_name": "Frosty Salmon", "next_hop": 80, "num": "0x78117a1d", "position": {"altitude": 1372, "latitude": 33.39358, "location_source": "LOC_INTERNAL", "longitude": -107.803851, "time_offset_sec": 10507}, "public_key_hex": "", "role": "CLIENT", "short_name": "F0WZ", "snr": 8.99, "status": null, "telemetry": {"air_util_tx": 2.145, "battery_level": 35, "channel_utilization": 17.12, "uptime_seconds": 113052, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6316, "long_name": "Loud Lynx", "next_hop": 111, "num": "0x78158291", "position": null, "public_key_hex": "c19da403438e3f84841beacc8d5c6938509575feee0d23ef60d18f9a00071e3d", "role": "CLIENT", "short_name": "LPDS", "snr": 11.04, "status": null, "telemetry": {"air_util_tx": 0.42, "battery_level": 34, "channel_utilization": 26.5, "uptime_seconds": 49304, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1069, "long_name": "Solar Ridge", "next_hop": 107, "num": "0x7816f9e6", "position": {"altitude": 1291, "latitude": 33.980841, "location_source": "LOC_INTERNAL", "longitude": -107.804028, "time_offset_sec": 1133}, "public_key_hex": "4c4d27451bcd92430f39491c0d04b5064c3f7f2845c271bd5a7207b7bc598fd6", "role": "CLIENT", "short_name": "SPEH", "snr": 5.72, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.926, "battery_level": 43, "channel_utilization": 30.0, "uptime_seconds": 224636, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK3312", "last_heard_offset_sec": 1700, "long_name": "Smooth Ridge", "next_hop": 82, "num": "0x7837361c", "position": {"altitude": 1696, "latitude": 33.32353, "location_source": "LOC_INTERNAL", "longitude": -106.74225, "time_offset_sec": 1854}, "public_key_hex": "831cef01e1620bd81537c68049e7deb56433529a927367c4409ee20e768b97c7", "role": "ROUTER", "short_name": "SOWL", "snr": 4.57, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.232, "battery_level": 29, "channel_utilization": 2.93, "uptime_seconds": 132965, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1006.76, "iaq": 52, "relative_humidity": 45.8, "temperature": 17.16}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8405, "long_name": "Wandering Cougar", "next_hop": 0, "num": "0x783f6ffe", "position": {"altitude": 1278, "latitude": 33.361398, "location_source": "LOC_INTERNAL", "longitude": -107.113663, "time_offset_sec": 8667}, "public_key_hex": "12eedfc3e2cfc87d67f39ec9eb1c2552c6c86de842fc89313605d8d43f49db02", "role": "CLIENT", "short_name": "WKZP", "snr": 1.38, "status": {"status": "active"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 1009, "long_name": "Storm Hawk", "next_hop": 0, "num": "0x7848140c", "position": {"altitude": 1128, "latitude": 33.540033, "location_source": "LOC_INTERNAL", "longitude": -108.186301, "time_offset_sec": 1083}, "public_key_hex": "8c96f918c3585d1052d439c43a0cab2719a538c6575c9aeb312a50a64b5caf5e", "role": "CLIENT", "short_name": "SUU0", "snr": 5.6, "status": null, "telemetry": {"air_util_tx": 0.667, "battery_level": 10, "channel_utilization": 10.59, "uptime_seconds": 101553, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2339, "long_name": "Forest Fox", "next_hop": 240, "num": "0x78489a74", "position": {"altitude": 1512, "latitude": 32.653974, "location_source": "LOC_INTERNAL", "longitude": -108.195803, "time_offset_sec": 2509}, "public_key_hex": "b69f5ec803bf3c1cf37f50fcf2ada8e65681386657dca63acfeeeabafb965fb2", "role": "CLIENT", "short_name": "FEW6", "snr": 10.82, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.185, "battery_level": 100, "channel_utilization": 7.34, "uptime_seconds": 22406, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2448, "long_name": "Red Trout", "next_hop": 29, "num": "0x785ba3d0", "position": {"altitude": 1588, "latitude": 33.428002, "location_source": "LOC_INTERNAL", "longitude": -107.133487, "time_offset_sec": 2653}, "public_key_hex": "50599a327de48dcb027c70a5c5b1bf9ba3ebe350c53cdba6f9895fe3da895df9", "role": "CLIENT", "short_name": "R08M", "snr": 3.11, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 2135, "long_name": "Floating Crane", "next_hop": 0, "num": "0x788bcc90", "position": {"altitude": 1835, "latitude": 33.378635, "location_source": "LOC_INTERNAL", "longitude": -107.191256, "time_offset_sec": 2297}, "public_key_hex": "2acfdec66db2917d914e5b2de5e415164395187cd20eed4818135b91e515b72d", "role": "CLIENT", "short_name": "🦂", "snr": 6.85, "status": null, "telemetry": {"air_util_tx": 0.103, "battery_level": 24, "channel_utilization": 6.09, "uptime_seconds": 2762, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 978, "long_name": "Silent Mesa KX5MV", "next_hop": 107, "num": "0x788c76ca", "position": {"altitude": 1685, "latitude": 33.546066, "location_source": "LOC_INTERNAL", "longitude": -107.300299, "time_offset_sec": 1160}, "public_key_hex": "32ade2d83c5990ae78d4489f52940ff689677cce79849a35d40a8729fe86a31c", "role": "SENSOR", "short_name": "SAQD", "snr": 4.28, "status": null, "telemetry": {"air_util_tx": 0.529, "battery_level": 71, "channel_utilization": 6.74, "uptime_seconds": 131996, "voltage": 3.939}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": {"barometric_pressure": 1020.63, "iaq": 7, "relative_humidity": 58.54, "temperature": 25.27}, "hops_away": 6, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 331, "long_name": "Forest Iguana", "next_hop": 182, "num": "0x7899b56b", "position": {"altitude": 1163, "latitude": 33.00818, "location_source": "LOC_INTERNAL", "longitude": -107.475896, "time_offset_sec": 611}, "public_key_hex": "5e5eb86b99335dcd75b5ab9e18c9fb45e0da771fe1a68e9f90ab501a60d806ea", "role": "TRACKER", "short_name": "🔥", "snr": 11.81, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 786, "long_name": "Frosty Tortoise", "next_hop": 0, "num": "0x789a251f", "position": {"altitude": 1605, "latitude": 32.663046, "location_source": "LOC_INTERNAL", "longitude": -106.992516, "time_offset_sec": 1005}, "public_key_hex": "31a2c206780eb7f5edc13bb8f91367dd795353d26e0dee30ace19b45648ae267", "role": "CLIENT", "short_name": "FOPX", "snr": 0.23, "status": null, "telemetry": {"air_util_tx": 0.068, "battery_level": 20, "channel_utilization": 4.5, "uptime_seconds": 24931, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.61, "iaq": 27, "relative_humidity": 70.74, "temperature": 31.52}, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 3133, "long_name": "Tiny Lion", "next_hop": 234, "num": "0x78badba7", "position": {"altitude": 1478, "latitude": 33.473469, "location_source": "LOC_INTERNAL", "longitude": -107.967922, "time_offset_sec": 3184}, "public_key_hex": "6dfaf822372c839e1be3fb92b7e060fb35c1bfba0d54a7b775e154d81fb25cc2", "role": "CLIENT", "short_name": "TP4Y", "snr": -3.24, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 11423, "long_name": "Tiny Juniper", "next_hop": 0, "num": "0x78c1d678", "position": {"altitude": 1136, "latitude": 32.651224, "location_source": "LOC_INTERNAL", "longitude": -106.693784, "time_offset_sec": 11689}, "public_key_hex": "", "role": "CLIENT", "short_name": "T9ZZ", "snr": 6.85, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 13432, "long_name": "Giant Hare", "next_hop": 0, "num": "0x78d043f9", "position": {"altitude": 942, "latitude": 33.649127, "location_source": "LOC_INTERNAL", "longitude": -107.323131, "time_offset_sec": 13678}, "public_key_hex": "6c4c3a5ee5738d9af73d40460fbb1efd048aa670bbba74d80f19af7c9798d218", "role": "CLIENT", "short_name": "🔥", "snr": 2.81, "status": null, "telemetry": {"air_util_tx": 0.122, "battery_level": 38, "channel_utilization": 2.2, "uptime_seconds": 254190, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1004.12, "iaq": 59, "relative_humidity": 53.77, "temperature": 32.32}, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 3510, "long_name": "Smooth Pike", "next_hop": 0, "num": "0x78dfc30a", "position": {"altitude": 1422, "latitude": 33.502961, "location_source": "LOC_INTERNAL", "longitude": -106.02955, "time_offset_sec": 3684}, "public_key_hex": "7a79abe6903881a4ec049b90ea0bd536fb42800e8296d682cf92ad27fee3f097", "role": "CLIENT", "short_name": "SXX6", "snr": 3.9, "status": null, "telemetry": {"air_util_tx": 0.775, "battery_level": 20, "channel_utilization": 19.21, "uptime_seconds": 106554, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 538, "long_name": "Lost Yucca", "next_hop": 0, "num": "0x78e3c6c9", "position": {"altitude": 1047, "latitude": 32.835996, "location_source": "LOC_INTERNAL", "longitude": -106.712952, "time_offset_sec": 829}, "public_key_hex": "674714b2d6144e44103c8232b5d333a21630a1b55baaddb7210d1faaac055469", "role": "CLIENT", "short_name": "LOOT", "snr": 12.0, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.109, "battery_level": 37, "channel_utilization": 7.42, "uptime_seconds": 1297, "voltage": 3.633}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 956, "long_name": "Sky Oak", "next_hop": 0, "num": "0x78eaebf9", "position": {"altitude": 1526, "latitude": 33.191973, "location_source": "LOC_INTERNAL", "longitude": -106.964953, "time_offset_sec": 1096}, "public_key_hex": "41da261418b73715f855484670fe4f80af67eccfca8c082ba45c79b60bf0a971", "role": "CLIENT", "short_name": "SXC8", "snr": 7.06, "status": null, "telemetry": {"air_util_tx": 0.158, "battery_level": 30, "channel_utilization": 10.74, "uptime_seconds": 18648, "voltage": 3.57}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 504, "long_name": "Storm Iguana", "next_hop": 229, "num": "0x7916f58a", "position": {"altitude": 1550, "latitude": 33.127503, "location_source": "LOC_INTERNAL", "longitude": -107.329344, "time_offset_sec": 754}, "public_key_hex": "34364d63e5061aaafc51c526c4051b0dc3bcc4afa79aa41c9a431c47a32ab675", "role": "CLIENT", "short_name": "SDJ7", "snr": 8.68, "status": {"status": "active"}, "telemetry": {"air_util_tx": 1.184, "battery_level": 84, "channel_utilization": 27.82, "uptime_seconds": 5164, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 4342, "long_name": "Black Hare", "next_hop": 62, "num": "0x791c1e84", "position": {"altitude": 1795, "latitude": 33.210126, "location_source": "LOC_INTERNAL", "longitude": -107.82682, "time_offset_sec": 4543}, "public_key_hex": "3a39aa22dadb674ae8e293eba67cfa8967769febc32053bbcc2c4e618985b227", "role": "CLIENT", "short_name": "B2GK", "snr": 10.77, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.215, "battery_level": 20, "channel_utilization": 8.01, "uptime_seconds": 170448, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2808, "long_name": "Brave Bluff", "next_hop": 64, "num": "0x7932340a", "position": {"altitude": 1527, "latitude": 32.097588, "location_source": "LOC_INTERNAL", "longitude": -107.377276, "time_offset_sec": 3001}, "public_key_hex": "d6c594988f960494063e727de5e7f2a443a30c889a4ecbba7358c32c1c8faaa7", "role": "CLIENT", "short_name": "BKH1", "snr": 9.89, "status": null, "telemetry": {"air_util_tx": 1.473, "battery_level": 28, "channel_utilization": 19.8, "uptime_seconds": 17408, "voltage": 3.552}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1005.64, "iaq": 39, "relative_humidity": 56.2, "temperature": 18.56}, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 11661, "long_name": "Bright Beaver", "next_hop": 155, "num": "0x79354b34", "position": {"altitude": 1394, "latitude": 33.865115, "location_source": "LOC_INTERNAL", "longitude": -107.193401, "time_offset_sec": 11706}, "public_key_hex": "5dad55179a38a402afcfc17839f9a122769c29d63b3985b56a9782a79c4c9d8a", "role": "CLIENT", "short_name": "BFND", "snr": 4.38, "status": null, "telemetry": {"air_util_tx": 0.356, "battery_level": 34, "channel_utilization": 8.49, "uptime_seconds": 527672, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1003.81, "iaq": 88, "relative_humidity": 51.67, "temperature": 2.15}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 1480, "long_name": "Short Bison", "next_hop": 0, "num": "0x7940e13d", "position": {"altitude": 1239, "latitude": 33.216391, "location_source": "LOC_INTERNAL", "longitude": -107.849798, "time_offset_sec": 1541}, "public_key_hex": "9eb6a418590b64768379b15ddcc859d4d89d642d74d3fba6809ce0be939cc381", "role": "CLIENT", "short_name": "SLIO", "snr": 0.01, "status": null, "telemetry": {"air_util_tx": 1.245, "battery_level": 43, "channel_utilization": 2.1, "uptime_seconds": 154056, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO", "last_heard_offset_sec": 5532, "long_name": "Misty Heron WD3QQ", "next_hop": 192, "num": "0x7947c19a", "position": {"altitude": 1274, "latitude": 34.259504, "location_source": "LOC_INTERNAL", "longitude": -107.006321, "time_offset_sec": 5751}, "public_key_hex": "ed58206e3471841923432200af035e94b9d7829e03987891e0c257f305051096", "role": "CLIENT_HIDDEN", "short_name": "MDXW", "snr": 11.83, "status": {"status": "low-batt"}, "telemetry": {"air_util_tx": 0.162, "battery_level": 36, "channel_utilization": 19.03, "uptime_seconds": 14135, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 27, "long_name": "Old Doe", "next_hop": 31, "num": "0x794cdfbc", "position": {"altitude": 995, "latitude": 33.104556, "location_source": "LOC_INTERNAL", "longitude": -107.022873, "time_offset_sec": 199}, "public_key_hex": "0079ff170765e8e0a330eddc8219b52cb44756a9b34357d07865038157d42df4", "role": "CLIENT_BASE", "short_name": "OXQG", "snr": 3.48, "status": null, "telemetry": {"air_util_tx": 0.938, "battery_level": 46, "channel_utilization": 6.08, "uptime_seconds": 172365, "voltage": 3.714}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.76, "iaq": 56, "relative_humidity": 24.22, "temperature": 25.81}, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4079, "long_name": "Green Bear", "next_hop": 0, "num": "0x795e7bef", "position": {"altitude": 1434, "latitude": 33.45599, "location_source": "LOC_INTERNAL", "longitude": -105.838124, "time_offset_sec": 4255}, "public_key_hex": "f4395eb74a7dde6423332d462e820eff0704006a9c9f1ab471db109e32ec5152", "role": "CLIENT", "short_name": "GHWS", "snr": 5.88, "status": {"status": "weak-signal"}, "telemetry": {"air_util_tx": 0.321, "battery_level": 31, "channel_utilization": 17.25, "uptime_seconds": 225970, "voltage": 3.579}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 3200, "long_name": "Short Otter", "next_hop": 82, "num": "0x7960c5ce", "position": {"altitude": 2020, "latitude": 33.624427, "location_source": "LOC_INTERNAL", "longitude": -107.594689, "time_offset_sec": 3235}, "public_key_hex": "a4fbadf4651c2e322fdc5178ad34034c94b6fbd858fb3f679b9181dd77cfe9a6", "role": "ROUTER", "short_name": "SXN7", "snr": 7.36, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 8212, "long_name": "Burning Fox", "next_hop": 229, "num": "0x798fffad", "position": {"altitude": 1775, "latitude": 33.189818, "location_source": "LOC_INTERNAL", "longitude": -106.138013, "time_offset_sec": 8379}, "public_key_hex": "114386a11a78e5eb05f4f6bfa48044283194eee4f198a4b28d59bfdf1823b4b0", "role": "CLIENT", "short_name": "B2FK", "snr": 7.7, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 1591, "long_name": "Stone Otter", "next_hop": 0, "num": "0x7992d0e7", "position": null, "public_key_hex": "416608bcd9750a9b03ef102c3b9f8564f4f67b4639887cad693b5d2fd895dedb", "role": "CLIENT", "short_name": "S0B5", "snr": 0.46, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.165, "battery_level": 26, "channel_utilization": 2.25, "uptime_seconds": 27518, "voltage": 3.534}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 1081, "long_name": "Mountain Eagle", "next_hop": 69, "num": "0x7995a65d", "position": {"altitude": 1405, "latitude": 32.912965, "location_source": "LOC_INTERNAL", "longitude": -106.716051, "time_offset_sec": 1188}, "public_key_hex": "f04ba5a8cb9841df20627b8e23c9e9b2239f3044dcf0b8d53630b2adb825f805", "role": "CLIENT", "short_name": "MDKX", "snr": 8.32, "status": null, "telemetry": {"air_util_tx": 0.413, "battery_level": 43, "channel_utilization": 11.76, "uptime_seconds": 59107, "voltage": 3.687}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1009.8, "iaq": 71, "relative_humidity": 67.83, "temperature": 22.78}, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 913, "long_name": "Gold Moose", "next_hop": 0, "num": "0x79fc2488", "position": null, "public_key_hex": "af9873481549dcf1168a75b1a234d9af405be4e87cb97972e5bc4cc1c0b5743c", "role": "CLIENT", "short_name": "G4II", "snr": 0.44, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.22, "battery_level": 101, "channel_utilization": 6.41, "uptime_seconds": 78991, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 1682, "long_name": "Slow Dolphin", "next_hop": 186, "num": "0x7a09f531", "position": {"altitude": 1536, "latitude": 32.909814, "location_source": "LOC_INTERNAL", "longitude": -107.136095, "time_offset_sec": 1906}, "public_key_hex": "39c411966edbc451552043bcf217469142f248e89b589b029a50c77bdf41055e", "role": "CLIENT", "short_name": "S33B", "snr": 4.67, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.456, "battery_level": 89, "channel_utilization": 13.38, "uptime_seconds": 44965, "voltage": 4.101}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.45, "iaq": 83, "relative_humidity": 71.3, "temperature": 11.51}, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3576, "long_name": "Whispering Shark", "next_hop": 0, "num": "0x7a12b17d", "position": null, "public_key_hex": "1d0e8274d6e80d9a63557587386f02bec35e4d0038211d7d9bff557dd5c66f43", "role": "CLIENT", "short_name": "🗻", "snr": 0.4, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.03, "battery_level": 29, "channel_utilization": 4.67, "uptime_seconds": 18705, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 1130, "long_name": "Bright Oak", "next_hop": 95, "num": "0x7a1eb6ec", "position": {"altitude": 1190, "latitude": 31.82515, "location_source": "LOC_INTERNAL", "longitude": -107.793678, "time_offset_sec": 1199}, "public_key_hex": "c65c3c5547b51e0b8223998cda681e86d4ae08d69912276c9d7adeda9a9d46f0", "role": "CLIENT", "short_name": "B57L", "snr": 4.82, "status": null, "telemetry": {"air_util_tx": 0.703, "battery_level": 20, "channel_utilization": 13.62, "uptime_seconds": 3574, "voltage": 3.48}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 670, "long_name": "Stone Trout", "next_hop": 50, "num": "0x7a2beac3", "position": {"altitude": 1449, "latitude": 33.585553, "location_source": "LOC_INTERNAL", "longitude": -106.98469, "time_offset_sec": 822}, "public_key_hex": "24e359444a981f695bbe33c982998b66738f48f97db366613aa1bdf3fb14fb6e", "role": "CLIENT", "short_name": "S6G5", "snr": 6.47, "status": null, "telemetry": {"air_util_tx": 1.454, "battery_level": 51, "channel_utilization": 14.06, "uptime_seconds": 4942, "voltage": 3.759}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 4568, "long_name": "Giant Colt", "next_hop": 0, "num": "0x7a33285e", "position": {"altitude": 1338, "latitude": 33.065679, "location_source": "LOC_INTERNAL", "longitude": -107.60877, "time_offset_sec": 4627}, "public_key_hex": "aa702c3c0ab521046c59da82377856836aa9d1fcf3091bb7d3a787373218b6a8", "role": "CLIENT_MUTE", "short_name": "G9YR", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.44, "battery_level": 41, "channel_utilization": 6.08, "uptime_seconds": 14589, "voltage": 3.669}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 620, "long_name": "Soft Phoenix", "next_hop": 0, "num": "0x7a5d7e85", "position": {"altitude": 1159, "latitude": 33.966949, "location_source": "LOC_INTERNAL", "longitude": -108.550288, "time_offset_sec": 832}, "public_key_hex": "6e4ae4c48ac05626a98994bf964dd4b78dd2c0a1b482199768eb268117ca10af", "role": "CLIENT", "short_name": "SIDE", "snr": 6.3, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.446, "battery_level": 33, "channel_utilization": 10.83, "uptime_seconds": 1822, "voltage": 3.597}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1021, "long_name": "Drifting Mamba", "next_hop": 42, "num": "0x7a63816b", "position": {"altitude": 1499, "latitude": 33.482548, "location_source": "LOC_INTERNAL", "longitude": -107.180764, "time_offset_sec": 1214}, "public_key_hex": "22a90b6df06102df9ed8e10a8868eb41af28e07e85a35764362f93d20e1f7f56", "role": "CLIENT", "short_name": "D96M", "snr": 3.75, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1016.82, "iaq": 88, "relative_humidity": 54.71, "temperature": 25.47}, "hops_away": 2, "hw_model": "T_LORA_PAGER", "last_heard_offset_sec": 3316, "long_name": "Black Mamba", "next_hop": 175, "num": "0x7a6b21f0", "position": {"altitude": 1576, "latitude": 33.005755, "location_source": "LOC_INTERNAL", "longitude": -107.5906, "time_offset_sec": 3370}, "public_key_hex": "c698d3362a4313cc665367cbf5b99b269430acc808886f71bf79cd64e4b2a41d", "role": "CLIENT", "short_name": "🦂", "snr": 8.25, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 2981, "long_name": "Sunny Hawk", "next_hop": 176, "num": "0x7a735b58", "position": {"altitude": 1582, "latitude": 32.912796, "location_source": "LOC_INTERNAL", "longitude": -106.974271, "time_offset_sec": 3130}, "public_key_hex": "2b5693c42f5e0d1ce51219d1a05d8b6e51d02afc13b9d857eb4f2ee2fe2fc307", "role": "ROUTER_LATE", "short_name": "SIM2", "snr": 2.32, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 2.345, "battery_level": 80, "channel_utilization": 11.08, "uptime_seconds": 6454, "voltage": 4.02}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 724, "long_name": "Dawn Adder", "next_hop": 0, "num": "0x7a75021b", "position": null, "public_key_hex": "8f474b36201ceb2d10c95912dd902f53567fcb5746a90aeb89b9018e16ebcaf6", "role": "CLIENT", "short_name": "DCKK", "snr": 7.13, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7617, "long_name": "Bright Cougar", "next_hop": 119, "num": "0x7a7fac3e", "position": {"altitude": 1309, "latitude": 33.4072, "location_source": "LOC_INTERNAL", "longitude": -106.661659, "time_offset_sec": 7735}, "public_key_hex": "8ea15e24534f509bbc176c2eadeccf20985cace267f9da85a180a70c5429ee60", "role": "ROUTER", "short_name": "B6GQ", "snr": 4.73, "status": null, "telemetry": {"air_util_tx": 0.131, "battery_level": 83, "channel_utilization": 1.64, "uptime_seconds": 32189, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.09, "iaq": 64, "relative_humidity": 68.38, "temperature": 20.13}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 7831, "long_name": "Wandering Aspen", "next_hop": 0, "num": "0x7a80ad23", "position": {"altitude": 1330, "latitude": 32.905587, "location_source": "LOC_INTERNAL", "longitude": -106.795417, "time_offset_sec": 8055}, "public_key_hex": "cdb416e6a3047fe8e97848e1b7a5ed5d3a1c82c1b339b889e1f75bf17d8af57e", "role": "SENSOR", "short_name": "WIPL", "snr": 5.19, "status": null, "telemetry": {"air_util_tx": 0.301, "battery_level": 58, "channel_utilization": 8.49, "uptime_seconds": 460911, "voltage": 3.822}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 8899, "long_name": "Soft Badger", "next_hop": 112, "num": "0x7a81bd73", "position": {"altitude": 1444, "latitude": 33.068337, "location_source": "LOC_INTERNAL", "longitude": -107.481722, "time_offset_sec": 9079}, "public_key_hex": "92bf12c8183ef4b19cfd897ce8ce3ef1c2c57cea8773a63dae0be45f9a596b65", "role": "CLIENT", "short_name": "SQLE", "snr": 7.42, "status": null, "telemetry": {"air_util_tx": 0.216, "battery_level": 77, "channel_utilization": 1.04, "uptime_seconds": 88227, "voltage": 3.993}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1009.64, "iaq": 12, "relative_humidity": 50.16, "temperature": 16.2}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 8905, "long_name": "Dawn Trout", "next_hop": 0, "num": "0x7a86efdf", "position": {"altitude": 1760, "latitude": 33.308449, "location_source": "LOC_INTERNAL", "longitude": -107.190528, "time_offset_sec": 9186}, "public_key_hex": "4632b9a811d147f5e78d59555563e89b34ce7527836780d8e732741c750ec2de", "role": "CLIENT", "short_name": "DO7P", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.904, "battery_level": 100, "channel_utilization": 11.45, "uptime_seconds": 24419, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 1245, "long_name": "Silver Stag", "next_hop": 60, "num": "0x7a8d4bcf", "position": {"altitude": 1549, "latitude": 34.019203, "location_source": "LOC_INTERNAL", "longitude": -106.93245, "time_offset_sec": 1338}, "public_key_hex": "583af846e6d7f951e38e7d105c2df1736935e12f892715d08c18c14bc6f60a7f", "role": "CLIENT", "short_name": "SLYL", "snr": 11.57, "status": null, "telemetry": {"air_util_tx": 0.458, "battery_level": 101, "channel_utilization": 9.47, "uptime_seconds": 90370, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 1100, "long_name": "Found Falcon", "next_hop": 25, "num": "0x7a91a53c", "position": {"altitude": 1328, "latitude": 33.705744, "location_source": "LOC_INTERNAL", "longitude": -106.781616, "time_offset_sec": 1288}, "public_key_hex": "cd225997fd997b4c05221c0ca8528178f521c3b7a2bc94544a7dc2065ed05660", "role": "CLIENT", "short_name": "FL3J", "snr": -3.11, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 2, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 4181, "long_name": "Iron Otter", "next_hop": 0, "num": "0x7ab759e4", "position": {"altitude": 1672, "latitude": 33.701335, "location_source": "LOC_INTERNAL", "longitude": -106.880616, "time_offset_sec": 4297}, "public_key_hex": "4df404160e839edc1f9ada05ebad9ba37d77d9a13652058137b85df42e104c4d", "role": "CLIENT_MUTE", "short_name": "INNM", "snr": -0.54, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 866, "long_name": "Copper Seal", "next_hop": 0, "num": "0x7abe6bc2", "position": {"altitude": 1216, "latitude": 33.011643, "location_source": "LOC_INTERNAL", "longitude": -107.458714, "time_offset_sec": 867}, "public_key_hex": "59bd96cabecbd9161afda2cbb5f7f9e568fb62a0f3c823036f760bd097ca3bcc", "role": "CLIENT_BASE", "short_name": "CYBL", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.682, "battery_level": 68, "channel_utilization": 23.24, "uptime_seconds": 292503, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.51, "iaq": 58, "relative_humidity": 75.49, "temperature": 30.85}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2763, "long_name": "Drifting Marmot", "next_hop": 144, "num": "0x7acef7f2", "position": {"altitude": 1419, "latitude": 33.215641, "location_source": "LOC_INTERNAL", "longitude": -107.16228, "time_offset_sec": 2879}, "public_key_hex": "", "role": "CLIENT", "short_name": "DZU5", "snr": 6.29, "status": null, "telemetry": {"air_util_tx": 0.716, "battery_level": 64, "channel_utilization": 5.97, "uptime_seconds": 66095, "voltage": 3.876}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 258, "long_name": "Silver Moose", "next_hop": 0, "num": "0x7ad93649", "position": {"altitude": 1407, "latitude": 33.136638, "location_source": "LOC_INTERNAL", "longitude": -106.782234, "time_offset_sec": 454}, "public_key_hex": "fbf901d12fdfc1acc2b7e07d275f48223b36df24ba64a5ddf72d296af46b3e22", "role": "ROUTER", "short_name": "S6L1", "snr": -0.04, "status": null, "telemetry": {"air_util_tx": 0.173, "battery_level": 10, "channel_utilization": 11.0, "uptime_seconds": 95275, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6990, "long_name": "Steel Iguana", "next_hop": 0, "num": "0x7adfaa44", "position": {"altitude": 1552, "latitude": 33.423542, "location_source": "LOC_INTERNAL", "longitude": -108.092115, "time_offset_sec": 7141}, "public_key_hex": "55318bcd1f5f06a6a52accba1165010fa1a60b4fbbb2540b380f97bb4cf7a30e", "role": "CLIENT_MUTE", "short_name": "SFYC", "snr": 5.77, "status": null, "telemetry": {"air_util_tx": 0.426, "battery_level": 24, "channel_utilization": 18.06, "uptime_seconds": 24264, "voltage": 3.516}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 279, "long_name": "Gold Mole", "next_hop": 254, "num": "0x7af41108", "position": {"altitude": 1684, "latitude": 32.235619, "location_source": "LOC_INTERNAL", "longitude": -107.107394, "time_offset_sec": 508}, "public_key_hex": "8e3b081c17f3d4666248bf078a95dd468e7f784bfddc59832ff751e2587a10b0", "role": "ROUTER", "short_name": "GK2S", "snr": 9.91, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 7404, "long_name": "Lunar Beaver", "next_hop": 0, "num": "0x7af62f0d", "position": {"altitude": 1433, "latitude": 34.336152, "location_source": "LOC_INTERNAL", "longitude": -107.996345, "time_offset_sec": 7605}, "public_key_hex": "4ff4de1745afa966203d5af640b94c18443ec88e2dda5aa5b0e001add5b19b18", "role": "CLIENT", "short_name": "LHQS", "snr": 6.34, "status": null, "telemetry": {"air_util_tx": 0.227, "battery_level": 34, "channel_utilization": 24.58, "uptime_seconds": 46207, "voltage": 3.606}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 3400, "long_name": "Silent Bison", "next_hop": 0, "num": "0x7afae783", "position": {"altitude": 1319, "latitude": 32.417834, "location_source": "LOC_INTERNAL", "longitude": -106.215565, "time_offset_sec": 3651}, "public_key_hex": "", "role": "ROUTER", "short_name": "🌙", "snr": 3.75, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.804, "battery_level": 29, "channel_utilization": 7.6, "uptime_seconds": 444447, "voltage": 3.561}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 4, "environment": {"barometric_pressure": 1000.67, "iaq": 46, "relative_humidity": 94.65, "temperature": 27.42}, "hops_away": 4, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 732, "long_name": "Shady Bronco", "next_hop": 50, "num": "0x7b025ea1", "position": {"altitude": 1715, "latitude": 32.96616, "location_source": "LOC_INTERNAL", "longitude": -106.566593, "time_offset_sec": 903}, "public_key_hex": "4530919efa942094e83396713fbb557e0d612274a11fb6f09ca203e613e76a7b", "role": "CLIENT", "short_name": "S181", "snr": 4.86, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1018.46, "iaq": 36, "relative_humidity": 67.08, "temperature": 34.03}, "hops_away": 0, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 1633, "long_name": "Fast Bass", "next_hop": 0, "num": "0x7b3b7bc7", "position": {"altitude": 1540, "latitude": 33.084808, "location_source": "LOC_INTERNAL", "longitude": -106.808441, "time_offset_sec": 1639}, "public_key_hex": "20ca76a8509568c77a11387156be571dd6739cc066b83a621daf6f595370b2d6", "role": "CLIENT", "short_name": "F5NM", "snr": 9.78, "status": null, "telemetry": {"air_util_tx": 0.516, "battery_level": 23, "channel_utilization": 2.63, "uptime_seconds": 89052, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 167, "long_name": "Storm Elk", "next_hop": 63, "num": "0x7b4945de", "position": null, "public_key_hex": "26503ac36c3d65fd235a97c7c63dd304c24db440fdf8e5d7546d3755dfd7f7ba", "role": "CLIENT", "short_name": "SIAJ", "snr": 3.8, "status": null, "telemetry": {"air_util_tx": 0.45, "battery_level": 21, "channel_utilization": 17.31, "uptime_seconds": 26019, "voltage": 3.489}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1013.55, "iaq": 30, "relative_humidity": 23.6, "temperature": 27.16}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1983, "long_name": "River Owl", "next_hop": 0, "num": "0x7b5706e0", "position": {"altitude": 1262, "latitude": 33.148758, "location_source": "LOC_INTERNAL", "longitude": -107.168502, "time_offset_sec": 2160}, "public_key_hex": "de536307bb9ce4f16777a0664c923060e5c4c29f8e453895c215484255b44895", "role": "CLIENT", "short_name": "RA4Q", "snr": -0.94, "status": null, "telemetry": {"air_util_tx": 0.411, "battery_level": 32, "channel_utilization": 3.0, "uptime_seconds": 48878, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 1394, "long_name": "Dawn Viper", "next_hop": 247, "num": "0x7b8f51bb", "position": {"altitude": 1590, "latitude": 33.303881, "location_source": "LOC_INTERNAL", "longitude": -106.965852, "time_offset_sec": 1486}, "public_key_hex": "889c88f4ad1bb3f0653dacddce2d99b2aff03929ed3bc1de225850421487fa81", "role": "TRACKER", "short_name": "DTRC", "snr": 8.9, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.414, "battery_level": 83, "channel_utilization": 19.71, "uptime_seconds": 153407, "voltage": 4.047}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4792, "long_name": "Loud Cactus", "next_hop": 96, "num": "0x7b9e27d7", "position": null, "public_key_hex": "3b42a41ce26b65655603b2d7eb8e57eff01e98215d81f00a6ba24c0bbd0169dd", "role": "ROUTER", "short_name": "LHMA", "snr": 0.31, "status": null, "telemetry": {"air_util_tx": 1.121, "battery_level": 79, "channel_utilization": 24.61, "uptime_seconds": 159818, "voltage": 4.011}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.6, "iaq": 66, "relative_humidity": 80.68, "temperature": 9.46}, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1211, "long_name": "Shady Lion", "next_hop": 0, "num": "0x7bb76c91", "position": {"altitude": 1336, "latitude": 33.839371, "location_source": "LOC_INTERNAL", "longitude": -106.790415, "time_offset_sec": 1390}, "public_key_hex": "3e25ffe0adc1a3bccd8c093a26ec37a7266d24fce270ee5f1efdcc8b33a1f04c", "role": "CLIENT", "short_name": "SS0C", "snr": 8.36, "status": null, "telemetry": {"air_util_tx": 0.326, "battery_level": 66, "channel_utilization": 11.8, "uptime_seconds": 26420, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 5253, "long_name": "Quick Mustang", "next_hop": 3, "num": "0x7bbc3074", "position": {"altitude": 1318, "latitude": 33.487101, "location_source": "LOC_INTERNAL", "longitude": -107.373752, "time_offset_sec": 5530}, "public_key_hex": "8e3b3b196c82734563f30de52a8b7de72dcafa006110fd154d6553acd1fbe45c", "role": "CLIENT", "short_name": "Q7DG", "snr": -0.85, "status": {"status": "online"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1202, "long_name": "Floating Mole NM2RR", "next_hop": 0, "num": "0x7bcb2fef", "position": {"altitude": 1440, "latitude": 32.995202, "location_source": "LOC_INTERNAL", "longitude": -106.815054, "time_offset_sec": 1357}, "public_key_hex": "86e6149f210a90219e487babc991ae1f75036ef291af09833bad28a7a0ee9234", "role": "CLIENT", "short_name": "FSPL", "snr": 4.48, "status": null, "telemetry": {"air_util_tx": 0.465, "battery_level": 73, "channel_utilization": 25.14, "uptime_seconds": 97668, "voltage": 3.957}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5129, "long_name": "Brave Owl", "next_hop": 191, "num": "0x7c2834a1", "position": {"altitude": 1507, "latitude": 33.510827, "location_source": "LOC_INTERNAL", "longitude": -106.496796, "time_offset_sec": 5385}, "public_key_hex": "36ca03c25a139a255412e117b0a5c4c0a29c489b77a1d60ae428ed21170c9e54", "role": "TAK", "short_name": "BCML", "snr": 7.42, "status": {"status": "online"}, "telemetry": {"air_util_tx": 1.517, "battery_level": 12, "channel_utilization": 22.92, "uptime_seconds": 140039, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": {"barometric_pressure": 1011.85, "iaq": 112, "relative_humidity": 67.55, "temperature": 21.67}, "hops_away": 0, "hw_model": "T_ECHO_PLUS", "last_heard_offset_sec": 2727, "long_name": "Misty Cedar", "next_hop": 0, "num": "0x7c4180f2", "position": {"altitude": 1087, "latitude": 32.791397, "location_source": "LOC_INTERNAL", "longitude": -107.368597, "time_offset_sec": 2803}, "public_key_hex": "fe6c3a2c005d131d07a1d05b80ecaedbfb38ba4fd14eccd8a30745702dad9d61", "role": "CLIENT", "short_name": "MV0F", "snr": 7.42, "status": null, "telemetry": {"air_util_tx": 0.135, "battery_level": 38, "channel_utilization": 9.34, "uptime_seconds": 135744, "voltage": 3.642}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WSL_V3", "last_heard_offset_sec": 10184, "long_name": "Drowsy Otter", "next_hop": 141, "num": "0x7c6c77fe", "position": {"altitude": 1519, "latitude": 33.928758, "location_source": "LOC_INTERNAL", "longitude": -108.298896, "time_offset_sec": 10232}, "public_key_hex": "d255663c68ac35333554a6e5af4de3a3f876596ac64a49414511fb3bd954bdf0", "role": "ROUTER", "short_name": "DI39", "snr": 9.1, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.16, "iaq": 83, "relative_humidity": 99.67, "temperature": 7.59}, "hops_away": 1, "hw_model": "CROWPANEL", "last_heard_offset_sec": 2414, "long_name": "Silver Elk", "next_hop": 95, "num": "0x7c75da2c", "position": {"altitude": 1657, "latitude": 33.068464, "location_source": "LOC_INTERNAL", "longitude": -107.205365, "time_offset_sec": 2632}, "public_key_hex": "9d5e2f35dd9007057441e60dadeb349955eea41fec3c43e41a7e521c3c486354", "role": "CLIENT", "short_name": "🐢", "snr": 10.11, "status": null, "telemetry": {"air_util_tx": 0.928, "battery_level": 13, "channel_utilization": 13.11, "uptime_seconds": 135310, "voltage": 3.417}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4371, "long_name": "Wandering Crane", "next_hop": 29, "num": "0x7c8349d2", "position": {"altitude": 1534, "latitude": 32.827511, "location_source": "LOC_INTERNAL", "longitude": -106.253999, "time_offset_sec": 4412}, "public_key_hex": "f7b8b7963674f7db03db45ab2b91894f135c715fc275ba8f67e4b6ceb2f868f0", "role": "ROUTER", "short_name": "WT7U", "snr": -0.02, "status": null, "telemetry": {"air_util_tx": 0.404, "battery_level": 70, "channel_utilization": 12.48, "uptime_seconds": 53323, "voltage": 3.93}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.48, "iaq": 0, "relative_humidity": 59.48, "temperature": 28.81}, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 1265, "long_name": "Short Cougar", "next_hop": 91, "num": "0x7c912547", "position": null, "public_key_hex": "b4115b993aa20cc2286bb341f2a1d5a51a16b47bf29f162421eb48ac2a5f85ea", "role": "ROUTER", "short_name": "SM2Z", "snr": 1.75, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 5110, "long_name": "Sunny Cedar", "next_hop": 85, "num": "0x7c9da5e9", "position": null, "public_key_hex": "e803b3d092a6104cc97950391c138722c9e5228fea9d2316e12193ba6dcc416f", "role": "CLIENT", "short_name": "SPDV", "snr": 5.53, "status": null, "telemetry": {"air_util_tx": 0.623, "battery_level": 84, "channel_utilization": 7.45, "uptime_seconds": 129241, "voltage": 4.056}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2063, "long_name": "Wandering Marmot", "next_hop": 136, "num": "0x7cb1267f", "position": null, "public_key_hex": "3b1d1fb4216885f169504b89b4b144825f30f65d03cffaf495999708ab060431", "role": "CLIENT", "short_name": "W6Y9", "snr": 2.71, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": true, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "T_DECK_PRO", "last_heard_offset_sec": 1800, "long_name": "Giant Whale", "next_hop": 191, "num": "0x7cb77b0d", "position": {"altitude": 894, "latitude": 31.900226, "location_source": "LOC_INTERNAL", "longitude": -107.543439, "time_offset_sec": 2047}, "public_key_hex": "534796c66c801334849aae3505e8e03dd00f486be52981e5b6a9c36fc2069ed9", "role": "CLIENT", "short_name": "GCAX", "snr": 4.91, "status": {"status": "weak-signal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 4799, "long_name": "Forest Crow", "next_hop": 0, "num": "0x7cbbaa43", "position": null, "public_key_hex": "b1366c31de4f62463128ff5a4fd3feca117ad09e1eb0a16db6d745bf9599bb4a", "role": "CLIENT", "short_name": "F1N4", "snr": 4.31, "status": null, "telemetry": {"air_util_tx": 0.79, "battery_level": 55, "channel_utilization": 5.89, "uptime_seconds": 30450, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_VISION_MASTER_E213", "last_heard_offset_sec": 4505, "long_name": "Copper Stag", "next_hop": 209, "num": "0x7ccac263", "position": {"altitude": 1212, "latitude": 32.880439, "location_source": "LOC_INTERNAL", "longitude": -107.234728, "time_offset_sec": 4788}, "public_key_hex": "0f4a6902d506dd18b0abc8076fc5b7a363e17384a7d506e0f7aad009d98f9db0", "role": "CLIENT", "short_name": "CRDX", "snr": 3.3, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1090, "long_name": "Storm Juniper", "next_hop": 0, "num": "0x7ccd22c4", "position": null, "public_key_hex": "0fbd6d692397be18a7c12252014a072ccb6469c530f56830e4949f460dcd01b1", "role": "CLIENT", "short_name": "🌙", "snr": 9.21, "status": null, "telemetry": {"air_util_tx": 1.815, "battery_level": 88, "channel_utilization": 13.0, "uptime_seconds": 11869, "voltage": 4.092}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP_V2", "last_heard_offset_sec": 1798, "long_name": "Forest Iguana", "next_hop": 0, "num": "0x7ce7a718", "position": {"altitude": 1486, "latitude": 33.267005, "location_source": "LOC_INTERNAL", "longitude": -107.484449, "time_offset_sec": 2083}, "public_key_hex": "bf56781bb84c89aa4e475d034d8a63085dd53bb6afbbcd71bc8115ff41e284e0", "role": "CLIENT_MUTE", "short_name": "F2E5", "snr": 6.88, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 2.524, "battery_level": 78, "channel_utilization": 14.63, "uptime_seconds": 575, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 1724, "long_name": "Misty Moose", "next_hop": 0, "num": "0x7d0c8fde", "position": {"altitude": 1349, "latitude": 32.703875, "location_source": "LOC_INTERNAL", "longitude": -107.015774, "time_offset_sec": 1886}, "public_key_hex": "8258d729c13eeaa4aff821ef621126de2951b2eb78c7ca46028271541891759b", "role": "CLIENT", "short_name": "MF3O", "snr": 1.86, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 7968, "long_name": "Found Gecko", "next_hop": 0, "num": "0x7d240b67", "position": {"altitude": 1273, "latitude": 32.67533, "location_source": "LOC_INTERNAL", "longitude": -107.212128, "time_offset_sec": 7974}, "public_key_hex": "9494d2f5515c1363042d00c629e8495aa57169abad6eb20187128a525a47c94b", "role": "ROUTER_LATE", "short_name": "F18A", "snr": 0.58, "status": {"status": "running"}, "telemetry": {"air_util_tx": 1.218, "battery_level": 32, "channel_utilization": 0.47, "uptime_seconds": 56446, "voltage": 3.588}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 2037, "long_name": "Rough Shark", "next_hop": 16, "num": "0x7d37f147", "position": {"altitude": 1763, "latitude": 32.627928, "location_source": "LOC_INTERNAL", "longitude": -107.483284, "time_offset_sec": 2043}, "public_key_hex": "0668e74b47ab00ba735b62d478d72c5c2f91d83413a732b4f11c8f99d04112a4", "role": "ROUTER", "short_name": "R123", "snr": 6.14, "status": {"status": "ready"}, "telemetry": {"air_util_tx": 0.262, "battery_level": 12, "channel_utilization": 20.7, "uptime_seconds": 259469, "voltage": 3.408}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 6818, "long_name": "Black Badger", "next_hop": 65, "num": "0x7d43e5a2", "position": {"altitude": 1314, "latitude": 32.610887, "location_source": "LOC_INTERNAL", "longitude": -106.833615, "time_offset_sec": 6938}, "public_key_hex": "", "role": "CLIENT", "short_name": "BKQE", "snr": 5.62, "status": {"status": "nominal"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_SOLAR", "last_heard_offset_sec": 2144, "long_name": "Short Cedar", "next_hop": 19, "num": "0x7d524cfb", "position": {"altitude": 1665, "latitude": 33.016637, "location_source": "LOC_INTERNAL", "longitude": -107.093367, "time_offset_sec": 2278}, "public_key_hex": "2e705cd0cf60210e9226474854b3268e7ff56cfdf52968e5d8d16b6dc03c6676", "role": "CLIENT", "short_name": "🐢", "snr": 4.51, "status": null, "telemetry": {"air_util_tx": 2.398, "battery_level": 16, "channel_utilization": 12.14, "uptime_seconds": 49720, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 1233, "long_name": "Brave Ridge", "next_hop": 0, "num": "0x7d83ed4d", "position": null, "public_key_hex": "c81bc161ea87ecf3deae1fe273dae298aaf5c7b29e95b706a3c6053d06b670c2", "role": "CLIENT", "short_name": "BA9L", "snr": 2.62, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 5560, "long_name": "Frozen Eagle", "next_hop": 0, "num": "0x7d87d803", "position": {"altitude": 1042, "latitude": 32.97672, "location_source": "LOC_INTERNAL", "longitude": -107.941052, "time_offset_sec": 5643}, "public_key_hex": "0ac07186b10b023d039351c6ec0fa9e9932bbc7aebf2c1418e03eee42e988730", "role": "CLIENT", "short_name": "FLFN", "snr": 5.73, "status": {"status": "ready"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1028.92, "iaq": 23, "relative_humidity": 48.98, "temperature": 17.35}, "hops_away": 1, "hw_model": "SEEED_WIO_TRACKER_L1", "last_heard_offset_sec": 8143, "long_name": "Howling Viper", "next_hop": 177, "num": "0x7db072d2", "position": {"altitude": 1216, "latitude": 33.317154, "location_source": "LOC_INTERNAL", "longitude": -106.370049, "time_offset_sec": 8404}, "public_key_hex": "6f6720c4979f08e8881ae2373d480c6f2f9e8d4c2421a998ca186fe270de0ca9", "role": "CLIENT", "short_name": "H8H7", "snr": 10.14, "status": {"status": "running"}, "telemetry": {"air_util_tx": 0.168, "battery_level": 55, "channel_utilization": 2.77, "uptime_seconds": 38043, "voltage": 3.795}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": {"barometric_pressure": 1023.87, "iaq": 18, "relative_humidity": 79.81, "temperature": 28.15}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 17470, "long_name": "Lunar Lion KQ8KH", "next_hop": 0, "num": "0x7db6335c", "position": {"altitude": 1418, "latitude": 32.021148, "location_source": "LOC_INTERNAL", "longitude": -107.179852, "time_offset_sec": 17542}, "public_key_hex": "", "role": "CLIENT", "short_name": "LS2L", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.108, "battery_level": 59, "channel_utilization": 20.13, "uptime_seconds": 25256, "voltage": 3.831}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 1979, "long_name": "Forest Whale", "next_hop": 0, "num": "0x7db6b5a2", "position": {"altitude": 1176, "latitude": 33.306947, "location_source": "LOC_INTERNAL", "longitude": -106.215732, "time_offset_sec": 2269}, "public_key_hex": "959987444a1a8771b76afa98aa09f0fe1e14d9ebf9aac41f65263f1934ac5444", "role": "CLIENT", "short_name": "FX6X", "snr": -0.0, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": true, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "T_DECK", "last_heard_offset_sec": 2347, "long_name": "Desert Bison", "next_hop": 191, "num": "0x7de1770d", "position": {"altitude": 1495, "latitude": 32.801493, "location_source": "LOC_INTERNAL", "longitude": -107.415761, "time_offset_sec": 2639}, "public_key_hex": "df05878828574814c9509f458880662742d220d40fd2bd85f83bf77d67c0ecaa", "role": "CLIENT", "short_name": "D3DC", "snr": 11.86, "status": null, "telemetry": {"air_util_tx": 1.328, "battery_level": 75, "channel_utilization": 12.98, "uptime_seconds": 33726, "voltage": 3.975}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1990, "long_name": "Sneaky Crow", "next_hop": 0, "num": "0x7de92140", "position": {"altitude": 1153, "latitude": 33.080012, "location_source": "LOC_INTERNAL", "longitude": -108.73241, "time_offset_sec": 2059}, "public_key_hex": "1f9cb8512ef03e8851927d00fddd4d80cd2015a23c8b64a5c7f98322f86f46a1", "role": "CLIENT_BASE", "short_name": "SC8O", "snr": 12.0, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.389, "battery_level": 101, "channel_utilization": 2.03, "uptime_seconds": 169595, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 5, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 11897, "long_name": "Bright Mamba", "next_hop": 0, "num": "0x7df50e4f", "position": {"altitude": 1270, "latitude": 32.68394, "location_source": "LOC_INTERNAL", "longitude": -106.847595, "time_offset_sec": 11990}, "public_key_hex": "ee290f0a5d7555209a63b02e3ac4d2fd1aa18f2c2f9661b0ed2ed7fc3d19504b", "role": "CLIENT", "short_name": "BFAG", "snr": 12.0, "status": {"status": "OK"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 496, "long_name": "Quick Trout", "next_hop": 0, "num": "0x7e0f696e", "position": {"altitude": 2161, "latitude": 32.848465, "location_source": "LOC_INTERNAL", "longitude": -106.372037, "time_offset_sec": 666}, "public_key_hex": "cd043f3cad89fe0a91569690d7a5e75f63ba28a18787ca7623564cd77368cece", "role": "SENSOR", "short_name": "QXDK", "snr": 1.77, "status": null, "telemetry": {"air_util_tx": 0.285, "battery_level": 95, "channel_utilization": 7.26, "uptime_seconds": 166665, "voltage": 4.155}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "SEEED_SOLAR_NODE", "last_heard_offset_sec": 2942, "long_name": "Fast Aspen KQ5RW", "next_hop": 0, "num": "0x7e10c153", "position": {"altitude": 1169, "latitude": 32.970169, "location_source": "LOC_INTERNAL", "longitude": -106.444973, "time_offset_sec": 2988}, "public_key_hex": "f1ac621ef3221eb8f83804ba15e53ed4f2e46ac546ba8e875ded0d38b242ee74", "role": "CLIENT", "short_name": "FVVN", "snr": 1.98, "status": null, "telemetry": {"air_util_tx": 1.064, "battery_level": 18, "channel_utilization": 8.29, "uptime_seconds": 26103, "voltage": 3.462}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 4, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 5251, "long_name": "Hidden Squirrel KQ1WE", "next_hop": 110, "num": "0x7e1ceb6e", "position": {"altitude": 1045, "latitude": 33.619297, "location_source": "LOC_INTERNAL", "longitude": -107.0679, "time_offset_sec": 5481}, "public_key_hex": "070360d41fe9cb7ab4e74858ad83a34ae8a6effb8b9c3514a75a4f6b5c27d5c0", "role": "CLIENT", "short_name": "🌙", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.445, "battery_level": 101, "channel_utilization": 3.6, "uptime_seconds": 16823, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 3159, "long_name": "Smooth Falcon K11UJ", "next_hop": 0, "num": "0x7e25a4ec", "position": {"altitude": 1340, "latitude": 33.240916, "location_source": "LOC_INTERNAL", "longitude": -107.751982, "time_offset_sec": 3414}, "public_key_hex": "32dd192ee003dc5b47f2c7f1c53accdbed0612c625d556cbfb2bfe56bef5946e", "role": "CLIENT", "short_name": "SX82", "snr": 5.95, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 8367, "long_name": "Drowsy Beaver", "next_hop": 0, "num": "0x7e2aa008", "position": {"altitude": 1236, "latitude": 33.478097, "location_source": "LOC_INTERNAL", "longitude": -106.916624, "time_offset_sec": 8536}, "public_key_hex": "07fed775e7d375a1b511ede84df9a348909a14e8dc63f7ab8215caf649a04431", "role": "CLIENT", "short_name": "D7GN", "snr": 9.88, "status": null, "telemetry": {"air_util_tx": 0.526, "battery_level": 52, "channel_utilization": 16.23, "uptime_seconds": 111303, "voltage": 3.768}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 6884, "long_name": "Forest Wolf", "next_hop": 0, "num": "0x7e39de5b", "position": {"altitude": 1615, "latitude": 33.602365, "location_source": "LOC_INTERNAL", "longitude": -107.632906, "time_offset_sec": 6983}, "public_key_hex": "bf894c78c050468cbf4a342c1957743fb7ba23d02ef847b77d019db3a84a5104", "role": "ROUTER", "short_name": "F166", "snr": 8.35, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.51, "battery_level": 36, "channel_utilization": 5.34, "uptime_seconds": 68196, "voltage": 3.624}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1012.57, "iaq": 41, "relative_humidity": 52.78, "temperature": 20.4}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 406, "long_name": "Happy Tortoise", "next_hop": 0, "num": "0x7e3ae1c9", "position": {"altitude": 1520, "latitude": 33.068758, "location_source": "LOC_INTERNAL", "longitude": -107.676538, "time_offset_sec": 513}, "public_key_hex": "6e92bd72767c173fc0e2a7c83339dea44ec2a61f2c97956ea1c63211e7102453", "role": "CLIENT", "short_name": "HTSG", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.57, "battery_level": 53, "channel_utilization": 16.9, "uptime_seconds": 31831, "voltage": 3.777}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 3229, "long_name": "Copper Otter", "next_hop": 0, "num": "0x7e3c64c3", "position": {"altitude": 1685, "latitude": 32.70481, "location_source": "LOC_INTERNAL", "longitude": -107.199366, "time_offset_sec": 3343}, "public_key_hex": "5cae9ad585cbc2dee1337890d36d3cdc867df156fba578b380c6216d6d3a444f", "role": "CLIENT", "short_name": "CEG6", "snr": 7.6, "status": {"status": "online"}, "telemetry": {"air_util_tx": 0.443, "battery_level": 76, "channel_utilization": 10.97, "uptime_seconds": 27748, "voltage": 3.984}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.67, "iaq": 62, "relative_humidity": 13.17, "temperature": 12.83}, "hops_away": 0, "hw_model": "T_ECHO", "last_heard_offset_sec": 9713, "long_name": "New Shark", "next_hop": 0, "num": "0x7e457813", "position": {"altitude": 1035, "latitude": 32.749356, "location_source": "LOC_INTERNAL", "longitude": -107.784488, "time_offset_sec": 9765}, "public_key_hex": "e9eabc49066daf7464d426537d843ae969131bc7703d3ccdc8f9c8f03782e202", "role": "CLIENT", "short_name": "N6LO", "snr": -0.65, "status": {"status": "nominal"}, "telemetry": {"air_util_tx": 0.278, "battery_level": 35, "channel_utilization": 15.51, "uptime_seconds": 95425, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V4", "last_heard_offset_sec": 8145, "long_name": "Tall Marmot", "next_hop": 0, "num": "0x7e45eb9e", "position": {"altitude": 1081, "latitude": 33.575633, "location_source": "LOC_INTERNAL", "longitude": -107.443789, "time_offset_sec": 8325}, "public_key_hex": "c4cab455f9b7bcd86b9ce984157217e4e571cc8f1e634f515db72c16081c48cc", "role": "ROUTER", "short_name": "TPSU", "snr": 7.68, "status": {"status": "no-gps"}, "telemetry": {"air_util_tx": 0.685, "battery_level": 68, "channel_utilization": 1.28, "uptime_seconds": 96747, "voltage": 3.912}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M2", "last_heard_offset_sec": 2953, "long_name": "Canyon Bronco", "next_hop": 0, "num": "0x7e4ac705", "position": {"altitude": 1445, "latitude": 33.769693, "location_source": "LOC_INTERNAL", "longitude": -107.777546, "time_offset_sec": 3095}, "public_key_hex": "9213c87d01f460bf9af69d42472ebdda7739ca17e88eb3fda5a00934a7b8cc4b", "role": "CLIENT", "short_name": "CRCE", "snr": 10.4, "status": null, "telemetry": {"air_util_tx": 0.865, "battery_level": 49, "channel_utilization": 16.74, "uptime_seconds": 3274, "voltage": 3.741}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "T_DECK", "last_heard_offset_sec": 4801, "long_name": "Smooth Hawk", "next_hop": 0, "num": "0x7e4ae324", "position": {"altitude": 1765, "latitude": 32.298208, "location_source": "LOC_INTERNAL", "longitude": -107.347678, "time_offset_sec": 4872}, "public_key_hex": "2322cb267f7e8364b1b7d3d439f1f25658c7b1c3abc211fc5e37ff3ebc87baf2", "role": "CLIENT", "short_name": "SG89", "snr": 0.18, "status": null, "telemetry": {"air_util_tx": 0.372, "battery_level": 14, "channel_utilization": 7.17, "uptime_seconds": 76602, "voltage": 3.426}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "XIAO_NRF52_KIT", "last_heard_offset_sec": 936, "long_name": "Rough Stag", "next_hop": 0, "num": "0x7e4da4e0", "position": {"altitude": 1513, "latitude": 33.754644, "location_source": "LOC_INTERNAL", "longitude": -106.802073, "time_offset_sec": 1131}, "public_key_hex": "325fbf639cce69776aad0e18b484e94c7d54c8a2a48840bb1e6c1634c9622994", "role": "CLIENT", "short_name": "RIKW", "snr": 12.0, "status": null, "telemetry": {"air_util_tx": 0.991, "battery_level": 23, "channel_utilization": 11.28, "uptime_seconds": 33228, "voltage": 3.507}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 7613, "long_name": "White Owl KE7YN", "next_hop": 159, "num": "0x7e57b3a0", "position": {"altitude": 1710, "latitude": 32.865061, "location_source": "LOC_INTERNAL", "longitude": -107.906595, "time_offset_sec": 7789}, "public_key_hex": "317813f606c1ef14ff11b2fb53abcdff2d66ebf020c5d6817d696c264942f5e2", "role": "ROUTER", "short_name": "W9E7", "snr": 6.77, "status": null, "telemetry": {"air_util_tx": 0.401, "battery_level": 35, "channel_utilization": 16.63, "uptime_seconds": 218402, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1019.51, "iaq": 74, "relative_humidity": 75.06, "temperature": 26.4}, "hops_away": 2, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 5571, "long_name": "Silent Bluff", "next_hop": 166, "num": "0x7e679336", "position": {"altitude": 1150, "latitude": 34.587061, "location_source": "LOC_INTERNAL", "longitude": -107.353402, "time_offset_sec": 5769}, "public_key_hex": "684a094d251846364647b853c74a3b8229b7a234df92296906b7a66fa738ff45", "role": "CLIENT", "short_name": "SCOV", "snr": 6.4, "status": null, "telemetry": {"air_util_tx": 0.138, "battery_level": 99, "channel_utilization": 16.47, "uptime_seconds": 36681, "voltage": 4.191}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 7, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 5019, "long_name": "Smooth Squirrel", "next_hop": 219, "num": "0x7e7774a3", "position": {"altitude": 1230, "latitude": 32.56684, "location_source": "LOC_INTERNAL", "longitude": -107.610717, "time_offset_sec": 5267}, "public_key_hex": "1c7b1c7526b343353c74e75a7da415ab4f7f5169495d09e873481ffb51ed2cce", "role": "CLIENT", "short_name": "SEG5", "snr": 1.64, "status": null, "telemetry": {"air_util_tx": 0.271, "battery_level": 72, "channel_utilization": 2.05, "uptime_seconds": 82, "voltage": 3.948}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 5, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 11998, "long_name": "Blue Falcon", "next_hop": 219, "num": "0x7e7ecdb9", "position": {"altitude": 1558, "latitude": 31.970862, "location_source": "LOC_INTERNAL", "longitude": -106.286475, "time_offset_sec": 12150}, "public_key_hex": "536f9f5ecbc0ef31b6db90bed772eed6d293c02eff65c2dc96dff6352ea11f53", "role": "SENSOR", "short_name": "BFGA", "snr": 8.12, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "THINKNODE_M5", "last_heard_offset_sec": 1763, "long_name": "Wild Elk", "next_hop": 0, "num": "0x7e863b0a", "position": {"altitude": 1773, "latitude": 33.199477, "location_source": "LOC_INTERNAL", "longitude": -108.005438, "time_offset_sec": 1948}, "public_key_hex": "163b04ecc42be2924a37e37578fa2e30d70716743740cb7cfd7642e55323a732", "role": "CLIENT", "short_name": "W9XE", "snr": 1.23, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "WISMESH_TAP", "last_heard_offset_sec": 2286, "long_name": "Loud Fox", "next_hop": 0, "num": "0x7e9aaa2a", "position": {"altitude": 1102, "latitude": 33.36937, "location_source": "LOC_INTERNAL", "longitude": -108.133834, "time_offset_sec": 2549}, "public_key_hex": "2442a39553a1ca643b036b823d2a18ebf1703a42c72d54516d4a7ce1ca2511a9", "role": "CLIENT", "short_name": "LL2B", "snr": 5.78, "status": null, "telemetry": {"air_util_tx": 0.107, "battery_level": 35, "channel_utilization": 2.51, "uptime_seconds": 46567, "voltage": 3.615}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 4, "environment": {"barometric_pressure": 1015.42, "iaq": 75, "relative_humidity": 38.71, "temperature": 4.93}, "hops_away": 1, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 755, "long_name": "Black Trout", "next_hop": 159, "num": "0x7e9dd91c", "position": {"altitude": 1107, "latitude": 33.120696, "location_source": "LOC_INTERNAL", "longitude": -106.779394, "time_offset_sec": 978}, "public_key_hex": "fe3547c35ac8a16f3925796bf097187310eceeca1a1c3e9fbf48951d69fd0818", "role": "CLIENT", "short_name": "B5PP", "snr": 0.4, "status": null, "telemetry": {"air_util_tx": 1.01, "battery_level": 69, "channel_utilization": 26.89, "uptime_seconds": 126373, "voltage": 3.921}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.85, "iaq": 39, "relative_humidity": 53.21, "temperature": 19.09}, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER", "last_heard_offset_sec": 9487, "long_name": "Silver Bear", "next_hop": 0, "num": "0x7ea27020", "position": {"altitude": 1153, "latitude": 33.001359, "location_source": "LOC_INTERNAL", "longitude": -108.297352, "time_offset_sec": 9541}, "public_key_hex": "7e5fcadd9fe90b70e7b7ebeb90675585b53749ed0623aecfd49852c534b932a2", "role": "CLIENT", "short_name": "S8RW", "snr": 4.21, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "LILYGO_TBEAM_S3_CORE", "last_heard_offset_sec": 99, "long_name": "Drowsy Raven", "next_hop": 0, "num": "0x7ebb1660", "position": {"altitude": 1218, "latitude": 33.409171, "location_source": "LOC_INTERNAL", "longitude": -108.403863, "time_offset_sec": 104}, "public_key_hex": "e8d0f8d5b545f3a91a8803adaffa032dd709054fbf7044304fa3d8008439753d", "role": "CLIENT", "short_name": "DW54", "snr": 3.75, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 3, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 8632, "long_name": "Solar Whale", "next_hop": 48, "num": "0x7ef116f0", "position": {"altitude": 1218, "latitude": 32.252643, "location_source": "LOC_INTERNAL", "longitude": -106.928019, "time_offset_sec": 8812}, "public_key_hex": "662ab4a9cca9bc059807fd43e3f47b3fb5fe61f1ef7201ec6e1d645ea5b249ab", "role": "CLIENT", "short_name": "S5F2", "snr": 1.76, "status": {"status": "rebooted"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 2128, "long_name": "Blue Pike", "next_hop": 0, "num": "0x7ef43fd6", "position": {"altitude": 1422, "latitude": 34.239619, "location_source": "LOC_INTERNAL", "longitude": -107.718846, "time_offset_sec": 2235}, "public_key_hex": "bb490267bec750080534694f03501c1125db0cac12ebf700af6d6bae28e9fcdc", "role": "CLIENT", "short_name": "BZ99", "snr": 7.8, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4433, "long_name": "Bright Pike", "next_hop": 0, "num": "0x7f15d319", "position": {"altitude": 1620, "latitude": 33.188179, "location_source": "LOC_INTERNAL", "longitude": -107.577278, "time_offset_sec": 4478}, "public_key_hex": "0bc5e1d93551b2b8c24c04b002f815f83a1b974b7f3b60d39c060e48e4f03927", "role": "CLIENT_BASE", "short_name": "🦌", "snr": 7.32, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": true, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 2251, "long_name": "Sleepy Iguana WD1XR", "next_hop": 0, "num": "0x7f1ab4a4", "position": {"altitude": 1416, "latitude": 32.937367, "location_source": "LOC_INTERNAL", "longitude": -106.36242, "time_offset_sec": 2313}, "public_key_hex": "8c50ecda09d3c993f988a3dd130cbb2601bb78b5d66869466b66ad80cf229b02", "role": "CLIENT_BASE", "short_name": "SFCM", "snr": 5.18, "status": null, "telemetry": {"air_util_tx": 0.242, "battery_level": 65, "channel_utilization": 12.22, "uptime_seconds": 17880, "voltage": 3.885}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 1470, "long_name": "Dusk Pike", "next_hop": 1, "num": "0x7f1cea13", "position": null, "public_key_hex": "9c475523c7c57eba54d07ebc05b4f17d94f8e468cdc0089ea7bcbee6b01eea91", "role": "CLIENT_MUTE", "short_name": "D45Z", "snr": 2.69, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.324, "battery_level": 22, "channel_utilization": 7.62, "uptime_seconds": 78019, "voltage": 3.498}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 2720, "long_name": "Burning Cedar", "next_hop": 0, "num": "0x7f271a2d", "position": {"altitude": 1243, "latitude": 32.761192, "location_source": "LOC_INTERNAL", "longitude": -106.925351, "time_offset_sec": 2805}, "public_key_hex": "da1f73a0d7ea7c13924053dfe1e3805596edea2e5d4f4ae250ffc5997a1e788f", "role": "CLIENT", "short_name": "B0FC", "snr": 3.22, "status": {"status": "active"}, "telemetry": {"air_util_tx": 0.07, "battery_level": 10, "channel_utilization": 10.35, "uptime_seconds": 3961, "voltage": 3.39}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_MESH_NODE_T114", "last_heard_offset_sec": 4158, "long_name": "Tall Lynx", "next_hop": 0, "num": "0x7f27549f", "position": null, "public_key_hex": "f9aca136896075bb795ee322fb4181a9ff72993e66fec544d395f5dbacbc0665", "role": "CLIENT", "short_name": "TL71", "snr": -1.42, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "TLORA_T3_S3", "last_heard_offset_sec": 2562, "long_name": "Lost Stag", "next_hop": 0, "num": "0x7f389096", "position": {"altitude": 1494, "latitude": 34.1985, "location_source": "LOC_INTERNAL", "longitude": -107.313806, "time_offset_sec": 2778}, "public_key_hex": "", "role": "CLIENT", "short_name": "LPUZ", "snr": 5.95, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_V3", "last_heard_offset_sec": 6998, "long_name": "Red Falcon", "next_hop": 0, "num": "0x7f38df24", "position": {"altitude": 1475, "latitude": 32.71871, "location_source": "LOC_INTERNAL", "longitude": -105.974505, "time_offset_sec": 7267}, "public_key_hex": "f88cf3d04f5fbb28936f38659dd43c6d7cb111c120f74907abb2d35bece5a39b", "role": "ROUTER_LATE", "short_name": "R5X6", "snr": 1.19, "status": null, "telemetry": {"air_util_tx": 0.276, "battery_level": 78, "channel_utilization": 2.89, "uptime_seconds": 58358, "voltage": 4.002}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 1356, "long_name": "River Seal", "next_hop": 0, "num": "0x7f3fb96e", "position": {"altitude": 1469, "latitude": 33.695602, "location_source": "LOC_INTERNAL", "longitude": -106.586824, "time_offset_sec": 1449}, "public_key_hex": "04611c6db6294be96003f445371c366a622b640575894e2e956bdc372aca4911", "role": "CLIENT", "short_name": "RVJT", "snr": 1.0, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.34, "battery_level": 15, "channel_utilization": 11.1, "uptime_seconds": 15197, "voltage": 3.435}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": true, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 996.52, "iaq": 85, "relative_humidity": 41.9, "temperature": 21.44}, "hops_away": 0, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 130, "long_name": "River Lion", "next_hop": 0, "num": "0x7f602986", "position": {"altitude": 1134, "latitude": 33.489073, "location_source": "LOC_INTERNAL", "longitude": -107.320602, "time_offset_sec": 309}, "public_key_hex": "5aecd9b7d58e03543891ca5055306fd4f3f9b624dcd3449f29bb46edb8ea741d", "role": "CLIENT", "short_name": "RWF0", "snr": 5.19, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "HELTEC_WIRELESS_TRACKER_V2", "last_heard_offset_sec": 4606, "long_name": "Drowsy Moose", "next_hop": 0, "num": "0x7f819a7c", "position": {"altitude": 1212, "latitude": 32.828082, "location_source": "LOC_INTERNAL", "longitude": -107.246402, "time_offset_sec": 4726}, "public_key_hex": "8b06155fe33d00d00ba32fda5bbcb4ab1a2221b41433c64f87041441836219b6", "role": "CLIENT", "short_name": "D2TR", "snr": 2.8, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": {"barometric_pressure": 1015.02, "iaq": 21, "relative_humidity": 73.31, "temperature": 20.76}, "hops_away": 1, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 685, "long_name": "Storm Beaver", "next_hop": 79, "num": "0x7f93daee", "position": {"altitude": 1392, "latitude": 32.874819, "location_source": "LOC_INTERNAL", "longitude": -108.317781, "time_offset_sec": 870}, "public_key_hex": "29a9a5dc513bb7e3408fd4c9fcd9a141a82ce0919467f1d1686eb00c3ee2c8e6", "role": "CLIENT", "short_name": "SEVH", "snr": 7.39, "status": {"status": "OK"}, "telemetry": {"air_util_tx": 0.907, "battery_level": 101, "channel_utilization": 25.59, "uptime_seconds": 11816, "voltage": 4.2}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 0, "hw_model": "RAK4631", "last_heard_offset_sec": 6909, "long_name": "Hidden Hare", "next_hop": 0, "num": "0x7fadc7fd", "position": {"altitude": 1619, "latitude": 33.048983, "location_source": "LOC_INTERNAL", "longitude": -107.114932, "time_offset_sec": 7141}, "public_key_hex": "8e72a57f881651269f80f614ab94067c062db83ce4486958e9638a79f481b42b", "role": "CLIENT", "short_name": "HL1R", "snr": 3.36, "status": null, "telemetry": {"air_util_tx": 0.351, "battery_level": 86, "channel_utilization": 11.45, "uptime_seconds": 11602, "voltage": 4.074}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 6, "environment": {"barometric_pressure": 1021.02, "iaq": 15, "relative_humidity": 49.18, "temperature": 12.01}, "hops_away": 0, "hw_model": "TRACKER_T1000_E", "last_heard_offset_sec": 2164, "long_name": "Mountain Stag", "next_hop": 0, "num": "0x7fb68f95", "position": {"altitude": 1099, "latitude": 33.706879, "location_source": "LOC_INTERNAL", "longitude": -107.377268, "time_offset_sec": 2245}, "public_key_hex": "042463075a25de5c9bc76b71cc1618650e41f7b708d513b3ec62a93f9e96900c", "role": "CLIENT", "short_name": "M4UI", "snr": -1.44, "status": null, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_WIRELESS_PAPER", "last_heard_offset_sec": 3855, "long_name": "Happy Moose", "next_hop": 114, "num": "0x7fbf2145", "position": {"altitude": 1191, "latitude": 32.89753, "location_source": "LOC_INTERNAL", "longitude": -106.659903, "time_offset_sec": 4097}, "public_key_hex": "38d39ee7d661b8805aa93fbc748566fc61faad07f4a8f1c5bf8de58909b58ab4", "role": "CLIENT", "short_name": "HYWL", "snr": 6.43, "status": {"status": "offline-soon"}, "telemetry": {"air_util_tx": 0.4, "battery_level": 16, "channel_utilization": 5.18, "uptime_seconds": 61567, "voltage": 3.444}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 2, "hw_model": "T_DECK", "last_heard_offset_sec": 1163, "long_name": "Lunar Seal", "next_hop": 243, "num": "0x7fc1eb04", "position": {"altitude": 1605, "latitude": 34.007849, "location_source": "LOC_INTERNAL", "longitude": -107.266295, "time_offset_sec": 1296}, "public_key_hex": "264d5c9d8ebe6962dc595cb5e8d9b8a655f99c55afb09aaf609157e9e3d80622", "role": "CLIENT", "short_name": "L0I9", "snr": 10.66, "status": null, "telemetry": {"air_util_tx": 0.243, "battery_level": 42, "channel_utilization": 21.25, "uptime_seconds": 126048, "voltage": 3.678}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": true, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 7, "environment": null, "hops_away": 0, "hw_model": "CROWPANEL", "last_heard_offset_sec": 2999, "long_name": "Sunny Doe", "next_hop": 0, "num": "0x7fc7bb67", "position": {"altitude": 1250, "latitude": 33.506977, "location_source": "LOC_INTERNAL", "longitude": -108.019242, "time_offset_sec": 3029}, "public_key_hex": "72c08ce806adc52ed896d718b677304c11e41ab68fd44e063e56d28a4df1c459", "role": "CLIENT", "short_name": "S4H6", "snr": 8.38, "status": null, "telemetry": {"air_util_tx": 0.582, "battery_level": 66, "channel_utilization": 4.0, "uptime_seconds": 97473, "voltage": 3.894}} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": false}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "HELTEC_MESH_POCKET", "last_heard_offset_sec": 2976, "long_name": "Wild Doe", "next_hop": 55, "num": "0x7fdc4eb2", "position": {"altitude": 1642, "latitude": 32.895465, "location_source": "LOC_INTERNAL", "longitude": -107.155609, "time_offset_sec": 3082}, "public_key_hex": "13af0800835fa0c781d21c8a93ba763d40b0ca94b3e2f7571a7ec51e3aebf6d7", "role": "CLIENT", "short_name": "W74W", "snr": 6.51, "status": {"status": "running"}, "telemetry": null} +{"bitfield": {"has_is_unmessagable": true, "has_user": true, "is_favorite": false, "is_ignored": false, "is_key_manually_verified": false, "is_licensed": false, "is_muted": false, "is_unmessagable": false, "via_mqtt": true}, "channel": 0, "environment": null, "hops_away": 1, "hw_model": "RAK4631", "last_heard_offset_sec": 990, "long_name": "New Tortoise", "next_hop": 137, "num": "0x7ff4beec", "position": {"altitude": 1800, "latitude": 33.770824, "location_source": "LOC_INTERNAL", "longitude": -107.77609, "time_offset_sec": 1251}, "public_key_hex": "d24e34d1ed884a41c30a6a89fd906a00907842397aa409892cbd4f54db858960", "role": "ROUTER", "short_name": "🌊", "snr": 1.7, "status": null, "telemetry": null} From 29a61dc75cbaef2815ae167a621b26433859d547 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 12 May 2026 21:45:16 -0500 Subject: [PATCH 176/225] Fix type declaration for ambientLightingThread and correct uint32_t usage in PacketHistory --- src/main.cpp | 2 +- src/mesh/PacketHistory.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 2f4b12437c6..28842734c89 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -245,7 +245,7 @@ const char *getDeviceName() uint32_t timeLastPowered = 0; static OSThread *powerFSMthread; -OSThread *ambientLightingThread; +AmbientLightingThread *ambientLightingThread; RadioLibHal *RadioLibHAL = NULL; diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index b56ce57c9d6..f5c36b08340 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -9,8 +9,8 @@ #include "Throttle.h" #define PACKETHISTORY_MAX \ - max((u_int32_t)(MAX_NUM_NODES * 2.0), \ - (u_int32_t)100) // x2..3 Should suffice. Empirical setup. 16B per record malloc'ed, but no less than 100 + max((uint32_t)(MAX_NUM_NODES * 2.0), \ + (uint32_t)100) // x2..3 Should suffice. Empirical setup. 16B per record malloc'ed, but no less than 100 #define RECENT_WARN_AGE (10 * 60 * 1000L) // Warn if the packet that gets removed was more recent than 10 min From 0a7b3c723e7abe021e9d01b0547c193cd74139ce Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 10:57:48 +0200 Subject: [PATCH 177/225] Update NeoPixel to v1.15.5 (#10466) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 71c362a79ed..d872780e528 100644 --- a/platformio.ini +++ b/platformio.ini @@ -138,7 +138,7 @@ lib_deps = # renovate: datasource=github-tags depName=Adafruit GFX packageName=adafruit/Adafruit-GFX-Library https://github.com/adafruit/Adafruit-GFX-Library/archive/refs/tags/1.12.6.zip # renovate: datasource=github-tags depName=NeoPixel packageName=adafruit/Adafruit_NeoPixel - https://github.com/adafruit/Adafruit_NeoPixel/archive/refs/tags/1.15.4.zip + https://github.com/adafruit/Adafruit_NeoPixel/archive/1.15.5.zip # renovate: datasource=github-tags depName=Adafruit SSD1306 packageName=adafruit/Adafruit_SSD1306 https://github.com/adafruit/Adafruit_SSD1306/archive/refs/tags/2.5.16.zip # renovate: datasource=github-tags depName=Adafruit BMP280 packageName=adafruit/Adafruit_BMP280_Library From 593909c26bbf5950c91ec9818f39f97c0fa5b3de Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 06:42:49 -0500 Subject: [PATCH 178/225] Radiolib excludes --- platformio.ini | 1 + variants/esp32/esp32-common.ini | 1 + variants/native/portduino.ini | 1 + variants/nrf52840/nrf52.ini | 1 + variants/rp2040/rp2040.ini | 3 ++- 5 files changed, 6 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 95c89a3ef14..dc854acb3c1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -49,6 +49,7 @@ build_flags = -Wno-missing-field-initializers -DRADIOLIB_EXCLUDE_PAGER=1 -DRADIOLIB_EXCLUDE_FSK4=1 -DRADIOLIB_EXCLUDE_APRS=1 + -DRADIOLIB_EXCLUDE_ADSB=1 -DRADIOLIB_EXCLUDE_LORAWAN=1 -DMESHTASTIC_EXCLUDE_DROPZONE=1 -DMESHTASTIC_EXCLUDE_REPLYBOT=1 diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index cd5a8d59360..95bb97a913c 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -40,6 +40,7 @@ build_flags = -Wextra -Isrc/platform/esp32 -std=gnu++17 + -DRADIOLIB_EXCLUDE_STM32WLX=1 -DLOG_LOCAL_LEVEL=ESP_LOG_DEBUG -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG -DMYNEWT_VAL_BLE_HS_LOG_LVL=LOG_LEVEL_CRITICAL diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 9ab45d1ab66..0c0798869bc 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -50,6 +50,7 @@ build_flags_common = -fPIC -Isrc/platform/portduino -DRADIOLIB_EEPROM_UNSUPPORTED + -DRADIOLIB_EXCLUDE_STM32WLX=1 -lpthread -lyaml-cpp -luv diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index d11f4fc565f..63ae40e0ad1 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -24,6 +24,7 @@ build_flags = -DLFS_NO_ASSERT ; Disable LFS assertions , see https://github.com/meshtastic/firmware/pull/3818 -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 + -DRADIOLIB_EXCLUDE_STM32WLX=1 -Os -std=gnu++17 build_unflags = diff --git a/variants/rp2040/rp2040.ini b/variants/rp2040/rp2040.ini index 9abfcbe1040..66358880653 100644 --- a/variants/rp2040/rp2040.ini +++ b/variants/rp2040/rp2040.ini @@ -11,13 +11,14 @@ platform_packages = board_build.core = earlephilhower board_build.filesystem_size = 0.5m -build_flags = +build_flags = ${arduino_base.build_flags} -Wno-unused-variable -Wcast-align -Isrc/platform/rp2xx0 -Isrc/platform/rp2xx0/hardware_rosc/include -Isrc/platform/rp2xx0/pico_sleep/include -D__PLAT_RP2040__ -D__FREERTOS=1 + -DRADIOLIB_EXCLUDE_STM32WLX=1 # -D _POSIX_THREADS build_src_filter = ${arduino_base.build_src_filter} - - - - - - - - - From 039ad4275862597a634fca89754943ac7ed50513 Mon Sep 17 00:00:00 2001 From: Andros Fenollosa Date: Wed, 13 May 2026 13:43:36 +0200 Subject: [PATCH 179/225] Fix WiFi TCP/HTTP services not starting without USB serial connected (#10460) Move WiFi.onEvent(WiFiEvent) registration before createSSLCert() to prevent a race where the ESP32 auto-reconnects during cert generation and fires GOT_IP before the handler is attached, causing onNetworkConnected() to never run and the TCP/HTTP API services to never initialize when booting without USB serial. Also call onNetworkConnected() from reconnectWiFi() on all platforms (not just RP2040) as a safety net; it is already guarded by APStartupComplete so it only runs once. --- src/mesh/wifi/WiFiAPClient.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index be25e6865b7..f1c9f888af3 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -291,9 +291,7 @@ static int32_t reconnectWiFi() #endif return 1000; // check once per second } else { -#ifdef ARCH_RP2040 - onNetworkConnected(); // will only do anything once -#endif + onNetworkConnected(); // will only do anything once (guarded by APStartupComplete) return 300000; // every 5 minutes } } @@ -343,9 +341,6 @@ bool initWifi() const char *wifiPsw = config.network.wifi_psk; #ifndef ARCH_RP2040 -#if !MESHTASTIC_EXCLUDE_WEBSERVER - createSSLCert(); // For WebServer -#endif WiFi.persistent(false); // Disable flash storage for WiFi credentials #endif if (!*wifiPsw) // Treat empty password as no password @@ -370,6 +365,9 @@ bool initWifi() #endif } #ifdef ARCH_ESP32 + // Register WiFi event handler BEFORE createSSLCert() to prevent race condition: + // Without this, WiFi can auto-reconnect during cert generation and fire GOT_IP + // before the handler is registered, causing onNetworkConnected() to never run. WiFi.onEvent(WiFiEvent); WiFi.setAutoReconnect(true); WiFi.setSleep(false); @@ -391,6 +389,12 @@ bool initWifi() wifiDisconnectReason = info.wifi_sta_disconnected.reason; }, WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_DISCONNECTED); +#endif + +#ifndef ARCH_RP2040 +#if !MESHTASTIC_EXCLUDE_WEBSERVER + createSSLCert(); // For WebServer - called after WiFi.onEvent() to avoid race condition +#endif #endif LOG_DEBUG("JOINING WIFI soon: ssid=%s", wifiName); wifiReconnect = new Periodic("WifiConnect", reconnectWiFi); From 5f734dabf9d641c4556d54b4aea6fc1a7aa6aced Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 06:44:51 -0500 Subject: [PATCH 180/225] Trunk --- src/mesh/wifi/WiFiAPClient.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index f1c9f888af3..9930d0a55d7 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -292,7 +292,7 @@ static int32_t reconnectWiFi() return 1000; // check once per second } else { onNetworkConnected(); // will only do anything once (guarded by APStartupComplete) - return 300000; // every 5 minutes + return 300000; // every 5 minutes } } From 8110887be292dea621152f9f2542af7850257994 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 07:57:54 -0500 Subject: [PATCH 181/225] Turns out it's already excluded --- variants/native/portduino.ini | 1 - variants/nrf52840/nrf52.ini | 1 - variants/rp2040/rp2040.ini | 1 - 3 files changed, 3 deletions(-) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 0c0798869bc..9ab45d1ab66 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -50,7 +50,6 @@ build_flags_common = -fPIC -Isrc/platform/portduino -DRADIOLIB_EEPROM_UNSUPPORTED - -DRADIOLIB_EXCLUDE_STM32WLX=1 -lpthread -lyaml-cpp -luv diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index 63ae40e0ad1..d11f4fc565f 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -24,7 +24,6 @@ build_flags = -DLFS_NO_ASSERT ; Disable LFS assertions , see https://github.com/meshtastic/firmware/pull/3818 -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 - -DRADIOLIB_EXCLUDE_STM32WLX=1 -Os -std=gnu++17 build_unflags = diff --git a/variants/rp2040/rp2040.ini b/variants/rp2040/rp2040.ini index 66358880653..090f5d19a65 100644 --- a/variants/rp2040/rp2040.ini +++ b/variants/rp2040/rp2040.ini @@ -18,7 +18,6 @@ build_flags = -Isrc/platform/rp2xx0/pico_sleep/include -D__PLAT_RP2040__ -D__FREERTOS=1 - -DRADIOLIB_EXCLUDE_STM32WLX=1 # -D _POSIX_THREADS build_src_filter = ${arduino_base.build_src_filter} - - - - - - - - - From 59025e4820c25bb764a2d6644f429e3afc20af3a Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 08:07:24 -0500 Subject: [PATCH 182/225] Add initial support for Station G3 variant (#10457) * Add initial support for Station G3 variant * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- bin/config.d/lora-station-g3.yaml | 18 ++++++++ boards/station-g3.json | 41 +++++++++++++++++++ src/platform/esp32/architecture.h | 2 + .../esp32s3/station-common/station_common.h | 38 +++++++++++++++++ variants/esp32s3/station-g2/platformio.ini | 3 +- variants/esp32s3/station-g2/variant.h | 32 +-------------- variants/esp32s3/station-g3/pins_arduino.h | 21 ++++++++++ variants/esp32s3/station-g3/platformio.ini | 32 +++++++++++++++ variants/esp32s3/station-g3/variant.h | 22 ++++++++++ 9 files changed, 177 insertions(+), 32 deletions(-) create mode 100644 bin/config.d/lora-station-g3.yaml create mode 100644 boards/station-g3.json create mode 100644 variants/esp32s3/station-common/station_common.h create mode 100644 variants/esp32s3/station-g3/pins_arduino.h create mode 100644 variants/esp32s3/station-g3/platformio.ini create mode 100644 variants/esp32s3/station-g3/variant.h diff --git a/bin/config.d/lora-station-g3.yaml b/bin/config.d/lora-station-g3.yaml new file mode 100644 index 00000000000..79d0d7e092d --- /dev/null +++ b/bin/config.d/lora-station-g3.yaml @@ -0,0 +1,18 @@ +# Station G3 motherboard with a Raspberry Pi Zero 2W as the MCU daughterboard. +# Verify spidev / I2C device paths for your OS — they may differ. +Meta: + name: Station G3 + support: community + compatible: + - raspberry-pi + +Lora: + Module: sx1262 + IRQ: 22 # BCM pin — wiki spec + Reset: 16 # BCM pin — wiki spec + Busy: 24 # BCM pin — wiki spec + # CS: 8 # BCM 8 = SPI0 CE0 (default); uncomment only to override + DIO2_AS_RF_SWITCH: true + DIO3_TCXO_VOLTAGE: true + spidev: spidev0.0 + # SX126X_MAX_POWER: 19 # matches Station G2 firmware cap; raise carefully per PA jumper mode diff --git a/boards/station-g3.json b/boards/station-g3.json new file mode 100644 index 00000000000..615f8bb4013 --- /dev/null +++ b/boards/station-g3.json @@ -0,0 +1,41 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=0" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "station-g3" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "BQ Station G3", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "", + "vendor": "BQ Consulting" +} diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index cd3ac1f9db9..e4ec807f823 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -158,6 +158,8 @@ #define HW_VENDOR meshtastic_HardwareModel_CHATTER_2 #elif defined(STATION_G2) #define HW_VENDOR meshtastic_HardwareModel_STATION_G2 +#elif defined(STATION_G3) +#define HW_VENDOR meshtastic_HardwareModel_STATION_G3 #elif defined(UNPHONE) #define HW_VENDOR meshtastic_HardwareModel_UNPHONE #elif defined(WIPHONE) diff --git a/variants/esp32s3/station-common/station_common.h b/variants/esp32s3/station-common/station_common.h new file mode 100644 index 00000000000..9b759fa426a --- /dev/null +++ b/variants/esp32s3/station-common/station_common.h @@ -0,0 +1,38 @@ +// Shared pin/feature defines for BQ Station G2 and G3. +// Boards differ only in PA output power (SX126X_MAX_POWER) and the wiki URL. +#pragma once + +// Station G2/G3 may not have GPS installed, but have a GROVE GPS Socket for an optional GPS module +#define GPS_RX_PIN 7 +#define GPS_TX_PIN 15 + +// 1.3 inch OLED Screen +#define USE_SH1107_128_64 + +#define I2C_SDA 5 +#define I2C_SCL 6 + +#define BUTTON_PIN 38 // Program button +#define BUTTON_NEED_PULLUP + +#define USE_SX1262 + +#define LORA_MISO 14 +#define LORA_SCK 12 +#define LORA_MOSI 13 +#define LORA_CS 11 + +#define LORA_RESET 21 +#define LORA_DIO1 48 + +#ifdef USE_SX1262 +#define SX126X_CS LORA_CS // Compatibility alias; prefer LORA_CS in board definitions. +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY 47 +#define SX126X_RESET LORA_RESET + +// DIO2 controls an antenna switch and the TCXO voltage is controlled by DIO3 +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 +// NOTE: SX126X_MAX_POWER is intentionally NOT defined here — each board sets it. +#endif diff --git a/variants/esp32s3/station-g2/platformio.ini b/variants/esp32s3/station-g2/platformio.ini index 4efb21a0098..9c6b71cecf7 100644 --- a/variants/esp32s3/station-g2/platformio.ini +++ b/variants/esp32s3/station-g2/platformio.ini @@ -22,10 +22,11 @@ upload_speed = 921600 build_unflags = ${esp32s3_base.build_unflags} -DARDUINO_USB_MODE=0 -build_flags = +build_flags = ${esp32s3_base.build_flags} -D STATION_G2 -I variants/esp32s3/station-g2 + -I variants/esp32s3/station-common -DBOARD_HAS_PSRAM -DSTATION_G2 -DARDUINO_USB_MODE=1 diff --git a/variants/esp32s3/station-g2/variant.h b/variants/esp32s3/station-g2/variant.h index 8f0b4b220c0..96c7f176bac 100644 --- a/variants/esp32s3/station-g2/variant.h +++ b/variants/esp32s3/station-g2/variant.h @@ -2,39 +2,9 @@ Board Information: https://wiki.uniteng.com/en/meshtastic/station-g2 */ -// Station G2 may not have GPS installed, but it has a GROVE GPS Socket for Optional GPS Module -#define GPS_RX_PIN 7 -#define GPS_TX_PIN 15 - -// Station G2 has 1.3 inch OLED Screen -#define USE_SH1107_128_64 - -#define I2C_SDA 5 // I2C pins for this board -#define I2C_SCL 6 - -#define BUTTON_PIN 38 // This is the Program Button -#define BUTTON_NEED_PULLUP - -#define USE_SX1262 - -#define LORA_MISO 14 -#define LORA_SCK 12 -#define LORA_MOSI 13 -#define LORA_CS 11 - -#define LORA_RESET 21 -#define LORA_DIO1 48 +#include "station_common.h" #ifdef USE_SX1262 -#define SX126X_CS LORA_CS // FIXME - we really should define LORA_CS instead -#define SX126X_DIO1 LORA_DIO1 -#define SX126X_BUSY 47 -#define SX126X_RESET LORA_RESET - -// DIO2 controlls an antenna switch and the TCXO voltage is controlled by DIO3 -#define SX126X_DIO2_AS_RF_SWITCH -#define SX126X_DIO3_TCXO_VOLTAGE 1.8 - // Ensure the PA does not exceed the saturation output power. More // Info:https://wiki.uniteng.com/en/meshtastic/station-g2#summary-for-lora-power-amplifier-conduction-test #define SX126X_MAX_POWER 19 diff --git a/variants/esp32s3/station-g3/pins_arduino.h b/variants/esp32s3/station-g3/pins_arduino.h new file mode 100644 index 00000000000..129d9f9e211 --- /dev/null +++ b/variants/esp32s3/station-g3/pins_arduino.h @@ -0,0 +1,21 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// GPIO48 Reference: https://github.com/espressif/arduino-esp32/pull/8600 + +// The default Wire will be mapped to Screen and Sensors +static const uint8_t SDA = 5; +static const uint8_t SCL = 6; + +// Default SPI will be mapped to Radio +static const uint8_t MISO = 14; +static const uint8_t SCK = 12; +static const uint8_t MOSI = 13; +static const uint8_t SS = 11; + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/station-g3/platformio.ini b/variants/esp32s3/station-g3/platformio.ini new file mode 100644 index 00000000000..f94809ddf0c --- /dev/null +++ b/variants/esp32s3/station-g3/platformio.ini @@ -0,0 +1,32 @@ +[env:station-g3] +custom_meshtastic_hw_model = 134 +custom_meshtastic_hw_model_slug = STATION_G3 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 2 +custom_meshtastic_display_name = Station G3 +custom_meshtastic_images = station-g3.svg +custom_meshtastic_tags = B&Q +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = esp32s3_base +board = station-g3 +board_level = pr +board_check = true +board_build.partitions = default_16MB.csv +board_build.mcu = esp32s3 +upload_protocol = esptool +;upload_port = /dev/ttyACM0 +upload_speed = 921600 +build_unflags = + ${esp32s3_base.build_unflags} + -DARDUINO_USB_MODE=0 +build_flags = + ${esp32s3_base.build_flags} + -D STATION_G3 + -I variants/esp32s3/station-g3 + -I variants/esp32s3/station-common + -DBOARD_HAS_PSRAM + -DSTATION_G3 + -DARDUINO_USB_MODE=1 diff --git a/variants/esp32s3/station-g3/variant.h b/variants/esp32s3/station-g3/variant.h new file mode 100644 index 00000000000..98441ea99af --- /dev/null +++ b/variants/esp32s3/station-g3/variant.h @@ -0,0 +1,22 @@ +#include "station_common.h" + +#ifdef USE_SX1262 +// Station G3 reuses the same Fast-Transient DC-DC PA design as G2 (BQ35LORA900V1M). +// PA Operating Mode is set in hardware via the PA-PL1 / PA-PL2 jumpers. +// 19 matches the G2 cap (SX1262 19 dBm in → ~31 dBm PA out at Power Level 1, ISM compliant). +// Raise (max 22) only if running a higher PA Power Level and you can stay within local band limits. +#define SX126X_MAX_POWER 19 +#endif + +/* +#define BATTERY_PIN 4 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage +#define ADC_CHANNEL ADC1_GPIO4_CHANNEL +#define ADC_MULTIPLIER 4 +#define BATTERY_SENSE_SAMPLES 15 // Set the number of samples, It has an effect of increasing sensitivity. +#define BAT_FULLVOLT 8400 +#define BAT_EMPTYVOLT 5000 +#define BAT_CHARGINGVOLT 8400 +#define BAT_NOBATVOLT 4460 +#define CELL_TYPE_LION // same curve for liion/lipo +#define NUM_CELLS 2 +*/ From 75b7a7df4f35cafb936297c3be6a41aaa2f441ff Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 08:50:15 -0500 Subject: [PATCH 183/225] Missed one --- variants/esp32/esp32-common.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 95bb97a913c..cd5a8d59360 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -40,7 +40,6 @@ build_flags = -Wextra -Isrc/platform/esp32 -std=gnu++17 - -DRADIOLIB_EXCLUDE_STM32WLX=1 -DLOG_LOCAL_LEVEL=ESP_LOG_DEBUG -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG -DMYNEWT_VAL_BLE_HS_LOG_LVL=LOG_LEVEL_CRITICAL From 4c3ba612bb8508e3a128be98332d54f174f352c8 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 13 May 2026 10:25:11 -0400 Subject: [PATCH 184/225] VSCode: Prepare for pioarduino transition (#10471) Start reccomending the pioarduino VS Code extension instead of the PlatformIO extension. pioarduino-based builds cannot complete correctly using the platformio extension. Normal platformio builds (nrf52, stm32) are unaffected//still work correctly. Devs may need to delete their ~.platformio and .pio directories once after install in order to build properly. --- .devcontainer/devcontainer.json | 10 +++++++--- .vscode/extensions.json | 8 ++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e3f076ce061..a631832cbd1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,17 +8,21 @@ "features": { "ghcr.io/devcontainers/features/python:1": { "installTools": true, - "version": "3.14" + "version": "3.13" } }, "customizations": { "vscode": { "extensions": [ "ms-vscode.cpptools", - "platformio.platformio-ide", + "Jason2866.esp-decoder", + "pioarduino.pioarduino-ide", "Trunk.io" ], - "unwantedRecommendations": ["ms-azuretools.vscode-docker"], + "unwantedRecommendations": [ + "ms-azuretools.vscode-docker", + "platformio.platformio-ide" + ], "settings": { "extensions.ignoreRecommendations": true } diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 080e70d08b9..66d8356e517 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,10 +1,10 @@ { - // See http://go.microsoft.com/fwlink/?LinkId=827846 - // for the documentation about the extensions.json format "recommendations": [ - "platformio.platformio-ide" + "Jason2866.esp-decoder", + "pioarduino.pioarduino-ide" ], "unwantedRecommendations": [ - "ms-vscode.cpptools-extension-pack" + "ms-vscode.cpptools-extension-pack", + "platformio.platformio-ide" ] } From c756bbe2c1136838b54c56571dc2509929512552 Mon Sep 17 00:00:00 2001 From: Andros Fenollosa Date: Wed, 13 May 2026 13:43:36 +0200 Subject: [PATCH 185/225] Fix WiFi TCP/HTTP services not starting without USB serial connected (#10460) Move WiFi.onEvent(WiFiEvent) registration before createSSLCert() to prevent a race where the ESP32 auto-reconnects during cert generation and fires GOT_IP before the handler is attached, causing onNetworkConnected() to never run and the TCP/HTTP API services to never initialize when booting without USB serial. Also call onNetworkConnected() from reconnectWiFi() on all platforms (not just RP2040) as a safety net; it is already guarded by APStartupComplete so it only runs once. --- src/mesh/wifi/WiFiAPClient.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index be25e6865b7..f1c9f888af3 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -291,9 +291,7 @@ static int32_t reconnectWiFi() #endif return 1000; // check once per second } else { -#ifdef ARCH_RP2040 - onNetworkConnected(); // will only do anything once -#endif + onNetworkConnected(); // will only do anything once (guarded by APStartupComplete) return 300000; // every 5 minutes } } @@ -343,9 +341,6 @@ bool initWifi() const char *wifiPsw = config.network.wifi_psk; #ifndef ARCH_RP2040 -#if !MESHTASTIC_EXCLUDE_WEBSERVER - createSSLCert(); // For WebServer -#endif WiFi.persistent(false); // Disable flash storage for WiFi credentials #endif if (!*wifiPsw) // Treat empty password as no password @@ -370,6 +365,9 @@ bool initWifi() #endif } #ifdef ARCH_ESP32 + // Register WiFi event handler BEFORE createSSLCert() to prevent race condition: + // Without this, WiFi can auto-reconnect during cert generation and fire GOT_IP + // before the handler is registered, causing onNetworkConnected() to never run. WiFi.onEvent(WiFiEvent); WiFi.setAutoReconnect(true); WiFi.setSleep(false); @@ -391,6 +389,12 @@ bool initWifi() wifiDisconnectReason = info.wifi_sta_disconnected.reason; }, WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_DISCONNECTED); +#endif + +#ifndef ARCH_RP2040 +#if !MESHTASTIC_EXCLUDE_WEBSERVER + createSSLCert(); // For WebServer - called after WiFi.onEvent() to avoid race condition +#endif #endif LOG_DEBUG("JOINING WIFI soon: ssid=%s", wifiName); wifiReconnect = new Periodic("WifiConnect", reconnectWiFi); From 1ae4a538f50cebac5b13e8fcaff4bac123b5cb51 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 06:44:51 -0500 Subject: [PATCH 186/225] Trunk --- src/mesh/wifi/WiFiAPClient.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index f1c9f888af3..9930d0a55d7 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -292,7 +292,7 @@ static int32_t reconnectWiFi() return 1000; // check once per second } else { onNetworkConnected(); // will only do anything once (guarded by APStartupComplete) - return 300000; // every 5 minutes + return 300000; // every 5 minutes } } From 748668b8e9d0ea912aa2e6f63c7f0dd1980caf08 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 10:13:53 -0500 Subject: [PATCH 187/225] Remove ARIAL24 on NRF52 --- src/graphics/ScreenFonts.h | 52 ++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/graphics/ScreenFonts.h b/src/graphics/ScreenFonts.h index 26276edb267..4a9def5e1d2 100644 --- a/src/graphics/ScreenFonts.h +++ b/src/graphics/ScreenFonts.h @@ -88,24 +88,56 @@ #endif #endif +// --------------------------------------------------------------------------- +// Flash budget: nRF52 boards drop the 24pt glyph (~9.6 KB) and substitute the +// 16pt one. Other architectures (ESP32, RP2040, Portduino, STM32) always keep +// 24pt. Any nRF52 variant that wants 24pt back can set +// MESHTASTIC_LARGE_FONT_24PT=1 in its build flags. +// --------------------------------------------------------------------------- +#if defined(ARCH_NRF52) && !defined(MESHTASTIC_LARGE_FONT_24PT) +#define MESHTASTIC_DROP_24PT_FONT +#endif + +// --------------------------------------------------------------------------- +// Display tier → pick FONT_SMALL/MEDIUM/LARGE. +// BIG — eInk panel / TFT / Hackaday Communicator. +// TINY — M5STACK_UNITC6L only. +// default — 128x64 SSD1306 / SH1106 small OLED. +// DISPLAY_FORCE_SMALL_FONTS opts a big-screen variant out of BIG (rarely used). +// --------------------------------------------------------------------------- #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) -// The screen is bigger so use bigger fonts -#define FONT_SMALL FONT_MEDIUM_LOCAL // Height: 19 -#define FONT_MEDIUM FONT_LARGE_LOCAL // Height: 28 -#define FONT_LARGE FONT_LARGE_LOCAL // Height: 28 +// Tier BIG. SMALL is 16pt; MEDIUM/LARGE normally 24pt. +#define FONT_SMALL FONT_MEDIUM_LOCAL // 16pt +#if defined(MESHTASTIC_DROP_24PT_FONT) && defined(USE_EINK) +// Flash-tight nRF52 eInk: collapse MEDIUM/LARGE to 16pt too. +#define FONT_MEDIUM FONT_MEDIUM_LOCAL // 16pt +#define FONT_LARGE FONT_MEDIUM_LOCAL // 16pt +#else +#define FONT_MEDIUM FONT_LARGE_LOCAL // 24pt +#define FONT_LARGE FONT_LARGE_LOCAL // 24pt +#endif #elif defined(M5STACK_UNITC6L) -#define FONT_SMALL FONT_SMALL_LOCAL // Height: 13 -#define FONT_MEDIUM FONT_SMALL_LOCAL // Height: 13 -#define FONT_LARGE FONT_SMALL_LOCAL // Height: 13 +// Tier TINY — 10pt everywhere. +#define FONT_SMALL FONT_SMALL_LOCAL // 10pt +#define FONT_MEDIUM FONT_SMALL_LOCAL // 10pt +#define FONT_LARGE FONT_SMALL_LOCAL // 10pt +#else +// Default tier — small OLED. +#define FONT_SMALL FONT_SMALL_LOCAL // 10pt +#define FONT_MEDIUM FONT_MEDIUM_LOCAL // 16pt +#if defined(MESHTASTIC_DROP_24PT_FONT) +// Flash-tight nRF52 small-OLED: substitute 16pt for 24pt. Only the BLE PIN +// screen and one audio-module screen use FONT_LARGE on this tier. +#define FONT_LARGE FONT_MEDIUM_LOCAL // 16pt #else -#define FONT_SMALL FONT_SMALL_LOCAL // Height: 13 -#define FONT_MEDIUM FONT_MEDIUM_LOCAL // Height: 19 -#define FONT_LARGE FONT_LARGE_LOCAL // Height: 28 +#define FONT_LARGE FONT_LARGE_LOCAL // 24pt +#endif #endif +// CrowPanel-S3 / T5-S3 ePaper override everything with their own 30pt font. #if defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(T5_S3_EPAPER_PRO) #undef FONT_SMALL #undef FONT_MEDIUM From 9cd3a869388c1f701813f5fb60d9690c97044ec6 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 10:43:16 -0500 Subject: [PATCH 188/225] Cleanup --- src/graphics/ScreenFonts.h | 55 +++++++++++--------------------------- 1 file changed, 16 insertions(+), 39 deletions(-) diff --git a/src/graphics/ScreenFonts.h b/src/graphics/ScreenFonts.h index 4a9def5e1d2..bac92b2b0b3 100644 --- a/src/graphics/ScreenFonts.h +++ b/src/graphics/ScreenFonts.h @@ -88,56 +88,33 @@ #endif #endif -// --------------------------------------------------------------------------- -// Flash budget: nRF52 boards drop the 24pt glyph (~9.6 KB) and substitute the -// 16pt one. Other architectures (ESP32, RP2040, Portduino, STM32) always keep -// 24pt. Any nRF52 variant that wants 24pt back can set -// MESHTASTIC_LARGE_FONT_24PT=1 in its build flags. -// --------------------------------------------------------------------------- +// nRF52 flash optimization: re-route FONT_LARGE_LOCAL to the 16pt glyph so +// the display-tier dispatch below picks up 16pt everywhere it would have used +// 24pt. Drops the ~9.6 KB ArialMT_Plain_24 table from the linked binary. +// Set MESHTASTIC_LARGE_FONT_24PT=1 in build_flags to opt out per variant. #if defined(ARCH_NRF52) && !defined(MESHTASTIC_LARGE_FONT_24PT) -#define MESHTASTIC_DROP_24PT_FONT +#undef FONT_LARGE_LOCAL +#define FONT_LARGE_LOCAL FONT_MEDIUM_LOCAL #endif -// --------------------------------------------------------------------------- -// Display tier → pick FONT_SMALL/MEDIUM/LARGE. -// BIG — eInk panel / TFT / Hackaday Communicator. -// TINY — M5STACK_UNITC6L only. -// default — 128x64 SSD1306 / SH1106 small OLED. -// DISPLAY_FORCE_SMALL_FONTS opts a big-screen variant out of BIG (rarely used). -// --------------------------------------------------------------------------- #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) -// Tier BIG. SMALL is 16pt; MEDIUM/LARGE normally 24pt. -#define FONT_SMALL FONT_MEDIUM_LOCAL // 16pt -#if defined(MESHTASTIC_DROP_24PT_FONT) && defined(USE_EINK) -// Flash-tight nRF52 eInk: collapse MEDIUM/LARGE to 16pt too. -#define FONT_MEDIUM FONT_MEDIUM_LOCAL // 16pt -#define FONT_LARGE FONT_MEDIUM_LOCAL // 16pt -#else -#define FONT_MEDIUM FONT_LARGE_LOCAL // 24pt -#define FONT_LARGE FONT_LARGE_LOCAL // 24pt -#endif +// The screen is bigger so use bigger fonts +#define FONT_SMALL FONT_MEDIUM_LOCAL // Height: 19 +#define FONT_MEDIUM FONT_LARGE_LOCAL // Height: 28 +#define FONT_LARGE FONT_LARGE_LOCAL // Height: 28 #elif defined(M5STACK_UNITC6L) -// Tier TINY — 10pt everywhere. -#define FONT_SMALL FONT_SMALL_LOCAL // 10pt -#define FONT_MEDIUM FONT_SMALL_LOCAL // 10pt -#define FONT_LARGE FONT_SMALL_LOCAL // 10pt +#define FONT_SMALL FONT_SMALL_LOCAL // Height: 13 +#define FONT_MEDIUM FONT_SMALL_LOCAL // Height: 13 +#define FONT_LARGE FONT_SMALL_LOCAL // Height: 13 #else -// Default tier — small OLED. -#define FONT_SMALL FONT_SMALL_LOCAL // 10pt -#define FONT_MEDIUM FONT_MEDIUM_LOCAL // 16pt -#if defined(MESHTASTIC_DROP_24PT_FONT) -// Flash-tight nRF52 small-OLED: substitute 16pt for 24pt. Only the BLE PIN -// screen and one audio-module screen use FONT_LARGE on this tier. -#define FONT_LARGE FONT_MEDIUM_LOCAL // 16pt -#else -#define FONT_LARGE FONT_LARGE_LOCAL // 24pt -#endif +#define FONT_SMALL FONT_SMALL_LOCAL // Height: 13 +#define FONT_MEDIUM FONT_MEDIUM_LOCAL // Height: 19 +#define FONT_LARGE FONT_LARGE_LOCAL // Height: 28 #endif -// CrowPanel-S3 / T5-S3 ePaper override everything with their own 30pt font. #if defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(T5_S3_EPAPER_PRO) #undef FONT_SMALL #undef FONT_MEDIUM From 5a1d2b9ef4cb4d73518e3194719280336fee073e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 10:52:05 -0500 Subject: [PATCH 189/225] Refine nRF52 flash optimization comment for FONT_LARGE_LOCAL definition --- src/graphics/ScreenFonts.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/graphics/ScreenFonts.h b/src/graphics/ScreenFonts.h index bac92b2b0b3..82ceb54066f 100644 --- a/src/graphics/ScreenFonts.h +++ b/src/graphics/ScreenFonts.h @@ -91,8 +91,9 @@ // nRF52 flash optimization: re-route FONT_LARGE_LOCAL to the 16pt glyph so // the display-tier dispatch below picks up 16pt everywhere it would have used // 24pt. Drops the ~9.6 KB ArialMT_Plain_24 table from the linked binary. -// Set MESHTASTIC_LARGE_FONT_24PT=1 in build_flags to opt out per variant. -#if defined(ARCH_NRF52) && !defined(MESHTASTIC_LARGE_FONT_24PT) +// Set MESHTASTIC_LARGE_FONT_24PT=1 in build_flags to opt out per variant +// (undefined or 0 keeps the optimization on). +#if defined(ARCH_NRF52) && (!defined(MESHTASTIC_LARGE_FONT_24PT) || MESHTASTIC_LARGE_FONT_24PT == 0) #undef FONT_LARGE_LOCAL #define FONT_LARGE_LOCAL FONT_MEDIUM_LOCAL #endif From 871194517d9f0a274543b3ed66904846636371eb Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 13 May 2026 11:02:19 -0500 Subject: [PATCH 190/225] Ble banner (#8902) * Drop unneeded Sizeof() instances * Use SimpleBanner for BLE pin * Support for different font sizes on notification banner * Fix NRF52 BLE cppcheck shadow warning Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/de12b52c-49d5-452a-b3fb-344724649270 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: Ben Meadors Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Co-authored-by: Jason P Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --- src/graphics/Screen.cpp | 19 +- src/graphics/draw/NotificationRenderer.cpp | 201 ++++++++++++++++++--- src/graphics/draw/NotificationRenderer.h | 6 + src/nimble/NimbleBluetooth.cpp | 39 +--- src/platform/nrf52/NRF52Bluetooth.cpp | 32 +--- 5 files changed, 205 insertions(+), 92 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 80e00ed692c..044db3637f8 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -230,6 +230,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) #endif // Store the message and set the expiration timestamp strncpy(NotificationRenderer::alertBannerMessage, banner_overlay_options.message, 255); + NotificationRenderer::parseBannerMessageWithFonts(NotificationRenderer::alertBannerMessage); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination NotificationRenderer::alertBannerUntil = (banner_overlay_options.durationMs == 0) ? 0 : millis() + banner_overlay_options.durationMs; @@ -239,9 +240,9 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) NotificationRenderer::alertBannerCallback = banner_overlay_options.bannerCallback; NotificationRenderer::curSelected = banner_overlay_options.InitialSelected; NotificationRenderer::pauseBanner = false; - NotificationRenderer::current_notification_type = notificationTypeEnum::selection_picker; + NotificationRenderer::current_notification_type = banner_overlay_options.notificationType; static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setOverlays(overlays, 2); ui->setTargetFPS(60); updateUiFrame(ui); } @@ -263,7 +264,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct NotificationRenderer::current_notification_type = notificationTypeEnum::node_picker; static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setOverlays(overlays, 2); ui->setTargetFPS(60); updateUiFrame(ui); } @@ -287,7 +288,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t NotificationRenderer::currentNumber = 0; static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setOverlays(overlays, 2); ui->setTargetFPS(60); updateUiFrame(ui); } @@ -310,7 +311,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t // Set the overlay using the same pattern as other notification types static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setOverlays(overlays, 2); ui->setTargetFPS(60); updateUiFrame(ui); } @@ -695,7 +696,7 @@ void Screen::setup() static OverlayCallback overlays[] = { graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame }; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setOverlays(overlays, 1); // Enable UTF-8 to display mapping dispdev->setFontTableLookupFunction(customFontTableLookup); @@ -1312,7 +1313,7 @@ void Screen::setFrames(FrameFocus focus) // Add overlays: frame icons and alert banner) static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setOverlays(overlays, 2); prevFrame = -1; // Force drawFavoriteNode to pick a new node (because our list just changed) @@ -1687,7 +1688,7 @@ int Screen::handleInputEvent(const InputEvent *event) if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { NotificationRenderer::inEvent = *event; static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setOverlays(overlays, 2); setFastFramerate(); // Draw ASAP updateUiFrame(ui); return 0; @@ -1702,7 +1703,7 @@ int Screen::handleInputEvent(const InputEvent *event) if (NotificationRenderer::isOverlayBannerShowing()) { NotificationRenderer::inEvent = *event; static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setOverlays(overlays, 2); setFastFramerate(); // Draw ASAP updateUiFrame(ui); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 8d031cf734c..d7eba0be45d 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -66,6 +66,110 @@ uint32_t pow_of_10(uint32_t n) return ret; } +char graphics::NotificationRenderer::alertBannerLines[MAX_LINES + 1][64] = {}; +uint8_t graphics::NotificationRenderer::alertBannerLineCount = 0; +graphics::NotificationRenderer::BannerFont graphics::NotificationRenderer::alertBannerLineFonts[MAX_LINES + 1] = {}; + +static inline graphics::NotificationRenderer::BannerFont parseFontTagPrefix(const char *&p) +{ + // Tags must be at the start of the line: + // [S] small, [M] medium, [L] large + if (p && p[0] == '[' && p[2] == ']' && p[1] != '\0') { + char t = p[1]; + if (t == 'S') { + p += 3; + return graphics::NotificationRenderer::BANNER_FONT_SMALL; + } + if (t == 'M') { + p += 3; + return graphics::NotificationRenderer::BANNER_FONT_MEDIUM; + } + if (t == 'L') { + p += 3; + return graphics::NotificationRenderer::BANNER_FONT_LARGE; + } + } + return graphics::NotificationRenderer::BANNER_FONT_DEFAULT; +} + +static inline const uint8_t *fontForBannerLine(graphics::NotificationRenderer::BannerFont f) +{ + switch (f) { + case graphics::NotificationRenderer::BANNER_FONT_SMALL: + return FONT_SMALL; + case graphics::NotificationRenderer::BANNER_FONT_MEDIUM: + return FONT_MEDIUM; + case graphics::NotificationRenderer::BANNER_FONT_LARGE: + return FONT_LARGE; + case graphics::NotificationRenderer::BANNER_FONT_DEFAULT: + default: + return FONT_SMALL; + } +} + +static inline uint8_t effectiveLineHeightForBannerLine(graphics::NotificationRenderer::BannerFont f) +{ + uint8_t height = FONT_HEIGHT_SMALL; + switch (f) { + case graphics::NotificationRenderer::BANNER_FONT_MEDIUM: + height = FONT_HEIGHT_MEDIUM; + break; + case graphics::NotificationRenderer::BANNER_FONT_LARGE: + height = FONT_HEIGHT_LARGE; + break; + case graphics::NotificationRenderer::BANNER_FONT_SMALL: + case graphics::NotificationRenderer::BANNER_FONT_DEFAULT: + default: + height = FONT_HEIGHT_SMALL; + break; + } + return (height > 3) ? (height - 3) : height; +} + +void graphics::NotificationRenderer::parseBannerMessageWithFonts(const char *message) +{ + alertBannerLineCount = 0; + for (uint8_t i = 0; i < (MAX_LINES + 1); i++) { + alertBannerLines[i][0] = '\0'; + alertBannerLineFonts[i] = BANNER_FONT_DEFAULT; + } + + if (!message || !message[0]) { + return; + } + + const char *p = message; + + while (*p && alertBannerLineCount < (MAX_LINES + 1)) { + const char *lineStart = p; + while (*p && *p != '\n') { + p++; + } + + char tmp[64] = {0}; + size_t len = (size_t)(p - lineStart); + if (len > (sizeof(tmp) - 1)) { + len = sizeof(tmp) - 1; + } + memcpy(tmp, lineStart, len); + tmp[len] = '\0'; + + // Tag at start + const char *tp = tmp; + BannerFont f = parseFontTagPrefix(tp); + alertBannerLineFonts[alertBannerLineCount] = f; + + // Store stripped text + strncpy(alertBannerLines[alertBannerLineCount], tp, sizeof(alertBannerLines[0]) - 1); + alertBannerLines[alertBannerLineCount][sizeof(alertBannerLines[0]) - 1] = '\0'; + alertBannerLineCount++; + + if (*p == '\n') { + p++; + } + } +} + // Used on boot when a certificate is being created void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { @@ -375,23 +479,37 @@ void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisp const char *lineStarts[MAX_LINES + 1] = {0}; uint16_t lineCount = 0; char lineBuffer[40] = {0}; - - // Parse lines - char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage)); - lineStarts[lineCount] = alertBannerMessage; - - while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) { - lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n'); - lineLengths[lineCount] = lineStarts[lineCount + 1] - lineStarts[lineCount]; - if (lineStarts[lineCount + 1][0] == '\n') - lineStarts[lineCount + 1] += 1; - lineWidths[lineCount] = display->getStringWidth(lineStarts[lineCount], lineLengths[lineCount], true); - if (lineWidths[lineCount] > maxWidth) - maxWidth = lineWidths[lineCount]; - lineCount++; + bool useTaggedTextBanner = + (current_notification_type == notificationTypeEnum::text_banner && alertBannerOptions == 0 && alertBannerLineCount > 0); + + if (useTaggedTextBanner) { + lineCount = std::min(alertBannerLineCount, MAX_LINES); + for (uint16_t i = 0; i < lineCount; i++) { + lineStarts[i] = alertBannerLines[i]; + lineLengths[i] = strlen(lineStarts[i]); + display->setFont(fontForBannerLine(alertBannerLineFonts[i])); + lineWidths[i] = display->getStringWidth(lineStarts[i], lineLengths[i], true); + if (lineWidths[i] > maxWidth) + maxWidth = lineWidths[i]; + } + } else { + char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage)); + lineStarts[lineCount] = alertBannerMessage; + + while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) { + lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n'); + lineLengths[lineCount] = lineStarts[lineCount + 1] - lineStarts[lineCount]; + if (lineStarts[lineCount + 1][0] == '\n') + lineStarts[lineCount + 1] += 1; + lineWidths[lineCount] = display->getStringWidth(lineStarts[lineCount], lineLengths[lineCount], true); + if (lineWidths[lineCount] > maxWidth) + maxWidth = lineWidths[lineCount]; + lineCount++; + } } // Measure option widths + display->setFont(FONT_SMALL); for (int i = 0; i < alertBannerOptions; i++) { optionWidths[i] = display->getStringWidth(optionsArrayPtr[i], strlen(optionsArrayPtr[i]), true); if (optionWidths[i] > maxWidth) @@ -505,6 +623,10 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay bool needs_bell = false; uint16_t lineWidths[totalLines] = {0}; uint16_t lineLengths[totalLines] = {0}; + BannerFont lineFonts[totalLines] = {}; + uint8_t lineEffectiveHeights[totalLines] = {0}; + const char *renderLines[totalLines] = {0}; + bool useTaggedBannerFonts = (current_notification_type == notificationTypeEnum::text_banner && alertBannerOptions == 0); if (maxWidth != 0) is_picker = true; @@ -517,11 +639,22 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay uint16_t widestLineWithBars = 0; while (lines[lineCount] != nullptr) { - auto newlinePointer = strchr(lines[lineCount], '\n'); + const char *renderText = lines[lineCount]; + BannerFont lineFont = BANNER_FONT_DEFAULT; + if (useTaggedBannerFonts && lineCount < alertBannerLineCount) { + renderText = alertBannerLines[lineCount]; + lineFont = alertBannerLineFonts[lineCount]; + } + renderLines[lineCount] = renderText; + lineFonts[lineCount] = lineFont; + lineEffectiveHeights[lineCount] = effectiveLineHeightForBannerLine(lineFont); + display->setFont(fontForBannerLine(lineFont)); + + auto newlinePointer = strchr(renderText, '\n'); if (newlinePointer) - lineLengths[lineCount] = (newlinePointer - lines[lineCount]); // Check for newlines first - else // if the newline wasn't found, then pull string length from strlen - lineLengths[lineCount] = strlen(lines[lineCount]); + lineLengths[lineCount] = (newlinePointer - renderText); + else + lineLengths[lineCount] = strlen(renderText); if (current_notification_type == notificationTypeEnum::node_picker) { char measureBuffer[64] = {0}; @@ -533,7 +666,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay // Consider extra width for signal bars on lines that contain "Signal:" uint16_t potentialWidth = lineWidths[lineCount]; - if (graphics::bannerSignalBars >= 0 && strncmp(lines[lineCount], "Signal:", 7) == 0) { + if (graphics::bannerSignalBars >= 0 && strncmp(renderText, "Signal:", 7) == 0) { const int totalBars = 5; const int barWidth = 3; const int barSpacing = 2; @@ -569,8 +702,21 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay uint16_t screenHeight = display->height(); uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3; - uint8_t visibleTotalLines = std::min(lineCount, (screenHeight - vPadding * 2) / effectiveLineHeight); - uint16_t contentHeight = visibleTotalLines * effectiveLineHeight; + uint8_t visibleTotalLines = 0; + uint16_t contentHeight = 0; + const uint16_t availableHeight = (screenHeight > (vPadding * 2)) ? (screenHeight - vPadding * 2) : 0; + for (uint8_t i = 0; i < lineCount; i++) { + uint8_t thisLineHeight = lineEffectiveHeights[i] ? lineEffectiveHeights[i] : effectiveLineHeight; + if (contentHeight + thisLineHeight > availableHeight) { + break; + } + contentHeight += thisLineHeight; + visibleTotalLines++; + } + if (visibleTotalLines == 0 && lineCount > 0) { + visibleTotalLines = 1; + contentHeight = lineEffectiveHeights[0] ? lineEffectiveHeights[0] : effectiveLineHeight; + } uint16_t boxHeight = contentHeight + vPadding * 2; if (visibleTotalLines == 1) { boxHeight += (currentResolution == ScreenResolution::High) ? 4 : 3; @@ -616,15 +762,18 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay // Draw Content int16_t lineY = boxTop + vPadding; - for (int i = 0; i < lineCount; i++) { + for (int i = 0; i < visibleTotalLines; i++) { + display->setFont(fontForBannerLine(lineFonts[i])); + int16_t thisLineHeight = lineEffectiveHeights[i] ? lineEffectiveHeights[i] : effectiveLineHeight; int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2; if (needs_bell && i == 0) { - int bellY = lineY + (FONT_HEIGHT_SMALL - 8) / 2; + int fontHeight = thisLineHeight + 3; + int bellY = lineY + (fontHeight - 8) / 2; display->drawXbm(textX - 10, bellY, 8, 8, bell_alert); display->drawXbm(textX + lineWidths[i] + 2, bellY, 8, 8, bell_alert); } char lineBuffer[lineLengths[i] + 1]; - strncpy(lineBuffer, lines[i], lineLengths[i]); + strncpy(lineBuffer, renderLines[i], lineLengths[i]); lineBuffer[lineLengths[i]] = '\0'; // Determine if this is a pop-up or a pick list if (alertBannerOptions > 0 && i == 0) { @@ -658,7 +807,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay display->drawString(textX, lineY - yOffset, lineBuffer); } display->setColor(WHITE); - lineY += (effectiveLineHeight - 2 - background_yOffset); + lineY += (thisLineHeight - 2 - background_yOffset); } else { // Pop-up // If this is the Signal line, center text + bars as one group @@ -716,7 +865,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay display->drawString(textX, lineY, lineBuffer); } } - lineY += (effectiveLineHeight); + lineY += thisLineHeight; } } diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index 45b05be9c5c..fc8a32c648a 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -31,6 +31,12 @@ class NotificationRenderer static bool pauseBanner; + enum BannerFont : uint8_t { BANNER_FONT_DEFAULT = 0, BANNER_FONT_SMALL, BANNER_FONT_MEDIUM, BANNER_FONT_LARGE }; + + static char alertBannerLines[MAX_LINES + 1][64]; // parsed text per line + static uint8_t alertBannerLineCount; + static BannerFont alertBannerLineFonts[MAX_LINES + 1]; + static void parseBannerMessageWithFonts(const char *message); static void resetBanner(); static void showKeyboardMessagePopupWithTitle(const char *title, const char *content, uint32_t durationMs); static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state); diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 5a4d150aeb1..ac52a99bc4c 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -587,51 +587,30 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks virtual uint32_t onPassKeyRequest() override #endif { - uint32_t passkey = config.bluetooth.fixed_pin; + uint32_t configuredPasskey = config.bluetooth.fixed_pin; if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN) { LOG_INFO("Use random passkey"); // This is the passkey to be entered on peer - we pick a number >100,000 to ensure 6 digits - passkey = random(100000, 999999); + configuredPasskey = random(100000, 999999); } - LOG_INFO("*** Enter passkey %d on the peer side ***", passkey); + LOG_INFO("*** Enter passkey %d on the peer side ***", configuredPasskey); powerFSM.trigger(EVENT_BLUETOOTH_PAIR); - meshtastic::BluetoothStatus newStatus(std::to_string(passkey)); + std::string passkey = std::to_string(configuredPasskey); + meshtastic::BluetoothStatus newStatus(passkey); bluetoothStatus->updateStatus(&newStatus); #if HAS_SCREEN // Todo: migrate this display code back into Screen class, and observe bluetoothStatus if (screen) { - screen->startAlert([passkey](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { - char btPIN[16] = "888888"; - snprintf(btPIN, sizeof(btPIN), "%06u", passkey); - int x_offset = display->width() / 2; - int y_offset = display->height() <= 80 ? 0 : 12; - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->setFont(FONT_MEDIUM); - display->drawString(x_offset + x, y_offset + y, "Bluetooth"); -#if !defined(M5STACK_UNITC6L) - display->setFont(FONT_SMALL); - y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_MEDIUM - 4 : y_offset + FONT_HEIGHT_MEDIUM + 5; - display->drawString(x_offset + x, y_offset + y, "Enter this code"); -#endif - display->setFont(FONT_LARGE); - char pin[8]; - snprintf(pin, sizeof(pin), "%.3s %.3s", btPIN, btPIN + 3); - y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_SMALL - 5 : y_offset + FONT_HEIGHT_SMALL + 5; - display->drawString(x_offset + x, y_offset + y, pin); - - display->setFont(FONT_SMALL); - char deviceName[64]; - snprintf(deviceName, sizeof(deviceName), "Name: %s", getDeviceName()); - y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_LARGE - 6 : y_offset + FONT_HEIGHT_LARGE + 5; - display->drawString(x_offset + x, y_offset + y, deviceName); - }); + + std::string ble_message = "Bluetooth\nPIN\n[M]" + passkey.substr(0, 3) + " " + passkey.substr(3, 6); + screen->showSimpleBanner(ble_message.c_str(), 30000); } #endif passkeyShowing = true; - return passkey; + return configuredPasskey; } #ifdef NIMBLE_TWO diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 52e45ccccc9..4af8b41f644 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -386,34 +386,12 @@ bool NRF52Bluetooth::onPairingPasskey(uint16_t conn_handle, uint8_t const passke meshtastic::BluetoothStatus newStatus(textkey); bluetoothStatus->updateStatus(&newStatus); -#if HAS_SCREEN && \ - !defined(MESHTASTIC_EXCLUDE_SCREEN) // Todo: migrate this display code back into Screen class, and observe bluetoothStatus +#if HAS_SCREEN && !defined(MESHTASTIC_EXCLUDE_SCREEN) if (screen) { - screen->startAlert([](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { - char btPIN[16] = "888888"; - snprintf(btPIN, sizeof(btPIN), "%06u", configuredPasskey); - int x_offset = display->width() / 2; - int y_offset = display->height() <= 80 ? 0 : 12; - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->setFont(FONT_MEDIUM); - display->drawString(x_offset + x, y_offset + y, "Bluetooth"); - - display->setFont(FONT_SMALL); - y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_MEDIUM - 4 : y_offset + FONT_HEIGHT_MEDIUM + 5; - display->drawString(x_offset + x, y_offset + y, "Enter this code"); - - display->setFont(FONT_LARGE); - String displayPin(btPIN); - String pin = displayPin.substring(0, 3) + " " + displayPin.substring(3, 6); - y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_SMALL - 5 : y_offset + FONT_HEIGHT_SMALL + 5; - display->drawString(x_offset + x, y_offset + y, pin); - - display->setFont(FONT_SMALL); - String deviceName = "Name: "; - deviceName.concat(getDeviceName()); - y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_LARGE - 6 : y_offset + FONT_HEIGHT_LARGE + 5; - display->drawString(x_offset + x, y_offset + y, deviceName); - }); + std::string configuredPasskeyText = std::to_string(configuredPasskey); + std::string ble_message = + "Bluetooth\nPIN\n[M]" + configuredPasskeyText.substr(0, 3) + " " + configuredPasskeyText.substr(3, 6); + screen->showSimpleBanner(ble_message.c_str(), 30000); } #endif passkeyShowing = true; From 35b0590408faddfa933edec3dafd915e714f05b1 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 13:50:17 -0500 Subject: [PATCH 191/225] Develop is 2.8 WIP now --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 56ea393171a..c08db24b826 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ [VERSION] major = 2 -minor = 7 -build = 24 +minor = 8 +build = 0 From 7bdff8ff706b76fcd3d3a972e0f8168c7b5b5da8 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 13 May 2026 14:33:15 -0500 Subject: [PATCH 192/225] Bump protos --- protobufs | 2 +- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 39 +++++++++++++------ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/protobufs b/protobufs index ff5b3925037..108919393a2 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit ff5b392503776bf13073034070543d5c5aa1acf7 +Subproject commit 108919393a2a3fdf6ab82e50e10965e74394620f diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 17bec9b3a32..7c14c3e0fbc 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -448,7 +448,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size #define meshtastic_BackupPreferences_size 2432 #define meshtastic_ChannelFile_size 718 -#define meshtastic_DeviceState_size 1737 +#define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 170 #define meshtastic_NodeInfoLite_size 105 #define meshtastic_NodePositionEntry_size 42 diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index f6fe88019a5..f0fea08d7cc 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -820,6 +820,7 @@ typedef struct _meshtastic_Routing { } meshtastic_Routing; typedef PB_BYTES_ARRAY_T(233) meshtastic_Data_payload_t; +typedef PB_BYTES_ARRAY_T(64) meshtastic_Data_xeddsa_signature_t; /* (Formerly called SubPacket) The payload portion fo a packet, this is the actual bytes that are sent inside a radio packet (because from/to are broken out by the comms library) */ @@ -853,6 +854,8 @@ typedef struct _meshtastic_Data { /* Bitfield for extra flags. First use is to indicate that user approves the packet being uploaded to MQTT. */ bool has_bitfield; uint8_t bitfield; + /* XEdDSA signature for the payload */ + meshtastic_Data_xeddsa_signature_t xeddsa_signature; } meshtastic_Data; typedef PB_BYTES_ARRAY_T(32) meshtastic_KeyVerification_hash1_t; @@ -1057,6 +1060,8 @@ typedef struct _meshtastic_MeshPacket { uint32_t tx_after; /* Indicates which transport mechanism this packet arrived over */ meshtastic_MeshPacket_TransportMechanism transport_mechanism; + /* Indicates whether the packet has a valid signature */ + bool xeddsa_signed; } meshtastic_MeshPacket; /* The bluetooth to device link: @@ -1113,6 +1118,10 @@ typedef struct _meshtastic_NodeInfo { /* True if node has been muted Persistes between NodeDB internal clean ups */ bool is_muted; + /* True if node is signing its packets via XEdDSA + Persists between NodeDB internal clean ups + LSB 1 of the bitfield */ + bool has_xeddsa_signed; } meshtastic_NodeInfo; typedef PB_BYTES_ARRAY_T(16) meshtastic_MyNodeInfo_device_id_t; @@ -1582,15 +1591,15 @@ extern "C" { #define meshtastic_User_init_default {"", "", "", {0}, _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} #define meshtastic_RouteDiscovery_init_default {0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}} #define meshtastic_Routing_init_default {0, {meshtastic_RouteDiscovery_init_default}} -#define meshtastic_Data_init_default {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0} +#define meshtastic_Data_init_default {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0, {0, {0}}} #define meshtastic_KeyVerification_init_default {0, {0, {0}}, {0, {0}}} #define meshtastic_StoreForwardPlusPlus_init_default {_meshtastic_StoreForwardPlusPlus_SFPP_message_type_MIN, {0, {0}}, {0, {0}}, {0, {0}}, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_RemoteShell_init_default {_meshtastic_RemoteShell_OpCode_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_Waypoint_init_default {0, false, 0, false, 0, 0, 0, "", "", 0} #define meshtastic_StatusMessage_init_default {""} #define meshtastic_MqttClientProxyMessage_init_default {"", 0, {{0, {0}}}, 0} -#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN} -#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} +#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_default {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_default {0, 0, 0, 0} @@ -1617,15 +1626,15 @@ extern "C" { #define meshtastic_User_init_zero {"", "", "", {0}, _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} #define meshtastic_RouteDiscovery_init_zero {0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}} #define meshtastic_Routing_init_zero {0, {meshtastic_RouteDiscovery_init_zero}} -#define meshtastic_Data_init_zero {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0} +#define meshtastic_Data_init_zero {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0, {0, {0}}} #define meshtastic_KeyVerification_init_zero {0, {0, {0}}, {0, {0}}} #define meshtastic_StoreForwardPlusPlus_init_zero {_meshtastic_StoreForwardPlusPlus_SFPP_message_type_MIN, {0, {0}}, {0, {0}}, {0, {0}}, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_RemoteShell_init_zero {_meshtastic_RemoteShell_OpCode_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_Waypoint_init_zero {0, false, 0, false, 0, 0, 0, "", "", 0} #define meshtastic_StatusMessage_init_zero {""} #define meshtastic_MqttClientProxyMessage_init_zero {"", 0, {{0, {0}}}, 0} -#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN} -#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} +#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_zero {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_zero {0, 0, 0, 0} @@ -1698,6 +1707,7 @@ extern "C" { #define meshtastic_Data_reply_id_tag 7 #define meshtastic_Data_emoji_tag 8 #define meshtastic_Data_bitfield_tag 9 +#define meshtastic_Data_xeddsa_signature_tag 10 #define meshtastic_KeyVerification_nonce_tag 1 #define meshtastic_KeyVerification_hash1_tag 2 #define meshtastic_KeyVerification_hash2_tag 3 @@ -1755,6 +1765,7 @@ extern "C" { #define meshtastic_MeshPacket_relay_node_tag 19 #define meshtastic_MeshPacket_tx_after_tag 20 #define meshtastic_MeshPacket_transport_mechanism_tag 21 +#define meshtastic_MeshPacket_xeddsa_signed_tag 22 #define meshtastic_NodeInfo_num_tag 1 #define meshtastic_NodeInfo_user_tag 2 #define meshtastic_NodeInfo_position_tag 3 @@ -1768,6 +1779,7 @@ extern "C" { #define meshtastic_NodeInfo_is_ignored_tag 11 #define meshtastic_NodeInfo_is_key_manually_verified_tag 12 #define meshtastic_NodeInfo_is_muted_tag 13 +#define meshtastic_NodeInfo_has_xeddsa_signed_tag 14 #define meshtastic_MyNodeInfo_my_node_num_tag 1 #define meshtastic_MyNodeInfo_reboot_count_tag 8 #define meshtastic_MyNodeInfo_min_app_version_tag 11 @@ -1934,7 +1946,8 @@ X(a, STATIC, SINGULAR, FIXED32, source, 5) \ X(a, STATIC, SINGULAR, FIXED32, request_id, 6) \ X(a, STATIC, SINGULAR, FIXED32, reply_id, 7) \ X(a, STATIC, SINGULAR, FIXED32, emoji, 8) \ -X(a, STATIC, OPTIONAL, UINT32, bitfield, 9) +X(a, STATIC, OPTIONAL, UINT32, bitfield, 9) \ +X(a, STATIC, SINGULAR, BYTES, xeddsa_signature, 10) #define meshtastic_Data_CALLBACK NULL #define meshtastic_Data_DEFAULT NULL @@ -2019,7 +2032,8 @@ X(a, STATIC, SINGULAR, BOOL, pki_encrypted, 17) \ X(a, STATIC, SINGULAR, UINT32, next_hop, 18) \ X(a, STATIC, SINGULAR, UINT32, relay_node, 19) \ X(a, STATIC, SINGULAR, UINT32, tx_after, 20) \ -X(a, STATIC, SINGULAR, UENUM, transport_mechanism, 21) +X(a, STATIC, SINGULAR, UENUM, transport_mechanism, 21) \ +X(a, STATIC, SINGULAR, BOOL, xeddsa_signed, 22) #define meshtastic_MeshPacket_CALLBACK NULL #define meshtastic_MeshPacket_DEFAULT NULL #define meshtastic_MeshPacket_payload_variant_decoded_MSGTYPE meshtastic_Data @@ -2037,7 +2051,8 @@ X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \ X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ X(a, STATIC, SINGULAR, BOOL, is_key_manually_verified, 12) \ -X(a, STATIC, SINGULAR, BOOL, is_muted, 13) +X(a, STATIC, SINGULAR, BOOL, is_muted, 13) \ +X(a, STATIC, SINGULAR, BOOL, has_xeddsa_signed, 14) #define meshtastic_NodeInfo_CALLBACK NULL #define meshtastic_NodeInfo_DEFAULT NULL #define meshtastic_NodeInfo_user_MSGTYPE meshtastic_User @@ -2339,7 +2354,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_ChunkedPayload_size 245 #define meshtastic_ClientNotification_size 482 #define meshtastic_Compressed_size 239 -#define meshtastic_Data_size 269 +#define meshtastic_Data_size 335 #define meshtastic_DeviceMetadata_size 54 #define meshtastic_DuplicatedPublicKey_size 0 #define meshtastic_FileInfo_size 236 @@ -2352,12 +2367,12 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_LockdownStatus_size 53 #define meshtastic_LogRecord_size 426 #define meshtastic_LowEntropyKey_size 0 -#define meshtastic_MeshPacket_size 381 +#define meshtastic_MeshPacket_size 450 #define meshtastic_MqttClientProxyMessage_size 501 #define meshtastic_MyNodeInfo_size 83 #define meshtastic_NeighborInfo_size 258 #define meshtastic_Neighbor_size 22 -#define meshtastic_NodeInfo_size 325 +#define meshtastic_NodeInfo_size 327 #define meshtastic_NodeRemoteHardwarePin_size 29 #define meshtastic_Position_size 144 #define meshtastic_QueueStatus_size 23 From fce419b335e33066d5f1cbea56542e36b230e3f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 06:43:06 -0500 Subject: [PATCH 193/225] Upgrade trunk (#10476) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index ad264bd76e3..9c4409b8aa1 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -8,7 +8,7 @@ plugins: uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.528 + - checkov@3.2.529 - renovate@43.150.0 - prettier@3.8.3 - trufflehog@3.95.3 From 48274981883b909190963d6dfb459ee4a9a2e39e Mon Sep 17 00:00:00 2001 From: Jord <650645+Jord-JD@users.noreply.github.com> Date: Fri, 15 May 2026 02:51:44 +0100 Subject: [PATCH 194/225] Clamp direct position packets to channel precision (fixes #8640) (#10383) * Fix position precision for direct sends * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Clarify zero position precision logging * Use const channel reference for position precision * Use C linkage for position precision test entrypoints --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mesh/PositionPrecision.cpp | 75 ++++++++++++++++ src/mesh/PositionPrecision.h | 9 ++ src/mesh/Router.cpp | 6 ++ src/modules/PositionModule.cpp | 48 +++------- test/test_position_precision/test_main.cpp | 100 +++++++++++++++++++++ 5 files changed, 204 insertions(+), 34 deletions(-) create mode 100644 src/mesh/PositionPrecision.cpp create mode 100644 src/mesh/PositionPrecision.h create mode 100644 test/test_position_precision/test_main.cpp diff --git a/src/mesh/PositionPrecision.cpp b/src/mesh/PositionPrecision.cpp new file mode 100644 index 00000000000..04db01c7919 --- /dev/null +++ b/src/mesh/PositionPrecision.cpp @@ -0,0 +1,75 @@ +#include "PositionPrecision.h" +#include "Channels.h" +#include "mesh-pb-constants.h" + +#include + +uint32_t getPositionPrecisionForChannel(uint8_t channelIndex) +{ + const meshtastic_Channel &channel = channels.getByIndex(channelIndex); + + if (channel.settings.has_module_settings) { + return channel.settings.module_settings.position_precision; + } else if (channel.role == meshtastic_Channel_Role_PRIMARY) { + return 32; + } else { + return 0; + } +} + +static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision) +{ + uint32_t coordinateBits = static_cast(coordinate); + uint32_t truncated = coordinateBits & (UINT32_MAX << (32 - precision)); + + // Use the middle of the possible location, not the low edge of the bucket. + truncated += (1UL << (31 - precision)); + + return static_cast(truncated); +} + +void applyPositionPrecision(meshtastic_Position &position, uint32_t precision) +{ + if (precision == 0) { + uint32_t time = position.time; + position = meshtastic_Position_init_default; + position.time = time; + return; + } + + uint32_t effectivePrecision = precision > 32 ? 32 : precision; + position.precision_bits = effectivePrecision; + + if (effectivePrecision < 32) { + position.latitude_i = truncateCoordinate(position.latitude_i, effectivePrecision); + position.longitude_i = truncateCoordinate(position.longitude_i, effectivePrecision); + } +} + +bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision) +{ + if (packet.which_payload_variant != meshtastic_MeshPacket_decoded_tag || + packet.decoded.portnum != meshtastic_PortNum_POSITION_APP) { + return true; + } + + meshtastic_Position position = meshtastic_Position_init_default; + if (!pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &position)) { + return false; + } + + applyPositionPrecision(position, precision); + packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), + &meshtastic_Position_msg, &position); + return true; +} + +bool applyPositionPrecisionForChannel(meshtastic_MeshPacket &packet, uint8_t channelIndex) +{ + if (packet.which_payload_variant != meshtastic_MeshPacket_decoded_tag || + packet.decoded.portnum != meshtastic_PortNum_POSITION_APP) { + return true; + } + + return applyPositionPrecision(packet, getPositionPrecisionForChannel(channelIndex)); +} diff --git a/src/mesh/PositionPrecision.h b/src/mesh/PositionPrecision.h new file mode 100644 index 00000000000..6fdbd2f6435 --- /dev/null +++ b/src/mesh/PositionPrecision.h @@ -0,0 +1,9 @@ +#pragma once + +#include "meshtastic/mesh.pb.h" +#include + +uint32_t getPositionPrecisionForChannel(uint8_t channelIndex); +void applyPositionPrecision(meshtastic_Position &position, uint32_t precision); +bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision); +bool applyPositionPrecisionForChannel(meshtastic_MeshPacket &packet, uint8_t channelIndex); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 980ceb6df6d..52731fe43ce 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -4,6 +4,7 @@ #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" +#include "PositionPrecision.h" #include "RTC.h" #include "configuration.h" @@ -367,6 +368,11 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) } fixPriority(p); // Before encryption, fix the priority if it's unset + if (!applyPositionPrecisionForChannel(*p, p->channel)) { + LOG_ERROR("Dropping malformed position packet before send"); + packetPool.release(p); + return meshtastic_Routing_Error_BAD_REQUEST; + } // If the packet is not yet encrypted, do so now if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index c13904c311b..d2603627b16 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -4,6 +4,7 @@ #include "GPS.h" #include "MeshService.h" #include "NodeDB.h" +#include "PositionPrecision.h" #include "RTC.h" #include "Router.h" #include "TransmitHistory.h" @@ -107,13 +108,7 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes } nodeDB->updatePosition(getFrom(&mp), p); - if (channels.getByIndex(mp.channel).settings.has_module_settings) { - precision = channels.getByIndex(mp.channel).settings.module_settings.position_precision; - } else if (channels.getByIndex(mp.channel).role == meshtastic_Channel_Role_PRIMARY) { - precision = 32; - } else { - precision = 0; - } + precision = getPositionPrecisionForChannel(mp.channel); return false; // Let others look at this message also if they want } @@ -121,15 +116,12 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes void PositionModule::alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic_Position *p) { // Phone position packets need to be truncated to the channel precision - if (isFromUs(&mp) && (precision < 32 && precision > 0)) { - LOG_DEBUG("Truncate phone position to channel precision %i", precision); - p->latitude_i = p->latitude_i & (UINT32_MAX << (32 - precision)); - p->longitude_i = p->longitude_i & (UINT32_MAX << (32 - precision)); - - // We want the imprecise position to be the middle of the possible location, not - p->latitude_i += (1 << (31 - precision)); - p->longitude_i += (1 << (31 - precision)); - + if (isFromUs(&mp)) { + if (precision == 0) + LOG_DEBUG("Strip phone position due to channel precision 0"); + else if (precision < 32) + LOG_DEBUG("Truncate phone position to channel precision %i", precision); + applyPositionPrecision(*p, precision); mp.decoded.payload.size = pb_encode_to_bytes(mp.decoded.payload.bytes, sizeof(mp.decoded.payload.bytes), &meshtastic_Position_msg, p); } @@ -206,20 +198,11 @@ meshtastic_MeshPacket *PositionModule::allocPositionPacket() // lat/lon are unconditionally included - IF AVAILABLE! LOG_DEBUG("Send location with precision %i", precision); - if (precision < 32 && precision > 0) { - p.latitude_i = localPosition.latitude_i & (UINT32_MAX << (32 - precision)); - p.longitude_i = localPosition.longitude_i & (UINT32_MAX << (32 - precision)); - - // We want the imprecise position to be the middle of the possible location, not - p.latitude_i += (1 << (31 - precision)); - p.longitude_i += (1 << (31 - precision)); - } else { - p.latitude_i = localPosition.latitude_i; - p.longitude_i = localPosition.longitude_i; - } - p.precision_bits = precision; + p.latitude_i = localPosition.latitude_i; + p.longitude_i = localPosition.longitude_i; p.has_latitude_i = true; p.has_longitude_i = true; + applyPositionPrecision(p, precision); // Always use NTP / GPS time if available if (getValidTime(RTCQualityNTP) > 0) { p.time = getValidTime(RTCQualityNTP); @@ -350,8 +333,7 @@ void PositionModule::sendOurPosition() // If we changed channels, ask everyone else for their latest info LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies); for (uint8_t channelNum = 0; channelNum < 8; channelNum++) { - if (channels.getByIndex(channelNum).settings.has_module_settings && - channels.getByIndex(channelNum).settings.module_settings.position_precision != 0) { + if (getPositionPrecisionForChannel(channelNum) != 0) { sendOurPosition(NODENUM_BROADCAST, requestReplies, channelNum); return; } @@ -369,10 +351,8 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha if (prevPacketId) // if we wrap around to zero, we'll simply fail to cancel in that rare case (no big deal) service->cancelSending(prevPacketId); - // Set's the class precision value for this particular packet - if (channels.getByIndex(channel).settings.has_module_settings) { - precision = channels.getByIndex(channel).settings.module_settings.position_precision; - } + // Set the class precision value for this particular packet. + precision = getPositionPrecisionForChannel(channel); meshtastic_MeshPacket *p = allocPositionPacket(); if (p == nullptr) { diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp new file mode 100644 index 00000000000..4f5aecfda97 --- /dev/null +++ b/test/test_position_precision/test_main.cpp @@ -0,0 +1,100 @@ +#include "PositionPrecision.h" +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include + +static meshtastic_Position makePosition() +{ + meshtastic_Position position = meshtastic_Position_init_default; + position.has_latitude_i = true; + position.latitude_i = static_cast(0x12345678); + position.has_longitude_i = true; + position.longitude_i = static_cast(0x22345678); + position.has_altitude = true; + position.altitude = 123; + position.time = 42; + position.location_source = meshtastic_Position_LocSource_LOC_EXTERNAL; + position.timestamp = 43; + position.sats_in_view = 10; + return position; +} + +static void test_applyPositionPrecision_clampsLatLonAndSetsPrecisionBits() +{ + meshtastic_Position position = makePosition(); + + applyPositionPrecision(position, 16); + + TEST_ASSERT_EQUAL_INT32(static_cast(0x12348000), position.latitude_i); + TEST_ASSERT_EQUAL_INT32(static_cast(0x22348000), position.longitude_i); + TEST_ASSERT_EQUAL_UINT32(16, position.precision_bits); + TEST_ASSERT_TRUE(position.has_latitude_i); + TEST_ASSERT_TRUE(position.has_longitude_i); +} + +static void test_applyPositionPrecision_fullPrecisionKeepsLatLon() +{ + meshtastic_Position position = makePosition(); + + applyPositionPrecision(position, 32); + + TEST_ASSERT_EQUAL_INT32(static_cast(0x12345678), position.latitude_i); + TEST_ASSERT_EQUAL_INT32(static_cast(0x22345678), position.longitude_i); + TEST_ASSERT_EQUAL_UINT32(32, position.precision_bits); +} + +static void test_applyPositionPrecision_zeroScrubsLocationButKeepsTime() +{ + meshtastic_Position position = makePosition(); + + applyPositionPrecision(position, 0); + + TEST_ASSERT_FALSE(position.has_latitude_i); + TEST_ASSERT_EQUAL_INT32(0, position.latitude_i); + TEST_ASSERT_FALSE(position.has_longitude_i); + TEST_ASSERT_EQUAL_INT32(0, position.longitude_i); + TEST_ASSERT_FALSE(position.has_altitude); + TEST_ASSERT_EQUAL_INT32(0, position.altitude); + TEST_ASSERT_EQUAL_UINT32(42, position.time); + TEST_ASSERT_EQUAL_UINT32(0, position.timestamp); + TEST_ASSERT_EQUAL_UINT32(0, position.sats_in_view); + TEST_ASSERT_EQUAL_UINT32(0, position.precision_bits); +} + +static void test_applyPositionPrecision_reencodesPositionPacket() +{ + meshtastic_Position position = makePosition(); + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_default; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = meshtastic_PortNum_POSITION_APP; + packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), + &meshtastic_Position_msg, &position); + + TEST_ASSERT_TRUE(applyPositionPrecision(packet, 16)); + + meshtastic_Position decoded = meshtastic_Position_init_default; + TEST_ASSERT_TRUE( + pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &decoded)); + TEST_ASSERT_EQUAL_INT32(static_cast(0x12348000), decoded.latitude_i); + TEST_ASSERT_EQUAL_INT32(static_cast(0x22348000), decoded.longitude_i); + TEST_ASSERT_EQUAL_UINT32(16, decoded.precision_bits); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_applyPositionPrecision_clampsLatLonAndSetsPrecisionBits); + RUN_TEST(test_applyPositionPrecision_fullPrecisionKeepsLatLon); + RUN_TEST(test_applyPositionPrecision_zeroScrubsLocationButKeepsTime); + RUN_TEST(test_applyPositionPrecision_reencodesPositionPacket); + exit(UNITY_END()); +} + +void loop() {} +} From 1c05633fcdd91b2d5688930f2cd7153108c7d8c6 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 15 May 2026 05:59:15 -0500 Subject: [PATCH 195/225] Add more support for small fonts in screen resolution determination (#10480) --- src/graphics/SharedUIDisplay.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index ec50654aef3..032b14dfa0f 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -27,6 +27,12 @@ ScreenResolution determineScreenResolution(int16_t screenheight, int16_t screenw return ScreenResolution::UltraLow; } +#ifdef DISPLAY_FORCE_SMALL_FONTS + if (screenwidth <= 160 && screenheight <= 80) { + return ScreenResolution::Low; + } +#endif + // Standard OLED screens if (screenwidth > 128 && screenheight <= 64) { return ScreenResolution::Low; From 2a91d186ebb53ee47888840b9d8d7ba17a27d4ab Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 15 May 2026 15:14:29 -0500 Subject: [PATCH 196/225] Add max_session_seconds to LockdownAuth for session management --- protobufs | 2 +- src/mesh/generated/meshtastic/admin.pb.h | 45 +++++++++++++++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/protobufs b/protobufs index 108919393a2..7ffb4bb60de 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 108919393a2a3fdf6ab82e50e10965e74394620f +Subproject commit 7ffb4bb60ded743a1ce23fe2edd5ead32be52bbb diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index e6f5110ad30..82644bc2a46 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -163,6 +163,41 @@ typedef struct _meshtastic_LockdownAuth { connection-level admin authorization, and reboot the device into the locked state. Always honoured regardless of current lock state. */ bool lock_now; + /* Optional per-boot uptime cap on the unlocked session, in seconds. + 0 = unlimited (token-only enforcement, suitable for unattended + tower / infrastructure nodes). + + When non-zero, the firmware arms an uptime timer at unlock. On + each expiry, while there is still boot-count budget, the firmware + decrements the on-flash boot count in place, revokes per- + connection admin auth (clients must re-authenticate to see + content), re-engages the screen lock, and re-arms the timer + without rebooting. Mesh routing keeps running across session + boundaries; only when the boot-count budget reaches zero does + the device hard-lock and reboot. + + Total exposure ceiling = ((resolved boot count) + 1) * max_session_seconds. + The +1 accounts for the initial passphrase-unlocked session + itself, since boots_remaining is the number of subsequent + session rolls (each consuming one boot from the rollback ledger). + The resolved boot count is the value the firmware writes into the + token at unlock time: the client-supplied boots_remaining when + non-zero, otherwise the firmware default (TOKEN_DEFAULT_BOOTS). + Note that boots_remaining == 0 in this message means "use firmware + default", NOT "zero boots" — a client computing the ceiling for + display should mirror that resolution rather than multiplying the + raw request value. + + The cap is persisted in the token, so it survives token-based + auto-unlock across reboots. Explicit operator Lock Now still + deletes the token and forces passphrase re-entry. + + Uses millis() (CPU uptime), not wall-clock time, so the cap is + immune to GPS spoofing, RTC backup-battery removal, and Faraday + cage isolation — none of those move the uptime counter. The only + way to reset the session clock is a reboot, which costs a boot + from the on-flash, HMAC-bound counter. */ + uint32_t max_session_seconds; } meshtastic_LockdownAuth; /* Parameters for setting up Meshtastic for ameteur radio usage */ @@ -486,7 +521,7 @@ extern "C" { #define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}} -#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0} +#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0} #define meshtastic_HamParameters_init_default {"", 0, 0, ""} #define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} #define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0} @@ -499,7 +534,7 @@ extern "C" { #define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}} -#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0} +#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0} #define meshtastic_HamParameters_init_zero {"", 0, 0, ""} #define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} #define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0} @@ -521,6 +556,7 @@ extern "C" { #define meshtastic_LockdownAuth_boots_remaining_tag 2 #define meshtastic_LockdownAuth_valid_until_epoch_tag 3 #define meshtastic_LockdownAuth_lock_now_tag 4 +#define meshtastic_LockdownAuth_max_session_seconds_tag 5 #define meshtastic_HamParameters_call_sign_tag 1 #define meshtastic_HamParameters_tx_power_tag 2 #define meshtastic_HamParameters_frequency_tag 3 @@ -717,7 +753,8 @@ X(a, STATIC, SINGULAR, BYTES, ota_hash, 2) X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \ X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \ X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \ -X(a, STATIC, SINGULAR, BOOL, lock_now, 4) +X(a, STATIC, SINGULAR, BOOL, lock_now, 4) \ +X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5) #define meshtastic_LockdownAuth_CALLBACK NULL #define meshtastic_LockdownAuth_DEFAULT NULL @@ -832,7 +869,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_AdminMessage_size 511 #define meshtastic_HamParameters_size 31 #define meshtastic_KeyVerificationAdmin_size 25 -#define meshtastic_LockdownAuth_size 48 +#define meshtastic_LockdownAuth_size 54 #define meshtastic_NodeRemoteHardwarePinsResponse_size 496 #define meshtastic_SCD30_config_size 27 #define meshtastic_SCD4X_config_size 29 From 05707079bd218c0e7ca6968d3006e96a475e7528 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 19:30:04 -0500 Subject: [PATCH 197/225] Update libch341-spi-userspace digest to 2e5ff75 (#10485) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/native/portduino.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 9ab45d1ab66..2fa8e865853 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -29,7 +29,7 @@ lib_deps = # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.21 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main - https://github.com/pine64/libch341-spi-userspace/archive/23c42319a69cffcb65868e3c72e6bed83974a393.zip + https://github.com/pine64/libch341-spi-userspace/archive/2e5ff751d0c39667993df672cb683740ed5c9394.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library adafruit/Adafruit seesaw Library@1.7.9 # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main From 502c5af524a2338e5afe834c3a401c8c99b03312 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 20:03:50 -0500 Subject: [PATCH 198/225] Upgrade trunk (#10481) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 9c4409b8aa1..e055a6d505c 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -4,7 +4,7 @@ cli: plugins: sources: - id: trunk - ref: v1.9.0 + ref: v1.10.0 uri: https://github.com/trunk-io/plugins lint: enabled: @@ -16,7 +16,7 @@ lint: - bandit@1.9.4 - trivy@0.70.0 - taplo@0.10.0 - - ruff@0.15.12 + - ruff@0.15.13 - isort@8.0.1 - markdownlint@0.48.0 - oxipng@10.1.1 From 4a1ff18f57b06fd2a88895b5e2cb749e8adafd32 Mon Sep 17 00:00:00 2001 From: Carlos Valdes Date: Sat, 16 May 2026 13:16:11 +0200 Subject: [PATCH 199/225] feat: add Nordic nRF54L15-DK variant (Zephyr + BLE + LoRa) (#10193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Nordic nRF54L15-DK variant (Zephyr + BLE + LoRa) Adds a community hardware variant for the Nordic nRF54L15-DK (PCA10156) with an external EBYTE E22-900M30S (SX1262) LoRa module. First Meshtastic port running on the Zephyr RTOS; all other Nordic targets use the nRF5 SoftDevice stack. Scope ----- - New Zephyr-based platform layer under src/platform/nrf54l15/ providing Arduino-compatible shims (Arduino.h, SPI, Wire, Print, Stream) over the Zephyr APIs plus a LittleFS-backed InternalFileSystem on SPIM20. - Bluetooth LE peripheral (NRF54L15Bluetooth.*) built on the Zephyr BT host stack, exposing the Meshtastic GATT service with legacy connectable advertising, just-works pairing, dynamic MTU exchange (up to 247 bytes), and iOS connection-parameter tweaks. - Variant directory variants/nrf54l15/nrf54l15dk/ with pin map for the E22 module on connector J1, PlatformIO env (nrf54l15dk), Zephyr DT overlay and a wiring README. - Zephyr project config (zephyr/prj.conf + board overlay) tuned for BT + LoRa: 16 KB main stack, 4 KB BT RX thread, RTT logging in immediate mode, newlib-nano heap sized to leave room for the GATT pools while still allowing ATT MTU=247. - extra_scripts/nrf54l15_linker.py works around a PlatformIO + old Ninja issue where Zephyr's two-pass linker script generation does not run automatically; the post-script parses build.ninja and invokes the gcc -E step directly before the final link. - boards/nrf54l15dk.json board definition (PlatformIO needs it for the DK; the Seeed platform only ships the XIAO variants). - variants/rp2350/rp2350.ini excludes platform/nrf54l15/ from RP2350 build_src_filter so the shared platform tree does not leak between targets. - .gitignore: add nRF J-Link / RTT debug artifacts (flash.jlink, rtt_*.txt). Shared source changes --------------------- - src/main.{cpp,h}, src/RedirectablePrint.cpp, src/FSCommon.{cpp,h}, src/mesh/{Channels,NodeDB,RadioLibInterface,MeshService,PhoneAPI}.cpp, src/mesh/RadioLibInterface.h, src/modules/AdminModule.cpp: add small guards / helpers so the Zephyr build compiles alongside the Arduino targets. Behavior on existing boards is unchanged. Hardware model -------------- HW_VENDOR maps to meshtastic_HardwareModel_PRIVATE_HW until a dedicated protobuf enum value is assigned upstream. The variant declares custom_meshtastic_hw_model = 132 so the maintainers can wire the new enum value through the protobufs repo after merge. Hardware note ------------- The E22-900M30S does not connect its DIO2 pin to TXEN internally — a wire/solder bridge between DIO2 and TXEN on the module is required for TX to work. Details and full pin map are in the variant README. Validation ---------- Built clean against develop. On real hardware (April 2026) the port passes end-to-end: iOS companion app pairs and connects, configuration round-trip works, LoRa TX/RX reaches a canonical tbeam on the same mesh channel, NodeDB updates propagate both ways, and traceroute completes. * fix(nrf54l15): use atomic fs_rename instead of copy fallback Zephyr LittleFS on nrf54l15 supports fs_rename natively, so route it through the same atomic path as ESP32. The previous copyFile+remove fallback truncated the destination before copying, leaving 0-byte files if interrupted mid-write. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(nrf54l15): expand storage_partition from 36KB to 700KB LittleFS on the default 9-block (36KB) storage_partition ran out of space during copy-on-write of config.proto, causing fs_write to return ENOSPC and pb_encode to surface "io error" when saving configuration via the mobile app. Reclaim slot1_partition (the MCUboot secondary slot — unused since we flash directly via J-Link) and grow storage_partition to span 0xb6000..0x165000 (~175 blocks). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(nrf54l15): drop USERPREFS_LORACONFIG_* so LoRa config stays mutable NodeDB rewrites LoRa config from USERPREFS_LORACONFIG_* on every boot, which prevented reconfiguration via the BLE/serial app. Drop the variant-level defaults; users configure region and modem preset through the app like every other variant. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(nrf54l15): enforce MITM passkey pairing on GATT service - Add MESH_PERM_READ/MESH_PERM_WRITE macros (READ_AUTHEN/WRITE_AUTHEN) on all mesh service characteristics so clients must complete passkey exchange before accessing fromNum/fromRadio/toRadio/logRadio. - Wire FIXED_PIN mode to bt_passkey_set() so the device advertises a known PIN (config.bluetooth.fixed_pin); RANDOM_PIN keeps default per-pairing random passkey. - Reduce BleDeferredThread HARD_WATCHDOG_MS from 3min to 1min. - prj.conf: CONFIG_BT_SMP_ENFORCE_MITM=y, CONFIG_BT_FIXED_PASSKEY=y, CONFIG_BT_SMP_SC_PAIR_ONLY=n (legacy fallback for clients that abort SC pairing with reason 0x01 within 150ms). * fix(nrf54l15): resolve develop-merge conflict + cppcheck warnings The `Merge branch 'develop'` left two ~RadioLibInterface() declarations in src/mesh/RadioLibInterface.h: the inline version added upstream by PR #10254 (which independently applied the same UAF guard this PR was carrying) and the out-of-line version this PR introduced. GCC rejects the duplicate, breaking every platform build. Drop the out-of-line declaration + definition; keep upstream's inline form. Also silence the 13 cppcheck low warnings introduced by the new nrf54l15 Arduino shim — Arduino's `String`/`SPISettings` API contract relies on implicit single-arg constructors used pervasively by existing Meshtastic code, so suppress `noExplicitConstructor` inline with a comment instead of breaking the API. The few mechanical wins (`const tmp[2]`, `const uint32_t *sp`) are applied directly. * fmt: fix Trunk Check lint issues on nrf54l15-port - extra_scripts/nrf54l15_linker.py: move regular imports above Import("env") to silence E402, add trunk-ignore-all(F821) for the PIO/SCons SConstruct injection (matches esp32_pre.py / nrf52_extra.py convention) - src/platform/nrf54l15/NRF54L15Bluetooth.cpp: clang-format 16.0.3 - boards/nrf54l15dk.json + variants/nrf54l15/nrf54l15dk/README.md: prettier 3.8.3 (also resolves markdownlint MD060 on README tables) No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(nrf54l15): address Copilot review comments + correct clang-format style Six review threads from the 2026-04-30 Copilot review: - src/platform/nrf54l15/nrf54l15_main.cpp: validate PSP against the nRF54L15 SRAM range (0x20000000..0x20040000) and 4-byte alignment before walking the faulting thread's stack, and clamp the walk so it never reads past the end of RAM. Prevents a second fault inside the fatal handler when PSP is corrupted (common in real faults). - src/platform/nrf54l15/nrf54l15_arduino.cpp: gate the bring-up printk traces in digitalWrite/digitalRead (CS/NRESET toggle log, BUSY-before-NRESET snapshot, BUSY periodic timeline) behind a new -DNRF54L15_GPIO_DEBUG flag that is off by default. The "dev NOT READY" message stays unconditional — it indicates a genuine hardware/DTS misconfig. - src/modules/AdminModule.cpp: don't mutate config.device.output_gpio_enabled from handleGetConfig(). Reflect the live pin state in the response payload only — a getter must not write back to disk-persisted state. - src/platform/nrf54l15/InternalFileSystem.h: derive totalBytes() from FIXED_PARTITION_SIZE(storage_partition) at compile time so it tracks the DK overlay's ~700 KB partition instead of the stale 36 KB hard-coded value. Updated the file header comment accordingly. - extra_scripts/nrf54l15_linker.py: make _extract_gcc_command() handle the POSIX Ninja COMMAND format (no `cmd.exe /C "..."` wrapper) in addition to the Windows form, so the script doesn't hard-fail on Linux/macOS hosts. - src/platform/nrf54l15/NRF54L15Bluetooth.cpp: clamp NO_PIN to RANDOM_PIN with a one-shot LOG_WARN. The mesh GATT permissions are declared with BT_GATT_PERM_*_AUTHEN and prj.conf sets CONFIG_BT_SMP_ENFORCE_MITM=y, so NO_PIN with no auth callbacks would leave every characteristic returning BT_ATT_ERR_AUTHENTICATION. Falling back to RANDOM_PIN keeps the link usable instead of silently broken. Also re-formatted this file with the project's .trunk/configs/.clang-format (Linux braces, 4-space indent, 130-col) — the previous lint-fix commit a2aca3234 accidentally used the default LLVM style here, which CI's clang-format would have rejected. Build verified: pio run -e nrf54l15dk passes, RAM 47.4%, Flash 28.6%. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(nrf54l15): address remaining Copilot review threads Round 2/3 review fixes — bugs first, then docs/portability: BLE concurrency (NRF54L15Bluetooth.cpp): - onNowHasData / sendLog / BleDeferredThread / shutdown: acquire active_conn under ble_mutex via new acquire_active_conn() helper so disconnected_cb can't free the conn between the null check and bt_conn_ref/bt_gatt_notify (use-after-unref). - write_toradio: reject writes that exceed MAX_TO_FROM_RADIO_SIZE with ATT_ERR_INVALID_ATTRIBUTE_LEN instead of returning success and silently dropping the payload (would hide failed config writes from the phone). - start_advertising: truncate the device name to fit the 31-byte legacy scan-response limit and switch to BT_DATA_NAME_SHORTENED so bt_le_adv_start() doesn't reject the payload when the name approaches CONFIG_BT_DEVICE_NAME_MAX=32. Linker / portability: - main.h: drop the rp2040Loop() forward declaration that had no definition and no callers — would surface as a link error if any RP2040 build added a call to the symbol. - nrf54l15_arduino.cpp: transfer16() now uses static __aligned(4) DMA buffers (matching transfer()), removing the EasyDMA-reachability hazard of caller-stack buffers on this part. Filesystem (InternalFileSystem.h): - usedBytes(): return real usage from fs_statvfs() instead of 0 so OTA / range-test free-space guards work. - rewindDirectory(): close the dir before reopening — Zephyr fs_dir_t has no rewind, and re-fs_opendir on an open handle leaks LittleFS state. Crash handler (nrf54l15_main.cpp): - After the stack walk, busy-wait 50 ms to flush RTT/printk and call sys_reboot(SYS_REBOOT_COLD) directly so the saved_crash record is actually reported on the next boot. Default Zephyr config has RESET_ON_FATAL_ERROR=n, so the previous k_fatal_halt() spun forever. Generalization / config: - PhoneAPI.cpp: replace the NRF54L15_DK ifdef with a MESHTASTIC_EXCLUDE_FILES_MANIFEST capability flag (defined in the nrf54l15dk env) so future variants can opt in/out without touching shared code. - variants/nrf54l15/nrf54l15.ini: parameterize libdeps include paths via ${PIOENV} so additional nRF54L15 envs sharing nrf54l15_base don't break. - prj.conf: drop the stale "36 KB storage_partition" comments — the DK overlay reclaims slot1 to ~700 KB and runtime size comes from FIXED_PARTITION_SIZE. - nrf54l15dk overlay: remove the zephyr,console / zephyr,shell-uart chosen entries that conflicted with CONFIG_UART_CONSOLE=n + RTT console; keep uart30 enabled so swapping the console is one Kconfig flip away. Build: nrf54l15dk SUCCESS (flash 28.6%, RAM 47.4%); wiznet_5500_evb_pico2 SUCCESS (verifies the shared main.h change). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(nrf54l15dk): use PRIVATE_HW (255) for custom_meshtastic_hw_model Per @thebentern's review: the nRF54L15-DK is a development kit, not a canonical Meshtastic SKU, so it falls under HardwareModel::PRIVATE_HW (255) — the same enum value already used at runtime via HW_VENDOR. The placeholder 132 is removed; no dedicated enum number will be assigned for DK boards. Slug stays NRF54L15_DK as a human-readable identifier. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(nrf54l15): unstarve bt_long_wq so SC pairing completes bt_pub_key_gen() runs the ECC P256 key generation on bt_long_wq. At default prio=10 (preemptible) and stack=1400 it gets starved by Meshtastic app threads at boot — sc_public_key stays NULL for minutes, smp_public_key() defers with SMP_FLAG_PKEY_SEND, and every SC pairing attempt stalls right after the public-key exchange. iOS shows "Connecting…" forever with no PIN prompt; bleak/CLI fails the first CCC notify write with "Protocol Error 0x05: Insufficient Authentication". Set CONFIG_BT_LONG_WQ_PRIO=0 (highest preemptible, ties with main) and CONFIG_BT_LONG_WQ_STACK_SIZE=4096 (margin for the P256M driver frames). Validated E2E with iOS Meshtastic app: bt_smp_pkey_ready fires within ~40 s of boot, 20 SC Passkey Entry rounds complete with matching pcnf/cfm, encrypt 0x01 / sec_level 0x04 (Authenticated MITM), bonded=1. * feat(nrf54l15): hardware I2C bus via TWIM30 + sensor telemetry Adds the Arduino TwoWire layer for the nRF54L15-DK so Meshtastic's sensor drivers can talk to external I2C devices over the hardware TWIM30 peripheral. Bus binding: - &uart30 disabled in the board overlay (peripheral instance 30 is shared between UARTE30 / TWIM30 / SPIM30 — pick one). Console stays on RTT via CONFIG_RTT_CONSOLE. - New i2c30_default / i2c30_sleep pinctrl with SDA=P0.03 / SCL=P0.04. External 4.7 kOhm pull-ups required on both lines. - &i2c30 enabled at I2C_BITRATE_FAST (400 kHz). - button_3 (SW3, P0.04) deleted from DTS so the pad can be claimed by i2c30 pinctrl; SW3 is still wired to the pad on the DK, do not press it during I2C use or it will short SCL to GND. Arduino layer: - src/platform/nrf54l15/Wire.cpp resolves the DT node at compile time via DEVICE_DT_GET(DT_NODELABEL(i2c30)) and dispatches Arduino's beginTransmission / write / endTransmission / requestFrom to i2c_write / i2c_write_read / i2c_read. Buffer is sized to 256 bytes for forward compatibility with the SE050 secure element on the custom PCB. - Wire.h drops the prior compile-only stubs and exposes the real TwoWire surface. - Arduino.h: BitOrder becomes an enum (not #define) so Adafruit_BusIO's `typedef BitOrder BusIOBitOrder;` compiles. Variant + build flags: - nrf54l15.ini flips HAS_WIRE / HAS_SENSOR / HAS_TELEMETRY from 0 to 1 and cherry-picks the sensor libs Meshtastic needs (BusIO, Sensor, BMP280, BME280, INA219/226/260/3221, SHT4X). The full environmental_base group is avoided because it pulls Adafruit_SSD1306 / Adafruit_GFX which rely on Arduino pin macros the Zephyr shim does not implement. - nrf54l15dk variant.h defines PIN_WIRE_SDA / PIN_WIRE_SCL for parity with the Arduino convention used by other variants. The actual bus wiring is fixed by the overlay pinctrl above. Validated 2026-05-14/15 on the DK with BMP280 @ 0x76 (temperature + barometric pressure) and INA3221 @ 0x42 (rail voltage / current); EnvironmentTelemetry / PowerTelemetry packets transmit successfully over LoRa. Footprint cost on nrf54l15dk: +45 KB flash, +1.7 KB RAM. * feat(nodedb): honor USERPREFS for environment telemetry on first boot installDefaultConfig() now respects two new compile-time prefs: USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED The mobile apps enforce a 30 min floor on environment_update_interval in the settings UI, which makes short-interval bring-up testing of new sensor hardware painful — you have to wait half an hour for the first LoRa packet to confirm wiring + driver. With these prefs baked into the variant, the firmware can ship a freshly-flashed device that broadcasts on a shorter cadence (e.g. 900 s) the moment storage_partition is empty. Both prefs are gated on #ifdef so the behavior is unchanged for any variant that does not opt in. Documented in userPrefs.jsonc with the existing telemetry-interval pref block. * fix(nrf54l15): allow multiple bonded BLE peers CONFIG_BT_MAX_PAIRED defaults to 1, so once the first peer (e.g. an iOS phone) has paired and bonded, every subsequent pairing attempt from a different MAC fails inside bt_keys_get_addr() with no free key slot — the host returns BT_SECURITY_ERR_KEY_DOES_NOT_EXIST and the second peer never gets past SMP. Raise the slot count to 4 so the device can simultaneously hold an iOS phone, a Windows host, a Linux host, and one spare bond. Add BT_KEYS_OVERWRITE_OLDEST so that once the table fills, the LRU peer is evicted on the next pairing rather than rejecting the new peer. This matches the behavior other Meshtastic ports already provide (nRF52 uses CONFIG_BT_PERIPHERAL_PRIO_CONN with similar semantics). Discovered while bringing up the Python CLI on Windows alongside the existing iOS bond. * fix(nrf54l15): zero-initialize TwoWire buffers + clang-format Wire cppcheck on every CI target (esp32s3, rp2040, rp2350, nrf52840, ...) was failing the build with two `uninitMemberVar` warnings on TwoWire's constructor: `txBuf` and `rxBuf` (256-byte arrays) were not initialized. Even though the buffers are only read after txLen/rxLen is set, leaving them uninitialized is a footgun if any future caller bypasses the len-set step. Use C++11 value-initialization in the member initializer list — costs ~512 B of memset at boot, gains a clean cppcheck pass and defensive-against-future-changes semantics. Also reformat Wire.{cpp,h} with the project's `.trunk/configs/.clang-format` config so the Trunk Check Runner passes — clang-format moved the `` include before the Zephyr-namespaced ones in Wire.cpp and collapsed two inline overloads to single lines in Wire.h. * fix(AdminModule): remove dead OUTPUT_GPIO_PIN/GpioOutputModule references OUTPUT_GPIO_PIN is never defined and modules/GpioOutputModule.h doesn't exist in the codebase; all #ifdef OUTPUT_GPIO_PIN branches were dead code introduced by the nRF54L15-DK variant commit. Strips the include, the output_gpio_enabled OFF→ON/ON→OFF transition logic in handleSetConfig(), and the digitalRead() reflection in handleGetConfig(). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Ben Meadors --- .gitignore | 4 + boards/nrf54l15dk.json | 26 + extra_scripts/nrf54l15_linker.py | 140 +++ platformio.ini | 1 + src/FSCommon.h | 8 + src/RedirectablePrint.cpp | 4 + src/main.cpp | 15 + src/main.h | 4 + src/mesh/Channels.cpp | 17 + src/mesh/MeshService.cpp | 10 +- src/mesh/NodeDB.cpp | 53 ++ src/mesh/PhoneAPI.cpp | 8 + src/mesh/RadioLibInterface.cpp | 21 +- src/mesh/RadioLibInterface.h | 15 +- src/modules/AdminModule.cpp | 10 +- src/platform/nrf54l15/Arduino.h | 824 ++++++++++++++++++ src/platform/nrf54l15/IPAddress.h | 34 + src/platform/nrf54l15/InternalFileSystem.cpp | 274 ++++++ src/platform/nrf54l15/InternalFileSystem.h | 212 +++++ src/platform/nrf54l15/NRF52Bluetooth.h | 18 + src/platform/nrf54l15/NRF54L15Bluetooth.cpp | 805 +++++++++++++++++ src/platform/nrf54l15/NRF54L15Bluetooth.h | 29 + src/platform/nrf54l15/Nrf52SaadcLock.h | 17 + src/platform/nrf54l15/Print.h | 4 + src/platform/nrf54l15/SPI.h | 62 ++ src/platform/nrf54l15/Stream.h | 5 + src/platform/nrf54l15/Tone.h | 4 + src/platform/nrf54l15/WProgram.h | 5 + src/platform/nrf54l15/Wire.cpp | 219 +++++ src/platform/nrf54l15/Wire.h | 83 ++ src/platform/nrf54l15/architecture.h | 79 ++ src/platform/nrf54l15/bluefruit.h | 19 + src/platform/nrf54l15/main-nrf54l15.cpp | 210 +++++ src/platform/nrf54l15/nrf54l15_arduino.cpp | 557 ++++++++++++ src/platform/nrf54l15/nrf54l15_main.cpp | 121 +++ src/platform/nrf54l15/utility/bonding.h | 11 + userPrefs.jsonc | 2 + variants/esp32/esp32-common.ini | 2 +- variants/native/portduino.ini | 5 +- variants/nrf52840/nrf52.ini | 2 +- variants/nrf54l15/nrf54l15.ini | 74 ++ variants/nrf54l15/nrf54l15dk/README.md | 108 +++ variants/nrf54l15/nrf54l15dk/platformio.ini | 24 + variants/nrf54l15/nrf54l15dk/variant.cpp | 9 + variants/nrf54l15/nrf54l15dk/variant.h | 86 ++ variants/rp2040/rp2040.ini | 2 +- variants/rp2350/rp2350.ini | 4 +- variants/stm32/stm32.ini | 2 +- .../boards/nrf54l15dk_nrf54l15_cpuapp.overlay | 117 +++ zephyr/prj.conf | 299 +++++++ 50 files changed, 4635 insertions(+), 29 deletions(-) create mode 100644 boards/nrf54l15dk.json create mode 100644 extra_scripts/nrf54l15_linker.py create mode 100644 src/platform/nrf54l15/Arduino.h create mode 100644 src/platform/nrf54l15/IPAddress.h create mode 100644 src/platform/nrf54l15/InternalFileSystem.cpp create mode 100644 src/platform/nrf54l15/InternalFileSystem.h create mode 100644 src/platform/nrf54l15/NRF52Bluetooth.h create mode 100644 src/platform/nrf54l15/NRF54L15Bluetooth.cpp create mode 100644 src/platform/nrf54l15/NRF54L15Bluetooth.h create mode 100644 src/platform/nrf54l15/Nrf52SaadcLock.h create mode 100644 src/platform/nrf54l15/Print.h create mode 100644 src/platform/nrf54l15/SPI.h create mode 100644 src/platform/nrf54l15/Stream.h create mode 100644 src/platform/nrf54l15/Tone.h create mode 100644 src/platform/nrf54l15/WProgram.h create mode 100644 src/platform/nrf54l15/Wire.cpp create mode 100644 src/platform/nrf54l15/Wire.h create mode 100644 src/platform/nrf54l15/architecture.h create mode 100644 src/platform/nrf54l15/bluefruit.h create mode 100644 src/platform/nrf54l15/main-nrf54l15.cpp create mode 100644 src/platform/nrf54l15/nrf54l15_arduino.cpp create mode 100644 src/platform/nrf54l15/nrf54l15_main.cpp create mode 100644 src/platform/nrf54l15/utility/bonding.h create mode 100644 variants/nrf54l15/nrf54l15.ini create mode 100644 variants/nrf54l15/nrf54l15dk/README.md create mode 100644 variants/nrf54l15/nrf54l15dk/platformio.ini create mode 100644 variants/nrf54l15/nrf54l15dk/variant.cpp create mode 100644 variants/nrf54l15/nrf54l15dk/variant.h create mode 100644 zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay create mode 100644 zephyr/prj.conf diff --git a/.gitignore b/.gitignore index eebd94ef9c2..55e90a8f28d 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ data/boot/logo.* managed_components/* arduino-lib-builder* dependencies.lock + +# JLink / RTT debug artifacts (nRF SoCs) +flash.jlink +rtt_*.txt idf_component.yml CMakeLists.txt /sdkconfig.* diff --git a/boards/nrf54l15dk.json b/boards/nrf54l15dk.json new file mode 100644 index 00000000000..863ad290b4d --- /dev/null +++ b/boards/nrf54l15dk.json @@ -0,0 +1,26 @@ +{ + "build": { + "cpu": "cortex-m33", + "f_cpu": "128000000L", + "mcu": "nrf54l15", + "zephyr": { + "variant": "nrf54l15dk/nrf54l15/cpuapp" + } + }, + "connectivity": ["bluetooth"], + "debug": { + "default_tools": ["jlink"], + "jlink_device": "nRF54L15_M33", + "svd_path": "nrf54l15.svd" + }, + "frameworks": ["zephyr"], + "name": "Nordic nRF54L15-DK (PCA10156)", + "upload": { + "maximum_ram_size": 262144, + "maximum_size": 1572864, + "protocol": "jlink", + "protocols": ["jlink"] + }, + "url": "https://www.nordicsemi.com/Products/nRF54L15", + "vendor": "Nordic Semiconductor" +} diff --git a/extra_scripts/nrf54l15_linker.py b/extra_scripts/nrf54l15_linker.py new file mode 100644 index 00000000000..fa104155307 --- /dev/null +++ b/extra_scripts/nrf54l15_linker.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# trunk-ignore-all(ruff/F821) +# trunk-ignore-all(flake8/F821): For SConstruct imports +# +# post:extra_scripts/nrf54l15_linker.py +# +# Fix for Zephyr two-pass link on nRF54L15: +# platformio-build.py registers env.Depends("$PROG_PATH", final_ld_script) but +# the SCons dependency chain is broken (final_ld_script Command never runs). +# This script adds a PreAction on the final firmware binary that runs the gcc +# preprocessing command directly (extracted from build.ninja) to generate +# zephyr/linker.cmd before the link step. +# +# PlatformIO bundles an old Ninja that can't handle multi-output depslog rules, +# so we parse the COMMAND line from build.ninja and run just the gcc -E part, +# skipping the cmake_transform_depfile step (only needed for Ninja deps tracking). + +import os +import re +import subprocess + +Import("env") + +if env.get("PIOENV") != "nrf54l15dk": + pass # Only for the nrf54l15dk environment +else: + + def _extract_gcc_command(ninja_build): + """Parse build.ninja to find the gcc -E command that generates linker.cmd. + + The rule format depends on the host: + Windows (CMake's RunCMake wraps every command): + COMMAND = cmd.exe /C "cd /D DIR && arm-none-eabi-gcc.exe ... -o linker.cmd && cmake.exe -E cmake_transform_depfile ..." + POSIX (Linux/macOS — no wrapper): + COMMAND = cd DIR && arm-none-eabi-gcc ... -o linker.cmd && cmake -E cmake_transform_depfile ... + + Returns (gcc_cmd_string, cwd_path) or raises RuntimeError. + """ + in_rule = False + with open(ninja_build, "r", encoding="utf-8", errors="replace") as f: + for line in f: + # Detect start of the linker.cmd custom command rule + if not in_rule: + if "build zephyr/linker.cmd" in line and "CUSTOM_COMMAND" in line: + in_rule = True + continue + + stripped = line.strip() + if not stripped.startswith("COMMAND = "): + continue + + command_val = stripped[len("COMMAND = ") :] + + # On Windows the value is wrapped in `cmd.exe /C "..."` — strip + # the wrapper. On POSIX hosts the inner sequence is the value + # itself (no quoting layer). + m = re.search(r'/C\s+"(.*)"\s*$', command_val) + inner = m.group(1) if m else command_val + parts = inner.split(" && ") + + cwd = None + gcc_cmd = None + for part in parts: + part = part.strip() + if part.startswith("cd /D "): # Windows form + cwd = part[len("cd /D ") :] + elif part.startswith("cd "): # POSIX form + cwd = part[len("cd ") :] + elif "arm-none-eabi-gcc" in part: + gcc_cmd = part + + if not gcc_cmd: + raise RuntimeError( + "nRF54L15 linker fix: arm-none-eabi-gcc command not found in:\n%s" + % inner[:400] + ) + + return gcc_cmd, cwd + + raise RuntimeError( + "nRF54L15 linker fix: 'build zephyr/linker.cmd' rule not found in build.ninja" + ) + + def _generate_linker_cmd(target, source, env): + """Generate zephyr/linker.cmd via direct gcc invocation before the final link.""" + build_dir = env.subst("$BUILD_DIR") + zephyr_dir = os.path.join(build_dir, "zephyr") + linker_cmd = os.path.join(zephyr_dir, "linker.cmd") + + if os.path.exists(linker_cmd): + return # Already present — nothing to do + + ninja_build = os.path.join(build_dir, "build.ninja") + if not os.path.exists(ninja_build): + raise RuntimeError( + "nRF54L15 linker fix: build.ninja not found at %s\n" + "Run a full build first so CMake generates the Ninja files." + % ninja_build + ) + + gcc_cmd, cwd = _extract_gcc_command(ninja_build) + run_cwd = cwd if cwd else zephyr_dir + + print( + "==> nRF54L15: Generating zephyr/linker.cmd (LINKER_ZEPHYR_FINAL) via GCC" + ) + # gcc_cmd comes verbatim from our own build.ninja (never user input) and + # contains Windows-style paths with spaces that cannot be safely argv-split + # with shlex, so we run it via the platform shell. nosec/nosemgrep below + # acknowledge this deliberate, scoped use of shell=True. + result = subprocess.run( # nosec B602 + gcc_cmd, + shell=True, # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true + cwd=run_cwd, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print("GCC stdout:", result.stdout[:2000]) + print("GCC stderr:", result.stderr[:2000]) + raise RuntimeError( + "nRF54L15 linker fix: GCC failed to generate linker.cmd (rc=%d)" + % result.returncode + ) + if not os.path.exists(linker_cmd): + raise RuntimeError( + "nRF54L15 linker fix: GCC returned 0 but linker.cmd was not created at %s" + % linker_cmd + ) + print("==> linker.cmd generated successfully") + + # Use PIOMAINPROG (set by ZephyrBuildProgram) to get the exact SCons node + prog = env.get("PIOMAINPROG") + if prog: + env.AddPreAction(prog, _generate_linker_cmd) + else: + print( + "[nrf54l15_linker] WARNING: PIOMAINPROG not set, falling back to $PROG_PATH" + ) + env.AddPreAction(env.subst("$PROG_PATH"), _generate_linker_cmd) diff --git a/platformio.ini b/platformio.ini index db689d8bbc1..c306eed7ba4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -17,6 +17,7 @@ test_build_src = true extra_scripts = pre:bin/platformio-pre.py bin/platformio-custom.py + post:extra_scripts/nrf54l15_linker.py ; note: we add src to our include search path so that lmic_project_config can override ; note: TINYGPS_OPTION_NO_CUSTOM_FIELDS is VERY important. We don't use custom fields and somewhere in that pile ; of code is a heap corruption bug! diff --git a/src/FSCommon.h b/src/FSCommon.h index 9fe71e47b41..e2e77da9a6f 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -48,6 +48,14 @@ using namespace STM32_LittleFS_Namespace; using namespace Adafruit_LittleFS_Namespace; #endif +#if defined(ARCH_NRF54L15) +// nRF54L15 — Zephyr LittleFS on 36 KB storage_partition (internal RRAM) +#include "InternalFileSystem.h" +#define FSCom InternalFS +#define FSBegin() FSCom.begin() +using namespace Adafruit_LittleFS_Namespace; +#endif + void fsInit(); void fsListFiles(); bool copyFile(const char *from, const char *to); diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 2d6cc13ec40..e887f01ee22 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -225,6 +225,8 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_ isBleConnected = nimbleBluetooth && nimbleBluetooth->isActive() && nimbleBluetooth->isConnected(); #elif defined(ARCH_NRF52) isBleConnected = nrf52Bluetooth != nullptr && nrf52Bluetooth->isConnected(); +#elif defined(ARCH_NRF54L15) + isBleConnected = nrf54l15Bluetooth != nullptr && nrf54l15Bluetooth->isConnected(); #endif if (isBleConnected) { auto thread = concurrency::OSThread::currentThread; @@ -241,6 +243,8 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_ nimbleBluetooth->sendLog(buffer.get(), size); #elif defined(ARCH_NRF52) nrf52Bluetooth->sendLog(buffer.get(), size); +#elif defined(ARCH_NRF54L15) + nrf54l15Bluetooth->sendLog(buffer.get(), size); #endif } } diff --git a/src/main.cpp b/src/main.cpp index 28842734c89..304712e6fa3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,9 @@ #if !MESHTASTIC_EXCLUDE_GPS #include "GPS.h" #endif +#if !MESHTASTIC_EXCLUDE_INPUTBROKER +#include "input/InputBroker.h" +#endif #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" @@ -59,6 +62,12 @@ NimbleBluetooth *nimbleBluetooth = nullptr; NRF52Bluetooth *nrf52Bluetooth = nullptr; #endif +#ifdef ARCH_NRF54L15 +void nrf54l15Setup(); +void nrf54l15Loop(); +NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr; +#endif + #if HAS_WIFI || defined(USE_WS5500) || defined(USE_CH390D) #include "mesh/api/WiFiServerAPI.h" #include "mesh/wifi/WiFiAPClient.h" @@ -696,6 +705,9 @@ void setup() #ifdef ARCH_NRF52 nrf52Setup(); #endif +#ifdef ARCH_NRF54L15 + nrf54l15Setup(); +#endif #ifdef ARCH_RP2040 rp2040Setup(); @@ -1126,6 +1138,9 @@ void loop() #endif #ifdef ARCH_NRF52 nrf52Loop(); +#endif +#ifdef ARCH_NRF54L15 + nrf54l15Loop(); #endif power->powerCommandsCheck(); diff --git a/src/main.h b/src/main.h index 56f048134cb..058a2aebc70 100644 --- a/src/main.h +++ b/src/main.h @@ -19,6 +19,10 @@ extern NimbleBluetooth *nimbleBluetooth; #include "NRF52Bluetooth.h" extern NRF52Bluetooth *nrf52Bluetooth; #endif +#ifdef ARCH_NRF54L15 +#include "NRF54L15Bluetooth.h" +extern NRF54L15Bluetooth *nrf54l15Bluetooth; +#endif #if !MESHTASTIC_EXCLUDE_I2C #include "detect/ScanI2CTwoWire.h" #endif diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 1583567fe60..be75e3d4214 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -93,6 +93,23 @@ void Channels::initDefaultLoraConfig() #ifdef USERPREFS_LORACONFIG_CHANNEL_NUM loraConfig.channel_num = USERPREFS_LORACONFIG_CHANNEL_NUM; #endif + + // Apply any hardcoded USERPREFS overrides for custom modem config (e.g. region-locked boards) +#ifdef USERPREFS_LORACONFIG_USE_PRESET + loraConfig.use_preset = USERPREFS_LORACONFIG_USE_PRESET; +#endif +#ifdef USERPREFS_LORACONFIG_BANDWIDTH + loraConfig.bandwidth = USERPREFS_LORACONFIG_BANDWIDTH; +#endif +#ifdef USERPREFS_LORACONFIG_SPREAD_FACTOR + loraConfig.spread_factor = USERPREFS_LORACONFIG_SPREAD_FACTOR; +#endif +#ifdef USERPREFS_LORACONFIG_CODING_RATE + loraConfig.coding_rate = USERPREFS_LORACONFIG_CODING_RATE; +#endif +#ifdef USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY + loraConfig.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY; +#endif } bool Channels::ensureLicensedOperation() diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 151831b962c..f219e60d220 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -334,7 +334,9 @@ void MeshService::sendToPhone(meshtastic_MeshPacket *p) if (toPhoneQueue.enqueue(p, 0) == false) { LOG_CRIT("Failed to queue a packet into toPhoneQueue!"); - abort(); + releaseToPool(p); + fromNum++; // notify observers so phone can resync + return; } fromNum++; } @@ -351,7 +353,8 @@ void MeshService::sendMqttMessageToClientProxy(meshtastic_MqttClientProxyMessage if (toPhoneMqttProxyQueue.enqueue(m, 0) == false) { LOG_CRIT("Failed to queue a packet into toPhoneMqttProxyQueue!"); - abort(); + releaseMqttClientProxyMessageToPool(m); + return; } fromNum++; } @@ -383,7 +386,8 @@ void MeshService::sendClientNotification(meshtastic_ClientNotification *n) if (toPhoneClientNotificationQueue.enqueue(n, 0) == false) { LOG_CRIT("Failed to queue a notification into toPhoneClientNotificationQueue!"); - abort(); + releaseClientNotificationToPool(n); + return; } fromNum++; } diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 8660aefaec2..5b6a6e980e8 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -393,6 +393,13 @@ NodeDB::NodeDB() } else { LOG_WARN("Failed to read unique id from efuse"); } +#elif defined(ARCH_NRF54L15) + // nRF54L15: DEVICEID is under FICR->INFO sub-struct (not top-level as on nRF52) + uint64_t device_id_start = ((uint64_t)NRF_FICR->INFO.DEVICEID[1] << 32) | NRF_FICR->INFO.DEVICEID[0]; + uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; + memcpy(myNodeInfo.device_id.bytes, &device_id_start, sizeof(device_id_start)); + memcpy(myNodeInfo.device_id.bytes + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); + myNodeInfo.device_id.size = 16; #elif defined(ARCH_NRF52) // Nordic applies a FIPS compliant Random ID to each chip at the factory // We concatenate the device address to the Random ID to create a unique ID for now @@ -826,6 +833,23 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.lora.modem_preset = USERPREFS_LORACONFIG_MODEM_PRESET; #else config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; +#endif +#ifdef USERPREFS_LORACONFIG_USE_PRESET + config.lora.use_preset = USERPREFS_LORACONFIG_USE_PRESET; +#else + config.lora.use_preset = true; +#endif +#ifdef USERPREFS_LORACONFIG_BANDWIDTH + config.lora.bandwidth = USERPREFS_LORACONFIG_BANDWIDTH; +#endif +#ifdef USERPREFS_LORACONFIG_SPREAD_FACTOR + config.lora.spread_factor = USERPREFS_LORACONFIG_SPREAD_FACTOR; +#endif +#ifdef USERPREFS_LORACONFIG_CODING_RATE + config.lora.coding_rate = USERPREFS_LORACONFIG_CODING_RATE; +#endif +#ifdef USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY + config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY; #endif config.lora.hop_limit = HOP_RELIABLE; #ifdef USERPREFS_CONFIG_LORA_IGNORE_MQTT @@ -1217,7 +1241,14 @@ void NodeDB::initModuleConfigIntervals() #else moduleConfig.telemetry.device_update_interval = MAX_INTERVAL; #endif +#ifdef USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL + moduleConfig.telemetry.environment_update_interval = USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL; +#else moduleConfig.telemetry.environment_update_interval = 0; +#endif +#ifdef USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED + moduleConfig.telemetry.environment_measurement_enabled = USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED; +#endif moduleConfig.telemetry.air_quality_interval = 0; moduleConfig.telemetry.power_update_interval = 0; moduleConfig.telemetry.health_update_interval = 0; @@ -1726,6 +1757,28 @@ void NodeDB::loadFromDisk() config.lora.tx_enabled = false; #endif + // Always-apply LoRa overrides: applied after loading saved config so they + // take effect even when NVS already has a valid config (e.g. region-locked + // dev boards with no BLE/serial to set the region at runtime). +#ifdef USERPREFS_CONFIG_LORA_REGION + config.lora.region = USERPREFS_CONFIG_LORA_REGION; +#endif +#ifdef USERPREFS_LORACONFIG_USE_PRESET + config.lora.use_preset = USERPREFS_LORACONFIG_USE_PRESET; +#endif +#ifdef USERPREFS_LORACONFIG_BANDWIDTH + config.lora.bandwidth = USERPREFS_LORACONFIG_BANDWIDTH; +#endif +#ifdef USERPREFS_LORACONFIG_SPREAD_FACTOR + config.lora.spread_factor = USERPREFS_LORACONFIG_SPREAD_FACTOR; +#endif +#ifdef USERPREFS_LORACONFIG_CODING_RATE + config.lora.coding_rate = USERPREFS_LORACONFIG_CODING_RATE; +#endif +#ifdef USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY + config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY; +#endif + if (backupSecurity.private_key.size > 0) { LOG_DEBUG("Restoring backup of security config"); config.security = backupSecurity; diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index ecf6ff809d4..349069de23f 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -71,7 +71,15 @@ void PhoneAPI::handleStartConfig() } 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 + // companion apps for those flows. + filesManifest.clear(); +#else filesManifest = getFiles("/", 10); +#endif spiLock->unlock(); LOG_DEBUG("Got %d files in manifest", filesManifest.size()); diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index de468cf9793..02cf2281d75 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -46,16 +46,6 @@ RadioLibInterface::RadioLibInterface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE c #endif } -RadioLibInterface::~RadioLibInterface() -{ - // If the static `instance` pointer still references us, clear it. - // A later successful init() may have replaced `instance` with a newer - // interface — don't clobber that case. - if (instance == this) { - instance = nullptr; - } -} - #ifdef ARCH_ESP32 // ESP32 doesn't use that flag #define YIELD_FROM_ISR(x) portYIELD_FROM_ISR() @@ -555,6 +545,9 @@ void RadioLibInterface::pollMissedIrqs() if (isReceiving) { checkRxDoneIrqFlag(); } + if (sendingPacket) { + checkTxDoneIrqFlag(); + } } void RadioLibInterface::resetAGC() @@ -570,6 +563,14 @@ void RadioLibInterface::checkRxDoneIrqFlag() } } +void RadioLibInterface::checkTxDoneIrqFlag() +{ + if (iface->checkIrq(RADIOLIB_IRQ_TX_DONE)) { + LOG_WARN("caught missed TX_DONE"); + notify(ISR_TX, true); + } +} + void RadioLibInterface::configHardwareForSend() { powerMon->setState(meshtastic_PowerMon_State_Lora_TXOn); diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 0740561f9b2..5b850d67578 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -104,6 +104,13 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ static RadioLibInterface *instance; + /** Clear instance on destruction so stale pointer checks in loop() are safe */ + virtual ~RadioLibInterface() + { + if (instance == this) + instance = nullptr; + } + /** * Glue functions called from ISR land */ @@ -136,13 +143,6 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified RadioLibInterface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, RADIOLIB_PIN_TYPE busy, PhysicalLayer *iface = NULL); - /** - * Clear the static `instance` pointer if it still points at us, so callers - * that check `RadioLibInterface::instance != nullptr` don't dereference a - * freed object after a failed init() + unique_ptr reset. - */ - virtual ~RadioLibInterface(); - virtual ErrorCode send(meshtastic_MeshPacket *p) override; /** @@ -293,4 +293,5 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified bool removePendingTXPacket(NodeNum from, PacketId id, uint32_t hop_limit_lt) override; void checkRxDoneIrqFlag(); + void checkTxDoneIrqFlag(); }; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index ba1cfdb97e0..e7acdf89e4a 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -666,7 +666,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) bool requiresReboot = true; switch (c.which_payload_variant) { - case meshtastic_Config_device_tag: + case meshtastic_Config_device_tag: { LOG_INFO("Set config: Device"); config.has_device = true; #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \ @@ -720,6 +720,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) } #endif break; + } // case meshtastic_Config_device_tag case meshtastic_Config_position_tag: LOG_INFO("Set config: Position"); config.has_position = true; @@ -1345,6 +1346,10 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r if (config.bluetooth.enabled && nrf52Bluetooth) { conn.bluetooth.is_connected = nrf52Bluetooth->isConnected(); } +#elif defined(ARCH_NRF54L15) + if (config.bluetooth.enabled && nrf54l15Bluetooth) { + conn.bluetooth.is_connected = nrf54l15Bluetooth->isConnected(); + } #endif #endif conn.has_serial = true; // No serial-less devices @@ -1605,6 +1610,9 @@ void disableBluetooth() #elif defined(ARCH_NRF52) if (nrf52Bluetooth) nrf52Bluetooth->shutdown(); +#elif defined(ARCH_NRF54L15) + if (nrf54l15Bluetooth) + nrf54l15Bluetooth->shutdown(); #endif #endif } diff --git a/src/platform/nrf54l15/Arduino.h b/src/platform/nrf54l15/Arduino.h new file mode 100644 index 00000000000..d94d9fd4ed7 --- /dev/null +++ b/src/platform/nrf54l15/Arduino.h @@ -0,0 +1,824 @@ +/** + * Arduino.h — Zephyr compatibility shim for nRF54L15 + * + * Provides the Arduino API surface expected by Meshtastic, backed by + * Zephyr primitives. Only the subset actually used by Meshtastic is + * implemented; the rest compiles as no-ops / stubs for now. + * + * Phase 2: compile only. Real GPIO / SPI / Wire implementations follow + * in Phase 3 once the build is clean. + */ + +#pragma once +#ifndef Arduino_h +#define Arduino_h + +// ── C standard headers ─────────────────────────────────────────────────────── +#include +#include +#include +#include +#include +#include +#include +#include /* strcasecmp, strncasecmp */ + +// ── Zephyr kernel ──────────────────────────────────────────────────────────── +#include +#include + +// ── Basic Arduino types ────────────────────────────────────────────────────── +typedef bool boolean; +typedef uint8_t byte; +typedef uint16_t word; + +// ── Pin / digital constants ────────────────────────────────────────────────── +#define INPUT 0u +#define OUTPUT 1u +#define INPUT_PULLUP 2u +#define INPUT_PULLDOWN 3u +#define OUTPUT_OPENDRAIN 4u + +#define HIGH 1u +#define LOW 0u + +#define CHANGE 1 +#define FALLING 2 +#define RISING 3 + +#ifndef LED_BUILTIN +#define LED_BUILTIN -1 +#endif + +// ── Math / trig constants ──────────────────────────────────────────────────── +#ifndef PI +#define PI 3.14159265358979323846 +#endif +#define HALF_PI 1.57079632679489661923 +#define TWO_PI 6.28318530717958647693 +#define DEG_TO_RAD 0.01745329251994329576 +#define RAD_TO_DEG 57.2957795130823208767 +#define EULER 2.71828182845904523536 + +// ── Bit utilities ──────────────────────────────────────────────────────────── +#define bitRead(v, b) (((v) >> (b)) & 1) +#define bitSet(v, b) ((v) |= (1UL << (b))) +#define bitClear(v, b) ((v) &= ~(1UL << (b))) +#define bitToggle(v, b) ((v) ^= (1UL << (b))) +#define bitWrite(v, b, x) ((x) ? bitSet(v, b) : bitClear(v, b)) +#define bit(b) (1UL << (b)) +#define lowByte(w) ((uint8_t)((w)&0xff)) +#define highByte(w) ((uint8_t)((w) >> 8)) +// word(h,l) — only define if not already defined (conflicts with typedef above) +#undef word +#define word(h, l) ((uint16_t)(((h) << 8) | (l))) + +// ── UART config constants ───────────────────────────────────────────────────── +#define SERIAL_8N1 0x800001cu +#define SERIAL_8N2 0x8000001eu +#define SERIAL_8E1 0x8000001eu +#define SERIAL_7E1 0x8000001cu + +// ── Integer order ──────────────────────────────────────────────────────────── +// Adafruit BusIO's SPIDevice.h has `typedef BitOrder BusIOBitOrder;` which +// requires BitOrder to be a *type*, not a macro. Mirror the ArduinoCore-API +// enum definition rather than #defines. +enum BitOrder : uint8_t { + LSBFIRST = 0, + MSBFIRST = 1, +}; + +// ── pgmspace compatibility (no-ops on Cortex-M) ────────────────────────────── +#define PROGMEM +#define PSTR(s) (s) +#define F(s) (s) +#define pgm_read_byte(addr) (*((const uint8_t *)(addr))) +#define pgm_read_word(addr) (*((const uint16_t *)(addr))) +#define pgm_read_dword(addr) (*((const uint32_t *)(addr))) +#define pgm_read_float(addr) (*((const float *)(addr))) +#define pgm_read_ptr(addr) (*((const void **)(addr))) +#define strlen_P(s) strlen(s) +#define strcpy_P(d, s) strcpy(d, s) +#define strncpy_P(d, s, n) strncpy(d, s, n) +#define strcmp_P(a, b) strcmp(a, b) +#define memcpy_P(d, s, n) memcpy(d, s, n) +#define sprintf_P sprintf +typedef const char *PGM_P; +typedef const char *PGM_VOID_P; + +// ── Arduino numeric base constants (used by Print, RadioLib, etc.) ─────────── +#define DEC 10 +#define HEX 16 +#define OCT 8 +#define BIN 2 + +// ── ulong / uint typedef (used by RadioLibInterface, etc.) ─────────────────── +typedef unsigned long ulong; +typedef unsigned int uint; + +// ── Interrupt stubs ────────────────────────────────────────────────────────── +static inline void interrupts() {} +static inline void noInterrupts() {} +#define digitalPinToInterrupt(p) (p) + +// ── portMAX_DELAY — freertosinc.h also defines this; let it win ────────────── +// We intentionally do NOT define portMAX_DELAY here. freertosinc.h defines +// it for the FreeRTOS / Meshtastic threading layer and must not be overridden. + +// ── Timing & system functions — declared with C linkage ────────────────────── +// buzz.cpp and others forward-declare delay() as extern "C"; keep linkage +// consistent by wrapping in extern "C" here. +#ifdef __cplusplus +extern "C" { +#endif +void NVIC_SystemReset(void); +uint32_t millis(void); +uint32_t micros(void); +void delay(uint32_t ms); +void delayMicroseconds(uint32_t us); +void yield(void); +#ifdef __cplusplus +} +#endif + +#ifdef __cplusplus + +#include +#include + +// ── C++ STL — include BEFORE defining any min/max helpers ─────────────────── +// Include algorithm first so its min/max templates are in scope. +// We must NOT define min/max as function-like macros: the C++ STL uses +// 3-argument versions (min(a,b,comp)) that the preprocessor would treat as +// calling a 2-arg macro with 3 args. +#include +// Bring 2-arg std::min / std::max into the global namespace as unqualified +// names so that Arduino code calling min(a,b) continues to compile. +// (Arduino convention; kept minimal to avoid surprises.) +#undef min +#undef max +using std::max; +using std::min; + +// ── Arduino math helpers (macros safe for mixed-type / C calls) ────────────── +#ifndef abs +#define abs(x) ((x) >= 0 ? (x) : -(x)) +#endif +#define constrain(x, l, h) ((x) < (l) ? (l) : ((x) > (h) ? (h) : (x))) +#define round(x) ((x) >= 0 ? (long)((x) + 0.5) : (long)((x)-0.5)) +#define radians(d) ((d)*DEG_TO_RAD) +#define degrees(r) ((r)*RAD_TO_DEG) +#define sq(x) ((x) * (x)) + +// ── Random ─────────────────────────────────────────────────────────────────── +static inline void randomSeed(unsigned long seed) +{ + srand((unsigned int)seed); +} +static inline long random(void) +{ + return (long)rand(); +} +static inline long random(long bound) +{ + return bound > 0 ? (rand() % bound) : 0; +} +static inline long random(long lo, long hi) +{ + return hi > lo ? lo + rand() % (hi - lo) : lo; +} + +// ── GPIO — real Zephyr implementation (Phase 3) ────────────────────────────── +// Implemented in nrf54l15_arduino.cpp using Zephyr GPIO/SPI APIs. +// Pin numbering: P0.n = n, P1.n = 16+n, P2.n = 32+n +void pinMode(uint32_t pin, uint32_t mode); +void digitalWrite(uint32_t pin, uint32_t value); +int digitalRead(uint32_t pin); +static inline void digitalToggle(uint32_t pin) +{ + digitalWrite(pin, !digitalRead(pin)); +} +static inline uint32_t analogRead(uint32_t) +{ + return 0; +} +static inline void analogWrite(uint32_t, uint32_t) {} +static inline void analogReadResolution(int) {} +static inline void analogWriteResolution(int) {} + +// ── __WFI — provided by CMSIS core_cm33.h; do NOT redefine here ───────────── + +// ── __FlashStringHelper — Arduino PROGMEM string class (no-op on Cortex-M) ── +class __FlashStringHelper; + +// ── attachInterrupt / detachInterrupt — real Zephyr GPIO interrupt impl ────── +typedef void (*voidFuncPtr)(void); +void attachInterrupt(uint32_t pin, voidFuncPtr cb, int mode); +void detachInterrupt(uint32_t pin); + +// ── Forward declaration of String (needed by Print / Stream) ───────────────── +class String; + +// ── Print base class ───────────────────────────────────────────────────────── +class Print +{ + public: + virtual size_t write(uint8_t c) = 0; + virtual size_t write(const uint8_t *buf, size_t n) + { + size_t written = 0; + while (n--) + written += write(*buf++); + return written; + } + size_t write(const char *s) { return s ? write((const uint8_t *)s, strlen(s)) : 0; } + size_t write(const char *s, size_t n) { return write((const uint8_t *)s, n); } + + size_t print(const char *s) { return s ? write((const uint8_t *)s, strlen(s)) : 0; } + int printf(const char *fmt, ...) __attribute__((format(printf, 2, 3))); + + size_t print(char c) { return write((uint8_t)c); } + size_t print(const String &s); + size_t print(unsigned char n, int base = 10); + size_t print(int n, int base = 10); + size_t print(long n, int base = 10); + size_t print(unsigned int n, int base = 10); + size_t print(unsigned long n, int base = 10); + size_t print(float n, int digits = 2); + size_t print(double n, int digits = 2); + size_t print(bool b) { return print(b ? "true" : "false"); } + + size_t println() { return write((uint8_t)'\n'); } + size_t println(const char *s) + { + size_t r = print(s); + return r + println(); + } + size_t println(char c) + { + size_t r = print(c); + return r + println(); + } + size_t println(const String &s); + size_t println(int n, int base = 10) + { + size_t r = print(n, base); + return r + println(); + } + size_t println(long n, int base = 10) + { + size_t r = print(n, base); + return r + println(); + } + size_t println(unsigned long n, int base = 10) + { + size_t r = print(n, base); + return r + println(); + } + size_t println(unsigned int n, int base = 10) + { + size_t r = print(n, base); + return r + println(); + } + size_t println(float n, int d = 2) + { + size_t r = print(n, d); + return r + println(); + } + size_t println(double n, int d = 2) + { + size_t r = print(n, d); + return r + println(); + } + size_t println(bool b) + { + size_t r = print(b); + return r + println(); + } + + virtual void flush() {} +}; + +// ── Stream base class ──────────────────────────────────────────────────────── +class Stream : public Print +{ + public: + virtual int available() = 0; + virtual int read() = 0; + virtual int peek() = 0; + virtual void setTimeout(unsigned long) {} + virtual bool find(const char *) { return false; } + String readString(); + String readStringUntil(char terminator); +}; + +// ── Minimal Arduino String class (backed by a char buffer) ─────────────────── +class String +{ + public: + String() : _buf(nullptr), _len(0), _cap(0) {} + // Implicit conversion is part of the Arduino String contract, used pervasively across the codebase. + // cppcheck-suppress noExplicitConstructor + String(const char *cstr) : _buf(nullptr), _len(0), _cap(0) + { + if (cstr) + assign(cstr, strlen(cstr)); + } + // cppcheck-suppress noExplicitConstructor + String(const String &s) : _buf(nullptr), _len(0), _cap(0) { assign(s._buf ? s._buf : "", s._len); } + // cppcheck-suppress noExplicitConstructor + String(char c) : _buf(nullptr), _len(0), _cap(0) + { + const char tmp[2] = {c, 0}; + assign(tmp, 1); + } + // cppcheck-suppress noExplicitConstructor + String(int n) : _buf(nullptr), _len(0), _cap(0) + { + char tmp[16]; + snprintf(tmp, 16, "%d", n); + assign(tmp, strlen(tmp)); + } + // cppcheck-suppress noExplicitConstructor + String(unsigned int n) : _buf(nullptr), _len(0), _cap(0) + { + char tmp[16]; + snprintf(tmp, 16, "%u", n); + assign(tmp, strlen(tmp)); + } + // cppcheck-suppress noExplicitConstructor + String(long n) : _buf(nullptr), _len(0), _cap(0) + { + char tmp[24]; + snprintf(tmp, 24, "%ld", n); + assign(tmp, strlen(tmp)); + } + // cppcheck-suppress noExplicitConstructor + String(unsigned long n) : _buf(nullptr), _len(0), _cap(0) + { + char tmp[24]; + snprintf(tmp, 24, "%lu", n); + assign(tmp, strlen(tmp)); + } + // cppcheck-suppress noExplicitConstructor + String(float n, int d = 2) : _buf(nullptr), _len(0), _cap(0) + { + char tmp[32]; + snprintf(tmp, 32, "%.*f", d, n); + assign(tmp, strlen(tmp)); + } + // cppcheck-suppress noExplicitConstructor + String(double n, int d = 2) : _buf(nullptr), _len(0), _cap(0) + { + char tmp[32]; + snprintf(tmp, 32, "%.*f", d, (double)n); + assign(tmp, strlen(tmp)); + } + ~String() { free(_buf); } + + String &operator=(const String &s) + { + assign(s._buf ? s._buf : "", s._len); + return *this; + } + String &operator=(const char *s) + { + assign(s ? s : "", s ? strlen(s) : 0); + return *this; + } + String &operator=(char c) + { + const char tmp[2] = {c, 0}; + assign(tmp, 1); + return *this; + } + + String &operator+=(const String &s) + { + concat(s._buf ? s._buf : "", s._len); + return *this; + } + String &operator+=(const char *s) + { + if (s) + concat(s, strlen(s)); + return *this; + } + String &operator+=(char c) + { + concat(&c, 1); + return *this; + } + String &operator+=(int n) { return *this += String(n); } + String &operator+=(unsigned long n) { return *this += String(n); } + + String operator+(const String &rhs) const + { + String r(*this); + r += rhs; + return r; + } + String operator+(const char *rhs) const + { + String r(*this); + r += rhs; + return r; + } + String operator+(char rhs) const + { + String r(*this); + r += rhs; + return r; + } + + bool operator==(const String &s) const { return _len == s._len && (_len == 0 || strcmp(_buf, s._buf) == 0); } + bool operator==(const char *s) const { return s && strcmp(c_str(), s) == 0; } + bool operator!=(const String &s) const { return !(*this == s); } + bool operator!=(const char *s) const { return !(*this == s); } + bool operator<(const String &s) const { return strcmp(c_str(), s.c_str()) < 0; } + bool operator>(const String &s) const { return strcmp(c_str(), s.c_str()) > 0; } + + char operator[](unsigned int i) const { return (_buf && i < _len) ? _buf[i] : 0; } + char &operator[](unsigned int i) + { + static char dummy = 0; + return (_buf && i < _len) ? _buf[i] : dummy; + } + + const char *c_str() const { return _buf ? _buf : ""; } + unsigned int length() const { return _len; } + bool isEmpty() const { return _len == 0; } + bool equals(const String &s) const { return *this == s; } + bool equals(const char *s) const { return *this == s; } + bool equalsIgnoreCase(const String &s) const + { + if (_len != s._len) + return false; + for (unsigned i = 0; i < _len; i++) + if (std::tolower(_buf[i]) != std::tolower(s._buf[i])) + return false; + return true; + } + bool startsWith(const String &pfx) const + { + if (pfx._len > _len) + return false; + return strncmp(c_str(), pfx.c_str(), pfx._len) == 0; + } + bool startsWith(const char *pfx) const + { + if (!pfx) + return false; + size_t pl = strlen(pfx); + return pl <= _len && strncmp(c_str(), pfx, pl) == 0; + } + bool endsWith(const String &sfx) const + { + if (sfx._len > _len) + return false; + return strcmp(c_str() + _len - sfx._len, sfx.c_str()) == 0; + } + int indexOf(char c, unsigned from = 0) const + { + if (!_buf) + return -1; + const char *p = strchr(_buf + from, c); + return p ? (int)(p - _buf) : -1; + } + int indexOf(const String &s, unsigned from = 0) const + { + if (!_buf) + return -1; + const char *p = strstr(_buf + from, s.c_str()); + return p ? (int)(p - _buf) : -1; + } + int lastIndexOf(char c) const + { + if (!_buf) + return -1; + const char *p = strrchr(_buf, c); + return p ? (int)(p - _buf) : -1; + } + String substring(unsigned beginIndex) const + { + if (!_buf || beginIndex >= _len) + return String(); + return String(_buf + beginIndex); + } + String substring(unsigned beginIndex, unsigned endIndex) const + { + if (!_buf || beginIndex >= _len) + return String(); + if (endIndex > _len) + endIndex = _len; + if (endIndex <= beginIndex) + return String(); + String r; + r.assign(_buf + beginIndex, endIndex - beginIndex); + return r; + } + void toUpperCase() + { + if (_buf) + for (unsigned i = 0; i < _len; i++) + _buf[i] = (char)std::toupper(_buf[i]); + } + void toLowerCase() + { + if (_buf) + for (unsigned i = 0; i < _len; i++) + _buf[i] = (char)std::tolower(_buf[i]); + } + void trim() + { + if (!_buf || _len == 0) + return; + unsigned s = 0; + while (s < _len && std::isspace(_buf[s])) + s++; + unsigned e = _len; + while (e > s && std::isspace(_buf[e - 1])) + e--; + if (s > 0 || e < _len) { + memmove(_buf, _buf + s, e - s); + _len = e - s; + _buf[_len] = 0; + } + } + void replace(char from, char to) + { + if (_buf) + for (unsigned i = 0; i < _len; i++) + if (_buf[i] == from) + _buf[i] = to; + } + void replace(const String &from, const String &to); + bool remove(unsigned index, unsigned count = 1) + { + if (!_buf || index >= _len) + return false; + if (index + count > _len) + count = _len - index; + memmove(_buf + index, _buf + index + count, _len - index - count + 1); + _len -= count; + return true; + } + void clear() + { + _len = 0; + if (_buf) + _buf[0] = 0; + } + char charAt(unsigned i) const { return (*this)[i]; } + void setCharAt(unsigned i, char c) + { + if (_buf && i < _len) + _buf[i] = c; + } + void toCharArray(char *buf, unsigned int bufsize, unsigned int index = 0) const + { + if (!buf || bufsize == 0) + return; + unsigned int avail = (_buf && _len > index) ? (_len - index) : 0; + unsigned int copy = avail < bufsize - 1 ? avail : bufsize - 1; + if (copy > 0) + memcpy(buf, _buf + index, copy); + buf[copy] = '\0'; + } + void concat(const String &s) { *this += s; } + void concat(const char *s) { *this += s; } + long toInt() const { return _buf ? atol(_buf) : 0; } + float toFloat() const { return _buf ? (float)atof(_buf) : 0.0f; } + double toDouble() const { return _buf ? atof(_buf) : 0.0; } + + private: + char *_buf; + unsigned int _len; + unsigned int _cap; + + void assign(const char *s, unsigned int n) + { + if (n >= _cap) + reserve(n + 1); + if (_buf) { + memcpy(_buf, s, n); + _buf[n] = 0; + _len = n; + } + } + void concat(const char *s, unsigned int n) + { + if (!s || n == 0) + return; + unsigned newlen = _len + n; + if (newlen >= _cap) + reserve(newlen + 1); + if (_buf) { + memcpy(_buf + _len, s, n); + _len = newlen; + _buf[_len] = 0; + } + } + void reserve(unsigned int n) + { + char *b = (char *)realloc(_buf, n); + if (b) { + _buf = b; + _cap = n; + } + } +}; + +inline String operator+(const char *lhs, const String &rhs) +{ + return String(lhs) + rhs; +} +inline String operator+(char lhs, const String &rhs) +{ + return String(lhs) + rhs; +} + +// ── Print inline definitions that need String ──────────────────────────────── +inline size_t Print::print(const String &s) +{ + return write((const uint8_t *)s.c_str(), s.length()); +} +inline size_t Print::println(const String &s) +{ + size_t r = print(s); + return r + println(); +} + +// ── Stream inline definitions that need String ─────────────────────────────── +inline String Stream::readString() +{ + return String(); +} +inline String Stream::readStringUntil(char) +{ + return String(); +} + +// ── HardwareSerial ─────────────────────────────────────────────────────────── +class HardwareSerial : public Stream +{ + public: + void begin(unsigned long) {} + void begin(unsigned long, uint16_t) {} + void end() {} + void setPins(int rx, int tx) {} + void setPinout(int tx, int rx) {} + void setFIFOSize(size_t) {} + void setRxBufferSize(size_t) {} + void begin(unsigned long baud, uint32_t config, int8_t rx = -1, int8_t tx = -1, bool invert = false) {} + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + size_t write(uint8_t c) override; + size_t write(const uint8_t *buf, size_t n) override; + using Print::write; // un-hide base class write(const char*) + size_t readBytes(uint8_t *buf, size_t len) { return 0; } + size_t readBytes(char *buf, size_t len) { return 0; } + operator bool() const { return true; } + void flush() override {} + String readString() { return String(); } + String readStringUntil(char) { return String(); } +}; + +// Uart — nRF52 BSP alias for HardwareSerial (used by GPS.h when ARCH_NRF52) +typedef HardwareSerial Uart; + +extern HardwareSerial Serial; +extern HardwareSerial Serial1; +extern HardwareSerial Serial2; + +// ── map() utility ──────────────────────────────────────────────────────────── +static inline long map(long x, long in_min, long in_max, long out_min, long out_max) +{ + return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; +} + +// ── shiftIn / shiftOut stubs ───────────────────────────────────────────────── +static inline uint8_t shiftIn(uint8_t, uint8_t, uint8_t) +{ + return 0; +} +static inline void shiftOut(uint8_t, uint8_t, uint8_t, uint8_t) {} + +// ── tone / noTone stubs ────────────────────────────────────────────────────── +static inline void tone(uint8_t, unsigned int, unsigned long = 0) {} +static inline void noTone(uint8_t) {} + +// ── pulseIn stub ───────────────────────────────────────────────────────────── +static inline unsigned long pulseIn(uint8_t, uint8_t, unsigned long = 1000000UL) +{ + return 0; +} + +// ── strdup / stpcpy — POSIX extensions not in Zephyr newlib ───────────────── +#ifndef strdup +static inline char *strdup(const char *s) +{ + size_t n = strlen(s) + 1; + char *d = (char *)malloc(n); + if (d) + memcpy(d, s, n); + return d; +} +#endif +#ifndef stpcpy +static inline char *stpcpy(char *dst, const char *src) +{ + while ((*dst++ = *src++) != '\0') { + } + return dst - 1; +} +#endif + +// ── strnstr — BSD extension not in Zephyr libc; defined in meshUtils.cpp ───── +// Declare here so callers (GPS.cpp etc.) don't need ARCH_PORTDUINO. +#ifndef STRNSTR +#define STRNSTR +char *strnstr(const char *s, const char *find, size_t slen); +#endif + +// ── strlcpy — BSD extension; implementation in nrf54l15_arduino.cpp ────────── +#ifndef HAVE_STRLCPY +#define HAVE_STRLCPY +#ifdef __cplusplus +extern "C" { +#endif +size_t strlcpy(char *dst, const char *src, size_t size); +#ifdef __cplusplus +} +#endif +#endif + +// ── setenv / getenv / tzset — Zephyr stubs for timezone support ────────────── +#include +static inline int setenv(const char *, const char *, int) +{ + return 0; +} +static inline void tzset(void) {} + +// ── dbgHeapFree / dbgHeapTotal — nRF52 BSP heap diagnostics ───────────────── +// Used by memGet.cpp when ARCH_NRF52 is defined. Return 0 for Phase 2. +static inline uint32_t dbgHeapFree(void) +{ + return 0; +} +static inline uint32_t dbgHeapTotal(void) +{ + return 0; +} + +// ── WCharacter helpers ─────────────────────────────────────────────────────── +static inline bool isAlpha(char c) +{ + return std::isalpha((unsigned char)c) != 0; +} +static inline bool isAlphaNumeric(char c) +{ + return std::isalnum((unsigned char)c) != 0; +} +static inline bool isDigit(char c) +{ + return std::isdigit((unsigned char)c) != 0; +} +static inline bool isSpace(char c) +{ + return std::isspace((unsigned char)c) != 0; +} +static inline bool isUpperCase(char c) +{ + return std::isupper((unsigned char)c) != 0; +} +static inline bool isLowerCase(char c) +{ + return std::islower((unsigned char)c) != 0; +} +static inline char toUpperCase(char c) +{ + return (char)std::toupper((unsigned char)c); +} +static inline char toLowerCase(char c) +{ + return (char)std::tolower((unsigned char)c); +} + +#else /* C only */ +#ifndef min +#define min(a, b) ((a) < (b) ? (a) : (b)) +#endif +#ifndef max +#define max(a, b) ((a) > (b) ? (a) : (b)) +#endif +#ifndef abs +#define abs(x) ((x) >= 0 ? (x) : -(x)) +#endif +#define constrain(x, l, h) ((x) < (l) ? (l) : ((x) > (h) ? (h) : (x))) +#define round(x) ((x) >= 0 ? (long)((x) + 0.5) : (long)((x)-0.5)) +#endif /* __cplusplus */ + +#endif /* Arduino_h */ diff --git a/src/platform/nrf54l15/IPAddress.h b/src/platform/nrf54l15/IPAddress.h new file mode 100644 index 00000000000..bb68580b936 --- /dev/null +++ b/src/platform/nrf54l15/IPAddress.h @@ -0,0 +1,34 @@ +// IPAddress.h — stub for nRF54L15/Zephyr +// MQTT.cpp includes this for IPv4 address representation. +// Phase 2: compile-only stub. +#pragma once +#include + +class IPAddress +{ + public: + IPAddress() : _addr(0) {} + explicit IPAddress(uint32_t addr) : _addr(addr) {} + IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d) + : _addr(((uint32_t)a) | ((uint32_t)b << 8) | ((uint32_t)c << 16) | ((uint32_t)d << 24)) + { + } + + uint8_t operator[](int i) const { return reinterpret_cast(&_addr)[i]; } + operator uint32_t() const { return _addr; } + bool operator==(const IPAddress &o) const { return _addr == o._addr; } + bool operator!=(const IPAddress &o) const { return _addr != o._addr; } + + bool fromString(const char *addr) + { + unsigned a, b, c, d; + if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4 && a <= 255 && b <= 255 && c <= 255 && d <= 255) { + _addr = a | (b << 8) | (c << 16) | (d << 24); + return true; + } + return false; + } + + private: + uint32_t _addr; +}; diff --git a/src/platform/nrf54l15/InternalFileSystem.cpp b/src/platform/nrf54l15/InternalFileSystem.cpp new file mode 100644 index 00000000000..1aed726f2a6 --- /dev/null +++ b/src/platform/nrf54l15/InternalFileSystem.cpp @@ -0,0 +1,274 @@ +// InternalFileSystem.cpp — Zephyr LittleFS backend for nRF54L15 +// +// Implements Adafruit_LittleFS_Namespace used by FSCommon.h/cpp. +// Storage: 36 KB storage_partition in nRF54L15 internal RRAM (defined in +// zephyr/dts/nordic/nrf54l15_partition.dtsi, included by the board DTS). + +#include "InternalFileSystem.h" +#include "configuration.h" + +#include +#include +#include + +using namespace Adafruit_LittleFS_Namespace; + +// ── LittleFS mount ──────────────────────────────────────────────────────── + +FS_LITTLEFS_DECLARE_DEFAULT_CONFIG(nrf54l15_lfs_data); + +static struct fs_mount_t _lfs_mnt = { + .type = FS_LITTLEFS, + .mnt_point = NRF54L15_FS_MOUNT, + .fs_data = &nrf54l15_lfs_data, + .storage_dev = (void *)(uintptr_t)FIXED_PARTITION_ID(storage_partition), + .flags = 0, +}; + +// ── Global singleton ────────────────────────────────────────────────────── + +Adafruit_LittleFS_Namespace::InternalFileSystem InternalFS; + +// ── Path helpers ────────────────────────────────────────────────────────── + +void InternalFileSystem::toabs(const char *rel, char *abs, size_t abssz) +{ + // Root "/" maps to the mount point itself (no trailing slash) + if (rel[0] == '/' && rel[1] == '\0') { + strncpy(abs, NRF54L15_FS_MOUNT, abssz - 1); + abs[abssz - 1] = '\0'; + } else if (rel[0] == '/') { + snprintf(abs, abssz, "%s%s", NRF54L15_FS_MOUNT, rel); + } else { + snprintf(abs, abssz, "%s/%s", NRF54L15_FS_MOUNT, rel); + } +} + +// Strip mount-point prefix to get the FS-root-relative path ("/prefs/..."). +static void torel(const char *abs, char *rel, size_t relsz) +{ + const char *mp = NRF54L15_FS_MOUNT; + size_t mplen = strlen(mp); + if (strncmp(abs, mp, mplen) == 0) { + const char *suffix = abs + mplen; + if (suffix[0] == '\0') { + strncpy(rel, "/", relsz); + } else { + strncpy(rel, suffix, relsz - 1); + rel[relsz - 1] = '\0'; + } + } else { + strncpy(rel, abs, relsz - 1); + rel[relsz - 1] = '\0'; + } +} + +// ── InternalFileSystem methods ──────────────────────────────────────────── + +bool InternalFileSystem::begin() +{ + if (_mounted) + return true; + + int rc = fs_mount(&_lfs_mnt); + if (rc == 0) { + _mounted = true; + return true; + } + + // Mount failed: attempt to format (creates a fresh LittleFS) + LOG_WARN("LittleFS mount failed (%d), formatting storage partition...", rc); + int fmt_rc = fs_mkfs(FS_LITTLEFS, (uintptr_t)FIXED_PARTITION_ID(storage_partition), NULL, 0); + if (fmt_rc != 0) { + LOG_ERROR("LittleFS format failed (%d)", fmt_rc); + return false; + } + + rc = fs_mount(&_lfs_mnt); + if (rc == 0) { + _mounted = true; + return true; + } + + LOG_ERROR("LittleFS mount failed after format (%d)", rc); + return false; +} + +File InternalFileSystem::open(const char *path, const char *mode) +{ + if (!_mounted) + return File(); + + char abs[NRF54L15_FS_PATHLEN]; + toabs(path, abs, sizeof(abs)); + + auto s = std::make_shared(); + if (!s) + return File(); + + strncpy(s->fullpath, abs, sizeof(s->fullpath) - 1); + torel(abs, s->relpath, sizeof(s->relpath)); + + // Check whether the path is a directory + struct fs_dirent entry; + int stat_rc = fs_stat(abs, &entry); + if (stat_rc == 0 && entry.type == FS_DIR_ENTRY_DIR) { + s->is_dir = true; + if (fs_opendir(&s->dir, abs) == 0) { + s->valid = true; + return File(s); + } + return File(); + } + + // Open as a regular file + fs_mode_t flags; + if (strcmp(mode, FILE_O_WRITE) == 0) { + // Truncate on write — unlink first to ensure a clean start + fs_unlink(abs); + flags = FS_O_WRITE | FS_O_CREATE; + } else { + flags = FS_O_READ; + } + + if (fs_open(&s->file, abs, flags) == 0) { + s->is_dir = false; + s->valid = true; + return File(s); + } + + return File(); +} + +bool InternalFileSystem::exists(const char *path) +{ + if (!_mounted) + return false; + char abs[NRF54L15_FS_PATHLEN]; + toabs(path, abs, sizeof(abs)); + struct fs_dirent entry; + return fs_stat(abs, &entry) == 0; +} + +bool InternalFileSystem::remove(const char *path) +{ + if (!_mounted) + return false; + char abs[NRF54L15_FS_PATHLEN]; + toabs(path, abs, sizeof(abs)); + return fs_unlink(abs) == 0; +} + +bool InternalFileSystem::rename(const char *from, const char *to) +{ + if (!_mounted) + return false; + char absfrom[NRF54L15_FS_PATHLEN], absto[NRF54L15_FS_PATHLEN]; + toabs(from, absfrom, sizeof(absfrom)); + toabs(to, absto, sizeof(absto)); + return fs_rename(absfrom, absto) == 0; +} + +bool InternalFileSystem::mkdir(const char *path) +{ + if (!_mounted) + return false; + char abs[NRF54L15_FS_PATHLEN]; + toabs(path, abs, sizeof(abs)); + int rc = fs_mkdir(abs); + return rc == 0 || rc == -EEXIST; +} + +bool InternalFileSystem::rmdir(const char *path) +{ + if (!_mounted) + return false; + char abs[NRF54L15_FS_PATHLEN]; + toabs(path, abs, sizeof(abs)); + return fs_unlink(abs) == 0; +} + +bool InternalFileSystem::rmdir_r(const char *path) +{ + if (!_mounted) + return false; + char abs[NRF54L15_FS_PATHLEN]; + toabs(path, abs, sizeof(abs)); + + struct fs_dir_t dir; + fs_dir_t_init(&dir); + if (fs_opendir(&dir, abs) != 0) { + // Not a directory — try to delete as file + return fs_unlink(abs) == 0; + } + + struct fs_dirent entry; + char child[NRF54L15_FS_PATHLEN]; + while (fs_readdir(&dir, &entry) == 0 && entry.name[0] != '\0') { + snprintf(child, sizeof(child), "%s/%s", abs, entry.name); + if (entry.type == FS_DIR_ENTRY_DIR) { + // Recurse: pass the absolute path stripped of mount prefix + char childrel[NRF54L15_FS_PATHLEN]; + torel(child, childrel, sizeof(childrel)); + rmdir_r(childrel); + } else { + fs_unlink(child); + } + } + fs_closedir(&dir); + return fs_unlink(abs) == 0; +} + +bool InternalFileSystem::format() +{ + if (_mounted) { + fs_unmount(&_lfs_mnt); + _mounted = false; + } + int rc = fs_mkfs(FS_LITTLEFS, (uintptr_t)FIXED_PARTITION_ID(storage_partition), NULL, 0); + if (rc != 0) { + LOG_ERROR("LittleFS format failed (%d)", rc); + return false; + } + return begin(); +} + +// ── File::openNextFile ──────────────────────────────────────────────────── +// Defined here because it accesses Zephyr fs_readdir/fs_open APIs. + +File File::openNextFile() +{ + if (!_s || !_s->valid || !_s->is_dir) + return File(); + + struct fs_dirent entry; + if (fs_readdir(&_s->dir, &entry) != 0) + return File(); + if (entry.name[0] == '\0') + return File(); // end of directory + + char childabs[NRF54L15_FS_PATHLEN]; + snprintf(childabs, sizeof(childabs), "%s/%s", _s->fullpath, entry.name); + + auto s = std::make_shared(); + if (!s) + return File(); + + strncpy(s->fullpath, childabs, sizeof(s->fullpath) - 1); + torel(childabs, s->relpath, sizeof(s->relpath)); + + if (entry.type == FS_DIR_ENTRY_DIR) { + s->is_dir = true; + if (fs_opendir(&s->dir, childabs) == 0) { + s->valid = true; + return File(s); + } + } else { + s->is_dir = false; + if (fs_open(&s->file, childabs, FS_O_READ) == 0) { + s->valid = true; + return File(s); + } + } + return File(); +} diff --git a/src/platform/nrf54l15/InternalFileSystem.h b/src/platform/nrf54l15/InternalFileSystem.h new file mode 100644 index 00000000000..d6b1a9d00ec --- /dev/null +++ b/src/platform/nrf54l15/InternalFileSystem.h @@ -0,0 +1,212 @@ +// InternalFileSystem.h — Zephyr LittleFS backend for nRF54L15 +// +// Implements the Adafruit InternalFileSystem API subset used by Meshtastic, +// backed by Zephyr's fs/littlefs on the storage_partition of the nRF54L15's +// internal RRAM. Partition size is taken from the DTS at compile time via +// FIXED_PARTITION_SIZE(storage_partition) — the DK overlay currently maps +// ~700 KB into slot1 (see zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay). +// +// Mount point: /lfs +// All paths passed to open/exists/mkdir etc. are relative to the FS root +// (e.g. "/prefs/config.proto") and are prepended with "/lfs" internally. +// +// File objects are copyable via std::shared_ptr. +// The underlying Zephyr handle is closed when the last copy is destroyed. + +#pragma once + +#include +#include +#include +#include + +#include +#include + +#ifndef FILE_O_READ +#define FILE_O_READ "r" +#define FILE_O_WRITE "w" +#endif + +#define NRF54L15_FS_MOUNT "/lfs" +#define NRF54L15_FS_PATHLEN 256 + +namespace Adafruit_LittleFS_Namespace +{ + +class InternalFileSystem; // forward + +// ── Internal file/dir state ─────────────────────────────────────────────── + +struct NRF54L15FileState { + bool valid = false; + bool is_dir = false; + + // Absolute Zephyr path, e.g. "/lfs/prefs/config.proto" + char fullpath[NRF54L15_FS_PATHLEN] = {0}; + // Path from FS root, e.g. "/prefs/config.proto" (returned by name()) + char relpath[NRF54L15_FS_PATHLEN] = {0}; + + struct fs_file_t file; + struct fs_dir_t dir; + + NRF54L15FileState() + { + fs_file_t_init(&file); + fs_dir_t_init(&dir); + } + + ~NRF54L15FileState() + { + if (valid) { + if (is_dir) + fs_closedir(&dir); + else + fs_close(&file); + valid = false; + } + } +}; + +// ── File ───────────────────────────────────────────────────────────────── + +class File +{ + public: + File() = default; + explicit File(InternalFileSystem &) {} // nRF52 compat constructor + + explicit operator bool() const { return _s && _s->valid; } + + int read(void *buf, uint16_t nbyte) + { + if (!_s || !_s->valid || _s->is_dir) + return -1; + ssize_t n = fs_read(&_s->file, buf, nbyte); + return n < 0 ? -1 : (int)n; + } + + int read() + { + uint8_t b; + return read(&b, 1) == 1 ? (int)b : -1; + } + + size_t write(const uint8_t *buf, size_t len) + { + if (!_s || !_s->valid || _s->is_dir) + return 0; + ssize_t n = fs_write(&_s->file, buf, len); + return n < 0 ? 0 : (size_t)n; + } + + size_t write(uint8_t b) { return write(&b, 1); } + + void flush() + { + if (_s && _s->valid && !_s->is_dir) + fs_sync(&_s->file); + } + + void close() { _s.reset(); } + + size_t size() + { + if (!_s || !_s->valid || _s->is_dir) + return 0; + struct fs_dirent entry; + if (fs_stat(_s->fullpath, &entry) == 0) + return (size_t)entry.size; + return 0; + } + + bool isDirectory() { return _s && _s->valid && _s->is_dir; } + + // Returns path from FS root, e.g. "/prefs/config.proto" + const char *name() { return _s ? _s->relpath : ""; } + + // Returns the next entry in a directory. Modifies the dir stream in _s. + File openNextFile(); + + void rewindDirectory() + { + if (!_s || !_s->valid || !_s->is_dir) + return; + // Zephyr has no rewinddir(); close + reopen the same handle. Skipping + // the close would leak the LittleFS dir state and the next openNextFile + // could return stale entries on some Zephyr versions. + fs_closedir(&_s->dir); + fs_dir_t_init(&_s->dir); + if (fs_opendir(&_s->dir, _s->fullpath) != 0) { + _s->valid = false; + } + } + + bool seek(uint32_t pos) + { + if (!_s || !_s->valid || _s->is_dir) + return false; + return fs_seek(&_s->file, (off_t)pos, FS_SEEK_SET) == 0; + } + + int available() + { + if (!_s || !_s->valid || _s->is_dir) + return 0; + off_t pos = fs_tell(&_s->file); + if (pos < 0) + return 0; + struct fs_dirent entry; + if (fs_stat(_s->fullpath, &entry) != 0) + return 0; + long rem = (long)entry.size - (long)pos; + return rem > 0 ? (int)rem : 0; + } + + int peek() { return -1; } + + // Internal: constructed by InternalFileSystem and openNextFile() + explicit File(std::shared_ptr s) : _s(std::move(s)) {} + + private: + std::shared_ptr _s; +}; + +// ── InternalFileSystem ──────────────────────────────────────────────────── + +class InternalFileSystem +{ + public: + bool begin(); + File open(const char *path, const char *mode); + bool exists(const char *path); + bool remove(const char *path); + bool rename(const char *from, const char *to); + bool mkdir(const char *path); + bool rmdir(const char *path); + bool rmdir_r(const char *path); // recursive delete (used by FSCommon rmDir) + uint32_t usedBytes() + { + struct fs_statvfs st = {}; + if (fs_statvfs(NRF54L15_FS_MOUNT, &st) != 0) + return 0; + // Zephyr returns block counts; convert to bytes. f_frsize is the + // fundamental fragment size (LittleFS reports it equal to the block + // size). used = (total - free) * frag_size. + if (st.f_blocks <= st.f_bfree) + return 0; + return (uint32_t)((st.f_blocks - st.f_bfree) * st.f_frsize); + } + uint32_t totalBytes() { return (uint32_t)FIXED_PARTITION_SIZE(storage_partition); } + bool format(); + + // Convert a FS-root-relative path to an absolute Zephyr path. + static void toabs(const char *rel, char *abs, size_t abssz); + + private: + bool _mounted = false; +}; + +} // namespace Adafruit_LittleFS_Namespace + +extern Adafruit_LittleFS_Namespace::InternalFileSystem InternalFS; diff --git a/src/platform/nrf54l15/NRF52Bluetooth.h b/src/platform/nrf54l15/NRF52Bluetooth.h new file mode 100644 index 00000000000..13cc7348532 --- /dev/null +++ b/src/platform/nrf54l15/NRF52Bluetooth.h @@ -0,0 +1,18 @@ +// NRF52Bluetooth.h — stub for nRF54L15/Zephyr +// main.h includes this when ARCH_NRF52 is defined. +// Bluetooth is excluded (MESHTASTIC_EXCLUDE_BLUETOOTH=1); this satisfies the +// include chain without pulling in the nRF52 Bluefruit SDK. +#pragma once + +class NRF52Bluetooth +{ + public: + void setup() {} + void shutdown() {} + void startDisabled() {} + void resumeAdvertising() {} + void clearBonds() {} + bool isConnected() { return false; } + int getRssi() { return 0; } + void sendLog(const uint8_t *, size_t) {} +}; diff --git a/src/platform/nrf54l15/NRF54L15Bluetooth.cpp b/src/platform/nrf54l15/NRF54L15Bluetooth.cpp new file mode 100644 index 00000000000..1e9281b7ba1 --- /dev/null +++ b/src/platform/nrf54l15/NRF54L15Bluetooth.cpp @@ -0,0 +1,805 @@ +// NRF54L15Bluetooth.cpp — Zephyr BLE GATT peripheral for Meshtastic nRF54L15 +// +// GATT profile (identical UUIDs to the nRF52 / NimBLE implementations): +// Service: 6ba1b218-15a8-461f-9fa8-5dcae273eafd +// fromNum: ed9da18c-a800-4f66-a670-aa7547e34453 READ | NOTIFY +// fromRadio: 2c55e69e-4993-11ed-b878-0242ac120002 READ +// toRadio: f75c76d2-129e-4dad-a1dd-7866124401e7 WRITE +// logRadio: 5a3d6e49-06e6-4423-9944-e9de8cdf9547 READ | NOTIFY | INDICATE +// +// Threading model: +// - BT RX thread: connected_cb / disconnected_cb / GATT read_/write_ +// callbacks +// - Meshtastic OSThread scheduler (cooperative, main thread): +// BleDeferredThread +// polls pendingToRadio and runs the zombie-connection watchdog every 100 ms +// - PhoneAPI::onNowHasData: sends fromNum notify synchronously from whichever +// thread pushed the packet (bt_gatt_notify is thread-safe in Zephyr) +// - active_conn protected by ble_mutex where needed + +#include "NRF54L15Bluetooth.h" +#include "BluetoothCommon.h" +#include "BluetoothStatus.h" +#include "PowerFSM.h" +#include "concurrency/OSThread.h" +#include "configuration.h" +#include "main.h" +#include "mesh/PhoneAPI.h" +#include "mesh/mesh-pb-constants.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// ── UUID definitions (little-endian per Bluetooth spec) +// ─────────────────────── Syntax: replace hyphens with commas, prefix 0x — +// matches BT_UUID_128_ENCODE doc. + +#define MESH_SVC_UUID_VAL BT_UUID_128_ENCODE(0x6ba1b218, 0x15a8, 0x461f, 0x9fa8, 0x5dcae273eafd) +#define FROMNUM_UUID_VAL BT_UUID_128_ENCODE(0xed9da18c, 0xa800, 0x4f66, 0xa670, 0xaa7547e34453) +#define FROMRADIO_UUID_VAL BT_UUID_128_ENCODE(0x2c55e69e, 0x4993, 0x11ed, 0xb878, 0x0242ac120002) +#define TORADIO_UUID_VAL BT_UUID_128_ENCODE(0xf75c76d2, 0x129e, 0x4dad, 0xa1dd, 0x7866124401e7) +#define LOGRADIO_UUID_VAL BT_UUID_128_ENCODE(0x5a3d6e49, 0x06e6, 0x4423, 0x9944, 0xe9de8cdf9547) + +static const struct bt_uuid_128 mesh_svc_uuid = BT_UUID_INIT_128(MESH_SVC_UUID_VAL); +static const struct bt_uuid_128 fromnum_uuid = BT_UUID_INIT_128(FROMNUM_UUID_VAL); +static const struct bt_uuid_128 fromradio_uuid = BT_UUID_INIT_128(FROMRADIO_UUID_VAL); +static const struct bt_uuid_128 toradio_uuid = BT_UUID_INIT_128(TORADIO_UUID_VAL); +static const struct bt_uuid_128 logradio_uuid = BT_UUID_INIT_128(LOGRADIO_UUID_VAL); + +// ── Module state ───────────────────────────────────────────────────────────── + +static struct bt_conn *active_conn = nullptr; +static K_MUTEX_DEFINE(ble_mutex); + +// Take a reference to active_conn under ble_mutex. Returns nullptr if there is +// no active connection. Caller MUST bt_conn_unref() when done. +// +// Reading `active_conn` outside this lock races with disconnected_cb which can +// unref + null it on the BT RX thread — touching the freed pointer (even just +// to bt_conn_ref it) is a use-after-free. +static struct bt_conn *acquire_active_conn() +{ + struct bt_conn *conn = nullptr; + k_mutex_lock(&ble_mutex, K_FOREVER); + if (active_conn) { + conn = bt_conn_ref(active_conn); + } + k_mutex_unlock(&ble_mutex); + return conn; +} + +static bool bt_initialized = false; // bt_enable() called at most once +static bool ble_enabled = false; // set by setup(), cleared by shutdown() + +// Forward declarations — BT_GATT_SERVICE_DEFINE(mesh_svc, ...) is below, but +// read_fromradio() (defined earlier) needs to reference the service to notify +// on fromNum after each non-empty read. +#define FROMNUM_ATTR_IDX 2 +#define LOGRADIO_ATTR_IDX 9 +extern const struct bt_gatt_service_static mesh_svc; + +static void start_advertising(); // forward declaration (defined in advertising + // section below) + +// Work item for advertising restart after disconnect. +// +// disconnected_cb runs on the BT RX thread (the same thread that processes +// HCI Command Complete events). Calling bt_le_adv_start() → +// bt_hci_cmd_send_sync() directly from that thread deadlocks: the thread blocks +// on k_sem_take waiting for Command Complete, but it is the very thread that +// would process it. After 10 s the host panics with "Controller unresponsive, +// opcode 0x2006 timeout". +// +// Fix: submit a k_work item. The system workqueue runs bt_adv_restart_work_fn +// on its own thread → no deadlock. +static struct k_work adv_restart_work; + +static void adv_restart_work_fn(struct k_work *work) +{ + if (ble_enabled) { + start_advertising(); + } +} + +// CCC state: 0=off, BT_GATT_CCC_NOTIFY=notify, BT_GATT_CCC_INDICATE=indicate +static uint16_t fromnum_ccc_val = 0; +static uint16_t logradio_ccc_val = 0; + +// Scratch buffers — only one BLE operation at a time +static uint8_t fromRadioBytes[meshtastic_FromRadio_size]; +static size_t fromRadioLen = 0; +static uint8_t toRadioBytes[meshtastic_ToRadio_size]; +static uint8_t lastToRadio[MAX_TO_FROM_RADIO_SIZE]; +static uint32_t fromNumValue = 0; + +// Deferred ToRadio processing +// +// write_toradio() runs on the BT RX workqueue thread (6 KB stack). Calling +// phoneAPI->handleToRadio() directly triggers handleStartConfig → +// getFiles("/", 10) → nanopb encode, which overflows the stack on the exact +// "Client wants config" write. Instead we copy the payload into a pending +// buffer under a mutex and let BleDeferredThread (running on the Meshtastic +// OSThread scheduler, 24 KB stack) do the actual call outside the lock. +// +// The mutex makes the producer/consumer handoff race-free — producer may +// overwrite a pending buffer the consumer hasn't read yet (dropped packet), +// but partial reads / torn writes are impossible. +K_MUTEX_DEFINE(pendingToRadioMutex); +static uint8_t pendingToRadioBuf[MAX_TO_FROM_RADIO_SIZE]; +static size_t pendingToRadioLen = 0; +static bool pendingToRadio = false; + +// Zombie-connection watchdog state. +// +// The nRF54L15 Zephyr 4.2.1 SW-LL occasionally fails to forward an +// LE Disconnection Complete event to the host: when iOS tears down the link +// (either explicitly by the user or via supervision timeout), the LL layer +// drops the connection but disconnected_cb never fires, active_conn stays +// non-null and advertising never restarts — the device vanishes from scans +// until power cycle. Track the connected timestamp and the last time we +// observed ATT traffic; a long ATT idle on an "active" connection means we +// are zombied. A cold reboot is the only path that reliably recovers (any +// bt_hci_cmd_send_sync after this state, e.g. bt_le_adv_start or +// bt_conn_disconnect, hangs in k_sem_take and later panics with "Controller +// unresponsive, opcode 0x2006 timeout"). +static uint32_t connect_time_ms = 0; +static uint32_t last_att_time_ms = 0; + +// ── BluetoothPhoneAPI +// ───────────────────────────────────────────────────────── + +class BluetoothPhoneAPI : public PhoneAPI +{ + virtual void onNowHasData(uint32_t fromRadioNum) override; + virtual bool checkIsConnected() override; + + public: + BluetoothPhoneAPI() { api_type = TYPE_BLE; } +}; + +static BluetoothPhoneAPI *phoneAPI = nullptr; + +// ── CCC change callbacks +// ────────────────────────────────────────────────────── + +static void fromnum_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) +{ + fromnum_ccc_val = value; + LOG_INFO("BLE fromNum CCC: %u", value); +} + +static void logradio_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) +{ + logradio_ccc_val = value; + LOG_INFO("BLE logRadio CCC: %u", value); +} + +// ── GATT attribute callbacks +// ────────────────────────────────────────────────── + +static ssize_t read_fromnum(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) +{ + LOG_INFO("GATT read_fromnum: fromNum=%u offset=%u", fromNumValue, offset); + return bt_gatt_attr_read(conn, attr, buf, len, offset, &fromNumValue, sizeof(fromNumValue)); +} + +static ssize_t read_fromradio(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) +{ + if (offset == 0) { + // First chunk: pull the next packet from the queue. + // Subsequent chunks (offset > 0) are ATT_READ_BLOB continuations of the + // same value and must reuse fromRadioBytes untouched. + fromRadioLen = phoneAPI ? phoneAPI->getFromRadio(fromRadioBytes) : 0; + LOG_DEBUG("GATT read_fromradio len=%u", (unsigned)fromRadioLen); + } + last_att_time_ms = k_uptime_get_32(); + return bt_gatt_attr_read(conn, attr, buf, len, offset, fromRadioBytes, fromRadioLen); +} + +static ssize_t read_logradio(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) +{ + // logRadio is write-only from the device side (notify/indicate). + // Return an empty read so GATT discovery doesn't fail with NOT_PERMITTED. + return bt_gatt_attr_read(conn, attr, buf, len, offset, NULL, 0); +} + +static ssize_t write_toradio(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len, + uint16_t offset, uint8_t flags) +{ + // Writes >MTU-3 arrive here with offset=0 and + // flags=BT_GATT_WRITE_FLAG_EXECUTE after Zephyr reassembles the ATT Prepare + // Write fragments (CONFIG_BT_ATT_PREPARE_COUNT>0). Single writes arrive with + // flags=0. + LOG_DEBUG("GATT write_toradio len=%u flags=0x%x", len, flags); + if (offset != 0) { + return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET); + } + // Reject any write that won't fit in the dedup buffer (lastToRadio) or the + // pending handoff buffer (pendingToRadioBuf), both sized + // MAX_TO_FROM_RADIO_SIZE. Returning success while silently dropping a + // payload would let the phone believe a config write was applied. + if (len > sizeof(toRadioBytes) || len > MAX_TO_FROM_RADIO_SIZE) { + return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); + } + + // Deduplicate — drop packet if identical to the last one we processed + if (memcmp(lastToRadio, buf, len) != 0) { + memcpy(lastToRadio, buf, len); + if (len < MAX_TO_FROM_RADIO_SIZE) { + memset(lastToRadio + len, 0, MAX_TO_FROM_RADIO_SIZE - len); + } + // Defer handleToRadio() to BleDeferredThread (24 KB stack). + // Running it here on bt_workq (6 KB) overflows during handleStartConfig. + // Always overwrite pending — we already dedup'd above via lastToRadio, + // so any new write here is genuinely new data that must be delivered. + k_mutex_lock(&pendingToRadioMutex, K_FOREVER); + memcpy(pendingToRadioBuf, buf, len); + pendingToRadioLen = len; + pendingToRadio = true; + k_mutex_unlock(&pendingToRadioMutex); + } + last_att_time_ms = k_uptime_get_32(); + return (ssize_t)len; +} + +// ── GATT service definition (static, linked at compile time) +// ────────────────── +// +// Attribute indices (0-based): +// [0] Primary Service declaration +// [1] fromNum characteristic declaration +// [2] fromNum value ← notify target (FROMNUM_ATTR_IDX) +// [3] fromNum CCC descriptor +// [4] fromRadio characteristic declaration +// [5] fromRadio value +// [6] toRadio characteristic declaration +// [7] toRadio value +// [8] logRadio characteristic declaration +// [9] logRadio value ← notify target (LOGRADIO_ATTR_IDX) +// [10] logRadio CCC descriptor + +// All user characteristics require authenticated encryption (MITM passkey) +// before the client can read/write. This mirrors the nrf52 +// SECMODE_ENC_WITH_MITM service permission. The stack returns "Insufficient +// Authentication" on the first access attempt, prompting the client to pair +// with the configured PIN. +#define MESH_PERM_READ (BT_GATT_PERM_READ | BT_GATT_PERM_READ_AUTHEN) +#define MESH_PERM_WRITE (BT_GATT_PERM_WRITE | BT_GATT_PERM_WRITE_AUTHEN) + +BT_GATT_SERVICE_DEFINE(mesh_svc, BT_GATT_PRIMARY_SERVICE(&mesh_svc_uuid.uuid), + + // fromNum: READ | NOTIFY — packet-counter triggers phone to read fromRadio + BT_GATT_CHARACTERISTIC(&fromnum_uuid.uuid, BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY, MESH_PERM_READ, + read_fromnum, NULL, &fromNumValue), + BT_GATT_CCC(fromnum_ccc_changed, MESH_PERM_READ | MESH_PERM_WRITE), + + // fromRadio: READ — phone polls this after receiving a fromNum notification + BT_GATT_CHARACTERISTIC(&fromradio_uuid.uuid, BT_GATT_CHRC_READ, MESH_PERM_READ, read_fromradio, NULL, + NULL), + + // toRadio: WRITE — phone sends protobuf packets to the device + BT_GATT_CHARACTERISTIC(&toradio_uuid.uuid, BT_GATT_CHRC_WRITE, MESH_PERM_WRITE, NULL, write_toradio, NULL), + + // logRadio: READ | NOTIFY | INDICATE — log stream to phone when connected + BT_GATT_CHARACTERISTIC(&logradio_uuid.uuid, + BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY | BT_GATT_CHRC_INDICATE, MESH_PERM_READ, + read_logradio, NULL, NULL), + BT_GATT_CCC(logradio_ccc_changed, MESH_PERM_READ | MESH_PERM_WRITE), ); + +// ── Advertising +// ─────────────────────────────────────────────────────────────── +// +// Use legacy advertising (bt_le_adv_start / HCI 0x2006 path). +// +// History: we previously used bt_le_ext_adv_create (true extended advertising) +// because bt_le_adv_start() with CONFIG_BT_EXT_ADV=y was translated internally +// to the extended HCI path with LEGACY-bit (0x2036), which produced +// non-connectable PDUs on the nRF54L15 SW-LL. The true extended path +// (0x203x, AUX_ADV_IND) was connectable but caused two problems: +// 1. iOS CoreBluetooth does not reliably complete GATT after connecting via +// extended advertising (zero ATT PDUs observed in all test sessions). +// 2. After each connection the controller auto-stops the advertising set, and +// the subsequent bt_le_ext_adv_delete() sends LE Remove Advertising Set +// (0x203c) which times out → kernel oops at hci_core.c:506. +// +// With CONFIG_BT_EXT_ADV=n the host uses pure legacy HCI commands — the same +// path Nordic NCS uses in all nRF54L15 examples (peripheral_uart, +// peripheral_lbs) and which is universally iOS-compatible. The legacy data +// payload is 31 bytes: +// FLAGS (3B) + UUID128 (18B) = 21B in adv; NAME in scan-response (17B). + +static void start_advertising() +{ + // IMPORTANT: BT_DATA_BYTES() uses C99 compound literals that GCC C++ treats + // as temporaries; with -Os the compiler may elide writes, leaving stack + // uninitialized. Use static const arrays for stable data (flags, UUID) + // and a runtime pointer for the dynamic device name. + static const uint8_t adv_flags_val[] = {BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR}; + static const uint8_t adv_uuid128_val[] = {MESH_SVC_UUID_VAL}; + + const char *name = bt_get_name(); + size_t full_name_len = strlen(name); + + // Legacy scan-response payload is 31 bytes total. Each AD entry costs 2 + // bytes (length + type), leaving 29 bytes for the name. With + // CONFIG_BT_DEVICE_NAME_MAX=32 the name can exceed that — truncate and + // mark as SHORTENED so bt_le_adv_start() doesn't reject the payload. + constexpr size_t LEGACY_SCAN_RSP_NAME_MAX = 31 - 2; + bool name_shortened = full_name_len > LEGACY_SCAN_RSP_NAME_MAX; + uint8_t name_len = (uint8_t)(name_shortened ? LEGACY_SCAN_RSP_NAME_MAX : full_name_len); + + // Primary advertising data: FLAGS + Meshtastic service UUID128 (21 bytes + // total) + struct bt_data ad[] = { + {BT_DATA_FLAGS, sizeof(adv_flags_val), adv_flags_val}, + {BT_DATA_UUID128_ALL, sizeof(adv_uuid128_val), adv_uuid128_val}, + }; + // Scan response: device name (discovered after scan request) + struct bt_data sd[] = { + {(uint8_t)(name_shortened ? BT_DATA_NAME_SHORTENED : BT_DATA_NAME_COMPLETE), name_len, (const uint8_t *)name}, + }; + + // BT_LE_ADV_OPT_CONN = connectable legacy ADV_IND + stops after first + // connection (replaces deprecated + // CONNECTABLE|ONE_TIME in Zephyr 4.2.1; + // BT_LE_ADV_OPT_CONN = BIT(0)|BIT(1)) + // BT_LE_ADV_OPT_USE_IDENTITY = use static random identity address (stable + // across reboots) Advertising restart after disconnect is via + // adv_restart_work (system workqueue) so calling bt_le_adv_start() from the + // BT RX thread context is avoided. + int err = bt_le_adv_start(BT_LE_ADV_PARAM(BT_LE_ADV_OPT_CONN | BT_LE_ADV_OPT_USE_IDENTITY, BT_GAP_ADV_FAST_INT_MIN_2, + BT_GAP_ADV_FAST_INT_MAX_2, NULL), + ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd)); + + if (err == -EALREADY) { + return; + } + if (err) { + LOG_WARN("BLE adv start failed: %d", err); + } else { + LOG_INFO("BLE advertising as '%s'", bt_get_name()); + } +} + +static void stop_advertising() +{ + bt_le_adv_stop(); +} + +// ── Connection callbacks +// ────────────────────────────────────────────────────── + +static void connected_cb(struct bt_conn *conn, uint8_t err) +{ + if (err) { + LOG_WARN("BLE connection failed, err=0x%02x", err); + return; + } + + k_mutex_lock(&ble_mutex, K_FOREVER); + active_conn = bt_conn_ref(conn); + k_mutex_unlock(&ble_mutex); + + memset(lastToRadio, 0, sizeof(lastToRadio)); + connect_time_ms = k_uptime_get_32(); + last_att_time_ms = connect_time_ms; + + char addr[BT_ADDR_LE_STR_LEN]; + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + LOG_INFO("BLE connected: %s", addr); + + meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); + bluetoothStatus->updateStatus(&newStatus); + + // nRF54L15-DK has no screen — cannot display a PIN to the user. + // Requesting BT_SECURITY_L2 causes the OS to show a pairing dialog that + // the user dismisses, triggering disconnect + advertising restart failure. + // Skip security negotiation; the Meshtastic app works over plain GATT. + // (Security can be re-enabled once a display or NFC OOB path is available.) +} + +static void disconnected_cb(struct bt_conn *conn, uint8_t reason) +{ + LOG_INFO("BLE disconnected, reason=0x%02x", reason); + + k_mutex_lock(&ble_mutex, K_FOREVER); + if (active_conn) { + bt_conn_unref(active_conn); + active_conn = nullptr; + } + k_mutex_unlock(&ble_mutex); + + fromnum_ccc_val = 0; + logradio_ccc_val = 0; + connect_time_ms = 0; + last_att_time_ms = 0; + + if (phoneAPI) { + phoneAPI->close(); + } + memset(lastToRadio, 0, sizeof(lastToRadio)); + + meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); + bluetoothStatus->updateStatus(&newStatus); + + // Schedule advertising restart via work queue — NOT from this callback + // directly. disconnected_cb runs on the BT RX thread; calling + // bt_le_adv_start() here would deadlock (see adv_restart_work comment above). + if (ble_enabled) { + k_work_submit(&adv_restart_work); + } +} + +#if defined(CONFIG_BT_SMP) +static void security_changed_cb(struct bt_conn *conn, bt_security_t level, enum bt_security_err err) +{ + if (err == BT_SECURITY_ERR_PIN_OR_KEY_MISSING) { + // Phone has a stale bond (device was wiped/reflashed). Unpair the stale + // entry so the phone re-pairs cleanly on the next connection attempt. + LOG_WARN("BLE stale bond detected (key missing) — unpairing"); + bt_unpair(BT_ID_DEFAULT, bt_conn_get_dst(conn)); + bt_conn_disconnect(conn, BT_HCI_ERR_AUTH_FAIL); + } else if (err) { + LOG_WARN("BLE security change failed: level=%d err=%d", (int)level, (int)err); + } else { + LOG_INFO("BLE security level %d established", (int)level); + } +} +#endif /* CONFIG_BT_SMP */ + +BT_CONN_CB_DEFINE(conn_callbacks) = { + .connected = connected_cb, + .disconnected = disconnected_cb, +#if defined(CONFIG_BT_SMP) + .security_changed = security_changed_cb, +#endif +}; + +// ── Pairing / auth callbacks +// ────────────────────────────────────────────────── + +#if defined(CONFIG_BT_SMP) +static uint32_t configuredPasskey; + +static void auth_passkey_display(struct bt_conn *conn, unsigned int passkey) +{ + char passkey_str[7]; + snprintf(passkey_str, sizeof(passkey_str), "%06u", passkey); + configuredPasskey = passkey; + LOG_INFO("BLE pairing PIN: %s", passkey_str); + powerFSM.trigger(EVENT_BLUETOOTH_PAIR); + + std::string textkey(passkey_str); + meshtastic::BluetoothStatus pairingStatus(textkey); + bluetoothStatus->updateStatus(&pairingStatus); +} + +static void auth_cancel(struct bt_conn *conn) +{ + LOG_WARN("BLE pairing cancelled"); +} + +static struct bt_conn_auth_cb auth_cb = { + .passkey_display = auth_passkey_display, + .passkey_entry = NULL, + .cancel = auth_cancel, +}; + +static void pairing_complete_cb(struct bt_conn *conn, bool bonded) +{ + LOG_INFO("BLE pairing complete, bonded=%d", (int)bonded); + meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); + bluetoothStatus->updateStatus(&newStatus); +} + +static void pairing_failed_cb(struct bt_conn *conn, enum bt_security_err reason) +{ + LOG_WARN("BLE pairing failed, reason=%d", (int)reason); + meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); + bluetoothStatus->updateStatus(&newStatus); +} + +static struct bt_conn_auth_info_cb auth_info_cb = { + .pairing_complete = pairing_complete_cb, + .pairing_failed = pairing_failed_cb, +}; +#endif /* CONFIG_BT_SMP */ + +// ── BluetoothPhoneAPI methods +// ───────────────────────────────────────────────── + +void BluetoothPhoneAPI::onNowHasData(uint32_t fromRadioNum) +{ + PhoneAPI::onNowHasData(fromRadioNum); + fromNumValue = fromRadioNum; + + if (!(fromnum_ccc_val & BT_GATT_CCC_NOTIFY)) + return; + + // active_conn may be torn down on another thread while we're dispatching + // this notify — acquire under ble_mutex so disconnected_cb can't free the + // conn between the null check and bt_conn_ref. + struct bt_conn *conn = acquire_active_conn(); + if (!conn) + return; + bt_gatt_notify(conn, &mesh_svc.attrs[FROMNUM_ATTR_IDX], &fromNumValue, sizeof(fromNumValue)); + bt_conn_unref(conn); +} + +bool BluetoothPhoneAPI::checkIsConnected() +{ + return active_conn != nullptr; +} + +// ── Deferred ToRadio processor + zombie-connection watchdog ────────────────── +// +// write_toradio() runs on the BT RX workqueue thread (CONFIG_BT_RX_STACK_SIZE) +// and cannot execute phoneAPI->handleToRadio() directly: handleStartConfig +// recurses through nanopb encode + state machine init and overflows the RX +// stack. This thread runs on the Meshtastic OSThread scheduler (24 KB stack), +// picks up the pending ToRadio buffer flagged by write_toradio(), and calls +// handleToRadio() with plenty of headroom. +// +// Real-time fromNum notifications are sent synchronously from +// BluetoothPhoneAPI::onNowHasData() (called by PhoneAPI when new data is +// queued). +// +// Zombie-connection detection has two tiers: +// +// (1) Liveness probe. After IDLE_BEFORE_PROBE_MS without ATT traffic, send +// a bt_gatt_notify to fromNum every PROBE_INTERVAL_MS. If the +// controller replies -ENOTCONN the LL link is definitely dead but the +// host didn't forward LE Disconnection Complete → reboot. We avoid +// probing during normal activity so iOS isn't woken up unnecessarily +// (each probe wakes iOS → triggers a zero-byte FromRadio drain). +// +// (2) Hard watchdog. Absolute HARD_WATCHDOG_MS ceiling on ATT idle as a +// fallback if probes somehow don't detect the zombie. +class BleDeferredThread : public concurrency::OSThread +{ + static constexpr uint32_t IDLE_BEFORE_PROBE_MS = 30000; // 30 s: start probing + static constexpr uint32_t PROBE_INTERVAL_MS = 5000; // 5 s: between probes + static constexpr uint32_t HARD_WATCHDOG_MS = 60000; // 1 min: last resort + + uint32_t last_probe_ms = 0; + + public: + BleDeferredThread() : concurrency::OSThread("BleDeferred") {} + + protected: + int32_t runOnce() override + { + // Snapshot the pending ToRadio buffer under the mutex, then release + // the lock before calling into handleToRadio (which can be slow and + // must not block the BT RX thread producer). + uint8_t buf[MAX_TO_FROM_RADIO_SIZE]; + size_t n = 0; + bool have_pending = false; + k_mutex_lock(&pendingToRadioMutex, K_FOREVER); + if (pendingToRadio) { + memcpy(buf, pendingToRadioBuf, pendingToRadioLen); + n = pendingToRadioLen; + pendingToRadio = false; + have_pending = true; + } + k_mutex_unlock(&pendingToRadioMutex); + if (have_pending && phoneAPI) { + phoneAPI->handleToRadio(buf, n); + } + + // Take a reference to active_conn so it can't be freed underneath us + // if disconnected_cb fires on another thread while we're dispatching. + struct bt_conn *conn = acquire_active_conn(); + if (!conn || connect_time_ms == 0) { + if (conn) + bt_conn_unref(conn); + last_probe_ms = 0; + return 100; + } + + uint32_t now = k_uptime_get_32(); + uint32_t att_idle = now - last_att_time_ms; + + // Liveness probe — only when ATT has been quiet for a while. + if (att_idle > IDLE_BEFORE_PROBE_MS && (now - last_probe_ms) >= PROBE_INTERVAL_MS && + (fromnum_ccc_val & BT_GATT_CCC_NOTIFY)) { + last_probe_ms = now; + int err = bt_gatt_notify(conn, &mesh_svc.attrs[FROMNUM_ATTR_IDX], &fromNumValue, sizeof(fromNumValue)); + if (err == -ENOTCONN) { + LOG_WARN("BLE zombie (probe ENOTCONN); rebooting"); + bt_conn_unref(conn); + k_sleep(K_MSEC(50)); // flush log + sys_reboot(SYS_REBOOT_COLD); + } + } + bt_conn_unref(conn); + + // Hard ceiling — last-resort reboot if probes miss the zombie. + if (att_idle > HARD_WATCHDOG_MS && (now - connect_time_ms) > HARD_WATCHDOG_MS) { + LOG_WARN("BLE zombie (hard watchdog %us); rebooting", HARD_WATCHDOG_MS / 1000); + k_sleep(K_MSEC(50)); + sys_reboot(SYS_REBOOT_COLD); + } + return 100; + } +}; + +static BleDeferredThread *bleDeferredThread = nullptr; + +// ── BT stack pre-initializer (call from main thread before OSThreads start) ── +// +// bt_enable() requires substantially more stack than a Meshtastic OSThread +// (PowerFSMThread) provides — calling it there causes a stack overflow. +// Call this from nrf54l15Setup() (main Zephyr thread, CONFIG_MAIN_STACK_SIZE) +// so that by the time NRF54L15Bluetooth::setup() runs from PowerFSMThread, +// bt_initialized is already true and bt_enable() is skipped. + +void nrf54l15_bt_preinit() +{ + if (!bt_initialized) { + int err = bt_enable(NULL); + if (err) { + LOG_ERROR("BLE pre-init failed: %d", err); + return; + } + bt_initialized = true; + LOG_INFO("BLE stack pre-initialized on main thread"); + + // Phase 7: load bonding keys from LittleFS (/lfs/bt_settings). + // LittleFS is already mounted by fsInit() before nrf54l15Setup() runs. + // On first boot the file doesn't exist — settings_load() returns 0 (OK). + // On subsequent boots, previously bonded peers are restored so the + // phone can reconnect without re-pairing. + err = settings_load(); + if (err) { + LOG_WARN("settings_load failed: %d (OK on first boot)", err); + } else { + LOG_INFO("BT settings loaded from /lfs/bt_settings"); + } + } +} + +// ── NRF54L15Bluetooth public methods ───────────────────────────────────────── + +// Shared init: idempotent setup of work item, OSThread, auth callbacks, +// bt_enable, and device name. Leaves advertising control to the caller. +static bool nrf54l15_bt_init_common() +{ + k_work_init(&adv_restart_work, adv_restart_work_fn); + + if (!bleDeferredThread) { + bleDeferredThread = new BleDeferredThread(); + } + + if (!phoneAPI) { + phoneAPI = new BluetoothPhoneAPI(); + } + +#if defined(CONFIG_BT_SMP) + // NO_PIN is unsupported on this platform: the mesh GATT permissions are + // declared with BT_GATT_PERM_*_AUTHEN, prj.conf sets + // CONFIG_BT_SMP_ENFORCE_MITM=y, and the build pulls in the SMP/passkey path. + // If a user requested NO_PIN we'd register no auth callbacks → no display + // path for the passkey → every GATT access returns BT_ATT_ERR_AUTHENTICATION + // and the link is unusable. Fall back to RANDOM_PIN behavior with a warning + // instead of leaving BLE silently broken. + if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { + LOG_WARN("BLE: NO_PIN not supported on nRF54L15-DK (MITM-only build); " + "treating as RANDOM_PIN"); + } + + bt_conn_auth_cb_register(&auth_cb); + bt_conn_auth_info_cb_register(&auth_info_cb); + + // FIXED_PIN — register the configured passkey so the mobile app prompts + // the user for that specific number instead of a random display-only PIN. + // RANDOM_PIN (and clamped NO_PIN) keeps the default behavior: Zephyr + // generates a fresh passkey on each pairing attempt and fires + // auth_passkey_display with it. + if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN) { + configuredPasskey = config.bluetooth.fixed_pin; + int rc = bt_passkey_set(configuredPasskey); + if (rc) { + LOG_WARN("bt_passkey_set(%u) failed: %d", configuredPasskey, rc); + } else { + LOG_INFO("BLE fixed PIN: %06u", configuredPasskey); + } + } else { + bt_passkey_set(BT_PASSKEY_INVALID); // random per-pair + } +#endif /* CONFIG_BT_SMP */ + + if (!bt_initialized) { + int err = bt_enable(NULL); + if (err) { + LOG_ERROR("BLE enable failed: %d", err); + return false; + } + bt_initialized = true; + LOG_INFO("BLE stack enabled"); + } + + bt_set_name(getDeviceName()); + return true; +} + +void NRF54L15Bluetooth::setup() +{ + LOG_INFO("NRF54L15Bluetooth::setup()"); + if (!nrf54l15_bt_init_common()) { + return; + } + ble_enabled = true; + start_advertising(); +} + +void NRF54L15Bluetooth::shutdown() +{ + LOG_INFO("NRF54L15Bluetooth::shutdown()"); + ble_enabled = false; + stop_advertising(); + + struct bt_conn *conn = acquire_active_conn(); + if (conn) { + bt_conn_disconnect(conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); + bt_conn_unref(conn); + } +} + +void NRF54L15Bluetooth::startDisabled() +{ + // Initialize BT stack but leave advertising off until resumeAdvertising(). + if (!nrf54l15_bt_init_common()) { + return; + } + ble_enabled = false; + LOG_INFO("BLE initialized, advertising stopped (startDisabled)"); +} + +void NRF54L15Bluetooth::resumeAdvertising() +{ + ble_enabled = true; + start_advertising(); +} + +void NRF54L15Bluetooth::clearBonds() +{ + LOG_INFO("BLE clear bonds"); + bt_unpair(BT_ID_DEFAULT, BT_ADDR_LE_ANY); +} + +bool NRF54L15Bluetooth::isConnected() +{ + return active_conn != nullptr; +} + +int NRF54L15Bluetooth::getRssi() +{ + return 0; // TODO: Zephyr has no direct bt_conn_get_rssi; use HCI RSSI read + // command +} + +void NRF54L15Bluetooth::sendLog(const uint8_t *logMessage, size_t length) +{ + if (length > 512 || logradio_ccc_val == 0) { + return; + } + // Acquire a reference under ble_mutex so disconnected_cb can't free the + // connection between the null check and bt_gatt_notify. + struct bt_conn *conn = acquire_active_conn(); + if (!conn) { + return; + } + // Send as notify regardless of whether client subscribed to NOTIFY or + // INDICATE — bt_gatt_indicate() requires a params struct with a callback; + // notify is simpler and the app accepts both. Change to indicate if + // compatibility issues arise. + bt_gatt_notify(conn, &mesh_svc.attrs[LOGRADIO_ATTR_IDX], logMessage, (uint16_t)length); + bt_conn_unref(conn); +} diff --git a/src/platform/nrf54l15/NRF54L15Bluetooth.h b/src/platform/nrf54l15/NRF54L15Bluetooth.h new file mode 100644 index 00000000000..499e91f9877 --- /dev/null +++ b/src/platform/nrf54l15/NRF54L15Bluetooth.h @@ -0,0 +1,29 @@ +// NRF54L15Bluetooth.h — Zephyr BLE backend for nRF54L15 +// +// Implements the same interface as NRF52Bluetooth (same method names and +// signatures) so main.cpp and AdminModule can use nrf52Bluetooth pointer +// without knowing the underlying implementation. +// +// GATT profile is identical to the nRF52 implementation: +// Service: MESH_SERVICE_UUID +// toRadio: TORADIO_UUID (WRITE) +// fromRadio: FROMRADIO_UUID (READ) +// fromNum: FROMNUM_UUID (READ | NOTIFY) +// logRadio: LOGRADIO_UUID (READ | NOTIFY | INDICATE) + +#pragma once + +#include "BluetoothCommon.h" + +class NRF54L15Bluetooth : public BluetoothApi +{ + public: + void setup(); + void shutdown(); + void startDisabled(); + void resumeAdvertising(); + void clearBonds(); + bool isConnected(); + int getRssi(); + void sendLog(const uint8_t *logMessage, size_t length); +}; diff --git a/src/platform/nrf54l15/Nrf52SaadcLock.h b/src/platform/nrf54l15/Nrf52SaadcLock.h new file mode 100644 index 00000000000..deb43a7a690 --- /dev/null +++ b/src/platform/nrf54l15/Nrf52SaadcLock.h @@ -0,0 +1,17 @@ +// Nrf52SaadcLock.h — stub for nRF54L15/Zephyr +// Power.cpp includes this when ARCH_NRF52 is defined. +// Phase 2: compile-only stub. +#pragma once + +#ifdef ARCH_NRF52 + +#include "concurrency/Lock.h" + +namespace concurrency +{ +/** Shared mutex for SAADC configuration and reads (VDD + battery analog path). + * On nRF54L15 ADC is handled differently; this is a compile-only stub. */ +extern Lock *nrf52SaadcLock; +} // namespace concurrency + +#endif diff --git a/src/platform/nrf54l15/Print.h b/src/platform/nrf54l15/Print.h new file mode 100644 index 00000000000..5ff8ed016ed --- /dev/null +++ b/src/platform/nrf54l15/Print.h @@ -0,0 +1,4 @@ +// Print.h — shim for nRF54L15/Zephyr +// Meshtastic includes separately; redirect to our Arduino.h shim. +#pragma once +#include "Arduino.h" diff --git a/src/platform/nrf54l15/SPI.h b/src/platform/nrf54l15/SPI.h new file mode 100644 index 00000000000..b95890b0e1d --- /dev/null +++ b/src/platform/nrf54l15/SPI.h @@ -0,0 +1,62 @@ +/** + * SPI.h — Arduino SPI shim for Zephyr/nRF54L15 + * + * Provides the Arduino SPIClass interface backed by Zephyr's SPI API. The + * backing controller is SPIM00 (HP domain, 3.0 V); the implementation in + * nrf54l15_arduino.cpp binds to DEVICE_DT_GET(DT_NODELABEL(spi00)) and the + * bus is configured in zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay. + * RadioLib uses ArduinoHal which calls transfer() byte-by-byte. + * + * CS pin is handled by RadioLib via digitalWrite() — hardware CS is not used. + */ + +#pragma once + +#include "Arduino.h" +#include + +#define SPI_MODE0 0 +#define SPI_MODE1 1 +#define SPI_MODE2 2 +#define SPI_MODE3 3 + +struct SPISettings { + uint32_t clock; + uint8_t bitOrder; + uint8_t dataMode; + + // Arduino API allows `SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0))` — implicit form is intentional. + // cppcheck-suppress noExplicitConstructor + SPISettings(uint32_t clock = 4000000, uint8_t bitOrder = MSBFIRST, uint8_t dataMode = SPI_MODE0) + : clock(clock), bitOrder(bitOrder), dataMode(dataMode) + { + } +}; + +class SPIClass +{ + public: + void begin() {} + void begin(uint8_t sck, uint8_t miso, uint8_t mosi, uint8_t ss = 0xFF) {} + void end() {} + void beginTransaction(SPISettings) {} + void endTransaction() {} + void setBitOrder(uint8_t order) {} + void setDataMode(uint8_t mode) {} + void setClockDivider(uint8_t div) {} + void setFrequency(uint32_t freq) {} + + // Real Zephyr SPI implementation — defined in nrf54l15_arduino.cpp + uint8_t transfer(uint8_t data); + uint16_t transfer16(uint16_t data); + void transfer(void *buf, size_t count); + void transferBytes(const uint8_t *tx, uint8_t *rx, uint32_t count); + uint8_t transfer(uint8_t tx, uint8_t *rx, uint32_t count) + { + transferBytes(&tx, rx, count); + return rx ? rx[0] : 0; + } +}; + +extern SPIClass SPI; +extern SPIClass SPI1; diff --git a/src/platform/nrf54l15/Stream.h b/src/platform/nrf54l15/Stream.h new file mode 100644 index 00000000000..a20f241dad4 --- /dev/null +++ b/src/platform/nrf54l15/Stream.h @@ -0,0 +1,5 @@ +// Stream.h — shim for nRF54L15/Zephyr +// StreamAPI.h and other Meshtastic headers include . +// Redirect to our Arduino.h shim which defines the Stream base class. +#pragma once +#include "Arduino.h" diff --git a/src/platform/nrf54l15/Tone.h b/src/platform/nrf54l15/Tone.h new file mode 100644 index 00000000000..68dc98b7bf2 --- /dev/null +++ b/src/platform/nrf54l15/Tone.h @@ -0,0 +1,4 @@ +// Tone.h — shim for nRF54L15/Zephyr +// Tone functions are stubbed in Arduino.h; this header satisfies direct includes. +#pragma once +#include "Arduino.h" diff --git a/src/platform/nrf54l15/WProgram.h b/src/platform/nrf54l15/WProgram.h new file mode 100644 index 00000000000..e602f1ee650 --- /dev/null +++ b/src/platform/nrf54l15/WProgram.h @@ -0,0 +1,5 @@ +// WProgram.h — shim for nRF54L15/Zephyr +// ArduinoThread (and other legacy Arduino libs) include . +// Redirect to our Arduino.h shim. +#pragma once +#include "Arduino.h" diff --git a/src/platform/nrf54l15/Wire.cpp b/src/platform/nrf54l15/Wire.cpp new file mode 100644 index 00000000000..2fc0acab737 --- /dev/null +++ b/src/platform/nrf54l15/Wire.cpp @@ -0,0 +1,219 @@ +// Wire.cpp — Arduino TwoWire backed by Zephyr i2c30 (TWIM30 hardware). +// +// The pinctrl + clock-frequency are configured in +// zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay. Runtime begin()/setClock() +// are best-effort: setClock() goes through i2c_configure() to actually change +// the bus speed; begin() just verifies the device is ready. + +#include "Wire.h" +#include "configuration.h" + +#include +#include +#include +#include + +// Resolve the i2c30 node at compile time. If the overlay has not enabled +// i2c30, this evaluates to a NULL device pointer and every call short-circuits +// to a NACK return code — matching the prior compile-only stub behavior. +#define I2C_NODE DT_NODELABEL(i2c30) + +static const struct device *getI2CDevice() +{ +#if DT_NODE_HAS_STATUS(I2C_NODE, okay) + static const struct device *const dev = DEVICE_DT_GET(I2C_NODE); + return dev; +#else + return nullptr; +#endif +} + +// Wire/Wire1 instances are defined in nrf54l15_arduino.cpp alongside the +// other Arduino singletons (Serial, SPI, …). + +TwoWire::TwoWire() : txAddr(0), txLen(0), txBuf{}, rxLen(0), rxPos(0), rxBuf{} {} + +void TwoWire::begin() +{ + const struct device *dev = getI2CDevice(); + if (dev == nullptr) { + LOG_WARN("Wire.begin(): i2c30 not enabled in DT overlay"); + return; + } + if (!device_is_ready(dev)) { + LOG_WARN("Wire.begin(): i2c30 device not ready"); + return; + } + LOG_INFO("Wire.begin(): i2c30 ready"); +} + +void TwoWire::begin(uint8_t /*sda*/, uint8_t /*scl*/) +{ + // SDA/SCL fixed by overlay pinctrl — pin args ignored. + begin(); +} + +void TwoWire::begin(int /*sda*/, int /*scl*/, uint32_t freq) +{ + begin(); + if (freq) { + setClock(freq); + } +} + +void TwoWire::end() +{ + // No-op: Zephyr i2c devices stay initialized for the lifetime of the + // application. Runtime PM (zephyr,pm-device-runtime-auto in the DT) + // handles low-power transitions when idle. +} + +void TwoWire::setClock(uint32_t freq) +{ + const struct device *dev = getI2CDevice(); + if (dev == nullptr) { + return; + } + uint32_t speed; + if (freq >= 1000000U) { + speed = I2C_SPEED_FAST_PLUS; // 1 MHz + } else if (freq >= 400000U) { + speed = I2C_SPEED_FAST; // 400 kHz + } else { + speed = I2C_SPEED_STANDARD; // 100 kHz + } + uint32_t cfg = I2C_MODE_CONTROLLER | I2C_SPEED_SET(speed); + int rc = i2c_configure(dev, cfg); + if (rc) { + LOG_WARN("Wire.setClock(%u) failed: %d", (unsigned)freq, rc); + } +} + +void TwoWire::beginTransmission(uint8_t addr) +{ + txAddr = addr; + txLen = 0; +} + +size_t TwoWire::write(uint8_t data) +{ + if (txLen >= WIRE_BUFFER_LENGTH) { + return 0; // overflow — endTransmission() will return 1 + } + txBuf[txLen++] = data; + return 1; +} + +size_t TwoWire::write(const uint8_t *data, size_t n) +{ + size_t written = 0; + for (size_t i = 0; i < n; i++) { + if (write(data[i]) == 0) { + break; + } + written++; + } + return written; +} + +uint8_t TwoWire::endTransmission(bool /*stop*/) +{ + // Arduino return codes: + // 0 = success + // 1 = data-too-long (overflow caught in write()) + // 2 = NACK on address + // 3 = NACK on data + // 4 = other error + // 5 = timeout + if (txLen > WIRE_BUFFER_LENGTH) { + return 1; + } + const struct device *dev = getI2CDevice(); + if (dev == nullptr || !device_is_ready(dev)) { + return 4; + } + int rc = i2c_write(dev, txBuf, txLen, txAddr); + txLen = 0; + if (rc == 0) { + return 0; + } + if (rc == -EIO) { + return 2; // address NACK is the most common -EIO source on nrf-twim + } + if (rc == -ETIMEDOUT) { + return 5; + } + return 4; +} + +uint8_t TwoWire::requestFrom(uint8_t addr, uint8_t quantity, bool /*stop*/) +{ + rxLen = 0; + rxPos = 0; + if (quantity == 0) { + return 0; + } + if (quantity > WIRE_BUFFER_LENGTH) { + quantity = WIRE_BUFFER_LENGTH; + } + const struct device *dev = getI2CDevice(); + if (dev == nullptr || !device_is_ready(dev)) { + return 0; + } + + // If there is a pending TX (driver wrote register address via write() + // without an explicit endTransmission()), use i2c_write_read so the + // repeated-start path matches the typical "set register pointer then + // read N bytes" sensor protocol. + int rc; + if (txLen > 0) { + rc = i2c_write_read(dev, addr, txBuf, txLen, rxBuf, quantity); + txLen = 0; + } else { + rc = i2c_read(dev, rxBuf, quantity, addr); + } + if (rc) { + return 0; + } + rxLen = quantity; + return quantity; +} + +int TwoWire::available() +{ + return rxLen - rxPos; +} + +int TwoWire::read() +{ + if (rxPos >= rxLen) { + return -1; + } + return rxBuf[rxPos++]; +} + +int TwoWire::peek() +{ + if (rxPos >= rxLen) { + return -1; + } + return rxBuf[rxPos]; +} + +size_t TwoWire::readBytes(uint8_t *buf, size_t len) +{ + size_t n = 0; + while (n < len) { + int b = read(); + if (b < 0) { + break; + } + buf[n++] = (uint8_t)b; + } + return n; +} + +TwoWire::operator bool() const +{ + return getI2CDevice() != nullptr; +} diff --git a/src/platform/nrf54l15/Wire.h b/src/platform/nrf54l15/Wire.h new file mode 100644 index 00000000000..2c8f66dff4f --- /dev/null +++ b/src/platform/nrf54l15/Wire.h @@ -0,0 +1,83 @@ +/** + * Wire.h — Arduino TwoWire (I2C) shim for Zephyr / nRF54L15. + * + * Bus binding: the Zephyr device tree alias `i2c30` (TWIM30 hardware + * peripheral, HP domain, 3.0 V) is resolved at compile time via + * DEVICE_DT_GET in Wire.cpp. SDA/SCL pins are configured in the board + * overlay via pinctrl — `begin(sda, scl)` overloads are accepted for + * Arduino API compatibility but the pin arguments are ignored. + * + * Buffer sizes are sized for the worst-case I2C consumer we plan to use + * (NXP SE050 secure element, ~256-byte T=1 frames). BMP280 / INA228 / + * SHT40 / INA3221 read in single-digit bytes and fit trivially. + */ + +#pragma once + +#include "Arduino.h" +#include +#include + +#ifndef WIRE_BUFFER_LENGTH +#define WIRE_BUFFER_LENGTH 256 +#endif + +class TwoWire +{ + public: + TwoWire(); + + // ── Bus lifecycle ───────────────────────────────────────────────── + // begin() variants — pin arguments are accepted for API compatibility + // but ignored: SDA/SCL are fixed by the Zephyr overlay pinctrl. freq + // is also fixed by overlay clock-frequency (use setClock() at runtime). + void begin(); + void begin(uint8_t sda, uint8_t scl); + void begin(int sda, int scl, uint32_t freq); + void end(); + + void setClock(uint32_t freq); + void setClockStretchLimit(uint32_t) {} // no-op on TWIM hardware + + // ── Master write ───────────────────────────────────────────────── + void beginTransmission(uint8_t addr); + void beginTransmission(int addr) { beginTransmission((uint8_t)addr); } + // Return codes (Arduino convention): + // 0 = success, 1 = data-too-long, 2 = NACK on addr, 3 = NACK on data, + // 4 = other error, 5 = timeout. + uint8_t endTransmission(bool stop = true); + uint8_t endTransmission(uint8_t stop) { return endTransmission(stop != 0); } + + size_t write(uint8_t data); + size_t write(const uint8_t *data, size_t n); + + // ── Master read ────────────────────────────────────────────────── + uint8_t requestFrom(uint8_t addr, uint8_t quantity, bool stop = true); + uint8_t requestFrom(uint8_t addr, uint8_t quantity, uint8_t stop) { return requestFrom(addr, quantity, stop != 0); } + uint8_t requestFrom(int addr, int quantity, int stop = 1) { return requestFrom((uint8_t)addr, (uint8_t)quantity, stop != 0); } + + int available(); + int read(); + int peek(); + size_t readBytes(uint8_t *buf, size_t len); + size_t readBytes(char *buf, size_t len) { return readBytes((uint8_t *)buf, len); } + void flush() {} + + // Slave callbacks unsupported — peripheral-only stub. + void onReceive(void (*)(int)) {} + void onRequest(void (*)(void)) {} + + operator bool() const; + + private: + uint8_t txAddr; + uint16_t txLen; + uint8_t txBuf[WIRE_BUFFER_LENGTH]; + + uint16_t rxLen; + uint16_t rxPos; + uint8_t rxBuf[WIRE_BUFFER_LENGTH]; +}; + +extern TwoWire Wire; +extern TwoWire Wire1; // alias to Wire — only one I2C bus on this board diff --git a/src/platform/nrf54l15/architecture.h b/src/platform/nrf54l15/architecture.h new file mode 100644 index 00000000000..3f2a4c4e0d3 --- /dev/null +++ b/src/platform/nrf54l15/architecture.h @@ -0,0 +1,79 @@ +#pragma once + +#define ARCH_NRF54L15 + +// +// Feature flags for nRF54L15. +// +// The HAS_* macros below are Meshtastic's compile-time feature gate: every +// optional subsystem (BLE, screen, I2C, GPS, buttons, telemetry, sensors, +// radio, CPU shutdown, ...) is wrapped in `#if HAS_FOO` so a given board +// only pays for the features it actually ships. On memory-tight MCUs this +// is not cosmetic — it's the difference between a binary that fits in +// flash and one that doesn't, and between a build that links and one that +// drags in drivers for hardware the board doesn't have. Defaulting to 0 +// here (rather than inheriting nRF52 defaults) is deliberate: the +// nRF54L15-DK is a bare dev kit with no screen, no I2C sensors, no GPS, +// no user buttons — so every HAS_* flag starts off and gets flipped on +// explicitly by variants that add that hardware. +// +// Feature flags are also the cleanest way to absorb platform divergence +// without sprinkling `#ifdef ARCH_NRF54L15` across shared code. Anywhere +// a subsystem can be conditionally compiled via HAS_*, prefer that over +// per-arch guards: it keeps the core code arch-agnostic, makes it trivial +// to bring up the next board (flip the flags, don't patch call sites), +// and keeps the "does this platform support X?" question answerable by +// reading one file instead of grepping the tree. BLE in particular is +// deferred to Phase 2 on this port — the nRF54L15 uses MPSL/Zephyr BLE +// APIs rather than the Adafruit SoftDevice stack used by nRF52840 — so +// while HAS_BLUETOOTH defaults to 1, the actual implementation lives in +// NRF54L15Bluetooth.cpp behind its own Zephyr Kconfig gates. +// + +#ifndef HAS_BLUETOOTH +#define HAS_BLUETOOTH 1 +#endif +#ifndef HAS_SCREEN +#define HAS_SCREEN 0 +#endif +#ifndef HAS_WIRE +#define HAS_WIRE 0 +#endif +#ifndef HAS_GPS +#define HAS_GPS 0 +#endif +#ifndef HAS_BUTTON +#define HAS_BUTTON 0 +#endif +#ifndef HAS_TELEMETRY +#define HAS_TELEMETRY 0 +#endif +#ifndef HAS_SENSOR +#define HAS_SENSOR 0 +#endif +#ifndef HAS_RADIO +#define HAS_RADIO 1 +#endif +#ifndef HAS_CPU_SHUTDOWN +#define HAS_CPU_SHUTDOWN 0 +#endif + +// ADC reference — nRF54L15 SAADC uses VDD/4 internal ref by default +#ifndef AREF_VOLTAGE +#define AREF_VOLTAGE 3.6 +#endif +#ifndef BATTERY_SENSE_RESOLUTION_BITS +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#endif + +// +// HW_VENDOR — maps build-time define to HardwareModel enum. +// PRIVATE_HW (255): the protobuf HardwareModel enum reserves DK / DIY boards +// without an SKU under this value; the nRF54L15-DK doesn't get a dedicated +// enum number. Variant manifest matches via custom_meshtastic_hw_model = 255. +// +#ifdef NRF54L15_DK +#define HW_VENDOR meshtastic_HardwareModel_PRIVATE_HW +#else +#define HW_VENDOR meshtastic_HardwareModel_UNSET +#endif diff --git a/src/platform/nrf54l15/bluefruit.h b/src/platform/nrf54l15/bluefruit.h new file mode 100644 index 00000000000..a9c0afe712b --- /dev/null +++ b/src/platform/nrf54l15/bluefruit.h @@ -0,0 +1,19 @@ +// bluefruit.h — stub for nRF54L15/Zephyr +// NodeDB.cpp includes this when ARCH_NRF52 is defined. +// Bluetooth is excluded (MESHTASTIC_EXCLUDE_BLUETOOTH=1); this satisfies +// the include chain without pulling in the Adafruit Bluefruit SDK. +#pragma once + +struct BLEPeripheral { + void clearBonds() {} +}; +struct BLECentral { + void clearBonds() {} +}; + +struct BlueFruitClass { + BLEPeripheral Periph; + BLECentral Central; +}; + +extern BlueFruitClass Bluefruit; diff --git a/src/platform/nrf54l15/main-nrf54l15.cpp b/src/platform/nrf54l15/main-nrf54l15.cpp new file mode 100644 index 00000000000..5b7729b4d8c --- /dev/null +++ b/src/platform/nrf54l15/main-nrf54l15.cpp @@ -0,0 +1,210 @@ +/* + * main-nrf54l15.cpp — Platform entry points for Nordic nRF54L15 + * + * Adapted from src/platform/nrf52/main-nrf52.cpp. + * SoftDevice, Adafruit BLE, and nRFCrypto are NOT available on nRF54L15. + * Phase 2 will add proper BLE via Zephyr MPSL APIs. + * + * TODO items are marked with "TODO(nrf54l15):" + */ + +#include "configuration.h" +#include +#include +#include +#include + +#include "NodeDB.h" +#include "PowerMon.h" +#include "Router.h" +#include "error.h" +#include "main.h" +#include "mesh/MeshService.h" +#include "meshUtils.h" +#include "power.h" +#include + +// ── Watchdog ────────────────────────────────────────────────────────────── +// TODO(nrf54l15): nRF54L15 has a WDT peripheral but nrfx_wdt driver support +// may differ depending on the Zephyr SDK version. Enable once confirmed. +#define APP_WATCHDOG_SECS 90 +static bool watchdog_running = false; + +static inline void watchdog_feed() {} // TODO(nrf54l15): replace with real WDT feed + +// ── Weak variant hooks ──────────────────────────────────────────────────── +void variant_shutdown() __attribute__((weak)); +void variant_shutdown() {} + +void variant_nrf54l15LoopHook(void) __attribute__((weak)); +void variant_nrf54l15LoopHook(void) {} + +// ── PowerHAL ───────────────────────────────────────────────────────────── +bool powerHAL_isVBUSConnected() +{ + // TODO(nrf54l15): nRF54L15 has a USB POWER peripheral — read USBREGSTATUS + return false; +} + +bool powerHAL_isPowerLevelSafe() +{ + // TODO(nrf54l15): implement SAADC VDD measurement similar to nRF52 + return true; +} + +void powerHAL_platformInit() +{ + // TODO(nrf54l15): configure POF comparator and analog reference if needed +} + +// ── Utilities ───────────────────────────────────────────────────────────── +bool loopCanSleep() +{ + return !Serial; +} + +void updateBatteryLevel(uint8_t level) +{ + (void)level; +} + +void __attribute__((noreturn)) __assert_func(const char *file, int line, const char *func, const char *failedexpr) +{ + LOG_ERROR("assert failed %s: %d, %s, test=%s", file, line, func, failedexpr); + NVIC_SystemReset(); +} + +void getMacAddr(uint8_t *dmac) +{ + // TODO(nrf54l15): verify FICR register layout for nRF54L15. + // nRF52840 uses NRF_FICR->DEVICEADDR[0/1]; nRF54L15 Zephyr HAL may differ. +#if defined(NRF_FICR) + const uint8_t *src = (const uint8_t *)NRF_FICR->DEVICEADDR; + dmac[5] = src[0]; + dmac[4] = src[1]; + dmac[3] = src[2]; + dmac[2] = src[3]; + dmac[1] = src[4]; + dmac[0] = src[5] | 0xc0; +#else + // Fallback: fixed placeholder until Zephyr FICR path is confirmed + dmac[0] = 0xC2; + dmac[1] = 0xA7; + dmac[2] = 0x54; + dmac[3] = 0x15; + dmac[4] = 0x00; + dmac[5] = 0x01; +#endif +} + +// ── Bluetooth ───────────────────────────────────────────────────────────────── + +void setBluetoothEnable(bool enable) +{ + if (enable) { + static bool initialized = false; + if (!initialized) { + nrf54l15Bluetooth = new NRF54L15Bluetooth(); + nrf54l15Bluetooth->startDisabled(); + initialized = true; + } + if (nrf54l15Bluetooth) { + nrf54l15Bluetooth->resumeAdvertising(); + } + } else { + if (nrf54l15Bluetooth) { + nrf54l15Bluetooth->shutdown(); + } + } +} + +void clearBonds() +{ + if (!nrf54l15Bluetooth) { + nrf54l15Bluetooth = new NRF54L15Bluetooth(); + nrf54l15Bluetooth->setup(); + } + nrf54l15Bluetooth->clearBonds(); +} + +void enterDfuMode() +{ + // TODO(nrf54l15): nRF54L15 uses nRF Connect DFU (MCUboot/SUIT). + // Trigger via Zephyr boot_request_upgrade() or similar. + NVIC_SystemReset(); +} + +// ── printf via RTT ──────────────────────────────────────────────────────── +// TODO(nrf54l15): SEGGER_RTT may not be available with Zephyr; use printk() +// or a USB CDC console instead. Remove this override if it conflicts. +#ifdef SEGGER_RTT_PRINTF +int printf(const char *fmt, ...) +{ + va_list args; + va_start(args, fmt); + auto res = SEGGER_RTT_vprintf(0, fmt, &args); + va_end(args); + return res; +} +#endif + +// ── Deep sleep ──────────────────────────────────────────────────────────── +void cpuDeepSleep(uint32_t msecToWake) +{ +#if HAS_WIRE + Wire.end(); +#endif + SPI.end(); + if (Serial) + Serial.end(); + + variant_shutdown(); + + // TODO(nrf54l15): use Zephyr pm_system_suspend() or WFI for proper low-power + if (msecToWake != portMAX_DELAY) { + delay(msecToWake); + NVIC_SystemReset(); + } else { + // System off equivalent — halt + while (1) { + __WFI(); + } + } +} + +// ── Setup / Loop ────────────────────────────────────────────────────────── +// Forward declaration — defined in NRF54L15Bluetooth.cpp +void nrf54l15_bt_preinit(); + +void nrf54l15Setup() +{ + // nRF54L15 power peripheral layout differs from nRF52; RESETREAS not present here. + // TODO(Phase 3): use zephyr/drivers/hwinfo.h hwinfo_get_reset_cause() + LOG_DEBUG("Reset reason: (nRF54L15 power peripheral differs from nRF52, skipped)"); + + // TODO(nrf54l15): init SAADC, watchdog, and random seed via nrfx or Zephyr + // For now seed with a fixed value; replace with hardware entropy source. +#if defined(NRF_FICR) + randomSeed(analogRead(0) ^ (uint32_t)NRF_FICR->DEVICEADDR[0]); +#else + randomSeed(analogRead(0)); +#endif + + // Pre-initialize BT stack here on the main thread (CONFIG_MAIN_STACK_SIZE=8192). + // bt_enable() overflows the smaller PowerFSMThread stack when called later. + // NRF54L15Bluetooth::setup() checks bt_initialized and skips bt_enable() if true. + nrf54l15_bt_preinit(); +} + +void nrf54l15Loop() +{ + // First-call gate for the future WDT init — body will hold real init code, not just the bookkeeping flag. + // cppcheck-suppress duplicateConditionalAssign + if (!watchdog_running) { + // TODO(nrf54l15): enable WDT here + watchdog_running = true; + } + watchdog_feed(); + + variant_nrf54l15LoopHook(); +} diff --git a/src/platform/nrf54l15/nrf54l15_arduino.cpp b/src/platform/nrf54l15/nrf54l15_arduino.cpp new file mode 100644 index 00000000000..43a3c11f83c --- /dev/null +++ b/src/platform/nrf54l15/nrf54l15_arduino.cpp @@ -0,0 +1,557 @@ +/** + * nrf54l15_arduino.cpp — Arduino shim implementations for Zephyr/nRF54L15 + * + * Provides concrete implementations for Print, HardwareSerial, GPIO, SPI, + * and String methods declared in Arduino.h / SPI.h. + * + * Phase 3: real GPIO via Zephyr GPIO API and real SPI via Zephyr SPI API. + * Pin numbering convention: P0.n = n, P1.n = 16+n, P2.n = 32+n. + */ + +#include "Arduino.h" +#include "SPI.h" +#include "Wire.h" +#include +#include +#include +#include +#include +#include +#include +// ── Bluefruit singleton stub (satisfies NodeDB.cpp ARCH_NRF52 path) ────────── +#include "bluefruit.h" +BlueFruitClass Bluefruit; + +// ── _fini stub — ARM newlib's __libc_fini_array references _fini, but ──────── +// Zephyr startup doesn't provide it. Provide a weak no-op so the linker +// is satisfied when C++ global dtors or atexit() pull in __libc_fini_array. +extern "C" void __attribute__((weak)) _fini(void) {} + +// ── SPI / Wire singletons ───────────────────────────────────────────────────── +SPIClass SPI; +SPIClass SPI1; +TwoWire Wire; +TwoWire Wire1; + +// ── HardwareSerial singletons ──────────────────────────────────────────────── +HardwareSerial Serial; +HardwareSerial Serial1; +HardwareSerial Serial2; + +// ── Timing functions — C linkage to match extern "C" declarations ──────────── +extern "C" uint32_t millis(void) +{ + return (uint32_t)k_uptime_get_32(); +} +extern "C" uint32_t micros(void) +{ + return (uint32_t)(k_uptime_get() * 1000ULL); +} +extern "C" void delay(uint32_t ms) +{ + k_sleep(K_MSEC(ms)); +} +extern "C" void delayMicroseconds(uint32_t us) +{ + k_sleep(K_USEC(us)); +} +extern "C" void yield(void) +{ + k_yield(); +} + +// ── NVIC_SystemReset — wraps __NVIC_SystemReset from CMSIS core_cm33.h ─────── +// core_cm33.h has #define NVIC_SystemReset __NVIC_SystemReset, so undef it +// before defining our own implementation to prevent macro expansion collision. +#pragma push_macro("NVIC_SystemReset") +#undef NVIC_SystemReset +extern "C" void NVIC_SystemReset(void) +{ + sys_reboot(SYS_REBOOT_COLD); +} +#pragma pop_macro("NVIC_SystemReset") + +// ── HardwareSerial::write ───────────────────────────────────────────────────── +size_t HardwareSerial::write(uint8_t c) +{ + // TODO(nrf54l15 Phase 3): route through Zephyr UART / USB-CDC console + // For now use printk so we at least get something over RTT/UART0 + printk("%c", (char)c); + return 1; +} + +size_t HardwareSerial::write(const uint8_t *buf, size_t n) +{ + for (size_t i = 0; i < n; i++) + printk("%c", (char)buf[i]); + return n; +} + +// ── Print::printf ───────────────────────────────────────────────────────────── +int Print::printf(const char *fmt, ...) +{ + char buf[256]; + va_list args; + va_start(args, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + if (n > 0) + write((const uint8_t *)buf, (size_t)(n < (int)sizeof(buf) ? n : (int)sizeof(buf) - 1)); + return n; +} + +// ── strlcpy — BSD extension not in Zephyr newlib ──────────────────────────── +extern "C" size_t strlcpy(char *dst, const char *src, size_t size) +{ + size_t len = strlen(src); + if (size > 0) { + size_t copy = len < size - 1 ? len : size - 1; + memcpy(dst, src, copy); + dst[copy] = '\0'; + } + return len; +} + +// ── Print numeric helpers ───────────────────────────────────────────────────── +static size_t printNumber(Print &p, unsigned long n, uint8_t base) +{ + if (base == 0) + return p.write((uint8_t)n); + + char buf[8 * sizeof(long) + 1]; + char *end = buf + sizeof(buf) - 1; + *end = '\0'; + if (n == 0) { + *--end = '0'; + } else { + while (n > 0) { + unsigned long remainder = n % base; + *--end = (char)(remainder < 10 ? '0' + remainder : 'A' + remainder - 10); + n /= base; + } + } + return p.write((const uint8_t *)end, strlen(end)); +} + +static size_t printFloat(Print &p, double number, uint8_t digits) +{ + if (isnan(number)) + return p.print("nan"); + if (isinf(number)) + return p.print("inf"); + if (number > 4294967040.0 || number < -4294967040.0) + return p.print("ovf"); + + size_t n = 0; + if (number < 0.0) { + n += p.write('-'); + number = -number; + } + + // Round + double rounding = 0.5; + for (uint8_t i = 0; i < digits; i++) + rounding /= 10.0; + number += rounding; + + unsigned long int_part = (unsigned long)number; + double remainder = number - (double)int_part; + n += printNumber(p, int_part, 10); + if (digits > 0) { + n += p.write('.'); + for (uint8_t i = 0; i < digits; i++) { + remainder *= 10.0; + unsigned int d = (unsigned int)remainder; + n += p.write('0' + d); + remainder -= d; + } + } + return n; +} + +size_t Print::print(unsigned char n, int base) +{ + return printNumber(*this, n, base); +} +size_t Print::print(int n, int base) +{ + if (base == 10 && n < 0) { + size_t r = write('-'); + return r + printNumber(*this, (unsigned long)(-n), base); + } + return printNumber(*this, (unsigned long)n, base); +} +size_t Print::print(long n, int base) +{ + if (base == 10 && n < 0) { + size_t r = write('-'); + return r + printNumber(*this, (unsigned long)(-n), base); + } + return printNumber(*this, (unsigned long)n, base); +} +size_t Print::print(unsigned int n, int base) +{ + return printNumber(*this, n, base); +} +size_t Print::print(unsigned long n, int base) +{ + return printNumber(*this, n, base); +} +size_t Print::print(float n, int d) +{ + return printFloat(*this, n, d); +} +size_t Print::print(double n, int d) +{ + return printFloat(*this, n, d); +} + +// ── String::replace(String, String) ───────────────────────────────────────── +void String::replace(const String &from, const String &to) +{ + if (from.isEmpty() || !_buf) + return; + // Simple O(n²) replace — fine for typical Meshtastic string lengths + String result; + const char *p = _buf; + while (*p) { + if (strncmp(p, from.c_str(), from.length()) == 0) { + result += to; + p += from.length(); + } else { + result += *p++; + } + } + *this = result; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// GPIO — Real Zephyr implementation (Phase 3) +// Pin mapping: P0.n = n (0-15), P1.n = 16+n (16-31), P2.n = 32+n (32-47) +// ═════════════════════════════════════════════════════════════════════════════ + +static const struct device *_gpio_dev_for_pin(uint32_t pin, gpio_pin_t *zpin) +{ + if (pin < 16) { + *zpin = (gpio_pin_t)pin; + return DEVICE_DT_GET(DT_NODELABEL(gpio0)); + } else if (pin < 32) { + *zpin = (gpio_pin_t)(pin - 16); + return DEVICE_DT_GET(DT_NODELABEL(gpio1)); + } else { + *zpin = (gpio_pin_t)(pin - 32); + return DEVICE_DT_GET(DT_NODELABEL(gpio2)); + } +} + +void pinMode(uint32_t pin, uint32_t mode) +{ + gpio_pin_t zpin; + const struct device *dev = _gpio_dev_for_pin(pin, &zpin); + if (!device_is_ready(dev)) + return; + + gpio_flags_t flags; + switch (mode) { + case OUTPUT: + flags = GPIO_OUTPUT_INACTIVE; + break; + case INPUT_PULLUP: + flags = GPIO_INPUT | GPIO_PULL_UP; + break; + case INPUT_PULLDOWN: + flags = GPIO_INPUT | GPIO_PULL_DOWN; + break; + default: + flags = GPIO_INPUT; + break; + } + gpio_pin_configure(dev, zpin, flags); +} + +// Bring-up diagnostics for the SX1262 wiring path. Off by default — enable by +// adding `-DNRF54L15_GPIO_DEBUG` to platformio.ini build_flags. Useful when +// validating CS/NRESET toggles after a wiring change, diagnosing a "stuck HIGH" +// BUSY before the first NRESET pulse, or tracing BUSY transitions during early +// boot. In normal operation these traces are noise (they bypass LOG level +// controls and print on every GPIO touch), so they are gated at compile time. +#ifdef NRF54L15_GPIO_DEBUG +#define GPIO_LOG_MAX 20 +static uint32_t _gpio_log_count = 0; +#endif + +void digitalWrite(uint32_t pin, uint32_t value) +{ +#ifdef NRF54L15_GPIO_DEBUG + // Before the very first NRESET pulse, snapshot BUSY state. + // If BUSY is already HIGH here, the chip never completed power-on calibration. + if (pin == 32 && value == 0) { + static bool _first_nreset = true; + if (_first_nreset) { + _first_nreset = false; + const struct device *bdev = DEVICE_DT_GET(DT_NODELABEL(gpio2)); + if (device_is_ready(bdev)) { + gpio_pin_configure(bdev, 3, GPIO_INPUT); // P2.03 = BUSY + int busy_before = gpio_pin_get(bdev, 3); + printk("[nrf54l15] BUSY before first NRESET = %d%s\n", busy_before, + busy_before ? " ← STUCK HIGH (chip damaged?)" : " ← LOW (chip OK)"); + } + } + } +#endif + + gpio_pin_t zpin; + const struct device *dev = _gpio_dev_for_pin(pin, &zpin); + if (!device_is_ready(dev)) { + // Genuine hardware/DTS misconfiguration — keep this regardless of the + // GPIO_DEBUG gate so it surfaces in production builds too. + printk("[GPIO] pin%u dev NOT READY\n", (unsigned)pin); + return; + } + gpio_pin_set(dev, zpin, (int)value); +#ifdef NRF54L15_GPIO_DEBUG + if ((pin == 37 || pin == 32) && _gpio_log_count < GPIO_LOG_MAX) { + // Read back the pin state to confirm it actually changed + int actual = gpio_pin_get(dev, zpin); + printk("[GPIO] pin%u → %u (read-back=%d)\n", (unsigned)pin, (unsigned)value, actual); + _gpio_log_count++; + } +#endif +} + +int digitalRead(uint32_t pin) +{ + gpio_pin_t zpin; + const struct device *dev = _gpio_dev_for_pin(pin, &zpin); + if (!device_is_ready(dev)) + return 0; + int v = gpio_pin_get(dev, zpin); +#ifdef NRF54L15_GPIO_DEBUG + // Log BUSY pin (35=P2.03) state changes + periodic updates for 10 seconds + if (pin == 35) { + static uint32_t busy_log_count = 0; + static int last_busy = -1; + static uint32_t first_read_ms = 0; + if (first_read_ms == 0) + first_read_ms = k_uptime_get_32(); + uint32_t elapsed_ms = k_uptime_get_32() - first_read_ms; + + // Always log state changes + if (v != last_busy) { + printk("[BUSY] %ums: state changed %d → %d\n", (unsigned)elapsed_ms, last_busy, v); + last_busy = v; + } + // Also log every 500ms for first 10 seconds so we can see timeline + if (elapsed_ms < 10000 && busy_log_count < 20 && (elapsed_ms / 500) > (busy_log_count)) { + printk("[BUSY] %ums: pin=%d (periodic)\n", (unsigned)elapsed_ms, v); + busy_log_count = (elapsed_ms / 500) + 1; + } + } +#endif + return v; +} + +// ─── attachInterrupt — supports up to NRF54L15_MAX_IRQS pins ──────────────── +#define NRF54L15_MAX_IRQS 8 + +struct _PinIrq { + struct gpio_callback cb; + voidFuncPtr user_cb; + const struct device *dev; + gpio_pin_t zpin; + bool used; +}; + +static _PinIrq _irq_table[NRF54L15_MAX_IRQS]; + +static void _gpio_irq_dispatch(const struct device *dev, struct gpio_callback *cb, uint32_t pins) +{ + _PinIrq *irq = CONTAINER_OF(cb, _PinIrq, cb); + if (irq->user_cb) + irq->user_cb(); +} + +void attachInterrupt(uint32_t pin, voidFuncPtr cb, int mode) +{ + gpio_pin_t zpin; + const struct device *dev = _gpio_dev_for_pin(pin, &zpin); + if (!device_is_ready(dev)) + return; + + // Find a free slot (or reuse existing registration for same pin) + _PinIrq *slot = nullptr; + for (int i = 0; i < NRF54L15_MAX_IRQS; i++) { + if (_irq_table[i].used && _irq_table[i].dev == dev && _irq_table[i].zpin == zpin) { + // Re-register: remove old callback first + gpio_remove_callback(dev, &_irq_table[i].cb); + slot = &_irq_table[i]; + break; + } + if (!slot && !_irq_table[i].used) + slot = &_irq_table[i]; + } + if (!slot) + return; // table full + + gpio_flags_t irq_flags; + switch (mode) { + case RISING: + irq_flags = GPIO_INT_EDGE_RISING; + break; + case FALLING: + irq_flags = GPIO_INT_EDGE_FALLING; + break; + default: + irq_flags = GPIO_INT_EDGE_BOTH; + break; + } + + slot->user_cb = cb; + slot->dev = dev; + slot->zpin = zpin; + slot->used = true; + + gpio_pin_configure(dev, zpin, GPIO_INPUT); + gpio_init_callback(&slot->cb, _gpio_irq_dispatch, BIT(zpin)); + gpio_add_callback(dev, &slot->cb); + gpio_pin_interrupt_configure(dev, zpin, irq_flags); +} + +void detachInterrupt(uint32_t pin) +{ + gpio_pin_t zpin; + const struct device *dev = _gpio_dev_for_pin(pin, &zpin); + for (int i = 0; i < NRF54L15_MAX_IRQS; i++) { + if (_irq_table[i].used && _irq_table[i].dev == dev && _irq_table[i].zpin == zpin) { + gpio_pin_interrupt_configure(dev, zpin, GPIO_INT_DISABLE); + gpio_remove_callback(dev, &_irq_table[i].cb); + _irq_table[i].used = false; + break; + } + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// SPI — Real Zephyr implementation using SPIM00 (HP domain, 3.0V) +// CS is handled by RadioLib via digitalWrite() — hardware CS not used. +// Mode 0 (CPOL=0, CPHA=0), MSB first. +// ═════════════════════════════════════════════════════════════════════════════ + +// Use SPIM00 (HP domain, 3.0V) — SPIM20 is 1.8V LP domain, incompatible with SX1262. +// Lazy-init: DEVICE_DT_GET in global scope fails when the extern symbol is +// not visible in this translation unit. Use a function-local static instead. +static const struct device *_spi00(void) +{ + static const struct device *dev = nullptr; + if (!dev) { + dev = DEVICE_DT_GET(DT_NODELABEL(spi00)); + if (!device_is_ready(dev)) { + printk("[nrf54l15] spi00 NOT READY\n"); + dev = nullptr; + } else { + printk("[nrf54l15] spi00 ready\n"); + } + } + return dev; +} + +// SPI config: Mode 0, MSB first, no hardware CS (RadioLib does it manually) +// +// SPIM00 base clock = 128 MHz (nRF54L15 default when NRF_CONFIG_CPU_FREQ_MHZ +// is not set; SystemInit() applies 128 MHz). Hardware prescaler must be EVEN +// and in [4, 126] (SPIM00_PRESCALER_DIVISOR_RANGE_MIN/MAX from MDK). +// 1 MHz → prescaler = 128 > 126 → NRFX_ERROR_INVALID_PARAM → -EIO on every +// transfer. Minimum valid frequency is 2 MHz (prescaler = 64). +static const struct spi_config _spi00_cfg = { + .frequency = 2000000U, // 2 MHz — minimum valid for SPIM00 at 128 MHz base + .operation = SPI_OP_MODE_MASTER | SPI_WORD_SET(8) | SPI_TRANSFER_MSB, + .slave = 0, + .cs = {}, // CS = NULL → RadioLib handles CS via GPIO +}; + +// Static DMA buffers — stack-allocated bufs on nRF54L15 may not be reachable +// by SPIM20 EasyDMA. Static placement in .bss/.data is always in Global SRAM. +// rx_byte is pre-filled with 0xAA before every transfer so we can distinguish: +// 0xAA → DMA never wrote (EasyDMA can't reach the buffer) +// 0x00 → MISO actively driven LOW (chip in reset / bus fight) +// 0xFF → MISO floating HIGH +// other → real chip response +static uint8_t _spi_tx_byte __attribute__((aligned(4))); +static uint8_t _spi_rx_byte __attribute__((aligned(4))); + +// Dump the first SPI_DUMP_N byte exchanges so we can see what MISO returns. +#define SPI_DUMP_N 30 +static uint32_t _spi_dump_count = 0; + +uint8_t SPIClass::transfer(uint8_t data) +{ + const struct device *dev = _spi00(); + if (!dev) + return 0xFF; + + _spi_tx_byte = data; + _spi_rx_byte = 0xAA; // sentinel: if DMA doesn't write, we return 0xAA + + struct spi_buf tx_buf = {.buf = &_spi_tx_byte, .len = 1}; + struct spi_buf rx_buf = {.buf = &_spi_rx_byte, .len = 1}; + struct spi_buf_set tx_set = {.buffers = &tx_buf, .count = 1}; + struct spi_buf_set rx_set = {.buffers = &rx_buf, .count = 1}; + + static uint32_t spi_err_count = 0; + int ret = spi_transceive(dev, &_spi00_cfg, &tx_set, &rx_set); + if (ret != 0 && spi_err_count++ < 3) + printk("[SPI] err=%d tx=0x%02x\n", ret, data); + + if (_spi_dump_count < SPI_DUMP_N) { + printk("[SPI] #%u tx=0x%02x rx=0x%02x\n", (unsigned)_spi_dump_count, data, _spi_rx_byte); + _spi_dump_count++; + } + + return _spi_rx_byte; +} + +// Static DMA-safe buffers for transfer16 — same EasyDMA reachability concern +// applies as for the byte path: stack buffers from a caller thread may sit in +// per-thread RAM regions that EasyDMA cannot reach. +static uint8_t _spi_tx16[2] __attribute__((aligned(4))); +static uint8_t _spi_rx16[2] __attribute__((aligned(4))); + +uint16_t SPIClass::transfer16(uint16_t data) +{ + const struct device *dev = _spi00(); + if (!dev) + return 0xFFFF; + + _spi_tx16[0] = (uint8_t)(data >> 8); + _spi_tx16[1] = (uint8_t)(data & 0xFF); + _spi_rx16[0] = 0xAA; + _spi_rx16[1] = 0xAA; + struct spi_buf tx_buf = {.buf = _spi_tx16, .len = 2}; + struct spi_buf rx_buf = {.buf = _spi_rx16, .len = 2}; + struct spi_buf_set tx_set = {.buffers = &tx_buf, .count = 1}; + struct spi_buf_set rx_set = {.buffers = &rx_buf, .count = 1}; + spi_transceive(dev, &_spi00_cfg, &tx_set, &rx_set); + return ((uint16_t)_spi_rx16[0] << 8) | _spi_rx16[1]; +} + +void SPIClass::transferBytes(const uint8_t *tx, uint8_t *rx, uint32_t count) +{ + if (!count) + return; + const struct device *dev = _spi00(); + if (!dev) + return; + // Zephyr requires non-const buf pointer; cast is safe for tx-only direction + struct spi_buf tx_buf = {.buf = const_cast(tx), .len = count}; + struct spi_buf rx_buf = {.buf = rx, .len = count}; + struct spi_buf_set tx_set = {.buffers = &tx_buf, .count = 1}; + struct spi_buf_set rx_set = {.buffers = rx_buf.buf ? &rx_buf : nullptr, .count = rx_buf.buf ? 1U : 0U}; + spi_transceive(dev, &_spi00_cfg, &tx_set, rx ? &rx_set : nullptr); +} + +void SPIClass::transfer(void *buf, size_t count) +{ + if (!count || !buf) + return; + transferBytes(reinterpret_cast(buf), reinterpret_cast(buf), (uint32_t)count); +} diff --git a/src/platform/nrf54l15/nrf54l15_main.cpp b/src/platform/nrf54l15/nrf54l15_main.cpp new file mode 100644 index 00000000000..4b633f0dfe7 --- /dev/null +++ b/src/platform/nrf54l15/nrf54l15_main.cpp @@ -0,0 +1,121 @@ +/* + * nrf54l15_main.cpp — Zephyr entry point for Meshtastic nRF54L15 port + * + * Zephyr calls main() instead of Arduino's setup()/loop(). + * This file provides the main() that bootstraps the Arduino-style + * Meshtastic application loop. + */ + +#include +#include +#include +#include + +// Forward declarations from src/main.cpp +void setup(); +void loop(); + +// ── Crash info saved to noinit RAM (survives soft reset) ───────────────────── +// Zephyr's arch_esf does not expose the faulting SP directly; we capture PSP +// at entry to the fatal handler (the exception-basic frame lives there) and +// store xPSR alongside PC/LR for context. +struct crash_info { + uint32_t magic; + uint32_t reason; + uint32_t pc; + uint32_t psp; // stack pointer captured at fault entry + uint32_t xpsr; // saved program status (flags + exception number) + uint32_t lr; + uint32_t cfsr; // Configurable Fault Status Register +}; +static struct crash_info saved_crash __attribute__((section(".noinit"))); +#define CRASH_MAGIC 0xDEADBEEF + +// Override Zephyr's weak fatal handler to save crash info, then cold-reboot so +// main() can report the saved record on the next boot. We don't rely on +// CONFIG_RESET_ON_FATAL_ERROR (default off → k_fatal_halt would spin forever) +// — we issue sys_reboot() ourselves after flushing logs. +extern "C" void k_sys_fatal_error_handler(unsigned int reason, const struct arch_esf *esf) +{ + saved_crash.magic = CRASH_MAGIC; + saved_crash.reason = reason; + // Capture the faulting thread's stack pointer before we start using the + // handler's own stack for logging. + uint32_t psp_at_entry; + __asm__ volatile("mrs %0, psp" : "=r"(psp_at_entry)); + saved_crash.psp = psp_at_entry; + if (esf) { + saved_crash.pc = esf->basic.pc; + saved_crash.xpsr = esf->basic.xpsr; + saved_crash.lr = esf->basic.lr; + } + // Read Cortex-M33 SCB CFSR + saved_crash.cfsr = *((volatile uint32_t *)0xE000ED28U); + printk("[nrf54l15] FATAL reason=%u pc=0x%08x lr=0x%08x cfsr=0x%08x\n", reason, saved_crash.pc, saved_crash.lr, + saved_crash.cfsr); + + // Walk the failing thread's stack and print any word that looks like a + // Thumb code address (0x1000 — flash end, with the Thumb-mode low bit set). + // The Cortex-M exception frame at PSP holds r0,r1,r2,r3,r12,lr,pc,xpsr + // (8 words); deeper words are the caller's saved frame, which gives a + // crude but useful poor-man's backtrace when CONFIG_DEBUG_COREDUMP is off. + // Found the BLE-init bad_alloc → abort() chain (heap exhaustion under + // CONFIG_BT_BUF_ACL_RX_SIZE=251) when the fault dump alone showed only + // abort itself. Cheap (~150 B of code) and silent until a fault. + uint32_t psp; + __asm__ volatile("mrs %0, psp" : "=r"(psp)); + printk("[nrf54l15] PSP=0x%08x — stack walk:\n", psp); + // Validate PSP before dereferencing. Real faults frequently leave PSP + // pointing at corrupted/unmapped memory, and walking it blindly triggers a + // second fault inside this handler. Restrict to nRF54L15 SRAM (256 KB at + // 0x20000000) with 4-byte alignment, and clamp the walk so we never read + // past the end of RAM. + const uintptr_t SRAM_START = 0x20000000UL; + const uintptr_t SRAM_END = 0x20040000UL; + if (psp < SRAM_START || psp >= SRAM_END || (psp & 0x3U) != 0) { + printk("[nrf54l15] PSP out of SRAM range or unaligned, skipping walk\n"); + } else { + const uint32_t *sp = (const uint32_t *)psp; + int max_words = (int)((SRAM_END - psp) / sizeof(uint32_t)); + if (max_words > 96) + max_words = 96; + for (int i = 0; i < max_words; i++) { + uint32_t v = sp[i]; + if (v >= 0x00001000 && v < 0x00080000 && (v & 1)) { + printk("[nrf54l15] sp[%d]=0x%08x (code)\n", i, v); + } + } + } + + // Give the RTT/printk backend a chance to drain before we reset, otherwise + // the crash log line above is lost and the next boot's "Prev crash" line is + // the only forensic evidence we get. + k_busy_wait(50000); // 50 ms + sys_reboot(SYS_REBOOT_COLD); + // Unreachable; k_fatal_halt as a defensive backstop in case sys_reboot + // returns (it shouldn't). + k_fatal_halt(reason); +} + +int main(void) +{ + uint32_t reset_cause = 0; + hwinfo_get_reset_cause(&reset_cause); + hwinfo_clear_reset_cause(); + printk("[nrf54l15] Reset cause: 0x%08x\n", reset_cause); + + if (saved_crash.magic == CRASH_MAGIC) { + printk("[nrf54l15] Prev crash: reason=%u pc=0x%08x lr=0x%08x psp=0x%08x xpsr=0x%08x cfsr=0x%08x\n", saved_crash.reason, + saved_crash.pc, saved_crash.lr, saved_crash.psp, saved_crash.xpsr, saved_crash.cfsr); + saved_crash.magic = 0; + } + + printk("[nrf54l15] A: main() entry\n"); + printk("[nrf54l15] B: calling setup()\n"); + setup(); + printk("[nrf54l15] C: setup() returned\n"); + while (true) { + loop(); + } + return 0; +} diff --git a/src/platform/nrf54l15/utility/bonding.h b/src/platform/nrf54l15/utility/bonding.h new file mode 100644 index 00000000000..c55625bb678 --- /dev/null +++ b/src/platform/nrf54l15/utility/bonding.h @@ -0,0 +1,11 @@ +// utility/bonding.h — stub for nRF54L15/Zephyr +// NodeDB.cpp includes this when ARCH_NRF52 is defined. +// Bluetooth is excluded; this stub satisfies the include chain. +#pragma once + +// BLE role constants (from Bluefruit SDK) +#define BLE_GAP_ROLE_PERIPH 0x01 +#define BLE_GAP_ROLE_CENTRAL 0x02 + +// Stub for bond_print_list() +static inline void bond_print_list(uint8_t) {} diff --git a/userPrefs.jsonc b/userPrefs.jsonc index a8201bab3f4..57ede8bbf91 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -34,6 +34,8 @@ // "USERPREFS_CONFIG_GPS_UPDATE_INTERVAL": "600", // "USERPREFS_CONFIG_POSITION_BROADCAST_INTERVAL": "1800", // "USERPREFS_CONFIG_DEVICE_TELEM_UPDATE_INTERVAL": "900", // Device telemetry update interval in seconds + // "USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL": "900", // Environment telemetry update interval in seconds + // "USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED": "1", // Force BMP280/sensor reads + LoRa broadcast on first boot // "USERPREFS_LORACONFIG_CHANNEL_NUM": "31", // "USERPREFS_LORACONFIG_MODEM_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST", // "USERPREFS_USE_ADMIN_KEY_0": "{ 0xcd, 0xc0, 0xb4, 0x3c, 0x53, 0x24, 0xdf, 0x13, 0xca, 0x5a, 0xa6, 0x0c, 0x0d, 0xec, 0x85, 0x5a, 0x4c, 0xf6, 0x1a, 0x96, 0x04, 0x1a, 0x3e, 0xfc, 0xbb, 0x8e, 0x33, 0x71, 0xe5, 0xfc, 0xff, 0x3c }", diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index cd5a8d59360..56e6e834000 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -16,7 +16,7 @@ extra_scripts = extra_scripts/esp32_extra.py build_src_filter = - ${arduino_base.build_src_filter} - - - - - + ${arduino_base.build_src_filter} - - - - - - upload_speed = 921600 debug_init_break = tbreak setup diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 9ab45d1ab66..972a9f3bff0 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -9,8 +9,9 @@ build_src_filter = ${env.build_src_filter} - - - - - - + - + - + - - - - diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index d11f4fc565f..c115da60588 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -40,7 +40,7 @@ build_unflags = -std=gnu++11 build_src_filter = - ${arduino_base.build_src_filter} - - - - - - - - - - - + ${arduino_base.build_src_filter} - - - - - - - - - - - - lib_deps= ${arduino_base.lib_deps} diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini new file mode 100644 index 00000000000..4e6fa3f0100 --- /dev/null +++ b/variants/nrf54l15/nrf54l15.ini @@ -0,0 +1,74 @@ +[nrf54l15_base] +platform = https://github.com/Seeed-Studio/platform-seeedboards.git +framework = zephyr +extends = arduino_base + +build_type = release +build_flags = + ${arduino_base.build_flags} + -Isrc/platform/nrf54l15 + -DMESHTASTIC_EXCLUDE_AUDIO=1 + -DMESHTASTIC_EXCLUDE_GPS=1 + -DMESHTASTIC_EXCLUDE_MQTT=1 + -DHAS_WIRE=1 + -DHAS_SENSOR=1 + -DHAS_BUTTON=0 + -DHAS_TELEMETRY=1 + -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 + -DARDUINO=100 + -DMESHTASTIC_EXCLUDE_ACCELEROMETER=1 + -DMAX_NUM_NODES=40 + -fpermissive + # Libraries that Zephyr LDF misses; add include paths explicitly + -I.pio/libdeps/${PIOENV}/Crypto + -I.pio/libdeps/${PIOENV}/ArduinoThread + -I".pio/libdeps/${PIOENV}/ESP8266 and ESP32 OLED driver for SSD1306 displays/src" + -I.pio/libdeps/${PIOENV}/OneButton/src + -I.pio/libdeps/${PIOENV}/arduino-fsm + -I.pio/libdeps/${PIOENV}/TinyGPSPlus/src + -I.pio/libdeps/${PIOENV}/ErriezCRC32/src + -I.pio/libdeps/${PIOENV}/NonBlockingRTTTL/src + -I.pio/libdeps/${PIOENV}/RadioLib/src + +build_src_filter = + ${arduino_base.build_src_filter} + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + +lib_compat_mode = off + +lib_deps = + ${arduino_base.lib_deps} + ${radiolib_base.lib_deps} + rweather/Crypto@0.4.0 + ; Cherry-picked sensor libs from environmental_base. The full + ; environmental_base pulls Adafruit_SSD1306 / GFX which need Arduino + ; pin macros (digitalPinToPort / portOutputRegister) that the Zephyr + ; Arduino shim does not implement. + https://github.com/adafruit/Adafruit_BusIO/archive/refs/tags/1.17.4.zip + https://github.com/adafruit/Adafruit_Sensor/archive/refs/tags/1.1.15.zip + https://github.com/adafruit/Adafruit_BMP280_Library/archive/refs/tags/3.0.0.zip + https://github.com/adafruit/Adafruit_BME280_Library/archive/refs/tags/2.3.0.zip + https://github.com/adafruit/Adafruit_INA260/archive/refs/tags/1.5.3.zip + https://github.com/adafruit/Adafruit_INA219/archive/refs/tags/1.2.3.zip + https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip + https://github.com/RobTillaart/INA226/archive/refs/tags/0.6.6.zip + https://github.com/adafruit/Adafruit_SHT4X/archive/refs/tags/1.0.5.zip + +lib_ignore = + BluetoothOTA + lvgl + Adafruit_nRFCrypto diff --git a/variants/nrf54l15/nrf54l15dk/README.md b/variants/nrf54l15/nrf54l15dk/README.md new file mode 100644 index 00000000000..bede3a04a89 --- /dev/null +++ b/variants/nrf54l15/nrf54l15dk/README.md @@ -0,0 +1,108 @@ +# nRF54L15-DK — EBYTE E22-900M30S Wiring Guide + +Board: **Nordic nRF54L15-DK (PCA10156)** +Radio: **EBYTE E22-900M30S** (SX1262, 30 dBm, 868/915 MHz) + +--- + +## Why P2 (HP domain) and not P1 + +The nRF54L15 splits its GPIOs across three supply domains: + +- **P0** — Main domain, **3.0 V** — usable +- **P1** — LP domain, **1.8 V** — **not compatible** with the SX1262 +- **P2** — HP domain, **3.0 V** — usable + +The SX1262 requires VIH ≥ 0.7 × VDD (≈ 2.31 V at VDD = 3.3 V). P1's 1.8 V output +leaves the chip stuck in reset with `BUSY` never going LOW. All E22 signals +therefore live on **P2** and are driven by **SPIM00**. + +> `P2.09` is normally wired to LED0 on the DK; we ignore the LED and use +> SPIM00's default MISO pin. The on-board MX25R64 NOR flash also sat on SPIM00 +> — it is deleted in the device-tree overlay to free the bus. + +--- + +## Connections — J2 header, P2 bank + +| E22-900M30S | GPIO | DK pin | Function | +| ----------- | ----- | ------ | ---------------------------------------------- | +| MISO | P2.04 | 36 | SPIM00 data in | +| NSS / CS | P2.05 | 37 | SPI chip-select (driven by RadioLib as a GPIO) | +| DIO1 | P2.06 | 38 | IRQ — modem interrupt (routed via gpiote30) | +| BUSY | P2.03 | 35 | Module busy (GPIO input) | +| NRESET | P2.00 | 32 | Module reset (GPIO output, active LOW) | +| RXEN | P2.07 | 39 | LNA enable — held HIGH via `SX126X_ANT_SW` | +| MOSI | P2.02 | 34 | SPIM00 data out | +| SCK | P2.01 | 33 | SPIM00 clock | +| GND | — | GND | Common ground | +| VCC | — | VDD | 3.3 V | + +> **Numbering convention**: `P0.n = n`, `P1.n = 16+n`, `P2.n = 32+n`. +> Example: `P2.04` → 32 + 4 = **36**. + +--- + +## DIO2 → TXEN bridge (required) + +The E22-900M30S does **not** connect DIO2 to TXEN internally. A physical bridge on the module is required: + +1. Locate the `DIO2` and `TXEN` pads on the underside of the E22 module. +2. Solder a wire bridge or a 0 Ω resistor between the two pads. +3. With this bridge, the SX1262 drives the PA automatically via `SX126X_DIO2_AS_RF_SWITCH`. + +Without this bridge the module **will not transmit** (PA is never enabled). + +--- + +## RXEN — LNA always on + +`RXEN` (P2.07) is held HIGH permanently via `SX126X_ANT_SW 39` in `variant.h`. +**Do not use** `SX126X_RXEN` — RadioLib would drive it LOW in IDLE state and +the LNA would stay disabled (radio deaf in RX). + +--- + +## Reserved DK pins — do not reuse + +| Pins | Reserved function | +| ----------- | -------------------------------------------------------- | +| P0.00–P0.03 | IMCU debug UART (uart30, J-Link VCOM — used by RTT host) | +| P0.04 | BTN3 | +| P1.00–P1.01 | 32 kHz crystal | +| P1.02–P1.03 | NFC antenna | +| P1.10 | LED1 (status LED — kept) | +| P1.13 | BTN0 (only remaining user button) | +| P1.14 | LED3 | +| P2.01–P2.05 | SPIM00 / E22 (see connection table above) | +| P2.08–P2.10 | Trace ETM / LED2 (avoid) | + +--- + +## Build and flash + +```bash +# Build +pio run -e nrf54l15dk + +# Flash (requires a J-Link connected via the DK's on-board IMCU) +pio run -e nrf54l15dk -t upload + +# Monitor RTT (channel 1 = Meshtastic logs) +JLinkRTTLogger -device nRF54L15_xxAA -if SWD -speed 4000 -RTTChannel 1 boot.log +``` + +Expected boot log: + +```text +*** Booting Zephyr OS build zephyr-v40201 *** +[nrf54l15] Reset cause: ... +[nrf54l15] B: calling setup() +INFO | ... SX1262 +INFO | ... lora.begin() = 0 ← RADIOLIB_ERR_NONE +[nrf54l15] C: setup() returned +``` + +If you see `Record critical error 3` (`NO_RADIO`), check: DIO2→TXEN bridge, +supply voltages (the E22 must see 3.0–3.3 V on P2, not 1.8 V), and SPI wiring +continuity. diff --git a/variants/nrf54l15/nrf54l15dk/platformio.ini b/variants/nrf54l15/nrf54l15dk/platformio.ini new file mode 100644 index 00000000000..71bbe252b81 --- /dev/null +++ b/variants/nrf54l15/nrf54l15dk/platformio.ini @@ -0,0 +1,24 @@ +[env:nrf54l15dk] +# PRIVATE_HW (255) — the protobuf HardwareModel enum reserves DK / DIY boards +# without an SKU under PRIVATE_HW; no dedicated enum value will be assigned. +custom_meshtastic_hw_model = 255 +custom_meshtastic_hw_model_slug = NRF54L15_DK +custom_meshtastic_architecture = nrf54l15 +custom_meshtastic_actively_supported = false +custom_meshtastic_support_level = 3 +custom_meshtastic_display_name = Nordic nRF54L15-DK + +extends = nrf54l15_base +board = nrf54l15dk +board_level = extra +debug_tool = jlink +upload_protocol = jlink +board_runner_args_jlink = --device nRF54L15_xxAA --speed 4000 + +build_flags = ${nrf54l15_base.build_flags} + -Ivariants/nrf54l15/nrf54l15dk + -DNRF54L15_DK + -DMESHTASTIC_EXCLUDE_FILES_MANIFEST=1 + +build_src_filter = ${nrf54l15_base.build_src_filter} + +<../variants/nrf54l15/nrf54l15dk> diff --git a/variants/nrf54l15/nrf54l15dk/variant.cpp b/variants/nrf54l15/nrf54l15dk/variant.cpp new file mode 100644 index 00000000000..b2982e7c555 --- /dev/null +++ b/variants/nrf54l15/nrf54l15dk/variant.cpp @@ -0,0 +1,9 @@ +#include "variant.h" + +void initVariant() +{ + // Minimal board init for nRF54L15-DK. + // GPIO/SPI peripheral setup is handled by the Zephyr device tree overlay + // (zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay). + // Add any board-level power sequencing here if needed. +} diff --git a/variants/nrf54l15/nrf54l15dk/variant.h b/variants/nrf54l15/nrf54l15dk/variant.h new file mode 100644 index 00000000000..b837ed7cf65 --- /dev/null +++ b/variants/nrf54l15/nrf54l15dk/variant.h @@ -0,0 +1,86 @@ +#pragma once + +/* + * Nordic nRF54L15-DK (PCA10156) — Meshtastic variant + * + * ── GPIO voltage domains ───────────────────────────────────────────────────── + * P0 (gpio0 @ 0x10A000) Main domain 3.0 V ← usable + * P1 (gpio1 @ 0xd8200 ) LP domain 1.8 V ← NOT compatible with E22 + * P2 (gpio2 @ 0x50400 ) HP domain 3.0 V ← usable + * + * The SX1262 needs VIH ≥ 0.7 × VDD = 2.31 V (VDD = 3.3 V). + * P1 outputs only 1.8 V → chip stays in reset, BUSY never goes LOW. + * All E22 signals are therefore on P2 (3.0 V), driven by SPIM00. + * + * EBYTE E22-900M30S (SX1262) wiring — J2 header, all P2: + * + * E22 pin GPIO pin# Notes + * ───────────────────────────────────────────────────────────────────── + * MISO → P2.04 36 SPIM00 data in + * NSS/CS → P2.05 37 SPI chip-select (RadioLib GPIO) + * DIO1 → P2.06 38 IRQ — interrupt via gpiote30 + * BUSY → P2.03 35 GPIO input + * NRESET → P2.00 32 GPIO output + * RXEN → P2.07 39 Held HIGH via ANT_SW (LNA always active) + * MOSI → P2.02 34 SPIM00 data out + * SCK → P2.01 33 SPIM00 clock + * + * DIO2 → TXEN bridge required on E22 module (solder bridge / wire). + * DIO3 drives TCXO reference (1.8 V). + * + * Pin numbering convention: P0.n = n, P1.n = 16+n, P2.n = 32+n. + * + * Reserved / do-not-use DK pins: + * P0.00-P0.02 IMCU VCOM TX/RX/RTS pads (uart30 disabled; pads idle) + * P0.03 I2C SDA (TWIM30) — sensor bus + * P0.04 I2C SCL (TWIM30) — sensor bus; SW3 button on this pad, + * DO NOT press SW3 while I2C is active + * P1.00-P1.01 32 kHz crystal + * P1.02-P1.03 NFC antenna + * P1.10 LED1 (status LED — keep) + * P1.13 BTN0 — main user button + * P1.14 LED3 + * P2.01-P2.05 SPIM00 / E22 (see above) + * P2.08-P2.10 Trace pins (avoid) + */ + +#ifndef NRF54L15_DK +#define NRF54L15_DK +#endif + +// ── SX1262 / E22-900M30S — all P2, HP domain (3.0 V) ──────────────────────── +#define USE_SX1262 +#define SX126X_CS 37 // P2.05 — chip-select +#define SX126X_DIO1 38 // P2.06 — IRQ (gpiote30 capable) +#define SX126X_BUSY 35 // P2.03 — BUSY +#define SX126X_RESET 32 // P2.00 — NRESET + +// RXEN (P2.07) held HIGH permanently — LNA always active. +// RadioLib must NOT toggle it; ANT_SW drives it HIGH before lora.begin(). +#define SX126X_ANT_SW 39 // P2.07 — RXEN driven HIGH at init + +// DIO2 controls TXEN via bridge on E22 module. +// DIO3 provides 1.8 V TCXO reference. +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8f + +// ── LEDs (active HIGH) ─────────────────────────────────────────────────────── +#define PIN_LED1 26 // P1.10 — LED1 (status LED, LP domain — output only, OK) +#define PIN_LED2 41 // P2.09 — LED0 on DK (remapped; P2.07 now used for RXEN) +#define LED_STATE_ON 1 + +// ── Buttons (active LOW, internal pull-up) ─────────────────────────────────── +// BTN1 (P1.09), BTN2 (P1.08) and BTN3 (P0.04) deleted from DTS — only BTN0 +// remains. BTN3's pad (P0.04) is now I2C SCL. +#define PIN_BUTTON1 29 // P1.13 — BTN0 +#define BUTTON_NEED_PULLUP + +// ── I2C bus (TWIM30, HP domain, 3.0 V) ────────────────────────────────────── +// SDA=P0.03, SCL=P0.04. Pinctrl + clock-frequency live in the board overlay. +// External 4.7 kΩ pull-ups required on both lines. Meshtastic's Arduino +// TwoWire layer (src/platform/nrf54l15/Wire.cpp) resolves the device at +// compile time via DT_NODELABEL(i2c30); these PIN_WIRE_* defines are kept +// for parity with the Arduino convention used by other variants. +#define PIN_WIRE_SDA 3 // P0.03 +#define PIN_WIRE_SCL 4 // P0.04 +#define WIRE_INTERFACES_COUNT 1 diff --git a/variants/rp2040/rp2040.ini b/variants/rp2040/rp2040.ini index 090f5d19a65..fb29153ea80 100644 --- a/variants/rp2040/rp2040.ini +++ b/variants/rp2040/rp2040.ini @@ -20,7 +20,7 @@ build_flags = -D__FREERTOS=1 # -D _POSIX_THREADS build_src_filter = - ${arduino_base.build_src_filter} - - - - - - - - - + ${arduino_base.build_src_filter} - - - - - - - - - - lib_ignore = BluetoothOTA diff --git a/variants/rp2350/rp2350.ini b/variants/rp2350/rp2350.ini index 934875c6ada..964545311f0 100644 --- a/variants/rp2350/rp2350.ini +++ b/variants/rp2350/rp2350.ini @@ -16,8 +16,8 @@ build_flags = -Isrc/platform/rp2xx0 -D__PLAT_RP2350__ -D__FREERTOS=1 -build_src_filter = - ${arduino_base.build_src_filter} - - - - - - - - - - - +build_src_filter = + ${arduino_base.build_src_filter} - - - - - - - - - - - - lib_ignore = BluetoothOTA diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 1efe18e3d43..051a2552420 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -44,7 +44,7 @@ build_flags = -Wl,--wrap=_tzset_unlocked_r build_src_filter = - ${arduino_base.build_src_filter} - - - - - - - - - - - - - - + ${arduino_base.build_src_filter} - - - - - - - - - - - - - - - board_upload.offset_address = 0x08000000 upload_protocol = stlink diff --git a/zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay b/zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay new file mode 100644 index 00000000000..4c861312c13 --- /dev/null +++ b/zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay @@ -0,0 +1,117 @@ +#include + +/* + * Zephyr device tree overlay — Nordic nRF54L15-DK + EBYTE E22-900M30S (SX1262) + * + * P1 GPIO bank runs at 1.8V VDDIO (LP domain) — NOT compatible with SX1262 + * which needs VIH ≥ 2.31V (0.7 × 3.3V). All E22 signals therefore use P2, + * which is in the HP domain (3.0V VDDIO), via SPIM00. + * + * SPIM00 (HP domain, 3.0V) replaces SPIM20 (LP domain, 1.8V). + * Default pinctrl from cpuapp_common.dtsi already maps SPIM00 to P2 pins: + * MISO = P2.04, MOSI = P2.02, SCK = P2.01 + * + * The on-board MX25R64 NOR flash was attached to SPIM00; it is not used by + * Meshtastic (LittleFS lives in internal RRAM), so we delete its DTS node to + * free the bus and P2.05 (its CS pin) for our use. + * + * Physical wiring (all P2, HP domain): + * E22 MISO → P2.04 (SPIM00 MISO) + * E22 NSS → P2.05 (freed from MX25R64 CS, RadioLib GPIO) + * E22 DIO1 → P2.06 (IRQ — interrupt capable via gpiote30) + * E22 BUSY → P2.03 (GPIO input) + * E22 RST → P2.00 (GPIO output) + * E22 RXEN → P2.07 (held HIGH via ANT_SW; replaces LED2 on DK) + * E22 MOSI → P2.02 (SPIM00 MOSI) + * E22 SCK → P2.01 (SPIM00 SCK) + * + * Pin numbering convention: P0.n = n, P1.n = 16+n, P2.n = 32+n. + */ + +/* Disable uart20 — no longer needed for LoRa (P1 domain abandoned). */ +&uart20 { + status = "disabled"; +}; + +/* Disable uart30 to free P0.00–P0.03 from the uart30_default pinctrl (which + * also reserves P0.03 as UART30 CTS). The peripheral instance 30 is shared + * with i2c30/spi30 — only one of {UARTE30, TWIM30, SPIM30} can be enabled at + * a time. We pick TWIM30 (i2c30) for sensors (SHT40 / INA3221 / SE050 on the + * custom PCB; BMP280 / INA228 on the DK for bring-up). Console stays on RTT + * via CONFIG_RTT_CONSOLE=y. */ +&uart30 { + status = "disabled"; +}; + +/* I2C bus via TWIM30 (HP domain, 3.0 V), SDA=P0.03 / SCL=P0.04. + * P0.03 was the UART30 CTS; freed by disabling uart30 above. + * P0.04 was button SW3 on the DK; deleted below. The pad still routes to + * the SW3 button on the board — DO NOT press SW3 during I2C use, it will + * short SCL to GND mid-transaction. + * 400 kHz is the highest standard rate supported by all three target sensors + * (BMP280, INA228, SE050). External 4.7 kΩ pull-ups required on both lines. */ +&pinctrl { + i2c30_default: i2c30_default { + group1 { + psels = , + ; + bias-pull-up; + }; + }; + + i2c30_sleep: i2c30_sleep { + group1 { + psels = , + ; + low-power-enable; + }; + }; +}; + +&i2c30 { + status = "okay"; + clock-frequency = ; /* 400 kHz */ + pinctrl-0 = <&i2c30_default>; + pinctrl-1 = <&i2c30_sleep>; + pinctrl-names = "default", "sleep"; +}; + +/ { + buttons { + /* button1 (P1.09) and button2 (P1.08) no longer repurposed — + * E22 now uses P2. button3 (P0.04) is now I2C SCL — its node + * must be removed before the i2c30 pinctrl can claim the pad. */ + /delete-node/ button_1; + /delete-node/ button_2; + /delete-node/ button_3; + }; + + aliases { + /delete-property/ sw1; + /delete-property/ sw2; + /delete-property/ sw3; + }; +}; + +/* + * Override SPIM00 to remove the MX25R64 NOR flash child. + * The SPIM00 peripheral itself stays enabled (status = "okay" from + * cpuapp_common.dtsi) with its existing pinctrl (P2.01/P2.02/P2.04). + * RadioLib drives CS (P2.05) as a plain GPIO — no hardware CS needed. + */ +&spi00 { + /delete-node/ mx25r6435f@0; +}; + +/* + * Grow storage_partition from 36 KB to 700 KB by reclaiming slot1_partition. + * slot1 (image-1) is the MCUboot secondary slot for dual-bank OTA, which we + * don't use (flashing is direct via J-Link). With only 9 blocks (36 KB / 4 KB) + * LittleFS ran out of space during COW writes of config.proto. + * New layout: storage_partition spans 0xb6000..0x165000 (700 KB, ~175 blocks). + */ +/delete-node/ &slot1_partition; + +&storage_partition { + reg = <0xb6000 0xaf000>; +}; diff --git a/zephyr/prj.conf b/zephyr/prj.conf new file mode 100644 index 00000000000..0723d0cd67c --- /dev/null +++ b/zephyr/prj.conf @@ -0,0 +1,299 @@ +# Zephyr project configuration for nRF54L15 Meshtastic port +# +# NOTE: this prj.conf is shared by ALL Zephyr PlatformIO environments +# in this project. Keep it compatible with any future Zephyr targets. + +# ── C++ support (required by Meshtastic) ────────────────────────────────────── +CONFIG_CPP=y +CONFIG_STD_CPP17=y +# Full libstdc++ — provides , , , , etc. +# Works with either newlib or picolibc (Zephyr auto-selects based on board). +CONFIG_REQUIRES_FULL_LIBCPP=y +# Disable C++ exceptions — not needed by Meshtastic and saves RAM/ROM +CONFIG_CPP_EXCEPTIONS=n + +# ── Peripheral subsystems ───────────────────────────────────────────────────── +CONFIG_SPI=y +CONFIG_I2C=y +CONFIG_GPIO=y + +# sys_reboot() used by BLE zombie-connection watchdog (BleDeferredThread). +# nRF54L15 SW-LL occasionally drops the BLE link without forwarding +# LE Disconnection Complete to the host; cold reboot is the only reliable +# recovery path. +CONFIG_REBOOT=y + +# ── ATT Prepare/Execute Write (LONG WRITE) ─────────────────────────────────── +# iOS CoreBluetooth automatically fragments writes >MTU-3 via ATT Prepare Write +# (opcode 0x16). Default CONFIG_BT_ATT_PREPARE_COUNT=0 makes Zephyr reject with +# "Request Not Supported" (0x06), which iOS surfaces as a write error → +# disconnect. With MTU=65 any ToRadio write >62 bytes triggers this path. +# Enabling this allocates N prep_pool buffers (each BT_ATT_BUF_SIZE = 65 bytes) +# and reassembles fragments into a single write_toradio() call on execute. +# 4 × 65 = 260 B max assembled value — enough for typical iOS NodeInfo/admin +# writes after config stream completes. +CONFIG_BT_ATT_PREPARE_COUNT=4 + +# ── Filesystem — LittleFS on storage_partition (RRAM) ─────────────────────── +# Size is set by the board overlay (the nRF54L15-DK overlay reclaims slot1 to +# expand storage_partition to ~700 KB). Capacity is reported at runtime via +# FIXED_PARTITION_SIZE(storage_partition) in InternalFileSystem::totalBytes(). +CONFIG_FLASH=y +CONFIG_FLASH_MAP=y +CONFIG_FLASH_PAGE_LAYOUT=y +CONFIG_FILE_SYSTEM=y +CONFIG_FILE_SYSTEM_LITTLEFS=y +CONFIG_FILE_SYSTEM_MKFS=y +# Disable SPI NOR flash driver — MX25R64 node deleted from DTS, SPIM00 used +# exclusively by RadioLib (SX1262). Without this, the spi_nor driver claims +# SPIM00 at boot and tries to read MX25R64 ID (gets garbage since the chip is +# not wired), producing "Device id a8 a8 a8 does not match config c2 28 17". +CONFIG_SPI_NOR=n +# Disable runtime PM — keeps SPI initialization path simple; avoids any +# interaction between PM auto-suspend/resume cycles and the SPIM00 clock +# request mechanism (CONFIG_CLOCK_CONTROL_NRF_HSFLL_GLOBAL). +CONFIG_PM_DEVICE_RUNTIME=n +# Suppress Zephyr FS subsystem's internal error/warning logs (ENOENT on +# missing files and EEXIST on duplicate mkdir are expected and handled). +CONFIG_FS_LOG_LEVEL_OFF=y + +# ── Console / logging ───────────────────────────────────────────────────────── +# Use SEGGER RTT for console — does not require COM3 (CDC UART), reads via SWD +CONFIG_UART_CONSOLE=n +CONFIG_USE_SEGGER_RTT=y +CONFIG_RTT_CONSOLE=y +CONFIG_LOG=y +CONFIG_LOG_BACKEND_RTT=y +CONFIG_LOG_DEFAULT_LEVEL=2 +# Immediate mode: log writes go directly to RTT backend without a separate thread. +# Deferred mode requires the log thread to run (lowest priority — never gets CPU +# in heavy setup()/loop() workloads), leaving the RTT buffer empty indefinitely. +CONFIG_LOG_MODE_IMMEDIATE=y +# Force RTT control block re-init on every boot — prevents stale/corrupted CB after crash +CONFIG_SEGGER_RTT_INIT_MODE_ALWAYS=y +# Use RTT channel 1 for the LOG backend, channel 0 (Terminal) for direct printk. +# Sharing channel 0 forces LOG_PRINTK=y (deferred) to avoid corruption. +CONFIG_LOG_BACKEND_RTT_BUFFER=1 +# Buffer sizes shrunk from 24576 → 4096 to free ~40 KB of BSS for newlib heap. +# At 24576 the BSS pushed _end up so far that newlib heap was only ~25 KB, +# and BUF_ACL_RX_SIZE=152 + BLE/PhoneAPI lazy init ran out of malloc space. +# 4 KB still gives several seconds of log retention before host attaches. +CONFIG_LOG_BACKEND_RTT_BUFFER_SIZE=4096 +# Overwrite oldest data if buffer fills — never stalls +CONFIG_LOG_BACKEND_RTT_MODE_BLOCK=n +CONFIG_LOG_BACKEND_RTT_MODE_OVERWRITE=y +CONFIG_LOG_BACKEND_RTT_OUTPUT_BUFFER_SIZE=256 + +# ── LFXO clock source — use RC oscillator to avoid ~2s crystal stabilization +# disrupting the GRTC timer and hanging k_sleep +CONFIG_CLOCK_CONTROL_NRF_K32SRC_RC=y + +# ── Stack sizes — Meshtastic setup() is heavy (RadioLib, NodeDB, printf) ────── +# bt_enable() called from nrf54l15Setup() needs >8KB. +# Phase 7: CONFIG_BT_SETTINGS=y causes bt_set_name() → settings_save_one() → +# settings_file_save() → LittleFS I/O. The I/O chain needs ~3 KB of stack +# headroom beyond what the BT init alone requires. Increase main stack to 24KB +# and system workqueue to 8KB to cover both the cooperative-OSThread call path +# (which runs on the main thread) and any async flash work items. +CONFIG_MAIN_STACK_SIZE=24576 +CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=8192 +# Log processing thread stack — default 768 overflows when processing RTT fault dump +CONFIG_LOG_PROCESS_THREAD_STACK_SIZE=2048 + +# ── Fault/exception diagnostics — identify ~2000ms crash ───────────────────── +CONFIG_FAULT_DUMP=2 +CONFIG_EXCEPTION_DEBUG=y +CONFIG_STACK_SENTINEL=y +# Thread names + extra exception info — fault dumps then identify the failing +# thread (otherwise "Current thread: 0x... (unknown)") and include r4-r11 + psp +# so the custom k_sys_fatal_error_handler can walk the stack to show the caller +# chain. Cheap (~32 B/thread for names, no perf hit) and very useful when a +# crash recurs in the field. +CONFIG_THREAD_NAME=y +CONFIG_EXTRA_EXCEPTION_INFO=y +# SEGGER RTT buffer — keep modest (4 KB) to leave RAM for newlib heap. +CONFIG_SEGGER_RTT_BUFFER_SIZE_UP=4096 +# Report reset reason from previous crash +CONFIG_HWINFO=y + +# ── Bluetooth ───────────────────────────────────────────────────────────────── +# Zephyr BT host + LL SW controller (MPSL) — peripheral role only +CONFIG_BT=y +CONFIG_BT_PERIPHERAL=y +# SMP / LE Encryption enabled so the Meshtastic app pairs with a PIN before +# accessing the GATT service. +CONFIG_BT_SMP=y +# Enforce MITM so clients must complete passkey exchange — without this Just +# Works pairings complete silently without prompting the user for a PIN. +CONFIG_BT_SMP_ENFORCE_MITM=y +# Fixed-passkey path so the device (no display) can advertise a known PIN via +# bt_passkey_set() when config.bluetooth.mode == FIXED_PIN. +CONFIG_BT_FIXED_PASSKEY=y +# Allow legacy pairing as fallback. SC_PAIR_ONLY=y has been observed to cause +# some clients to abort pairing with reason 0x01 within 150 ms of the pairing +# request, before any PIN dialog appears. Accepting legacy lets the same +# clients fall through to Passkey Entry successfully. +CONFIG_BT_SMP_SC_PAIR_ONLY=n +# BT_LL_SW_SPLIT is auto-selected from DT (zephyr,bt-hci-ll-sw-split node in nRF54L15 DTS) +# Do NOT set CONFIG_BT_CTLR=y (deprecated — radio silently non-functional) +# Dynamic device name so bt_set_name() can embed the node short ID at runtime +CONFIG_BT_DEVICE_NAME_DYNAMIC=y +CONFIG_BT_DEVICE_NAME_MAX=32 +# Only need one simultaneous central connection +CONFIG_BT_MAX_CONN=1 +# BT subsystem logging — INF for connection/service diagnostics +CONFIG_BT_LOG_LEVEL_INF=y +# BT thread stacks — defaults are too small for nRF54L15 SW-LL init. +# prio_recv_thread overflows at 2048; bump all BT stacks to safe sizes. +# BT RX thread runs ALL our GATT write callbacks (rx_work_handler → hci_acl → +# bt_conn_recv → bt_l2cap_recv → bt_att_recv → write_toradio_cb → +# PhoneAPI::handleStartConfig → getFiles("/", 10) recursion + nanopb encode). +# 4096 overflows on "Client wants config" → abort() / kernel panic (reason 4). +CONFIG_BT_RX_STACK_SIZE=4096 +CONFIG_BT_HCI_TX_STACK_SIZE=1024 +# bt_long_wq runs bt_pub_key_gen (ECC P256 keygen) on this thread. +# Defaults (prio=10, stack=1400) leave it starved by Meshtastic app threads +# at boot: pub_key gen never completes, so smp_public_key() defers +# indefinitely waiting for sc_public_key, and every SC pairing attempt +# stalls right after exchanging public keys (no PIN prompt on iOS, every +# AUTHEN-gated char rejects with ATT error 0x05). +# Prio 0 = highest preemptible, ties with main; stack 4096 clears P256M +# driver frames with margin. +CONFIG_BT_LONG_WQ_PRIO=0 +CONFIG_BT_LONG_WQ_STACK_SIZE=4096 +# Use legacy advertising (bt_le_adv_start / HCI 0x2006 path). +# With CONFIG_BT_EXT_ADV=y, bt_le_adv_start() is internally translated to the +# extended HCI path with LEGACY-bit (0x2036), which produces non-connectable PDUs +# on the nRF54L15 SW-LL. With CONFIG_BT_EXT_ADV=n the host uses pure legacy HCI +# commands (0x2006/0x2008/0x200a) — the same path Nordic uses in all nRF54L15 +# NCS examples (peripheral_uart, peripheral_lbs), which is iOS-compatible and +# avoids the LE Remove Advertising Set (0x203c) controller timeout crash. +CONFIG_BT_EXT_ADV=n + +# ── Phase 7: BT bond persistence ────────────────────────────────────────────── +# CONFIG_BT_SETTINGS enables the BT host settings integration: the stack +# automatically calls settings_save_subtree("bt/keys") after pairing, and +# settings_load() on boot restores previously bonded peers. +# +# Backend: SETTINGS_FILE stores all key-value pairs in a single flat file in +# LittleFS. No new partition needed — the existing storage_partition (mounted +# at /lfs, size set by the board overlay) is used. File path: /lfs/bt_settings. +# +# Ordering guarantee: LittleFS is mounted by fsInit() BEFORE nrf54l15Setup() +# calls nrf54l15_bt_preinit(), so the file backend is always available when +# settings_load() is called after bt_enable(). +CONFIG_BT_SETTINGS=y +CONFIG_SETTINGS=y +CONFIG_SETTINGS_FILE=y +CONFIG_SETTINGS_FILE_PATH="/lfs/bt_settings" +# BT_MAX_PAIRED default is 1 — first bond (e.g. iOS) blocks every subsequent +# peer's SMP pairing request with "Unable to get keys" because there is no free +# bt_keys slot to allocate. Raise to 4 so the device can simultaneously hold +# iOS, Windows, Linux, and one spare bond. Add OVERWRITE_OLDEST so that when +# the table fills, the LRU peer is evicted instead of rejecting the new pair. +CONFIG_BT_MAX_PAIRED=4 +CONFIG_BT_KEYS_OVERWRITE_OLDEST=y +# Disable GATT database caching and Service Changed characteristic. +# CONFIG_BT_GATT_CACHING (default y with BT_SETTINGS) marks every new client as +# "not change-aware" and returns ATT_ERR_DB_OUT_OF_SYNC (0x12) on every GATT +# request until the client reads the DB-hash characteristic. The Meshtastic app +# does not implement GATT caching and silently aborts service discovery on 0x12, +# causing the connection to stall with zero GATT activity. +# CONFIG_BT_GATT_SERVICE_CHANGED (default y) adds the Generic Attribute Profile +# service; disabling it is required before BT_GATT_CACHING can be disabled. +CONFIG_BT_GATT_SERVICE_CHANGED=n +CONFIG_BT_GATT_CACHING=n +# Disable automatic PHY update (1M→2M) after connection. +# The nRF54L15 SW-LL fails the LL_PHY_REQ/RSP exchange and disconnects +# exactly 1.786s after connection — before any ATT/GATT operations. +CONFIG_BT_AUTO_PHY_UPDATE=n +# ATT/GATT/L2CAP debug logging — see exactly what happens after connection +CONFIG_BT_ATT_LOG_LEVEL_DBG=y +CONFIG_BT_GATT_LOG_LEVEL_DBG=y +CONFIG_BT_SMP_LOG_LEVEL_DBG=y +# L2CAP DBG: shows recv on fixed ATT channel — confirms whether iOS sends any data +CONFIG_BT_L2CAP_LOG_LEVEL_DBG=y +# Keep bt_conn at INF — DBG floods RTT buffer every ~150µs (tx_processor loop), +# overwriting all ATT/GATT messages before they can be read. +# Connection events (connected/disconnected) are logged at INF level. +CONFIG_BT_CONN_LOG_LEVEL_INF=y +# Keep HCI logs at INF to save RAM (log thread processing buffers, etc.). +# (Earlier DBG was used to diagnose the hci_acl → L2CAP stall — fix applied.) +CONFIG_BT_HCI_CORE_LOG_LEVEL_INF=y +CONFIG_BT_HCI_DRIVER_LOG_LEVEL_INF=y +# Fix: ACL packets reach hci_acl() but never reach bt_l2cap_recv(). +# Root cause: bt_conn_recv() calls bt_conn_tx_notify(conn, true) which submits +# tx_complete_work to k_sys_work_q and blocks on k_work_flush(). The BT rx +# workqueue (bt_workq) is stuck in k_work_flush waiting for the system +# workqueue, which is busy with LittleFS I/O / other work → dead stall until +# iOS supervision timeout fires (5s) and disconnects with reason 0x13. +# Solution: dedicate a separate workqueue for TX notify processing so it is +# independent from the system workqueue. +CONFIG_BT_CONN_TX_NOTIFY_WQ=y +# Dedicated workqueue only runs tx_notify_process() (iterates tx_complete list, +# calls short callbacks). Default 8192 is overkill and eats malloc heap needed +# by PowerFSM init → realloc() returns NULL → bus fault during FSM::add_transition. +CONFIG_BT_CONN_TX_NOTIFY_WQ_STACK_SIZE=2048 + +# ── ATT/L2CAP MTU — larger payloads for Meshtastic packets ─────────────────── +# TX side: controller sends up to L2CAP_TX_MTU bytes per ATT operation. +# RX side: server ATT MTU is min(BT_L2CAP_TX_MTU, BT_BUF_ACL_RX_SIZE - 4). +# Both set to 247 / 251 → ATT MTU = 247 in each direction, matching Zephyr's +# samples/bluetooth/mtu_update reference. This means typical iOS ToRadio +# writes (NodeInfo, channel settings, common admin packets) fit in a single +# ATT_WRITE_REQ and avoid the ATT Prepare/Execute Write path entirely. +# CONFIG_BT_ATT_PREPARE_COUNT=4 (above) still backstops oversized writes. +# +# Heap dependency: bumping BUF_ACL_RX_SIZE > default (~69) grows the BT host +# net_buf pools in BSS, which proportionally shrinks the newlib heap arena +# (MAX_HEAP_SIZE = SRAM_SIZE - (_end - SRAM_BASE), so any BSS growth steals +# from the heap directly). Empirically the lazy BLE init path +# (setBluetoothEnable → startDisabled → bt_set_name → settings_save → +# LittleFS) needs ~12 KB of newlib heap to run without bad_alloc. At +# BUF_ACL_RX_SIZE=251 with the previous 24 KB RTT buffers (LOG_BACKEND_RTT + +# SEGGER_RTT_BUFFER_SIZE_UP), the heap collapsed to ~4 KB free at +# transition time → `new char[]` in RedirectablePrint::log returned NULL → +# libstdc++ called abort() from main thread. Shrinking both RTT buffers to +# 4 KB (above) frees ~40 KB of BSS for the heap and resolves it. +# +# DLE stays off (BT_DATA_LEN_UPDATE=n below): the LLCP remote table at +# ull_llcp_remote.c:878 is guarded by #ifdef CONFIG_BT_CTLR_DATA_LENGTH, so +# the controller answers iOS's LL_LENGTH_REQ with LL_UNKNOWN_RSP and falls +# back to 27-byte LL PDUs. The host reassembles LL PDUs into L2CAP frames +# up to BT_BUF_ACL_RX_SIZE before dispatching to ATT. +CONFIG_BT_L2CAP_TX_MTU=247 +# Server ATT MTU = BUF_ACL_RX_SIZE - 4 = 247 (matches L2CAP_TX_MTU) +CONFIG_BT_BUF_ACL_RX_SIZE=251 + +# ── Fix: LL Feature Exchange collision (ROOT CAUSE of iOS GATT hang) ───────── +# On connection, Zephyr host calls bt_hci_le_read_remote_features() because +# BT_CTLR_PER_INIT_FEAT_XCHG=y makes can_initiate_feature_exchange() return +# true for peripheral role. This makes the controller send LL_PER_INIT_FEAT_XCHG +# to iOS right after connecting. +# iOS (as central) simultaneously sends LL_FEATURE_REQ to the peripheral. +# The nRF54L15 SW-LL mishandles this COLLISION: iOS waits for LL_FEATURE_RSP +# to its LL_FEATURE_REQ, never gets it, and stalls — sending zero L2CAP bytes. +# BT_CTLR_PER_INIT_FEAT_XCHG=n: host does NOT send HCI_LE_Read_Remote_Features +# as peripheral → no LL_PER_INIT_FEAT_XCHG sent → no collision → iOS feature +# exchange completes → iOS proceeds to L2CAP/ATT. +CONFIG_BT_CTLR_PER_INIT_FEAT_XCHG=n +# Fix: LL Connection Parameter Request handling. +# BT_CTLR_CONN_PARAM_REQ=n was ineffective: the LLCP remote decode table in +# ull_llcp_remote.c hardcodes PDU_DATA_LLCTRL_TYPE_CONN_PARAM_REQ → PROC_CONN_PARAM_REQ +# regardless of Kconfig. With =n the handler is compiled out → controller asserts / +# enters broken state when iOS sends LL_CONN_PARAM_REQ (which is optional from Central). +# Fix: =y so the procedure is actually handled. To avoid host/peripheral vs Central +# collision at 5 s (deferred_work → send_conn_le_param_update), disable auto-update +# below so the host never initiates HCI_LE_Connection_Update. +CONFIG_BT_CTLR_CONN_PARAM_REQ=y +# Prevent Zephyr host from initiating connection parameter update 5 s after connect. +# With CONN_PARAM_REQ=y, if iOS (Central) already issued LL_CONN_PARAM_REQ and the +# SW-LL is mid-procedure, a simultaneous host-initiated HCI_LE_Connection_Update +# creates an LL collision. Disabling the auto-update avoids the collision entirely. +CONFIG_BT_GAP_AUTO_UPDATE_CONN_PARAMS=n +# Disable optional LL procedures (belt-and-suspenders while debugging): +# BT_PHY_UPDATE=n + BT_CTLR_PHY_2M=n: no PHY update procedure → iOS doesn't attempt LL_PHY_REQ +# BT_DATA_LEN_UPDATE=n: no DLE → controller sends LL_UNKNOWN_RSP to LL_LENGTH_REQ +CONFIG_BT_CTLR_PHY_2M=n +CONFIG_BT_PHY_UPDATE=n +CONFIG_BT_DATA_LEN_UPDATE=n From fc5556b8e639306448c6c5c44583ffb6f27d0a2f Mon Sep 17 00:00:00 2001 From: Jord <650645+Jord-JD@users.noreply.github.com> Date: Fri, 15 May 2026 02:51:44 +0100 Subject: [PATCH 200/225] Clamp direct position packets to channel precision (fixes #8640) (#10383) * Fix position precision for direct sends * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Clarify zero position precision logging * Use const channel reference for position precision * Use C linkage for position precision test entrypoints --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mesh/PositionPrecision.cpp | 75 ++++++++++++++++ src/mesh/PositionPrecision.h | 9 ++ src/mesh/Router.cpp | 6 ++ src/modules/PositionModule.cpp | 48 +++------- test/test_position_precision/test_main.cpp | 100 +++++++++++++++++++++ 5 files changed, 204 insertions(+), 34 deletions(-) create mode 100644 src/mesh/PositionPrecision.cpp create mode 100644 src/mesh/PositionPrecision.h create mode 100644 test/test_position_precision/test_main.cpp diff --git a/src/mesh/PositionPrecision.cpp b/src/mesh/PositionPrecision.cpp new file mode 100644 index 00000000000..04db01c7919 --- /dev/null +++ b/src/mesh/PositionPrecision.cpp @@ -0,0 +1,75 @@ +#include "PositionPrecision.h" +#include "Channels.h" +#include "mesh-pb-constants.h" + +#include + +uint32_t getPositionPrecisionForChannel(uint8_t channelIndex) +{ + const meshtastic_Channel &channel = channels.getByIndex(channelIndex); + + if (channel.settings.has_module_settings) { + return channel.settings.module_settings.position_precision; + } else if (channel.role == meshtastic_Channel_Role_PRIMARY) { + return 32; + } else { + return 0; + } +} + +static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision) +{ + uint32_t coordinateBits = static_cast(coordinate); + uint32_t truncated = coordinateBits & (UINT32_MAX << (32 - precision)); + + // Use the middle of the possible location, not the low edge of the bucket. + truncated += (1UL << (31 - precision)); + + return static_cast(truncated); +} + +void applyPositionPrecision(meshtastic_Position &position, uint32_t precision) +{ + if (precision == 0) { + uint32_t time = position.time; + position = meshtastic_Position_init_default; + position.time = time; + return; + } + + uint32_t effectivePrecision = precision > 32 ? 32 : precision; + position.precision_bits = effectivePrecision; + + if (effectivePrecision < 32) { + position.latitude_i = truncateCoordinate(position.latitude_i, effectivePrecision); + position.longitude_i = truncateCoordinate(position.longitude_i, effectivePrecision); + } +} + +bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision) +{ + if (packet.which_payload_variant != meshtastic_MeshPacket_decoded_tag || + packet.decoded.portnum != meshtastic_PortNum_POSITION_APP) { + return true; + } + + meshtastic_Position position = meshtastic_Position_init_default; + if (!pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &position)) { + return false; + } + + applyPositionPrecision(position, precision); + packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), + &meshtastic_Position_msg, &position); + return true; +} + +bool applyPositionPrecisionForChannel(meshtastic_MeshPacket &packet, uint8_t channelIndex) +{ + if (packet.which_payload_variant != meshtastic_MeshPacket_decoded_tag || + packet.decoded.portnum != meshtastic_PortNum_POSITION_APP) { + return true; + } + + return applyPositionPrecision(packet, getPositionPrecisionForChannel(channelIndex)); +} diff --git a/src/mesh/PositionPrecision.h b/src/mesh/PositionPrecision.h new file mode 100644 index 00000000000..6fdbd2f6435 --- /dev/null +++ b/src/mesh/PositionPrecision.h @@ -0,0 +1,9 @@ +#pragma once + +#include "meshtastic/mesh.pb.h" +#include + +uint32_t getPositionPrecisionForChannel(uint8_t channelIndex); +void applyPositionPrecision(meshtastic_Position &position, uint32_t precision); +bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision); +bool applyPositionPrecisionForChannel(meshtastic_MeshPacket &packet, uint8_t channelIndex); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index eb5fd41ff1b..bb24b365e54 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -4,6 +4,7 @@ #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" +#include "PositionPrecision.h" #include "RTC.h" #include "configuration.h" @@ -350,6 +351,11 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) } fixPriority(p); // Before encryption, fix the priority if it's unset + if (!applyPositionPrecisionForChannel(*p, p->channel)) { + LOG_ERROR("Dropping malformed position packet before send"); + packetPool.release(p); + return meshtastic_Routing_Error_BAD_REQUEST; + } // If the packet is not yet encrypted, do so now if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 0378d01e74b..c4ffc9a92bc 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -4,6 +4,7 @@ #include "GPS.h" #include "MeshService.h" #include "NodeDB.h" +#include "PositionPrecision.h" #include "RTC.h" #include "Router.h" #include "TransmitHistory.h" @@ -107,13 +108,7 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes } nodeDB->updatePosition(getFrom(&mp), p); - if (channels.getByIndex(mp.channel).settings.has_module_settings) { - precision = channels.getByIndex(mp.channel).settings.module_settings.position_precision; - } else if (channels.getByIndex(mp.channel).role == meshtastic_Channel_Role_PRIMARY) { - precision = 32; - } else { - precision = 0; - } + precision = getPositionPrecisionForChannel(mp.channel); return false; // Let others look at this message also if they want } @@ -121,15 +116,12 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes void PositionModule::alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic_Position *p) { // Phone position packets need to be truncated to the channel precision - if (isFromUs(&mp) && (precision < 32 && precision > 0)) { - LOG_DEBUG("Truncate phone position to channel precision %i", precision); - p->latitude_i = p->latitude_i & (UINT32_MAX << (32 - precision)); - p->longitude_i = p->longitude_i & (UINT32_MAX << (32 - precision)); - - // We want the imprecise position to be the middle of the possible location, not - p->latitude_i += (1 << (31 - precision)); - p->longitude_i += (1 << (31 - precision)); - + if (isFromUs(&mp)) { + if (precision == 0) + LOG_DEBUG("Strip phone position due to channel precision 0"); + else if (precision < 32) + LOG_DEBUG("Truncate phone position to channel precision %i", precision); + applyPositionPrecision(*p, precision); mp.decoded.payload.size = pb_encode_to_bytes(mp.decoded.payload.bytes, sizeof(mp.decoded.payload.bytes), &meshtastic_Position_msg, p); } @@ -205,20 +197,11 @@ meshtastic_MeshPacket *PositionModule::allocPositionPacket() // lat/lon are unconditionally included - IF AVAILABLE! LOG_DEBUG("Send location with precision %i", precision); - if (precision < 32 && precision > 0) { - p.latitude_i = localPosition.latitude_i & (UINT32_MAX << (32 - precision)); - p.longitude_i = localPosition.longitude_i & (UINT32_MAX << (32 - precision)); - - // We want the imprecise position to be the middle of the possible location, not - p.latitude_i += (1 << (31 - precision)); - p.longitude_i += (1 << (31 - precision)); - } else { - p.latitude_i = localPosition.latitude_i; - p.longitude_i = localPosition.longitude_i; - } - p.precision_bits = precision; + p.latitude_i = localPosition.latitude_i; + p.longitude_i = localPosition.longitude_i; p.has_latitude_i = true; p.has_longitude_i = true; + applyPositionPrecision(p, precision); // Always use NTP / GPS time if available if (getValidTime(RTCQualityNTP) > 0) { p.time = getValidTime(RTCQualityNTP); @@ -349,8 +332,7 @@ void PositionModule::sendOurPosition() // If we changed channels, ask everyone else for their latest info LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies); for (uint8_t channelNum = 0; channelNum < 8; channelNum++) { - if (channels.getByIndex(channelNum).settings.has_module_settings && - channels.getByIndex(channelNum).settings.module_settings.position_precision != 0) { + if (getPositionPrecisionForChannel(channelNum) != 0) { sendOurPosition(NODENUM_BROADCAST, requestReplies, channelNum); return; } @@ -368,10 +350,8 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha if (prevPacketId) // if we wrap around to zero, we'll simply fail to cancel in that rare case (no big deal) service->cancelSending(prevPacketId); - // Set's the class precision value for this particular packet - if (channels.getByIndex(channel).settings.has_module_settings) { - precision = channels.getByIndex(channel).settings.module_settings.position_precision; - } + // Set the class precision value for this particular packet. + precision = getPositionPrecisionForChannel(channel); meshtastic_MeshPacket *p = allocPositionPacket(); if (p == nullptr) { diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp new file mode 100644 index 00000000000..4f5aecfda97 --- /dev/null +++ b/test/test_position_precision/test_main.cpp @@ -0,0 +1,100 @@ +#include "PositionPrecision.h" +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include + +static meshtastic_Position makePosition() +{ + meshtastic_Position position = meshtastic_Position_init_default; + position.has_latitude_i = true; + position.latitude_i = static_cast(0x12345678); + position.has_longitude_i = true; + position.longitude_i = static_cast(0x22345678); + position.has_altitude = true; + position.altitude = 123; + position.time = 42; + position.location_source = meshtastic_Position_LocSource_LOC_EXTERNAL; + position.timestamp = 43; + position.sats_in_view = 10; + return position; +} + +static void test_applyPositionPrecision_clampsLatLonAndSetsPrecisionBits() +{ + meshtastic_Position position = makePosition(); + + applyPositionPrecision(position, 16); + + TEST_ASSERT_EQUAL_INT32(static_cast(0x12348000), position.latitude_i); + TEST_ASSERT_EQUAL_INT32(static_cast(0x22348000), position.longitude_i); + TEST_ASSERT_EQUAL_UINT32(16, position.precision_bits); + TEST_ASSERT_TRUE(position.has_latitude_i); + TEST_ASSERT_TRUE(position.has_longitude_i); +} + +static void test_applyPositionPrecision_fullPrecisionKeepsLatLon() +{ + meshtastic_Position position = makePosition(); + + applyPositionPrecision(position, 32); + + TEST_ASSERT_EQUAL_INT32(static_cast(0x12345678), position.latitude_i); + TEST_ASSERT_EQUAL_INT32(static_cast(0x22345678), position.longitude_i); + TEST_ASSERT_EQUAL_UINT32(32, position.precision_bits); +} + +static void test_applyPositionPrecision_zeroScrubsLocationButKeepsTime() +{ + meshtastic_Position position = makePosition(); + + applyPositionPrecision(position, 0); + + TEST_ASSERT_FALSE(position.has_latitude_i); + TEST_ASSERT_EQUAL_INT32(0, position.latitude_i); + TEST_ASSERT_FALSE(position.has_longitude_i); + TEST_ASSERT_EQUAL_INT32(0, position.longitude_i); + TEST_ASSERT_FALSE(position.has_altitude); + TEST_ASSERT_EQUAL_INT32(0, position.altitude); + TEST_ASSERT_EQUAL_UINT32(42, position.time); + TEST_ASSERT_EQUAL_UINT32(0, position.timestamp); + TEST_ASSERT_EQUAL_UINT32(0, position.sats_in_view); + TEST_ASSERT_EQUAL_UINT32(0, position.precision_bits); +} + +static void test_applyPositionPrecision_reencodesPositionPacket() +{ + meshtastic_Position position = makePosition(); + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_default; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = meshtastic_PortNum_POSITION_APP; + packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), + &meshtastic_Position_msg, &position); + + TEST_ASSERT_TRUE(applyPositionPrecision(packet, 16)); + + meshtastic_Position decoded = meshtastic_Position_init_default; + TEST_ASSERT_TRUE( + pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &decoded)); + TEST_ASSERT_EQUAL_INT32(static_cast(0x12348000), decoded.latitude_i); + TEST_ASSERT_EQUAL_INT32(static_cast(0x22348000), decoded.longitude_i); + TEST_ASSERT_EQUAL_UINT32(16, decoded.precision_bits); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_applyPositionPrecision_clampsLatLonAndSetsPrecisionBits); + RUN_TEST(test_applyPositionPrecision_fullPrecisionKeepsLatLon); + RUN_TEST(test_applyPositionPrecision_zeroScrubsLocationButKeepsTime); + RUN_TEST(test_applyPositionPrecision_reencodesPositionPacket); + exit(UNITY_END()); +} + +void loop() {} +} From 767a7481880d7cfc5d555dc8e9a2f0b7c4317d0f Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Sat, 16 May 2026 22:11:49 -0500 Subject: [PATCH 201/225] add optional LED_LORA to indicate LoRa TX (#10465) --- src/mesh/RadioLibInterface.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 02cf2281d75..ef9a17f8d41 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -424,6 +424,9 @@ void RadioLibInterface::completeSending() // that can take a long time auto p = sendingPacket; sendingPacket = NULL; +#ifdef LED_LORA + digitalWrite(LED_LORA, LED_STATE_OFF); +#endif if (p) { // Packet has been sent, count it toward our TX airtime utilization. @@ -612,6 +615,9 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) enableInterrupt(isrTxLevel0); lastTxStart = millis(); printPacket("Started Tx", txp); +#ifdef LED_LORA + digitalWrite(LED_LORA, LED_STATE_ON); +#endif } return res == RADIOLIB_ERR_NONE; From a5419574804709db446273c8ce5d946a3a62807c Mon Sep 17 00:00:00 2001 From: Austin Date: Sun, 17 May 2026 19:53:32 -0400 Subject: [PATCH 202/225] ESP32: Migrate to Arduino 3.x (pioarduino) (#9122) * Migrate esp32 families to pioarduino platform * ESP32c6 align text.handler_execute same as C3 * Use pioarduino `develop` The latest fixes and the latest bugs! * preliminary esp32p4.ini * pioarduino: Update LovyanGFX Includes Manuel's recent commit * pioarduino 3.3.6 * pioarduino 3.3.6 *release* chasing the release * pioarduino: Fix OG ESP32 duplicate libs * pioarduino: T-Beam 1W CDC mode * pioarduino: disable network provisioning (wifiprov) * pioarduino: use legacy esptoolpy naming (forward-compatible) * Update lovyangfx from `develop` commit to 1.2.19 * fix esp32p4.ini * check for esp32 w/ wifi * esp32-p4 specific adaptations * Switch to meshtastic/esp32_https_server fork (idf5 branch) * don't ignore esp_lcd * config for MUI * fix/workaround SDMMC * revert a6f6175, update to 3.3.8 * enable esp_hosted for esp32-p4 (experimental) * Pioarduino 55.03.38-1 * NimBLE-Arduino -> Arduino "BLE" (3.3.x) migration (#10164) * NimBLE-Arduino -> Arduino "BLE" (3.3.x) migration * More NimBLE * Fix Device Name in ATT Read Request (0x2A00). Device Name is exposed in two places: - Advertisement data: this is set properly in startAdvertising. - GATT attribute Device Name (0x2A00). This one is handled internally in NimBLE and comes from ble_svc_gap_device_name_set. This is set initially, but then BLEDevice::createServer calls ble_svc_gap_init which resets the device name. This causes the device to apparently "change name after pairing": < ACL Data TX:... flags 0x00 dlen 7 #113 [hci0] 14.241149 ATT: Read Request (0x0a) len 2 Handle: 0x0003 Type: Device Name (0x2a00) > ACL Data RX: Handle 2048 flags 0x02 dlen 11 #115 [hci0] 14.269050 ATT: Read Response (0x0b) len 6 Value[6]: 6e696d626c65 # "nimble" Workaround this by setting the device name once again after BLEDevice::createServer. * Temporarily lower CORE_DEBUG_LEVEL to INFO to avoid triggering an apparent ESP-IDF Bluetooth bug when re-connecting to Pixel 8 Android devices. Initial pairing works, but after ESP32 is rebooted, phone fails to reconnect. Meshtastic app shows it as disconnecting immediately. LightBlue shows a more detailed error "Peripheral Connection - Warning: onConnectionStatusChange: status 61" (0x3D - MIC Failure). Bug report to Espresssif: https://github.com/espressif/esp-idf/issues/18126#issuecomment-4286197744 * Temporarily disable ble_gap_set_data_len, causes crash with Pixel 8 Android reconnect. Crash looks like this: [ 11966][E][BLEAdvertising.cpp:341] setScanResponseData(): ble_gap_adv_rsp_set_data: 22 [ 11975][E][BLEAdvertising.cpp:1554] start(): Host reset, wait for sync. ERROR | ??:??:?? 11 BLE failed to start advertising Guru Meditation Error: Core 0 panic'ed (LoadProhibited). Exception was unhandled. Core 0 register dump: PC : 0x420e6190 PS : 0x00060730 A0 : 0x820e158b A1 : 0x3fce50c0 A2 : 0x00000000 A3 : 0x3fcb8600 A4 : 0x3fcb85cc A5 : 0x00000000 A6 : 0x00000000 A7 : 0x00000c03 A8 : 0x00000000 A9 : 0x3fce50b0 A10 : 0x0000000e A11 : 0x00000000 A12 : 0x00000010 A13 : 0x3fce50e0 A14 : 0x00000c03 A15 : 0x00000001 SAR : 0x0000001e EXCCAUSE: 0x0000001c EXCVADDR: 0x00000000 LBEG : 0x400570e8 LEND : 0x400570f3 LCOUNT : 0x00000000 Backtrace: 0x420e618d:0x3fce50c0 0x420e1588:0x3fce5110 0x420dfe87:0x3fce5200 0x420dfefb:0x3fce5220 0x420dff3f:0x3fce5240 0x4219602b:0x3fce5260 0x4037b0e5:0x3fce5280 0x4201edf3:0x3fce52a0 Connection seems fast enough even without this. We'll investigate the reason for the crash and re-enable once it's safe. --------- Co-authored-by: Catalin Patulea * Add extension from pioarduino nag "Jason2866.esp-decoder" * Cleanup after merge * ESP32: Disable classic bluetooth * Cleanup: Fix ADC channels on new variants * InkHUD: Fix type casting for message size in saveToFlash method inkhud compiles again! * update p4 esp_hosted for BT * I thought I fixed this * fix linker error using response file (p4 only) * fix infinite loop * Fix Power.cpp check warning Local variable 'config' shadows outer variable [shadowVariable] * Build ESP32 original with NimBLE ('custom_sdkconfig' approach). (#10235) * Re-enable littlefs json manifest This works locally again :) Not sure what changed * Re-add tool-mklittlefs * sensecap indicator fixes after upgrade arduino-esp & lovyanGFX libs * hackaday fix * robot tbeam cache error fix Co-authored-by: Copilot * trunk fmt * ignore trunk * BLEDevice::deinit() added Co-authored-by: Copilot * platformio-custom: Modify mtjson target dependency to prevent fake-success. (#10291) Co-authored-by: Copilot * Fix ESP32-C6 linker errors. Align .text.handler_execute section to 4 bytes and update watchdog timer core mask configuration Co-authored-by: Copilot * tlora-c6: Disable Screen MESHTASTIC_EXCLUDE_SCREEN=1 on tlora-c6. It doesn't have a screen, and this gets it compiling again (saving flash). * Use mverch's iram_memset hack for all OG-ESP32 * Refactor watchdog timer initialization and handling * use adc_channel_t in variant.h * Fix variant headers * More idiomatic default ethernet that doesn't break the build * Elecrows: Delete problematic variant.cpp Not needed after USE_ETHERNET_DEFAULT --------- Co-authored-by: mverch67 Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com> Co-authored-by: Catalin Patulea Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> Co-authored-by: Copilot Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> Co-authored-by: Ben Meadors --- bin/build-esp32.sh | 2 +- bin/platformio-custom.py | 12 +- boards/CDEBYTE_EoRa-S3.json | 2 +- boards/bpi_picow_esp32_s3.json | 2 +- boards/hackaday-communicator.json | 2 +- boards/heltec_vision_master_e213.json | 2 +- boards/heltec_vision_master_e290.json | 2 +- boards/heltec_vision_master_t190.json | 2 +- boards/heltec_wireless_tracker.json | 2 +- boards/heltec_wireless_tracker_v2.json | 2 +- boards/seeed-xiao-s3.json | 2 +- boards/t-beam-1w.json | 2 +- boards/t-deck.json | 2 +- boards/tbeam-s3-core.json | 2 +- boards/tlora-t3s3-v1.json | 2 +- boards/unphone.json | 2 +- default_16MB.csv | 7 + default_8MB.csv | 7 + extra_scripts/esp32_extra.py | 17 +- extra_scripts/ld_response_file.py | 23 + platformio.ini | 2 +- src/DebugConfiguration.h | 5 +- src/FSCommon.cpp | 2 +- src/Power.cpp | 209 +++++---- src/configuration.h | 4 + src/graphics/niche/InkHUD/MessageStore.cpp | 9 +- src/input/ExpressLRSFiveWay.h | 2 + src/input/TLoraPagerKeyboard.cpp | 4 +- src/mesh/NodeDB.cpp | 4 + src/mesh/api/WiFiServerAPI.h | 5 +- src/mesh/http/WebServer.cpp | 5 +- src/mesh/udp/UdpMulticastHandler.h | 5 +- src/mesh/wifi/WiFiAPClient.cpp | 5 +- src/mesh/wifi/WiFiAPClient.h | 5 +- src/modules/AdminModule.cpp | 2 +- src/modules/esp32/PaxcounterModule.cpp | 4 +- src/mqtt/MQTT.cpp | 8 +- src/nimble/NimbleBluetooth.cpp | 437 ++++++++---------- src/nimble/NimbleBluetooth.h | 5 - src/platform/esp32/IramMemcpy.c | 42 ++ src/platform/esp32/IramMemset.c | 42 ++ .../esp32/align-text.handler_execute-4.ld | 11 + src/platform/esp32/main-esp32.cpp | 27 +- src/power.h | 11 +- src/sleep.cpp | 42 +- variants/esp32/chatter2/variant.h | 2 +- .../diy/9m2ibr_aprs_lora_tracker/variant.h | 2 +- variants/esp32/diy/dr-dev/variant.h | 2 +- variants/esp32/diy/hydra/variant.h | 2 +- variants/esp32/diy/v1/variant.h | 2 +- variants/esp32/esp32-common.ini | 272 ++++++++++- variants/esp32/esp32.ini | 31 +- variants/esp32/heltec_wsl_v2.1/variant.h | 2 +- variants/esp32/m5stack_coreink/variant.h | 2 +- variants/esp32/nano-g1-explorer/variant.h | 2 +- .../radiomaster_900_bandit/platformio.ini | 2 +- variants/esp32/rak11200/variant.h | 2 +- variants/esp32/station-g1/variant.h | 2 +- variants/esp32/tbeam/platformio.ini | 1 + variants/esp32/tbeam_v07/variant.h | 2 +- variants/esp32/tlora_v1_3/variant.h | 2 +- variants/esp32/tlora_v2/variant.h | 2 +- variants/esp32/tlora_v2_1_16/variant.h | 2 +- variants/esp32/tlora_v2_1_18/variant.h | 2 +- variants/esp32/trackerd/variant.h | 2 +- variants/esp32c3/esp32c3.ini | 13 + variants/esp32c6/esp32c6.ini | 58 +-- .../esp32c6/m5stack_unitc6l/platformio.ini | 16 - variants/esp32c6/tlora_c6/platformio.ini | 8 + variants/esp32p4/esp32p4.ini | 110 +++++ variants/esp32s2/esp32s2.ini | 13 +- variants/esp32s3/CDEBYTE_EoRa-Hub/variant.h | 2 +- variants/esp32s3/CDEBYTE_EoRa-S3/variant.h | 2 +- .../esp32s3/ELECROW-ThinkNode-G3/variant.cpp | 6 - .../esp32s3/ELECROW-ThinkNode-G3/variant.h | 2 + .../esp32s3/ELECROW-ThinkNode-M2/variant.h | 4 +- .../esp32s3/ELECROW-ThinkNode-M5/variant.h | 2 +- .../esp32s3/ELECROW-ThinkNode-M7/variant.cpp | 6 - .../esp32s3/ELECROW-ThinkNode-M7/variant.h | 2 + .../crowpanel-esp32s3-5-epaper/variant.h | 2 +- .../diy/my_esp32s3_diy_eink/platformio.ini | 1 + .../diy/my_esp32s3_diy_oled/platformio.ini | 4 - .../diy/t-energy-s3_e22/platformio.ini | 5 +- .../esp32s3/diy/t-energy-s3_e22/variant.h | 2 +- variants/esp32s3/elecrow_panel/pins_arduino.h | 6 +- variants/esp32s3/elecrow_panel/platformio.ini | 7 +- variants/esp32s3/esp32-s3-pico/variant.h | 4 +- variants/esp32s3/esp32s3.ini | 10 + .../hackaday-communicator/platformio.ini | 4 +- .../heltec_capsule_sensor_v3/variant.h | 2 +- variants/esp32s3/heltec_sensor_hub/variant.h | 2 +- variants/esp32s3/heltec_v3/variant.h | 2 +- variants/esp32s3/heltec_v4/variant.h | 2 +- variants/esp32s3/heltec_v4_r8/variant.h | 2 +- .../heltec_vision_master_e213/platformio.ini | 2 + .../heltec_vision_master_e213/variant.h | 2 +- .../heltec_vision_master_e290/variant.h | 2 +- .../heltec_vision_master_t190/variant.h | 2 +- .../esp32s3/heltec_wireless_paper/variant.h | 2 +- .../heltec_wireless_paper_v1/variant.h | 2 +- .../heltec_wireless_tracker/platformio.ini | 4 +- .../esp32s3/heltec_wireless_tracker/variant.h | 2 +- .../heltec_wireless_tracker_V1_0/variant.h | 2 +- .../heltec_wireless_tracker_v2/variant.h | 2 +- variants/esp32s3/heltec_wsl_v3/variant.h | 2 +- variants/esp32s3/icarus/platformio.ini | 4 +- .../esp32s3/m5stack_cardputer_adv/variant.h | 2 +- variants/esp32s3/mesh-tab/variant.h | 2 +- variants/esp32s3/mini-epaper-s3/variant.h | 2 +- variants/esp32s3/picomputer-s3/variant.h | 2 +- variants/esp32s3/rak3312/platformio.ini | 2 +- variants/esp32s3/rak3312/variant.h | 4 +- variants/esp32s3/rak_wismesh_tap_v2/variant.h | 2 +- .../seeed-sensecap-indicator/platformio.ini | 53 ++- .../seeed-sensecap-indicator/variant.h | 4 +- variants/esp32s3/seeed_xiao_s3/platformio.ini | 4 - variants/esp32s3/seeed_xiao_s3/variant.h | 2 +- variants/esp32s3/station-g2/variant.h | 2 +- variants/esp32s3/t-beam-1w/variant.h | 2 +- variants/esp32s3/t-deck/variant.h | 2 +- variants/esp32s3/t-eth-elite/platformio.ini | 17 +- variants/esp32s3/t5s3_epaper/variant.h | 1 + variants/esp32s3/tlora_t3s3_epaper/variant.h | 2 +- variants/esp32s3/tlora_t3s3_v1/variant.h | 2 +- .../esp32s3/tracksenger/internal/variant.h | 2 +- variants/esp32s3/tracksenger/lcd/variant.h | 2 +- variants/esp32s3/tracksenger/oled/variant.h | 2 +- variants/esp32s3/unphone/platformio.ini | 1 - 128 files changed, 1158 insertions(+), 614 deletions(-) create mode 100644 default_16MB.csv create mode 100644 default_8MB.csv create mode 100644 extra_scripts/ld_response_file.py create mode 100644 src/platform/esp32/IramMemcpy.c create mode 100644 src/platform/esp32/IramMemset.c create mode 100644 src/platform/esp32/align-text.handler_execute-4.ld create mode 100644 variants/esp32p4/esp32p4.ini delete mode 100644 variants/esp32s3/ELECROW-ThinkNode-G3/variant.cpp delete mode 100644 variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp diff --git a/bin/build-esp32.sh b/bin/build-esp32.sh index d07a09a1664..4e799b30a3a 100755 --- a/bin/build-esp32.sh +++ b/bin/build-esp32.sh @@ -38,4 +38,4 @@ cp bin/device-install.* $OUTDIR/ cp bin/device-update.* $OUTDIR/ echo "Copying manifest" -cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json || true +cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json diff --git a/bin/platformio-custom.py b/bin/platformio-custom.py index b75c666241c..f1946770c90 100644 --- a/bin/platformio-custom.py +++ b/bin/platformio-custom.py @@ -293,9 +293,12 @@ def load_boot_logo(source, target, env): board_arch = infer_architecture(env.BoardConfig()) should_skip_manifest = board_arch is None -# For host/native envs, avoid depending on 'buildprog' (some targets don't define it) -mtjson_deps = [] if should_skip_manifest else ["buildprog"] -if not should_skip_manifest and platform.name == "espressif32": +# Most platforms can generate the manifest as part of the default 'buildprog' target. +# Typically this passes success/failure properly. +mtjson_deps = ["buildprog"] +if platform.name == "espressif32": + # On ESP32, we need to explicitly depend upon the binary to prevent fake-success upon failure. + mtjson_deps = ["$BUILD_DIR/${PROGNAME}.bin"] # Build littlefs image as part of mtjson target # Equivalent to `pio run -t buildfs` target_lfs = env.DataToBin( @@ -309,7 +312,8 @@ def skip_manifest(source, target, env): env.AddCustomTarget( name="mtjson", - dependencies=mtjson_deps, + # For host/native envs, avoid depending on 'buildprog' (some targets don't define it) + dependencies=[], actions=[skip_manifest], title="Meshtastic Manifest (skipped)", description="mtjson generation is skipped for native environments", diff --git a/boards/CDEBYTE_EoRa-S3.json b/boards/CDEBYTE_EoRa-S3.json index afaabc5a7e9..2355cecd3ab 100644 --- a/boards/CDEBYTE_EoRa-S3.json +++ b/boards/CDEBYTE_EoRa-S3.json @@ -7,7 +7,7 @@ "extra_flags": [ "-D CDEBYTE_EORA_S3", "-D ARDUINO_USB_CDC_ON_BOOT=1", - "-D ARDUINO_USB_MODE=0", + "-D ARDUINO_USB_MODE=1", "-D ARDUINO_RUNNING_CORE=1", "-D ARDUINO_EVENT_RUNNING_CORE=1", "-D BOARD_HAS_PSRAM" diff --git a/boards/bpi_picow_esp32_s3.json b/boards/bpi_picow_esp32_s3.json index 75983d8450d..62ad666f154 100644 --- a/boards/bpi_picow_esp32_s3.json +++ b/boards/bpi_picow_esp32_s3.json @@ -6,7 +6,7 @@ "core": "esp32", "extra_flags": [ "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1", "-DBOARD_HAS_PSRAM" diff --git a/boards/hackaday-communicator.json b/boards/hackaday-communicator.json index 6e6c1ad2d09..5aedf5d19b7 100644 --- a/boards/hackaday-communicator.json +++ b/boards/hackaday-communicator.json @@ -8,7 +8,7 @@ "extra_flags": [ "-DBOARD_HAS_PSRAM", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], diff --git a/boards/heltec_vision_master_e213.json b/boards/heltec_vision_master_e213.json index 152515cf375..d9d5f85824e 100644 --- a/boards/heltec_vision_master_e213.json +++ b/boards/heltec_vision_master_e213.json @@ -9,7 +9,7 @@ "extra_flags": [ "-DBOARD_HAS_PSRAM", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], diff --git a/boards/heltec_vision_master_e290.json b/boards/heltec_vision_master_e290.json index b7cbac8786f..171125338ad 100644 --- a/boards/heltec_vision_master_e290.json +++ b/boards/heltec_vision_master_e290.json @@ -9,7 +9,7 @@ "extra_flags": [ "-DBOARD_HAS_PSRAM", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], diff --git a/boards/heltec_vision_master_t190.json b/boards/heltec_vision_master_t190.json index 440f76ad01f..fbdf1f09d89 100644 --- a/boards/heltec_vision_master_t190.json +++ b/boards/heltec_vision_master_t190.json @@ -9,7 +9,7 @@ "extra_flags": [ "-DBOARD_HAS_PSRAM", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], diff --git a/boards/heltec_wireless_tracker.json b/boards/heltec_wireless_tracker.json index 04c6e5553f8..59d0daa1597 100644 --- a/boards/heltec_wireless_tracker.json +++ b/boards/heltec_wireless_tracker.json @@ -8,7 +8,7 @@ "extra_flags": [ "-DHELTEC_WIRELESS_TRACKER", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], diff --git a/boards/heltec_wireless_tracker_v2.json b/boards/heltec_wireless_tracker_v2.json index 502954e69d0..3d20f7edbcc 100644 --- a/boards/heltec_wireless_tracker_v2.json +++ b/boards/heltec_wireless_tracker_v2.json @@ -7,7 +7,7 @@ "core": "esp32", "extra_flags": [ "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], diff --git a/boards/seeed-xiao-s3.json b/boards/seeed-xiao-s3.json index 6981085ddc8..d8e5d6b94ae 100644 --- a/boards/seeed-xiao-s3.json +++ b/boards/seeed-xiao-s3.json @@ -8,7 +8,7 @@ "extra_flags": [ "-DBOARD_HAS_PSRAM", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=0" ], diff --git a/boards/t-beam-1w.json b/boards/t-beam-1w.json index 40f16195d0f..80776ee055f 100644 --- a/boards/t-beam-1w.json +++ b/boards/t-beam-1w.json @@ -9,7 +9,7 @@ "-DBOARD_HAS_PSRAM", "-DLILYGO_TBEAM_1W", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], diff --git a/boards/t-deck.json b/boards/t-deck.json index b112921b9b5..33a34b60dcf 100644 --- a/boards/t-deck.json +++ b/boards/t-deck.json @@ -8,7 +8,7 @@ "extra_flags": [ "-DBOARD_HAS_PSRAM", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], diff --git a/boards/tbeam-s3-core.json b/boards/tbeam-s3-core.json index 7bda2e5a0a3..8d2c3eed6a2 100644 --- a/boards/tbeam-s3-core.json +++ b/boards/tbeam-s3-core.json @@ -8,7 +8,7 @@ "-DBOARD_HAS_PSRAM", "-DLILYGO_TBEAM_S3_CORE", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], diff --git a/boards/tlora-t3s3-v1.json b/boards/tlora-t3s3-v1.json index 0bfd17afc29..652b4178ebe 100644 --- a/boards/tlora-t3s3-v1.json +++ b/boards/tlora-t3s3-v1.json @@ -7,7 +7,7 @@ "extra_flags": [ "-DLILYGO_T3S3_V1", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1", "-DBOARD_HAS_PSRAM" diff --git a/boards/unphone.json b/boards/unphone.json index 4d37f7bb52d..72075f5aef5 100644 --- a/boards/unphone.json +++ b/boards/unphone.json @@ -10,7 +10,7 @@ "-DBOARD_HAS_PSRAM", "-DUNPHONE_SPIN=9", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], diff --git a/default_16MB.csv b/default_16MB.csv new file mode 100644 index 00000000000..67d773728e9 --- /dev/null +++ b/default_16MB.csv @@ -0,0 +1,7 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x640000, +app1, app, ota_1, 0x650000,0x640000, +spiffs, data, spiffs, 0xc90000,0x360000, +coredump, data, coredump,0xFF0000,0x10000, diff --git a/default_8MB.csv b/default_8MB.csv new file mode 100644 index 00000000000..4e92afa6936 --- /dev/null +++ b/default_8MB.csv @@ -0,0 +1,7 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x330000, +app1, app, ota_1, 0x340000,0x330000, +spiffs, data, spiffs, 0x670000,0x180000, +coredump, data, coredump,0x7F0000,0x10000, diff --git a/extra_scripts/esp32_extra.py b/extra_scripts/esp32_extra.py index f7698561af9..975ec0f30da 100755 --- a/extra_scripts/esp32_extra.py +++ b/extra_scripts/esp32_extra.py @@ -70,17 +70,6 @@ def esp32_create_combined_bin(source, target, env): env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_create_combined_bin) -esp32_kind = env.GetProjectOption("custom_esp32_kind") -if esp32_kind == "esp32": - # Free up some IRAM by removing auxiliary SPI flash chip drivers. - # Wrapped stub symbols are defined in src/platform/esp32/iram-quirk.c. - env.Append( - LINKFLAGS=[ - "-Wl,--wrap=esp_flash_chip_gd", - "-Wl,--wrap=esp_flash_chip_issi", - "-Wl,--wrap=esp_flash_chip_winbond", - ] - ) -else: - # For newer ESP32 targets, using newlib nano works better. - env.Append(LINKFLAGS=["--specs=nano.specs", "-u", "_printf_float"]) +# Enable Newlib Nano formatting to save space +# ...but allow printf float support (compromise) +env.Append(LINKFLAGS=["--specs=nano.specs", "-u", "_printf_float"]) diff --git a/extra_scripts/ld_response_file.py b/extra_scripts/ld_response_file.py new file mode 100644 index 00000000000..e79475f197c --- /dev/null +++ b/extra_scripts/ld_response_file.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# trunk-ignore-all(ruff/F821) +# trunk-ignore-all(flake8/F821): For SConstruct imports + +# force linker response file instead of command line arguments + +Import("env") + + +def wrap_with_tempfile(command_key): + command = env.get(command_key) + if not command or not isinstance(command, str): + return + if "TEMPFILE(" in command: + return + env.Replace(**{command_key: "${TEMPFILE('%s')}" % command}) + + +# Force SCons to spill long commands into response files on this target. +env.Replace(MAXLINELENGTH=8192) + +for key in ("LINKCOM", "CXXLINKCOM", "SHLINKCOM", "SHCXXLINKCOM"): + wrap_with_tempfile(key) diff --git a/platformio.ini b/platformio.ini index c306eed7ba4..fd25bfa59ca 100644 --- a/platformio.ini +++ b/platformio.ini @@ -2,7 +2,7 @@ ; https://docs.platformio.org/page/projectconf.html [platformio] -default_envs = tbeam +default_envs = heltec-v3 extra_configs = variants/*/*.ini diff --git a/src/DebugConfiguration.h b/src/DebugConfiguration.h index a78dde78e31..f8ab28a5e2c 100644 --- a/src/DebugConfiguration.h +++ b/src/DebugConfiguration.h @@ -158,9 +158,8 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...); #include #endif // HAS_ETHERNET -#if HAS_ETHERNET && defined(USE_WS5500) -#include -#define ETH ETH2 +#if HAS_ETHERNET && defined(ARCH_ESP32) +#include #endif // HAS_ETHERNET #if HAS_WIFI diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index 8fafc6c52da..a229dcbfc20 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -277,7 +277,7 @@ void fsInit() */ void setupSDCard() { -#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) && !defined(HAS_SD_MMC) concurrency::LockGuard g(spiLock); SDHandler.begin(SPI_SCK, SPI_MISO, SPI_MOSI); if (!SD.begin(SDCARD_CS, SDHandler, SD_SPI_FREQUENCY)) { diff --git a/src/Power.cpp b/src/Power.cpp index 0318dd9f3c8..43e9f1059ae 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -24,6 +24,13 @@ #include "meshUtils.h" #include "power/PowerHAL.h" #include "sleep.h" +#ifdef ARCH_ESP32 +// #include +#include +#include +#include +#include +#endif #if defined(ARCH_PORTDUINO) #include "api/WiFiServerAPI.h" @@ -63,9 +70,8 @@ #include #endif -#if HAS_ETHERNET && defined(USE_WS5500) -#include -#define ETH ETH2 +#if HAS_ETHERNET && defined(ARCH_ESP32) +#include #endif // HAS_ETHERNET #endif @@ -77,21 +83,86 @@ #if defined(BATTERY_PIN) && defined(ARCH_ESP32) #ifndef BAT_MEASURE_ADC_UNIT // ADC1 is default -static const adc1_channel_t adc_channel = ADC_CHANNEL; +static const adc_channel_t adc_channel = ADC_CHANNEL; static const adc_unit_t unit = ADC_UNIT_1; -#else // ADC2 -static const adc2_channel_t adc_channel = ADC_CHANNEL; +#else // ADC2 +static const adc_channel_t adc_channel = ADC_CHANNEL; static const adc_unit_t unit = ADC_UNIT_2; -RTC_NOINIT_ATTR uint64_t RTC_reg_b; - #endif // BAT_MEASURE_ADC_UNIT -esp_adc_cal_characteristics_t *adc_characs = (esp_adc_cal_characteristics_t *)calloc(1, sizeof(esp_adc_cal_characteristics_t)); +static adc_oneshot_unit_handle_t adc_handle = nullptr; +static adc_cali_handle_t adc_cali_handle = nullptr; +static bool adc_calibrated = false; #ifndef ADC_ATTENUATION static const adc_atten_t atten = ADC_ATTEN_DB_12; #else static const adc_atten_t atten = ADC_ATTENUATION; #endif +#ifdef ADC_BITWIDTH +static const adc_bitwidth_t adc_width = ADC_BITWIDTH; +#else +static const adc_bitwidth_t adc_width = ADC_BITWIDTH_DEFAULT; +#endif + +static int adcBitWidthToBits(adc_bitwidth_t width) +{ + switch (width) { + case ADC_BITWIDTH_9: + return 9; + case ADC_BITWIDTH_10: + return 10; + case ADC_BITWIDTH_11: + return 11; + case ADC_BITWIDTH_12: + return 12; +#ifdef ADC_BITWIDTH_13 + case ADC_BITWIDTH_13: + return 13; +#endif + default: + return 12; + } +} + +static bool initAdcCalibration() +{ +#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED + adc_cali_curve_fitting_config_t cali_config = { + .unit_id = unit, + .atten = atten, + .bitwidth = adc_width, + }; + esp_err_t ret = adc_cali_create_scheme_curve_fitting(&cali_config, &adc_cali_handle); + if (ret == ESP_OK) { + LOG_INFO("ADC calibration: curve fitting enabled"); + return true; + } + if (ret != ESP_ERR_NOT_SUPPORTED) { + LOG_WARN("ADC calibration: curve fitting failed: %s", esp_err_to_name(ret)); + } +#endif + +#if ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED + adc_cali_line_fitting_config_t cali_config = { + .unit_id = unit, + .atten = atten, + .bitwidth = adc_width, + .default_vref = DEFAULT_VREF, + }; + esp_err_t ret = adc_cali_create_scheme_line_fitting(&cali_config, &adc_cali_handle); + if (ret == ESP_OK) { + LOG_INFO("ADC calibration: line fitting enabled"); + return true; + } + if (ret != ESP_ERR_NOT_SUPPORTED) { + LOG_WARN("ADC calibration: line fitting failed: %s", esp_err_to_name(ret)); + } +#endif + + LOG_INFO("ADC calibration not supported; using approximate scaling"); + return false; +} + #endif // BATTERY_PIN && ARCH_ESP32 #ifdef EXT_PWR_DETECT @@ -367,8 +438,20 @@ class AnalogBatteryLevel : public HasBatteryLevel scaled *= operativeAdcMultiplier; #elif defined(ARCH_ESP32) // ADC block for espressif platforms raw = espAdcRead(); - scaled = esp_adc_cal_raw_to_voltage(raw, adc_characs); - scaled *= operativeAdcMultiplier; + int voltage_mv = 0; + if (adc_calibrated && adc_cali_handle) { + if (adc_cali_raw_to_voltage(adc_cali_handle, raw, &voltage_mv) != ESP_OK) { + LOG_WARN("ADC calibration read failed; using raw value"); + voltage_mv = 0; + } + } + if (voltage_mv == 0) { + // Fallback approximate conversion without calibration + const int bits = adcBitWidthToBits(adc_width); + const float max_code = powf(2.0f, bits) - 1.0f; + voltage_mv = (int)((raw / max_code) * DEFAULT_VREF); + } + scaled = voltage_mv * operativeAdcMultiplier; #else // block for all other platforms #ifdef ARCH_NRF52 concurrency::LockGuard saadcGuard(concurrency::nrf52SaadcLock); @@ -410,51 +493,22 @@ class AnalogBatteryLevel : public HasBatteryLevel uint32_t raw = 0; uint8_t raw_c = 0; // raw reading counter -#ifndef BAT_MEASURE_ADC_UNIT // ADC1 - for (int i = 0; i < BATTERY_SENSE_SAMPLES; i++) { - int val_ = adc1_get_raw(adc_channel); - if (val_ >= 0) { // save only valid readings - raw += val_; - raw_c++; - } - // delayMicroseconds(100); + if (!adc_handle) { + LOG_ERROR("ADC oneshot handle not initialized"); + return 0; } -#else // ADC2 -#ifdef CONFIG_IDF_TARGET_ESP32S3 // ESP32S3 - // ADC2 wifi bug workaround not required, breaks compile - // On ESP32S3, ADC2 can take turns with Wifi (?) - - int32_t adc_buf; - esp_err_t read_result; - // Multiple samples for (int i = 0; i < BATTERY_SENSE_SAMPLES; i++) { - adc_buf = 0; - read_result = -1; - - read_result = adc2_get_raw(adc_channel, ADC_WIDTH_BIT_12, &adc_buf); - if (read_result == ESP_OK) { - raw += adc_buf; - raw_c++; // Count valid samples + int val = 0; + esp_err_t err = adc_oneshot_read(adc_handle, adc_channel, &val); + if (err == ESP_OK) { + raw += val; + raw_c++; } else { - LOG_DEBUG("An attempt to sample ADC2 failed"); + LOG_DEBUG("ADC read failed: %s", esp_err_to_name(err)); } } -#else // Other ESP32 - int32_t adc_buf = 0; - for (int i = 0; i < BATTERY_SENSE_SAMPLES; i++) { - // ADC2 wifi bug workaround, see - // https://github.com/espressif/arduino-esp32/issues/102 - WRITE_PERI_REG(SENS_SAR_READ_CTRL2_REG, RTC_reg_b); - SET_PERI_REG_MASK(SENS_SAR_READ_CTRL2_REG, SENS_SAR2_DATA_INV); - adc2_get_raw(adc_channel, ADC_WIDTH_BIT_12, &adc_buf); - raw += adc_buf; - raw_c++; - } -#endif // BAT_MEASURE_ADC_UNIT - -#endif // End BAT_MEASURE_ADC_UNIT return (raw / (raw_c < 1 ? 1 : raw_c)); } #endif @@ -666,42 +720,31 @@ bool Power::analogInit() #ifdef ARCH_STM32WL analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS); #elif defined(ARCH_ESP32) // ESP32 needs special analog stuff - -#ifndef ADC_WIDTH // max resolution by default - static const adc_bits_width_t width = ADC_WIDTH_BIT_12; -#else - static const adc_bits_width_t width = ADC_WIDTH; -#endif -#ifndef BAT_MEASURE_ADC_UNIT // ADC1 - adc1_config_width(width); - adc1_config_channel_atten(adc_channel, atten); -#else // ADC2 - adc2_config_channel_atten(adc_channel, atten); -#ifndef CONFIG_IDF_TARGET_ESP32S3 - // ADC2 wifi bug workaround - // Not required with ESP32S3, breaks compile - RTC_reg_b = READ_PERI_REG(SENS_SAR_READ_CTRL2_REG); -#endif -#endif - // calibrate ADC - esp_adc_cal_value_t val_type = esp_adc_cal_characterize(unit, atten, width, DEFAULT_VREF, adc_characs); - // show ADC characterization base - if (val_type == ESP_ADC_CAL_VAL_EFUSE_TP) { - LOG_INFO("ADC config based on Two Point values stored in eFuse"); - } else if (val_type == ESP_ADC_CAL_VAL_EFUSE_VREF) { - LOG_INFO("ADC config based on reference voltage stored in eFuse"); - } -#ifdef CONFIG_IDF_TARGET_ESP32S3 - // ESP32S3 - else if (val_type == ESP_ADC_CAL_VAL_EFUSE_TP_FIT) { - LOG_INFO("ADC config based on Two Point values and fitting curve " - "coefficients stored in eFuse"); + adc_oneshot_unit_init_cfg_t init_config = { + .unit_id = unit, + }; + + if (!adc_handle) { + esp_err_t err = adc_oneshot_new_unit(&init_config, &adc_handle); + if (err != ESP_OK) { + LOG_ERROR("ADC oneshot init failed: %s", esp_err_to_name(err)); + return false; + } } -#endif - else { - LOG_INFO("ADC config based on default reference voltage"); + + adc_oneshot_chan_cfg_t chan_cfg = { + .atten = atten, + .bitwidth = adc_width, + }; + + esp_err_t err = adc_oneshot_config_channel(adc_handle, adc_channel, &chan_cfg); + if (err != ESP_OK) { + LOG_ERROR("ADC channel config failed: %s", esp_err_to_name(err)); + return false; } -#endif // ARCH_ESP32 + + adc_calibrated = initAdcCalibration(); +#endif // ARCH_ESP32 // NRF52 ADC init moved to powerHAL_init in nrf52 platform diff --git a/src/configuration.h b/src/configuration.h index d263d9ae1c6..a31198346d7 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -561,5 +561,9 @@ along with this program. If not, see . #define HAS_SCREEN 0 #endif +#ifndef USE_ETHERNET_DEFAULT +#define USE_ETHERNET_DEFAULT 0 +#endif + #include "DebugConfiguration.h" #include "RF95Configuration.h" diff --git a/src/graphics/niche/InkHUD/MessageStore.cpp b/src/graphics/niche/InkHUD/MessageStore.cpp index 44a1ef63310..671f380573e 100644 --- a/src/graphics/niche/InkHUD/MessageStore.cpp +++ b/src/graphics/niche/InkHUD/MessageStore.cpp @@ -53,10 +53,11 @@ void InkHUD::MessageStore::saveToFlash() f.write(reinterpret_cast(&m.timestamp), sizeof(m.timestamp)); // Write timestamp. 4 bytes f.write(reinterpret_cast(&m.sender), sizeof(m.sender)); // Write sender NodeId. 4 Bytes f.write(reinterpret_cast(&m.channelIndex), sizeof(m.channelIndex)); // Write channel index. 1 Byte - f.write(reinterpret_cast(m.text.c_str()), min(MAX_MESSAGE_SIZE, m.text.size())); // Write message text - f.write('\0'); // Append null term - LOG_DEBUG("Wrote message %u, length %u, text \"%s\"", static_cast(i), min(MAX_MESSAGE_SIZE, m.text.size()), - m.text.c_str()); + f.write(reinterpret_cast(m.text.c_str()), + min((size_t)MAX_MESSAGE_SIZE, m.text.size())); // Write message text + f.write('\0'); // Append null term + LOG_DEBUG("Wrote message %u, length %u, text \"%s\"", static_cast(i), + min((size_t)MAX_MESSAGE_SIZE, m.text.size()), m.text.c_str()); } // Release firmware's SPI lock, because SafeFile::close needs it diff --git a/src/input/ExpressLRSFiveWay.h b/src/input/ExpressLRSFiveWay.h index 7c7f210f845..e980317101d 100644 --- a/src/input/ExpressLRSFiveWay.h +++ b/src/input/ExpressLRSFiveWay.h @@ -13,6 +13,8 @@ #ifdef INPUTBROKER_EXPRESSLRSFIVEWAY_TYPE +// REVISIT esp_adc_cal.h +// "legacy adc calibration driver is deprecated, please migrate to use esp_adc/adc_cali.h and esp_adc/adc_cali_scheme.h" #include #include diff --git a/src/input/TLoraPagerKeyboard.cpp b/src/input/TLoraPagerKeyboard.cpp index 4efa5d6e250..3b7e45d1884 100644 --- a/src/input/TLoraPagerKeyboard.cpp +++ b/src/input/TLoraPagerKeyboard.cpp @@ -68,7 +68,7 @@ TLoraPagerKeyboard::TLoraPagerKeyboard() : TCA8418KeyboardBase(_TCA8418_ROWS, _TCA8418_COLS), modifierFlag(0), last_modifier_time(0), last_key(UINT8_MAX), next_key(UINT8_MAX), last_tap(0L), char_idx(0), tap_interval(0) { -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) +#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0) ledcAttach(KB_BL_PIN, LEDC_BACKLIGHT_FREQ, LEDC_BACKLIGHT_BIT_WIDTH); #else ledcSetup(LEDC_BACKLIGHT_CHANNEL, LEDC_BACKLIGHT_FREQ, LEDC_BACKLIGHT_BIT_WIDTH); @@ -108,7 +108,7 @@ void TLoraPagerKeyboard::setBacklight(bool on) uint32_t _brightness = 0; if (on) _brightness = brightness; -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) +#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0) ledcWrite(KB_BL_PIN, _brightness); #else ledcWrite(LEDC_BACKLIGHT_CHANNEL, _brightness); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 5b6a6e980e8..9a8593bc45c 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -985,6 +985,10 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.network.wifi_enabled = USERPREFS_NETWORK_WIFI_ENABLED; #endif +#if USE_ETHERNET_DEFAULT + config.network.eth_enabled = true; +#endif + #ifdef USERPREFS_NETWORK_WIFI_SSID strncpy(config.network.wifi_ssid, USERPREFS_NETWORK_WIFI_SSID, sizeof(config.network.wifi_ssid)); #endif diff --git a/src/mesh/api/WiFiServerAPI.h b/src/mesh/api/WiFiServerAPI.h index 5f20199830b..4d754e7ed64 100644 --- a/src/mesh/api/WiFiServerAPI.h +++ b/src/mesh/api/WiFiServerAPI.h @@ -3,9 +3,8 @@ #include "ServerAPI.h" #include -#if HAS_ETHERNET && defined(USE_WS5500) -#include -#define ETH ETH2 +#if HAS_ETHERNET && defined(ARCH_ESP32) +#include #endif // HAS_ETHERNET /** diff --git a/src/mesh/http/WebServer.cpp b/src/mesh/http/WebServer.cpp index 90fd8b0841b..fddc118a777 100644 --- a/src/mesh/http/WebServer.cpp +++ b/src/mesh/http/WebServer.cpp @@ -13,9 +13,8 @@ #include #include -#if HAS_ETHERNET && defined(USE_WS5500) -#include -#define ETH ETH2 +#if HAS_ETHERNET && defined(ARCH_ESP32) +#include #endif // HAS_ETHERNET #ifdef ARCH_ESP32 diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index 493cc5353e3..eb4d90d40ea 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -12,9 +12,8 @@ #include -#if HAS_ETHERNET && defined(USE_WS5500) -#include -#define ETH ETH2 +#if HAS_ETHERNET && defined(ARCH_ESP32) +#include #endif // HAS_ETHERNET #define UDP_MULTICAST_DEFAUL_PORT 4403 // Default port for UDP multicast is same as TCP api server diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index 9930d0a55d7..82201ab8249 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -10,9 +10,8 @@ #include "target_specific.h" #include -#if HAS_ETHERNET && defined(USE_WS5500) -#include -#define ETH ETH2 +#if HAS_ETHERNET && defined(ARCH_ESP32) +#include #endif // HAS_ETHERNET #if HAS_ETHERNET && defined(USE_CH390D) diff --git a/src/mesh/wifi/WiFiAPClient.h b/src/mesh/wifi/WiFiAPClient.h index 1de897d7a58..71086836176 100644 --- a/src/mesh/wifi/WiFiAPClient.h +++ b/src/mesh/wifi/WiFiAPClient.h @@ -9,9 +9,8 @@ #include #endif -#if HAS_ETHERNET && defined(USE_WS5500) -#include -#define ETH ETH2 +#if HAS_ETHERNET && defined(ARCH_ESP32) +#include #endif // HAS_ETHERNET extern bool needReconnect; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index e7acdf89e4a..1d05caaa2e1 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -262,7 +262,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } case meshtastic_AdminMessage_ota_request_tag: { -#if defined(ARCH_ESP32) +#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI LOG_INFO("OTA Requested"); if (r->ota_request.ota_hash.size != 32) { diff --git a/src/modules/esp32/PaxcounterModule.cpp b/src/modules/esp32/PaxcounterModule.cpp index 9c25177bc26..4bee4fb7e7a 100644 --- a/src/modules/esp32/PaxcounterModule.cpp +++ b/src/modules/esp32/PaxcounterModule.cpp @@ -90,8 +90,8 @@ int32_t PaxcounterModule::runOnce() configuration.blecounter = 1; configuration.blescantime = 0; // infinite configuration.wificounter = 1; - configuration.wifi_channel_map = WIFI_CHANNEL_ALL; - configuration.wifi_channel_switch_interval = 50; + // configuration.wifi_channel_map = WIFI_CHANNEL_ALL; + // configuration.wifi_channel_switch_interval = 50; configuration.wifi_rssi_threshold = Default::getConfiguredOrDefault(moduleConfig.paxcounter.wifi_threshold, -80); configuration.ble_rssi_threshold = Default::getConfiguredOrDefault(moduleConfig.paxcounter.ble_threshold, -80); libpax_update_config(&configuration); diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index a6ae79614e4..46ad952f40c 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -19,12 +19,8 @@ #include "mesh/wifi/WiFiAPClient.h" #include #endif -#if HAS_ETHERNET && defined(USE_WS5500) -#include -#define ETH ETH2 -#elif HAS_ETHERNET && defined(USE_CH390D) -#include "ESP32_CH390.h" -#define ETH CH390 +#if HAS_ETHERNET && defined(ARCH_ESP32) +#include #endif // HAS_ETHERNET #include "Default.h" #if !defined(ARCH_NRF52) || NRF52_USE_JSON diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index ac52a99bc4c..6857f9b5d3c 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -10,23 +10,18 @@ #include "mesh/PhoneAPI.h" #include "mesh/mesh-pb-constants.h" #include "sleep.h" -#include +#include +#include +#include +#include +#include #include #include -#ifdef NIMBLE_TWO -#include "NimBLEAdvertising.h" -#include "NimBLEExtAdvertising.h" #include "PowerStatus.h" -#endif -#if defined(CONFIG_NIMBLE_CPP_IDF) #include "host/ble_gap.h" -#else -#include "nimble/nimble/host/include/host/ble_gap.h" -#endif - -#if defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) +#include "host/ble_store.h" namespace { @@ -34,7 +29,6 @@ constexpr uint16_t kPreferredBleMtu = 517; constexpr uint16_t kPreferredBleTxOctets = 251; constexpr uint16_t kPreferredBleTxTimeUs = (kPreferredBleTxOctets + 14) * 8; } // namespace -#endif // Debugging options: careful, they slow things down quite a bit! // #define DEBUG_NIMBLE_ON_READ_TIMING // uncomment to time onRead duration @@ -44,10 +38,10 @@ constexpr uint16_t kPreferredBleTxTimeUs = (kPreferredBleTxOctets + 14) * 8; #define NIMBLE_BLUETOOTH_TO_PHONE_QUEUE_SIZE 3 #define NIMBLE_BLUETOOTH_FROM_PHONE_QUEUE_SIZE 3 -NimBLECharacteristic *fromNumCharacteristic; -NimBLECharacteristic *BatteryCharacteristic; -NimBLECharacteristic *logRadioCharacteristic; -NimBLEServer *bleServer; +BLECharacteristic *fromNumCharacteristic; +BLECharacteristic *BatteryCharacteristic; +BLECharacteristic *logRadioCharacteristic; +BLEServer *bleServer; static bool passkeyShowing; static std::atomic nimbleBluetoothConnHandle{BLE_HS_CONN_HANDLE_NONE}; // BLE_HS_CONN_HANDLE_NONE means "no connection" @@ -118,7 +112,8 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread - Yes, we have to do some copy operations on pop because of this, but it's worth it to avoid cross-task memory management. NOTIFY IS BROKEN: - - Adding NIMBLE_PROPERTY::NOTIFY to FromRadioCharacteristic appears to break things. It is NOT backwards compatible. + - Adding BLECharacteristic::PROPERTY_NOTIFY to FromRadioCharacteristic appears to break things. It is NOT backwards + compatible. ZERO-SIZE READS: - Returning a zero-size read from onRead breaks some clients during the config phase. So we have to block onRead until we @@ -139,7 +134,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread std::mutex fromPhoneMutex; std::atomic fromPhoneQueueSize{0}; // We use array here (and pay the cost of memcpy) to avoid dynamic memory allocations and frees across FreeRTOS tasks. - std::array fromPhoneQueue{}; + std::array fromPhoneQueue{}; /* Packets to phone (BLE onRead callback) */ std::mutex toPhoneMutex; @@ -301,7 +296,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread LOG_DEBUG("NimbleBluetooth: handling ToRadio packet, fromPhoneQueueSize=%u", fromPhoneQueueSize.load()); // Pop the front of fromPhoneQueue, holding the mutex only briefly while we pop. - NimBLEAttValue val; + BLEValue val; { // scope for fromPhoneMutex mutex std::lock_guard guard(fromPhoneMutex); val = fromPhoneQueue[0]; @@ -316,7 +311,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread fromPhoneQueueSize--; } - handleToRadio(val.data(), val.length()); + handleToRadio(val.getData(), val.getLength()); } } @@ -328,9 +323,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread PhoneAPI::onNowHasData(fromRadioNum); #ifdef DEBUG_NIMBLE_NOTIFY - int currentNotifyCount = notifyCount.fetch_add(1); - uint8_t cc = bleServer->getConnectedCount(); // This logging slows things down when there are lots of packets going to the phone, like initial connection: LOG_DEBUG("BLE notify(%d) fromNum: %d connections: %d", currentNotifyCount, fromRadioNum, cc); @@ -340,13 +333,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread put_le32(val, fromRadioNum); fromNumCharacteristic->setValue(val, sizeof(val)); -#ifdef NIMBLE_TWO - // NOTE: I don't have any NIMBLE_TWO devices, but this line makes me suspicious, and I suspect it needs to just be - // notify(). - fromNumCharacteristic->notify(val, sizeof(val), BLE_HS_CONN_HANDLE_NONE); -#else fromNumCharacteristic->notify(); -#endif } /// Check the current underlying physical link to see if the client is currently connected @@ -409,14 +396,9 @@ static BluetoothPhoneAPI *bluetoothPhoneAPI; // Last ToRadio value received from the phone static uint8_t lastToRadio[MAX_TO_FROM_RADIO_SIZE]; -class NimbleBluetoothToRadioCallback : public NimBLECharacteristicCallbacks +class NimbleBluetoothToRadioCallback : public BLECharacteristicCallbacks { -#ifdef NIMBLE_TWO - virtual void onWrite(NimBLECharacteristic *pCharacteristic, NimBLEConnInfo &connInfo) override -#else - virtual void onWrite(NimBLECharacteristic *pCharacteristic) override - -#endif + void onWrite(BLECharacteristic *pCharacteristic) override { // CAUTION: This callback runs in the NimBLE task!!! Don't do anything except communicate with the main task's runOnce. // Assumption: onWrite is serialized by NimBLE, so we don't need to lock here against multiple concurrent onWrite calls. @@ -428,15 +410,17 @@ class NimbleBluetoothToRadioCallback : public NimBLECharacteristicCallbacks LOG_DEBUG("BLE onWrite(%d): start millis=%d", currentWriteCount, startMillis); #endif - auto val = pCharacteristic->getValue(); + // Create a BLEValue and populate it with the received data + BLEValue val; + val.setValue(pCharacteristic->getData(), pCharacteristic->getLength()); - if (memcmp(lastToRadio, val.data(), val.length()) != 0) { + if (memcmp(lastToRadio, val.getData(), val.getLength()) != 0) { if (bluetoothPhoneAPI->fromPhoneQueueSize < NIMBLE_BLUETOOTH_FROM_PHONE_QUEUE_SIZE) { // Note: the comparison above is safe without a mutex because we are the only method that *increases* // fromPhoneQueueSize. (It's okay if fromPhoneQueueSize *decreases* in the main task meanwhile.) - memcpy(lastToRadio, val.data(), val.length()); + memcpy(lastToRadio, val.getData(), val.getLength()); - { // scope for fromPhoneMutex mutex + { // scope for fromPhoneMutex mutexv, pCharacteristic->getLen // Append to fromPhoneQueue, protected by fromPhoneMutex. Hold the mutex as briefly as possible. std::lock_guard guard(bluetoothPhoneAPI->fromPhoneMutex); bluetoothPhoneAPI->fromPhoneQueue.at(bluetoothPhoneAPI->fromPhoneQueueSize) = val; @@ -450,24 +434,21 @@ class NimbleBluetoothToRadioCallback : public NimBLECharacteristicCallbacks #ifdef DEBUG_NIMBLE_ON_WRITE_TIMING int finishMillis = millis(); LOG_DEBUG("BLE onWrite(%d): append to fromPhoneQueue took %u ms. numBytes=%d", currentWriteCount, - finishMillis - startMillis, val.length()); + finishMillis - startMillis, val.getLength()); #endif } else { - LOG_WARN("BLE onWrite(%d): Drop ToRadio packet, fromPhoneQueue full (%u bytes)", currentWriteCount, val.length()); + LOG_WARN("BLE onWrite(%d): Drop ToRadio packet, fromPhoneQueue full (%u bytes)", currentWriteCount, + val.getLength()); } } else { - LOG_DEBUG("BLE onWrite(%d): Drop duplicate ToRadio packet (%u bytes)", currentWriteCount, val.length()); + LOG_DEBUG("BLE onWrite(%d): Drop duplicate ToRadio packet (%u bytes)", currentWriteCount, val.getLength()); } } }; -class NimbleBluetoothFromRadioCallback : public NimBLECharacteristicCallbacks +class NimbleBluetoothFromRadioCallback : public BLECharacteristicCallbacks { -#ifdef NIMBLE_TWO - virtual void onRead(NimBLECharacteristic *pCharacteristic, NimBLEConnInfo &connInfo) override -#else - virtual void onRead(NimBLECharacteristic *pCharacteristic) override -#endif + void onRead(BLECharacteristic *pCharacteristic) override { // CAUTION: This callback runs in the NimBLE task!!! Don't do anything except communicate with the main task's runOnce. @@ -573,51 +554,46 @@ class NimbleBluetoothFromRadioCallback : public NimBLECharacteristicCallbacks } }; -class NimbleBluetoothServerCallback : public NimBLEServerCallbacks +class NimbleBluetoothSecurityCallback : public BLESecurityCallbacks { -#ifdef NIMBLE_TWO - public: - NimbleBluetoothServerCallback(NimbleBluetooth *ble) { this->ble = ble; } - - private: - NimbleBluetooth *ble; - - virtual uint32_t onPassKeyDisplay() override -#else - virtual uint32_t onPassKeyRequest() override -#endif + void onPassKeyNotify(uint32_t passkey) override { - uint32_t configuredPasskey = config.bluetooth.fixed_pin; - - if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN) { - LOG_INFO("Use random passkey"); - // This is the passkey to be entered on peer - we pick a number >100,000 to ensure 6 digits - configuredPasskey = random(100000, 999999); - } - LOG_INFO("*** Enter passkey %d on the peer side ***", configuredPasskey); - + LOG_INFO("*** Enter passkey %06u on the peer side ***", passkey); powerFSM.trigger(EVENT_BLUETOOTH_PAIR); - std::string passkey = std::to_string(configuredPasskey); - meshtastic::BluetoothStatus newStatus(passkey); + meshtastic::BluetoothStatus newStatus(std::to_string(passkey)); bluetoothStatus->updateStatus(&newStatus); - -#if HAS_SCREEN // Todo: migrate this display code back into Screen class, and observe bluetoothStatus +#if HAS_SCREEN if (screen) { - - std::string ble_message = "Bluetooth\nPIN\n[M]" + passkey.substr(0, 3) + " " + passkey.substr(3, 6); - screen->showSimpleBanner(ble_message.c_str(), 30000); + screen->startAlert([passkey](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { + char btPIN[16] = "888888"; + snprintf(btPIN, sizeof(btPIN), "%06u", passkey); + int x_offset = display->width() / 2; + int y_offset = display->height() <= 80 ? 0 : 12; + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setFont(FONT_MEDIUM); + display->drawString(x_offset + x, y_offset + y, "Bluetooth"); +#if !defined(M5STACK_UNITC6L) + display->setFont(FONT_SMALL); + y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_MEDIUM - 4 : y_offset + FONT_HEIGHT_MEDIUM + 5; + display->drawString(x_offset + x, y_offset + y, "Enter this code"); +#endif + display->setFont(FONT_LARGE); + char pin[8]; + snprintf(pin, sizeof(pin), "%.3s %.3s", btPIN, btPIN + 3); + y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_SMALL - 5 : y_offset + FONT_HEIGHT_SMALL + 5; + display->drawString(x_offset + x, y_offset + y, pin); + + display->setFont(FONT_SMALL); + char deviceName[64]; + snprintf(deviceName, sizeof(deviceName), "Name: %s", getDeviceName()); + y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_LARGE - 6 : y_offset + FONT_HEIGHT_LARGE + 5; + display->drawString(x_offset + x, y_offset + y, deviceName); + }); } #endif passkeyShowing = true; - - return configuredPasskey; } - -#ifdef NIMBLE_TWO - virtual void onAuthenticationComplete(NimBLEConnInfo &connInfo) override -#else - virtual void onAuthenticationComplete(ble_gap_conn_desc *desc) override -#endif + void onAuthenticationComplete(ble_gap_conn_desc *desc) override { LOG_INFO("BLE authentication complete"); @@ -625,58 +601,47 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks bluetoothStatus->updateStatus(&newStatus); clearPairingDisplay(); - // Store the connection handle for future use -#ifdef NIMBLE_TWO - nimbleBluetoothConnHandle = connInfo.getConnHandle(); -#else nimbleBluetoothConnHandle = desc->conn_handle; -#endif } +}; + +class NimbleBluetoothServerCallback : public BLEServerCallbacks +{ + public: + explicit NimbleBluetoothServerCallback(NimbleBluetooth *ble) : ble(ble) {} + + private: + NimbleBluetooth *ble; -#ifdef NIMBLE_TWO - virtual void onConnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo) override + void onConnect(BLEServer *pServer, struct ble_gap_conn_desc *desc) { - LOG_INFO("BLE incoming connection %s", connInfo.getAddress().toString().c_str()); - - const uint16_t connHandle = connInfo.getConnHandle(); -#if NIMBLE_ENABLE_2M_PHY && (defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6)) - int phyResult = - ble_gap_set_prefered_le_phy(connHandle, BLE_GAP_LE_PHY_2M_MASK, BLE_GAP_LE_PHY_2M_MASK, BLE_GAP_LE_PHY_CODED_ANY); - if (phyResult == 0) { - LOG_INFO("BLE conn %u requested 2M PHY", connHandle); - } else { - LOG_WARN("Failed to prefer 2M PHY for conn %u, rc=%d", connHandle, phyResult); - } -#endif + BLEAddress peer_addr(desc->peer_id_addr); + LOG_INFO("BLE incoming connection %s", peer_addr.toString().c_str()); + const uint16_t connHandle = desc->conn_handle; + + // With Google Pixel 8 Android devices, this causes ESP32 device crash + // when phone reconnects. Disable this to make progress on the + // Arduino v3 migration while we investigate the Android compatibility + // issue. +#if 0 int dataLenResult = ble_gap_set_data_len(connHandle, kPreferredBleTxOctets, kPreferredBleTxTimeUs); if (dataLenResult == 0) { LOG_INFO("BLE conn %u requested data length %u bytes", connHandle, kPreferredBleTxOctets); } else { LOG_WARN("Failed to raise data length for conn %u, rc=%d", connHandle, dataLenResult); } +#endif - LOG_INFO("BLE conn %u initial MTU %u (target %u)", connHandle, connInfo.getMTU(), kPreferredBleMtu); + LOG_INFO("BLE conn %u peer MTU %u (target %u)", connHandle, pServer->getPeerMTU(connHandle), kPreferredBleMtu); pServer->updateConnParams(connHandle, 6, 12, 0, 200); } -#endif -#ifdef NIMBLE_TWO - virtual void onDisconnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo, int reason) override + void onDisconnect(BLEServer *pServer, struct ble_gap_conn_desc *desc) { - LOG_INFO("BLE disconnect reason: %d", reason); -#else - virtual void onDisconnect(NimBLEServer *pServer, ble_gap_conn_desc *desc) override - { - LOG_INFO("BLE disconnect"); -#endif -#ifdef NIMBLE_TWO + LOG_INFO("BLE disconnected"); if (ble->isDeInit) return; -#else - if (nimbleBluetooth && nimbleBluetooth->isDeInit) - return; -#endif meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); bluetoothStatus->updateStatus(&newStatus); @@ -701,43 +666,51 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks bluetoothPhoneAPI->writeCount = 0; } - // Clear the last ToRadio packet buffer to avoid rejecting first packet from new connection memset(lastToRadio, 0, sizeof(lastToRadio)); - nimbleBluetoothConnHandle = BLE_HS_CONN_HANDLE_NONE; // BLE_HS_CONN_HANDLE_NONE means "no connection" + nimbleBluetoothConnHandle = BLE_HS_CONN_HANDLE_NONE; -#ifdef NIMBLE_TWO - // Restart Advertising ble->startAdvertising(); -#else - NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); - if (!pAdvertising->start(0)) { - if (pAdvertising->isAdvertising()) { - LOG_DEBUG("BLE advertising already running"); - } else { - LOG_ERROR("BLE failed to restart advertising"); - } - } -#endif } }; static NimbleBluetoothToRadioCallback *toRadioCallbacks; static NimbleBluetoothFromRadioCallback *fromRadioCallbacks; +void NimbleBluetooth::startAdvertising() +{ + BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); + pAdvertising->stop(); + pAdvertising->reset(); + + pAdvertising->addServiceUUID(MESH_SERVICE_UUID); + // if (powerStatus->getHasBattery() == 1) { + // pAdvertising->addServiceUUID(BLEUUID((uint16_t)0x180f)); + // } + + BLEAdvertisementData scan = BLEAdvertisementData(); + scan.setName(getDeviceName()); + pAdvertising->setScanResponseData(scan); + pAdvertising->setMinPreferred(0x06); // functions that help with iPhone connections issue + pAdvertising->setMaxPreferred(0x12); + + if (!pAdvertising->start(0)) { + LOG_ERROR("BLE failed to start advertising"); + } else { + LOG_DEBUG("BLE Advertising started"); + } +} + void NimbleBluetooth::shutdown() { - // No measurable power saving for ESP32 during light-sleep(?) #ifndef ARCH_ESP32 - // Shutdown bluetooth for minimum power draw LOG_INFO("Disable bluetooth"); - NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); + BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); pAdvertising->reset(); pAdvertising->stop(); #endif } -// Proper shutdown for ESP32. Needs reboot to reverse. void NimbleBluetooth::deinit() { #ifdef ARCH_ESP32 @@ -747,21 +720,19 @@ void NimbleBluetooth::deinit() #ifdef BLE_LED digitalWrite(BLE_LED, LED_STATE_OFF); #endif -#ifndef NIMBLE_TWO - NimBLEDevice::deinit(); -#endif + + BLEDevice::deinit(true); #endif } -// Has initial setup been completed bool NimbleBluetooth::isActive() { - return bleServer; + return bleServer != nullptr; } bool NimbleBluetooth::isConnected() { - return bleServer->getConnectedCount() > 0; + return bleServer && bleServer->getConnectedCount() > 0; } int NimbleBluetooth::getRssi() @@ -774,9 +745,9 @@ int NimbleBluetooth::getRssi() uint16_t connHandle = nimbleBluetoothConnHandle.load(); if (connHandle == BLE_HS_CONN_HANDLE_NONE) { - const auto peers = bleServer->getPeerDevices(); + const auto peers = bleServer->getPeerDevices(true); if (!peers.empty()) { - connHandle = peers.front(); + connHandle = peers.begin()->first; nimbleBluetoothConnHandle = connHandle; } } @@ -804,74 +775,84 @@ void NimbleBluetooth::setup() LOG_INFO("Init the NimBLE bluetooth module"); - NimBLEDevice::init(getDeviceName()); - NimBLEDevice::setPower(ESP_PWR_LVL_P9); + BLEDevice::init(getDeviceName()); + BLEDevice::setPower(ESP_PWR_LVL_P9); -#if NIMBLE_ENABLE_2M_PHY && (defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6)) - int mtuResult = NimBLEDevice::setMTU(kPreferredBleMtu); + int mtuResult = BLEDevice::setMTU(kPreferredBleMtu); if (mtuResult == 0) { LOG_INFO("BLE MTU request set to %u", kPreferredBleMtu); } else { LOG_WARN("Unable to request MTU %u, rc=%d", kPreferredBleMtu, mtuResult); } - int phyResult = ble_gap_set_prefered_default_le_phy(BLE_GAP_LE_PHY_2M_MASK, BLE_GAP_LE_PHY_2M_MASK); - if (phyResult == 0) { - LOG_INFO("BLE default PHY preference set to 2M"); + BLESecurity *pSecurity = new BLESecurity(); + pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); + pSecurity->setRespEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); + if (config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { + // Set IO capability to DisplayOnly for MITM authentication + pSecurity->setCapability(ESP_IO_CAP_OUT); + // Set the passkey + if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN) { + LOG_INFO("Use random passkey"); + pSecurity->setPassKey(false); // generate a random passkey + } else { + LOG_INFO("Use fixed passkey"); + pSecurity->setPassKey(true, config.bluetooth.fixed_pin); + } + // Enable authorization requirements: + // - bonding: true (for persistent storage of the keys) + // - MITM: true (enables Man-In-The-Middle protection for password prompts) + // - secure connection: true (enables secure connection for encryption) + pSecurity->setAuthenticationMode(true, true, true); } else { - LOG_WARN("Failed to prefer 2M PHY by default, rc=%d", phyResult); + // No IO capability for no PIN mode + pSecurity->setCapability(ESP_IO_CAP_NONE); + // No PIN mode: no MITM protection + pSecurity->setAuthenticationMode(true, false, false); } - - int dataLenResult = ble_gap_write_sugg_def_data_len(kPreferredBleTxOctets, kPreferredBleTxTimeUs); - if (dataLenResult == 0) { - LOG_INFO("BLE suggested data length set to %u bytes", kPreferredBleTxOctets); - } else { - LOG_WARN("Failed to raise suggested data length (%u/%u), rc=%d", kPreferredBleTxOctets, kPreferredBleTxTimeUs, - dataLenResult); + // Set the security callbacks + BLEDevice::setSecurityCallbacks(new NimbleBluetoothSecurityCallback()); + bleServer = BLEDevice::createServer(); + + // BLEDevice::createServer calls ble_svc_gap_init, which resets the device + // name to default, so set it again. + int nameRc = ble_svc_gap_device_name_set(BLEDevice::getDeviceName().c_str()); + if (nameRc != 0) { + LOG_ERROR("ble_svc_gap_device_name_set: rc=%d %s", nameRc, BLEUtils::returnCodeToString(nameRc)); } -#endif - if (config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { - NimBLEDevice::setSecurityAuth(BLE_SM_PAIR_AUTHREQ_BOND | BLE_SM_PAIR_AUTHREQ_MITM | BLE_SM_PAIR_AUTHREQ_SC); - NimBLEDevice::setSecurityInitKey(BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID); - NimBLEDevice::setSecurityRespKey(BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID); - NimBLEDevice::setSecurityIOCap(BLE_HS_IO_DISPLAY_ONLY); - } - bleServer = NimBLEDevice::createServer(); -#ifdef NIMBLE_TWO - NimbleBluetoothServerCallback *serverCallbacks = new NimbleBluetoothServerCallback(this); -#else - NimbleBluetoothServerCallback *serverCallbacks = new NimbleBluetoothServerCallback(); -#endif - bleServer->setCallbacks(serverCallbacks, true); + bleServer->setCallbacks(new NimbleBluetoothServerCallback(this)); setupService(); startAdvertising(); } void NimbleBluetooth::setupService() { - NimBLEService *bleService = bleServer->createService(MESH_SERVICE_UUID); - NimBLECharacteristic *ToRadioCharacteristic; - NimBLECharacteristic *FromRadioCharacteristic; + BLEService *bleService = bleServer->createService(MESH_SERVICE_UUID); + BLECharacteristic *ToRadioCharacteristic; + BLECharacteristic *FromRadioCharacteristic; // Define the characteristics that the app is looking for if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { - ToRadioCharacteristic = bleService->createCharacteristic(TORADIO_UUID, NIMBLE_PROPERTY::WRITE); + ToRadioCharacteristic = bleService->createCharacteristic(TORADIO_UUID, BLECharacteristic::PROPERTY_WRITE); // Allow notifications so phones can stream FromRadio without polling. - FromRadioCharacteristic = bleService->createCharacteristic(FROMRADIO_UUID, NIMBLE_PROPERTY::READ); - fromNumCharacteristic = bleService->createCharacteristic(FROMNUM_UUID, NIMBLE_PROPERTY::NOTIFY | NIMBLE_PROPERTY::READ); - logRadioCharacteristic = - bleService->createCharacteristic(LOGRADIO_UUID, NIMBLE_PROPERTY::NOTIFY | NIMBLE_PROPERTY::READ, 512U); - } else { - ToRadioCharacteristic = bleService->createCharacteristic( - TORADIO_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_AUTHEN | NIMBLE_PROPERTY::WRITE_ENC); - FromRadioCharacteristic = bleService->createCharacteristic( - FROMRADIO_UUID, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::READ_AUTHEN | NIMBLE_PROPERTY::READ_ENC); + FromRadioCharacteristic = bleService->createCharacteristic(FROMRADIO_UUID, BLECharacteristic::PROPERTY_READ); fromNumCharacteristic = - bleService->createCharacteristic(FROMNUM_UUID, NIMBLE_PROPERTY::NOTIFY | NIMBLE_PROPERTY::READ | - NIMBLE_PROPERTY::READ_AUTHEN | NIMBLE_PROPERTY::READ_ENC); + bleService->createCharacteristic(FROMNUM_UUID, BLECharacteristic::PROPERTY_NOTIFY | BLECharacteristic::PROPERTY_READ); + logRadioCharacteristic = bleService->createCharacteristic(LOGRADIO_UUID, BLECharacteristic::PROPERTY_NOTIFY | + BLECharacteristic::PROPERTY_READ); + } else { + ToRadioCharacteristic = bleService->createCharacteristic(TORADIO_UUID, BLECharacteristic::PROPERTY_WRITE | + BLECharacteristic::PROPERTY_WRITE_AUTHEN | + BLECharacteristic::PROPERTY_WRITE_ENC); + FromRadioCharacteristic = bleService->createCharacteristic(FROMRADIO_UUID, BLECharacteristic::PROPERTY_READ | + BLECharacteristic::PROPERTY_READ_AUTHEN | + BLECharacteristic::PROPERTY_READ_ENC); + fromNumCharacteristic = bleService->createCharacteristic( + FROMNUM_UUID, BLECharacteristic::PROPERTY_NOTIFY | BLECharacteristic::PROPERTY_READ | + BLECharacteristic::PROPERTY_READ_AUTHEN | BLECharacteristic::PROPERTY_READ_ENC); logRadioCharacteristic = bleService->createCharacteristic( - LOGRADIO_UUID, - NIMBLE_PROPERTY::NOTIFY | NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::READ_AUTHEN | NIMBLE_PROPERTY::READ_ENC, 512U); + LOGRADIO_UUID, BLECharacteristic::PROPERTY_NOTIFY | BLECharacteristic::PROPERTY_READ | + BLECharacteristic::PROPERTY_READ_AUTHEN | BLECharacteristic::PROPERTY_READ_ENC); } bluetoothPhoneAPI = new BluetoothPhoneAPI(); @@ -884,76 +865,31 @@ void NimbleBluetooth::setupService() bleService->start(); // Setup the battery service - NimBLEService *batteryService = bleServer->createService(NimBLEUUID((uint16_t)0x180f)); // 0x180F is the Battery Service - BatteryCharacteristic = batteryService->createCharacteristic( // 0x2A19 is the Battery Level characteristic) - (uint16_t)0x2a19, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY, 1); -#ifdef NIMBLE_TWO - NimBLE2904 *batteryLevelDescriptor = BatteryCharacteristic->create2904(); -#else - NimBLE2904 *batteryLevelDescriptor = (NimBLE2904 *)BatteryCharacteristic->createDescriptor((uint16_t)0x2904); -#endif - batteryLevelDescriptor->setFormat(NimBLE2904::FORMAT_UINT8); + BLEService *batteryService = bleServer->createService(BLEUUID((uint16_t)0x180f)); // 0x180F is the Battery Service + BLE2904 *batteryLevelDescriptor = new BLE2904(); + batteryLevelDescriptor->setFormat(BLE2904::FORMAT_UINT8); batteryLevelDescriptor->setNamespace(1); batteryLevelDescriptor->setUnit(0x27ad); - + BatteryCharacteristic = batteryService->createCharacteristic( // 0x2A19 is the Battery Level characteristic) + (uint16_t)0x2a19, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); + BatteryCharacteristic->addDescriptor(batteryLevelDescriptor); batteryService->start(); } -void NimbleBluetooth::startAdvertising() -{ -#ifdef NIMBLE_TWO - NimBLEExtAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); - NimBLEExtAdvertisement legacyAdvertising; - - legacyAdvertising.setLegacyAdvertising(true); - legacyAdvertising.setScannable(true); - legacyAdvertising.setConnectable(true); - legacyAdvertising.setFlags(BLE_HS_ADV_F_DISC_GEN); - if (powerStatus->getHasBattery() == 1) { - legacyAdvertising.setCompleteServices(NimBLEUUID((uint16_t)0x180f)); - } - legacyAdvertising.setCompleteServices(NimBLEUUID(MESH_SERVICE_UUID)); - legacyAdvertising.setMinInterval(500); - legacyAdvertising.setMaxInterval(1000); - - NimBLEExtAdvertisement legacyScanResponse; - legacyScanResponse.setLegacyAdvertising(true); - legacyScanResponse.setConnectable(true); - legacyScanResponse.setName(getDeviceName()); - - if (!pAdvertising->setInstanceData(0, legacyAdvertising)) { - LOG_ERROR("BLE failed to set legacyAdvertising"); - } else if (!pAdvertising->setScanResponseData(0, legacyScanResponse)) { - LOG_ERROR("BLE failed to set legacyScanResponse"); - } else if (!pAdvertising->start(0, 0, 0)) { - LOG_ERROR("BLE failed to start legacyAdvertising"); - } -#else - NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); - pAdvertising->reset(); - pAdvertising->addServiceUUID(MESH_SERVICE_UUID); - pAdvertising->addServiceUUID(NimBLEUUID((uint16_t)0x180f)); // 0x180F is the Battery Service - pAdvertising->start(0); -#endif -} - /// Given a level between 0-100, update the BLE attribute void updateBatteryLevel(uint8_t level) { if ((config.bluetooth.enabled == true) && bleServer && nimbleBluetooth->isConnected()) { BatteryCharacteristic->setValue(&level, 1); -#ifdef NIMBLE_TWO - BatteryCharacteristic->notify(&level, 1, BLE_HS_CONN_HANDLE_NONE); -#else BatteryCharacteristic->notify(); -#endif } } void NimbleBluetooth::clearBonds() { LOG_INFO("Clearing bluetooth bonds!"); - NimBLEDevice::deleteAllBonds(); + ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_PEER_SEC, nullptr); + ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_CCCD, nullptr); } void NimbleBluetooth::sendLog(const uint8_t *logMessage, size_t length) @@ -961,11 +897,16 @@ void NimbleBluetooth::sendLog(const uint8_t *logMessage, size_t length) if (!bleServer || !isConnected() || length > 512) { return; } -#ifdef NIMBLE_TWO - logRadioCharacteristic->notify(logMessage, length, BLE_HS_CONN_HANDLE_NONE); -#else - logRadioCharacteristic->notify(logMessage, length, true); -#endif + logRadioCharacteristic->setValue(logMessage, length); + logRadioCharacteristic->notify(); } +void clearNVS() +{ + ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_PEER_SEC, nullptr); + ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_CCCD, nullptr); +#ifdef ARCH_ESP32 + ESP.restart(); +#endif +} #endif diff --git a/src/nimble/NimbleBluetooth.h b/src/nimble/NimbleBluetooth.h index a7b14ff7322..dad0a8c9853 100644 --- a/src/nimble/NimbleBluetooth.h +++ b/src/nimble/NimbleBluetooth.h @@ -12,16 +12,11 @@ class NimbleBluetooth : BluetoothApi bool isConnected(); int getRssi(); void sendLog(const uint8_t *logMessage, size_t length); -#if defined(NIMBLE_TWO) void startAdvertising(); -#endif bool isDeInit = false; private: void setupService(); -#if !defined(NIMBLE_TWO) - void startAdvertising(); -#endif }; void setBluetoothEnable(bool enable); \ No newline at end of file diff --git a/src/platform/esp32/IramMemcpy.c b/src/platform/esp32/IramMemcpy.c new file mode 100644 index 00000000000..898f1bdc614 --- /dev/null +++ b/src/platform/esp32/IramMemcpy.c @@ -0,0 +1,42 @@ +#include +#include +#include + +#include "esp_attr.h" + +#ifdef ESP32_FORCE_IRAM_MEMSET + +/* + * T-Beam/classic ESP32 boot workaround + * ------------------------------------ + * During early flash operations the ESP32 disables cache, but some IRAM flash + * code paths still reach libc memcpy/memset. If those resolve to flash-resident + * implementations, startup can panic with cache-disabled access errors. + * + * We wrap memcpy/memset for the T-Beam environment. Fast path uses the + * normal libc routines when cache is enabled; slow path uses IRAM-safe byte + * loops when cache is disabled. + */ + +extern void *__real_memcpy(void *dst, const void *src, size_t n); + +static inline bool IRAM_ATTR cache_is_enabled(void) +{ + return (*(volatile uint32_t *)0x3FF00040u) & (1u << 3); +} + +extern void *IRAM_ATTR __wrap_memcpy(void *dst, const void *src, size_t n) +{ + if (cache_is_enabled()) { + return __real_memcpy(dst, src, n); + } + + uint8_t *d = (uint8_t *)dst; + const uint8_t *s = (const uint8_t *)src; + while (n--) { + *d++ = *s++; + } + return dst; +} + +#endif diff --git a/src/platform/esp32/IramMemset.c b/src/platform/esp32/IramMemset.c new file mode 100644 index 00000000000..66d74d8bc58 --- /dev/null +++ b/src/platform/esp32/IramMemset.c @@ -0,0 +1,42 @@ +#include +#include +#include + +#include "esp_attr.h" + +#ifdef ESP32_FORCE_IRAM_MEMSET + +/* + * T-Beam/classic ESP32 boot workaround + * ------------------------------------ + * During early flash operations the ESP32 disables cache, but some IRAM flash + * code paths still reach libc memcpy/memset. If those resolve to flash-resident + * implementations, startup can panic with cache-disabled access errors. + * + * We wrap memcpy/memset for the T-Beam environment. Fast path uses the + * normal libc routines when cache is enabled; slow path uses IRAM-safe byte + * loops when cache is disabled. + */ + +extern void *__real_memset(void *dst, int c, size_t n); + +static inline bool IRAM_ATTR cache_is_enabled(void) +{ + return (*(volatile uint32_t *)0x3FF00040u) & (1u << 3); +} + +extern void *IRAM_ATTR __wrap_memset(void *dst, int c, size_t n) +{ + if (cache_is_enabled()) { + return __real_memset(dst, c, n); + } + + uint8_t *ptr = (uint8_t *)dst; + const uint8_t fill = (uint8_t)c; + while (n--) { + *ptr++ = fill; + } + return dst; +} + +#endif \ No newline at end of file diff --git a/src/platform/esp32/align-text.handler_execute-4.ld b/src/platform/esp32/align-text.handler_execute-4.ld new file mode 100644 index 00000000000..fca4f27a2ea --- /dev/null +++ b/src/platform/esp32/align-text.handler_execute-4.ld @@ -0,0 +1,11 @@ +/* Arduino fix: catch esp_event's orphaned .text.handler_execute section and align to 4 bytes */ +SECTIONS +{ + .text.handler_execute ALIGN(4) : + { + KEEP(*(.text.handler_execute)) + KEEP(*(.text.handler_execute.*)) + . = ALIGN(4); + } +} +INSERT AFTER .flash.text; \ No newline at end of file diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index dbc573c95e8..69b69ff686e 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -165,17 +165,30 @@ void esp32Setup() // #define APP_WATCHDOG_SECS 45 #define APP_WATCHDOG_SECS 90 -#ifdef CONFIG_IDF_TARGET_ESP32C6 - esp_task_wdt_config_t *wdt_config = (esp_task_wdt_config_t *)malloc(sizeof(esp_task_wdt_config_t)); - wdt_config->timeout_ms = APP_WATCHDOG_SECS * 1000; - wdt_config->trigger_panic = true; - res = esp_task_wdt_init(wdt_config); +#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0) + const esp_task_wdt_config_t wdt_config = { + .timeout_ms = APP_WATCHDOG_SECS * 1000, + .idle_core_mask = (1U << CONFIG_FREERTOS_NUMBER_OF_CORES) - 1U, + .trigger_panic = true, + }; + res = esp_task_wdt_init(&wdt_config); + if (res == ESP_ERR_INVALID_STATE) { + LOG_WARN("Task watchdog already initialized, reconfiguring existing instance"); + res = esp_task_wdt_reconfigure(&wdt_config); + } assert(res == ESP_OK); #else res = esp_task_wdt_init(APP_WATCHDOG_SECS, true); + if (res == ESP_ERR_INVALID_STATE) { + LOG_WARN("Task watchdog already initialized, reusing existing instance"); + res = ESP_OK; + } assert(res == ESP_OK); #endif - res = esp_task_wdt_add(NULL); + res = esp_task_wdt_status(NULL); + if (res == ESP_ERR_NOT_FOUND) { + res = esp_task_wdt_add(NULL); + } assert(res == ESP_OK); #if HAS_32768HZ @@ -258,8 +271,10 @@ void cpuDeepSleep(uint32_t msecToWake) #endif variant_shutdown(); +#if SOC_PM_SUPPORT_RTC_PERIPH_PD // We want RTC peripherals to stay on esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); +#endif esp_sleep_enable_timer_wakeup(msecToWake * 1000ULL); // call expects usecs esp_deep_sleep_start(); // TBD mA sleep current (battery) diff --git a/src/power.h b/src/power.h index 4b5ef609daa..993163c39ee 100644 --- a/src/power.h +++ b/src/power.h @@ -4,8 +4,10 @@ #include "configuration.h" #ifdef ARCH_ESP32 -// "legacy adc calibration driver is deprecated, please migrate to use esp_adc/adc_cali.h and esp_adc/adc_cali_scheme.h -#include +// #include +#include +#include +#include #include #endif @@ -28,11 +30,6 @@ #define NUM_CELLS 1 #endif -#ifdef BAT_MEASURE_ADC_UNIT -extern RTC_NOINIT_ATTR uint64_t RTC_reg_b; -#include "soc/sens_reg.h" // needed for adc pin reset -#endif - #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR #include "modules/Telemetry/Sensor/nullSensor.h" #if __has_include() diff --git a/src/sleep.cpp b/src/sleep.cpp index 792781f6d0d..626fc4e11bd 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -18,8 +18,6 @@ #include "target_specific.h" #ifdef ARCH_ESP32 -// "esp_pm_config_esp32_t is deprecated, please include esp_pm.h and use esp_pm_config_t instead" -#include "esp32/pm.h" #include "esp_pm.h" #if HAS_WIFI #include "mesh/wifi/WiFiAPClient.h" @@ -146,15 +144,31 @@ void initDeepSleep() // If we booted because our timer ran out or the user pressed reset, send those as fake events RESET_REASON hwReason = rtc_get_reset_reason(0); +#ifdef CONFIG_IDF_TARGET_ESP32P4 + if (hwReason == BROWN_OUT_RESET) + reason = "brownout"; + else if (hwReason == HP_CORE_HP_WDT_RESET) + reason = "taskWatchdog"; + else if (hwReason == HP_CORE_LP_WDT_RESET) + reason = "intWatchdog"; + else if (hwReason == CHIP_LP_WDT_RESET) + reason = "chipWatchdog"; + else if (hwReason == SUPER_WDT_RESET) + reason = "superWatchdog"; + else if (hwReason == HP_SYS_HP_WDT_RESET) + reason = "systemWatchdog"; + else if (hwReason == HP_SYS_LP_WDT_RESET) + reason = "systemLowPowerWatchdog"; +#else if (hwReason == RTCWDT_BROWN_OUT_RESET) reason = "brownout"; - - if (hwReason == TG0WDT_SYS_RESET) + else if (hwReason == RTCWDT_RTC_RESET) + reason = "rtcWatchdog"; + else if (hwReason == TG0WDT_SYS_RESET) reason = "taskWatchdog"; - - if (hwReason == TG1WDT_SYS_RESET) + else if (hwReason == TG1WDT_SYS_RESET) reason = "intWatchdog"; - +#endif LOG_INFO("Booted, wake cause %d (boot count %d), reset_reason=%s", wakeCause, bootCount, reason); #endif @@ -397,8 +411,10 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r // NOTE! ESP docs say we must disable bluetooth and wifi before light sleep +#if SOC_PM_SUPPORT_RTC_PERIPH_PD // We want RTC peripherals to stay on esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); +#endif #if defined(BUTTON_PIN) && defined(BUTTON_NEED_PULLUP) gpio_pullup_en((gpio_num_t)BUTTON_PIN); @@ -532,11 +548,7 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r */ void enableModemSleep() { -#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0) static esp_pm_config_t esp32_config; // filled with zeros because bss -#else - static esp_pm_config_esp32_t esp32_config; // filled with zeros because bss -#endif #if CONFIG_IDF_TARGET_ESP32S3 esp32_config.max_freq_mhz = CONFIG_ESP32S3_DEFAULT_CPU_FREQ_MHZ; #elif CONFIG_IDF_TARGET_ESP32S2 @@ -545,6 +557,12 @@ void enableModemSleep() esp32_config.max_freq_mhz = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ; #elif CONFIG_IDF_TARGET_ESP32C3 esp32_config.max_freq_mhz = CONFIG_ESP32C3_DEFAULT_CPU_FREQ_MHZ; +#elif CONFIG_IDF_TARGET_ESP32P4 +#if CONFIG_ESP32P4_REV_MIN_FULL < 300 + esp32_config.max_freq_mhz = 360; +#else + esp32_config.max_freq_mhz = 400; +#endif #else esp32_config.max_freq_mhz = CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ; #endif @@ -562,8 +580,8 @@ bool shouldLoraWake(uint32_t msecToWake) void enableLoraInterrupt() { - esp_err_t res; #if SOC_PM_SUPPORT_EXT_WAKEUP && defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) + esp_err_t res; res = gpio_pulldown_en((gpio_num_t)LORA_DIO1); if (res != ESP_OK) { LOG_ERROR("gpio_pulldown_en(LORA_DIO1) result %d", res); diff --git a/variants/esp32/chatter2/variant.h b/variants/esp32/chatter2/variant.h index e91e4dcef42..9241ed6f2a3 100644 --- a/variants/esp32/chatter2/variant.h +++ b/variants/esp32/chatter2/variant.h @@ -70,7 +70,7 @@ // Battery #define BATTERY_PIN 34 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO34_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_6 #define ADC_ATTENUATION \ ADC_ATTEN_DB_2_5 // 2_5-> 100mv-1250mv, 11-> 150mv-3100mv for ESP32 // ESP32-S2/C3/S3 are different diff --git a/variants/esp32/diy/9m2ibr_aprs_lora_tracker/variant.h b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/variant.h index 1f84fffa123..6435e29b2d0 100644 --- a/variants/esp32/diy/9m2ibr_aprs_lora_tracker/variant.h +++ b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/variant.h @@ -30,7 +30,7 @@ // Battery sense #define BATTERY_PIN 35 #define ADC_MULTIPLIER 2.01 // 100k + 100k, and add 1% tolerance -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define BATTERY_SENSE_RESOLUTION_BITS ADC_RESOLUTION // SPI diff --git a/variants/esp32/diy/dr-dev/variant.h b/variants/esp32/diy/dr-dev/variant.h index 35b18ee74ae..e47b4c41c35 100644 --- a/variants/esp32/diy/dr-dev/variant.h +++ b/variants/esp32/diy/dr-dev/variant.h @@ -3,7 +3,7 @@ #define I2C_SDA 4 #define I2C_SCL 5 #define BATTERY_PIN 34 -#define ADC_CHANNEL ADC1_GPIO34_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_6 // GPS #undef GPS_RX_PIN diff --git a/variants/esp32/diy/hydra/variant.h b/variants/esp32/diy/hydra/variant.h index 68194f86993..917962b5f0f 100644 --- a/variants/esp32/diy/hydra/variant.h +++ b/variants/esp32/diy/hydra/variant.h @@ -11,7 +11,7 @@ // Note: On the ESP32 base version, gpio34-39 are input-only, and do not have internal pull-ups. // If 39 is not being used for a button, it is suggested to remove the #define. #define BATTERY_PIN 35 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define ADC_MULTIPLIER 1.85 // (R1 = 470k, R2 = 680k) #define EXT_PWR_DETECT 4 // Pin to detect connected external power source for LILYGO® TTGO T-Energy T18 and other DIY boards #define EXT_NOTIFY_OUT 12 // Overridden default pin to use for Ext Notify Module (#975). diff --git a/variants/esp32/diy/v1/variant.h b/variants/esp32/diy/v1/variant.h index 862969af0fc..98a9eb9c3db 100644 --- a/variants/esp32/diy/v1/variant.h +++ b/variants/esp32/diy/v1/variant.h @@ -11,7 +11,7 @@ #define BUTTON_PIN 39 // The middle button GPIO on the T-Beam #define BATTERY_PIN 35 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define ADC_MULTIPLIER 1.85 // (R1 = 470k, R2 = 680k) #define EXT_PWR_DETECT 4 // Pin to detect connected external power source for LILYGO® TTGO T-Energy T18 and other DIY boards #define EXT_NOTIFY_OUT 12 // Overridden default pin to use for Ext Notify Module (#975). diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 56e6e834000..f2fbb4c616b 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -4,8 +4,9 @@ extends = arduino_base custom_esp32_kind = custom_mtjson_part = platform = - # renovate: datasource=custom.pio depName=platformio/espressif32 packageName=platformio/platform/espressif32 - platformio/espressif32@6.13.0 + # TODO renovate + https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip + ; https://github.com/pioarduino/platform-espressif32.git#develop platform_packages = # renovate: datasource=custom.pio depName=platformio/tool-mklittlefs packageName=platformio/tool/tool-mklittlefs platformio/tool-mklittlefs@1.203.210628 @@ -35,20 +36,18 @@ build_unflags = -std=gnu++11 build_flags = ${arduino_base.build_flags} - -flto -Wall -Wextra -Isrc/platform/esp32 -std=gnu++17 -DLOG_LOCAL_LEVEL=ESP_LOG_DEBUG - -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG + # DO NOT INCREASE THIS TO DEBUG. It appears to trigger a bug in ESP-IDF + # Bluetooth stack with Pixel 8 Android devices: + # https://github.com/espressif/esp-idf/issues/18126#issuecomment-4286197744 + # Once the bug is resolved, we can remove this warning. + -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_INFO -DMYNEWT_VAL_BLE_HS_LOG_LVL=LOG_LEVEL_CRITICAL -DAXP_DEBUG_PORT=Serial - -DCONFIG_BT_NIMBLE_ENABLED - -DCONFIG_BT_NIMBLE_MAX_BONDS=6 # default is 3 - -DCONFIG_NIMBLE_CPP_LOG_LEVEL=2 - -DCONFIG_BT_NIMBLE_MAX_CCCDS=20 - -DCONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=8192 -DESP_OPENSSL_SUPPRESS_LEGACY_WARNING -DSERIAL_BUFFER_SIZE=4096 -DSERIAL_HAS_ON_RECEIVE @@ -62,16 +61,13 @@ build_flags = lib_deps = ${arduino_base.lib_deps} ${networking_base.lib_deps} - ${networking_extra.lib_deps} ${environmental_base.lib_deps} ${environmental_extra.lib_deps} ${radiolib_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-esp32_https_server packageName=https://github.com/meshtastic/esp32_https_server gitBranch=master https://github.com/meshtastic/esp32_https_server/archive/0c71f380390ad483ff134ad938e07f6cf1226c5b.zip - # renovate: datasource=custom.pio depName=NimBLE-Arduino packageName=h2zero/library/NimBLE-Arduino - h2zero/NimBLE-Arduino@1.4.3 - # renovate: datasource=git-refs depName=libpax packageName=https://github.com/dbinfrago/libpax gitBranch=master - https://github.com/dbinfrago/libpax/archive/df424747f9acb86ab07c5a206ded1e8e3650726a.zip + # TODO renovate + https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib lewisxhe/XPowersLib@0.3.3 # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto @@ -79,7 +75,30 @@ lib_deps = lib_ignore = segger_rtt - ESP32 BLE Arduino + ; ESP32 BLE Arduino + ; BLE + ; Ignore select Bluetooth libs + NimBLE-Arduino + BluetoothSerial + SimpleBLE + WiFiProv + ArduinoOTA + ; Ignore pioarduino esp32 libs we don't use + ESP_I2S + ESP_NOW + ESP_SR + Insights + Matter + OpenThread + RainMaker + ;SPIFFS + USB + ;NetworkClientSecure + Zigbee + Micro-RTSP + ; testing below + mqtt + esp-mqtt ; leave this commented out to avoid breaking Windows ;upload_port = /dev/ttyUSB0 @@ -92,3 +111,226 @@ lib_ignore = ; customize the partition table ; http://docs.platformio.org/en/latest/platforms/espressif32.html#partition-tables board_build.partitions = partition-table.csv + +custom_component_remove = + espressif/esp_hosted + espressif/esp_wifi_remote + espressif/esp_modem + espressif/esp-dsp + espressif/esp32-camera + espressif/libsodium + espressif/esp-modbus + espressif/qrcode + espressif/esp_insights + espressif/esp_diag_data_store + espressif/esp_diagnostics + espressif/esp_rainmaker + espressif/rmaker_common + espressif/network_provisioning + chmorgan/esp-libhelix-mp3 + espressif/esp-tflite-micro + espressif/esp-sr + espressif/esp_matter + espressif/esp-zboss-lib + espressif/esp-zigbee-lib + espressif/mqtt + +custom_sdkconfig = + ; CONFIG_LOG_DEFAULT_LEVEL=4 + ; CONFIG_LOG_MAXIMUM_LEVEL=4 + '# CONFIG_BT_NIMBLE_LOG_LEVEL_INFO is not set' + CONFIG_BT_NIMBLE_LOG_LEVEL_ERROR=y + CONFIG_LOG_COLORS=y + CONFIG_ARDUHAL_LOG_COLORS=y + CONFIG_BOOTLOADER_LOG_LEVEL_NONE=y + CONFIG_IDF_EXPERIMENTAL_FEATURES=y + ; + ; Compiler options + ; + CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE=n + CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE=y + CONFIG_COMPILER_CXX_EXCEPTIONS=n + CONFIG_COMPILER_STACK_CHECK_MODE_NORM=n + CONFIG_COMPILER_STACK_CHECK_MODE_NONE=y + CONFIG_COMPILER_DISABLE_GCC12_WARNINGS=y + CONFIG_COMPILER_DISABLE_GCC13_WARNINGS=y + CONFIG_COMPILER_DISABLE_GCC14_WARNINGS=y + CONFIG_COMPILER_ORPHAN_SECTIONS_WARNING=n + CONFIG_COMPILER_ORPHAN_SECTIONS_PLACE=y + CONFIG_ESP_GDBSTUB_ENABLED=n + CONFIG_ESP_TASK_WDT_INIT=n + CONFIG_NEWLIB_NANO_FORMAT=y + ; LIBC_NEWLIB_NANO_FORMAT is enabled via 'esp32_extra.py' to allow float support + CONFIG_ESP_COREDUMP_ENABLE=n + CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=n + CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y + CONFIG_ESP32_ENABLE_COREDUMP=n + CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=n + CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=n + CONFIG_FREERTOS_USE_TRACE_FACILITY=n + CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH=y + CONFIG_FREERTOS_PLACE_SNAPSHOT_FUNS_INTO_FLASH=y + CONFIG_RINGBUF_PLACE_FUNCTIONS_INTO_FLASH=y + CONFIG_ESP_SYSTEM_ESP32_SRAM1_REGION_AS_IRAM=y + CONFIG_ESP_WIFI_IRAM_OPT=n + CONFIG_ESP32_WIFI_RX_IRAM_OPT=n + CONFIG_SPIRAM_CACHE_LIBCHAR_IN_IRAM=n + CONFIG_SPIRAM_CACHE_LIBSTR_IN_IRAM=n + CONFIG_SPIRAM_CACHE_LIBMISC_IN_IRAM=n + CONFIG_SPIRAM_CACHE_LIBTIME_IN_IRAM=n + CONFIG_UNITY_ENABLE_FLOAT=n + CONFIG_UNITY_ENABLE_DOUBLE=n + CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=n + CONFIG_SUPPORT_TERMIOS=n + CONFIG_VFS_SUPPORT_TERMIOS=n + CONFIG_VFS_SUPPORT_SELECT=n + CONFIG_VFS_SUPPRESS_SELECT_DEBUG_OUTPUT=n + CONFIG_WS_TRANSPORT=n + CONFIG_PPP_SUPPORT=n + CONFIG_NETWORK_PROV_NETWORK_TYPE_WIFI=n + ; CONFIG_LWIP_DHCPS=n + CONFIG_LWIP_PPP_SUPPORT=n + CONFIG_LWIP_IP_FORWARD=n + CONFIG_LWIP_IPV4_NAPT=n + ; + ; MBEDTLS + ; + CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=8192 + CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=n + CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y + ; Switch to custom CA bundle (for Meshtastic MQTT/etc) in the future + ; https://docs.espressif.com/projects/esp-idf/en/latest/esp32s3/api-reference/kconfig-reference.html#certificate-bundle-configuration + ; CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE=y + ; CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE_PATH="" + CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_MAX_CERTS=1 + ; #shame + CONFIG_MBEDTLS_ALLOW_WEAK_CERTIFICATE_VERIFICATION=y + CONFIG_MBEDTLS_SSL_RENEGOTIATION=n + CONFIG_MBEDTLS_SSL_PROTO_DTLS=n + CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS=n + CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS=n + CONFIG_MBEDTLS_PKCS7_C=n + CONFIG_MBEDTLS_CAMELLIA_C=n + CONFIG_MBEDTLS_CCM_C=n + CONFIG_MBEDTLS_CMAC_C=n + CONFIG_MBEDTLS_SHA512_C=n + CONFIG_MBEDTLS_X509_CRL_PARSE_C=n + CONFIG_MBEDTLS_X509_CSR_PARSE_C=n + CONFIG_MBEDTLS_KEY_EXCHANGE_ECDHE_PSK=n + CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA=n + CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_RSA=n + CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE=n + CONFIG_MBEDTLS_ECP_DP_SECP192R1_ENABLED=n + CONFIG_MBEDTLS_ECP_DP_SECP224R1_ENABLED=n + CONFIG_MBEDTLS_ECP_DP_SECP384R1_ENABLED=n + CONFIG_MBEDTLS_ECP_DP_SECP521R1_ENABLED=n + CONFIG_MBEDTLS_ECP_DP_SECP192K1_ENABLED=n + CONFIG_MBEDTLS_ECP_DP_SECP224K1_ENABLED=n + CONFIG_MBEDTLS_ECP_DP_SECP256K1_ENABLED=n + CONFIG_MBEDTLS_ECP_DP_BP256R1_ENABLED=n + CONFIG_MBEDTLS_ECP_DP_BP384R1_ENABLED=n + CONFIG_MBEDTLS_ECP_DP_BP512R1_ENABLED=n + CONFIG_MDNS_ENABLE_CONSOLE_CLI=n + CONFIG_MDNS_PREDEF_NETIF_AP=n + ; CONFIG_ESP_WIFI_SOFTAP_SUPPORT=n + CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT=n + CONFIG_ESP_WIFI_ENABLE_WPA3_SAE=n + CONFIG_ESP_WIFI_ENABLE_WPA3_OWE_STA=n + CONFIG_ESP_WIFI_ENABLE_SAE_H2E=n + CONFIG_OPENTHREAD_ENABLED=n + CONFIG_ZB_ENABLED=n + CONFIG_IEEE802154_ENABLED=n + CONFIG_ESP_INSIGHTS_ENABLED=n + CONFIG_HTTPD_WS_SUPPORT=n + CONFIG_ESP_HTTP_CLIENT_ENABLE_HTTPS=n + CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH=n + ; using esp-idf5-https_server library instead, for now? + CONFIG_ESP_HTTPS_SERVER_ENABLE=n + ; + ; Builtin ESP-MQTT, we don't use this (yet?) + ; + CONFIG_MQTT_TRANSPORT_SSL=n + CONFIG_MQTT_TRANSPORT_WEBSOCKET=n + CONFIG_MQTT_TRANSPORT_WEBSOCKET_SECURE=n + CONFIG_MQTT_PROTOCOL_311=n + ; + ; Ethernet + ; Disabled in esp32_common, re-enable on variants that need it + ; + CONFIG_ETH_ENABLED=n + CONFIG_ARDUINO_SELECTIVE_Ethernet=n + ; + ; Bluetooth + ; + CONFIG_BLE_MESH=n + CONFIG_BT_ENABLED=y + CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y + CONFIG_BT_BLUEDROID_ENABLED=n + CONFIG_BT_NIMBLE_ENABLED=y + CONFIG_BT_NIMBLE_NVS_PERSIST=y + CONFIG_BT_NIMBLE_ROLE_CENTRAL=n + CONFIG_BT_NIMBLE_ROLE_OBSERVER=n + CONFIG_BT_CONTROLLER_ENABLED=y + CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=8192 + CONFIG_BT_NIMBLE_MAX_CCCDS=20 + CONFIG_BT_NIMBLE_MAX_BONDS=6 + CONFIG_BT_NIMBLE_ENABLE_PERIODIC_SYNC=n + CONFIG_BT_NIMBLE_ENABLE_PERIODIC_ADV=n + CONFIG_BT_NIMBLE_EXT_SCAN=n + CONFIG_BT_NIMBLE_EXT_ADV=n + CONFIG_BT_NIMBLE_PERIODIC_ADV_SYNC_TRANSFER=n + CONFIG_BT_NIMBLE_LL_CFG_FEAT_LE_2M_PHY=n + CONFIG_BT_NIMBLE_BLUFI_ENABLE=n + CONFIG_BT_NIMBLE_PROX_SERVICE=n + CONFIG_BT_NIMBLE_ANS_SERVICE=n + CONFIG_BT_NIMBLE_CTS_SERVICE=n + CONFIG_BT_NIMBLE_HTP_SERVICE=n + CONFIG_BT_NIMBLE_IPSS_SERVICE=n + CONFIG_BT_NIMBLE_TPS_SERVICE=n + CONFIG_BT_NIMBLE_IAS_SERVICE=n + CONFIG_BT_NIMBLE_LLS_SERVICE=n + CONFIG_BT_NIMBLE_SPS_SERVICE=y + CONFIG_BT_NIMBLE_HR_SERVICE=n + CONFIG_BT_NIMBLE_HID_SERVICE=n + CONFIG_BT_NIMBLE_BAS_SERVICE=n + CONFIG_BT_NIMBLE_DIS_SERVICE=n + ; + ; Arduino selective compilation + ; Disable unused Arduino libraries to save space + ; + CONFIG_ARDUINO_SELECTIVE_COMPILATION=y + CONFIG_ARDUINO_SELECTIVE_SPI=y + CONFIG_ARDUINO_SELECTIVE_Wire=y + CONFIG_ARDUINO_SELECTIVE_ESP_SR=n + CONFIG_ARDUINO_SELECTIVE_EEPROM=y + CONFIG_ARDUINO_SELECTIVE_Preferences=y + CONFIG_ARDUINO_SELECTIVE_Ticker=y + CONFIG_ARDUINO_SELECTIVE_Update=y + CONFIG_ARDUINO_SELECTIVE_Zigbee=n + CONFIG_ARDUINO_SELECTIVE_FS=y + CONFIG_ARDUINO_SELECTIVE_SD=y + CONFIG_ARDUINO_SELECTIVE_SD_MMC=y + CONFIG_ARDUINO_SELECTIVE_SPIFFS=y + CONFIG_ARDUINO_SELECTIVE_FFat=n + CONFIG_ARDUINO_SELECTIVE_LittleFS=y + CONFIG_ARDUINO_SELECTIVE_Network=y + ; CONFIG_ARDUINO_SELECTIVE_Ethernet=n + CONFIG_ARDUINO_SELECTIVE_PPP=n + CONFIG_ARDUINO_SELECTIVE_Hash=y + CONFIG_ARDUINO_SELECTIVE_ArduinoOTA=n + CONFIG_ARDUINO_SELECTIVE_AsyncUDP=y + CONFIG_ARDUINO_SELECTIVE_DNSServer=n + CONFIG_ARDUINO_SELECTIVE_ESPmDNS=y + CONFIG_ARDUINO_SELECTIVE_HTTPClient=n + CONFIG_ARDUINO_SELECTIVE_Matter=n + CONFIG_ARDUINO_SELECTIVE_NetBIOS=n + CONFIG_ARDUINO_SELECTIVE_WebServer=n + CONFIG_ARDUINO_SELECTIVE_WiFi=y + CONFIG_ARDUINO_SELECTIVE_NetworkClientSecure=y + CONFIG_ARDUINO_SELECTIVE_WiFiProv=n + CONFIG_ARDUINO_SELECTIVE_BLE=y + CONFIG_ARDUINO_SELECTIVE_BluetoothSerial=n + CONFIG_ARDUINO_SELECTIVE_SimpleBLE=n + CONFIG_ARDUINO_SELECTIVE_RainMaker=n + CONFIG_ARDUINO_SELECTIVE_OpenThread=n + CONFIG_ARDUINO_SELECTIVE_Insights=n diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini index f40f1d0643f..50b475a6c21 100644 --- a/variants/esp32/esp32.ini +++ b/variants/esp32/esp32.ini @@ -11,12 +11,39 @@ build_src_filter = build_flags = ${esp32_common.build_flags} + -mtext-section-literals + -D ESP32_FORCE_IRAM_MEMSET + -Wl,--wrap=memset + -Wl,--wrap=memcpy -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_ACCELEROMETER=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 -DMESHTASTIC_EXCLUDE_WEBSERVER=1 -DMESHTASTIC_EXCLUDE_RANGETEST=1 + ; HACK HACK HACK this is just a proof of concept ESP32+NimBLE but these + ; includes need to be done properly somehow + -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/include + -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/include + -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/porting/nimble/include + -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/port/include + -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/porting/npl/freertos/include + -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/transport/include + -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/services/gap/include + -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/esp-hci/include + -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/services/gatt/include + -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/util/include +custom_sdkconfig = + ${esp32_common.custom_sdkconfig} + CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y + '# CONFIG_BTDM_CTRL_MODE_BR_EDR_ONLY is not set' + '# CONFIG_BTDM_CTRL_MODE_BTDM is not set' + '# CONFIG_BT_BLUEDROID_ENABLED is not set' + CONFIG_BT_NIMBLE_ENABLED=y + CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y + +; Override lib_deps to use environmental_extra_no_bsec instead of environmental_extra +; BSEC library uses ~3.5KB DRAM which causes overflow on original ESP32 targets lib_deps = ${arduino_base.lib_deps} ${networking_base.lib_deps} @@ -24,8 +51,8 @@ lib_deps = ${radiolib_base.lib_deps} ${environmental_base.lib_deps} ${environmental_extra_no_bsec.lib_deps} - # renovate: datasource=custom.pio depName=NimBLE-Arduino packageName=h2zero/library/NimBLE-Arduino - h2zero/NimBLE-Arduino@1.4.3 + # TODO renovate + https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib lewisxhe/XPowersLib@0.3.3 # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto diff --git a/variants/esp32/heltec_wsl_v2.1/variant.h b/variants/esp32/heltec_wsl_v2.1/variant.h index db374afb642..c0afeac8279 100644 --- a/variants/esp32/heltec_wsl_v2.1/variant.h +++ b/variants/esp32/heltec_wsl_v2.1/variant.h @@ -12,7 +12,7 @@ #define ADC_CTRL 21 #define ADC_CTRL_ENABLED LOW #define BATTERY_PIN 37 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_CHANNEL_1 +#define ADC_CHANNEL ADC_CHANNEL_1 // ratio of voltage divider = 3.20 (R1=100k, R2=220k) #define ADC_MULTIPLIER 3.2 diff --git a/variants/esp32/m5stack_coreink/variant.h b/variants/esp32/m5stack_coreink/variant.h index 84a1e196612..ede008bab47 100644 --- a/variants/esp32/m5stack_coreink/variant.h +++ b/variants/esp32/m5stack_coreink/variant.h @@ -92,7 +92,7 @@ #define PIN_EINK_MOSI 23 // EPD_MOSI #define BATTERY_PIN 35 -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 // https://m5stack.oss-cn-shenzhen.aliyuncs.com/resource/docs/schematic/Core/m5paper/M5_PAPER_SCH.pdf // https://github.com/m5stack/M5Core-Ink/blob/master/examples/Basics/FactoryTest/FactoryTest.ino#L58 // VBAT diff --git a/variants/esp32/nano-g1-explorer/variant.h b/variants/esp32/nano-g1-explorer/variant.h index f3640241aea..48d06471098 100644 --- a/variants/esp32/nano-g1-explorer/variant.h +++ b/variants/esp32/nano-g1-explorer/variant.h @@ -33,7 +33,7 @@ #endif #define BATTERY_PIN 35 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define BATTERY_SENSE_SAMPLES 15 // Set the number of samples, It has an effect of increasing sensitivity. #define ADC_MULTIPLIER 2 diff --git a/variants/esp32/radiomaster_900_bandit/platformio.ini b/variants/esp32/radiomaster_900_bandit/platformio.ini index 6729235ed30..fdc483fe31c 100644 --- a/variants/esp32/radiomaster_900_bandit/platformio.ini +++ b/variants/esp32/radiomaster_900_bandit/platformio.ini @@ -7,7 +7,7 @@ build_flags = -DVTABLES_IN_FLASH=1 -DCONFIG_DISABLE_HAL_LOCKS=1 -DHAS_STK8XXX=1 - -O2 + ; -O2 -I variants/esp32/radiomaster_900_bandit board_build.f_cpu = 240000000L upload_protocol = esptool diff --git a/variants/esp32/rak11200/variant.h b/variants/esp32/rak11200/variant.h index a38ac83b766..feb38a8e1c3 100644 --- a/variants/esp32/rak11200/variant.h +++ b/variants/esp32/rak11200/variant.h @@ -47,7 +47,7 @@ static const uint8_t SCK = 33; #define PIN_VBAT WB_A0 #define BATTERY_PIN PIN_VBAT -#define ADC_CHANNEL ADC1_GPIO36_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 // https://docs.rakwireless.com/Product-Categories/WisBlock/RAK13300/ diff --git a/variants/esp32/station-g1/variant.h b/variants/esp32/station-g1/variant.h index 6c3a39261b6..29f3cc1d84b 100644 --- a/variants/esp32/station-g1/variant.h +++ b/variants/esp32/station-g1/variant.h @@ -32,7 +32,7 @@ #endif #define BATTERY_PIN 35 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define BATTERY_SENSE_SAMPLES 30 // Set the number of samples, It has an effect of increasing sensitivity. #define ADC_MULTIPLIER 6.45 #define CELL_TYPE_LION // same curve for liion/lipo diff --git a/variants/esp32/tbeam/platformio.ini b/variants/esp32/tbeam/platformio.ini index 96e9879ce0f..b1367df87af 100644 --- a/variants/esp32/tbeam/platformio.ini +++ b/variants/esp32/tbeam/platformio.ini @@ -16,6 +16,7 @@ board_check = true build_flags = ${esp32_base.build_flags} -D TBEAM_V10 -I variants/esp32/tbeam + upload_speed = 921600 [env:tbeam-displayshield] diff --git a/variants/esp32/tbeam_v07/variant.h b/variants/esp32/tbeam_v07/variant.h index 898705ce292..1fdb295bfc6 100644 --- a/variants/esp32/tbeam_v07/variant.h +++ b/variants/esp32/tbeam_v07/variant.h @@ -6,7 +6,7 @@ #define BUTTON_PIN 39 #define BATTERY_PIN 35 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage #define EXT_NOTIFY_OUT 13 // Default pin to use for Ext Notify Module. -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define USE_RF95 #define LORA_DIO0 26 // a No connect on the SX1262 module diff --git a/variants/esp32/tlora_v1_3/variant.h b/variants/esp32/tlora_v1_3/variant.h index 2b0395d8ad0..ab783e57d31 100644 --- a/variants/esp32/tlora_v1_3/variant.h +++ b/variants/esp32/tlora_v1_3/variant.h @@ -1,5 +1,5 @@ #define BATTERY_PIN 35 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define I2C_SDA 21 // I2C pins for this board #define I2C_SCL 22 diff --git a/variants/esp32/tlora_v2/variant.h b/variants/esp32/tlora_v2/variant.h index 099fdc2ee63..cd714b8feca 100644 --- a/variants/esp32/tlora_v2/variant.h +++ b/variants/esp32/tlora_v2/variant.h @@ -1,5 +1,5 @@ #define BATTERY_PIN 35 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define I2C_SDA 21 // I2C pins for this board #define I2C_SCL 22 diff --git a/variants/esp32/tlora_v2_1_16/variant.h b/variants/esp32/tlora_v2_1_16/variant.h index 5488fddf4f5..27d2fb5ab8a 100644 --- a/variants/esp32/tlora_v2_1_16/variant.h +++ b/variants/esp32/tlora_v2_1_16/variant.h @@ -1,5 +1,5 @@ #define BATTERY_PIN 35 -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define BATTERY_SENSE_SAMPLES 30 // ratio of voltage divider = 2.0 (R42=100k, R43=100k) diff --git a/variants/esp32/tlora_v2_1_18/variant.h b/variants/esp32/tlora_v2_1_18/variant.h index 1ab08c36478..75578e1ca3f 100644 --- a/variants/esp32/tlora_v2_1_18/variant.h +++ b/variants/esp32/tlora_v2_1_18/variant.h @@ -1,7 +1,7 @@ #define BATTERY_PIN 35 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage // ratio of voltage divider = 2.0 (R42=100k, R43=100k) #define ADC_MULTIPLIER 2.11 // 2.0 + 10% for correction of display undervoltage. -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define I2C_SDA 21 // I2C pins for this board #define I2C_SCL 22 diff --git a/variants/esp32/trackerd/variant.h b/variants/esp32/trackerd/variant.h index 8071ba99daf..8ba32c77623 100644 --- a/variants/esp32/trackerd/variant.h +++ b/variants/esp32/trackerd/variant.h @@ -22,7 +22,7 @@ #undef BAT_MEASURE_ADC_UNIT #define BATTERY_PIN 35 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage #define ADC_MULTIPLIER 1.34 // tracked resistance divider is 100k+470k, so it can not fillfull well on esp32 adc -#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define ADC_ATTENUATION ADC_ATTEN_DB_12 // lower dB for high resistance voltage divider #undef GPS_RX_PIN diff --git a/variants/esp32c3/esp32c3.ini b/variants/esp32c3/esp32c3.ini index e5f117ad771..02042047f01 100644 --- a/variants/esp32c3/esp32c3.ini +++ b/variants/esp32c3/esp32c3.ini @@ -5,6 +5,19 @@ custom_esp32_kind = esp32c3 monitor_speed = 115200 monitor_filters = esp32_c3_exception_decoder +build_flags = + ${esp32_common.build_flags} + ; Linker script to align text.handler_execute section to 4 bytes + -Wl,-Tsrc/platform/esp32/align-text.handler_execute-4.ld + +custom_sdkconfig = + ${esp32_common.custom_sdkconfig} + ; ESP32c3 doesn't support SD_MMC + CONFIG_ARDUINO_SELECTIVE_SD_MMC=n + ; CONFIG_BT_NIMBLE_EXT_ADV=y + ; CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE=257 + ; CONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES=2 + lib_deps = ${esp32_common.lib_deps} # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master diff --git a/variants/esp32c6/esp32c6.ini b/variants/esp32c6/esp32c6.ini index cdd9f986852..ae91e6e81fd 100644 --- a/variants/esp32c6/esp32c6.ini +++ b/variants/esp32c6/esp32c6.ini @@ -1,50 +1,34 @@ [esp32c6_base] extends = esp32_common -platform = - # Do not renovate until we have switched to pioarduino tagged builds - https://github.com/Jason2866/platform-espressif32/archive/22faa566df8c789000f8136cd8d0aca49617af55.zip -platform_packages = - # HACK: This release was automatically removed upstream - framework-arduinoespressif32 @ https://github.com/vidplace7/platform-espressif32/releases/download/meshtastic-esp32c6/framework-arduinoespressif32-all-release_v5.1-124d64e.zip +custom_esp32_kind = esp32c6 + build_flags = - ${arduino_base.build_flags} - -Wall - -Wextra - -Isrc/platform/esp32 - -DESP_OPENSSL_SUPPRESS_LEGACY_WARNING - -DSERIAL_BUFFER_SIZE=4096 - -DLIBPAX_ARDUINO - -DLIBPAX_WIFI - -DLIBPAX_BLE - -DMESHTASTIC_EXCLUDE_WEBSERVER - ;-DDEBUG_HEAP - ; TEMP - -DHAS_BLUETOOTH=0 + ${esp32_common.build_flags} + ; Linker script to align text.handler_execute section to 4 bytes + -Wl,-Tsrc/platform/esp32/align-text.handler_execute-4.ld + ; Exclude Paxcounter, it uses 'esp_vhci_host_send_packet' whch is not available on ESP32-C6 + ; https://github.com/espressif/arduino-esp32/issues/11716 -DMESHTASTIC_EXCLUDE_PAXCOUNTER - -DMESHTASTIC_EXCLUDE_BLUETOOTH - -lib_deps = - ${arduino_base.lib_deps} - ${networking_base.lib_deps} - ${environmental_base.lib_deps} - ${environmental_extra.lib_deps} - ${radiolib_base.lib_deps} - # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib - lewisxhe/XPowersLib@0.3.3 - # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master - https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip - # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto - rweather/Crypto@0.4.0 build_src_filter = - ${esp32_common.build_src_filter} - + ${esp32_common.build_src_filter} monitor_speed = 460800 monitor_filters = esp32_c3_exception_decoder +custom_sdkconfig = + ${esp32_common.custom_sdkconfig} + ; ESP32c6 doesn't support SD_MMC + CONFIG_ARDUINO_SELECTIVE_SD_MMC=n + ; CONFIG_BT_NIMBLE_EXT_ADV=y + ; CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE=257 + ; CONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES=2 + lib_ignore = ${esp32_common.lib_ignore} - NonBlockingRTTTL - NimBLE-Arduino libpax - + +lib_deps = + ${esp32_common.lib_deps} + # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master + https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip diff --git a/variants/esp32c6/m5stack_unitc6l/platformio.ini b/variants/esp32c6/m5stack_unitc6l/platformio.ini index bf605ca612d..59eca377b22 100644 --- a/variants/esp32c6/m5stack_unitc6l/platformio.ini +++ b/variants/esp32c6/m5stack_unitc6l/platformio.ini @@ -17,28 +17,12 @@ board_build.partitions = default_16MB.csv ;Normal method upload_protocol = esptool ;upload_port = /dev/ttyACM2 -build_unflags = - -D HAS_BLUETOOTH - -D MESHTASTIC_EXCLUDE_BLUETOOTH - -D HAS_WIFI -lib_deps = - ${esp32c6_base.lib_deps} - # renovate: datasource=custom.pio depName=NimBLE-Arduino packageName=h2zero/library/NimBLE-Arduino - h2zero/NimBLE-Arduino@2.3.7 build_flags = ${esp32c6_base.build_flags} -D M5STACK_UNITC6L -I variants/esp32c6/m5stack_unitc6l - -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 - -D HAS_BLUETOOTH=1 - -DCONFIG_BT_NIMBLE_EXT_ADV=1 - -DCONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES=2 - -D NIMBLE_TWO monitor_speed=115200 -lib_ignore = - NonBlockingRTTTL - libpax build_src_filter = ${esp32c6_base.build_src_filter} +<../variants/esp32c6/m5stack_unitc6l> diff --git a/variants/esp32c6/tlora_c6/platformio.ini b/variants/esp32c6/tlora_c6/platformio.ini index 6b402d7c549..07585e79fcc 100644 --- a/variants/esp32c6/tlora_c6/platformio.ini +++ b/variants/esp32c6/tlora_c6/platformio.ini @@ -8,3 +8,11 @@ build_flags = -I variants/esp32c6/tlora_c6 -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 + ; Disable screen + audio. + ; ESP32-C6 with 4MB flash is TIGHT on space. + -DMESHTASTIC_EXCLUDE_SCREEN=1 + -DMESHTASTIC_EXCLUDE_AUDIO=1 + +lib_ignore = + ${esp32c6_base.lib_ignore} + ESP32 Codec2 diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini new file mode 100644 index 00000000000..44094ee042b --- /dev/null +++ b/variants/esp32p4/esp32p4.ini @@ -0,0 +1,110 @@ +[esp32p4_base] +extends = esp32_common +custom_esp32_kind = esp32p4 +board_build.mcu = esp32p4 + +build_unflags= + -DHAS_UDP_MULTICAST=1 + +build_flags = + ${esp32_common.build_flags} + -DMESHTASTIC_EXCLUDE_WIFI=1 ; TODO + -DMESHTASTIC_EXCLUDE_MQTT=1 + -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 + +build_src_filter = + ${esp32_common.build_src_filter} - - - - - - - + +extra_scripts = + ${esp32_common.extra_scripts} + extra_scripts/ld_response_file.py + +; Override esp32_common component pruning: keep esp_hosted + esp_wifi_remote for P4 hosted BT +custom_component_remove = + espressif/esp_modem + espressif/esp-dsp + espressif/esp32-camera + espressif/libsodium + espressif/esp-modbus + espressif/qrcode + espressif/esp_insights + espressif/esp_diag_data_store + espressif/esp_diagnostics + espressif/esp_rainmaker + espressif/rmaker_common + espressif/network_provisioning + chmorgan/esp-libhelix-mp3 + espressif/esp-tflite-micro + espressif/esp-sr + espressif/esp_matter + espressif/esp-zboss-lib + espressif/esp-zigbee-lib + espressif/mqtt + +custom_sdkconfig = + ${esp32_common.custom_sdkconfig} + CONFIG_ARDUINO_SELECTIVE_SD_MMC=y + CONFIG_BT_CONTROLLER_DISABLED=y + CONFIG_ESP_WIFI_REMOTE_ENABLED=y + # esp_hosted core + CONFIG_ESP_HOSTED_ENABLED=y + # Board: custom (not Espressif EV board) + CONFIG_ESP_HOSTED_P4_DEV_BOARD_NONE=y + # Delay after C6 reset to allow boot (e.g. old v2.3.0 may be slow) + CONFIG_ESP_HOSTED_SDIO_RESET_DELAY_MS=1500 + CONFIG_ESP_HOSTED_SDIO_OPTIMIZATION_RX_STREAMING_MODE=y + CONFIG_ESP_HOSTED_SDIO_HOST_INTERFACE=y + CONFIG_ESP_HOSTED_IDF_SLAVE_TARGET="esp32c6" + CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6=y + CONFIG_ESP_HOSTED_CP_TARGET_ESP32H2=n + CONFIG_ESP_HOSTED_PRIV_SDIO_OPTION=y + CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE=n + CONFIG_ESP_HOSTED_TRANSPORT_SDIO=y + CONFIG_ESP_HOSTED_ENABLE_NIMBLE=y + CONFIG_ESP_HOSTED_ENABLE_BT_NIMBLE=y + CONFIG_ESP_HOSTED_NIMBLE_HCI_VHCI=y + CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER=y + CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS=3 + # OTA method: LittleFS + CONFIG_OTA_METHOD_LITTLEFS=y + + # Skip version check — we force OTA regardless + # CONFIG_OTA_VERSION_CHECK_HOST_SLAVE is not set + # CONFIG_OTA_VERSION_CHECK_SLAVEFW_SLAVE is not set + + # RX streaming mode + CONFIG_ESP_HOSTED_SLAVE_RESET_ON_EVERY_HOST_BOOTUP=y + ;CONFIG_ESP_HOSTED_SLAVE_RESET_ONLY_IF_NECESSARY=y + # SOC_LCD (MUI / lovyanGFX) + CONFIG_SOC_LCD_I80_SUPPORTED=y + CONFIG_SOC_LCD_RGB_SUPPORTED=y + CONFIG_SOC_MIPI_DSI_SUPPORTED=y + # stack dump + CONFIG_ESP_SYSTEM_PANIC_PRINT_HALT=y ; remove for production version + ;CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y; for production version + ;CONFIG_ESP_SYSTEM_PANIC_GDBSTUB=y ; for target debugging + # Logger: verbose for experiment + CONFIG_LOG_DEFAULT_LEVEL_INFO=y + CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y + + +lib_ignore = + ${esp32_common.lib_ignore} + libpax + esp8266-oled-ssd1306 + bsec2 + esp32_idf5_https_server + esp_driver_cam + esp_http_server + +; Override lib_deps to exclude environmental_extras +lib_deps = + ${arduino_base.lib_deps} + ${networking_base.lib_deps} + ${networking_extra.lib_deps} + ${environmental_base.lib_deps} + ${radiolib_base.lib_deps} + # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto + rweather/Crypto@0.4.0 + # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master + https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip diff --git a/variants/esp32s2/esp32s2.ini b/variants/esp32s2/esp32s2.ini index 836e31d8d48..189f51898a2 100644 --- a/variants/esp32s2/esp32s2.ini +++ b/variants/esp32s2/esp32s2.ini @@ -12,13 +12,18 @@ build_flags = -DHAS_BLUETOOTH=0 -DMESHTASTIC_EXCLUDE_PAXCOUNTER -DMESHTASTIC_EXCLUDE_BLUETOOTH + -mtext-section-literals -lib_deps = - ${esp32_common.lib_deps} - # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master - https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip +custom_sdkconfig = + ${esp32_common.custom_sdkconfig} lib_ignore = ${esp32_common.lib_ignore} NimBLE-Arduino libpax + +lib_deps = + ${esp32_common.lib_deps} + # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master + https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip + \ No newline at end of file diff --git a/variants/esp32s3/CDEBYTE_EoRa-Hub/variant.h b/variants/esp32s3/CDEBYTE_EoRa-Hub/variant.h index 5decc7eb236..628ebcf8b44 100644 --- a/variants/esp32s3/CDEBYTE_EoRa-Hub/variant.h +++ b/variants/esp32s3/CDEBYTE_EoRa-Hub/variant.h @@ -7,7 +7,7 @@ #define BUTTON_PIN 0 // BOOT button #define BATTERY_PIN 1 -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_MULTIPLIER 103.0 // Calibrated value #define ADC_ATTENUATION ADC_ATTEN_DB_0 #define ADC_CTRL 37 diff --git a/variants/esp32s3/CDEBYTE_EoRa-S3/variant.h b/variants/esp32s3/CDEBYTE_EoRa-S3/variant.h index 85321cbe0ca..0991afd53ce 100644 --- a/variants/esp32s3/CDEBYTE_EoRa-S3/variant.h +++ b/variants/esp32s3/CDEBYTE_EoRa-S3/variant.h @@ -18,7 +18,7 @@ // Battery voltage monitoring - TODO: test, currently untested, copied from T3S3 variant #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_MULTIPLIER \ 2.11 // ratio of voltage divider = 2.0 (R10=1M, R13=1M), plus some undervoltage correction - TODO: this was carried over from // the T3S3, test to see if the undervoltage correction is needed. diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/variant.cpp b/variants/esp32s3/ELECROW-ThinkNode-G3/variant.cpp deleted file mode 100644 index c6ff6b8d8a8..00000000000 --- a/variants/esp32s3/ELECROW-ThinkNode-G3/variant.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#include "mesh/NodeDB.h" - -void variantDefaultConfig() -{ - config.network.eth_enabled = true; -} diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/variant.h b/variants/esp32s3/ELECROW-ThinkNode-G3/variant.h index c5afd574a6c..31676349908 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-G3/variant.h +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/variant.h @@ -34,3 +34,5 @@ #define ETH_INT_PIN 10 #define ETH_RST_PIN 9 // #define ETH_ADDR 1 + +#define USE_ETHERNET_DEFAULT 1 \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-M2/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M2/variant.h index c8e56426f5e..66820858654 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M2/variant.h +++ b/variants/esp32s3/ELECROW-ThinkNode-M2/variant.h @@ -40,8 +40,8 @@ // Battery // #define BATTERY_PIN 2 #define BATTERY_PIN 17 -// #define ADC_CHANNEL ADC1_GPIO2_CHANNEL -#define ADC_CHANNEL ADC2_GPIO17_CHANNEL +// #define ADC_CHANNEL ADC_CHANNEL_1 +#define ADC_CHANNEL ADC_CHANNEL_6 #define BATTERY_SENSE_RESOLUTION_BITS 12 #define BATTERY_SENSE_RESOLUTION 4096.0 #undef AREF_VOLTAGE diff --git a/variants/esp32s3/ELECROW-ThinkNode-M5/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M5/variant.h index 2d02c7f27be..55bcd0dc729 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M5/variant.h +++ b/variants/esp32s3/ELECROW-ThinkNode-M5/variant.h @@ -16,7 +16,7 @@ // USB_CHECK #define EXT_PWR_DETECT 12 #define BATTERY_PIN 8 -#define ADC_CHANNEL ADC1_GPIO8_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_7 #define ADC_MULTIPLIER 2.0 // 2.0 + 10% for correction of display undervoltage. diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp deleted file mode 100644 index c6ff6b8d8a8..00000000000 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#include "mesh/NodeDB.h" - -void variantDefaultConfig() -{ - config.network.eth_enabled = true; -} diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.h index 9724b20fab3..da73bcd1e58 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/variant.h +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.h @@ -41,3 +41,5 @@ #define ETH_CS_PIN 21 #define ETH_INT_PIN 45 // #define ETH_ADDR 1 + +#define USE_ETHERNET_DEFAULT 1 \ No newline at end of file diff --git a/variants/esp32s3/crowpanel-esp32s3-5-epaper/variant.h b/variants/esp32s3/crowpanel-esp32s3-5-epaper/variant.h index c9200b96b14..f8a0e28c846 100644 --- a/variants/esp32s3/crowpanel-esp32s3-5-epaper/variant.h +++ b/variants/esp32s3/crowpanel-esp32s3-5-epaper/variant.h @@ -17,7 +17,7 @@ // #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to // measure battery voltage ratio of voltage divider = 2.0 (assumption) // #define ADC_MULTIPLIER 2.11 // 2.0 + 10% for correction of display undervoltage. -// #define ADC_CHANNEL ADC1_GPIO1_CHANNEL +// #define ADC_CHANNEL ADC_CHANNEL_0 #define I2C_SDA SDA // 21 #define I2C_SCL SCL // 15 diff --git a/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini b/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini index 71116279c5e..f43f104d7db 100644 --- a/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini +++ b/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini @@ -26,3 +26,4 @@ build_flags = -DBOARD_HAS_PSRAM -mfix-esp32-psram-cache-issue -DARDUINO_USB_MODE=0 + -DARDUINO_USB_CDC_ON_BOOT=1 diff --git a/variants/esp32s3/diy/my_esp32s3_diy_oled/platformio.ini b/variants/esp32s3/diy/my_esp32s3_diy_oled/platformio.ini index 60b030d4232..b90325f5131 100644 --- a/variants/esp32s3/diy/my_esp32s3_diy_oled/platformio.ini +++ b/variants/esp32s3/diy/my_esp32s3_diy_oled/platformio.ini @@ -8,13 +8,9 @@ board_build.f_cpu = 240000000L upload_protocol = esptool ;upload_port = /dev/ttyACM0 upload_speed = 921600 -build_unflags = - ${esp32s3_base.build_unflags} - -DARDUINO_USB_MODE=1 build_flags = ${esp32s3_base.build_flags} -D PRIVATE_HW -I variants/esp32s3/diy/my_esp32s3_diy_oled -DBOARD_HAS_PSRAM -mfix-esp32-psram-cache-issue - -DARDUINO_USB_MODE=0 diff --git a/variants/esp32s3/diy/t-energy-s3_e22/platformio.ini b/variants/esp32s3/diy/t-energy-s3_e22/platformio.ini index 681ee6c45fa..9d977e45d46 100644 --- a/variants/esp32s3/diy/t-energy-s3_e22/platformio.ini +++ b/variants/esp32s3/diy/t-energy-s3_e22/platformio.ini @@ -6,13 +6,10 @@ board_build.partitions = default_16MB.csv board_level = extra board_upload.flash_size = 16MB ;Specify the FLASH capacity as 16MB board_build.arduino.memory_type = qio_opi ;Enable internal PSRAM -build_unflags = - ${esp32s3_base.build_unflags} - -D ARDUINO_USB_MODE=1 build_flags = ${esp32s3_base.build_flags} -D EBYTE_ESP32_S3 -D BOARD_HAS_PSRAM - -D ARDUINO_USB_MODE=0 + -D ARDUINO_USB_MODE=1 -D ARDUINO_USB_CDC_ON_BOOT=1 -I variants/esp32s3/diy/t-energy-s3_e22 diff --git a/variants/esp32s3/diy/t-energy-s3_e22/variant.h b/variants/esp32s3/diy/t-energy-s3_e22/variant.h index 6933d7715ca..10a0fc20d67 100644 --- a/variants/esp32s3/diy/t-energy-s3_e22/variant.h +++ b/variants/esp32s3/diy/t-energy-s3_e22/variant.h @@ -4,7 +4,7 @@ // Battery #define BATTERY_PIN 3 #define ADC_MULTIPLIER 2.0 -#define ADC_CHANNEL ADC1_GPIO3_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_2 // Button on NanoVHF PCB #define BUTTON_PIN 39 diff --git a/variants/esp32s3/elecrow_panel/pins_arduino.h b/variants/esp32s3/elecrow_panel/pins_arduino.h index 81c9e0a2cd4..75b5d561ae7 100644 --- a/variants/esp32s3/elecrow_panel/pins_arduino.h +++ b/variants/esp32s3/elecrow_panel/pins_arduino.h @@ -15,9 +15,9 @@ static const uint8_t MOSI = 48; static const uint8_t MISO = 47; static const uint8_t SCK = 41; -static const uint8_t SPI_MOSI = 6; -static const uint8_t SPI_SCK = 5; -static const uint8_t SPI_MISO = 4; +// static const uint8_t SPI_MOSI = 6; +// static const uint8_t SPI_SCK = 5; +// static const uint8_t SPI_MISO = 4; static const uint8_t A0 = 1; static const uint8_t A1 = 2; diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index 1b91a02bbe9..a707a832e80 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -38,6 +38,9 @@ build_flags = ${esp32s3_base.build_flags} -Os -D USE_PACKET_API -D HAS_SDCARD -D SD_SPI_FREQUENCY=75000000 + -D SPI_MISO=4 + -D SPI_MOSI=6 + -D SPI_SCK=5 lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} @@ -47,7 +50,9 @@ lib_deps = ${esp32s3_base.lib_deps} earlephilhower/ESP8266SAM@1.1.0 # renovate: datasource=custom.pio depName=TCA9534 packageName=hideakitai/library/TCA9534 hideakitai/TCA9534@0.1.1 - lovyan03/LovyanGFX@1.2.0 ; note: v1.2.7 breaks the elecrow 7" display functionality + # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.19 + ; REVISIT note: v1.2.7 breaks the elecrow 7" display functionality [crowpanel_small_esp32s3_base] ; 2.4, 2.8, 3.5 inch extends = crowpanel_base diff --git a/variants/esp32s3/esp32-s3-pico/variant.h b/variants/esp32s3/esp32-s3-pico/variant.h index 65732171aaf..1629ebebe1d 100644 --- a/variants/esp32s3/esp32-s3-pico/variant.h +++ b/variants/esp32s3/esp32-s3-pico/variant.h @@ -21,8 +21,8 @@ // https://www.waveshare.com/img/devkit/ESP32-S3-Pico/ESP32-S3-Pico-details-inter-1.jpg // digram is incorrect labeled as battery pin is getting readings on GPIO7_CH1? #define BATTERY_PIN 7 -#define ADC_CHANNEL ADC1_GPIO7_CHANNEL -// #define ADC_CHANNEL ADC1_GPIO6_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_6 +// #define ADC_CHANNEL ADC_CHANNEL_5 // ratio of voltage divider = 3.0 (R17=200k, R18=100k) #define ADC_MULTIPLIER 3.1 // 3.0 + a bit for being optimistic diff --git a/variants/esp32s3/esp32s3.ini b/variants/esp32s3/esp32s3.ini index 29941544230..17b451d4246 100644 --- a/variants/esp32s3/esp32s3.ini +++ b/variants/esp32s3/esp32s3.ini @@ -4,6 +4,16 @@ custom_esp32_kind = esp32s3 monitor_speed = 115200 +build_flags = + ${esp32_common.build_flags} + -mtext-section-literals + +custom_sdkconfig = + ${esp32_common.custom_sdkconfig} + ; CONFIG_BT_NIMBLE_EXT_ADV=y + ; CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE=257 + ; CONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES=2 + lib_deps = ${esp32_common.lib_deps} # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master diff --git a/variants/esp32s3/hackaday-communicator/platformio.ini b/variants/esp32s3/hackaday-communicator/platformio.ini index 8fd275c0e9a..cee393e48af 100644 --- a/variants/esp32s3/hackaday-communicator/platformio.ini +++ b/variants/esp32s3/hackaday-communicator/platformio.ini @@ -16,5 +16,5 @@ build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/hackaday-communicator lib_deps = ${esp32s3_base.lib_deps} - # renovate: datasource=git-refs depName=meshtastic-Arduino_GFX packageName=https://github.com/meshtastic/Arduino_GFX gitBranch=master - https://github.com/meshtastic/Arduino_GFX/archive/054e81ffaf23784830a734e3c184346789349406.zip \ No newline at end of file + # renovate: datasource=git-refs depName=moononournation-Arduino_GFX packageName=https://github.com/moononournation/Arduino_GFX gitBranch=master + https://github.com/moononournation/Arduino_GFX/archive/refs/tags/v1.6.5.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_capsule_sensor_v3/variant.h b/variants/esp32s3/heltec_capsule_sensor_v3/variant.h index f689b20a8a1..71c5f0743cf 100644 --- a/variants/esp32s3/heltec_capsule_sensor_v3/variant.h +++ b/variants/esp32s3/heltec_capsule_sensor_v3/variant.h @@ -8,7 +8,7 @@ #define BUTTON_ACTIVE_PULLUP false #define BATTERY_PIN 7 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO7_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_6 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER (4.9 * 1.045) #define ADC_CTRL 36 // active HIGH, powers the voltage divider. Only on 1.1 diff --git a/variants/esp32s3/heltec_sensor_hub/variant.h b/variants/esp32s3/heltec_sensor_hub/variant.h index 64255c038da..64450e0eaea 100644 --- a/variants/esp32s3/heltec_sensor_hub/variant.h +++ b/variants/esp32s3/heltec_sensor_hub/variant.h @@ -6,7 +6,7 @@ #define BUTTON_ACTIVE_PULLUP false #define BATTERY_PIN 7 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO7_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_6 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER (4.9 * 1.045) #define ADC_CTRL 34 // active HIGH, powers the voltage divider. Only on 1.1 diff --git a/variants/esp32s3/heltec_v3/variant.h b/variants/esp32s3/heltec_v3/variant.h index d2d904d9c21..e4a2004e56c 100644 --- a/variants/esp32s3/heltec_v3/variant.h +++ b/variants/esp32s3/heltec_v3/variant.h @@ -16,7 +16,7 @@ #define ADC_CTRL 37 #define ADC_CTRL_ENABLED LOW #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 * 1.045 diff --git a/variants/esp32s3/heltec_v4/variant.h b/variants/esp32s3/heltec_v4/variant.h index 72f55d09fa8..5a806653e49 100644 --- a/variants/esp32s3/heltec_v4/variant.h +++ b/variants/esp32s3/heltec_v4/variant.h @@ -4,7 +4,7 @@ #define ADC_CTRL 37 #define ADC_CTRL_ENABLED HIGH #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 * 1.045 diff --git a/variants/esp32s3/heltec_v4_r8/variant.h b/variants/esp32s3/heltec_v4_r8/variant.h index 1f638f24cc9..d59f0ae2cfc 100644 --- a/variants/esp32s3/heltec_v4_r8/variant.h +++ b/variants/esp32s3/heltec_v4_r8/variant.h @@ -3,7 +3,7 @@ #define BUTTON_PIN 0 #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 * 1.035 diff --git a/variants/esp32s3/heltec_vision_master_e213/platformio.ini b/variants/esp32s3/heltec_vision_master_e213/platformio.ini index bd1b73d2b11..fb97fd98319 100644 --- a/variants/esp32s3/heltec_vision_master_e213/platformio.ini +++ b/variants/esp32s3/heltec_vision_master_e213/platformio.ini @@ -16,6 +16,7 @@ board = heltec_vision_master_e213 board_build.partitions = default_8MB.csv build_flags = ${esp32s3_base.build_flags} + -DBOARD_HAS_PSRAM -Ivariants/esp32s3/heltec_vision_master_e213 -DHELTEC_VISION_MASTER_E213 -DUSE_EINK @@ -44,6 +45,7 @@ build_src_filter = build_flags = ${esp32s3_base.build_flags} ${inkhud.build_flags} + -D BOARD_HAS_PSRAM -I variants/esp32s3/heltec_vision_master_e213 -D HELTEC_VISION_MASTER_E213 lib_deps = diff --git a/variants/esp32s3/heltec_vision_master_e213/variant.h b/variants/esp32s3/heltec_vision_master_e213/variant.h index c9aaa2ee805..931a2d7bb12 100644 --- a/variants/esp32s3/heltec_vision_master_e213/variant.h +++ b/variants/esp32s3/heltec_vision_master_e213/variant.h @@ -28,7 +28,7 @@ #define ADC_CTRL 46 #define ADC_CTRL_ENABLED HIGH #define BATTERY_PIN 7 -#define ADC_CHANNEL ADC1_GPIO7_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_6 #define ADC_MULTIPLIER 4.9 * 1.03 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 diff --git a/variants/esp32s3/heltec_vision_master_e290/variant.h b/variants/esp32s3/heltec_vision_master_e290/variant.h index b32715e3902..daeac0a51cf 100644 --- a/variants/esp32s3/heltec_vision_master_e290/variant.h +++ b/variants/esp32s3/heltec_vision_master_e290/variant.h @@ -27,7 +27,7 @@ #define ADC_CTRL 46 #define ADC_CTRL_ENABLED HIGH #define BATTERY_PIN 7 -#define ADC_CHANNEL ADC1_GPIO7_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_6 #define ADC_MULTIPLIER 4.9 * 1.03 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 diff --git a/variants/esp32s3/heltec_vision_master_t190/variant.h b/variants/esp32s3/heltec_vision_master_t190/variant.h index a6a80920758..037460c2f49 100644 --- a/variants/esp32s3/heltec_vision_master_t190/variant.h +++ b/variants/esp32s3/heltec_vision_master_t190/variant.h @@ -45,7 +45,7 @@ #define ADC_CTRL 46 #define ADC_CTRL_ENABLED HIGH #define BATTERY_PIN 6 -#define ADC_CHANNEL ADC1_GPIO6_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_5 #define ADC_MULTIPLIER 4.9 * 1.03 // Voltage divider is roughly 1:1 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // Voltage divider output is quite high diff --git a/variants/esp32s3/heltec_wireless_paper/variant.h b/variants/esp32s3/heltec_wireless_paper/variant.h index 7f57bb67f5e..c18fa40c19b 100644 --- a/variants/esp32s3/heltec_wireless_paper/variant.h +++ b/variants/esp32s3/heltec_wireless_paper/variant.h @@ -24,7 +24,7 @@ #define VEXT_ON_VALUE LOW #define ADC_CTRL 19 #define BATTERY_PIN 20 -#define ADC_CHANNEL ADC2_GPIO20_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_9 #define ADC_MULTIPLIER 2 // Voltage divider is roughly 1:1 #define BAT_MEASURE_ADC_UNIT 2 // Use ADC2 #define ADC_ATTENUATION ADC_ATTEN_DB_12 // Voltage divider output is quite high diff --git a/variants/esp32s3/heltec_wireless_paper_v1/variant.h b/variants/esp32s3/heltec_wireless_paper_v1/variant.h index 59dd485f640..62615fa4674 100644 --- a/variants/esp32s3/heltec_wireless_paper_v1/variant.h +++ b/variants/esp32s3/heltec_wireless_paper_v1/variant.h @@ -25,7 +25,7 @@ #define VEXT_ON_VALUE LOW #define ADC_CTRL 19 #define BATTERY_PIN 20 -#define ADC_CHANNEL ADC2_GPIO20_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_9 #define ADC_MULTIPLIER 2 // Voltage divider is roughly 1:1 #define BAT_MEASURE_ADC_UNIT 2 // Use ADC2 #define ADC_ATTENUATION ADC_ATTEN_DB_12 // Voltage divider output is quite high diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index 1450bb45ce3..3f11bf7e2f1 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -15,10 +15,12 @@ board = heltec_wireless_tracker board_build.partitions = default_8MB.csv upload_protocol = esptool -build_flags = +build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/heltec_wireless_tracker -D HELTEC_TRACKER_V1_1 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DARDUINO_USB_MODE=1 ;-D DEBUG_DISABLED ; uncomment this line to disable DEBUG output lib_deps = diff --git a/variants/esp32s3/heltec_wireless_tracker/variant.h b/variants/esp32s3/heltec_wireless_tracker/variant.h index b40e400114e..e2049bcf659 100644 --- a/variants/esp32s3/heltec_wireless_tracker/variant.h +++ b/variants/esp32s3/heltec_wireless_tracker/variant.h @@ -39,7 +39,7 @@ #define BUTTON_PIN 0 #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 * 1.045 #define ADC_CTRL 2 // active HIGH, powers the voltage divider. Only on 1.1 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/variant.h b/variants/esp32s3/heltec_wireless_tracker_V1_0/variant.h index e7d3f93c109..16afbee8158 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/variant.h +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/variant.h @@ -35,7 +35,7 @@ #define BUTTON_PIN 0 #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 * 1.045 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/variant.h b/variants/esp32s3/heltec_wireless_tracker_v2/variant.h index 7c797a5031b..a3d52fd3c32 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/variant.h +++ b/variants/esp32s3/heltec_wireless_tracker_v2/variant.h @@ -34,7 +34,7 @@ #define BUTTON_PIN 0 #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 * 1.045 #define ADC_CTRL 2 // active HIGH, powers the voltage divider. diff --git a/variants/esp32s3/heltec_wsl_v3/variant.h b/variants/esp32s3/heltec_wsl_v3/variant.h index c81f45d3b0b..26276d22791 100644 --- a/variants/esp32s3/heltec_wsl_v3/variant.h +++ b/variants/esp32s3/heltec_wsl_v3/variant.h @@ -10,7 +10,7 @@ #define ADC_CTRL 37 #define ADC_CTRL_ENABLED LOW #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 * 1.045 diff --git a/variants/esp32s3/icarus/platformio.ini b/variants/esp32s3/icarus/platformio.ini index 0aaff52f586..de29cc2cf4b 100644 --- a/variants/esp32s3/icarus/platformio.ini +++ b/variants/esp32s3/icarus/platformio.ini @@ -7,12 +7,10 @@ board_build.mcu = esp32s3 board_build.partitions = default_8MB.csv upload_protocol = esptool upload_speed = 921600 -build_unflags = - ${esp32s3_base.build_unflags} - -DARDUINO_USB_MODE=1 build_flags = ${esp32s3_base.build_flags} -D PRIVATE_HW -I variants/esp32s3/icarus -DBOARD_HAS_PSRAM + -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=0 diff --git a/variants/esp32s3/m5stack_cardputer_adv/variant.h b/variants/esp32s3/m5stack_cardputer_adv/variant.h index 48437cd1346..7b25a0193cc 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/variant.h +++ b/variants/esp32s3/m5stack_cardputer_adv/variant.h @@ -84,7 +84,7 @@ #define NEOPIXEL_TYPE (NEO_GRB + NEO_KHZ800) #define BATTERY_PIN 10 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO10_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_9 #define ADC_MULTIPLIER 2 * 1.02 // 100k + 100k, and add 2% to kick the voltage over the max voltage to show charging. // BMI270 6-axis IMU on internal I2C bus diff --git a/variants/esp32s3/mesh-tab/variant.h b/variants/esp32s3/mesh-tab/variant.h index 30042b90f46..5b450aa6cc6 100644 --- a/variants/esp32s3/mesh-tab/variant.h +++ b/variants/esp32s3/mesh-tab/variant.h @@ -10,7 +10,7 @@ #define BATTERY_PIN 4 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage // ratio of voltage divider (100k, 220k) #define ADC_MULTIPLIER 1.6 // 1.45 + 10% for correction of display undervoltage. -#define ADC_CHANNEL ADC1_GPIO4_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_3 // LED #define LED_POWER 21 diff --git a/variants/esp32s3/mini-epaper-s3/variant.h b/variants/esp32s3/mini-epaper-s3/variant.h index 0b640f9cff4..1ac0a1e74bd 100644 --- a/variants/esp32s3/mini-epaper-s3/variant.h +++ b/variants/esp32s3/mini-epaper-s3/variant.h @@ -18,7 +18,7 @@ #define BATTERY_PIN 2 // A battery voltage measurement pin, voltage divider connected here to // measure battery voltage ratio of voltage divider = 2.0 (assumption) #define ADC_MULTIPLIER 2.11 // 2.0 + 10% for correction of display undervoltage. -#define ADC_CHANNEL ADC1_GPIO2_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_1 // Display (E-Ink) #define PIN_EINK_EN 42 diff --git a/variants/esp32s3/picomputer-s3/variant.h b/variants/esp32s3/picomputer-s3/variant.h index 60afac002bf..10adff3b635 100644 --- a/variants/esp32s3/picomputer-s3/variant.h +++ b/variants/esp32s3/picomputer-s3/variant.h @@ -11,7 +11,7 @@ // A battery voltage measurement pin, voltage divider connected here to measure battery voltage // ratio of voltage divider = 3.0 (R11=200k, R7=100k) #define ADC_MULTIPLIER 3.1 // 3.0 with correction of display undervoltage. -#define ADC_CHANNEL ADC1_GPIO2_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_1 #define USE_RF95 // RFM95/SX127x diff --git a/variants/esp32s3/rak3312/platformio.ini b/variants/esp32s3/rak3312/platformio.ini index 87e5b63fff8..dcdb2974ca1 100644 --- a/variants/esp32s3/rak3312/platformio.ini +++ b/variants/esp32s3/rak3312/platformio.ini @@ -29,7 +29,7 @@ board_level = extra upload_protocol = esptool build_flags = - ${esp32_base.build_flags} + ${esp32s3_base.build_flags} -D RAK3312 -D _VARIANT_RAK3112_ -I variants/esp32s3/rak3312 diff --git a/variants/esp32s3/rak3312/variant.h b/variants/esp32s3/rak3312/variant.h index ee0fff524c3..38150686636 100644 --- a/variants/esp32s3/rak3312/variant.h +++ b/variants/esp32s3/rak3312/variant.h @@ -30,7 +30,7 @@ #define LED_STATE_ON 1 // State when LED is litted #define BATTERY_PIN 1 -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #ifdef _VARIANT_RAK3112_ // Modular variant (stamp) #define ADC_MULTIPLIER 2.11 @@ -55,4 +55,4 @@ #define GPS_TX_PIN 43 #define GPS_RX_PIN 44 -#endif \ No newline at end of file +#endif diff --git a/variants/esp32s3/rak_wismesh_tap_v2/variant.h b/variants/esp32s3/rak_wismesh_tap_v2/variant.h index 90cb12053cb..f8672edac3f 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/variant.h +++ b/variants/esp32s3/rak_wismesh_tap_v2/variant.h @@ -51,7 +51,7 @@ #define USE_VIRTUAL_KEYBOARD 1 #define BATTERY_PIN 1 -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_MULTIPLIER 1.667 #define PIN_BUZZER 38 diff --git a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini index bb52f801bbc..9b01033df27 100644 --- a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini +++ b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini @@ -13,7 +13,8 @@ custom_meshtastic_has_mui = true extends = esp32s3_base platform_packages = - platformio/framework-arduinoespressif32 @ https://github.com/mverch67/arduino-esp32/archive/aef7fef6de3329ed6f75512d46d63bba12b09bb5.zip ; add_tca9535 (based on 2.0.16) + ; Version needs to match the pioarduino version used in esp32_common.ini + platformio/framework-arduinoespressif32 @ https://github.com/mverch67/arduino-esp32#82aee17619ea2940de03b0db4d082d5def657771 board = seeed-sensecap-indicator board_check = true @@ -23,7 +24,7 @@ upload_protocol = esptool build_flags = ${esp32s3_base.build_flags} -Ivariants/esp32s3/seeed-sensecap-indicator -DSENSECAP_INDICATOR - -DCONFIG_ARDUHAL_LOG_COLORS + -DUSE_ARDUINO_HAL_GPIO ; patch for lovyanGFX to use digitalWrite instead of direct register access for GPIO control, which is necessary for the IO expander to work correctly -DRADIOLIB_DEBUG_SPI=0 -DRADIOLIB_DEBUG_PROTOCOL=0 -DRADIOLIB_DEBUG_BASIC=0 @@ -32,19 +33,48 @@ build_flags = ${esp32s3_base.build_flags} -DIO_EXPANDER=0x40 -DIO_EXPANDER_IRQ=42 ;-DIO_EXPANDER_DEBUG - -DUSE_ARDUINO_HAL_GPIO lib_deps = ${esp32s3_base.lib_deps} - ; TODO switch back to official LovyanGFX - https://github.com/mverch67/LovyanGFX/archive/4c76238c1344162a234ae917b27651af146d6fb2.zip + https://github.com/mverch67/LovyanGFX/archive/9abe502add013f392a1898d7dc48d65ddc112754.zip # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM earlephilhower/ESP8266SAM@1.1.0 +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + CONFIG_AUTOSTART_ARDUINO=y + CONFIG_LOG_DEFAULT_LEVEL_INFO=y + CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y + CONFIG_SPIRAM_MODE_OCT=y +; CONFIG_SPIRAM_SPEED_120M=y +; CONFIG_SPIRAM_SPEED=120 + CONFIG_SPIRAM_SPEED_80M=y + CONFIG_SPIRAM_SPEED=80 + CONFIG_SPIRAM_XIP_FROM_PSRAM=y + CONFIG_LCD_RGB_ISR_IRAM_SAFE=y + CONFIG_GDMA_CTRL_FUNC_IN_IRAM=y + CONFIG_I2S_ISR_IRAM_SAFE=y + CONFIG_GDMA_ISR_IRAM_SAFE=y + CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y + CONFIG_ESP32S3_DATA_CACHE_64KB=y + CONFIG_ESP32S3_DATA_CACHE_LINE_64B=y + CONFIG_I2C_SKIP_LEGACY_CONFLICT_CHECK=y + CONFIG_HAL_DEFAULT_ASSERTION_LEVEL=0 + CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y + CONFIG_ESPTOOLPY_FLASHSIZE="8MB" +; CONFIG_ESPTOOLPY_FLASHFREQ_120M=y +; CONFIG_ESPTOOLPY_FLASHFREQ="120m" + '# CONFIG_ESPTOOLPY_FLASHFREQ_120M is not set' + CONFIG_ESPTOOLPY_FLASHFREQ_80M=y + CONFIG_ESPTOOLPY_FLASHFREQ="80m" + CONFIG_COMPILER_OPTIMIZATION_PERF=y + CONFIG_BOOTLOADER_LOG_LEVEL_NONE=n + CONFIG_BOOTLOADER_LOG_LEVEL_INFO=y + [env:seeed-sensecap-indicator-tft] extends = env:seeed-sensecap-indicator -board_level = pr +board_check = true upload_speed = 460800 build_flags = @@ -54,10 +84,10 @@ build_flags = -D HAS_SCREEN=1 -D HAS_TFT=1 -D DISPLAY_SET_RESOLUTION - -D RAM_SIZE=4096 - -D LV_LVGL_H_INCLUDE_SIMPLE - -D LV_CONF_INCLUDE_SIMPLE - -D LV_COMP_CONF_INCLUDE_SIMPLE + -D RAM_SIZE=3072 + -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 @@ -77,5 +107,6 @@ build_flags = lib_deps = ${env:seeed-sensecap-indicator.lib_deps} ${device-ui_base.lib_deps} - ; TODO switch back to official bb_captouch + ;# renovate: datasource=github-tags depName=bb_captouch packageName=bitbank2/bb_captouch + ;https://github.com/bitbank2/bb_captouch/archive/refs/tags/1.3.1.zip ; not working https://github.com/mverch67/bb_captouch/archive/8626412fe650d808a267791c0eae6e5860c85a5d.zip ; alternative touch library supporting FT6x36 diff --git a/variants/esp32s3/seeed-sensecap-indicator/variant.h b/variants/esp32s3/seeed-sensecap-indicator/variant.h index 8fa9e239366..db0054348a9 100644 --- a/variants/esp32s3/seeed-sensecap-indicator/variant.h +++ b/variants/esp32s3/seeed-sensecap-indicator/variant.h @@ -4,7 +4,7 @@ // This board has a serial coprocessor for sensor readings #define SENSOR_RP2040_TXD 19 #define SENSOR_RP2040_RXD 20 -#define SENSOR_PORT_NUM 2 +#define SENSOR_PORT_NUM UART_NUM_2 #define SENSOR_BAUD_RATE 115200 #define BUTTON_PIN 38 @@ -14,7 +14,7 @@ // #define BUTTON_NEED_PULLUP // #define BATTERY_PIN 27 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -// #define ADC_CHANNEL ADC1_GPIO27_CHANNEL +// #define ADC_CHANNEL ADC2_GPIO27_CHANNEL // #define ADC_MULTIPLIER 2 // ST7701 TFT LCD diff --git a/variants/esp32s3/seeed_xiao_s3/platformio.ini b/variants/esp32s3/seeed_xiao_s3/platformio.ini index b0e66241b4c..ee457eaf072 100644 --- a/variants/esp32s3/seeed_xiao_s3/platformio.ini +++ b/variants/esp32s3/seeed_xiao_s3/platformio.ini @@ -17,12 +17,8 @@ board_check = true board_build.partitions = default_8MB.csv upload_protocol = esptool upload_speed = 921600 -build_unflags = - ${esp32s3_base.build_unflags} - -DARDUINO_USB_MODE=1 build_flags = ${esp32s3_base.build_flags} -D SEEED_XIAO_S3 -I variants/esp32s3/seeed_xiao_s3 -DBOARD_HAS_PSRAM - -DARDUINO_USB_MODE=0 diff --git a/variants/esp32s3/seeed_xiao_s3/variant.h b/variants/esp32s3/seeed_xiao_s3/variant.h index cbdbf8eb892..7eee372fa21 100644 --- a/variants/esp32s3/seeed_xiao_s3/variant.h +++ b/variants/esp32s3/seeed_xiao_s3/variant.h @@ -37,7 +37,7 @@ L76K GPS Module Information : https://www.seeedstudio.com/L76K-GNSS-Module-for-S #define BUTTON_NEED_PULLUP #define BATTERY_PIN -1 -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define BATTERY_SENSE_RESOLUTION_BITS 12 /*Warning: diff --git a/variants/esp32s3/station-g2/variant.h b/variants/esp32s3/station-g2/variant.h index a343823c7e6..9e844588f3a 100755 --- a/variants/esp32s3/station-g2/variant.h +++ b/variants/esp32s3/station-g2/variant.h @@ -20,7 +20,7 @@ Board Information: https://wiki.uniteng.com/en/meshtastic/station-g2 /* #define BATTERY_PIN 4 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO4_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_3 #define ADC_MULTIPLIER 4 #define BATTERY_SENSE_SAMPLES 15 // Set the number of samples, It has an effect of increasing sensitivity. #define BAT_FULLVOLT 8400 diff --git a/variants/esp32s3/t-beam-1w/variant.h b/variants/esp32s3/t-beam-1w/variant.h index 52e99320e50..673b7c4c017 100644 --- a/variants/esp32s3/t-beam-1w/variant.h +++ b/variants/esp32s3/t-beam-1w/variant.h @@ -72,7 +72,7 @@ // Battery ADC #define BATTERY_PIN 4 -#define ADC_CHANNEL ADC1_GPIO4_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_3 #define BATTERY_SENSE_SAMPLES 30 #define ADC_MULTIPLIER 2.9333 diff --git a/variants/esp32s3/t-deck/variant.h b/variants/esp32s3/t-deck/variant.h index eb1bbdfef2c..1e6d7b97d9a 100644 --- a/variants/esp32s3/t-deck/variant.h +++ b/variants/esp32s3/t-deck/variant.h @@ -53,7 +53,7 @@ #define BATTERY_PIN 4 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage // ratio of voltage divider = 2.0 (RD2=100k, RD3=100k) #define ADC_MULTIPLIER 2.11 // 2.0 + 10% for correction of display undervoltage. -#define ADC_CHANNEL ADC1_GPIO4_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_3 // keyboard #define I2C_SDA 18 // I2C pins for this board diff --git a/variants/esp32s3/t-eth-elite/platformio.ini b/variants/esp32s3/t-eth-elite/platformio.ini index 5ed67e9776d..5b1f01db743 100644 --- a/variants/esp32s3/t-eth-elite/platformio.ini +++ b/variants/esp32s3/t-eth-elite/platformio.ini @@ -10,10 +10,15 @@ build_flags = -D HAS_UDP_MULTICAST=1 -I variants/esp32s3/t-eth-elite -lib_ignore = - Ethernet +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + CONFIG_ETH_ENABLED=y + CONFIG_ARDUINO_SELECTIVE_Ethernet=y -lib_deps = - ${esp32s3_base.lib_deps} - # renovate: datasource=github-tags depName=ETHClass2 packageName=meshtastic/ETHClass2 - https://github.com/meshtastic/ETHClass2/archive/v1.0.0.zip +; lib_ignore = +; Ethernet + +; lib_deps = +; ${esp32s3_base.lib_deps} +; # renovate: datasource=github-tags depName=ETHClass2 packageName=meshtastic/ETHClass2 +; https://github.com/meshtastic/ETHClass2/archive/v1.0.0.zip diff --git a/variants/esp32s3/t5s3_epaper/variant.h b/variants/esp32s3/t5s3_epaper/variant.h index 49579a39cca..fdf8589da7b 100644 --- a/variants/esp32s3/t5s3_epaper/variant.h +++ b/variants/esp32s3/t5s3_epaper/variant.h @@ -1,3 +1,4 @@ +#include "pins_arduino.h" // Display (E-Ink) ED047TC1 - 8bit parallel #define EPD_WIDTH 960 diff --git a/variants/esp32s3/tlora_t3s3_epaper/variant.h b/variants/esp32s3/tlora_t3s3_epaper/variant.h index 0f4875fc4dd..77dccb61603 100644 --- a/variants/esp32s3/tlora_t3s3_epaper/variant.h +++ b/variants/esp32s3/tlora_t3s3_epaper/variant.h @@ -12,7 +12,7 @@ #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to // measure battery voltage ratio of voltage divider = 2.0 (assumption) #define ADC_MULTIPLIER 2.11 // 2.0 + 10% for correction of display undervoltage. -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define I2C_SDA SDA #define I2C_SCL SCL diff --git a/variants/esp32s3/tlora_t3s3_v1/variant.h b/variants/esp32s3/tlora_t3s3_v1/variant.h index 02e2a0e429e..7c6df9be953 100644 --- a/variants/esp32s3/tlora_t3s3_v1/variant.h +++ b/variants/esp32s3/tlora_t3s3_v1/variant.h @@ -6,7 +6,7 @@ #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage // ratio of voltage divider = 2.0 (R42=100k, R43=100k) #define ADC_MULTIPLIER 2.11 // 2.0 + 10% for correction of display undervoltage. -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define I2C_SDA 18 // I2C pins for this board #define I2C_SCL 17 diff --git a/variants/esp32s3/tracksenger/internal/variant.h b/variants/esp32s3/tracksenger/internal/variant.h index b2822c24b29..c015be28019 100644 --- a/variants/esp32s3/tracksenger/internal/variant.h +++ b/variants/esp32s3/tracksenger/internal/variant.h @@ -35,7 +35,7 @@ #define BUTTON_PIN 0 #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 #define ADC_CTRL 2 // active HIGH, powers the voltage divider. Only on 1.1 diff --git a/variants/esp32s3/tracksenger/lcd/variant.h b/variants/esp32s3/tracksenger/lcd/variant.h index 6c32ff27909..38df4d3abb0 100644 --- a/variants/esp32s3/tracksenger/lcd/variant.h +++ b/variants/esp32s3/tracksenger/lcd/variant.h @@ -59,7 +59,7 @@ #define BUTTON_PIN 0 #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 #define ADC_CTRL 2 // active HIGH, powers the voltage divider. Only on 1.1 diff --git a/variants/esp32s3/tracksenger/oled/variant.h b/variants/esp32s3/tracksenger/oled/variant.h index 72762c7afef..f85e845f3a1 100644 --- a/variants/esp32s3/tracksenger/oled/variant.h +++ b/variants/esp32s3/tracksenger/oled/variant.h @@ -36,7 +36,7 @@ #define BUTTON_PIN 0 #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 #define ADC_CTRL 2 // active HIGH, powers the voltage divider. Only on 1.1 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index 56838b1fc70..8c551cf4193 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -21,7 +21,6 @@ build_flags = ${esp32s3_base.build_flags} -D UNPHONE -I variants/esp32s3/unphone - -D ARDUINO_USB_MODE=0 -D UNPHONE_ACCEL=0 -D UNPHONE_TOUCHS=0 -D UNPHONE_SDCARD=0 From 3261c04afbdcc7ccfde40591b514d8f438837cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 18 May 2026 11:18:40 +0200 Subject: [PATCH 203/225] Fix Antenna Switch on Cardputer (#10491) --- .../m5stack_cardputer_adv/variant.cpp | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp b/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp index 7ec9dca807a..0c252159f4c 100644 --- a/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp +++ b/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp @@ -3,14 +3,63 @@ #ifdef M5STACK_CARDPUTER_ADV #include "AudioBoard.h" +#include DriverPins PinsAudioBoardES8311; AudioBoard board(AudioDriverES8311, PinsAudioBoardES8311); +// PI4IOE5V6408 on the optional Cap LoRa-1262 (and Cap LoRa868). +#define PI4IO_ADDR 0x43 +#define PI4IO_REG_IO_DIR 0x03 +#define PI4IO_REG_OUT_SET 0x05 +#define PI4IO_REG_OUT_H_IM 0x07 + +static TwoWire *findLoraCapBus() +{ + TwoWire *candidates[] = {&Wire1, &Wire}; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + candidates[i]->beginTransmission(PI4IO_ADDR); + if (candidates[i]->endTransmission() == 0) { + return candidates[i]; + } + } + return nullptr; +} + +static bool pi4ioWrite(TwoWire *bus, uint8_t reg, uint8_t val) +{ + bus->beginTransmission(PI4IO_ADDR); + bus->write(reg); + bus->write(val); + uint8_t status = bus->endTransmission(); + if (status != 0) { + LOG_DEBUG("PI4IO write reg=0x%02x val=0x%02x failed, I2C status=%u", reg, val, status); + return false; + } + return true; +} + +static void initLoraCap() +{ + TwoWire *bus = findLoraCapBus(); + if (!bus) { + LOG_ERROR("Cap LoRa-1262 not found"); + return; + } + bool ok = pi4ioWrite(bus, PI4IO_REG_IO_DIR, 0b00000001); + ok = ok && pi4ioWrite(bus, PI4IO_REG_OUT_H_IM, 0b00000001); + ok = ok && pi4ioWrite(bus, PI4IO_REG_OUT_SET, 0b00000001); + if (!ok) { + LOG_ERROR("Antenna switch init failed"); + } +} + // M5stack Cardputer ADV specific init void lateInitVariant() { + initLoraCap(); + // AudioDriverLogger.begin(Serial, AudioDriverLogLevel::Debug); // I2C: function, scl, sda PinsAudioBoardES8311.addI2C(PinFunction::CODEC, Wire); From 6199faacf19193694c090a5d904f838269a22397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 18 May 2026 23:24:32 +0200 Subject: [PATCH 204/225] cherry pick backport fix for cardputer --- .../m5stack_cardputer_adv/variant.cpp | 56 ++++++++++++++++++- .../m5stack_cardputer_adv/platformio.ini | 3 - .../esp32s3/m5stack_cardputer_adv/variant.h | 1 + 3 files changed, 56 insertions(+), 4 deletions(-) rename {variants/esp32s3 => src/platform/extra_variants}/m5stack_cardputer_adv/variant.cpp (50%) diff --git a/variants/esp32s3/m5stack_cardputer_adv/variant.cpp b/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp similarity index 50% rename from variants/esp32s3/m5stack_cardputer_adv/variant.cpp rename to src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp index 2bbe8e2e332..0c252159f4c 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/variant.cpp +++ b/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp @@ -1,13 +1,65 @@ -#include "AudioBoard.h" #include "configuration.h" +#ifdef M5STACK_CARDPUTER_ADV + +#include "AudioBoard.h" +#include + DriverPins PinsAudioBoardES8311; AudioBoard board(AudioDriverES8311, PinsAudioBoardES8311); +// PI4IOE5V6408 on the optional Cap LoRa-1262 (and Cap LoRa868). +#define PI4IO_ADDR 0x43 +#define PI4IO_REG_IO_DIR 0x03 +#define PI4IO_REG_OUT_SET 0x05 +#define PI4IO_REG_OUT_H_IM 0x07 + +static TwoWire *findLoraCapBus() +{ + TwoWire *candidates[] = {&Wire1, &Wire}; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + candidates[i]->beginTransmission(PI4IO_ADDR); + if (candidates[i]->endTransmission() == 0) { + return candidates[i]; + } + } + return nullptr; +} + +static bool pi4ioWrite(TwoWire *bus, uint8_t reg, uint8_t val) +{ + bus->beginTransmission(PI4IO_ADDR); + bus->write(reg); + bus->write(val); + uint8_t status = bus->endTransmission(); + if (status != 0) { + LOG_DEBUG("PI4IO write reg=0x%02x val=0x%02x failed, I2C status=%u", reg, val, status); + return false; + } + return true; +} + +static void initLoraCap() +{ + TwoWire *bus = findLoraCapBus(); + if (!bus) { + LOG_ERROR("Cap LoRa-1262 not found"); + return; + } + bool ok = pi4ioWrite(bus, PI4IO_REG_IO_DIR, 0b00000001); + ok = ok && pi4ioWrite(bus, PI4IO_REG_OUT_H_IM, 0b00000001); + ok = ok && pi4ioWrite(bus, PI4IO_REG_OUT_SET, 0b00000001); + if (!ok) { + LOG_ERROR("Antenna switch init failed"); + } +} + // M5stack Cardputer ADV specific init void lateInitVariant() { + initLoraCap(); + // AudioDriverLogger.begin(Serial, AudioDriverLogLevel::Debug); // I2C: function, scl, sda PinsAudioBoardES8311.addI2C(PinFunction::CODEC, Wire); @@ -38,3 +90,5 @@ void lateInitVariant() es8311_write_reg(0x32, 0xBF); // DAC volume (0dB) es8311_write_reg(0x37, 0x08); // EQ bypass } + +#endif diff --git a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini index 3b378ed942a..69c4f52a5bd 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini +++ b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini @@ -10,9 +10,6 @@ build_flags = -D M5STACK_CARDPUTER_ADV -D ARDUINO_USB_CDC_ON_BOOT=1 -I variants/esp32s3/m5stack_cardputer_adv -build_src_filter = - ${esp32s3_base.build_src_filter} - +<../variants/esp32s3/m5stack_cardputer_adv> lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main diff --git a/variants/esp32s3/m5stack_cardputer_adv/variant.h b/variants/esp32s3/m5stack_cardputer_adv/variant.h index 5fdb1436eb7..48437cd1346 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/variant.h +++ b/variants/esp32s3/m5stack_cardputer_adv/variant.h @@ -9,6 +9,7 @@ #define ST7789_BUSY -1 // #define VTFT_CTRL 38 #define VTFT_LEDA 38 +#define TFT_BACKLIGHT_ON HIGH // #define ST7789_BL (32+6) #define ST7789_SPI_HOST SPI2_HOST // #define TFT_BL (32+6) From 0148a89ddb562d4497b345f95a5ef0c80f5b1f9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 20:49:28 -0500 Subject: [PATCH 205/225] Upgrade trunk (#10493) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index e055a6d505c..77006cf99a8 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -26,7 +26,7 @@ lint: - hadolint@2.14.0 - shfmt@3.6.0 - shellcheck@0.11.0 - - black@26.3.1 + - black@26.5.0 - git-diff-check - gitleaks@8.30.1 - clang-format@16.0.3 From af3739fd6356e57971152171e1bbb1d7919a8f55 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 07:22:15 +0200 Subject: [PATCH 206/225] Update protobufs (#10499) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/admin.pb.cpp | 3 + src/mesh/generated/meshtastic/admin.pb.h | 67 +++++++++++++- src/mesh/generated/meshtastic/config.pb.h | 8 +- src/mesh/generated/meshtastic/mesh.pb.cpp | 5 + src/mesh/generated/meshtastic/mesh.pb.h | 91 ++++++++++++++++++- .../generated/meshtastic/module_config.pb.h | 2 +- 7 files changed, 171 insertions(+), 7 deletions(-) diff --git a/protobufs b/protobufs index b302d923327..59cb394dcfc 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit b302d923327402fbe49efcf15ff1b6ef2361b22b +Subproject commit 59cb394dcfc4432cb216358ca26e861c7d13f462 diff --git a/src/mesh/generated/meshtastic/admin.pb.cpp b/src/mesh/generated/meshtastic/admin.pb.cpp index 3dcc241d9b8..945840c0f4d 100644 --- a/src/mesh/generated/meshtastic/admin.pb.cpp +++ b/src/mesh/generated/meshtastic/admin.pb.cpp @@ -15,6 +15,9 @@ PB_BIND(meshtastic_AdminMessage_InputEvent, meshtastic_AdminMessage_InputEvent, PB_BIND(meshtastic_AdminMessage_OTAEvent, meshtastic_AdminMessage_OTAEvent, AUTO) +PB_BIND(meshtastic_LockdownAuth, meshtastic_LockdownAuth, AUTO) + + PB_BIND(meshtastic_HamParameters, meshtastic_HamParameters, AUTO) diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index 58e0356ca39..e6f5110ad30 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -130,6 +130,41 @@ typedef struct _meshtastic_AdminMessage_OTAEvent { meshtastic_AdminMessage_OTAEvent_ota_hash_t ota_hash; } meshtastic_AdminMessage_OTAEvent; +typedef PB_BYTES_ARRAY_T(32) meshtastic_LockdownAuth_passphrase_t; +/* Lockdown passphrase delivery payload. + + One message handles three operations distinguished by content: + - Provision (first-time): passphrase set, lock_now=false. Firmware + generates DEK, wraps with passphrase-derived KEK, persists. + - Unlock: passphrase set, lock_now=false. Firmware verifies + passphrase against stored DEK, unlocks storage, authorizes the + connection that delivered this packet. + - Lock now: lock_now=true, passphrase ignored. Firmware revokes + all client auth and reboots into the locked state. + + Firmware decides between provision and unlock based on its own state + (whether a DEK file already exists). Clients do not need to track + which case applies. */ +typedef struct _meshtastic_LockdownAuth { + /* Passphrase bytes (1-32). Empty when lock_now is true. + Capped to 32 to match the proto cap on related security fields. */ + meshtastic_LockdownAuth_passphrase_t passphrase; + /* Optional override of the boot-count token TTL granted on success. + 0 = use firmware default (TOKEN_DEFAULT_BOOTS). + On reboot the firmware decrements this; when it reaches 0 the + device boots fully locked and requires a fresh passphrase. */ + uint32_t boots_remaining; + /* Optional wall-clock expiry for the unlock token, as absolute + Unix-epoch seconds. 0 = no time limit (only the boot-count TTL + applies). On boot, if the device RTC is set and now > this value, + the token is treated as expired. */ + uint32_t valid_until_epoch; + /* If true, ignore passphrase fields, immediately revoke all + connection-level admin authorization, and reboot the device into + the locked state. Always honoured regardless of current lock state. */ + bool lock_now; +} meshtastic_LockdownAuth; + /* Parameters for setting up Meshtastic for ameteur radio usage */ typedef struct _meshtastic_HamParameters { /* Amateur radio call sign, eg. KD2ABC */ @@ -384,6 +419,15 @@ typedef struct _meshtastic_AdminMessage { meshtastic_AdminMessage_OTAEvent ota_request; /* Parameters and sensor configuration */ meshtastic_SensorConfig sensor_config; + /* Lockdown passphrase delivery / unlock / lock-now command for hardened + firmware builds (see MESHTASTIC_LOCKDOWN). Used to provision the + passphrase on first boot, unlock encrypted storage on subsequent + reboots, re-verify on already-unlocked devices to authorize a new + client connection, or immediately re-lock the device. + + Replaces the earlier scheme that repurposed SecurityConfig.private_key + to carry passphrase bytes; that hack is retired. */ + meshtastic_LockdownAuth lockdown_auth; }; /* The node generates this key and sends it with any get_x_response packets. The client MUST include the same key with any set_x commands. Key expires after 300 seconds. @@ -429,6 +473,7 @@ extern "C" { + #define meshtastic_KeyVerificationAdmin_message_type_ENUMTYPE meshtastic_KeyVerificationAdmin_MessageType @@ -441,6 +486,7 @@ extern "C" { #define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}} +#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0} #define meshtastic_HamParameters_init_default {"", 0, 0, ""} #define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} #define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0} @@ -453,6 +499,7 @@ extern "C" { #define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}} +#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0} #define meshtastic_HamParameters_init_zero {"", 0, 0, ""} #define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} #define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0} @@ -470,6 +517,10 @@ extern "C" { #define meshtastic_AdminMessage_InputEvent_touch_y_tag 4 #define meshtastic_AdminMessage_OTAEvent_reboot_ota_mode_tag 1 #define meshtastic_AdminMessage_OTAEvent_ota_hash_tag 2 +#define meshtastic_LockdownAuth_passphrase_tag 1 +#define meshtastic_LockdownAuth_boots_remaining_tag 2 +#define meshtastic_LockdownAuth_valid_until_epoch_tag 3 +#define meshtastic_LockdownAuth_lock_now_tag 4 #define meshtastic_HamParameters_call_sign_tag 1 #define meshtastic_HamParameters_tx_power_tag 2 #define meshtastic_HamParameters_frequency_tag 3 @@ -560,6 +611,7 @@ extern "C" { #define meshtastic_AdminMessage_nodedb_reset_tag 100 #define meshtastic_AdminMessage_ota_request_tag 102 #define meshtastic_AdminMessage_sensor_config_tag 103 +#define meshtastic_AdminMessage_lockdown_auth_tag 104 #define meshtastic_AdminMessage_session_passkey_tag 101 /* Struct field encoding specification for nanopb */ @@ -621,7 +673,8 @@ X(a, STATIC, ONEOF, INT32, (payload_variant,factory_reset_config,factory X(a, STATIC, ONEOF, BOOL, (payload_variant,nodedb_reset,nodedb_reset), 100) \ X(a, STATIC, SINGULAR, BYTES, session_passkey, 101) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,ota_request,ota_request), 102) \ -X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config), 103) +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config), 103) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_auth,lockdown_auth), 104) #define meshtastic_AdminMessage_CALLBACK NULL #define meshtastic_AdminMessage_DEFAULT NULL #define meshtastic_AdminMessage_payload_variant_get_channel_response_MSGTYPE meshtastic_Channel @@ -644,6 +697,7 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config) #define meshtastic_AdminMessage_payload_variant_key_verification_MSGTYPE meshtastic_KeyVerificationAdmin #define meshtastic_AdminMessage_payload_variant_ota_request_MSGTYPE meshtastic_AdminMessage_OTAEvent #define meshtastic_AdminMessage_payload_variant_sensor_config_MSGTYPE meshtastic_SensorConfig +#define meshtastic_AdminMessage_payload_variant_lockdown_auth_MSGTYPE meshtastic_LockdownAuth #define meshtastic_AdminMessage_InputEvent_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, event_code, 1) \ @@ -659,6 +713,14 @@ X(a, STATIC, SINGULAR, BYTES, ota_hash, 2) #define meshtastic_AdminMessage_OTAEvent_CALLBACK NULL #define meshtastic_AdminMessage_OTAEvent_DEFAULT NULL +#define meshtastic_LockdownAuth_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \ +X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \ +X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \ +X(a, STATIC, SINGULAR, BOOL, lock_now, 4) +#define meshtastic_LockdownAuth_CALLBACK NULL +#define meshtastic_LockdownAuth_DEFAULT NULL + #define meshtastic_HamParameters_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, STRING, call_sign, 1) \ X(a, STATIC, SINGULAR, INT32, tx_power, 2) \ @@ -737,6 +799,7 @@ X(a, STATIC, OPTIONAL, UINT32, set_accuracy, 1) extern const pb_msgdesc_t meshtastic_AdminMessage_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_InputEvent_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_OTAEvent_msg; +extern const pb_msgdesc_t meshtastic_LockdownAuth_msg; extern const pb_msgdesc_t meshtastic_HamParameters_msg; extern const pb_msgdesc_t meshtastic_NodeRemoteHardwarePinsResponse_msg; extern const pb_msgdesc_t meshtastic_SharedContact_msg; @@ -751,6 +814,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_AdminMessage_fields &meshtastic_AdminMessage_msg #define meshtastic_AdminMessage_InputEvent_fields &meshtastic_AdminMessage_InputEvent_msg #define meshtastic_AdminMessage_OTAEvent_fields &meshtastic_AdminMessage_OTAEvent_msg +#define meshtastic_LockdownAuth_fields &meshtastic_LockdownAuth_msg #define meshtastic_HamParameters_fields &meshtastic_HamParameters_msg #define meshtastic_NodeRemoteHardwarePinsResponse_fields &meshtastic_NodeRemoteHardwarePinsResponse_msg #define meshtastic_SharedContact_fields &meshtastic_SharedContact_msg @@ -768,6 +832,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_AdminMessage_size 511 #define meshtastic_HamParameters_size 31 #define meshtastic_KeyVerificationAdmin_size 25 +#define meshtastic_LockdownAuth_size 48 #define meshtastic_NodeRemoteHardwarePinsResponse_size 496 #define meshtastic_SCD30_config_size 27 #define meshtastic_SCD4X_config_size 29 diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index d614a6438c3..820bb276450 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -198,7 +198,9 @@ typedef enum _meshtastic_Config_DisplayConfig_OledType { /* Can not be auto detected but set by proto. Used for 128x64 screens */ meshtastic_Config_DisplayConfig_OledType_OLED_SH1107 = 3, /* Can not be auto detected but set by proto. Used for 128x128 screens */ - meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128 = 4 + meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128 = 4, + /* Can not be auto detected but set by proto. Used for 64x128 rotated screens */ + meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_ROTATED = 5 } meshtastic_Config_DisplayConfig_OledType; typedef enum _meshtastic_Config_DisplayConfig_DisplayMode { @@ -720,8 +722,8 @@ extern "C" { #define _meshtastic_Config_DisplayConfig_DisplayUnits_ARRAYSIZE ((meshtastic_Config_DisplayConfig_DisplayUnits)(meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL+1)) #define _meshtastic_Config_DisplayConfig_OledType_MIN meshtastic_Config_DisplayConfig_OledType_OLED_AUTO -#define _meshtastic_Config_DisplayConfig_OledType_MAX meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128 -#define _meshtastic_Config_DisplayConfig_OledType_ARRAYSIZE ((meshtastic_Config_DisplayConfig_OledType)(meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128+1)) +#define _meshtastic_Config_DisplayConfig_OledType_MAX meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_ROTATED +#define _meshtastic_Config_DisplayConfig_OledType_ARRAYSIZE ((meshtastic_Config_DisplayConfig_OledType)(meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_ROTATED+1)) #define _meshtastic_Config_DisplayConfig_DisplayMode_MIN meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT #define _meshtastic_Config_DisplayConfig_DisplayMode_MAX meshtastic_Config_DisplayConfig_DisplayMode_COLOR diff --git a/src/mesh/generated/meshtastic/mesh.pb.cpp b/src/mesh/generated/meshtastic/mesh.pb.cpp index 3648d88502a..a68ffabacda 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.cpp +++ b/src/mesh/generated/meshtastic/mesh.pb.cpp @@ -57,6 +57,9 @@ PB_BIND(meshtastic_QueueStatus, meshtastic_QueueStatus, AUTO) PB_BIND(meshtastic_FromRadio, meshtastic_FromRadio, 2) +PB_BIND(meshtastic_LockdownStatus, meshtastic_LockdownStatus, AUTO) + + PB_BIND(meshtastic_ClientNotification, meshtastic_ClientNotification, 2) @@ -134,6 +137,8 @@ PB_BIND(meshtastic_ChunkedPayloadResponse, meshtastic_ChunkedPayloadResponse, AU + + diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 41ef2798c77..cb5f19df5a0 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -321,6 +321,10 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_HELTEC_MESH_NODE_T1 = 133, /* B&Q Consulting Station G3: TBD */ meshtastic_HardwareModel_STATION_G3 = 134, + /* Lilygo T-Impulse-Plus */ + meshtastic_HardwareModel_T_IMPULSE_PLUS = 135, + /* Lilygo T-Echo Card */ + meshtastic_HardwareModel_T_ECHO_CARD = 136, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ @@ -638,6 +642,25 @@ typedef enum _meshtastic_LogRecord_Level { meshtastic_LogRecord_Level_TRACE = 5 } meshtastic_LogRecord_Level; +typedef enum _meshtastic_LockdownStatus_State { + /* Default; should not be sent. */ + meshtastic_LockdownStatus_State_STATE_UNSPECIFIED = 0, + /* No passphrase has ever been provisioned on this device. + Client should prompt the operator to set one. */ + meshtastic_LockdownStatus_State_NEEDS_PROVISION = 1, + /* Storage is locked or this client has not authenticated yet. + lock_reason carries a machine-readable detail string. + Client should present (or auto-replay) a passphrase via + AdminMessage.lockdown_auth. */ + meshtastic_LockdownStatus_State_LOCKED = 2, + /* Passphrase accepted; client is now authorized for this connection. + boots_remaining and valid_until_epoch describe the active session + token's TTL. */ + meshtastic_LockdownStatus_State_UNLOCKED = 3, + /* Passphrase rejected. backoff_seconds is non-zero when rate-limited. */ + meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4 +} meshtastic_LockdownStatus_State; + /* Struct definitions */ /* A GPS Position */ typedef struct _meshtastic_Position { @@ -1148,6 +1171,38 @@ typedef struct _meshtastic_QueueStatus { uint32_t mesh_packet_id; } meshtastic_QueueStatus; +/* Lockdown state report from firmware to client (for hardened builds + with MESHTASTIC_LOCKDOWN). Sent immediately after config_complete_id + to inform a freshly-connected unauthorized client what it must do, + and again in response to each LockdownAuth admin command. */ +typedef struct _meshtastic_LockdownStatus { + /* Current lockdown state being reported. */ + meshtastic_LockdownStatus_State state; + /* For LOCKED: machine-readable reason. Known values: + "needs_auth" — storage already unlocked, client must auth + "token_missing" — no boot token on flash + "token_expired" — boot token wall-clock TTL elapsed + "token_boots_zero" — boot token boot-count TTL exhausted + "token_hmac_fail" — token tampered or wrong device + "token_dek_fail" — token DEK decrypt failed + "token_wrong_size" — token file corrupted + "token_bad_magic" — token file corrupted + "not_provisioned" — should generally use NEEDS_PROVISION state instead + Other values may be added; clients should treat unknown values as + "locked, ask for passphrase". */ + char lock_reason[32]; + /* For UNLOCKED: remaining boots on the issued session token. + Decrements by 1 on each subsequent boot. */ + uint32_t boots_remaining; + /* For UNLOCKED: wall-clock expiry of the issued session token, + absolute Unix-epoch seconds. 0 = no time limit. */ + uint32_t valid_until_epoch; + /* For UNLOCK_FAILED: seconds the client must wait before another + passphrase attempt will be accepted. 0 = wrong passphrase, no + backoff (immediate retry allowed but advisable to prompt user). */ + uint32_t backoff_seconds; +} meshtastic_LockdownStatus; + typedef struct _meshtastic_KeyVerificationNumberInform { uint64_t nonce; char remote_longname[40]; @@ -1321,6 +1376,12 @@ typedef struct _meshtastic_FromRadio { meshtastic_ClientNotification clientNotification; /* Persistent data for device-ui */ meshtastic_DeviceUIConfig deviceuiConfig; + /* Lockdown state notification for hardened firmware builds. + Sent post-config (so unauthorized clients learn they must + provision/unlock) and after each LockdownAuth admin command + to report success or failure. Replaces the earlier scheme of + encoding state as magic-string prefixes inside ClientNotification. */ + meshtastic_LockdownStatus lockdown_status; }; } meshtastic_FromRadio; @@ -1462,6 +1523,10 @@ extern "C" { #define _meshtastic_LogRecord_Level_MAX meshtastic_LogRecord_Level_CRITICAL #define _meshtastic_LogRecord_Level_ARRAYSIZE ((meshtastic_LogRecord_Level)(meshtastic_LogRecord_Level_CRITICAL+1)) +#define _meshtastic_LockdownStatus_State_MIN meshtastic_LockdownStatus_State_STATE_UNSPECIFIED +#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_UNLOCK_FAILED +#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_UNLOCK_FAILED+1)) + #define meshtastic_Position_location_source_ENUMTYPE meshtastic_Position_LocSource #define meshtastic_Position_altitude_source_ENUMTYPE meshtastic_Position_AltSource @@ -1492,6 +1557,8 @@ extern "C" { +#define meshtastic_LockdownStatus_state_ENUMTYPE meshtastic_LockdownStatus_State + #define meshtastic_ClientNotification_level_ENUMTYPE meshtastic_LogRecord_Level @@ -1532,6 +1599,7 @@ extern "C" { #define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_default {0, 0, 0, 0} #define meshtastic_FromRadio_init_default {0, 0, {meshtastic_MeshPacket_init_default}} +#define meshtastic_LockdownStatus_init_default {_meshtastic_LockdownStatus_State_MIN, "", 0, 0, 0} #define meshtastic_ClientNotification_init_default {false, 0, 0, _meshtastic_LogRecord_Level_MIN, "", 0, {meshtastic_KeyVerificationNumberInform_init_default}} #define meshtastic_KeyVerificationNumberInform_init_default {0, "", 0} #define meshtastic_KeyVerificationNumberRequest_init_default {0, ""} @@ -1566,6 +1634,7 @@ extern "C" { #define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_zero {0, 0, 0, 0} #define meshtastic_FromRadio_init_zero {0, 0, {meshtastic_MeshPacket_init_zero}} +#define meshtastic_LockdownStatus_init_zero {_meshtastic_LockdownStatus_State_MIN, "", 0, 0, 0} #define meshtastic_ClientNotification_init_zero {false, 0, 0, _meshtastic_LogRecord_Level_MIN, "", 0, {meshtastic_KeyVerificationNumberInform_init_zero}} #define meshtastic_KeyVerificationNumberInform_init_zero {0, "", 0} #define meshtastic_KeyVerificationNumberRequest_init_zero {0, ""} @@ -1718,6 +1787,11 @@ extern "C" { #define meshtastic_QueueStatus_free_tag 2 #define meshtastic_QueueStatus_maxlen_tag 3 #define meshtastic_QueueStatus_mesh_packet_id_tag 4 +#define meshtastic_LockdownStatus_state_tag 1 +#define meshtastic_LockdownStatus_lock_reason_tag 2 +#define meshtastic_LockdownStatus_boots_remaining_tag 3 +#define meshtastic_LockdownStatus_valid_until_epoch_tag 4 +#define meshtastic_LockdownStatus_backoff_seconds_tag 5 #define meshtastic_KeyVerificationNumberInform_nonce_tag 1 #define meshtastic_KeyVerificationNumberInform_remote_longname_tag 2 #define meshtastic_KeyVerificationNumberInform_security_number_tag 3 @@ -1777,6 +1851,7 @@ extern "C" { #define meshtastic_FromRadio_fileInfo_tag 15 #define meshtastic_FromRadio_clientNotification_tag 16 #define meshtastic_FromRadio_deviceuiConfig_tag 17 +#define meshtastic_FromRadio_lockdown_status_tag 18 #define meshtastic_Heartbeat_nonce_tag 1 #define meshtastic_ToRadio_packet_tag 1 #define meshtastic_ToRadio_want_config_id_tag 3 @@ -2017,7 +2092,8 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,metadata,metadata), 13) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,mqttClientProxyMessage,mqttClientProxyMessage), 14) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,fileInfo,fileInfo), 15) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,clientNotification,clientNotification), 16) \ -X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfig), 17) +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfig), 17) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_status), 18) #define meshtastic_FromRadio_CALLBACK NULL #define meshtastic_FromRadio_DEFAULT NULL #define meshtastic_FromRadio_payload_variant_packet_MSGTYPE meshtastic_MeshPacket @@ -2034,6 +2110,16 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfi #define meshtastic_FromRadio_payload_variant_fileInfo_MSGTYPE meshtastic_FileInfo #define meshtastic_FromRadio_payload_variant_clientNotification_MSGTYPE meshtastic_ClientNotification #define meshtastic_FromRadio_payload_variant_deviceuiConfig_MSGTYPE meshtastic_DeviceUIConfig +#define meshtastic_FromRadio_payload_variant_lockdown_status_MSGTYPE meshtastic_LockdownStatus + +#define meshtastic_LockdownStatus_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, state, 1) \ +X(a, STATIC, SINGULAR, STRING, lock_reason, 2) \ +X(a, STATIC, SINGULAR, UINT32, boots_remaining, 3) \ +X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 4) \ +X(a, STATIC, SINGULAR, UINT32, backoff_seconds, 5) +#define meshtastic_LockdownStatus_CALLBACK NULL +#define meshtastic_LockdownStatus_DEFAULT NULL #define meshtastic_ClientNotification_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, UINT32, reply_id, 1) \ @@ -2194,6 +2280,7 @@ extern const pb_msgdesc_t meshtastic_MyNodeInfo_msg; extern const pb_msgdesc_t meshtastic_LogRecord_msg; extern const pb_msgdesc_t meshtastic_QueueStatus_msg; extern const pb_msgdesc_t meshtastic_FromRadio_msg; +extern const pb_msgdesc_t meshtastic_LockdownStatus_msg; extern const pb_msgdesc_t meshtastic_ClientNotification_msg; extern const pb_msgdesc_t meshtastic_KeyVerificationNumberInform_msg; extern const pb_msgdesc_t meshtastic_KeyVerificationNumberRequest_msg; @@ -2230,6 +2317,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_LogRecord_fields &meshtastic_LogRecord_msg #define meshtastic_QueueStatus_fields &meshtastic_QueueStatus_msg #define meshtastic_FromRadio_fields &meshtastic_FromRadio_msg +#define meshtastic_LockdownStatus_fields &meshtastic_LockdownStatus_msg #define meshtastic_ClientNotification_fields &meshtastic_ClientNotification_msg #define meshtastic_KeyVerificationNumberInform_fields &meshtastic_KeyVerificationNumberInform_msg #define meshtastic_KeyVerificationNumberRequest_fields &meshtastic_KeyVerificationNumberRequest_msg @@ -2265,6 +2353,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_KeyVerificationNumberInform_size 58 #define meshtastic_KeyVerificationNumberRequest_size 52 #define meshtastic_KeyVerification_size 79 +#define meshtastic_LockdownStatus_size 53 #define meshtastic_LogRecord_size 426 #define meshtastic_LowEntropyKey_size 0 #define meshtastic_MeshPacket_size 381 diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index b8cf60bf09b..25937e9720d 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -144,7 +144,7 @@ typedef struct _meshtastic_ModuleConfig_MQTTConfig { (the default official mqtt.meshtastic.org server can handle encrypted packets) Decrypted packets may be useful for external systems that want to consume meshtastic packets */ bool encryption_enabled; - /* Whether to send / consume json packets on MQTT */ + /* Deprecated: JSON packet support on MQTT was removed, and this field is ignored. */ bool json_enabled; /* If true, we attempt to establish a secure connection using TLS */ bool tls_enabled; From 23ead5f2f13f1ad9855aba2c887adab654083e8a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 07:47:06 +0200 Subject: [PATCH 207/225] Update protobufs (#10500) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/admin.pb.h | 45 +---- src/mesh/generated/meshtastic/config.pb.h | 8 +- .../generated/meshtastic/deviceonly.pb.cpp | 12 -- src/mesh/generated/meshtastic/deviceonly.pb.h | 185 +++++------------- src/mesh/generated/meshtastic/mesh.pb.h | 43 ++-- .../generated/meshtastic/module_config.pb.h | 2 +- 7 files changed, 75 insertions(+), 222 deletions(-) diff --git a/protobufs b/protobufs index 7ffb4bb60de..59cb394dcfc 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 7ffb4bb60ded743a1ce23fe2edd5ead32be52bbb +Subproject commit 59cb394dcfc4432cb216358ca26e861c7d13f462 diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index 82644bc2a46..e6f5110ad30 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -163,41 +163,6 @@ typedef struct _meshtastic_LockdownAuth { connection-level admin authorization, and reboot the device into the locked state. Always honoured regardless of current lock state. */ bool lock_now; - /* Optional per-boot uptime cap on the unlocked session, in seconds. - 0 = unlimited (token-only enforcement, suitable for unattended - tower / infrastructure nodes). - - When non-zero, the firmware arms an uptime timer at unlock. On - each expiry, while there is still boot-count budget, the firmware - decrements the on-flash boot count in place, revokes per- - connection admin auth (clients must re-authenticate to see - content), re-engages the screen lock, and re-arms the timer - without rebooting. Mesh routing keeps running across session - boundaries; only when the boot-count budget reaches zero does - the device hard-lock and reboot. - - Total exposure ceiling = ((resolved boot count) + 1) * max_session_seconds. - The +1 accounts for the initial passphrase-unlocked session - itself, since boots_remaining is the number of subsequent - session rolls (each consuming one boot from the rollback ledger). - The resolved boot count is the value the firmware writes into the - token at unlock time: the client-supplied boots_remaining when - non-zero, otherwise the firmware default (TOKEN_DEFAULT_BOOTS). - Note that boots_remaining == 0 in this message means "use firmware - default", NOT "zero boots" — a client computing the ceiling for - display should mirror that resolution rather than multiplying the - raw request value. - - The cap is persisted in the token, so it survives token-based - auto-unlock across reboots. Explicit operator Lock Now still - deletes the token and forces passphrase re-entry. - - Uses millis() (CPU uptime), not wall-clock time, so the cap is - immune to GPS spoofing, RTC backup-battery removal, and Faraday - cage isolation — none of those move the uptime counter. The only - way to reset the session clock is a reboot, which costs a boot - from the on-flash, HMAC-bound counter. */ - uint32_t max_session_seconds; } meshtastic_LockdownAuth; /* Parameters for setting up Meshtastic for ameteur radio usage */ @@ -521,7 +486,7 @@ extern "C" { #define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}} -#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0} +#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0} #define meshtastic_HamParameters_init_default {"", 0, 0, ""} #define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} #define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0} @@ -534,7 +499,7 @@ extern "C" { #define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}} -#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0} +#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0} #define meshtastic_HamParameters_init_zero {"", 0, 0, ""} #define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} #define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0} @@ -556,7 +521,6 @@ extern "C" { #define meshtastic_LockdownAuth_boots_remaining_tag 2 #define meshtastic_LockdownAuth_valid_until_epoch_tag 3 #define meshtastic_LockdownAuth_lock_now_tag 4 -#define meshtastic_LockdownAuth_max_session_seconds_tag 5 #define meshtastic_HamParameters_call_sign_tag 1 #define meshtastic_HamParameters_tx_power_tag 2 #define meshtastic_HamParameters_frequency_tag 3 @@ -753,8 +717,7 @@ X(a, STATIC, SINGULAR, BYTES, ota_hash, 2) X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \ X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \ X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \ -X(a, STATIC, SINGULAR, BOOL, lock_now, 4) \ -X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5) +X(a, STATIC, SINGULAR, BOOL, lock_now, 4) #define meshtastic_LockdownAuth_CALLBACK NULL #define meshtastic_LockdownAuth_DEFAULT NULL @@ -869,7 +832,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_AdminMessage_size 511 #define meshtastic_HamParameters_size 31 #define meshtastic_KeyVerificationAdmin_size 25 -#define meshtastic_LockdownAuth_size 54 +#define meshtastic_LockdownAuth_size 48 #define meshtastic_NodeRemoteHardwarePinsResponse_size 496 #define meshtastic_SCD30_config_size 27 #define meshtastic_SCD4X_config_size 29 diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index d614a6438c3..820bb276450 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -198,7 +198,9 @@ typedef enum _meshtastic_Config_DisplayConfig_OledType { /* Can not be auto detected but set by proto. Used for 128x64 screens */ meshtastic_Config_DisplayConfig_OledType_OLED_SH1107 = 3, /* Can not be auto detected but set by proto. Used for 128x128 screens */ - meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128 = 4 + meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128 = 4, + /* Can not be auto detected but set by proto. Used for 64x128 rotated screens */ + meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_ROTATED = 5 } meshtastic_Config_DisplayConfig_OledType; typedef enum _meshtastic_Config_DisplayConfig_DisplayMode { @@ -720,8 +722,8 @@ extern "C" { #define _meshtastic_Config_DisplayConfig_DisplayUnits_ARRAYSIZE ((meshtastic_Config_DisplayConfig_DisplayUnits)(meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL+1)) #define _meshtastic_Config_DisplayConfig_OledType_MIN meshtastic_Config_DisplayConfig_OledType_OLED_AUTO -#define _meshtastic_Config_DisplayConfig_OledType_MAX meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128 -#define _meshtastic_Config_DisplayConfig_OledType_ARRAYSIZE ((meshtastic_Config_DisplayConfig_OledType)(meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128+1)) +#define _meshtastic_Config_DisplayConfig_OledType_MAX meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_ROTATED +#define _meshtastic_Config_DisplayConfig_OledType_ARRAYSIZE ((meshtastic_Config_DisplayConfig_OledType)(meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_ROTATED+1)) #define _meshtastic_Config_DisplayConfig_DisplayMode_MIN meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT #define _meshtastic_Config_DisplayConfig_DisplayMode_MAX meshtastic_Config_DisplayConfig_DisplayMode_COLOR diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.cpp b/src/mesh/generated/meshtastic/deviceonly.pb.cpp index 5580866379a..5a96957027d 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.cpp +++ b/src/mesh/generated/meshtastic/deviceonly.pb.cpp @@ -18,18 +18,6 @@ PB_BIND(meshtastic_NodeInfoLite, meshtastic_NodeInfoLite, AUTO) PB_BIND(meshtastic_DeviceState, meshtastic_DeviceState, 2) -PB_BIND(meshtastic_NodePositionEntry, meshtastic_NodePositionEntry, AUTO) - - -PB_BIND(meshtastic_NodeTelemetryEntry, meshtastic_NodeTelemetryEntry, AUTO) - - -PB_BIND(meshtastic_NodeEnvironmentEntry, meshtastic_NodeEnvironmentEntry, AUTO) - - -PB_BIND(meshtastic_NodeStatusEntry, meshtastic_NodeStatusEntry, AUTO) - - PB_BIND(meshtastic_NodeDatabase, meshtastic_NodeDatabase, AUTO) diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 7c14c3e0fbc..6d03dc64379 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -33,8 +33,6 @@ typedef struct _meshtastic_PositionLite { uint32_t time; /* TODO: REPLACE */ meshtastic_Position_LocSource location_source; - /* Indicates the bits of precision set by the sending node */ - uint32_t precision_bits; } meshtastic_PositionLite; typedef PB_BYTES_ARRAY_T(32) meshtastic_UserLite_public_key_t; @@ -65,35 +63,43 @@ typedef struct _meshtastic_UserLite { bool is_unmessagable; } meshtastic_UserLite; -typedef PB_BYTES_ARRAY_T(32) meshtastic_NodeInfoLite_public_key_t; typedef struct _meshtastic_NodeInfoLite { /* The node number */ uint32_t num; + /* The user info for this node */ + bool has_user; + meshtastic_UserLite user; + /* This position data. Note: before 1.2.14 we would also store the last time we've heard from this node in position.time, that is no longer true. + Position.time now indicates the last time we received a POSITION from that node. */ + bool has_position; + meshtastic_PositionLite position; /* Returns the Signal-to-noise ratio (SNR) of the last received message, as measured by the receiver. Return SNR of the last received message in dB */ float snr; /* Set to indicate the last time we received a packet from this node */ uint32_t last_heard; + /* The latest device metrics for the node. */ + bool has_device_metrics; + meshtastic_DeviceMetrics device_metrics; /* local channel index we heard that node on. Only populated if its not the default channel. */ uint8_t channel; + /* True if we witnessed the node over MQTT instead of LoRA transport */ + bool via_mqtt; /* Number of hops away from us this node is (0 if direct neighbor) */ bool has_hops_away; uint8_t hops_away; + /* True if node is in our favorites list + Persists between NodeDB internal clean ups */ + bool is_favorite; + /* True if node is in our ignored list + Persists between NodeDB internal clean ups */ + bool is_ignored; /* Last byte of the node number of the node that should be used as the next hop to reach this node. */ uint8_t next_hop; - /* Bitfield for storing booleans. See NODEINFO_BITFIELD_* in src/mesh/NodeDB.h. */ + /* Bitfield for storing booleans. + LSB 0 is_key_manually_verified + LSB 1 is_muted */ uint32_t bitfield; - /* A full name for this user, i.e. "Kevin Hester". */ - char long_name[25]; - /* A VERY short name, ideally two characters or an emoji. - Suitable for a tiny OLED screen. */ - char short_name[5]; - /* Hardware model the user's device is running. */ - meshtastic_HardwareModel hw_model; - /* The user's role in the mesh. */ - meshtastic_Config_DeviceConfig_Role role; - /* The public key of the user's device, for PKI-based encrypted DMs. */ - meshtastic_NodeInfoLite_public_key_t public_key; } meshtastic_NodeInfoLite; /* This message is never sent over the wire, but it is used for serializing DB @@ -137,30 +143,6 @@ typedef struct _meshtastic_DeviceState { meshtastic_NodeRemoteHardwarePin node_remote_hardware_pins[12]; } meshtastic_DeviceState; -typedef struct _meshtastic_NodePositionEntry { - uint32_t num; - bool has_position; - meshtastic_PositionLite position; -} meshtastic_NodePositionEntry; - -typedef struct _meshtastic_NodeTelemetryEntry { - uint32_t num; - bool has_device_metrics; - meshtastic_DeviceMetrics device_metrics; -} meshtastic_NodeTelemetryEntry; - -typedef struct _meshtastic_NodeEnvironmentEntry { - uint32_t num; - bool has_environment_metrics; - meshtastic_EnvironmentMetrics environment_metrics; -} meshtastic_NodeEnvironmentEntry; - -typedef struct _meshtastic_NodeStatusEntry { - uint32_t num; - bool has_status; - meshtastic_StatusMessage status; -} meshtastic_NodeStatusEntry; - typedef struct _meshtastic_NodeDatabase { /* A version integer used to invalidate old save files when we make incompatible changes This integer is set at build time and is private to @@ -168,12 +150,6 @@ typedef struct _meshtastic_NodeDatabase { uint32_t version; /* New lite version of NodeDB to decrease memory footprint */ std::vector nodes; - /* Per-NodeNum satellite arrays. Constrained platforms (e.g. STM32WL) omit - these via MESHTASTIC_EXCLUDE_*DB build flags. */ - std::vector positions; - std::vector telemetry; - std::vector status; - std::vector environment; } meshtastic_NodeDatabase; /* The on-disk saved channels */ @@ -213,26 +189,18 @@ extern "C" { #endif /* Initializer values for message structs */ -#define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0} +#define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} #define meshtastic_UserLite_init_default {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} -#define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} +#define meshtastic_NodeInfoLite_init_default {0, false, meshtastic_UserLite_init_default, false, meshtastic_PositionLite_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0} #define meshtastic_DeviceState_init_default {false, meshtastic_MyNodeInfo_init_default, false, meshtastic_User_init_default, 0, {meshtastic_MeshPacket_init_default}, false, meshtastic_MeshPacket_init_default, 0, 0, 0, false, meshtastic_MeshPacket_init_default, 0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} -#define meshtastic_NodePositionEntry_init_default {0, false, meshtastic_PositionLite_init_default} -#define meshtastic_NodeTelemetryEntry_init_default {0, false, meshtastic_DeviceMetrics_init_default} -#define meshtastic_NodeEnvironmentEntry_init_default {0, false, meshtastic_EnvironmentMetrics_init_default} -#define meshtastic_NodeStatusEntry_init_default {0, false, meshtastic_StatusMessage_init_default} -#define meshtastic_NodeDatabase_init_default {0, {0}, {0}, {0}, {0}, {0}} +#define meshtastic_NodeDatabase_init_default {0, {0}} #define meshtastic_ChannelFile_init_default {0, {meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default}, 0} #define meshtastic_BackupPreferences_init_default {0, 0, false, meshtastic_LocalConfig_init_default, false, meshtastic_LocalModuleConfig_init_default, false, meshtastic_ChannelFile_init_default, false, meshtastic_User_init_default} -#define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0} +#define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} #define meshtastic_UserLite_init_zero {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} -#define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} +#define meshtastic_NodeInfoLite_init_zero {0, false, meshtastic_UserLite_init_zero, false, meshtastic_PositionLite_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0} #define meshtastic_DeviceState_init_zero {false, meshtastic_MyNodeInfo_init_zero, false, meshtastic_User_init_zero, 0, {meshtastic_MeshPacket_init_zero}, false, meshtastic_MeshPacket_init_zero, 0, 0, 0, false, meshtastic_MeshPacket_init_zero, 0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} -#define meshtastic_NodePositionEntry_init_zero {0, false, meshtastic_PositionLite_init_zero} -#define meshtastic_NodeTelemetryEntry_init_zero {0, false, meshtastic_DeviceMetrics_init_zero} -#define meshtastic_NodeEnvironmentEntry_init_zero {0, false, meshtastic_EnvironmentMetrics_init_zero} -#define meshtastic_NodeStatusEntry_init_zero {0, false, meshtastic_StatusMessage_init_zero} -#define meshtastic_NodeDatabase_init_zero {0, {0}, {0}, {0}, {0}, {0}} +#define meshtastic_NodeDatabase_init_zero {0, {0}} #define meshtastic_ChannelFile_init_zero {0, {meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero}, 0} #define meshtastic_BackupPreferences_init_zero {0, 0, false, meshtastic_LocalConfig_init_zero, false, meshtastic_LocalModuleConfig_init_zero, false, meshtastic_ChannelFile_init_zero, false, meshtastic_User_init_zero} @@ -242,7 +210,6 @@ extern "C" { #define meshtastic_PositionLite_altitude_tag 3 #define meshtastic_PositionLite_time_tag 4 #define meshtastic_PositionLite_location_source_tag 5 -#define meshtastic_PositionLite_precision_bits_tag 6 #define meshtastic_UserLite_macaddr_tag 1 #define meshtastic_UserLite_long_name_tag 2 #define meshtastic_UserLite_short_name_tag 3 @@ -252,17 +219,18 @@ extern "C" { #define meshtastic_UserLite_public_key_tag 7 #define meshtastic_UserLite_is_unmessagable_tag 9 #define meshtastic_NodeInfoLite_num_tag 1 +#define meshtastic_NodeInfoLite_user_tag 2 +#define meshtastic_NodeInfoLite_position_tag 3 #define meshtastic_NodeInfoLite_snr_tag 4 #define meshtastic_NodeInfoLite_last_heard_tag 5 +#define meshtastic_NodeInfoLite_device_metrics_tag 6 #define meshtastic_NodeInfoLite_channel_tag 7 +#define meshtastic_NodeInfoLite_via_mqtt_tag 8 #define meshtastic_NodeInfoLite_hops_away_tag 9 +#define meshtastic_NodeInfoLite_is_favorite_tag 10 +#define meshtastic_NodeInfoLite_is_ignored_tag 11 #define meshtastic_NodeInfoLite_next_hop_tag 12 #define meshtastic_NodeInfoLite_bitfield_tag 13 -#define meshtastic_NodeInfoLite_long_name_tag 14 -#define meshtastic_NodeInfoLite_short_name_tag 15 -#define meshtastic_NodeInfoLite_hw_model_tag 16 -#define meshtastic_NodeInfoLite_role_tag 17 -#define meshtastic_NodeInfoLite_public_key_tag 18 #define meshtastic_DeviceState_my_node_tag 2 #define meshtastic_DeviceState_owner_tag 3 #define meshtastic_DeviceState_receive_queue_tag 5 @@ -272,20 +240,8 @@ extern "C" { #define meshtastic_DeviceState_did_gps_reset_tag 11 #define meshtastic_DeviceState_rx_waypoint_tag 12 #define meshtastic_DeviceState_node_remote_hardware_pins_tag 13 -#define meshtastic_NodePositionEntry_num_tag 1 -#define meshtastic_NodePositionEntry_position_tag 2 -#define meshtastic_NodeTelemetryEntry_num_tag 1 -#define meshtastic_NodeTelemetryEntry_device_metrics_tag 2 -#define meshtastic_NodeEnvironmentEntry_num_tag 1 -#define meshtastic_NodeEnvironmentEntry_environment_metrics_tag 2 -#define meshtastic_NodeStatusEntry_num_tag 1 -#define meshtastic_NodeStatusEntry_status_tag 2 #define meshtastic_NodeDatabase_version_tag 1 #define meshtastic_NodeDatabase_nodes_tag 2 -#define meshtastic_NodeDatabase_positions_tag 3 -#define meshtastic_NodeDatabase_telemetry_tag 4 -#define meshtastic_NodeDatabase_status_tag 5 -#define meshtastic_NodeDatabase_environment_tag 6 #define meshtastic_ChannelFile_channels_tag 1 #define meshtastic_ChannelFile_version_tag 2 #define meshtastic_BackupPreferences_version_tag 1 @@ -301,8 +257,7 @@ X(a, STATIC, SINGULAR, SFIXED32, latitude_i, 1) \ X(a, STATIC, SINGULAR, SFIXED32, longitude_i, 2) \ X(a, STATIC, SINGULAR, INT32, altitude, 3) \ X(a, STATIC, SINGULAR, FIXED32, time, 4) \ -X(a, STATIC, SINGULAR, UENUM, location_source, 5) \ -X(a, STATIC, SINGULAR, UINT32, precision_bits, 6) +X(a, STATIC, SINGULAR, UENUM, location_source, 5) #define meshtastic_PositionLite_CALLBACK NULL #define meshtastic_PositionLite_DEFAULT NULL @@ -320,19 +275,23 @@ X(a, STATIC, OPTIONAL, BOOL, is_unmessagable, 9) #define meshtastic_NodeInfoLite_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, user, 2) \ +X(a, STATIC, OPTIONAL, MESSAGE, position, 3) \ X(a, STATIC, SINGULAR, FLOAT, snr, 4) \ X(a, STATIC, SINGULAR, FIXED32, last_heard, 5) \ +X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 6) \ X(a, STATIC, SINGULAR, UINT32, channel, 7) \ +X(a, STATIC, SINGULAR, BOOL, via_mqtt, 8) \ X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \ +X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ +X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ X(a, STATIC, SINGULAR, UINT32, next_hop, 12) \ -X(a, STATIC, SINGULAR, UINT32, bitfield, 13) \ -X(a, STATIC, SINGULAR, STRING, long_name, 14) \ -X(a, STATIC, SINGULAR, STRING, short_name, 15) \ -X(a, STATIC, SINGULAR, UENUM, hw_model, 16) \ -X(a, STATIC, SINGULAR, UENUM, role, 17) \ -X(a, STATIC, SINGULAR, BYTES, public_key, 18) +X(a, STATIC, SINGULAR, UINT32, bitfield, 13) #define meshtastic_NodeInfoLite_CALLBACK NULL #define meshtastic_NodeInfoLite_DEFAULT NULL +#define meshtastic_NodeInfoLite_user_MSGTYPE meshtastic_UserLite +#define meshtastic_NodeInfoLite_position_MSGTYPE meshtastic_PositionLite +#define meshtastic_NodeInfoLite_device_metrics_MSGTYPE meshtastic_DeviceMetrics #define meshtastic_DeviceState_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, MESSAGE, my_node, 2) \ @@ -353,49 +312,13 @@ X(a, STATIC, REPEATED, MESSAGE, node_remote_hardware_pins, 13) #define meshtastic_DeviceState_rx_waypoint_MSGTYPE meshtastic_MeshPacket #define meshtastic_DeviceState_node_remote_hardware_pins_MSGTYPE meshtastic_NodeRemoteHardwarePin -#define meshtastic_NodePositionEntry_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, position, 2) -#define meshtastic_NodePositionEntry_CALLBACK NULL -#define meshtastic_NodePositionEntry_DEFAULT NULL -#define meshtastic_NodePositionEntry_position_MSGTYPE meshtastic_PositionLite - -#define meshtastic_NodeTelemetryEntry_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 2) -#define meshtastic_NodeTelemetryEntry_CALLBACK NULL -#define meshtastic_NodeTelemetryEntry_DEFAULT NULL -#define meshtastic_NodeTelemetryEntry_device_metrics_MSGTYPE meshtastic_DeviceMetrics - -#define meshtastic_NodeEnvironmentEntry_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, environment_metrics, 2) -#define meshtastic_NodeEnvironmentEntry_CALLBACK NULL -#define meshtastic_NodeEnvironmentEntry_DEFAULT NULL -#define meshtastic_NodeEnvironmentEntry_environment_metrics_MSGTYPE meshtastic_EnvironmentMetrics - -#define meshtastic_NodeStatusEntry_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, status, 2) -#define meshtastic_NodeStatusEntry_CALLBACK NULL -#define meshtastic_NodeStatusEntry_DEFAULT NULL -#define meshtastic_NodeStatusEntry_status_MSGTYPE meshtastic_StatusMessage - #define meshtastic_NodeDatabase_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, version, 1) \ -X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) \ -X(a, CALLBACK, REPEATED, MESSAGE, positions, 3) \ -X(a, CALLBACK, REPEATED, MESSAGE, telemetry, 4) \ -X(a, CALLBACK, REPEATED, MESSAGE, status, 5) \ -X(a, CALLBACK, REPEATED, MESSAGE, environment, 6) +X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) extern bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field); #define meshtastic_NodeDatabase_CALLBACK meshtastic_NodeDatabase_callback #define meshtastic_NodeDatabase_DEFAULT NULL #define meshtastic_NodeDatabase_nodes_MSGTYPE meshtastic_NodeInfoLite -#define meshtastic_NodeDatabase_positions_MSGTYPE meshtastic_NodePositionEntry -#define meshtastic_NodeDatabase_telemetry_MSGTYPE meshtastic_NodeTelemetryEntry -#define meshtastic_NodeDatabase_status_MSGTYPE meshtastic_NodeStatusEntry -#define meshtastic_NodeDatabase_environment_MSGTYPE meshtastic_NodeEnvironmentEntry #define meshtastic_ChannelFile_FIELDLIST(X, a) \ X(a, STATIC, REPEATED, MESSAGE, channels, 1) \ @@ -422,10 +345,6 @@ extern const pb_msgdesc_t meshtastic_PositionLite_msg; extern const pb_msgdesc_t meshtastic_UserLite_msg; extern const pb_msgdesc_t meshtastic_NodeInfoLite_msg; extern const pb_msgdesc_t meshtastic_DeviceState_msg; -extern const pb_msgdesc_t meshtastic_NodePositionEntry_msg; -extern const pb_msgdesc_t meshtastic_NodeTelemetryEntry_msg; -extern const pb_msgdesc_t meshtastic_NodeEnvironmentEntry_msg; -extern const pb_msgdesc_t meshtastic_NodeStatusEntry_msg; extern const pb_msgdesc_t meshtastic_NodeDatabase_msg; extern const pb_msgdesc_t meshtastic_ChannelFile_msg; extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; @@ -435,10 +354,6 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_UserLite_fields &meshtastic_UserLite_msg #define meshtastic_NodeInfoLite_fields &meshtastic_NodeInfoLite_msg #define meshtastic_DeviceState_fields &meshtastic_DeviceState_msg -#define meshtastic_NodePositionEntry_fields &meshtastic_NodePositionEntry_msg -#define meshtastic_NodeTelemetryEntry_fields &meshtastic_NodeTelemetryEntry_msg -#define meshtastic_NodeEnvironmentEntry_fields &meshtastic_NodeEnvironmentEntry_msg -#define meshtastic_NodeStatusEntry_fields &meshtastic_NodeStatusEntry_msg #define meshtastic_NodeDatabase_fields &meshtastic_NodeDatabase_msg #define meshtastic_ChannelFile_fields &meshtastic_ChannelFile_msg #define meshtastic_BackupPreferences_fields &meshtastic_BackupPreferences_msg @@ -448,13 +363,9 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size #define meshtastic_BackupPreferences_size 2432 #define meshtastic_ChannelFile_size 718 -#define meshtastic_DeviceState_size 1944 -#define meshtastic_NodeEnvironmentEntry_size 170 -#define meshtastic_NodeInfoLite_size 105 -#define meshtastic_NodePositionEntry_size 42 -#define meshtastic_NodeStatusEntry_size 89 -#define meshtastic_NodeTelemetryEntry_size 35 -#define meshtastic_PositionLite_size 34 +#define meshtastic_DeviceState_size 1737 +#define meshtastic_NodeInfoLite_size 196 +#define meshtastic_PositionLite_size 28 #define meshtastic_UserLite_size 98 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index f0fea08d7cc..cb5f19df5a0 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -321,6 +321,10 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_HELTEC_MESH_NODE_T1 = 133, /* B&Q Consulting Station G3: TBD */ meshtastic_HardwareModel_STATION_G3 = 134, + /* Lilygo T-Impulse-Plus */ + meshtastic_HardwareModel_T_IMPULSE_PLUS = 135, + /* Lilygo T-Echo Card */ + meshtastic_HardwareModel_T_ECHO_CARD = 136, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ @@ -820,7 +824,6 @@ typedef struct _meshtastic_Routing { } meshtastic_Routing; typedef PB_BYTES_ARRAY_T(233) meshtastic_Data_payload_t; -typedef PB_BYTES_ARRAY_T(64) meshtastic_Data_xeddsa_signature_t; /* (Formerly called SubPacket) The payload portion fo a packet, this is the actual bytes that are sent inside a radio packet (because from/to are broken out by the comms library) */ @@ -854,8 +857,6 @@ typedef struct _meshtastic_Data { /* Bitfield for extra flags. First use is to indicate that user approves the packet being uploaded to MQTT. */ bool has_bitfield; uint8_t bitfield; - /* XEdDSA signature for the payload */ - meshtastic_Data_xeddsa_signature_t xeddsa_signature; } meshtastic_Data; typedef PB_BYTES_ARRAY_T(32) meshtastic_KeyVerification_hash1_t; @@ -1060,8 +1061,6 @@ typedef struct _meshtastic_MeshPacket { uint32_t tx_after; /* Indicates which transport mechanism this packet arrived over */ meshtastic_MeshPacket_TransportMechanism transport_mechanism; - /* Indicates whether the packet has a valid signature */ - bool xeddsa_signed; } meshtastic_MeshPacket; /* The bluetooth to device link: @@ -1118,10 +1117,6 @@ typedef struct _meshtastic_NodeInfo { /* True if node has been muted Persistes between NodeDB internal clean ups */ bool is_muted; - /* True if node is signing its packets via XEdDSA - Persists between NodeDB internal clean ups - LSB 1 of the bitfield */ - bool has_xeddsa_signed; } meshtastic_NodeInfo; typedef PB_BYTES_ARRAY_T(16) meshtastic_MyNodeInfo_device_id_t; @@ -1591,15 +1586,15 @@ extern "C" { #define meshtastic_User_init_default {"", "", "", {0}, _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} #define meshtastic_RouteDiscovery_init_default {0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}} #define meshtastic_Routing_init_default {0, {meshtastic_RouteDiscovery_init_default}} -#define meshtastic_Data_init_default {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0, {0, {0}}} +#define meshtastic_Data_init_default {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0} #define meshtastic_KeyVerification_init_default {0, {0, {0}}, {0, {0}}} #define meshtastic_StoreForwardPlusPlus_init_default {_meshtastic_StoreForwardPlusPlus_SFPP_message_type_MIN, {0, {0}}, {0, {0}}, {0, {0}}, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_RemoteShell_init_default {_meshtastic_RemoteShell_OpCode_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_Waypoint_init_default {0, false, 0, false, 0, 0, 0, "", "", 0} #define meshtastic_StatusMessage_init_default {""} #define meshtastic_MqttClientProxyMessage_init_default {"", 0, {{0, {0}}}, 0} -#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} -#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0, 0} +#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN} +#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_default {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_default {0, 0, 0, 0} @@ -1626,15 +1621,15 @@ extern "C" { #define meshtastic_User_init_zero {"", "", "", {0}, _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} #define meshtastic_RouteDiscovery_init_zero {0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}} #define meshtastic_Routing_init_zero {0, {meshtastic_RouteDiscovery_init_zero}} -#define meshtastic_Data_init_zero {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0, {0, {0}}} +#define meshtastic_Data_init_zero {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0} #define meshtastic_KeyVerification_init_zero {0, {0, {0}}, {0, {0}}} #define meshtastic_StoreForwardPlusPlus_init_zero {_meshtastic_StoreForwardPlusPlus_SFPP_message_type_MIN, {0, {0}}, {0, {0}}, {0, {0}}, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_RemoteShell_init_zero {_meshtastic_RemoteShell_OpCode_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_Waypoint_init_zero {0, false, 0, false, 0, 0, 0, "", "", 0} #define meshtastic_StatusMessage_init_zero {""} #define meshtastic_MqttClientProxyMessage_init_zero {"", 0, {{0, {0}}}, 0} -#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} -#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0, 0} +#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN} +#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_zero {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_zero {0, 0, 0, 0} @@ -1707,7 +1702,6 @@ extern "C" { #define meshtastic_Data_reply_id_tag 7 #define meshtastic_Data_emoji_tag 8 #define meshtastic_Data_bitfield_tag 9 -#define meshtastic_Data_xeddsa_signature_tag 10 #define meshtastic_KeyVerification_nonce_tag 1 #define meshtastic_KeyVerification_hash1_tag 2 #define meshtastic_KeyVerification_hash2_tag 3 @@ -1765,7 +1759,6 @@ extern "C" { #define meshtastic_MeshPacket_relay_node_tag 19 #define meshtastic_MeshPacket_tx_after_tag 20 #define meshtastic_MeshPacket_transport_mechanism_tag 21 -#define meshtastic_MeshPacket_xeddsa_signed_tag 22 #define meshtastic_NodeInfo_num_tag 1 #define meshtastic_NodeInfo_user_tag 2 #define meshtastic_NodeInfo_position_tag 3 @@ -1779,7 +1772,6 @@ extern "C" { #define meshtastic_NodeInfo_is_ignored_tag 11 #define meshtastic_NodeInfo_is_key_manually_verified_tag 12 #define meshtastic_NodeInfo_is_muted_tag 13 -#define meshtastic_NodeInfo_has_xeddsa_signed_tag 14 #define meshtastic_MyNodeInfo_my_node_num_tag 1 #define meshtastic_MyNodeInfo_reboot_count_tag 8 #define meshtastic_MyNodeInfo_min_app_version_tag 11 @@ -1946,8 +1938,7 @@ X(a, STATIC, SINGULAR, FIXED32, source, 5) \ X(a, STATIC, SINGULAR, FIXED32, request_id, 6) \ X(a, STATIC, SINGULAR, FIXED32, reply_id, 7) \ X(a, STATIC, SINGULAR, FIXED32, emoji, 8) \ -X(a, STATIC, OPTIONAL, UINT32, bitfield, 9) \ -X(a, STATIC, SINGULAR, BYTES, xeddsa_signature, 10) +X(a, STATIC, OPTIONAL, UINT32, bitfield, 9) #define meshtastic_Data_CALLBACK NULL #define meshtastic_Data_DEFAULT NULL @@ -2032,8 +2023,7 @@ X(a, STATIC, SINGULAR, BOOL, pki_encrypted, 17) \ X(a, STATIC, SINGULAR, UINT32, next_hop, 18) \ X(a, STATIC, SINGULAR, UINT32, relay_node, 19) \ X(a, STATIC, SINGULAR, UINT32, tx_after, 20) \ -X(a, STATIC, SINGULAR, UENUM, transport_mechanism, 21) \ -X(a, STATIC, SINGULAR, BOOL, xeddsa_signed, 22) +X(a, STATIC, SINGULAR, UENUM, transport_mechanism, 21) #define meshtastic_MeshPacket_CALLBACK NULL #define meshtastic_MeshPacket_DEFAULT NULL #define meshtastic_MeshPacket_payload_variant_decoded_MSGTYPE meshtastic_Data @@ -2051,8 +2041,7 @@ X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \ X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ X(a, STATIC, SINGULAR, BOOL, is_key_manually_verified, 12) \ -X(a, STATIC, SINGULAR, BOOL, is_muted, 13) \ -X(a, STATIC, SINGULAR, BOOL, has_xeddsa_signed, 14) +X(a, STATIC, SINGULAR, BOOL, is_muted, 13) #define meshtastic_NodeInfo_CALLBACK NULL #define meshtastic_NodeInfo_DEFAULT NULL #define meshtastic_NodeInfo_user_MSGTYPE meshtastic_User @@ -2354,7 +2343,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_ChunkedPayload_size 245 #define meshtastic_ClientNotification_size 482 #define meshtastic_Compressed_size 239 -#define meshtastic_Data_size 335 +#define meshtastic_Data_size 269 #define meshtastic_DeviceMetadata_size 54 #define meshtastic_DuplicatedPublicKey_size 0 #define meshtastic_FileInfo_size 236 @@ -2367,12 +2356,12 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_LockdownStatus_size 53 #define meshtastic_LogRecord_size 426 #define meshtastic_LowEntropyKey_size 0 -#define meshtastic_MeshPacket_size 450 +#define meshtastic_MeshPacket_size 381 #define meshtastic_MqttClientProxyMessage_size 501 #define meshtastic_MyNodeInfo_size 83 #define meshtastic_NeighborInfo_size 258 #define meshtastic_Neighbor_size 22 -#define meshtastic_NodeInfo_size 327 +#define meshtastic_NodeInfo_size 325 #define meshtastic_NodeRemoteHardwarePin_size 29 #define meshtastic_Position_size 144 #define meshtastic_QueueStatus_size 23 diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index b8cf60bf09b..25937e9720d 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -144,7 +144,7 @@ typedef struct _meshtastic_ModuleConfig_MQTTConfig { (the default official mqtt.meshtastic.org server can handle encrypted packets) Decrypted packets may be useful for external systems that want to consume meshtastic packets */ bool encryption_enabled; - /* Whether to send / consume json packets on MQTT */ + /* Deprecated: JSON packet support on MQTT was removed, and this field is ignored. */ bool json_enabled; /* If true, we attempt to establish a secure connection using TLS */ bool tls_enabled; From 98e0604edf878cb2fe22c94e1c02cd715bfa27a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 19 May 2026 07:58:24 +0200 Subject: [PATCH 208/225] Fix antenna switch initialization logic once more --- src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp b/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp index 0c252159f4c..6bd86ab4b21 100644 --- a/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp +++ b/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp @@ -47,7 +47,7 @@ static void initLoraCap() return; } bool ok = pi4ioWrite(bus, PI4IO_REG_IO_DIR, 0b00000001); - ok = ok && pi4ioWrite(bus, PI4IO_REG_OUT_H_IM, 0b00000001); + ok = ok && pi4ioWrite(bus, PI4IO_REG_OUT_H_IM, 0b00000000); ok = ok && pi4ioWrite(bus, PI4IO_REG_OUT_SET, 0b00000001); if (!ok) { LOG_ERROR("Antenna switch init failed"); From 0832330327867f666c2196dd0deab37ed9ffe129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 19 May 2026 08:00:02 +0200 Subject: [PATCH 209/225] Fix antenna switch initialization logic --- src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp b/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp index 0c252159f4c..6bd86ab4b21 100644 --- a/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp +++ b/src/platform/extra_variants/m5stack_cardputer_adv/variant.cpp @@ -47,7 +47,7 @@ static void initLoraCap() return; } bool ok = pi4ioWrite(bus, PI4IO_REG_IO_DIR, 0b00000001); - ok = ok && pi4ioWrite(bus, PI4IO_REG_OUT_H_IM, 0b00000001); + ok = ok && pi4ioWrite(bus, PI4IO_REG_OUT_H_IM, 0b00000000); ok = ok && pi4ioWrite(bus, PI4IO_REG_OUT_SET, 0b00000001); if (!ok) { LOG_ERROR("Antenna switch init failed"); From 622aa046f1a99e099ae6f8606a8fa894b4ec87cc Mon Sep 17 00:00:00 2001 From: Riker Date: Tue, 19 May 2026 14:31:42 +0800 Subject: [PATCH 210/225] Enabled SX_LNA_EN by default (#10469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enabled SX_LNA_EN by default * Update I2C configuration for IO direction and pull settings --------- Co-authored-by: Thomas Göttgens --- variants/esp32c6/m5stack_unitc6l/variant.cpp | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/variants/esp32c6/m5stack_unitc6l/variant.cpp b/variants/esp32c6/m5stack_unitc6l/variant.cpp index 8e26b4ab7fd..7dc5785f682 100644 --- a/variants/esp32c6/m5stack_unitc6l/variant.cpp +++ b/variants/esp32c6/m5stack_unitc6l/variant.cpp @@ -52,23 +52,25 @@ void c6l_init() vTaskDelay(10 / portTICK_PERIOD_MS); i2c_read_byte(PI4IO_M_ADDR, PI4IO_REG_CHIP_RESET, &in_data); vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_IO_DIR, 0b11000000); // 0: input 1: output + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_IO_DIR, 0b11100000); // P5,P6,P7 as outputs vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_H_IM, 0b00111100); // 使用到的引脚关闭High-Impedance + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_H_IM, 0b00011100); // High-Impedance vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_PULL_SEL, 0b11000011); // pull up/down select, 0 down, 1 up + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_PULL_SEL, 0b11100011); // pull up/down select, 0 down, 1 up vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_PULL_EN, 0b11000011); // pull up/down enable, 0 disable, 1 enable + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_PULL_EN, 0b11100011); // pull up/down enable, 0 disable, 1 enable vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_IN_DEF_STA, 0b00000011); // P0 P1 默认高电平, 按键按下触发中断 + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_IN_DEF_STA, 0b00000011); // P0 P1 default to high level; button press triggers the interrupt vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_INT_MASK, 0b11111100); // P0 P1 中断使能 0 enable, 1 disable + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_INT_MASK, 0b11111100); // P0 P1 interrupts enabled (0 = enable, 1 = disable) vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_SET, 0b10000000); // 默认输出为0 + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_SET, 0b10000000); // default output is 0 + vTaskDelay(10 / portTICK_PERIOD_MS); + i2c_read_byte(PI4IO_M_ADDR, PI4IO_REG_IRQ_STA, &in_data); // read IRQ_STA to clear the flag vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_read_byte(PI4IO_M_ADDR, PI4IO_REG_IRQ_STA, &in_data); // 读取IRQ_STA清除标志 i2c_read_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_SET, &in_data); - setbit(in_data, 6); // HIGH + setbit(in_data, 6); // enable SX_ANT_SW + setbit(in_data, 5); // enable SX_LNA_EN i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_SET, in_data); } From 1747e2d8e5c542f01fd9ed526d143adf853ce177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 19 May 2026 09:31:04 +0200 Subject: [PATCH 211/225] T-Echo-Card support (#10267) --- .trunk/trunk.yaml | 8 +- src/graphics/Screen.cpp | 11 +- src/graphics/ScreenFonts.h | 2 +- src/graphics/SharedUIDisplay.cpp | 8 +- src/graphics/draw/DebugRenderer.cpp | 4 +- src/graphics/draw/MenuHandler.cpp | 2 +- src/graphics/draw/NodeListRenderer.cpp | 8 +- src/graphics/draw/NotificationRenderer.cpp | 2 +- src/graphics/draw/UIRenderer.cpp | 12 +- src/graphics/images.h | 2 +- src/main.cpp | 5 + src/mesh/NodeDB.cpp | 5 +- src/modules/ExternalNotificationModule.cpp | 10 + src/modules/ExternalNotificationModule.h | 17 ++ src/modules/StatusLEDModule.cpp | 33 +++ src/modules/StatusLEDModule.h | 26 +++ src/nimble/NimbleBluetooth.cpp | 2 +- variants/esp32c6/m5stack_unitc6l/variant.h | 3 + variants/native/portduino.ini | 2 +- variants/nrf52840/t-echo-card/platformio.ini | 12 ++ variants/nrf52840/t-echo-card/variant.cpp | 66 ++++++ variants/nrf52840/t-echo-card/variant.h | 202 +++++++++++++++++++ 22 files changed, 414 insertions(+), 28 deletions(-) create mode 100644 variants/nrf52840/t-echo-card/platformio.ini create mode 100644 variants/nrf52840/t-echo-card/variant.cpp create mode 100644 variants/nrf52840/t-echo-card/variant.h diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index a36974327f9..26e4c68926a 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -4,11 +4,11 @@ cli: plugins: sources: - id: trunk - ref: v1.9.0 + ref: v1.10.0 uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.528 + - checkov@3.2.529 - renovate@43.150.0 - prettier@3.8.3 - trufflehog@3.95.3 @@ -16,7 +16,7 @@ lint: - bandit@1.9.4 - trivy@0.70.0 - taplo@0.10.0 - - ruff@0.15.12 + - ruff@0.15.13 - isort@8.0.1 - markdownlint@0.48.0 - oxipng@10.1.1 @@ -26,7 +26,7 @@ lint: - hadolint@2.14.0 - shfmt@3.6.0 - shellcheck@0.11.0 - - black@26.3.1 + - black@26.5.0 - git-diff-check - gitleaks@8.30.1 - clang-format@16.0.3 diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 044db3637f8..4de23bce7d9 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -425,6 +425,11 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O #elif defined(USE_SSD1306) dispdev = new SSD1306Wire(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); +#if defined(OLED_Y_OFFSET_PAGES) + // Panels whose active window does not start at GDDRAM row 0 (e.g. 72x40 + // modules on pages 3..7) need a fixed vertical page shift on every write. + static_cast(dispdev)->setYOffset(OLED_Y_OFFSET_PAGES); +#endif #elif defined(USE_SPISSD1306) dispdev = new SSD1306Spi(SSD1306_RESET, SSD1306_RS, SSD1306_NSS, GEOMETRY_64_48); if (!dispdev->init()) { @@ -910,7 +915,7 @@ int32_t Screen::runOnce() #ifndef DISABLE_WELCOME_UNSET if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) menuHandler::LoraRegionPicker(); #else menuHandler::OnboardMessage(); @@ -1147,7 +1152,7 @@ void Screen::setFrames(FrameFocus focus) #if defined(DISPLAY_CLOCK_FRAME) if (!hiddenFrames.clock) { fsi.positions.clock = numframes; -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) normalFrames[numframes++] = graphics::ClockRenderer::drawAnalogClockFrame; #else normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame @@ -1606,7 +1611,7 @@ void Screen::showFrame(FrameDirection direction) void Screen::setFastFramerate() { -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) dispdev->clear(); #if GRAPHICS_TFT_COLORING_ENABLED prepareFrameColorRegions(); diff --git a/src/graphics/ScreenFonts.h b/src/graphics/ScreenFonts.h index 82ceb54066f..624465f33d4 100644 --- a/src/graphics/ScreenFonts.h +++ b/src/graphics/ScreenFonts.h @@ -106,7 +106,7 @@ #define FONT_SMALL FONT_MEDIUM_LOCAL // Height: 19 #define FONT_MEDIUM FONT_LARGE_LOCAL // Height: 28 #define FONT_LARGE FONT_LARGE_LOCAL // Height: 28 -#elif defined(M5STACK_UNITC6L) +#elif defined(OLED_TINY) #define FONT_SMALL FONT_SMALL_LOCAL // Height: 13 #define FONT_MEDIUM FONT_SMALL_LOCAL // Height: 13 #define FONT_LARGE FONT_SMALL_LOCAL // Height: 13 diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index becd3e75d4a..691a6fc5819 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -31,6 +31,12 @@ ScreenResolution determineScreenResolution(int16_t screenheight, int16_t screenw return ScreenResolution::UltraLow; } +#ifdef DISPLAY_FORCE_SMALL_FONTS + if (screenwidth <= 160 && screenheight <= 80) { + return ScreenResolution::Low; + } +#endif + // Standard OLED screens if (screenwidth > 128 && screenheight <= 64) { return ScreenResolution::Low; @@ -240,7 +246,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int batteryX = 1; int batteryY = HEADER_OFFSET_Y + 1; -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) // === Battery Icons === if (usbPowered && !isCharging) { // This is a basic check to determine USB Powered is flagged but not charging batteryX += 1; diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 67136437a5e..0f81982548a 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -460,7 +460,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, nameX = (SCREEN_WIDTH - textWidth) / 2; display->drawString(nameX, getTextPositions(display)[line++], frequencyslot); -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) // === Fifth Row: Channel Utilization === const char *chUtil = "ChUtil:"; char chUtilPercentage[10]; @@ -592,7 +592,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x // Label display->setTextAlignment(TEXT_ALIGN_LEFT); display->drawString(labelX, getTextPositions(display)[line], label); -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) // Bar int barY = getTextPositions(display)[line] + (FONT_HEIGHT_SMALL - barHeight) / 2; display->setColor(WHITE); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 386a4c077de..9fc7fa41080 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -555,7 +555,7 @@ void menuHandler::TZPicker() void menuHandler::clockMenu() { -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) static const char *optionsArray[] = {"Back", "Time Format", "Timezone"}; #else static const char *optionsArray[] = {"Back", "Clock Face", "Time Format", "Timezone"}; diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index d0b027c1356..dfe6671c882 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -26,7 +26,7 @@ extern bool haveGlyphs(const char *str); // Global screen instance extern graphics::Screen *screen; -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) static uint32_t lastSwitchTime = 0; #endif namespace graphics @@ -788,7 +788,7 @@ void drawDynamicListScreen_Nodes(OLEDDisplay *display, OLEDDisplayUiState *state unsigned long now = millis(); -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) display->clear(); if (now - lastSwitchTime >= 3000) { display->display(); @@ -824,7 +824,7 @@ void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *st unsigned long now = millis(); -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) display->clear(); if (now - lastSwitchTime >= 3000) { display->display(); @@ -894,7 +894,7 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, double lat = DegD(ourSelfPos.latitude_i); double lon = DegD(ourSelfPos.longitude_i); -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) display->clear(); uint32_t now = millis(); if (now - lastSwitchTime >= 2000) { diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index d7eba0be45d..02bfec31f6c 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -728,7 +728,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay } int16_t boxTop = (display->height() / 2) - (boxHeight / 2); boxHeight += (currentResolution == ScreenResolution::High) ? 2 : 1; -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) if (visibleTotalLines == 1) { boxTop += 25; } diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index cfde101247f..e7b00e5d5da 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -25,7 +25,7 @@ // External variables extern graphics::Screen *screen; -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) static uint32_t lastSwitchTime = 0; #endif namespace graphics @@ -753,7 +753,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat if (!node || node->num == nodeDB->getNodeNum() || !nodeInfoLiteIsFavorite(node)) return; display->clear(); -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) uint32_t now = millis(); if (now - lastSwitchTime >= 10000) // 10000 ms = 10 秒 { @@ -959,7 +959,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat if (seenStr[0]) { display->drawString(x, getTextPositions(display)[line++], seenStr); } -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) // === 4. Uptime (only show if metric is present) === char uptimeStr[32] = ""; meshtastic_DeviceMetrics nodeMetrics; @@ -1172,7 +1172,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta } #endif -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) line += 1; // === Node Identity === @@ -1456,7 +1456,7 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED // needs to be drawn relative to x and y // draw centered icon left to right and centered above the one line of app text -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) display->drawXbm(x + (SCREEN_WIDTH - 50) / 2, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits); if (gBootSplashBoldPass) { display->drawXbm(x + (SCREEN_WIDTH - 50) / 2 + 1, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits); @@ -1666,7 +1666,7 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU } display->drawString(x, textPos[line++], altitudeLine); } -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) // === Draw Compass === if (validHeading || statusLine1) { // --- Compass Rendering: landscape (wide) screens use original side-aligned logic --- diff --git a/src/graphics/images.h b/src/graphics/images.h index 66fcbc79c5d..f11ad568653 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -318,7 +318,7 @@ const uint8_t chirpy_small[] = {0x7f, 0x41, 0x55, 0x55, 0x55, 0x55, 0x41, 0x7f}; #define connection_icon_height 5 const uint8_t connection_icon[] = {0x36, 0x41, 0x5D, 0x41, 0x36}; -#ifdef M5STACK_UNITC6L +#ifdef OLED_TINY #include "img/icon_small.xbm" #else #include "img/icon.xbm" diff --git a/src/main.cpp b/src/main.cpp index 304712e6fa3..979517f0a0b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -753,6 +753,11 @@ void setup() } } #endif +#ifdef OLED_GEOMETRY_OVERRIDE + // Per-variant geometry (e.g. 72x40 micro-OLEDs). Takes precedence over the + // default GEOMETRY_128_64 set at the top of setup(). + screen_geometry = OLED_GEOMETRY_OVERRIDE; +#endif #endif #if !MESHTASTIC_EXCLUDE_I2C diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 9a8593bc45c..9c1b85dad78 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1071,7 +1071,8 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.has_store_forward = true; moduleConfig.has_telemetry = true; moduleConfig.has_external_notification = true; -#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(LED_NOTIFICATION) || defined(PCA_LED_NOTIFICATION) +#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(LED_NOTIFICATION) || defined(PCA_LED_NOTIFICATION) || \ + defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) moduleConfig.external_notification.enabled = true; #endif #if defined(PIN_BUZZER) @@ -1092,7 +1093,7 @@ void NodeDB::installDefaultModuleConfig() #endif #if defined(PIN_VIBRATION) moduleConfig.external_notification.nag_timeout = 2; -#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) +#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs; #endif diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 1a0b699300d..8a98e86df41 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -206,6 +206,10 @@ void ExternalNotificationModule::setExternalState(uint8_t index, bool on) #ifdef PCA_LED_NOTIFICATION io.digitalWrite(PCA_LED_NOTIFICATION, on); +#endif +#ifdef NEOPIXEL_STATUS_NOTIFICATION_PIN + notificationPixel.setPixelColor(0, on ? NEOPIXEL_STATUS_NOTIFICATION_COLOR : 0); + notificationPixel.show(); #endif break; } @@ -324,6 +328,12 @@ ExternalNotificationModule::ExternalNotificationModule() LOG_INFO("Use Pin %i in digital mode", output); pinMode(output, OUTPUT); } +#ifdef NEOPIXEL_STATUS_NOTIFICATION_PIN + LOG_INFO("Use WS2812 on GPIO %d as notification LED", NEOPIXEL_STATUS_NOTIFICATION_PIN); + notificationPixel.begin(); + notificationPixel.clear(); + notificationPixel.show(); +#endif setExternalState(0, false); externalTurnedOn[0] = 0; if (moduleConfig.external_notification.output_vibra) { diff --git a/src/modules/ExternalNotificationModule.h b/src/modules/ExternalNotificationModule.h index 94b02136016..8781c1ca84a 100644 --- a/src/modules/ExternalNotificationModule.h +++ b/src/modules/ExternalNotificationModule.h @@ -10,6 +10,19 @@ extern AmbientLightingThread *ambientLightingThread; #endif +// Drive a single WS2812 as the notification LED (M1/M2-style LED_NOTIFICATION +// but addressable). A variant defines NEOPIXEL_STATUS_NOTIFICATION_PIN to +// enable. Colour defaults to green but can be overridden. +#ifdef NEOPIXEL_STATUS_NOTIFICATION_PIN +#include +#ifndef NEOPIXEL_STATUS_TYPE +#define NEOPIXEL_STATUS_TYPE (NEO_GRB + NEO_KHZ800) +#endif +#ifndef NEOPIXEL_STATUS_NOTIFICATION_COLOR +#define NEOPIXEL_STATUS_NOTIFICATION_COLOR 0x00FF00 // green +#endif +#endif + #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !defined(CONFIG_IDF_TARGET_ESP32C6) #include #else @@ -38,6 +51,10 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: CallbackObserver(this, &ExternalNotificationModule::handleInputEvent); uint32_t output = 0; +#ifdef NEOPIXEL_STATUS_NOTIFICATION_PIN + Adafruit_NeoPixel notificationPixel = Adafruit_NeoPixel(1, NEOPIXEL_STATUS_NOTIFICATION_PIN, NEOPIXEL_STATUS_TYPE); +#endif + public: ExternalNotificationModule(); diff --git a/src/modules/StatusLEDModule.cpp b/src/modules/StatusLEDModule.cpp index 4ea34fb529a..f3a0e7a03ed 100644 --- a/src/modules/StatusLEDModule.cpp +++ b/src/modules/StatusLEDModule.cpp @@ -17,7 +17,28 @@ StatusLEDModule::StatusLEDModule() : concurrency::OSThread("StatusLEDModule") if (inputBroker) inputObserver.observe(inputBroker); #endif +#ifdef NEOPIXEL_STATUS_POWER_PIN + powerPixel.begin(); + powerPixel.clear(); + powerPixel.show(); +#endif +#ifdef NEOPIXEL_STATUS_PAIRING_PIN + pairingPixel.begin(); + pairingPixel.clear(); + pairingPixel.show(); +#endif +} + +// Helper: write a 1-pixel NeoPixel strand to `color` when stateOn, else clear. +// Kept as a static inline here (rather than a member) so it compiles out +// completely when no NeoPixel status pins are defined. +#if defined(NEOPIXEL_STATUS_POWER_PIN) || defined(NEOPIXEL_STATUS_PAIRING_PIN) +static inline void writeStatusPixel(Adafruit_NeoPixel &pixel, uint32_t color, bool stateOn) +{ + pixel.setPixelColor(0, stateOn ? color : 0); + pixel.show(); } +#endif int StatusLEDModule::handleStatusUpdate(const meshtastic::Status *arg) { @@ -176,6 +197,12 @@ int32_t StatusLEDModule::runOnce() #ifdef LED_PAIRING digitalWrite(LED_PAIRING, PAIRING_LED_state); #endif +#ifdef NEOPIXEL_STATUS_POWER_PIN + writeStatusPixel(powerPixel, NEOPIXEL_STATUS_POWER_COLOR, CHARGE_LED_state == LED_STATE_ON); +#endif +#ifdef NEOPIXEL_STATUS_PAIRING_PIN + writeStatusPixel(pairingPixel, NEOPIXEL_STATUS_PAIRING_COLOR, PAIRING_LED_state == LED_STATE_ON); +#endif #ifdef RGB_LED_POWER if (!config.device.led_heartbeat_disabled) { @@ -225,6 +252,12 @@ void StatusLEDModule::setPowerLED(bool LEDon) #ifdef LED_PAIRING digitalWrite(LED_PAIRING, ledState); #endif +#ifdef NEOPIXEL_STATUS_POWER_PIN + writeStatusPixel(powerPixel, NEOPIXEL_STATUS_POWER_COLOR, LEDon); +#endif +#ifdef NEOPIXEL_STATUS_PAIRING_PIN + writeStatusPixel(pairingPixel, NEOPIXEL_STATUS_PAIRING_COLOR, LEDon); +#endif #ifdef Battery_LED_1 digitalWrite(Battery_LED_1, ledState); diff --git a/src/modules/StatusLEDModule.h b/src/modules/StatusLEDModule.h index f66a536f677..f20198e39ef 100644 --- a/src/modules/StatusLEDModule.h +++ b/src/modules/StatusLEDModule.h @@ -13,6 +13,25 @@ #include "input/InputBroker.h" #endif +// WS2812/NeoPixel status-LED support. A variant may define +// NEOPIXEL_STATUS_POWER_PIN (required to enable the power/charge pixel) +// NEOPIXEL_STATUS_POWER_COLOR (optional, default red 0xFF0000) +// NEOPIXEL_STATUS_PAIRING_PIN / _COLOR (default blue 0x0000FF) +// Each pixel is a standalone 1-LED strand on its own GPIO — this mirrors how +// boards like the LilyGo T-Echo-Card expose three independent WS2812s. +#if defined(NEOPIXEL_STATUS_POWER_PIN) || defined(NEOPIXEL_STATUS_PAIRING_PIN) +#include +#ifndef NEOPIXEL_STATUS_TYPE +#define NEOPIXEL_STATUS_TYPE (NEO_GRB + NEO_KHZ800) +#endif +#ifndef NEOPIXEL_STATUS_POWER_COLOR +#define NEOPIXEL_STATUS_POWER_COLOR 0xFF0000 // red +#endif +#ifndef NEOPIXEL_STATUS_PAIRING_COLOR +#define NEOPIXEL_STATUS_PAIRING_COLOR 0x0000FF // blue +#endif +#endif + class StatusLEDModule : private concurrency::OSThread { bool slowTrack = false; @@ -27,6 +46,13 @@ class StatusLEDModule : private concurrency::OSThread void setPowerLED(bool); +#ifdef NEOPIXEL_STATUS_POWER_PIN + Adafruit_NeoPixel powerPixel = Adafruit_NeoPixel(1, NEOPIXEL_STATUS_POWER_PIN, NEOPIXEL_STATUS_TYPE); +#endif +#ifdef NEOPIXEL_STATUS_PAIRING_PIN + Adafruit_NeoPixel pairingPixel = Adafruit_NeoPixel(1, NEOPIXEL_STATUS_PAIRING_PIN, NEOPIXEL_STATUS_TYPE); +#endif + protected: unsigned int my_interval = 1000; // interval in millisconds virtual int32_t runOnce() override; diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 6857f9b5d3c..e66e738e3bd 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -572,7 +572,7 @@ class NimbleBluetoothSecurityCallback : public BLESecurityCallbacks display->setTextAlignment(TEXT_ALIGN_CENTER); display->setFont(FONT_MEDIUM); display->drawString(x_offset + x, y_offset + y, "Bluetooth"); -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) display->setFont(FONT_SMALL); y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_MEDIUM - 4 : y_offset + FONT_HEIGHT_MEDIUM + 5; display->drawString(x_offset + x, y_offset + y, "Enter this code"); diff --git a/variants/esp32c6/m5stack_unitc6l/variant.h b/variants/esp32c6/m5stack_unitc6l/variant.h index 1654ee590ca..576d4e114b8 100644 --- a/variants/esp32c6/m5stack_unitc6l/variant.h +++ b/variants/esp32c6/m5stack_unitc6l/variant.h @@ -48,6 +48,9 @@ void c6l_init(); #define SSD1306_RESET 15 // #define OLED_DG 1 #endif +// Tiny OLED panel — opts into compile-time layout/font/feature substitutions +// gated on OLED_TINY across the graphics stack. +#define OLED_TINY #define SCREEN_TRANSITION_FRAMERATE 10 #define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 972a9f3bff0..bc21a9e9d28 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -30,7 +30,7 @@ lib_deps = # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.21 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main - https://github.com/pine64/libch341-spi-userspace/archive/23c42319a69cffcb65868e3c72e6bed83974a393.zip + https://github.com/pine64/libch341-spi-userspace/archive/2e5ff751d0c39667993df672cb683740ed5c9394.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library adafruit/Adafruit seesaw Library@1.7.9 # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main diff --git a/variants/nrf52840/t-echo-card/platformio.ini b/variants/nrf52840/t-echo-card/platformio.ini new file mode 100644 index 00000000000..bc012d6e108 --- /dev/null +++ b/variants/nrf52840/t-echo-card/platformio.ini @@ -0,0 +1,12 @@ +[env:t-echo-card] +extends = nrf52840_base +board = t-echo +board_level = extra +debug_tool = jlink + +build_flags = ${nrf52840_base.build_flags} + -I variants/nrf52840/t-echo-card + -D PRIVATE_HW + -D T_ECHO_CARD + +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/t-echo-card> diff --git a/variants/nrf52840/t-echo-card/variant.cpp b/variants/nrf52840/t-echo-card/variant.cpp new file mode 100644 index 00000000000..e82a63f8ef8 --- /dev/null +++ b/variants/nrf52840/t-echo-card/variant.cpp @@ -0,0 +1,66 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "variant.h" +#include "Arduino.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[] = { + // P0 - pins 0 and 1 are hardwired for xtal and should never be enabled + 0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47}; + +void initVariant() +{ + // No plain GPIO LEDs on this board (only WS2812 addressable LEDs, not driven here). +} + +// Reproduces the vendor firmware's boot sequence from +// examples/original_test/original_test.ino. Runs before Meshtastic touches +// PIN_POWER_EN, so the RT9080 LDO gets a clean reset pulse and peripherals +// whose EN pins must be LOW at boot (GPS_EN, GPS_RF_EN, BUZZER) aren't left +// floating while the 3V3 rail is ramping. +void earlyInitVariant() +{ + // 3.3V rail: toggle RT9080_EN HIGH → LOW → HIGH with 100 ms dwell so the + // LDO enters enable from a known state. The single-shot HIGH in main.cpp + // is not enough on this hardware — if the chip was in a half-enabled + // state from a previous reset, the rail brown-outs once LoRa TX fires. + pinMode(PIN_POWER_EN, OUTPUT); + digitalWrite(PIN_POWER_EN, HIGH); + delay(100); + digitalWrite(PIN_POWER_EN, LOW); + delay(100); + digitalWrite(PIN_POWER_EN, HIGH); + delay(100); + + // Park peripherals with active-high enables LOW so they don't sink + // current while the rest of setup() runs. + pinMode(PIN_GPS_STANDBY, OUTPUT); + digitalWrite(PIN_GPS_STANDBY, LOW); + pinMode(PIN_GPS_RESET, OUTPUT); + digitalWrite(PIN_GPS_RESET, LOW); + pinMode(PIN_BUZZER, OUTPUT); + digitalWrite(PIN_BUZZER, LOW); +} diff --git a/variants/nrf52840/t-echo-card/variant.h b/variants/nrf52840/t-echo-card/variant.h new file mode 100644 index 00000000000..37fa28d89a0 --- /dev/null +++ b/variants/nrf52840/t-echo-card/variant.h @@ -0,0 +1,202 @@ +// Variant definition for LilyGo T-Echo-Card (nRF52840) + +#ifndef _VARIANT_T_ECHO_CARD_ +#define _VARIANT_T_ECHO_CARD_ + +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32kHz crystal for LF + +/*---------------------------------------------------------------------------- + * Headers + *----------------------------------------------------------------------------*/ + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +// Number of pins defined in PinDescription array +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (1) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs - board only exposes 3x WS2812 addressable LEDs. No plain GPIO LEDs. +// Intentionally do not define PIN_LED1 on this variant, so nRF52 platform +// code does not auto-enable a nonexistent GPIO power/status LED. +#define LED_STATE_ON 1 + +// Three independent WS2812 data lines (one LED per line, not a daisy chain). +// Each is driven as a 1-pixel NeoPixel by StatusLEDModule / ExternalNotification, +// assigns LED_POWER (red) and LED_NOTIFICATION (green). +#define WS2812_DATA_1 (32 + 7) // P1.7 - charge/heartbeat (red) +#define WS2812_DATA_2 (32 + 12) // P1.12 - external notification (green) +#define WS2812_DATA_3 (0 + 28) // P0.28 - BLE pairing (blue) + +// Wire each WS2812 to a status role. Colour defaults are scaled to 25% +// brightness (0x40) — the bare-die WS2812s on this board are very bright at +// full intensity in a close-range enclosure. +#define NEOPIXEL_STATUS_POWER_PIN WS2812_DATA_1 +#define NEOPIXEL_STATUS_NOTIFICATION_PIN WS2812_DATA_2 +#define NEOPIXEL_STATUS_PAIRING_PIN WS2812_DATA_3 +#define NEOPIXEL_STATUS_POWER_COLOR 0x400000 // red @ 25% +#define NEOPIXEL_STATUS_NOTIFICATION_COLOR 0x004000 // green @ 25% +#define NEOPIXEL_STATUS_PAIRING_COLOR 0x000040 // blue @ 25% + +// The charger IC does not blink on its own; let StatusLEDModule do the +// software blink while charging +// If left defined: hardware would be expected to handle the charging pulse. +// #define POWER_LED_HARDWARE_BLINKS_WHILE_CHARGING + +// Buttons +#define PIN_BUTTON1 (32 + 10) // KEY_1: P1.10 + +#define BUTTON_CLICK_MS 400 + +// Analog pins +#define PIN_A0 (0 + 2) // Battery ADC (BATTERY_ADC_DATA) + +#define BATTERY_PIN PIN_A0 + +static const uint8_t A0 = PIN_A0; + +#define ADC_RESOLUTION 14 + +// BATTERY_MEASUREMENT_CONTROL - enable divider for battery reading +#define ADC_CTRL (0 + 31) +#define ADC_CTRL_ENABLED HIGH + +// NFC placeholders, not used +#define PIN_NFC1 (9) +#define PIN_NFC2 (10) + +// Wire Interfaces (IIC_1 on the vendor header) +#define WIRE_INTERFACES_COUNT 1 + +#define PIN_WIRE_SDA (32 + 4) // IIC_1_SDA: P1.4 +#define PIN_WIRE_SCL (32 + 2) // IIC_1_SCL: P1.2 + +// External serial flash ZD25WQ32CEIGR +// QSPI Pins +#define PIN_QSPI_SCK (0 + 4) +#define PIN_QSPI_CS (0 + 12) +#define PIN_QSPI_IO0 (0 + 6) // MOSI if using two bit interface +#define PIN_QSPI_IO1 (0 + 8) // MISO if using two bit interface +#define PIN_QSPI_IO2 (32 + 9) // WP +#define PIN_QSPI_IO3 (0 + 26) // HOLD + +// On-board QSPI Flash +#define EXTERNAL_FLASH_DEVICES ZD25WQ32CEIGR +#define EXTERNAL_FLASH_USE_QSPI + +// Lora S62F (SX1262) +#define USE_SX1262 +#define SX126X_CS (0 + 11) +#define SX126X_DIO1 (32 + 8) +#define SX126X_DIO2 (0 + 5) +#define SX126X_BUSY (0 + 14) +#define SX126X_RESET (0 + 7) +#define SX126X_RXEN (32 + 1) // SX1262_RF_VC2 +#define SX126X_TXEN (0 + 27) // SX1262_RF_VC1 +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// ─────────────────────────────────────────────────────────────────────────── +// OLED display: SSD1315 on I2C @ 0x3C (IIC_1). SSD1315 is register-compatible +// with SSD1306, so USE_SSD1306 initializes the controller correctly. +// +// Viewport: the physical panel is 72×40, mapped into the SSD1315's 128×64 +// GDDRAM at columns 28..99, pages 3..7 (rows 24..63). The firmware handles +// this by: +// * asking the library for GEOMETRY_72_40, which sets the framebuffer to +// 72×40 and emits the right SETMULTIPLEX (39) / SETCOMPINS at init; +// * relying on SSD1306Wire's built-in horizontal auto-centering +// ((128 - width) / 2 = 28), so no horizontal shim is needed; +// * calling SSD1306Wire::setYOffset(3) in Screen.cpp when +// OLED_Y_OFFSET_PAGES is defined — this shifts every PAGEADDR write by +// three pages (24 rows) so data lands on the visible rows. +// ─────────────────────────────────────────────────────────────────────────── +#define HAS_SCREEN 1 +#define USE_SSD1306 +#define OLED_GEOMETRY_OVERRIDE GEOMETRY_72_40 +#define OLED_Y_OFFSET_PAGES 3 +#define OLED_TINY + +// Controls power 3V3 for all peripherals (GPS + LoRa + Sensor) +#define PIN_POWER_EN (0 + 30) // RT9080_EN + +// SPI1 is unused (no external SPI display). Keep declarations for the core. +#define PIN_SPI1_MISO (-1) +#define PIN_SPI1_MOSI (-1) +#define PIN_SPI1_SCK (-1) + +// GPS pins +#define GPS_L76K +#define GPS_BAUDRATE 9600 +#define HAS_GPS 1 + +#define PIN_GPS_EN (32 + 15) // GPS_EN: P1.15 - GPS power enable +#define GPS_EN_ACTIVE 1 +#define PIN_GPS_STANDBY (0 + 25) // GPS_WAKE_UP: P0.25 - wakeup pin +#define PIN_GPS_PPS (0 + 23) // GPS_1PPS: P0.23 +#define GPS_RX_PIN (0 + 19) // MCU RX ← GPS's TX (vendor GPS_UART_TX / P0.19) +#define GPS_TX_PIN (0 + 21) // MCU TX → GPS's RX (vendor GPS_UART_RX / P0.21) +#define PIN_GPS_RESET (0 + 29) // GPS_RF_EN: GPS RF enable / reset + +#define GPS_THREAD_INTERVAL 50 + +#define PIN_SERIAL1_RX GPS_RX_PIN +#define PIN_SERIAL1_TX GPS_TX_PIN + +// SPI Interfaces (LoRa on SPI0) +#define SPI_INTERFACES_COUNT 2 + +// For LORA, SPI 0 +#define PIN_SPI_MISO (0 + 17) +#define PIN_SPI_MOSI (0 + 15) +#define PIN_SPI_SCK (0 + 13) + +// Battery +// The battery sense is hooked to PIN_A0 (P0.2) via a divider controlled by ADC_CTRL. +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#undef AREF_VOLTAGE +#define AREF_VOLTAGE 3.0 +#define VBAT_AR_INTERNAL AR_INTERNAL_3_0 +#define ADC_MULTIPLIER (2.0F) + +// Buzzer (PWM output, passive piezo) +#define PIN_BUZZER (32 + 6) // BUZZER_DATA: P1.6 + +// ─────────────────────────────────────────────────────────────────────────── +// I²S speaker (MAX98357 Class-D amp). Stereo I²S data path. +// Not supported on nrf52. These defines exist for out-of-tree code only. +// ─────────────────────────────────────────────────────────────────────────── +#define SPEAKER_EN (32 + 11) // P1.11 - amp main enable +#define SPEAKER_EN_2 (0 + 3) // P0.3 - secondary enable (vendor firmware toggles both) +#define SPEAKER_BCLK (0 + 16) // P0.16 - I2S bit clock +#define SPEAKER_DATA (0 + 20) // P0.20 - I2S data (SDOUT) +#define SPEAKER_WS_LRCK (0 + 22) // P0.22 - I2S word select / LRCK + +// ─────────────────────────────────────────────────────────────────────────── +// PDM microphone (ST MP34DT05). +// TODO to enable a mic path: +// Use Adafruit nRF52 core's built-in PDM.h wrapper (Arduino-compatible +// API exists on nRF52840). Clock on MIC_SCLK, data on MIC_DATA. +// ─────────────────────────────────────────────────────────────────────────── +#define MIC_SCLK (32 + 3) // P1.3 - PDM clock (MIC_SCLK on vendor header) +#define MIC_DATA (32 + 5) // P1.5 - PDM data (MIC_DATA on vendor header) + +#define SERIAL_PRINT_PORT 0 + +#ifdef __cplusplus +} +#endif + +/*---------------------------------------------------------------------------- + * Arduino objects - C++ only + *----------------------------------------------------------------------------*/ + +#endif From e2aa44ec5440f2b8b455acdaf748561371f10f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 19 May 2026 09:31:04 +0200 Subject: [PATCH 212/225] T-Echo-Card support (#10267) # Conflicts: # src/graphics/draw/UIRenderer.cpp --- src/graphics/Screen.cpp | 11 +- src/graphics/ScreenFonts.h | 2 +- src/graphics/SharedUIDisplay.cpp | 2 +- src/graphics/draw/DebugRenderer.cpp | 4 +- src/graphics/draw/MenuHandler.cpp | 2 +- src/graphics/draw/NodeListRenderer.cpp | 8 +- src/graphics/draw/NotificationRenderer.cpp | 2 +- src/graphics/draw/UIRenderer.cpp | 12 +- src/graphics/images.h | 2 +- src/main.cpp | 5 + src/mesh/NodeDB.cpp | 5 +- src/modules/ExternalNotificationModule.cpp | 10 + src/modules/ExternalNotificationModule.h | 17 ++ src/modules/StatusLEDModule.cpp | 33 +++ src/modules/StatusLEDModule.h | 26 +++ src/nimble/NimbleBluetooth.cpp | 2 +- variants/esp32c6/m5stack_unitc6l/variant.h | 3 + variants/nrf52840/t-echo-card/platformio.ini | 12 ++ variants/nrf52840/t-echo-card/variant.cpp | 66 ++++++ variants/nrf52840/t-echo-card/variant.h | 202 +++++++++++++++++++ 20 files changed, 403 insertions(+), 23 deletions(-) create mode 100644 variants/nrf52840/t-echo-card/platformio.ini create mode 100644 variants/nrf52840/t-echo-card/variant.cpp create mode 100644 variants/nrf52840/t-echo-card/variant.h diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 60e1c43a622..f51a6ee9e01 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -353,6 +353,11 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O #elif defined(USE_SSD1306) dispdev = new SSD1306Wire(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); +#if defined(OLED_Y_OFFSET_PAGES) + // Panels whose active window does not start at GDDRAM row 0 (e.g. 72x40 + // modules on pages 3..7) need a fixed vertical page shift on every write. + static_cast(dispdev)->setYOffset(OLED_Y_OFFSET_PAGES); +#endif #elif defined(USE_SPISSD1306) dispdev = new SSD1306Spi(SSD1306_RESET, SSD1306_RS, SSD1306_NSS, GEOMETRY_64_48); if (!dispdev->init()) { @@ -834,7 +839,7 @@ int32_t Screen::runOnce() #ifndef DISABLE_WELCOME_UNSET if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) menuHandler::LoraRegionPicker(); #else menuHandler::OnboardMessage(); @@ -1058,7 +1063,7 @@ void Screen::setFrames(FrameFocus focus) #if defined(DISPLAY_CLOCK_FRAME) if (!hiddenFrames.clock) { fsi.positions.clock = numframes; -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) normalFrames[numframes++] = graphics::ClockRenderer::drawAnalogClockFrame; #else normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame @@ -1511,7 +1516,7 @@ void Screen::showFrame(FrameDirection direction) void Screen::setFastFramerate() { -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) dispdev->clear(); dispdev->display(); #endif diff --git a/src/graphics/ScreenFonts.h b/src/graphics/ScreenFonts.h index 26276edb267..c6689d0d14e 100644 --- a/src/graphics/ScreenFonts.h +++ b/src/graphics/ScreenFonts.h @@ -96,7 +96,7 @@ #define FONT_SMALL FONT_MEDIUM_LOCAL // Height: 19 #define FONT_MEDIUM FONT_LARGE_LOCAL // Height: 28 #define FONT_LARGE FONT_LARGE_LOCAL // Height: 28 -#elif defined(M5STACK_UNITC6L) +#elif defined(OLED_TINY) #define FONT_SMALL FONT_SMALL_LOCAL // Height: 13 #define FONT_MEDIUM FONT_SMALL_LOCAL // Height: 13 #define FONT_LARGE FONT_SMALL_LOCAL // Height: 13 diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index 032b14dfa0f..7ad0b93bb9b 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -161,7 +161,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int batteryX = 1; int batteryY = HEADER_OFFSET_Y + 1; -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) // === Battery Icons === if (usbPowered && !isCharging) { // This is a basic check to determine USB Powered is flagged but not charging batteryX += 1; diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 6b26abe7fb9..6472f3e5e87 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -449,7 +449,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, nameX = (SCREEN_WIDTH - textWidth) / 2; display->drawString(nameX, getTextPositions(display)[line++], frequencyslot); -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) // === Fifth Row: Channel Utilization === const char *chUtil = "ChUtil:"; char chUtilPercentage[10]; @@ -569,7 +569,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x // Label display->setTextAlignment(TEXT_ALIGN_LEFT); display->drawString(labelX, getTextPositions(display)[line], label); -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) // Bar int barY = getTextPositions(display)[line] + (FONT_HEIGHT_SMALL - barHeight) / 2; display->setColor(WHITE); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index a1d49946ff8..24302c1db71 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -491,7 +491,7 @@ void menuHandler::TZPicker() void menuHandler::clockMenu() { -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) static const char *optionsArray[] = {"Back", "Time Format", "Timezone"}; #else static const char *optionsArray[] = {"Back", "Clock Face", "Time Format", "Timezone"}; diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 98644ee3baa..d7f0a1483a1 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -21,7 +21,7 @@ extern bool haveGlyphs(const char *str); // Global screen instance extern graphics::Screen *screen; -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) static uint32_t lastSwitchTime = 0; #endif namespace graphics @@ -670,7 +670,7 @@ void drawDynamicListScreen_Nodes(OLEDDisplay *display, OLEDDisplayUiState *state unsigned long now = millis(); -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) display->clear(); if (now - lastSwitchTime >= 3000) { display->display(); @@ -706,7 +706,7 @@ void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *st unsigned long now = millis(); -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) display->clear(); if (now - lastSwitchTime >= 3000) { display->display(); @@ -771,7 +771,7 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, double lat = DegD(ourNode->position.latitude_i); double lon = DegD(ourNode->position.longitude_i); -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) display->clear(); uint32_t now = millis(); if (now - lastSwitchTime >= 2000) { diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 31eb2c3c83a..3704dcf79c1 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -580,7 +580,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay } int16_t boxTop = (display->height() / 2) - (boxHeight / 2); boxHeight += (currentResolution == ScreenResolution::High) ? 2 : 1; -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) if (visibleTotalLines == 1) { boxTop += 25; } diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index e3a4d13a258..92cc59a9ac6 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -20,7 +20,7 @@ // External variables extern graphics::Screen *screen; -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) static uint32_t lastSwitchTime = 0; #endif namespace graphics @@ -304,7 +304,7 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i if (!node || node->num == nodeDB->getNodeNum() || !node->is_favorite) return; display->clear(); -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) uint32_t now = millis(); if (now - lastSwitchTime >= 10000) // 10000 ms = 10 秒 { @@ -518,7 +518,7 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i if (seenStr[0]) { display->drawString(x, getTextPositions(display)[line++], seenStr); } -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) // === 4. Uptime (only show if metric is present) === char uptimeStr[32] = ""; if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) { @@ -795,7 +795,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta } #endif -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) line += 1; // === Node Identity === @@ -1092,7 +1092,7 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED // needs to be drawn relative to x and y // draw centered icon left to right and centered above the one line of app text -#if defined(M5STACK_UNITC6L) +#if defined(OLED_TINY) display->drawXbm(x + (SCREEN_WIDTH - 50) / 2, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits); display->setFont(FONT_MEDIUM); display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -1243,7 +1243,7 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU } display->drawString(x, getTextPositions(display)[line++], altitudeLine); } -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) // === Draw Compass if heading is valid === if (validHeading) { // --- Compass Rendering: landscape (wide) screens use original side-aligned logic --- diff --git a/src/graphics/images.h b/src/graphics/images.h index 66fcbc79c5d..f11ad568653 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -318,7 +318,7 @@ const uint8_t chirpy_small[] = {0x7f, 0x41, 0x55, 0x55, 0x55, 0x55, 0x41, 0x7f}; #define connection_icon_height 5 const uint8_t connection_icon[] = {0x36, 0x41, 0x5D, 0x41, 0x36}; -#ifdef M5STACK_UNITC6L +#ifdef OLED_TINY #include "img/icon_small.xbm" #else #include "img/icon.xbm" diff --git a/src/main.cpp b/src/main.cpp index 2f4b12437c6..dab965c4c95 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -741,6 +741,11 @@ void setup() } } #endif +#ifdef OLED_GEOMETRY_OVERRIDE + // Per-variant geometry (e.g. 72x40 micro-OLEDs). Takes precedence over the + // default GEOMETRY_128_64 set at the top of setup(). + screen_geometry = OLED_GEOMETRY_OVERRIDE; +#endif #endif #if !MESHTASTIC_EXCLUDE_I2C diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index d35e0a38ae4..3ce78513cb7 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -836,7 +836,8 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.has_store_forward = true; moduleConfig.has_telemetry = true; moduleConfig.has_external_notification = true; -#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(LED_NOTIFICATION) || defined(PCA_LED_NOTIFICATION) +#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(LED_NOTIFICATION) || defined(PCA_LED_NOTIFICATION) || \ + defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) moduleConfig.external_notification.enabled = true; #endif #if defined(PIN_BUZZER) @@ -857,7 +858,7 @@ void NodeDB::installDefaultModuleConfig() #endif #if defined(PIN_VIBRATION) moduleConfig.external_notification.nag_timeout = 2; -#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) +#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs; #endif diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 16ccdd74498..0a1c4a6dd40 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -206,6 +206,10 @@ void ExternalNotificationModule::setExternalState(uint8_t index, bool on) #ifdef PCA_LED_NOTIFICATION io.digitalWrite(PCA_LED_NOTIFICATION, on); +#endif +#ifdef NEOPIXEL_STATUS_NOTIFICATION_PIN + notificationPixel.setPixelColor(0, on ? NEOPIXEL_STATUS_NOTIFICATION_COLOR : 0); + notificationPixel.show(); #endif break; } @@ -324,6 +328,12 @@ ExternalNotificationModule::ExternalNotificationModule() LOG_INFO("Use Pin %i in digital mode", output); pinMode(output, OUTPUT); } +#ifdef NEOPIXEL_STATUS_NOTIFICATION_PIN + LOG_INFO("Use WS2812 on GPIO %d as notification LED", NEOPIXEL_STATUS_NOTIFICATION_PIN); + notificationPixel.begin(); + notificationPixel.clear(); + notificationPixel.show(); +#endif setExternalState(0, false); externalTurnedOn[0] = 0; if (moduleConfig.external_notification.output_vibra) { diff --git a/src/modules/ExternalNotificationModule.h b/src/modules/ExternalNotificationModule.h index 94b02136016..8781c1ca84a 100644 --- a/src/modules/ExternalNotificationModule.h +++ b/src/modules/ExternalNotificationModule.h @@ -10,6 +10,19 @@ extern AmbientLightingThread *ambientLightingThread; #endif +// Drive a single WS2812 as the notification LED (M1/M2-style LED_NOTIFICATION +// but addressable). A variant defines NEOPIXEL_STATUS_NOTIFICATION_PIN to +// enable. Colour defaults to green but can be overridden. +#ifdef NEOPIXEL_STATUS_NOTIFICATION_PIN +#include +#ifndef NEOPIXEL_STATUS_TYPE +#define NEOPIXEL_STATUS_TYPE (NEO_GRB + NEO_KHZ800) +#endif +#ifndef NEOPIXEL_STATUS_NOTIFICATION_COLOR +#define NEOPIXEL_STATUS_NOTIFICATION_COLOR 0x00FF00 // green +#endif +#endif + #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !defined(CONFIG_IDF_TARGET_ESP32C6) #include #else @@ -38,6 +51,10 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: CallbackObserver(this, &ExternalNotificationModule::handleInputEvent); uint32_t output = 0; +#ifdef NEOPIXEL_STATUS_NOTIFICATION_PIN + Adafruit_NeoPixel notificationPixel = Adafruit_NeoPixel(1, NEOPIXEL_STATUS_NOTIFICATION_PIN, NEOPIXEL_STATUS_TYPE); +#endif + public: ExternalNotificationModule(); diff --git a/src/modules/StatusLEDModule.cpp b/src/modules/StatusLEDModule.cpp index 4ea34fb529a..f3a0e7a03ed 100644 --- a/src/modules/StatusLEDModule.cpp +++ b/src/modules/StatusLEDModule.cpp @@ -17,7 +17,28 @@ StatusLEDModule::StatusLEDModule() : concurrency::OSThread("StatusLEDModule") if (inputBroker) inputObserver.observe(inputBroker); #endif +#ifdef NEOPIXEL_STATUS_POWER_PIN + powerPixel.begin(); + powerPixel.clear(); + powerPixel.show(); +#endif +#ifdef NEOPIXEL_STATUS_PAIRING_PIN + pairingPixel.begin(); + pairingPixel.clear(); + pairingPixel.show(); +#endif +} + +// Helper: write a 1-pixel NeoPixel strand to `color` when stateOn, else clear. +// Kept as a static inline here (rather than a member) so it compiles out +// completely when no NeoPixel status pins are defined. +#if defined(NEOPIXEL_STATUS_POWER_PIN) || defined(NEOPIXEL_STATUS_PAIRING_PIN) +static inline void writeStatusPixel(Adafruit_NeoPixel &pixel, uint32_t color, bool stateOn) +{ + pixel.setPixelColor(0, stateOn ? color : 0); + pixel.show(); } +#endif int StatusLEDModule::handleStatusUpdate(const meshtastic::Status *arg) { @@ -176,6 +197,12 @@ int32_t StatusLEDModule::runOnce() #ifdef LED_PAIRING digitalWrite(LED_PAIRING, PAIRING_LED_state); #endif +#ifdef NEOPIXEL_STATUS_POWER_PIN + writeStatusPixel(powerPixel, NEOPIXEL_STATUS_POWER_COLOR, CHARGE_LED_state == LED_STATE_ON); +#endif +#ifdef NEOPIXEL_STATUS_PAIRING_PIN + writeStatusPixel(pairingPixel, NEOPIXEL_STATUS_PAIRING_COLOR, PAIRING_LED_state == LED_STATE_ON); +#endif #ifdef RGB_LED_POWER if (!config.device.led_heartbeat_disabled) { @@ -225,6 +252,12 @@ void StatusLEDModule::setPowerLED(bool LEDon) #ifdef LED_PAIRING digitalWrite(LED_PAIRING, ledState); #endif +#ifdef NEOPIXEL_STATUS_POWER_PIN + writeStatusPixel(powerPixel, NEOPIXEL_STATUS_POWER_COLOR, LEDon); +#endif +#ifdef NEOPIXEL_STATUS_PAIRING_PIN + writeStatusPixel(pairingPixel, NEOPIXEL_STATUS_PAIRING_COLOR, LEDon); +#endif #ifdef Battery_LED_1 digitalWrite(Battery_LED_1, ledState); diff --git a/src/modules/StatusLEDModule.h b/src/modules/StatusLEDModule.h index f66a536f677..f20198e39ef 100644 --- a/src/modules/StatusLEDModule.h +++ b/src/modules/StatusLEDModule.h @@ -13,6 +13,25 @@ #include "input/InputBroker.h" #endif +// WS2812/NeoPixel status-LED support. A variant may define +// NEOPIXEL_STATUS_POWER_PIN (required to enable the power/charge pixel) +// NEOPIXEL_STATUS_POWER_COLOR (optional, default red 0xFF0000) +// NEOPIXEL_STATUS_PAIRING_PIN / _COLOR (default blue 0x0000FF) +// Each pixel is a standalone 1-LED strand on its own GPIO — this mirrors how +// boards like the LilyGo T-Echo-Card expose three independent WS2812s. +#if defined(NEOPIXEL_STATUS_POWER_PIN) || defined(NEOPIXEL_STATUS_PAIRING_PIN) +#include +#ifndef NEOPIXEL_STATUS_TYPE +#define NEOPIXEL_STATUS_TYPE (NEO_GRB + NEO_KHZ800) +#endif +#ifndef NEOPIXEL_STATUS_POWER_COLOR +#define NEOPIXEL_STATUS_POWER_COLOR 0xFF0000 // red +#endif +#ifndef NEOPIXEL_STATUS_PAIRING_COLOR +#define NEOPIXEL_STATUS_PAIRING_COLOR 0x0000FF // blue +#endif +#endif + class StatusLEDModule : private concurrency::OSThread { bool slowTrack = false; @@ -27,6 +46,13 @@ class StatusLEDModule : private concurrency::OSThread void setPowerLED(bool); +#ifdef NEOPIXEL_STATUS_POWER_PIN + Adafruit_NeoPixel powerPixel = Adafruit_NeoPixel(1, NEOPIXEL_STATUS_POWER_PIN, NEOPIXEL_STATUS_TYPE); +#endif +#ifdef NEOPIXEL_STATUS_PAIRING_PIN + Adafruit_NeoPixel pairingPixel = Adafruit_NeoPixel(1, NEOPIXEL_STATUS_PAIRING_PIN, NEOPIXEL_STATUS_TYPE); +#endif + protected: unsigned int my_interval = 1000; // interval in millisconds virtual int32_t runOnce() override; diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 3bb4ce8179d..d4cb1d9effa 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -610,7 +610,7 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks display->setTextAlignment(TEXT_ALIGN_CENTER); display->setFont(FONT_MEDIUM); display->drawString(x_offset + x, y_offset + y, "Bluetooth"); -#if !defined(M5STACK_UNITC6L) +#if !defined(OLED_TINY) display->setFont(FONT_SMALL); y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_MEDIUM - 4 : y_offset + FONT_HEIGHT_MEDIUM + 5; display->drawString(x_offset + x, y_offset + y, "Enter this code"); diff --git a/variants/esp32c6/m5stack_unitc6l/variant.h b/variants/esp32c6/m5stack_unitc6l/variant.h index 1654ee590ca..576d4e114b8 100644 --- a/variants/esp32c6/m5stack_unitc6l/variant.h +++ b/variants/esp32c6/m5stack_unitc6l/variant.h @@ -48,6 +48,9 @@ void c6l_init(); #define SSD1306_RESET 15 // #define OLED_DG 1 #endif +// Tiny OLED panel — opts into compile-time layout/font/feature substitutions +// gated on OLED_TINY across the graphics stack. +#define OLED_TINY #define SCREEN_TRANSITION_FRAMERATE 10 #define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness diff --git a/variants/nrf52840/t-echo-card/platformio.ini b/variants/nrf52840/t-echo-card/platformio.ini new file mode 100644 index 00000000000..bc012d6e108 --- /dev/null +++ b/variants/nrf52840/t-echo-card/platformio.ini @@ -0,0 +1,12 @@ +[env:t-echo-card] +extends = nrf52840_base +board = t-echo +board_level = extra +debug_tool = jlink + +build_flags = ${nrf52840_base.build_flags} + -I variants/nrf52840/t-echo-card + -D PRIVATE_HW + -D T_ECHO_CARD + +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/t-echo-card> diff --git a/variants/nrf52840/t-echo-card/variant.cpp b/variants/nrf52840/t-echo-card/variant.cpp new file mode 100644 index 00000000000..e82a63f8ef8 --- /dev/null +++ b/variants/nrf52840/t-echo-card/variant.cpp @@ -0,0 +1,66 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "variant.h" +#include "Arduino.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[] = { + // P0 - pins 0 and 1 are hardwired for xtal and should never be enabled + 0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47}; + +void initVariant() +{ + // No plain GPIO LEDs on this board (only WS2812 addressable LEDs, not driven here). +} + +// Reproduces the vendor firmware's boot sequence from +// examples/original_test/original_test.ino. Runs before Meshtastic touches +// PIN_POWER_EN, so the RT9080 LDO gets a clean reset pulse and peripherals +// whose EN pins must be LOW at boot (GPS_EN, GPS_RF_EN, BUZZER) aren't left +// floating while the 3V3 rail is ramping. +void earlyInitVariant() +{ + // 3.3V rail: toggle RT9080_EN HIGH → LOW → HIGH with 100 ms dwell so the + // LDO enters enable from a known state. The single-shot HIGH in main.cpp + // is not enough on this hardware — if the chip was in a half-enabled + // state from a previous reset, the rail brown-outs once LoRa TX fires. + pinMode(PIN_POWER_EN, OUTPUT); + digitalWrite(PIN_POWER_EN, HIGH); + delay(100); + digitalWrite(PIN_POWER_EN, LOW); + delay(100); + digitalWrite(PIN_POWER_EN, HIGH); + delay(100); + + // Park peripherals with active-high enables LOW so they don't sink + // current while the rest of setup() runs. + pinMode(PIN_GPS_STANDBY, OUTPUT); + digitalWrite(PIN_GPS_STANDBY, LOW); + pinMode(PIN_GPS_RESET, OUTPUT); + digitalWrite(PIN_GPS_RESET, LOW); + pinMode(PIN_BUZZER, OUTPUT); + digitalWrite(PIN_BUZZER, LOW); +} diff --git a/variants/nrf52840/t-echo-card/variant.h b/variants/nrf52840/t-echo-card/variant.h new file mode 100644 index 00000000000..37fa28d89a0 --- /dev/null +++ b/variants/nrf52840/t-echo-card/variant.h @@ -0,0 +1,202 @@ +// Variant definition for LilyGo T-Echo-Card (nRF52840) + +#ifndef _VARIANT_T_ECHO_CARD_ +#define _VARIANT_T_ECHO_CARD_ + +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32kHz crystal for LF + +/*---------------------------------------------------------------------------- + * Headers + *----------------------------------------------------------------------------*/ + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +// Number of pins defined in PinDescription array +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (1) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs - board only exposes 3x WS2812 addressable LEDs. No plain GPIO LEDs. +// Intentionally do not define PIN_LED1 on this variant, so nRF52 platform +// code does not auto-enable a nonexistent GPIO power/status LED. +#define LED_STATE_ON 1 + +// Three independent WS2812 data lines (one LED per line, not a daisy chain). +// Each is driven as a 1-pixel NeoPixel by StatusLEDModule / ExternalNotification, +// assigns LED_POWER (red) and LED_NOTIFICATION (green). +#define WS2812_DATA_1 (32 + 7) // P1.7 - charge/heartbeat (red) +#define WS2812_DATA_2 (32 + 12) // P1.12 - external notification (green) +#define WS2812_DATA_3 (0 + 28) // P0.28 - BLE pairing (blue) + +// Wire each WS2812 to a status role. Colour defaults are scaled to 25% +// brightness (0x40) — the bare-die WS2812s on this board are very bright at +// full intensity in a close-range enclosure. +#define NEOPIXEL_STATUS_POWER_PIN WS2812_DATA_1 +#define NEOPIXEL_STATUS_NOTIFICATION_PIN WS2812_DATA_2 +#define NEOPIXEL_STATUS_PAIRING_PIN WS2812_DATA_3 +#define NEOPIXEL_STATUS_POWER_COLOR 0x400000 // red @ 25% +#define NEOPIXEL_STATUS_NOTIFICATION_COLOR 0x004000 // green @ 25% +#define NEOPIXEL_STATUS_PAIRING_COLOR 0x000040 // blue @ 25% + +// The charger IC does not blink on its own; let StatusLEDModule do the +// software blink while charging +// If left defined: hardware would be expected to handle the charging pulse. +// #define POWER_LED_HARDWARE_BLINKS_WHILE_CHARGING + +// Buttons +#define PIN_BUTTON1 (32 + 10) // KEY_1: P1.10 + +#define BUTTON_CLICK_MS 400 + +// Analog pins +#define PIN_A0 (0 + 2) // Battery ADC (BATTERY_ADC_DATA) + +#define BATTERY_PIN PIN_A0 + +static const uint8_t A0 = PIN_A0; + +#define ADC_RESOLUTION 14 + +// BATTERY_MEASUREMENT_CONTROL - enable divider for battery reading +#define ADC_CTRL (0 + 31) +#define ADC_CTRL_ENABLED HIGH + +// NFC placeholders, not used +#define PIN_NFC1 (9) +#define PIN_NFC2 (10) + +// Wire Interfaces (IIC_1 on the vendor header) +#define WIRE_INTERFACES_COUNT 1 + +#define PIN_WIRE_SDA (32 + 4) // IIC_1_SDA: P1.4 +#define PIN_WIRE_SCL (32 + 2) // IIC_1_SCL: P1.2 + +// External serial flash ZD25WQ32CEIGR +// QSPI Pins +#define PIN_QSPI_SCK (0 + 4) +#define PIN_QSPI_CS (0 + 12) +#define PIN_QSPI_IO0 (0 + 6) // MOSI if using two bit interface +#define PIN_QSPI_IO1 (0 + 8) // MISO if using two bit interface +#define PIN_QSPI_IO2 (32 + 9) // WP +#define PIN_QSPI_IO3 (0 + 26) // HOLD + +// On-board QSPI Flash +#define EXTERNAL_FLASH_DEVICES ZD25WQ32CEIGR +#define EXTERNAL_FLASH_USE_QSPI + +// Lora S62F (SX1262) +#define USE_SX1262 +#define SX126X_CS (0 + 11) +#define SX126X_DIO1 (32 + 8) +#define SX126X_DIO2 (0 + 5) +#define SX126X_BUSY (0 + 14) +#define SX126X_RESET (0 + 7) +#define SX126X_RXEN (32 + 1) // SX1262_RF_VC2 +#define SX126X_TXEN (0 + 27) // SX1262_RF_VC1 +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// ─────────────────────────────────────────────────────────────────────────── +// OLED display: SSD1315 on I2C @ 0x3C (IIC_1). SSD1315 is register-compatible +// with SSD1306, so USE_SSD1306 initializes the controller correctly. +// +// Viewport: the physical panel is 72×40, mapped into the SSD1315's 128×64 +// GDDRAM at columns 28..99, pages 3..7 (rows 24..63). The firmware handles +// this by: +// * asking the library for GEOMETRY_72_40, which sets the framebuffer to +// 72×40 and emits the right SETMULTIPLEX (39) / SETCOMPINS at init; +// * relying on SSD1306Wire's built-in horizontal auto-centering +// ((128 - width) / 2 = 28), so no horizontal shim is needed; +// * calling SSD1306Wire::setYOffset(3) in Screen.cpp when +// OLED_Y_OFFSET_PAGES is defined — this shifts every PAGEADDR write by +// three pages (24 rows) so data lands on the visible rows. +// ─────────────────────────────────────────────────────────────────────────── +#define HAS_SCREEN 1 +#define USE_SSD1306 +#define OLED_GEOMETRY_OVERRIDE GEOMETRY_72_40 +#define OLED_Y_OFFSET_PAGES 3 +#define OLED_TINY + +// Controls power 3V3 for all peripherals (GPS + LoRa + Sensor) +#define PIN_POWER_EN (0 + 30) // RT9080_EN + +// SPI1 is unused (no external SPI display). Keep declarations for the core. +#define PIN_SPI1_MISO (-1) +#define PIN_SPI1_MOSI (-1) +#define PIN_SPI1_SCK (-1) + +// GPS pins +#define GPS_L76K +#define GPS_BAUDRATE 9600 +#define HAS_GPS 1 + +#define PIN_GPS_EN (32 + 15) // GPS_EN: P1.15 - GPS power enable +#define GPS_EN_ACTIVE 1 +#define PIN_GPS_STANDBY (0 + 25) // GPS_WAKE_UP: P0.25 - wakeup pin +#define PIN_GPS_PPS (0 + 23) // GPS_1PPS: P0.23 +#define GPS_RX_PIN (0 + 19) // MCU RX ← GPS's TX (vendor GPS_UART_TX / P0.19) +#define GPS_TX_PIN (0 + 21) // MCU TX → GPS's RX (vendor GPS_UART_RX / P0.21) +#define PIN_GPS_RESET (0 + 29) // GPS_RF_EN: GPS RF enable / reset + +#define GPS_THREAD_INTERVAL 50 + +#define PIN_SERIAL1_RX GPS_RX_PIN +#define PIN_SERIAL1_TX GPS_TX_PIN + +// SPI Interfaces (LoRa on SPI0) +#define SPI_INTERFACES_COUNT 2 + +// For LORA, SPI 0 +#define PIN_SPI_MISO (0 + 17) +#define PIN_SPI_MOSI (0 + 15) +#define PIN_SPI_SCK (0 + 13) + +// Battery +// The battery sense is hooked to PIN_A0 (P0.2) via a divider controlled by ADC_CTRL. +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#undef AREF_VOLTAGE +#define AREF_VOLTAGE 3.0 +#define VBAT_AR_INTERNAL AR_INTERNAL_3_0 +#define ADC_MULTIPLIER (2.0F) + +// Buzzer (PWM output, passive piezo) +#define PIN_BUZZER (32 + 6) // BUZZER_DATA: P1.6 + +// ─────────────────────────────────────────────────────────────────────────── +// I²S speaker (MAX98357 Class-D amp). Stereo I²S data path. +// Not supported on nrf52. These defines exist for out-of-tree code only. +// ─────────────────────────────────────────────────────────────────────────── +#define SPEAKER_EN (32 + 11) // P1.11 - amp main enable +#define SPEAKER_EN_2 (0 + 3) // P0.3 - secondary enable (vendor firmware toggles both) +#define SPEAKER_BCLK (0 + 16) // P0.16 - I2S bit clock +#define SPEAKER_DATA (0 + 20) // P0.20 - I2S data (SDOUT) +#define SPEAKER_WS_LRCK (0 + 22) // P0.22 - I2S word select / LRCK + +// ─────────────────────────────────────────────────────────────────────────── +// PDM microphone (ST MP34DT05). +// TODO to enable a mic path: +// Use Adafruit nRF52 core's built-in PDM.h wrapper (Arduino-compatible +// API exists on nRF52840). Clock on MIC_SCLK, data on MIC_DATA. +// ─────────────────────────────────────────────────────────────────────────── +#define MIC_SCLK (32 + 3) // P1.3 - PDM clock (MIC_SCLK on vendor header) +#define MIC_DATA (32 + 5) // P1.5 - PDM data (MIC_DATA on vendor header) + +#define SERIAL_PRINT_PORT 0 + +#ifdef __cplusplus +} +#endif + +/*---------------------------------------------------------------------------- + * Arduino objects - C++ only + *----------------------------------------------------------------------------*/ + +#endif From 0f9eb86830c82c22b525d5e6f3130097345beef6 Mon Sep 17 00:00:00 2001 From: Riker Date: Tue, 19 May 2026 14:31:42 +0800 Subject: [PATCH 213/225] Enabled SX_LNA_EN by default (#10469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enabled SX_LNA_EN by default * Update I2C configuration for IO direction and pull settings --------- Co-authored-by: Thomas Göttgens --- variants/esp32c6/m5stack_unitc6l/variant.cpp | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/variants/esp32c6/m5stack_unitc6l/variant.cpp b/variants/esp32c6/m5stack_unitc6l/variant.cpp index 8e26b4ab7fd..7dc5785f682 100644 --- a/variants/esp32c6/m5stack_unitc6l/variant.cpp +++ b/variants/esp32c6/m5stack_unitc6l/variant.cpp @@ -52,23 +52,25 @@ void c6l_init() vTaskDelay(10 / portTICK_PERIOD_MS); i2c_read_byte(PI4IO_M_ADDR, PI4IO_REG_CHIP_RESET, &in_data); vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_IO_DIR, 0b11000000); // 0: input 1: output + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_IO_DIR, 0b11100000); // P5,P6,P7 as outputs vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_H_IM, 0b00111100); // 使用到的引脚关闭High-Impedance + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_H_IM, 0b00011100); // High-Impedance vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_PULL_SEL, 0b11000011); // pull up/down select, 0 down, 1 up + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_PULL_SEL, 0b11100011); // pull up/down select, 0 down, 1 up vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_PULL_EN, 0b11000011); // pull up/down enable, 0 disable, 1 enable + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_PULL_EN, 0b11100011); // pull up/down enable, 0 disable, 1 enable vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_IN_DEF_STA, 0b00000011); // P0 P1 默认高电平, 按键按下触发中断 + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_IN_DEF_STA, 0b00000011); // P0 P1 default to high level; button press triggers the interrupt vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_INT_MASK, 0b11111100); // P0 P1 中断使能 0 enable, 1 disable + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_INT_MASK, 0b11111100); // P0 P1 interrupts enabled (0 = enable, 1 = disable) vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_SET, 0b10000000); // 默认输出为0 + i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_SET, 0b10000000); // default output is 0 + vTaskDelay(10 / portTICK_PERIOD_MS); + i2c_read_byte(PI4IO_M_ADDR, PI4IO_REG_IRQ_STA, &in_data); // read IRQ_STA to clear the flag vTaskDelay(10 / portTICK_PERIOD_MS); - i2c_read_byte(PI4IO_M_ADDR, PI4IO_REG_IRQ_STA, &in_data); // 读取IRQ_STA清除标志 i2c_read_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_SET, &in_data); - setbit(in_data, 6); // HIGH + setbit(in_data, 6); // enable SX_ANT_SW + setbit(in_data, 5); // enable SX_LNA_EN i2c_write_byte(PI4IO_M_ADDR, PI4IO_REG_OUT_SET, in_data); } From 82aefd1af16648e73ba58e2a27d353855a9f3db6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 05:23:01 -0500 Subject: [PATCH 214/225] Upgrade trunk (#10503) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 77006cf99a8..0bbfbcf08e6 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -26,7 +26,7 @@ lint: - hadolint@2.14.0 - shfmt@3.6.0 - shellcheck@0.11.0 - - black@26.5.0 + - black@26.5.1 - git-diff-check - gitleaks@8.30.1 - clang-format@16.0.3 From 00ec69201d96d22baf54b649cc030b76c83b5014 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 19 May 2026 06:57:05 -0500 Subject: [PATCH 215/225] Develop protos should be on develop --- protobufs | 2 +- src/mesh/generated/meshtastic/admin.pb.h | 45 ++++- .../generated/meshtastic/deviceonly.pb.cpp | 12 ++ src/mesh/generated/meshtastic/deviceonly.pb.h | 185 +++++++++++++----- src/mesh/generated/meshtastic/mesh.pb.h | 39 ++-- 5 files changed, 218 insertions(+), 65 deletions(-) diff --git a/protobufs b/protobufs index 59cb394dcfc..e978a1850b9 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 59cb394dcfc4432cb216358ca26e861c7d13f462 +Subproject commit e978a1850b905e05913c6ef6c73c1d3b79486d4a diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index e6f5110ad30..82644bc2a46 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -163,6 +163,41 @@ typedef struct _meshtastic_LockdownAuth { connection-level admin authorization, and reboot the device into the locked state. Always honoured regardless of current lock state. */ bool lock_now; + /* Optional per-boot uptime cap on the unlocked session, in seconds. + 0 = unlimited (token-only enforcement, suitable for unattended + tower / infrastructure nodes). + + When non-zero, the firmware arms an uptime timer at unlock. On + each expiry, while there is still boot-count budget, the firmware + decrements the on-flash boot count in place, revokes per- + connection admin auth (clients must re-authenticate to see + content), re-engages the screen lock, and re-arms the timer + without rebooting. Mesh routing keeps running across session + boundaries; only when the boot-count budget reaches zero does + the device hard-lock and reboot. + + Total exposure ceiling = ((resolved boot count) + 1) * max_session_seconds. + The +1 accounts for the initial passphrase-unlocked session + itself, since boots_remaining is the number of subsequent + session rolls (each consuming one boot from the rollback ledger). + The resolved boot count is the value the firmware writes into the + token at unlock time: the client-supplied boots_remaining when + non-zero, otherwise the firmware default (TOKEN_DEFAULT_BOOTS). + Note that boots_remaining == 0 in this message means "use firmware + default", NOT "zero boots" — a client computing the ceiling for + display should mirror that resolution rather than multiplying the + raw request value. + + The cap is persisted in the token, so it survives token-based + auto-unlock across reboots. Explicit operator Lock Now still + deletes the token and forces passphrase re-entry. + + Uses millis() (CPU uptime), not wall-clock time, so the cap is + immune to GPS spoofing, RTC backup-battery removal, and Faraday + cage isolation — none of those move the uptime counter. The only + way to reset the session clock is a reboot, which costs a boot + from the on-flash, HMAC-bound counter. */ + uint32_t max_session_seconds; } meshtastic_LockdownAuth; /* Parameters for setting up Meshtastic for ameteur radio usage */ @@ -486,7 +521,7 @@ extern "C" { #define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}} -#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0} +#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0} #define meshtastic_HamParameters_init_default {"", 0, 0, ""} #define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} #define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0} @@ -499,7 +534,7 @@ extern "C" { #define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}} -#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0} +#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0} #define meshtastic_HamParameters_init_zero {"", 0, 0, ""} #define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} #define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0} @@ -521,6 +556,7 @@ extern "C" { #define meshtastic_LockdownAuth_boots_remaining_tag 2 #define meshtastic_LockdownAuth_valid_until_epoch_tag 3 #define meshtastic_LockdownAuth_lock_now_tag 4 +#define meshtastic_LockdownAuth_max_session_seconds_tag 5 #define meshtastic_HamParameters_call_sign_tag 1 #define meshtastic_HamParameters_tx_power_tag 2 #define meshtastic_HamParameters_frequency_tag 3 @@ -717,7 +753,8 @@ X(a, STATIC, SINGULAR, BYTES, ota_hash, 2) X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \ X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \ X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \ -X(a, STATIC, SINGULAR, BOOL, lock_now, 4) +X(a, STATIC, SINGULAR, BOOL, lock_now, 4) \ +X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5) #define meshtastic_LockdownAuth_CALLBACK NULL #define meshtastic_LockdownAuth_DEFAULT NULL @@ -832,7 +869,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_AdminMessage_size 511 #define meshtastic_HamParameters_size 31 #define meshtastic_KeyVerificationAdmin_size 25 -#define meshtastic_LockdownAuth_size 48 +#define meshtastic_LockdownAuth_size 54 #define meshtastic_NodeRemoteHardwarePinsResponse_size 496 #define meshtastic_SCD30_config_size 27 #define meshtastic_SCD4X_config_size 29 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.cpp b/src/mesh/generated/meshtastic/deviceonly.pb.cpp index 5a96957027d..5580866379a 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.cpp +++ b/src/mesh/generated/meshtastic/deviceonly.pb.cpp @@ -18,6 +18,18 @@ PB_BIND(meshtastic_NodeInfoLite, meshtastic_NodeInfoLite, AUTO) PB_BIND(meshtastic_DeviceState, meshtastic_DeviceState, 2) +PB_BIND(meshtastic_NodePositionEntry, meshtastic_NodePositionEntry, AUTO) + + +PB_BIND(meshtastic_NodeTelemetryEntry, meshtastic_NodeTelemetryEntry, AUTO) + + +PB_BIND(meshtastic_NodeEnvironmentEntry, meshtastic_NodeEnvironmentEntry, AUTO) + + +PB_BIND(meshtastic_NodeStatusEntry, meshtastic_NodeStatusEntry, AUTO) + + PB_BIND(meshtastic_NodeDatabase, meshtastic_NodeDatabase, AUTO) diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 6d03dc64379..7c14c3e0fbc 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -33,6 +33,8 @@ typedef struct _meshtastic_PositionLite { uint32_t time; /* TODO: REPLACE */ meshtastic_Position_LocSource location_source; + /* Indicates the bits of precision set by the sending node */ + uint32_t precision_bits; } meshtastic_PositionLite; typedef PB_BYTES_ARRAY_T(32) meshtastic_UserLite_public_key_t; @@ -63,43 +65,35 @@ typedef struct _meshtastic_UserLite { bool is_unmessagable; } meshtastic_UserLite; +typedef PB_BYTES_ARRAY_T(32) meshtastic_NodeInfoLite_public_key_t; typedef struct _meshtastic_NodeInfoLite { /* The node number */ uint32_t num; - /* The user info for this node */ - bool has_user; - meshtastic_UserLite user; - /* This position data. Note: before 1.2.14 we would also store the last time we've heard from this node in position.time, that is no longer true. - Position.time now indicates the last time we received a POSITION from that node. */ - bool has_position; - meshtastic_PositionLite position; /* Returns the Signal-to-noise ratio (SNR) of the last received message, as measured by the receiver. Return SNR of the last received message in dB */ float snr; /* Set to indicate the last time we received a packet from this node */ uint32_t last_heard; - /* The latest device metrics for the node. */ - bool has_device_metrics; - meshtastic_DeviceMetrics device_metrics; /* local channel index we heard that node on. Only populated if its not the default channel. */ uint8_t channel; - /* True if we witnessed the node over MQTT instead of LoRA transport */ - bool via_mqtt; /* Number of hops away from us this node is (0 if direct neighbor) */ bool has_hops_away; uint8_t hops_away; - /* True if node is in our favorites list - Persists between NodeDB internal clean ups */ - bool is_favorite; - /* True if node is in our ignored list - Persists between NodeDB internal clean ups */ - bool is_ignored; /* Last byte of the node number of the node that should be used as the next hop to reach this node. */ uint8_t next_hop; - /* Bitfield for storing booleans. - LSB 0 is_key_manually_verified - LSB 1 is_muted */ + /* Bitfield for storing booleans. See NODEINFO_BITFIELD_* in src/mesh/NodeDB.h. */ uint32_t bitfield; + /* A full name for this user, i.e. "Kevin Hester". */ + char long_name[25]; + /* A VERY short name, ideally two characters or an emoji. + Suitable for a tiny OLED screen. */ + char short_name[5]; + /* Hardware model the user's device is running. */ + meshtastic_HardwareModel hw_model; + /* The user's role in the mesh. */ + meshtastic_Config_DeviceConfig_Role role; + /* The public key of the user's device, for PKI-based encrypted DMs. */ + meshtastic_NodeInfoLite_public_key_t public_key; } meshtastic_NodeInfoLite; /* This message is never sent over the wire, but it is used for serializing DB @@ -143,6 +137,30 @@ typedef struct _meshtastic_DeviceState { meshtastic_NodeRemoteHardwarePin node_remote_hardware_pins[12]; } meshtastic_DeviceState; +typedef struct _meshtastic_NodePositionEntry { + uint32_t num; + bool has_position; + meshtastic_PositionLite position; +} meshtastic_NodePositionEntry; + +typedef struct _meshtastic_NodeTelemetryEntry { + uint32_t num; + bool has_device_metrics; + meshtastic_DeviceMetrics device_metrics; +} meshtastic_NodeTelemetryEntry; + +typedef struct _meshtastic_NodeEnvironmentEntry { + uint32_t num; + bool has_environment_metrics; + meshtastic_EnvironmentMetrics environment_metrics; +} meshtastic_NodeEnvironmentEntry; + +typedef struct _meshtastic_NodeStatusEntry { + uint32_t num; + bool has_status; + meshtastic_StatusMessage status; +} meshtastic_NodeStatusEntry; + typedef struct _meshtastic_NodeDatabase { /* A version integer used to invalidate old save files when we make incompatible changes This integer is set at build time and is private to @@ -150,6 +168,12 @@ typedef struct _meshtastic_NodeDatabase { uint32_t version; /* New lite version of NodeDB to decrease memory footprint */ std::vector nodes; + /* Per-NodeNum satellite arrays. Constrained platforms (e.g. STM32WL) omit + these via MESHTASTIC_EXCLUDE_*DB build flags. */ + std::vector positions; + std::vector telemetry; + std::vector status; + std::vector environment; } meshtastic_NodeDatabase; /* The on-disk saved channels */ @@ -189,18 +213,26 @@ extern "C" { #endif /* Initializer values for message structs */ -#define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} +#define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0} #define meshtastic_UserLite_init_default {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} -#define meshtastic_NodeInfoLite_init_default {0, false, meshtastic_UserLite_init_default, false, meshtastic_PositionLite_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} #define meshtastic_DeviceState_init_default {false, meshtastic_MyNodeInfo_init_default, false, meshtastic_User_init_default, 0, {meshtastic_MeshPacket_init_default}, false, meshtastic_MeshPacket_init_default, 0, 0, 0, false, meshtastic_MeshPacket_init_default, 0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} -#define meshtastic_NodeDatabase_init_default {0, {0}} +#define meshtastic_NodePositionEntry_init_default {0, false, meshtastic_PositionLite_init_default} +#define meshtastic_NodeTelemetryEntry_init_default {0, false, meshtastic_DeviceMetrics_init_default} +#define meshtastic_NodeEnvironmentEntry_init_default {0, false, meshtastic_EnvironmentMetrics_init_default} +#define meshtastic_NodeStatusEntry_init_default {0, false, meshtastic_StatusMessage_init_default} +#define meshtastic_NodeDatabase_init_default {0, {0}, {0}, {0}, {0}, {0}} #define meshtastic_ChannelFile_init_default {0, {meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default, meshtastic_Channel_init_default}, 0} #define meshtastic_BackupPreferences_init_default {0, 0, false, meshtastic_LocalConfig_init_default, false, meshtastic_LocalModuleConfig_init_default, false, meshtastic_ChannelFile_init_default, false, meshtastic_User_init_default} -#define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN} +#define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0} #define meshtastic_UserLite_init_zero {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} -#define meshtastic_NodeInfoLite_init_zero {0, false, meshtastic_UserLite_init_zero, false, meshtastic_PositionLite_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}} #define meshtastic_DeviceState_init_zero {false, meshtastic_MyNodeInfo_init_zero, false, meshtastic_User_init_zero, 0, {meshtastic_MeshPacket_init_zero}, false, meshtastic_MeshPacket_init_zero, 0, 0, 0, false, meshtastic_MeshPacket_init_zero, 0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} -#define meshtastic_NodeDatabase_init_zero {0, {0}} +#define meshtastic_NodePositionEntry_init_zero {0, false, meshtastic_PositionLite_init_zero} +#define meshtastic_NodeTelemetryEntry_init_zero {0, false, meshtastic_DeviceMetrics_init_zero} +#define meshtastic_NodeEnvironmentEntry_init_zero {0, false, meshtastic_EnvironmentMetrics_init_zero} +#define meshtastic_NodeStatusEntry_init_zero {0, false, meshtastic_StatusMessage_init_zero} +#define meshtastic_NodeDatabase_init_zero {0, {0}, {0}, {0}, {0}, {0}} #define meshtastic_ChannelFile_init_zero {0, {meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero, meshtastic_Channel_init_zero}, 0} #define meshtastic_BackupPreferences_init_zero {0, 0, false, meshtastic_LocalConfig_init_zero, false, meshtastic_LocalModuleConfig_init_zero, false, meshtastic_ChannelFile_init_zero, false, meshtastic_User_init_zero} @@ -210,6 +242,7 @@ extern "C" { #define meshtastic_PositionLite_altitude_tag 3 #define meshtastic_PositionLite_time_tag 4 #define meshtastic_PositionLite_location_source_tag 5 +#define meshtastic_PositionLite_precision_bits_tag 6 #define meshtastic_UserLite_macaddr_tag 1 #define meshtastic_UserLite_long_name_tag 2 #define meshtastic_UserLite_short_name_tag 3 @@ -219,18 +252,17 @@ extern "C" { #define meshtastic_UserLite_public_key_tag 7 #define meshtastic_UserLite_is_unmessagable_tag 9 #define meshtastic_NodeInfoLite_num_tag 1 -#define meshtastic_NodeInfoLite_user_tag 2 -#define meshtastic_NodeInfoLite_position_tag 3 #define meshtastic_NodeInfoLite_snr_tag 4 #define meshtastic_NodeInfoLite_last_heard_tag 5 -#define meshtastic_NodeInfoLite_device_metrics_tag 6 #define meshtastic_NodeInfoLite_channel_tag 7 -#define meshtastic_NodeInfoLite_via_mqtt_tag 8 #define meshtastic_NodeInfoLite_hops_away_tag 9 -#define meshtastic_NodeInfoLite_is_favorite_tag 10 -#define meshtastic_NodeInfoLite_is_ignored_tag 11 #define meshtastic_NodeInfoLite_next_hop_tag 12 #define meshtastic_NodeInfoLite_bitfield_tag 13 +#define meshtastic_NodeInfoLite_long_name_tag 14 +#define meshtastic_NodeInfoLite_short_name_tag 15 +#define meshtastic_NodeInfoLite_hw_model_tag 16 +#define meshtastic_NodeInfoLite_role_tag 17 +#define meshtastic_NodeInfoLite_public_key_tag 18 #define meshtastic_DeviceState_my_node_tag 2 #define meshtastic_DeviceState_owner_tag 3 #define meshtastic_DeviceState_receive_queue_tag 5 @@ -240,8 +272,20 @@ extern "C" { #define meshtastic_DeviceState_did_gps_reset_tag 11 #define meshtastic_DeviceState_rx_waypoint_tag 12 #define meshtastic_DeviceState_node_remote_hardware_pins_tag 13 +#define meshtastic_NodePositionEntry_num_tag 1 +#define meshtastic_NodePositionEntry_position_tag 2 +#define meshtastic_NodeTelemetryEntry_num_tag 1 +#define meshtastic_NodeTelemetryEntry_device_metrics_tag 2 +#define meshtastic_NodeEnvironmentEntry_num_tag 1 +#define meshtastic_NodeEnvironmentEntry_environment_metrics_tag 2 +#define meshtastic_NodeStatusEntry_num_tag 1 +#define meshtastic_NodeStatusEntry_status_tag 2 #define meshtastic_NodeDatabase_version_tag 1 #define meshtastic_NodeDatabase_nodes_tag 2 +#define meshtastic_NodeDatabase_positions_tag 3 +#define meshtastic_NodeDatabase_telemetry_tag 4 +#define meshtastic_NodeDatabase_status_tag 5 +#define meshtastic_NodeDatabase_environment_tag 6 #define meshtastic_ChannelFile_channels_tag 1 #define meshtastic_ChannelFile_version_tag 2 #define meshtastic_BackupPreferences_version_tag 1 @@ -257,7 +301,8 @@ X(a, STATIC, SINGULAR, SFIXED32, latitude_i, 1) \ X(a, STATIC, SINGULAR, SFIXED32, longitude_i, 2) \ X(a, STATIC, SINGULAR, INT32, altitude, 3) \ X(a, STATIC, SINGULAR, FIXED32, time, 4) \ -X(a, STATIC, SINGULAR, UENUM, location_source, 5) +X(a, STATIC, SINGULAR, UENUM, location_source, 5) \ +X(a, STATIC, SINGULAR, UINT32, precision_bits, 6) #define meshtastic_PositionLite_CALLBACK NULL #define meshtastic_PositionLite_DEFAULT NULL @@ -275,23 +320,19 @@ X(a, STATIC, OPTIONAL, BOOL, is_unmessagable, 9) #define meshtastic_NodeInfoLite_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, num, 1) \ -X(a, STATIC, OPTIONAL, MESSAGE, user, 2) \ -X(a, STATIC, OPTIONAL, MESSAGE, position, 3) \ X(a, STATIC, SINGULAR, FLOAT, snr, 4) \ X(a, STATIC, SINGULAR, FIXED32, last_heard, 5) \ -X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 6) \ X(a, STATIC, SINGULAR, UINT32, channel, 7) \ -X(a, STATIC, SINGULAR, BOOL, via_mqtt, 8) \ X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \ -X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ -X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ X(a, STATIC, SINGULAR, UINT32, next_hop, 12) \ -X(a, STATIC, SINGULAR, UINT32, bitfield, 13) +X(a, STATIC, SINGULAR, UINT32, bitfield, 13) \ +X(a, STATIC, SINGULAR, STRING, long_name, 14) \ +X(a, STATIC, SINGULAR, STRING, short_name, 15) \ +X(a, STATIC, SINGULAR, UENUM, hw_model, 16) \ +X(a, STATIC, SINGULAR, UENUM, role, 17) \ +X(a, STATIC, SINGULAR, BYTES, public_key, 18) #define meshtastic_NodeInfoLite_CALLBACK NULL #define meshtastic_NodeInfoLite_DEFAULT NULL -#define meshtastic_NodeInfoLite_user_MSGTYPE meshtastic_UserLite -#define meshtastic_NodeInfoLite_position_MSGTYPE meshtastic_PositionLite -#define meshtastic_NodeInfoLite_device_metrics_MSGTYPE meshtastic_DeviceMetrics #define meshtastic_DeviceState_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, MESSAGE, my_node, 2) \ @@ -312,13 +353,49 @@ X(a, STATIC, REPEATED, MESSAGE, node_remote_hardware_pins, 13) #define meshtastic_DeviceState_rx_waypoint_MSGTYPE meshtastic_MeshPacket #define meshtastic_DeviceState_node_remote_hardware_pins_MSGTYPE meshtastic_NodeRemoteHardwarePin +#define meshtastic_NodePositionEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, position, 2) +#define meshtastic_NodePositionEntry_CALLBACK NULL +#define meshtastic_NodePositionEntry_DEFAULT NULL +#define meshtastic_NodePositionEntry_position_MSGTYPE meshtastic_PositionLite + +#define meshtastic_NodeTelemetryEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, device_metrics, 2) +#define meshtastic_NodeTelemetryEntry_CALLBACK NULL +#define meshtastic_NodeTelemetryEntry_DEFAULT NULL +#define meshtastic_NodeTelemetryEntry_device_metrics_MSGTYPE meshtastic_DeviceMetrics + +#define meshtastic_NodeEnvironmentEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, environment_metrics, 2) +#define meshtastic_NodeEnvironmentEntry_CALLBACK NULL +#define meshtastic_NodeEnvironmentEntry_DEFAULT NULL +#define meshtastic_NodeEnvironmentEntry_environment_metrics_MSGTYPE meshtastic_EnvironmentMetrics + +#define meshtastic_NodeStatusEntry_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, num, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, status, 2) +#define meshtastic_NodeStatusEntry_CALLBACK NULL +#define meshtastic_NodeStatusEntry_DEFAULT NULL +#define meshtastic_NodeStatusEntry_status_MSGTYPE meshtastic_StatusMessage + #define meshtastic_NodeDatabase_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, version, 1) \ -X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) +X(a, CALLBACK, REPEATED, MESSAGE, nodes, 2) \ +X(a, CALLBACK, REPEATED, MESSAGE, positions, 3) \ +X(a, CALLBACK, REPEATED, MESSAGE, telemetry, 4) \ +X(a, CALLBACK, REPEATED, MESSAGE, status, 5) \ +X(a, CALLBACK, REPEATED, MESSAGE, environment, 6) extern bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field); #define meshtastic_NodeDatabase_CALLBACK meshtastic_NodeDatabase_callback #define meshtastic_NodeDatabase_DEFAULT NULL #define meshtastic_NodeDatabase_nodes_MSGTYPE meshtastic_NodeInfoLite +#define meshtastic_NodeDatabase_positions_MSGTYPE meshtastic_NodePositionEntry +#define meshtastic_NodeDatabase_telemetry_MSGTYPE meshtastic_NodeTelemetryEntry +#define meshtastic_NodeDatabase_status_MSGTYPE meshtastic_NodeStatusEntry +#define meshtastic_NodeDatabase_environment_MSGTYPE meshtastic_NodeEnvironmentEntry #define meshtastic_ChannelFile_FIELDLIST(X, a) \ X(a, STATIC, REPEATED, MESSAGE, channels, 1) \ @@ -345,6 +422,10 @@ extern const pb_msgdesc_t meshtastic_PositionLite_msg; extern const pb_msgdesc_t meshtastic_UserLite_msg; extern const pb_msgdesc_t meshtastic_NodeInfoLite_msg; extern const pb_msgdesc_t meshtastic_DeviceState_msg; +extern const pb_msgdesc_t meshtastic_NodePositionEntry_msg; +extern const pb_msgdesc_t meshtastic_NodeTelemetryEntry_msg; +extern const pb_msgdesc_t meshtastic_NodeEnvironmentEntry_msg; +extern const pb_msgdesc_t meshtastic_NodeStatusEntry_msg; extern const pb_msgdesc_t meshtastic_NodeDatabase_msg; extern const pb_msgdesc_t meshtastic_ChannelFile_msg; extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; @@ -354,6 +435,10 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_UserLite_fields &meshtastic_UserLite_msg #define meshtastic_NodeInfoLite_fields &meshtastic_NodeInfoLite_msg #define meshtastic_DeviceState_fields &meshtastic_DeviceState_msg +#define meshtastic_NodePositionEntry_fields &meshtastic_NodePositionEntry_msg +#define meshtastic_NodeTelemetryEntry_fields &meshtastic_NodeTelemetryEntry_msg +#define meshtastic_NodeEnvironmentEntry_fields &meshtastic_NodeEnvironmentEntry_msg +#define meshtastic_NodeStatusEntry_fields &meshtastic_NodeStatusEntry_msg #define meshtastic_NodeDatabase_fields &meshtastic_NodeDatabase_msg #define meshtastic_ChannelFile_fields &meshtastic_ChannelFile_msg #define meshtastic_BackupPreferences_fields &meshtastic_BackupPreferences_msg @@ -363,9 +448,13 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size #define meshtastic_BackupPreferences_size 2432 #define meshtastic_ChannelFile_size 718 -#define meshtastic_DeviceState_size 1737 -#define meshtastic_NodeInfoLite_size 196 -#define meshtastic_PositionLite_size 28 +#define meshtastic_DeviceState_size 1944 +#define meshtastic_NodeEnvironmentEntry_size 170 +#define meshtastic_NodeInfoLite_size 105 +#define meshtastic_NodePositionEntry_size 42 +#define meshtastic_NodeStatusEntry_size 89 +#define meshtastic_NodeTelemetryEntry_size 35 +#define meshtastic_PositionLite_size 34 #define meshtastic_UserLite_size 98 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index cb5f19df5a0..192aeeffe89 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -824,6 +824,7 @@ typedef struct _meshtastic_Routing { } meshtastic_Routing; typedef PB_BYTES_ARRAY_T(233) meshtastic_Data_payload_t; +typedef PB_BYTES_ARRAY_T(64) meshtastic_Data_xeddsa_signature_t; /* (Formerly called SubPacket) The payload portion fo a packet, this is the actual bytes that are sent inside a radio packet (because from/to are broken out by the comms library) */ @@ -857,6 +858,8 @@ typedef struct _meshtastic_Data { /* Bitfield for extra flags. First use is to indicate that user approves the packet being uploaded to MQTT. */ bool has_bitfield; uint8_t bitfield; + /* XEdDSA signature for the payload */ + meshtastic_Data_xeddsa_signature_t xeddsa_signature; } meshtastic_Data; typedef PB_BYTES_ARRAY_T(32) meshtastic_KeyVerification_hash1_t; @@ -1061,6 +1064,8 @@ typedef struct _meshtastic_MeshPacket { uint32_t tx_after; /* Indicates which transport mechanism this packet arrived over */ meshtastic_MeshPacket_TransportMechanism transport_mechanism; + /* Indicates whether the packet has a valid signature */ + bool xeddsa_signed; } meshtastic_MeshPacket; /* The bluetooth to device link: @@ -1117,6 +1122,10 @@ typedef struct _meshtastic_NodeInfo { /* True if node has been muted Persistes between NodeDB internal clean ups */ bool is_muted; + /* True if node is signing its packets via XEdDSA + Persists between NodeDB internal clean ups + LSB 1 of the bitfield */ + bool has_xeddsa_signed; } meshtastic_NodeInfo; typedef PB_BYTES_ARRAY_T(16) meshtastic_MyNodeInfo_device_id_t; @@ -1586,15 +1595,15 @@ extern "C" { #define meshtastic_User_init_default {"", "", "", {0}, _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} #define meshtastic_RouteDiscovery_init_default {0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}} #define meshtastic_Routing_init_default {0, {meshtastic_RouteDiscovery_init_default}} -#define meshtastic_Data_init_default {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0} +#define meshtastic_Data_init_default {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0, {0, {0}}} #define meshtastic_KeyVerification_init_default {0, {0, {0}}, {0, {0}}} #define meshtastic_StoreForwardPlusPlus_init_default {_meshtastic_StoreForwardPlusPlus_SFPP_message_type_MIN, {0, {0}}, {0, {0}}, {0, {0}}, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_RemoteShell_init_default {_meshtastic_RemoteShell_OpCode_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_Waypoint_init_default {0, false, 0, false, 0, 0, 0, "", "", 0} #define meshtastic_StatusMessage_init_default {""} #define meshtastic_MqttClientProxyMessage_init_default {"", 0, {{0, {0}}}, 0} -#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN} -#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} +#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_default {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_default {0, 0, 0, 0} @@ -1621,15 +1630,15 @@ extern "C" { #define meshtastic_User_init_zero {"", "", "", {0}, _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0} #define meshtastic_RouteDiscovery_init_zero {0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}, 0, {0, 0, 0, 0, 0, 0, 0, 0}} #define meshtastic_Routing_init_zero {0, {meshtastic_RouteDiscovery_init_zero}} -#define meshtastic_Data_init_zero {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0} +#define meshtastic_Data_init_zero {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0, {0, {0}}} #define meshtastic_KeyVerification_init_zero {0, {0, {0}}, {0, {0}}} #define meshtastic_StoreForwardPlusPlus_init_zero {_meshtastic_StoreForwardPlusPlus_SFPP_message_type_MIN, {0, {0}}, {0, {0}}, {0, {0}}, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_RemoteShell_init_zero {_meshtastic_RemoteShell_OpCode_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0, 0, 0} #define meshtastic_Waypoint_init_zero {0, false, 0, false, 0, 0, 0, "", "", 0} #define meshtastic_StatusMessage_init_zero {""} #define meshtastic_MqttClientProxyMessage_init_zero {"", 0, {{0, {0}}}, 0} -#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN} -#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0} +#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} +#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_zero {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_zero {0, 0, 0, 0} @@ -1702,6 +1711,7 @@ extern "C" { #define meshtastic_Data_reply_id_tag 7 #define meshtastic_Data_emoji_tag 8 #define meshtastic_Data_bitfield_tag 9 +#define meshtastic_Data_xeddsa_signature_tag 10 #define meshtastic_KeyVerification_nonce_tag 1 #define meshtastic_KeyVerification_hash1_tag 2 #define meshtastic_KeyVerification_hash2_tag 3 @@ -1759,6 +1769,7 @@ extern "C" { #define meshtastic_MeshPacket_relay_node_tag 19 #define meshtastic_MeshPacket_tx_after_tag 20 #define meshtastic_MeshPacket_transport_mechanism_tag 21 +#define meshtastic_MeshPacket_xeddsa_signed_tag 22 #define meshtastic_NodeInfo_num_tag 1 #define meshtastic_NodeInfo_user_tag 2 #define meshtastic_NodeInfo_position_tag 3 @@ -1772,6 +1783,7 @@ extern "C" { #define meshtastic_NodeInfo_is_ignored_tag 11 #define meshtastic_NodeInfo_is_key_manually_verified_tag 12 #define meshtastic_NodeInfo_is_muted_tag 13 +#define meshtastic_NodeInfo_has_xeddsa_signed_tag 14 #define meshtastic_MyNodeInfo_my_node_num_tag 1 #define meshtastic_MyNodeInfo_reboot_count_tag 8 #define meshtastic_MyNodeInfo_min_app_version_tag 11 @@ -1938,7 +1950,8 @@ X(a, STATIC, SINGULAR, FIXED32, source, 5) \ X(a, STATIC, SINGULAR, FIXED32, request_id, 6) \ X(a, STATIC, SINGULAR, FIXED32, reply_id, 7) \ X(a, STATIC, SINGULAR, FIXED32, emoji, 8) \ -X(a, STATIC, OPTIONAL, UINT32, bitfield, 9) +X(a, STATIC, OPTIONAL, UINT32, bitfield, 9) \ +X(a, STATIC, SINGULAR, BYTES, xeddsa_signature, 10) #define meshtastic_Data_CALLBACK NULL #define meshtastic_Data_DEFAULT NULL @@ -2023,7 +2036,8 @@ X(a, STATIC, SINGULAR, BOOL, pki_encrypted, 17) \ X(a, STATIC, SINGULAR, UINT32, next_hop, 18) \ X(a, STATIC, SINGULAR, UINT32, relay_node, 19) \ X(a, STATIC, SINGULAR, UINT32, tx_after, 20) \ -X(a, STATIC, SINGULAR, UENUM, transport_mechanism, 21) +X(a, STATIC, SINGULAR, UENUM, transport_mechanism, 21) \ +X(a, STATIC, SINGULAR, BOOL, xeddsa_signed, 22) #define meshtastic_MeshPacket_CALLBACK NULL #define meshtastic_MeshPacket_DEFAULT NULL #define meshtastic_MeshPacket_payload_variant_decoded_MSGTYPE meshtastic_Data @@ -2041,7 +2055,8 @@ X(a, STATIC, OPTIONAL, UINT32, hops_away, 9) \ X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ X(a, STATIC, SINGULAR, BOOL, is_key_manually_verified, 12) \ -X(a, STATIC, SINGULAR, BOOL, is_muted, 13) +X(a, STATIC, SINGULAR, BOOL, is_muted, 13) \ +X(a, STATIC, SINGULAR, BOOL, has_xeddsa_signed, 14) #define meshtastic_NodeInfo_CALLBACK NULL #define meshtastic_NodeInfo_DEFAULT NULL #define meshtastic_NodeInfo_user_MSGTYPE meshtastic_User @@ -2343,7 +2358,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_ChunkedPayload_size 245 #define meshtastic_ClientNotification_size 482 #define meshtastic_Compressed_size 239 -#define meshtastic_Data_size 269 +#define meshtastic_Data_size 335 #define meshtastic_DeviceMetadata_size 54 #define meshtastic_DuplicatedPublicKey_size 0 #define meshtastic_FileInfo_size 236 @@ -2356,12 +2371,12 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_LockdownStatus_size 53 #define meshtastic_LogRecord_size 426 #define meshtastic_LowEntropyKey_size 0 -#define meshtastic_MeshPacket_size 381 +#define meshtastic_MeshPacket_size 450 #define meshtastic_MqttClientProxyMessage_size 501 #define meshtastic_MyNodeInfo_size 83 #define meshtastic_NeighborInfo_size 258 #define meshtastic_Neighbor_size 22 -#define meshtastic_NodeInfo_size 325 +#define meshtastic_NodeInfo_size 327 #define meshtastic_NodeRemoteHardwarePin_size 29 #define meshtastic_Position_size 144 #define meshtastic_QueueStatus_size 23 From 8d08077412ee50f2a436b025920f62e4e736e02c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 20 May 2026 07:12:43 -0500 Subject: [PATCH 216/225] Add Ethernet configuration to platformio.ini for ThinkNode variants --- variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini | 5 +++++ variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini | 9 +++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini index 4f94f5d3927..cfd1095e8c0 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini @@ -11,6 +11,11 @@ build_flags = -I variants/esp32s3/ELECROW-ThinkNode-G3 -mfix-esp32-psram-cache-issue +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + CONFIG_ETH_ENABLED=y + CONFIG_ARDUINO_SELECTIVE_Ethernet=y + lib_ignore = Ethernet diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini index 68fe6818249..65e0b27f08d 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini @@ -2,7 +2,7 @@ extends = esp32s3_base board = ThinkNode-M7 -build_flags = +build_flags = ${esp32s3_base.build_flags} -D ELECROW_ThinkNode_M7 -D HAS_UDP_MULTICAST=1 @@ -10,7 +10,12 @@ build_flags = -I variants/esp32s3/ELECROW-ThinkNode-M7 -mfix-esp32-psram-cache-issue -lib_ignore = +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + CONFIG_ETH_ENABLED=y + CONFIG_ARDUINO_SELECTIVE_Ethernet=y + +lib_ignore = Ethernet build_src_filter = From 2f92eb8499c22958a08087d14b7d93d813a91e48 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 20 May 2026 10:18:46 -0500 Subject: [PATCH 217/225] Refactor position precision handling to honor explicit channel settings and prevent location leaks (#10513) --- src/mesh/PositionPrecision.cpp | 16 ++++---- src/mesh/PositionPrecision.h | 2 + test/test_position_precision/test_main.cpp | 43 ++++++++++++++++++++++ 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/mesh/PositionPrecision.cpp b/src/mesh/PositionPrecision.cpp index 04db01c7919..75a17d6e9db 100644 --- a/src/mesh/PositionPrecision.cpp +++ b/src/mesh/PositionPrecision.cpp @@ -4,17 +4,19 @@ #include -uint32_t getPositionPrecisionForChannel(uint8_t channelIndex) +uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel) { - const meshtastic_Channel &channel = channels.getByIndex(channelIndex); - if (channel.settings.has_module_settings) { return channel.settings.module_settings.position_precision; - } else if (channel.role == meshtastic_Channel_Role_PRIMARY) { - return 32; - } else { - return 0; } + // No module settings: fail closed. A PRIMARY channel used to default to 32 + // here, leaking an exact position on a sharing-disabled channel. See #10509. + return 0; +} + +uint32_t getPositionPrecisionForChannel(uint8_t channelIndex) +{ + return getPositionPrecisionForChannel(channels.getByIndex(channelIndex)); } static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision) diff --git a/src/mesh/PositionPrecision.h b/src/mesh/PositionPrecision.h index 6fdbd2f6435..89828f2e03f 100644 --- a/src/mesh/PositionPrecision.h +++ b/src/mesh/PositionPrecision.h @@ -1,8 +1,10 @@ #pragma once +#include "meshtastic/channel.pb.h" #include "meshtastic/mesh.pb.h" #include +uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel); uint32_t getPositionPrecisionForChannel(uint8_t channelIndex); void applyPositionPrecision(meshtastic_Position &position, uint32_t precision); bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision); diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index 4f5aecfda97..bb611817795 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -19,6 +19,16 @@ static meshtastic_Position makePosition() return position; } +static meshtastic_Channel makeChannel(meshtastic_Channel_Role role, bool hasModuleSettings, uint32_t positionPrecision) +{ + meshtastic_Channel channel = meshtastic_Channel_init_default; + channel.has_settings = true; + channel.role = role; + channel.settings.has_module_settings = hasModuleSettings; + channel.settings.module_settings.position_precision = positionPrecision; + return channel; +} + static void test_applyPositionPrecision_clampsLatLonAndSetsPrecisionBits() { meshtastic_Position position = makePosition(); @@ -80,6 +90,35 @@ static void test_applyPositionPrecision_reencodesPositionPacket() TEST_ASSERT_EQUAL_UINT32(16, decoded.precision_bits); } +static void test_getPositionPrecisionForChannel_explicitPrecisionIsHonored() +{ + meshtastic_Channel channel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 16); + + TEST_ASSERT_EQUAL_UINT32(16, getPositionPrecisionForChannel(channel)); +} + +static void test_getPositionPrecisionForChannel_explicitZeroDisablesPrimary() +{ + meshtastic_Channel channel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 0); + + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(channel)); +} + +static void test_getPositionPrecisionForChannel_primaryWithoutModuleSettingsFailsClosed() +{ + // Regression guard for #10509: precision 32 below must be ignored (no module settings). + meshtastic_Channel channel = makeChannel(meshtastic_Channel_Role_PRIMARY, false, 32); + + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(channel)); +} + +static void test_getPositionPrecisionForChannel_secondaryWithoutModuleSettingsFailsClosed() +{ + meshtastic_Channel channel = makeChannel(meshtastic_Channel_Role_SECONDARY, false, 32); + + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(channel)); +} + void setUp(void) {} void tearDown(void) {} @@ -93,6 +132,10 @@ void setup() RUN_TEST(test_applyPositionPrecision_fullPrecisionKeepsLatLon); RUN_TEST(test_applyPositionPrecision_zeroScrubsLocationButKeepsTime); RUN_TEST(test_applyPositionPrecision_reencodesPositionPacket); + RUN_TEST(test_getPositionPrecisionForChannel_explicitPrecisionIsHonored); + RUN_TEST(test_getPositionPrecisionForChannel_explicitZeroDisablesPrimary); + RUN_TEST(test_getPositionPrecisionForChannel_primaryWithoutModuleSettingsFailsClosed); + RUN_TEST(test_getPositionPrecisionForChannel_secondaryWithoutModuleSettingsFailsClosed); exit(UNITY_END()); } From 894c5556cfd6c745933bae75ca9d725b93a53b72 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 21 May 2026 07:09:57 -0400 Subject: [PATCH 218/225] Actions: Fix tagging upon release. (#10521) Current release tags are actually based upon the latest state of `develop` currently... Specify target_commitish to always use the commit that triggered the build --- .github/workflows/main_matrix.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 3505d950e35..f46bf465260 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -333,6 +333,7 @@ jobs: prerelease: true name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha tag_name: v${{ needs.version.outputs.long }} + target_commitish: ${{ github.sha }} body: ${{ steps.release_notes.outputs.notes }} - name: Download source deb From f3cb2bff7883a85e61df0639dcc6e8f1067b53cb Mon Sep 17 00:00:00 2001 From: BJK <58904384+Bjk8kds@users.noreply.github.com> Date: Thu, 21 May 2026 18:12:34 +0700 Subject: [PATCH 219/225] Refactor keyboard cell height logic for consistency (#10501) Adjust keyboard cell height calculation for better layout consistency across different screen sizes. Co-authored-by: Ben Meadors --- src/graphics/VirtualKeyboard.cpp | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/graphics/VirtualKeyboard.cpp b/src/graphics/VirtualKeyboard.cpp index 52f0195b354..43b33e8538d 100644 --- a/src/graphics/VirtualKeyboard.cpp +++ b/src/graphics/VirtualKeyboard.cpp @@ -142,8 +142,9 @@ void VirtualKeyboard::draw(OLEDDisplay *display, int16_t offsetX, int16_t offset if (keyboardStartY < 0) keyboardStartY = 0; } else { - // Default (non-wide, non-64px) behavior: use key height heuristic and place at bottom - cellH = KEY_HEIGHT; + // Default (non-wide, non-64px) e.g. SH1107 128x128: + // cellH = FONT_HEIGHT_SMALL - 2 so rows are tighter while still hosting the font + cellH = std::max((int)KEY_HEIGHT, FONT_HEIGHT_SMALL - 2); int keyboardHeight = KEYBOARD_ROWS * cellH; keyboardStartY = screenH - keyboardHeight; if (keyboardStartY < 0) @@ -446,11 +447,8 @@ void VirtualKeyboard::drawKey(OLEDDisplay *display, const VirtualKey &key, bool if (textX < x) textX = x; // guard } else { - if (display->getHeight() <= 64 && (key.character >= '0' && key.character <= '9')) { - textX = x + (width - textWidth + 1) / 2; - } else { - textX = x + (width - textWidth) / 2; - } + // Use ceil rounding for all screens (consistent with 128x64 behavior for numbers) + textX = x + (width - textWidth + 1) / 2; } int contentTop = y; int contentH = height; @@ -746,4 +744,4 @@ bool VirtualKeyboard::isTimedOut() const } } // namespace graphics -#endif \ No newline at end of file +#endif From 5e69bc6c3ff990f5a1e79da8a9125116be7d1004 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 21 May 2026 16:20:09 +0100 Subject: [PATCH 220/225] Enable Narrow and Lite regions for EU (#10120) * Enable Lite and Narrow regions and introduce getEffectiveDutyCycle for Lite profiles * Add TrafficType enum and extend getConfiguredOrDefaultMsScaled to manage based on regionProfile settings * Refactor telemetry modules to include TrafficType in getConfiguredOrDefaultMsScaled calls * Update submodule protobufs to latest commit * Add support for new region presets and modem presets in menu options * Add new LoRa region codes and modem presets for EU bands * boof * Add modem presets for LITE and NARROW configurations * Update subproject commit reference in protobufs * Update protobufs * Refactor modem preset definitions to use macro for consistency and clarity * Refactor modem preset cases to use PRESET macro for consistency * fix: update LoRa region code for EU 868 narrowband configuration Co-authored-by: Copilot * Fix test suite failure Co-authored-by: Copilot * Add override slot override - for when one override isn't enough. Co-authored-by: Copilot * address copilot comments --------- Co-authored-by: Copilot --- src/DisplayFormatters.cpp | 31 ++- src/airtime.cpp | 7 +- src/graphics/draw/MenuHandler.cpp | 92 +++++--- src/graphics/draw/UIRenderer.cpp | 17 +- .../InkHUD/Applets/System/Menu/MenuAction.h | 7 + .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 61 ++++-- src/mesh/Default.cpp | 20 ++ src/mesh/Default.h | 4 + src/mesh/MeshRadio.h | 62 ++++-- src/mesh/RadioInterface.cpp | 160 ++++++++------ src/mesh/Router.cpp | 7 +- src/modules/AdminModule.cpp | 2 +- src/modules/CannedMessageModule.cpp | 17 +- src/modules/PositionModule.cpp | 4 +- src/modules/Telemetry/AirQualityTelemetry.cpp | 21 +- src/modules/Telemetry/DeviceTelemetry.cpp | 2 +- .../Telemetry/EnvironmentTelemetry.cpp | 7 +- src/modules/Telemetry/HealthTelemetry.cpp | 7 +- src/modules/Telemetry/PowerTelemetry.cpp | 5 +- test/test_admin_radio/test_main.cpp | 196 +++++++++++++++++- 20 files changed, 552 insertions(+), 177 deletions(-) diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp index fdcf840dc28..13aded6a6ae 100644 --- a/src/DisplayFormatters.cpp +++ b/src/DisplayFormatters.cpp @@ -1,4 +1,5 @@ #include "DisplayFormatters.h" +#include "MeshRadio.h" const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset, bool useShortName, bool usePreset) @@ -11,33 +12,45 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC } switch (preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + case PRESET(SHORT_TURBO): return useShortName ? "ShortT" : "ShortTurbo"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + case PRESET(SHORT_SLOW): return useShortName ? "ShortS" : "ShortSlow"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + case PRESET(SHORT_FAST): return useShortName ? "ShortF" : "ShortFast"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + case PRESET(MEDIUM_SLOW): return useShortName ? "MedS" : "MediumSlow"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + case PRESET(MEDIUM_FAST): return useShortName ? "MedF" : "MediumFast"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + case PRESET(LONG_SLOW): return useShortName ? "LongS" : "LongSlow"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + case PRESET(LONG_FAST): return useShortName ? "LongF" : "LongFast"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: + case PRESET(LONG_TURBO): return useShortName ? "LongT" : "LongTurbo"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + case PRESET(LONG_MODERATE): return useShortName ? "LongM" : "LongMod"; break; + case PRESET(LITE_FAST): + return useShortName ? "LiteF" : "LiteFast"; + break; + case PRESET(LITE_SLOW): + return useShortName ? "LiteS" : "LiteSlow"; + break; + case PRESET(NARROW_FAST): + return useShortName ? "NarF" : "NarrowFast"; + break; + case PRESET(NARROW_SLOW): + return useShortName ? "NarS" : "NarrowSlow"; + break; default: return useShortName ? "Custom" : "Invalid"; break; diff --git a/src/airtime.cpp b/src/airtime.cpp index a7736d66711..0e0d72e20e9 100644 --- a/src/airtime.cpp +++ b/src/airtime.cpp @@ -133,11 +133,12 @@ bool AirTime::isTxAllowedChannelUtil(bool polite) bool AirTime::isTxAllowedAirUtil() { - if (!config.lora.override_duty_cycle && myRegion->dutyCycle < 100) { - if (utilizationTXPercent() < myRegion->dutyCycle * polite_duty_cycle_percent / 100) { + float effectiveDutyCycle = getEffectiveDutyCycle(); + if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) { + if (utilizationTXPercent() < effectiveDutyCycle * polite_duty_cycle_percent / 100) { return true; } else { - LOG_WARN("TX air util. >%f%%. Skip send", myRegion->dutyCycle * polite_duty_cycle_percent / 100); + LOG_WARN("TX air util. >%f%%. Skip send", effectiveDutyCycle * polite_duty_cycle_percent / 100); return false; } } diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 9fc7fa41080..279339acd66 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -2,6 +2,7 @@ #if HAS_SCREEN #include "ClockRenderer.h" #include "Default.h" +#include "DisplayFormatters.h" #include "GPS.h" #include "MenuHandler.h" #include "MeshRadio.h" @@ -180,6 +181,8 @@ void menuHandler::LoraRegionPicker(uint32_t duration) {"US", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_US}, {"EU_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_433}, {"EU_868", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_868}, + {"EU_866", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_866}, + {"EU_868_NARROW", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_N_868}, {"CN", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_CN}, {"JP", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_JP}, {"ANZ", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ANZ}, @@ -203,6 +206,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration) {"KZ_863", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_KZ_863}, {"NP_865", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_NP_865}, {"BR_902", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_BR_902}, + }; constexpr size_t regionCount = sizeof(regionOptions) / sizeof(regionOptions[0]); @@ -244,7 +248,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration) #endif config.lora.tx_enabled = true; initRegion(); - if (myRegion->dutyCycle < 100) { + if (getEffectiveDutyCycle() < 100) { config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit } @@ -378,42 +382,64 @@ void menuHandler::FrequencySlotPicker() screen->showOverlayBanner(bannerOptions); } -void menuHandler::radioPresetPicker() -{ - static const RadioPresetOption presetOptions[] = { - {"Back", OptionsAction::Back}, - {"LongTurbo", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO}, - {"LongModerate", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE}, - {"LongFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST}, - {"MediumSlow", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW}, - {"MediumFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST}, - {"ShortSlow", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW}, - {"ShortFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST}, - {"ShortTurbo", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO}, - }; - - constexpr size_t presetCount = sizeof(presetOptions) / sizeof(presetOptions[0]); - static std::array presetLabels{}; +// Maximum presets any region can have + 1 for Back +static constexpr int MAX_PRESET_OPTIONS = 16; - auto bannerOptions = - createStaticBannerOptions("Radio Preset", presetOptions, presetLabels, [](const RadioPresetOption &option, int) -> void { - if (option.action == OptionsAction::Back) { - menuHandler::menuQueue = menuHandler::LoraMenu; - screen->runNow(); - return; - } +static BannerOverlayOptions buildRegionPresetBanner() +{ + // 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 + int count = 0; + + optionsArray[count] = "Back"; + optionsEnumArray[count++] = -1; + + if (myRegion && myRegion->profile) { + const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets(); + size_t numPresets = myRegion->getNumPresets(); + for (size_t i = 0; i < numPresets && count < MAX_PRESET_OPTIONS; ++i) { + const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true); + strncpy(presetLabelBuf[count], name, sizeof(presetLabelBuf[count]) - 1); + presetLabelBuf[count][sizeof(presetLabelBuf[count]) - 1] = '\0'; + optionsArray[count] = presetLabelBuf[count]; + optionsEnumArray[count++] = static_cast(presets[i]); + } + } - if (!option.hasValue) { - return; - } + int initialSelection = 0; + for (int i = 1; i < count; ++i) { + if (optionsEnumArray[i] == static_cast(config.lora.modem_preset)) { + initialSelection = i; + break; + } + } - config.lora.modem_preset = option.value; - config.lora.channel_num = 0; // Reset to default channel for the preset - config.lora.override_frequency = 0; // Clear any custom frequency - service->reloadConfig(SEGMENT_CONFIG); - }); + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Radio Preset"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = static_cast(count); + bannerOptions.InitialSelected = initialSelection; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == -1) { + menuHandler::menuQueue = menuHandler::LoraMenu; + screen->runNow(); + return; + } + config.lora.use_preset = true; + config.lora.modem_preset = static_cast(selected); + config.lora.channel_num = 0; // Reset to default channel for the preset + config.lora.override_frequency = 0; // Clear any custom frequency + service->reloadConfig(SEGMENT_CONFIG); + }; + return bannerOptions; +} - screen->showOverlayBanner(bannerOptions); +void menuHandler::radioPresetPicker() +{ + screen->showOverlayBanner(buildRegionPresetBanner()); } void menuHandler::twelveHourPicker() diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index e7b00e5d5da..f64d4a4366d 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -2,6 +2,7 @@ #if HAS_SCREEN #include "CompassRenderer.h" #include "GPSStatus.h" +#include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" #include "NodeListRenderer.h" @@ -816,16 +817,16 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat // Helper to get SNR limit based on modem preset auto getSnrLimit = [](meshtastic_Config_LoRaConfig_ModemPreset preset) -> float { switch (preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + case PRESET(LONG_SLOW): + case PRESET(LONG_MODERATE): + case PRESET(LONG_FAST): return -6.0f; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + case PRESET(MEDIUM_SLOW): + case PRESET(MEDIUM_FAST): return -5.5f; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + case PRESET(SHORT_SLOW): + case PRESET(SHORT_FAST): + case PRESET(SHORT_TURBO): return -4.5f; default: return -6.0f; diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h index e1f004d389f..fc1aef02a0b 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h @@ -64,6 +64,8 @@ enum MenuAction { SET_REGION_KZ_863, SET_REGION_NP_865, SET_REGION_BR_902, + SET_REGION_EU_866, + SET_REGION_NARROW_868, // Device Roles SET_ROLE_CLIENT, SET_ROLE_CLIENT_MUTE, @@ -78,6 +80,11 @@ enum MenuAction { SET_PRESET_SHORT_SLOW, SET_PRESET_SHORT_FAST, SET_PRESET_SHORT_TURBO, + SET_PRESET_LITE_SLOW, + SET_PRESET_LITE_FAST, + SET_PRESET_NARROW_SLOW, + SET_PRESET_NARROW_FAST, + SET_PRESET_FROM_REGION, // Dynamic: preset chosen from region-available list // Timezones SET_TZ_US_HAWAII, SET_TZ_US_ALASKA, diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 79ac1e701b2..b70853151a0 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -4,6 +4,7 @@ #include "DisplayFormatters.h" #include "GPS.h" +#include "MeshRadio.h" #include "MeshService.h" #include "RTC.h" #include "Router.h" @@ -257,6 +258,11 @@ int32_t InkHUD::MenuApplet::runOnce() return OSThread::disable(); } +// 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; + static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) { if (config.lora.region == region) @@ -276,7 +282,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) initRegion(); - if (myRegion && myRegion->dutyCycle < 100) { + if (myRegion && getEffectiveDutyCycle() < 100) { config.lora.ignore_mqtt = true; } @@ -770,6 +776,14 @@ void InkHUD::MenuApplet::execute(MenuItem item) applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_BR_902); break; + case SET_REGION_EU_866: + applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866); + break; + + case SET_REGION_NARROW_868: + applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868); + break; + // Roles case SET_ROLE_CLIENT: applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT); @@ -789,37 +803,46 @@ void InkHUD::MenuApplet::execute(MenuItem item) // Presets case SET_PRESET_LONG_SLOW: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW); + applyLoRaPreset(PRESET(LONG_SLOW)); break; case SET_PRESET_LONG_MODERATE: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE); + applyLoRaPreset(PRESET(LONG_MODERATE)); break; case SET_PRESET_LONG_FAST: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + applyLoRaPreset(PRESET(LONG_FAST)); break; case SET_PRESET_MEDIUM_SLOW: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW); + applyLoRaPreset(PRESET(MEDIUM_SLOW)); break; case SET_PRESET_MEDIUM_FAST: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + applyLoRaPreset(PRESET(MEDIUM_FAST)); break; case SET_PRESET_SHORT_SLOW: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW); + applyLoRaPreset(PRESET(SHORT_SLOW)); break; case SET_PRESET_SHORT_FAST: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + applyLoRaPreset(PRESET(SHORT_FAST)); break; case SET_PRESET_SHORT_TURBO: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); + applyLoRaPreset(PRESET(SHORT_TURBO)); break; + case SET_PRESET_FROM_REGION: { + // cursor - 1 because index 0 is "Back" + const uint8_t index = cursor - 1; + if (index < regionPresetCount) { + applyLoRaPreset(regionPresets[index]); + } + break; + } + // Timezones case SET_TZ_US_HAWAII: applyTimezone("HST10"); @@ -1421,6 +1444,8 @@ void InkHUD::MenuApplet::showPage(MenuPage page) items.push_back(MenuItem("US", MenuAction::SET_REGION_US, MenuPage::EXIT)); items.push_back(MenuItem("EU 868", MenuAction::SET_REGION_EU_868, MenuPage::EXIT)); items.push_back(MenuItem("EU 433", MenuAction::SET_REGION_EU_433, MenuPage::EXIT)); + items.push_back(MenuItem("EU 866", MenuAction::SET_REGION_EU_866, MenuPage::EXIT)); + items.push_back(MenuItem("EU 868 Narrow", MenuAction::SET_REGION_NARROW_868, MenuPage::EXIT)); items.push_back(MenuItem("CN", MenuAction::SET_REGION_CN, MenuPage::EXIT)); items.push_back(MenuItem("JP", MenuAction::SET_REGION_JP, MenuPage::EXIT)); items.push_back(MenuItem("ANZ", MenuAction::SET_REGION_ANZ, MenuPage::EXIT)); @@ -1450,13 +1475,17 @@ void InkHUD::MenuApplet::showPage(MenuPage page) case NODE_CONFIG_PRESET: { previousPage = MenuPage::NODE_CONFIG_LORA; items.push_back(MenuItem("Back", previousPage)); - items.push_back(MenuItem("Long Moderate", MenuAction::SET_PRESET_LONG_MODERATE, MenuPage::EXIT)); - items.push_back(MenuItem("Long Fast", MenuAction::SET_PRESET_LONG_FAST, MenuPage::EXIT)); - items.push_back(MenuItem("Medium Slow", MenuAction::SET_PRESET_MEDIUM_SLOW, MenuPage::EXIT)); - items.push_back(MenuItem("Medium Fast", MenuAction::SET_PRESET_MEDIUM_FAST, MenuPage::EXIT)); - items.push_back(MenuItem("Short Slow", MenuAction::SET_PRESET_SHORT_SLOW, MenuPage::EXIT)); - items.push_back(MenuItem("Short Fast", MenuAction::SET_PRESET_SHORT_FAST, MenuPage::EXIT)); - items.push_back(MenuItem("Short Turbo", MenuAction::SET_PRESET_SHORT_TURBO, MenuPage::EXIT)); + regionPresetCount = 0; + if (myRegion && myRegion->profile) { + const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets(); + size_t numPresets = myRegion->getNumPresets(); + for (size_t i = 0; i < numPresets && regionPresetCount < MAX_REGION_PRESETS; ++i) { + regionPresets[regionPresetCount++] = presets[i]; + const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true); + nodeConfigLabels.emplace_back(name); + items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_PRESET_FROM_REGION, MenuPage::EXIT)); + } + } items.push_back(MenuItem("Exit", MenuPage::EXIT)); break; } diff --git a/src/mesh/Default.cpp b/src/mesh/Default.cpp index 7a2d9e410d2..67331f6ce82 100644 --- a/src/mesh/Default.cpp +++ b/src/mesh/Default.cpp @@ -60,6 +60,26 @@ uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t d return base * coef; } +uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t defaultValue, uint32_t numOnlineNodes, + TrafficType type) +{ + uint32_t baseMs = getConfiguredOrDefaultMsScaled(configured, defaultValue, numOnlineNodes); + + if (!myRegion || !myRegion->profile) + return baseMs; + + 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 + if (throttle <= 1) + return baseMs; + + constexpr uint32_t MAX_MS = static_cast(INT32_MAX); + uint64_t result = static_cast(baseMs) * static_cast(throttle); + return result >= static_cast(MAX_MS) ? MAX_MS : static_cast(result); +} + uint32_t Default::getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue) { // If zero, intervals should be coalesced later by getConfiguredOrDefault... methods diff --git a/src/mesh/Default.h b/src/mesh/Default.h index 59425042ebf..b1ebf5f2f06 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -31,6 +31,8 @@ #define min_neighbor_info_broadcast_secs 4 * 60 * 60 #define default_map_publish_interval_secs 60 * 60 +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 @@ -64,6 +66,8 @@ class Default // Note: numOnlineNodes uses uint32_t to match the public API and allow flexibility, // even though internal node counts use uint16_t (max 65535 nodes) static uint32_t getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t defaultValue, uint32_t numOnlineNodes); + static uint32_t getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t defaultValue, uint32_t numOnlineNodes, + TrafficType type); static uint8_t getConfiguredOrDefaultHopLimit(uint8_t configured); static uint32_t getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue); diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index fe4788bff0a..e2c053a8bed 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -13,9 +13,16 @@ static const meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = static_cast(0xFF); +#define PRESET(name) meshtastic_Config_LoRaConfig_ModemPreset_##name + +// Override slot magic numbers for RegionProfile.overrideSlot +#define OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH 0 // Use hash of primary channel name +#define OVERRIDE_SLOT_PRESET_HASH -1 // Use hash of preset name instead +// Positive values (1-32767) are explicit slot numbers + // Region profile: bundles the preset list with regulatory parameters shared across regions struct RegionProfile { - const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated; first entry is the default + const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated float spacing; // gaps between radio channels float padding; // padding at each side of the "operating channel" bool audioPermitted; @@ -23,14 +30,22 @@ struct RegionProfile { int8_t textThrottle; // throttle for text - future expansion int8_t positionThrottle; // throttle for location data - future expansion int8_t telemetryThrottle; // throttle for telemetry - future expansion - uint8_t overrideSlot; // a per-region override slot for if we need to fix it in place + int16_t overrideSlot; // a per-region override slot for if we need to fix it in place + // Magic values: 0 = use channel name hash, -1 = use preset name hash, >0 = explicit slot }; +/** + * Get the effective duty cycle for the current region based on device role. + * For EU_866, returns 10% for fixed devices (ROUTER, ROUTER_LATE) and 2.5% for mobile devices. + * For other regions, returns the standard duty cycle. + */ +extern float getEffectiveDutyCycle(); + extern const RegionProfile PROFILE_STD; extern const RegionProfile PROFILE_EU868; extern const RegionProfile PROFILE_UNDEF; -// extern const RegionProfile PROFILE_LITE; -// extern const RegionProfile PROFILE_NARROW; +extern const RegionProfile PROFILE_LITE; +extern const RegionProfile PROFILE_NARROW; // extern const RegionProfile PROFILE_HAM; // Map from old region names to new region enums @@ -43,10 +58,11 @@ struct RegionInfo { bool freqSwitching; bool wideLora; const RegionProfile *profile; + meshtastic_Config_LoRaConfig_ModemPreset defaultPreset; const char *name; // EU433 etc // Preset accessors (delegate through profile) - meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return profile->presets[0]; } + meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return defaultPreset; } const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; } size_t getNumPresets() const { @@ -143,46 +159,66 @@ static inline void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset uint8_t &cr) { switch (preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + case PRESET(SHORT_TURBO): bwKHz = wideLora ? 1625.0f : 500.0f; cr = 5; sf = 7; break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + case PRESET(SHORT_FAST): bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; sf = 7; break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + case PRESET(SHORT_SLOW): bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; sf = 8; break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + case PRESET(MEDIUM_FAST): bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; sf = 9; break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + case PRESET(MEDIUM_SLOW): bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; sf = 10; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: + case PRESET(LONG_TURBO): bwKHz = wideLora ? 1625.0f : 500.0f; cr = 8; sf = 11; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + case PRESET(LONG_MODERATE): bwKHz = wideLora ? 406.25f : 125.0f; cr = 8; sf = 11; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + case PRESET(LONG_SLOW): bwKHz = wideLora ? 406.25f : 125.0f; cr = 8; sf = 12; break; + case PRESET(LITE_FAST): + bwKHz = 125; + cr = 5; + sf = 9; + break; + case PRESET(LITE_SLOW): + bwKHz = 125; + cr = 5; + sf = 10; + break; + case PRESET(NARROW_FAST): + bwKHz = 62.5f; + cr = 6; + sf = 7; + break; + case PRESET(NARROW_SLOW): + bwKHz = 62.5f; + cr = 6; + sf = 8; + break; default: // LONG_FAST (or illegal) bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index f4f25f80c9b..d4eeed08970 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -35,31 +35,32 @@ #endif static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_STD[] = { - meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, - meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, - meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, - meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, - meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, MODEM_PRESET_END}; + PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), PRESET(SHORT_SLOW), + PRESET(SHORT_FAST), PRESET(LONG_MODERATE), PRESET(SHORT_TURBO), PRESET(LONG_TURBO), MODEM_PRESET_END}; static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[] = { - meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, - meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, - meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, - meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, MODEM_PRESET_END}; + PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), + PRESET(SHORT_SLOW), PRESET(SHORT_FAST), PRESET(LONG_MODERATE), MODEM_PRESET_END}; -static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, - MODEM_PRESET_END}; +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {PRESET(LONG_FAST), MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_LITE[] = {PRESET(LITE_FAST), PRESET(LITE_SLOW), MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[] = {PRESET(NARROW_FAST), PRESET(NARROW_SLOW), + MODEM_PRESET_END}; // Region profiles: bundle preset list + regulatory parameters shared across regions // presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle, override slot -const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 0, 0, 0}; -const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 0, 0, 0}; -const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 0, 0, 0}; +const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1, 0}; +const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 1, 1, 0}; +const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 1, 1, 0}; +const RegionProfile PROFILE_LITE = {PRESETS_LITE, 0.4, 0.0375f, false, false, 0, 10, 10, 0}; +const RegionProfile PROFILE_NARROW = {PRESETS_NARROW, 0, 0.0104f, true, false, 0, 1, 1, 1}; -#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr) \ +#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr, default_preset) \ { \ meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, \ - wide_lora, &profile_ptr, #name \ + wide_lora, &profile_ptr, default_preset, #name \ } const RegionInfo regions[] = { @@ -67,7 +68,7 @@ const RegionInfo regions[] = { https://link.springer.com/content/pdf/bbm%3A978-1-4842-4357-2%2F1.pdf https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ */ - RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD), + RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21] @@ -75,7 +76,7 @@ const RegionInfo regions[] = { https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.02.01_60/en_30022002v030201p.pdf FIXME: https://github.com/meshtastic/firmware/issues/3371 */ - RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD), + RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/ https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ @@ -90,33 +91,33 @@ const RegionInfo regions[] = { AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.) https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf */ - RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868), + RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868, PRESET(LONG_FAST)), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD), + RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf https://www.arib.or.jp/english/html/overview/doc/5-STD-T108v1_5-E1.pdf https://qiita.com/ammo0613/items/d952154f1195b64dc29f */ - RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD), + RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://www.iot.org.au/wp/wp-content/uploads/2016/12/IoTSpectrumFactSheet.pdf https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf Also used in Brazil. */ - RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD), + RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* 433.05 - 434.79 MHz, 25mW EIRP max, No duty cycle restrictions AU Low Interference Potential https://www.acma.gov.au/licences/low-interference-potential-devices-lipd-class-licence NZ General User Radio Licence for Short Range Devices https://gazette.govt.nz/notice/id/2022-go3100 */ - RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD), + RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://digital.gov.ru/uploaded/files/prilozhenie-12-k-reshenyu-gkrch-18-46-03-1.pdf @@ -124,13 +125,13 @@ const RegionInfo regions[] = { Note: - We do LBT, so 100% is allowed. */ - RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD), + RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulId=53943&efYd=0 https://resources.lora-alliance.org/technical-specifications/rp002-1-0-4-regional-parameters */ - RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD), + RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Taiwan, 920-925Mhz, limited to 0.5W indoor or coastal, 1.0W outdoor. @@ -138,40 +139,40 @@ const RegionInfo regions[] = { https://www.ncc.gov.tw/english/files/23070/102_5190_230703_1_doc_C.PDF https://gazette.nat.gov.tw/egFront/e_detail.do?metaid=147283 */ - RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD), + RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD), + RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://rrf.rsm.govt.nz/smart-web/smart/page/-smart/domain/licence/LicenceSummary.wdk?id=219752 https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf */ - RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD), + RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* 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. */ - RDEF(TH, 920.0f, 925.0f, 10, 27, false, false, PROFILE_STD), + RDEF(TH, 920.0f, 925.0f, 10, 27, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* 433,05-434,7 Mhz 10 mW 868,0-868,6 Mhz 25 mW https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf */ - RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD), - RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD), + RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)), + RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Malaysia 433 - 435 MHz at 100mW, no restrictions. https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf */ - RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD), + RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Malaysia @@ -180,14 +181,14 @@ const RegionInfo regions[] = { Frequency hopping is used for 919 - 923 MHz. https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf */ - RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD), + RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD, PRESET(LONG_FAST)), /* Singapore SG_923 Band 30d: 917 - 925 MHz at 100mW, no restrictions. https://www.imda.gov.sg/-/media/imda/files/regulation-licensing-and-consultations/ict-standards/telecommunication-standards/radio-comms/imdatssrd.pdf */ - RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD), + RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Philippines @@ -197,9 +198,9 @@ const RegionInfo regions[] = { https://github.com/meshtastic/firmware/issues/4948#issuecomment-2394926135 */ - RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD), - RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD), - RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD), + RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)), + RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD, PRESET(LONG_FAST)), + RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Kazakhstan @@ -207,32 +208,46 @@ const RegionInfo regions[] = { 863 - 868 MHz <25 mW EIRP, 500kHz channels allowed, must not be used at airfields https://github.com/meshtastic/firmware/issues/7204 */ - RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD), - RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD), + RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)), + RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Nepal 865 MHz to 868 MHz frequency band for IoT (Internet of Things), M2M (Machine-to-Machine), and smart metering use, specifically in non-cellular mode. https://www.nta.gov.np/uploads/contents/Radio-Frequency-Policy-2080-English.pdf */ - RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD), + RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Brazil 902 - 907.5 MHz , 1W power limit, no duty cycle restrictions https://github.com/meshtastic/firmware/issues/3741 */ - RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD), + RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* 2.4 GHZ WLAN Band equivalent. Only for SX128x chips. */ - RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD), + RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD, PRESET(LONG_FAST)), + + /* + EU 866MHz band (Band no. 46b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD) + Gives 4 channels at 865.7/866.3/866.9/867.5 MHz, 400 kHz gap plus 37.5 kHz padding between channels, 27 dBm, + duty cycle 2.5% (mobile) or 10% (fixed) https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006D0771(01)-20250123 + */ + RDEF(EU_866, 865.6f, 867.6f, 2.5, 27, false, false, PROFILE_LITE, PRESET(LITE_FAST)), + + /* + EU 868MHz band: 3 channels at 869.410/869.4625/869.577 MHz + Channel centres at 869.442/869.525/869.608 MHz, + 10.4 kHz padding on channels, 27 dBm, duty cycle 10% + */ + RDEF(EU_N_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW)), /* This needs to be last. Same as US. */ - RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF) + RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF, PRESET(LONG_FAST)), }; @@ -546,6 +561,23 @@ const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code) return r; } +/** + * Get duty cycle for current region. EU_866: 10% for routers, 2.5% for mobile. + */ +float getEffectiveDutyCycle() +{ + if (myRegion->code == meshtastic_Config_LoRaConfig_RegionCode_EU_866) { + if (config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER || + config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER_LATE) { + return 10.0f; + } else { + return 2.5f; + } + } + // For all other regions, return the standard duty cycle + return myRegion->dutyCycle; +} + uint32_t RadioInterface::getPacketTime(const meshtastic_MeshPacket *p, bool received) { uint32_t pl = 0; @@ -897,12 +929,15 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo if (loraConfig.override_frequency == 0) { // Check if we use the default frequency slot + // overrideSlot: 0 = channel hash, -1 = preset hash, >0 = explicit slot uses_default_frequency_slot = (loraConfig.channel_num == 0) || // user choice unset, no frequency override, so use default - (newRegion->profile->overrideSlot != 0 && - loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches override - ((newRegion->profile->overrideSlot == 0) && - ((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset hash, no override + (newRegion->profile->overrideSlot > 0 && + loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches explicit override slot + ((newRegion->profile->overrideSlot == OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH) && + ((uint32_t)(loraConfig.channel_num - 1) == channelNameHashSlot)) || // user setting matches channel name hash + ((newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) && + ((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset name hash // check if user setting different to preset name uses_custom_channel_name = (strcmp(channelName, presetNameDisplay) != 0); @@ -917,10 +952,14 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo if (clamp) { if (uses_custom_channel_name) { // clamp to channel name hash loraConfig.channel_num = - channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 - } else if ((loraConfig.use_preset) && (newRegion->profile->overrideSlot != 0)) { // clamp to preset override slot + channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 + } else if (newRegion->profile->overrideSlot > 0) { // clamp to explicit override slot loraConfig.channel_num = - newRegion->profile->overrideSlot; // use the override slot specified by the region profile + newRegion->profile->overrideSlot; // use the explicit override slot specified by the region profile + uses_default_frequency_slot = true; + } else if (newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH && loraConfig.use_preset) { + // clamp to preset name hash + loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 uses_default_frequency_slot = true; } else if (loraConfig.use_preset) { // clamp to preset slot loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 @@ -1018,6 +1057,8 @@ void RadioInterface::applyModemConfig() // Calculate hash of channel name and preset name to pick a default frequency slot if user has not specified one. // Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to // (numFreqSlots - 1). + const char *channelName = channels.getName(channels.getPrimaryIndex()); + uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots; uint32_t presetNameHashSlot = hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % numFreqSlots; @@ -1034,11 +1075,13 @@ void RadioInterface::applyModemConfig() // (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to (numFreqSlots - 1). // NB: channel_num is also know as frequency slot but it's too late to fix now. if (uses_default_frequency_slot) { - // if there's an override slot, use that - if (newRegion->profile->overrideSlot != 0) { - channel_num = newRegion->profile->overrideSlot - 1; + // Handle three override slot cases: explicit slot (>0), preset hash (-1), or channel hash (0) + if (newRegion->profile->overrideSlot > 0) { + channel_num = newRegion->profile->overrideSlot - 1; // explicit override slot (1-based to 0-based) + } else if (newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) { + channel_num = presetNameHashSlot; // use preset name hash } else { - channel_num = presetNameHashSlot; + channel_num = channelNameHashSlot; // use channel name hash (default case) } } else { // use the manually defined one channel_num = loraConfig.channel_num - 1; @@ -1051,7 +1094,6 @@ void RadioInterface::applyModemConfig() saveChannelNum(channel_num); saveFreq(freq + loraConfig.frequency_offset); - const char *channelName = channels.getName(channels.getPrimaryIndex()); if (newRegion->wideLora) { // clamp if wide freq range preambleLength = wideLoraPreambleLengthDefault; // 12 is the default for operation above 2GHz @@ -1068,9 +1110,11 @@ void RadioInterface::applyModemConfig() channel_num, power); LOG_INFO("newRegion->freqStart -> newRegion->freqEnd: %f -> %f (%f MHz)", newRegion->freqStart, newRegion->freqEnd, newRegion->freqEnd - newRegion->freqStart); - LOG_INFO("numFreqSlots: %d x %.3fkHz", numFreqSlots, bw); - if (newRegion->profile->overrideSlot != 0) { - LOG_INFO("Using region override slot: %d", newRegion->profile->overrideSlot); + LOG_INFO("numFreqSlots: %u x %.3fkHz", numFreqSlots, bw); + if (newRegion->profile->overrideSlot > 0) { + LOG_INFO("Using region explicit override slot: %d", newRegion->profile->overrideSlot); + } else if (newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) { + LOG_INFO("Using region preset name hash for slot selection"); } LOG_INFO("channel_num: %d", channel_num + 1); LOG_INFO("frequency: %f", getFreq()); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 52731fe43ce..6cecf4a0e26 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -318,10 +318,11 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) } // should have already been handled by sendLocal // Abort sending if we are violating the duty cycle - if (!config.lora.override_duty_cycle && myRegion->dutyCycle < 100) { + float effectiveDutyCycle = getEffectiveDutyCycle(); + if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) { float hourlyTxPercent = airTime->utilizationTXPercent(); - if (hourlyTxPercent > myRegion->dutyCycle) { - uint8_t silentMinutes = airTime->getSilentMinutes(hourlyTxPercent, myRegion->dutyCycle); + if (hourlyTxPercent > effectiveDutyCycle) { + uint8_t silentMinutes = airTime->getSilentMinutes(hourlyTxPercent, effectiveDutyCycle); LOG_WARN("Duty cycle limit exceeded. Aborting send for now, you can send again in %d mins", silentMinutes); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 1d05caaa2e1..c30d8d0ba6d 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -824,7 +824,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // Ensure initRegion() uses the newly validated region config.lora.region = validatedLora.region; initRegion(); - if (myRegion->dutyCycle < 100) { + if (getEffectiveDutyCycle() < 100) { validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit } if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index a29b9fa5838..1dbc3a6684e 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -6,6 +6,7 @@ #include "CannedMessageModule.h" #include "Channels.h" #include "FSCommon.h" +#include "MeshRadio.h" #include "MeshService.h" #include "MessageStore.h" #include "NodeDB.h" @@ -2103,16 +2104,16 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st static float getSnrLimit(meshtastic_Config_LoRaConfig_ModemPreset preset) { switch (preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + case PRESET(LONG_SLOW): + case PRESET(LONG_MODERATE): + case PRESET(LONG_FAST): return -6.0f; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + case PRESET(MEDIUM_SLOW): + case PRESET(MEDIUM_FAST): return -5.5f; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + case PRESET(SHORT_SLOW): + case PRESET(SHORT_FAST): + case PRESET(SHORT_TURBO): return -4.5f; default: return -6.0f; diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index d2603627b16..6a36c2af3aa 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -407,8 +407,8 @@ int32_t PositionModule::runOnce() // We limit our GPS broadcasts to a max rate uint32_t now = millis(); - uint32_t intervalMs = Default::getConfiguredOrDefaultMsScaled(config.position.position_broadcast_secs, - default_broadcast_interval_secs, numOnlineNodes); + uint32_t intervalMs = Default::getConfiguredOrDefaultMsScaled( + config.position.position_broadcast_secs, default_broadcast_interval_secs, numOnlineNodes, TrafficType::POSITION); uint32_t msSinceLastSend = now - lastGpsSend; // Only send packets if the channel util. is less than 25% utilized or we're a tracker with less than 40% utilized. if (!airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_TRACKER && diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index ca853d0510e..ef0d0cd6bc0 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -116,11 +116,11 @@ int32_t AirQualityTelemetryModule::runOnce() for (TelemetrySensor *sensor : sensors) { if (!sensor->canSleep()) { LOG_DEBUG("%s sensor doesn't have sleep feature. Skipping", sensor->sensorName); - } else if (((lastTelemetry == 0) || - !Throttle::isWithinTimespanMs(lastTelemetry - sensor->wakeUpTimeMs(), - Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.air_quality_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes))) && + } else if (((lastTelemetry == 0) || !Throttle::isWithinTimespanMs(lastTelemetry - sensor->wakeUpTimeMs(), + Default::getConfiguredOrDefaultMsScaled( + moduleConfig.telemetry.air_quality_interval, + default_telemetry_broadcast_interval_secs, + numOnlineNodes, TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { if (!sensor->isActive()) { @@ -136,10 +136,10 @@ int32_t AirQualityTelemetryModule::runOnce() } } - if (((lastTelemetry == 0) || - !Throttle::isWithinTimespanMs(lastTelemetry, Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.air_quality_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes))) && + if (((lastTelemetry == 0) || !Throttle::isWithinTimespanMs(lastTelemetry, Default::getConfiguredOrDefaultMsScaled( + moduleConfig.telemetry.air_quality_interval, + default_telemetry_broadcast_interval_secs, + numOnlineNodes, TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { sendTelemetry(); @@ -159,7 +159,8 @@ int32_t AirQualityTelemetryModule::runOnce() if (sensor->isActive() && sensor->canSleep()) { if (sensor->wakeUpTimeMs() < (int32_t)Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.air_quality_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes)) { + default_telemetry_broadcast_interval_secs, numOnlineNodes, + TrafficType::TELEMETRY)) { LOG_DEBUG("Disabling %s until next period", sensor->sensorName); sensor->sleep(); } else { diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index 1c2d18c717c..912cc6e24e5 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -26,7 +26,7 @@ int32_t DeviceTelemetryModule::runOnce() if (((lastTelemetry == 0) || ((uptimeLastMs - lastTelemetry) >= Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.device_update_interval, default_telemetry_broadcast_interval_secs, - numOnlineNodes))) && + numOnlineNodes, TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(!isImpoliteRole) && airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN && moduleConfig.telemetry.device_telemetry_enabled) { diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 04a5370de4d..1535b536eb4 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -310,9 +310,10 @@ int32_t EnvironmentTelemetryModule::runOnce() uint32_t lastTelemetry = transmitHistory ? transmitHistory->getLastSentToMeshMillis(TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY) : 0; if (((lastTelemetry == 0) || - !Throttle::isWithinTimespanMs(lastTelemetry, Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.environment_update_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes))) && + !Throttle::isWithinTimespanMs( + lastTelemetry, Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.environment_update_interval, + default_telemetry_broadcast_interval_secs, numOnlineNodes, + TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { sendTelemetry(); diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index ae6b366bda3..da6ee2b58ab 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -74,9 +74,10 @@ int32_t HealthTelemetryModule::runOnce() uint32_t lastTelemetry = transmitHistory ? transmitHistory->getLastSentToMeshMillis(TX_HISTORY_KEY_HEALTH_TELEMETRY) : 0; if (((lastTelemetry == 0) || - !Throttle::isWithinTimespanMs(lastTelemetry, Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.health_update_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes))) && + !Throttle::isWithinTimespanMs(lastTelemetry, + Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.health_update_interval, + default_telemetry_broadcast_interval_secs, + numOnlineNodes, TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { sendTelemetry(); diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index d02aed9c2d7..6216f2bb74c 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -55,8 +55,9 @@ int32_t PowerTelemetryModule::runOnce() return disable(); } - uint32_t sendToMeshIntervalMs = Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.power_update_interval, default_telemetry_broadcast_interval_secs, numOnlineNodes); + uint32_t sendToMeshIntervalMs = Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.power_update_interval, + default_telemetry_broadcast_interval_secs, + numOnlineNodes, TrafficType::TELEMETRY); if (firstTime) { // This is the first time the OSThread library has called this function, so do some setup diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 9906bb94c5c..5e383d396ef 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -11,6 +11,7 @@ * 6. Channel spacing calculation (placeholder for future protobuf changes) */ +#include "DisplayFormatters.h" #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" @@ -21,6 +22,9 @@ #include "meshtastic/config.pb.h" +// hash() is a file-scope function in RadioInterface.cpp; link it in for slot-formula tests +extern uint32_t hash(const char *str); + class MockMeshService : public MeshService { public: @@ -163,20 +167,58 @@ static const RegionProfile TEST_PROFILE_TURBO = { /* overrideSlot */ 0, }; +// A preset list for the preset-hash override slot test (LONG_FAST + MEDIUM_FAST) +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_PRESET_HASH[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + MODEM_PRESET_END, +}; + +// Profile with overrideSlot = OVERRIDE_SLOT_PRESET_HASH (-1): +// slot selection always uses hash(presetDisplayName), ignoring the primary channel name. +static const RegionProfile TEST_PROFILE_PRESET_HASH = { + TEST_PRESETS_PRESET_HASH, + /* spacing */ 0.0f, + /* padding */ 0.0f, + /* audioPermitted */ true, + /* licensedOnly */ false, + /* textThrottle */ 0, + /* positionThrottle */ 0, + /* telemetryThrottle */ 0, + /* overrideSlot */ OVERRIDE_SLOT_PRESET_HASH, +}; + +// Standalone test region using US frequencies (26 MHz span → 104 slots at 250 kHz BW) +// Used to verify OVERRIDE_SLOT_PRESET_HASH slot formula; not inserted into testRegions[]. +static const RegionInfo TEST_REGION_PRESET_HASH = { + meshtastic_Config_LoRaConfig_RegionCode_US, + 902.0f, + 928.0f, + 100, + 30, + false, + false, + &TEST_PROFILE_PRESET_HASH, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + "TEST_PRESET_HASH", +}; + static const RegionInfo testRegions[] = { // A wide US-like region with spacing + padding - {meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_US_SPACED"}, + {meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, "TEST_US_SPACED"}, // A narrow band simulating tight EU regulation {meshtastic_Config_LoRaConfig_RegionCode_EU_868, 869.4f, 869.65f, 10, 14, false, false, &TEST_PROFILE_LICENSED, - "TEST_EU_LICENSED"}, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, "TEST_EU_LICENSED"}, // A wide-LoRa region with turbo-only presets {meshtastic_Config_LoRaConfig_RegionCode_LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, &TEST_PROFILE_TURBO, - "TEST_LORA24_TURBO"}, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, "TEST_LORA24_TURBO"}, // Sentinel — must be last - {meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_UNSET"}, + {meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, "TEST_UNSET"}, }; static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode code) @@ -194,6 +236,13 @@ static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode c // Shadow table tests // ----------------------------------------------------------------------- +// Helper: replicate the numFreqSlots formula from RadioInterface so tests can compute expected values. +static uint32_t testComputeNumFreqSlots(const RegionInfo *r, float bw_kHz) +{ + float w = r->profile->spacing + (r->profile->padding * 2) + (bw_kHz / 1000.0f); + return (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / w) + 0.5f); +} + static void test_shadowTable_spacedProfileHasNonZeroSpacing() { const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); @@ -268,6 +317,137 @@ static void test_shadowTable_unknownCodeFallsToSentinel() TEST_ASSERT_EQUAL_STRING("TEST_UNSET", r->name); } +static void test_shadowTable_presetHashProfileHasCorrectOverrideSlot() +{ + TEST_ASSERT_EQUAL(OVERRIDE_SLOT_PRESET_HASH, TEST_PROFILE_PRESET_HASH.overrideSlot); + TEST_ASSERT_EQUAL(-1, TEST_PROFILE_PRESET_HASH.overrideSlot); + TEST_ASSERT_EQUAL(2, TEST_REGION_PRESET_HASH.getNumPresets()); +} + +// ----------------------------------------------------------------------- +// OVERRIDE_SLOT_PRESET_HASH (-1) slot formula tests +// +// Property under test: +// overrideSlot = -1 → slot = hash(presetDisplayName) % numSlots +// regardless of what the primary channel is named +// overrideSlot = 0 → slot = hash(channelName) % numSlots +// when channel name = preset display name, these two modes give identical slots +// ----------------------------------------------------------------------- + +static void test_overrideSlotPresetHash_longFast_customChannelMatchesDefaultNameSlot() +{ + // US + LONG_FAST: spacing=0, padding=0, bw=250 kHz + // numSlots = round((928-902+0)/0.250) = 104 + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora); + uint32_t numSlots = testComputeNumFreqSlots(us, bw); + TEST_ASSERT_EQUAL_UINT32(104, numSlots); // sanity + + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true); + + // OVERRIDE_SLOT_PRESET_HASH (-1): + // channel is "MyCustomNetwork" but slot still uses preset name hash + uint32_t slotPresetHashMode = hash(presetName) % numSlots; + + // OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH (0) with channel name = preset name (user never renamed it): + // channelName == presetName → same hash → same slot + const char *defaultChannelName = presetName; + uint32_t slotChannelHashModeDefaultName = hash(defaultChannelName) % numSlots; + + TEST_ASSERT_EQUAL_UINT32(slotPresetHashMode, slotChannelHashModeDefaultName); + + // Confirm a different custom channel name gives a different hash INPUT + // (so mode 0 would diverge while mode -1 stays locked) + TEST_ASSERT_TRUE(strcmp(presetName, "MyCustomNetwork") != 0); +} + +static void test_overrideSlotPresetHash_mediumFast_customChannelMatchesDefaultNameSlot() +{ + // US + MEDIUM_FAST: bw=250 kHz → same 104 slots as LONG_FAST for US + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, us->wideLora); + uint32_t numSlots = testComputeNumFreqSlots(us, bw); + TEST_ASSERT_EQUAL_UINT32(104, numSlots); // sanity + + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true); + + // Mode -1: slot = hash(presetName) % numSlots (channel name irrelevant) + uint32_t slotPresetHashMode = hash(presetName) % numSlots; + + // Mode 0 + default name (channel name = preset display name): + uint32_t slotChannelHashModeDefaultName = hash(presetName) % numSlots; + + TEST_ASSERT_EQUAL_UINT32(slotPresetHashMode, slotChannelHashModeDefaultName); + + TEST_ASSERT_TRUE(strcmp(presetName, "MyCustomNetwork") != 0); +} + +static void test_overrideSlotPresetHash_longFast_slotIsStableAcrossCustomNames() +{ + // Mode -1 must give the same slot for LONG_FAST regardless of which custom name is in use. + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora); + uint32_t numSlots = testComputeNumFreqSlots(us, bw); + + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true); + uint32_t expectedSlot = hash(presetName) % numSlots; + + // Simulate three different custom channel names; mode -1 ignores all of them + const char *customNames[] = {"AlphaNet", "BetaMesh", "GammaMesh"}; + for (int i = 0; i < 3; i++) { + uint32_t slotForCustom = hash(presetName) % numSlots; // mode -1: presetName only + TEST_ASSERT_EQUAL_UINT32(expectedSlot, slotForCustom); + // Confirm input would have differed in mode 0 + TEST_ASSERT_TRUE(strcmp(presetName, customNames[i]) != 0); + } +} + +static void test_overrideSlotPresetHash_mediumFast_slotIsStableAcrossCustomNames() +{ + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, us->wideLora); + uint32_t numSlots = testComputeNumFreqSlots(us, bw); + + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true); + uint32_t expectedSlot = hash(presetName) % numSlots; + + const char *customNames[] = {"AlphaNet", "BetaMesh", "GammaMesh"}; + for (int i = 0; i < 3; i++) { + uint32_t slotForCustom = hash(presetName) % numSlots; // mode -1: presetName only + TEST_ASSERT_EQUAL_UINT32(expectedSlot, slotForCustom); + TEST_ASSERT_TRUE(strcmp(presetName, customNames[i]) != 0); + } +} + +static void test_overrideSlotPresetHash_longFastAndMediumFast_slotsAreDifferentPresets() +{ + // LONG_FAST and MEDIUM_FAST have different display names → likely different hash slots. + // This verifies the two presets genuinely occupy distinct positions, so the equivalence + // tests above are not trivially vacuous. + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw_lf = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false); + float bw_mf = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false); + uint32_t numSlots_lf = testComputeNumFreqSlots(us, bw_lf); + uint32_t numSlots_mf = testComputeNumFreqSlots(us, bw_mf); + + const char *nameLF = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true); + const char *nameMF = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true); + + TEST_ASSERT_TRUE(strcmp(nameLF, nameMF) != 0); + + uint32_t slotLF = hash(nameLF) % numSlots_lf; + uint32_t slotMF = hash(nameMF) % numSlots_mf; + // They use the same numSlots (both 250 kHz on US), so a difference in display name + // should produce a different slot. + TEST_ASSERT_NOT_EQUAL(slotLF, slotMF); +} + // ----------------------------------------------------------------------- // validateConfigLora() tests // ----------------------------------------------------------------------- @@ -769,6 +949,7 @@ void setup() RUN_TEST(test_shadowTable_channelSpacingWithPadding); RUN_TEST(test_shadowTable_turboOnlyOnWideLora); RUN_TEST(test_shadowTable_unknownCodeFallsToSentinel); + RUN_TEST(test_shadowTable_presetHashProfileHasCorrectOverrideSlot); // validateConfigLora() RUN_TEST(test_validateConfigLora_validPresetForUS); @@ -798,6 +979,13 @@ void setup() RUN_TEST(test_regionFieldsAreSane); RUN_TEST(test_onlyLORA24HasWideLora); + // OVERRIDE_SLOT_PRESET_HASH (-1) slot formula tests + RUN_TEST(test_overrideSlotPresetHash_longFast_customChannelMatchesDefaultNameSlot); + RUN_TEST(test_overrideSlotPresetHash_mediumFast_customChannelMatchesDefaultNameSlot); + RUN_TEST(test_overrideSlotPresetHash_longFast_slotIsStableAcrossCustomNames); + RUN_TEST(test_overrideSlotPresetHash_mediumFast_slotIsStableAcrossCustomNames); + RUN_TEST(test_overrideSlotPresetHash_longFastAndMediumFast_slotsAreDifferentPresets); + // Channel spacing (current + placeholder) RUN_TEST(test_channelSpacingCalculation_US_LONG_FAST); RUN_TEST(test_channelSpacingCalculation_EU868_LONG_FAST); From e10e13226d706feace78a32c09e6d8cdd710a107 Mon Sep 17 00:00:00 2001 From: Carlos Valdes Date: Thu, 21 May 2026 18:00:10 +0200 Subject: [PATCH 221/225] nrf54l15: fix SHT4x libdep -- arduino-sht, not Adafruit_SHT4X (#10515) SHTXXSensor (the SHT4x driver) includes , gated by __has_include(). That header ships in Sensirion/arduino-sht. Adafruit_SHT4X ships Adafruit_SHT4X.h and has no consumer anywhere in src/, so the SHT40 driver was silently excluded from the build -- the nRF54L15 variant could not read an SHT4x sensor as committed in #10193. Replace the dead Adafruit_SHT4X libdep with arduino-sht v1.2.6. Validated on nRF54L15-DK: SHT40-AD1B @0x44, 24.3h soak, 0 reboots, temperature/humidity telemetry stable end-to-end. Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Ben Meadors --- variants/nrf54l15/nrf54l15.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index 4e6fa3f0100..45dab1a41bc 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -66,7 +66,10 @@ lib_deps = https://github.com/adafruit/Adafruit_INA219/archive/refs/tags/1.2.3.zip https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip https://github.com/RobTillaart/INA226/archive/refs/tags/0.6.6.zip - https://github.com/adafruit/Adafruit_SHT4X/archive/refs/tags/1.0.5.zip + ; SHTXXSensor gates on __has_include(), a header shipped by + ; Sensirion/arduino-sht. Adafruit_SHT4X ships Adafruit_SHT4X.h instead and + ; has no consumer in src/, so it left the SHT40 driver out of the build. + https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip lib_ignore = BluetoothOTA From f2c5cb0a058761e34b10a3ca24d56a9cec6125d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=BA=CF=81=CE=BC=CE=B3?= Date: Thu, 21 May 2026 21:30:27 +0300 Subject: [PATCH 222/225] fix: first set pinMode, then write to pin (#10520) --- src/mesh/SX126xInterface.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 3513bbba3f3..d499e4a8c6d 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -52,8 +52,8 @@ template bool SX126xInterface::init() #ifdef SX126X_POWER_EN // Perhaps add RADIOLIB_NC check, and beforehand define as such if it is undefined, but it is not commonly // used and not part of the 'default' set of pin definitions. - digitalWrite(SX126X_POWER_EN, HIGH); pinMode(SX126X_POWER_EN, OUTPUT); + digitalWrite(SX126X_POWER_EN, HIGH); #endif #if HAS_LORA_FEM @@ -65,8 +65,8 @@ template bool SX126xInterface::init() #endif #ifdef RF95_FAN_EN - digitalWrite(RF95_FAN_EN, HIGH); pinMode(RF95_FAN_EN, OUTPUT); + digitalWrite(RF95_FAN_EN, HIGH); #endif #if ARCH_PORTDUINO From 91f930d5c028f0690090e102935094049dc0b608 Mon Sep 17 00:00:00 2001 From: Jaime Roldan Date: Fri, 22 May 2026 06:58:21 -0500 Subject: [PATCH 223/225] fix(telemetry): stop emitting -0.001V sentinel when battery unavailable (#7958) (#10217) * fix(telemetry): stop emitting -0.001V sentinel when battery unavailable (#7958) * address review: use int32_t for batteryMv to avoid uint16_t signed wrap --- src/modules/Telemetry/DeviceTelemetry.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index 912cc6e24e5..9e0cc50c9fc 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -99,17 +99,21 @@ meshtastic_Telemetry DeviceTelemetryModule::getDeviceTelemetry() t.variant.device_metrics.has_air_util_tx = true; t.variant.device_metrics.has_battery_level = true; t.variant.device_metrics.has_channel_utilization = true; - t.variant.device_metrics.has_voltage = true; t.variant.device_metrics.has_uptime_seconds = true; - t.variant.device_metrics.air_util_tx = airTime->utilizationTXPercent(); t.variant.device_metrics.battery_level = (!powerStatus->getHasBattery() || powerStatus->getIsCharging()) ? MAGIC_USB_BATTERY_LEVEL : powerStatus->getBatteryChargePercent(); t.variant.device_metrics.channel_utilization = airTime->channelUtilizationPercent(); - t.variant.device_metrics.voltage = powerStatus->getBatteryVoltageMv() / 1000.0; + // Only populate voltage when we actually have a battery reading. Previously this assigned + // -0.001 (from -1 mV / 1000) whenever the ADC returned -1, leaking a sentinel onto the wire + // that clients then displayed as a real negative voltage. See GH #7958. + int32_t batteryMv = powerStatus->getBatteryVoltageMv(); + if (powerStatus->getHasBattery() && batteryMv > 0) { + t.variant.device_metrics.has_voltage = true; + t.variant.device_metrics.voltage = batteryMv / 1000.0f; + } t.variant.device_metrics.uptime_seconds = getUptimeSeconds(); - return t; } From c7748a1602157928442b09440ce76869ec1a29a6 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 22 May 2026 14:53:52 -0500 Subject: [PATCH 224/225] Fix update neighbor_info before checking update_interval in handleSetModuleConfig --- src/modules/AdminModule.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index c30d8d0ba6d..ff72aad424b 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1013,11 +1013,11 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) case meshtastic_ModuleConfig_neighbor_info_tag: LOG_INFO("Set module config: Neighbor Info"); moduleConfig.has_neighbor_info = true; + moduleConfig.neighbor_info = c.payload_variant.neighbor_info; if (moduleConfig.neighbor_info.update_interval < min_neighbor_info_broadcast_secs) { LOG_DEBUG("Tried to set update_interval too low, setting to %d", default_neighbor_info_broadcast_secs); moduleConfig.neighbor_info.update_interval = default_neighbor_info_broadcast_secs; } - moduleConfig.neighbor_info = c.payload_variant.neighbor_info; break; case meshtastic_ModuleConfig_detection_sensor_tag: LOG_INFO("Set module config: Detection Sensor"); From ef734b73c798ce6f2a6668b4b48d8cca6b61b41c Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Sat, 23 May 2026 02:14:42 +0100 Subject: [PATCH 225/225] fix: mbed TLS crash in Arduino 3.x (pioarduino) (#10535) * fix mbed TLS crash * adapt SSL_MAX_CONTENT_LEN size to framework-libs * found two more --- variants/esp32/esp32-common.ini | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index f2fbb4c616b..4f302694a4e 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -195,7 +195,7 @@ custom_sdkconfig = ; ; MBEDTLS ; - CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=8192 + CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384 ; do not change, affects buffer allocation (PR10535) CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=n CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y ; Switch to custom CA bundle (for Meshtastic MQTT/etc) in the future @@ -205,10 +205,15 @@ custom_sdkconfig = CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_MAX_CERTS=1 ; #shame CONFIG_MBEDTLS_ALLOW_WEAK_CERTIFICATE_VERIFICATION=y - CONFIG_MBEDTLS_SSL_RENEGOTIATION=n - CONFIG_MBEDTLS_SSL_PROTO_DTLS=n - CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS=n - CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS=n + ; These six options must match the precompiled framework-arduinoespressif32-libs + ; which was built with all six enabled. Disabling them changes mbedtls_ssl_context + ; struct layout, causing an ABI mismatch and a crash in mbedtls_ssl_set_hostname. + CONFIG_MBEDTLS_SSL_RENEGOTIATION=y + CONFIG_MBEDTLS_SSL_PROTO_DTLS=y + CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS=y + CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS=y + CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE=y + CONFIG_MBEDTLS_SSL_ALPN=y CONFIG_MBEDTLS_PKCS7_C=n CONFIG_MBEDTLS_CAMELLIA_C=n CONFIG_MBEDTLS_CCM_C=n