diff --git a/README.md b/README.md index 2acd40a..6152ccf 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Other benefits: | **ThinkNode M3** | LR1110 | GPS, buzzer, two buttons, RGB LEDs | | **ThinkNode M6** | SX1262 | GPS (L76K), QSPI flash, RGB LEDs | | **LilyGo T-Echo** | SX1262 (TCXO 1.8V) | GPS (L76K), 1.54" e-paper (SSD1681), BME280, QSPI flash, touch-button backlight | +| **Heltec Mesh Node T096** | SX1262 + KCT8103L PA/FEM | UC6580 GNSS, button, LED, battery ADC | | **Ikoka Nano 30dBm** | SX1262 (E22-900M30S, 30 dBm PA) | RGB LEDs | | **GAT562 30S Mesh Kit** | SX1262 (30 dBm / 1 W PA) | RAK4631 core module. OLED (SSD1306), 5-way joystick, buzzer, GPS, BME280 pad, 2×18650 + solar | | **SenseCAP Solar** | SX1262 | GPS (L76K), QSPI flash, battery monitor | diff --git a/build.sh b/build.sh index 1361e1e..d89ac12 100644 --- a/build.sh +++ b/build.sh @@ -20,6 +20,7 @@ nRF_boards=( lilygo_techo promicro_sx1262 heltec_t114 + heltec_t096 gat562_30s ) @@ -250,4 +251,4 @@ print(str(size // 1048576) + "MB") mv build/zephcore/zephyr/zephyr.signed.bin firmware/"$board_clean_for_path"-repeater-"$COMMIT_HASH".bin fi done -fi \ No newline at end of file +fi diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index fd9285d..8b8d54d 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -265,7 +265,7 @@ set(ZEPHCORE_COMMON_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/zephcore_com # Detect platform from board name and add platform-specific common config # For boards with qualifiers like "wio_tracker_l1/nrf52840", check both the full BOARD # string and the BOARD_QUALIFIERS which contains just the qualifier (e.g., "nrf52840") -if(BOARD MATCHES ".*nrf52.*" OR BOARD MATCHES "rak4631" OR BOARD MATCHES "rak_wismesh_tag" OR BOARD MATCHES "rak3401_1watt" OR BOARD MATCHES "wio_tracker" OR BOARD MATCHES "ikoka_nano" OR BOARD MATCHES "t1000_e" OR BOARD MATCHES "thinknode_m1" OR BOARD MATCHES "thinknode_m3" OR BOARD MATCHES "thinknode_m6" OR BOARD MATCHES "promicro_lr2021" OR BOARD MATCHES "promicro_sx1262" OR BOARD MATCHES "sensecap_solar" OR BOARD MATCHES "xiao_nrf52840" OR BOARD MATCHES "lilygo_techo" OR BOARD MATCHES "heltec_t114" OR BOARD MATCHES "gat562") +if(BOARD MATCHES ".*nrf52.*" OR BOARD MATCHES "rak4631" OR BOARD MATCHES "rak_wismesh_tag" OR BOARD MATCHES "rak3401_1watt" OR BOARD MATCHES "wio_tracker" OR BOARD MATCHES "ikoka_nano" OR BOARD MATCHES "t1000_e" OR BOARD MATCHES "thinknode_m1" OR BOARD MATCHES "thinknode_m3" OR BOARD MATCHES "thinknode_m6" OR BOARD MATCHES "promicro_lr2021" OR BOARD MATCHES "promicro_sx1262" OR BOARD MATCHES "sensecap_solar" OR BOARD MATCHES "xiao_nrf52840" OR BOARD MATCHES "lilygo_techo" OR BOARD MATCHES "heltec_t114" OR BOARD MATCHES "heltec_t096" OR BOARD MATCHES "gat562") set(ZEPHCORE_PLATFORM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/nrf52_common.conf") elseif(DEFINED BOARD_QUALIFIERS AND BOARD_QUALIFIERS MATCHES ".*nrf52.*") set(ZEPHCORE_PLATFORM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/nrf52_common.conf") diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 3f2176c..b11f9e0 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -248,6 +248,71 @@ void LoRaRadioBase::buildModemConfig(struct lora_modem_config &cfg, bool tx) cfg.cad.mode = LORA_CAD_MODE_LBT; } +uint32_t LoRaRadioBase::getActiveFrequencyHz() const +{ + float freq_mhz = _has_radio_override ? _override_freq + : (_prefs ? _prefs->freq : (LoRaConfig::FREQ_HZ / 1000000.0f)); + + return (uint32_t)(freq_mhz * 1000000.0f + 0.5f); +} + +uint16_t LoRaRadioBase::getActiveBandwidthKHzX10() const +{ + float bw_khz = _has_radio_override ? _override_bw + : (_prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH); + + return (uint16_t)(bw_khz * 10.0f + 0.5f); +} + +uint8_t LoRaRadioBase::getActiveSpreadingFactor() const +{ + return _has_radio_override ? _override_sf + : (_prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR); +} + +uint8_t LoRaRadioBase::getActiveCodingRate() const +{ + return _has_radio_override ? _override_cr + : (_prefs ? _prefs->cr : LoRaConfig::CODING_RATE); +} + +uint16_t LoRaRadioBase::getActivePreambleLength() const +{ + return preambleLengthForSF(getActiveSpreadingFactor()); +} + +uint8_t LoRaRadioBase::getActiveSyncWord() const +{ + /* buildModemConfig() currently sets public_network=false, which maps + * Zephyr's LoRa API to the Semtech private sync word. */ + return 0x12; +} + +int8_t LoRaRadioBase::getConfiguredTxPower() const +{ + int power = _prefs ? _prefs->tx_power_dbm : LoRaConfig::TX_POWER_DBM; + +#ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM + if (power > CONFIG_ZEPHCORE_MAX_TX_POWER_DBM) { + power = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; + } +#endif + if (power < -9) { + power = -9; + } + return (int8_t)power; +} + +int8_t LoRaRadioBase::getEffectiveTxPower() const +{ + int power = (int)getConfiguredTxPower() - (int)_tx_power_reduction_db; + + if (power < -9) { + power = -9; + } + return (int8_t)power; +} + /** * Compare radio-relevant fields of two modem configs. * Ignores the tx flag — that only selects TX vs RX mode, the actual diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index 8fcdff3..60b3166 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -81,6 +81,19 @@ public: void setRxBoost(bool enable); bool isRxBoostEnabled() const { return _rx_boost_enabled; } + /* Read-only view of the modem config currently used by buildModemConfig(). + * These honor temporary radio overrides for freq/bw/sf/cr and the same TX + * clamps/APC reduction as the actual lora_config() path. */ + uint32_t getActiveFrequencyHz() const; + uint16_t getActiveBandwidthKHzX10() const; + uint8_t getActiveSpreadingFactor() const; + uint8_t getActiveCodingRate() const; + uint16_t getActivePreambleLength() const; + uint8_t getActiveSyncWord() const; + int8_t getConfiguredTxPower() const; + int8_t getEffectiveTxPower() const; + bool isTxActive() const { return atomic_get(&_tx_active) != 0; } + /* Duty-cycle preamble false-positive counter. * Incremented by the driver whenever RX_TX_TIMEOUT fires in * duty-cycle mode and the chip is silently re-armed. High diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 24d9b95..d3234b7 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -152,6 +152,31 @@ public: */ void setPinChangeCallback(PinChangeCallback cb) { _pin_change_cb = cb; } +#ifdef CONFIG_ZEPHCORE_APC + /* Adaptive Power Control hooks used by the USB text CLI. */ + int8_t getAPCReduction() const { + return getPowerController().getPowerReduction(); + } + float getAPCMargin() const { + return getPowerController().getMarginEstimate(); + } + bool isAPCEnabled() const { + return getPowerController().isEnabled(); + } + void setAPCEnabled(bool en) { + getPowerController().setEnabled(en); + if (!en) { + _radio->setTxPowerReduction(0); + } + } + uint8_t getAPCTargetMargin() const { + return getPowerController().getTargetMargin(); + } + void setAPCTargetMargin(uint8_t margin_db) { + getPowerController().setTargetMargin(margin_db); + } +#endif + /** * Continue contact iteration (call each main loop iteration). * Returns true if contacts are still being sent. diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 2a844c0..d7c1a53 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -24,6 +24,7 @@ Supported Boards | GAT562 30S Mesh Kit | `west build -b gat562_30s zephcore` | UF2 drag-drop or `west flash` | | LilyGo T-Echo | `west build -b lilygo_techo zephcore` | UF2 drag-drop or `west flash` | | Heltec T114 | `west build -b heltec_t114 zephcore` | UF2 drag-drop or `west flash` | +| Heltec Mesh Node T096 | `west build -b heltec_t096 zephcore` | UF2 drag-drop or `west flash` | **Heltec T114 screenless:** append `boards/nrf52840/heltec_t114/no_display.conf` to `EXTRA_CONF_FILE` for units without the TFT module. diff --git a/zephcore/boards/nrf52840/heltec_t096/Kconfig.heltec_t096 b/zephcore/boards/nrf52840/heltec_t096/Kconfig.heltec_t096 new file mode 100644 index 0000000..0a3bd09 --- /dev/null +++ b/zephcore/boards/nrf52840/heltec_t096/Kconfig.heltec_t096 @@ -0,0 +1,6 @@ +# Copyright (c) 2026 ZephCore +# +# SPDX-License-Identifier: MIT + +config BOARD_HELTEC_T096 + select SOC_NRF52840_QIAA diff --git a/zephcore/boards/nrf52840/heltec_t096/README.md b/zephcore/boards/nrf52840/heltec_t096/README.md new file mode 100644 index 0000000..c21eff2 --- /dev/null +++ b/zephcore/boards/nrf52840/heltec_t096/README.md @@ -0,0 +1,88 @@ +# Heltec Mesh Node T096 + +Custom ZephCore board target for the Heltec Mesh Node T096. + +Build: + +```bash +west build -b heltec_t096 zephcore --pristine +``` + +## Hardware + +- MCU: nRF52840 +- Radio: SX1262 +- RF front-end: KCT8103L PA/FEM, official maximum LoRa output power up to 28+/-1 dBm +- GNSS: UC6580 +- Display: ST7735S 0.96" TFT, 160x80 pixels + +## Confirmed Pinout + +Sources: official Heltec Mesh Node T096 datasheet/schematic, with MeshCore `variants/heltec_t096` and Zephyr's Heltec Wireless Tracker ST7735 160x80 node used as implementation references. + +| Function | nRF52840 pin | Schematic net | ZephCore mapping | +| --- | --- | --- | --- | +| LoRa SCK | P1.08 | LoRa_SCK | `spi2` SCK | +| LoRa MISO | P0.14 | LoRa_MISO | `spi2` MISO | +| LoRa MOSI | P0.11 | LoRa_MOSI | `spi2` MOSI | +| LoRa NSS / CS | P0.05 | LoRa_NSS | `cs-gpios` | +| LoRa reset | P0.16 | LoRa_RST | `reset-gpios` | +| LoRa busy | P0.19 | LoRa_BUSY | `busy-gpios` | +| LoRa DIO1 | P0.21 | DIO1 | `dio1-gpios` | +| SX1262 DIO2 / FEM CPS | SX1262 DIO2 | PA_CPS | `dio2-tx-enable` | +| FEM enable | P0.12 | PA_CSD | `antenna-enable-gpios` | +| FEM TX select | P1.09 | PA_CTX | `tx-enable-gpios` | +| FEM rail enable | P0.30 | VFEM_Ctrl | fixed regulator `vfem_enable` | +| GNSS UART RX into MCU | P0.23 | GNSS_TX | `uart0` RX | +| GNSS UART TX from MCU | P0.25 | GNSS_RX | `uart0` TX | +| GNSS reset | P1.14 | GNSS_RST | `gps-reset` alias | +| GNSS power | P0.06 | VGNSS_CTRL | fixed regulator `gps_power` | +| GNSS PPS | P1.11 | PPS | documented, not consumed | +| Display SCK | P0.20 | SCLK | `spi3` SCK | +| Display MOSI | P0.17 | SDIN | `spi3` MOSI | +| Display CS | P0.22 | CS | `spi3` `cs-gpios` | +| Display DC | P0.15 | RS | MIPI-DBI `dc-gpios` | +| Display reset | P0.13 | RESET | MIPI-DBI `reset-gpios` | +| Display backlight | P1.12 | LEDA | `disp_pwr_enable`, active-low | +| Display/peripheral rail | P0.26 | Vext_Ctrl | `tft_pwr_enable`, active-high | +| LED | P0.28 | LED | `led0`, `lora-tx-led` | +| Button | P1.10 | Button | `sw0` | +| Battery ADC | P0.03 / AIN1 | ADC_IN | ADC channel 1 | +| Battery ADC enable | P1.15 | ADC_Ctrl | fixed regulator `vbat_enable` | + +## Display + +- Controller: ST7735S, modeled with Zephyr's `sitronix,st7735r` ST7735R/ST7735S driver. +- Resolution: 160x80 pixels. +- Bus: SPI3 through Zephyr MIPI-DBI, write-only. +- Orientation/init: ST7735 mini 160x80 parameters from Zephyr's Heltec Wireless Tracker node, with `madctl = 0xa8` matching MeshCore's T096 `DISPLAY_ROTATION=1` plus BGR override. +- UI path: `zephcore,mono-tft` wraps the color TFT as a 1bpp display for ZephCore's CFB companion UI. +- Power: `Vext_Ctrl` is boot-enabled so the ST7735 init sequence reaches a powered panel; `LEDA` follows ZephCore display on/off as the backlight control. + +## Not Enabled + +- External flash: the schematic shows `F_SPI_*` nets and a flash footprint, but the flash device and parameters are not confirmed. + +## T096 vs T114 Differences + +- LoRa SPI/control pins are different across SCK, MISO, MOSI, CS, reset, busy, and DIO1. +- T096 adds a KCT8103L PA/FEM path with `PA_CSD`, `PA_CTX`, `PA_CPS`, and `VFEM_Ctrl`; T114 uses SX1262 DIO2 RF switch without those MCU PA/FEM GPIOs. +- T096 GNSS is UC6580 at 115200 baud with `VGNSS_CTRL`, `GNSS_RST`, and `PPS`; T114 uses ATGM336H-style wiring at 9600 baud. +- T096 battery ADC is P0.03/AIN1 with enable P1.15; T114 battery ADC is P0.04/AIN2 with enable P0.06. +- T096 display is an ST7735S 160x80 panel on P0.20/P0.17 SPI3 with P0.26 rail enable and P1.12 active-low backlight; T114 display is an ST7789V 240x135 panel with different pins and power polarity. +- T096 LED is P0.28 active-high; T114 LED is P1.03 active-low. +- T096 button is P1.10, matching T114's user button pin. + +## Hardware Smoke Test Plan + +1. Flash UF2 by double-tap reset and drag-drop. +2. Confirm USB CDC/logging appears. +3. Confirm BLE advertises as ZephCore/MeshCore companion. +4. Confirm MeshCore app pairing works. +5. Confirm the display wakes, shows the normal ZephCore companion status/pairing UI, and blanks/backlights off after the configured timeout. +6. Confirm SX1262 init succeeds with no reset, busy, or DIO1 errors. +7. Confirm RX starts. +8. Test low-power TX first. +9. Confirm PA/FEM control before testing higher TX power. +10. Test PA progressively. +11. Confirm GNSS only after radio and BLE are stable. diff --git a/zephcore/boards/nrf52840/heltec_t096/board.cmake b/zephcore/boards/nrf52840/heltec_t096/board.cmake new file mode 100644 index 0000000..3dc94fb --- /dev/null +++ b/zephcore/boards/nrf52840/heltec_t096/board.cmake @@ -0,0 +1,14 @@ +# Copyright (c) 2026 ZephCore +# +# SPDX-License-Identifier: MIT + +board_runner_args(jlink "--device=nRF52840_xxAA" "--speed=4000") +board_runner_args(pyocd "--target=nrf52840" "--frequency=4000000") + +set(OPENOCD_NRF5_SUBFAMILY "nrf52") + +include(${ZEPHYR_BASE}/boards/common/jlink.board.cmake) +include(${ZEPHYR_BASE}/boards/common/pyocd.board.cmake) +include(${ZEPHYR_BASE}/boards/common/openocd-nrf5.board.cmake) +include(${ZEPHYR_BASE}/boards/common/nrfjprog.board.cmake) +include(${ZEPHYR_BASE}/boards/common/nrfutil.board.cmake) diff --git a/zephcore/boards/nrf52840/heltec_t096/board.conf b/zephcore/boards/nrf52840/heltec_t096/board.conf new file mode 100644 index 0000000..362e4aa --- /dev/null +++ b/zephcore/boards/nrf52840/heltec_t096/board.conf @@ -0,0 +1,41 @@ +# Heltec Mesh Node T096 - nRF52840 + SX1262 + UC6580 GNSS +# Board-specific configuration +# +# Hardware: +# - nRF52840 SoC, SX1262 LoRa, KCT8103L PA/FEM +# - UC6580 GNSS on UART0 at 115200 baud +# - Battery ADC on AIN1 (P0.03), 390K/100K divider, enable P1.15 +# - I2C0 header bus (SDA=P0.07, SCL=P0.08) +# - LED: P0.28 (active-HIGH) +# - Button: P1.10 (active-LOW) +# - ST7735S 0.96" 160x80 TFT (SPI3, MIPI-DBI, mono-tft wrapper) +# - External SPI flash footprint present but device is not identified/enabled +# - No buzzer/audio hardware + +# Board identification (matches Arduino MeshCore variant name) +CONFIG_ZEPHCORE_BOARD_NAME="Heltec T096" + +# Device Information Service model name +CONFIG_BT_DIS_MODEL_NUMBER_STR="Heltec Mesh Node T096" + +# SoftDevice firmware ID (s140 v6.1.1) +CONFIG_ZEPHCORE_SD_FWID=0x00B6 + +# KCT8103L PA/FEM. MeshCore's T096 target uses 9 dBm SX1262 input for about +# 22 dBm at the antenna; keep ZephCore's boot default conservative. +CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM=9 + +# Same KCT8103L-class Heltec PA path as the V4.3 boards. +CONFIG_ZEPHCORE_SX126X_HELTEC_REG_PATCH=y + +# ========== RAM budget ========== +# CFB mono-tft framebuffer = 160x80/8 = 1600 bytes, allocated from k_malloc. +# Default heap (2048) is sized tightly for SSD1306 128x64 (1024 bytes). +CONFIG_HEAP_MEM_POOL_SIZE=4096 + +# Single-button board with longpress filter: a "double long-press" is +# physically slower than 500 ms, so widen the confirmation window. +CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 + +# T096 has no buzzer. Keep notification audio disabled and omit the buzzer UI page. +CONFIG_ZEPHCORE_UI_BUZZER=n diff --git a/zephcore/boards/nrf52840/heltec_t096/board.yml b/zephcore/boards/nrf52840/heltec_t096/board.yml new file mode 100644 index 0000000..6a1e001 --- /dev/null +++ b/zephcore/boards/nrf52840/heltec_t096/board.yml @@ -0,0 +1,10 @@ +# Copyright (c) 2026 ZephCore +# +# SPDX-License-Identifier: MIT + +board: + name: heltec_t096 + full_name: Heltec Mesh Node T096 + vendor: heltec + socs: + - name: nrf52840 diff --git a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840-pinctrl.dtsi b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840-pinctrl.dtsi new file mode 100644 index 0000000..530c598 --- /dev/null +++ b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840-pinctrl.dtsi @@ -0,0 +1,83 @@ +/* + * Heltec Mesh Node T096 pin control definitions + * Copyright (c) 2026 ZephCore + * + * SPDX-License-Identifier: MIT + * + * Sources: + * - MeshCore variants/heltec_t096/variant.h and platformio.ini + * - Official Heltec Mesh_Node_T096_V0.2 schematic + * + * Confirmed T096 mappings: + * - SPI2 (SX1262 LoRa): SCK=P1.08, MOSI=P0.11, MISO=P0.14 + * - SPI3 (ST7735S display): SCK=P0.20, MOSI=P0.17 + * - I2C0: SDA=P0.07, SCL=P0.08 + * - UART0 (UC6580 GNSS): TX=P0.25 -> GNSS_RX, RX=P0.23 <- GNSS_TX + */ + +&pinctrl { + spi2_default: spi2_default { + group1 { + psels = , + , + ; + }; + }; + + spi2_sleep: spi2_sleep { + group1 { + psels = , + , + ; + low-power-enable; + }; + }; + + spi3_default: spi3_default { + group1 { + psels = , + ; + }; + }; + + spi3_sleep: spi3_sleep { + group1 { + psels = , + ; + low-power-enable; + }; + }; + + i2c0_default: i2c0_default { + group1 { + psels = , + ; + }; + }; + + i2c0_sleep: i2c0_sleep { + group1 { + psels = , + ; + low-power-enable; + }; + }; + + uart0_default: uart0_default { + group1 { + psels = ; + }; + group2 { + psels = ; + bias-pull-up; + }; + }; + + uart0_sleep: uart0_sleep { + group1 { + psels = , + ; + low-power-enable; + }; + }; +}; diff --git a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts new file mode 100644 index 0000000..730a5e8 --- /dev/null +++ b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts @@ -0,0 +1,334 @@ +/* + * Heltec Mesh Node T096 - nRF52840 + SX1262 + UC6580 GNSS + * Copyright (c) 2026 ZephCore + * + * SPDX-License-Identifier: MIT + * + * Pin mappings are verified against the official Heltec + * Mesh_Node_T096_V0.2 schematic. MeshCore's heltec_t096 variant was used as + * a comparison source; the schematic is the source of truth for implemented + * nets in this DTS. + */ + +/dts-v1/; +#include +#include +#include +#include +#include "heltec_t096_nrf52840-pinctrl.dtsi" + +/ { + model = "Heltec Mesh Node T096"; + compatible = "heltec,t096"; + + chosen { + zephyr,sram = &sram0; + zephyr,flash = &flash0; + zephyr,code-partition = &code_partition; + zephyr,console = &cdc_acm_uart; + zephyr,shell-uart = &cdc_acm_uart; + zephyr,display = &mono_tft; + zephcore,gps-power = &gps_power; + }; + + aliases { + led0 = &led_white; + lora-tx-led = &led_white; + sw0 = &user_button; + lora0 = &lora; + watchdog0 = &wdt0; + gps-reset = &gps_reset_pin; + display0 = &tft; + }; + + leds { + compatible = "gpio-leds"; + + /* Schematic net LED: P0.28 -> R25 -> LED2 -> GND, active-HIGH. */ + led_white: led_0 { + gpios = <&gpio0 28 GPIO_ACTIVE_HIGH>; + label = "White LED"; + }; + }; + + buttons: buttons { + compatible = "gpio-keys"; + + /* Schematic net Button: P1.10, switch to GND. */ + user_button: button_0 { + gpios = <&gpio1 10 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>; + zephyr,code = ; + label = "User Button"; + }; + }; + + user_btn_longpress: user_btn_longpress { + compatible = "zephyr,input-longpress"; + input = <&buttons>; + input-codes = ; + short-codes = ; + long-codes = ; + long-delay-ms = <1000>; + }; + + user_btn_multitap { + compatible = "zephcore,input-multi-tap"; + input-codes = ; + tap-codes = ; + tap-delay-ms = <400>; + }; + + /* + * Schematic net VGNSS_CTRL: P0.06 controls the VGNSS_3V3 rail. + * MeshCore defines PIN_GPS_EN_ACTIVE=LOW for this net. + */ + gps_power: gps-power { + compatible = "regulator-fixed"; + regulator-name = "gps-power"; + enable-gpios = <&gpio0 6 GPIO_ACTIVE_LOW>; + regulator-boot-on; + startup-delay-us = <10000>; + }; + + /* Schematic net GNSS_RST: P1.14 -> UC6580 RESETN, active-LOW. */ + gps_rst: gps-reset { + compatible = "gpio-leds"; + gps_reset_pin: gps_reset { + gpios = <&gpio1 14 GPIO_ACTIVE_LOW>; + label = "GNSS Reset"; + }; + }; + + /* Schematic net VFEM_Ctrl: P0.30 enables U5, producing the Vfem rail. */ + vfem_enable: vfem-enable { + compatible = "regulator-fixed"; + regulator-name = "vfem-enable"; + enable-gpios = <&gpio0 30 GPIO_ACTIVE_HIGH>; + regulator-boot-on; + startup-delay-us = <1000>; + }; + + /* Schematic net ADC_Ctrl: P1.15 enables the VBAT divider. */ + vbat_enable: vbat-enable { + compatible = "regulator-fixed"; + regulator-name = "vbat-enable"; + enable-gpios = <&gpio1 15 GPIO_ACTIVE_HIGH>; + }; + + /* + * Schematic net Vext_Ctrl: P0.26 enables the switched peripheral rail + * that MeshCore's T096 variant claims before ST7735 display init. + */ + tft_pwr_enable: tft-pwr-enable { + compatible = "regulator-fixed"; + regulator-name = "tft-pwr-enable"; + enable-gpios = <&gpio0 26 GPIO_ACTIVE_HIGH>; + regulator-boot-on; + startup-delay-us = <10000>; + }; + + /* + * Schematic/datasheet display LED net LEDA: P1.12. MeshCore defines + * PIN_TFT_LEDA_CTL_ACTIVE=LOW for the T096 backlight control. + */ + disp_pwr_enable: disp-pwr-enable { + compatible = "regulator-fixed"; + regulator-name = "disp-pwr-enable"; + enable-gpios = <&gpio1 12 GPIO_ACTIVE_LOW>; + }; + + /* + * Monochrome wrapper around the ST7735S-compatible TFT. + * Reports PIXEL_FORMAT_MONO01 to CFB and converts rows to RGB565. + */ + mono_tft: mono-tft { + compatible = "zephcore,mono-tft"; + tft-dev = <&tft>; + width = <160>; + height = <80>; + }; + + /* + * MIPI-DBI SPI bus for the ST7735S 0.96" 160x80 TFT. + * T096 schematic nets: + * - RS -> P0.15, data/command + * - RESET -> P0.13, active-LOW reset + * - SDIN -> P0.17, SPI MOSI + * - SCLK -> P0.20, SPI SCK + * - CS -> P0.22, SPI chip-select + */ + mipi_dbi { + compatible = "zephyr,mipi-dbi-spi"; + spi-dev = <&spi3>; + dc-gpios = <&gpio0 15 GPIO_ACTIVE_HIGH>; + reset-gpios = <&gpio0 13 GPIO_ACTIVE_LOW>; + write-only; + #address-cells = <1>; + #size-cells = <0>; + + tft: st7735s@0 { + compatible = "sitronix,st7735r"; + mipi-max-frequency = <20000000>; + mipi-mode = "MIPI_DBI_MODE_SPI_4WIRE"; + reg = <0>; + width = <160>; + height = <80>; + inversion-on; + x-offset = <1>; + y-offset = <26>; + madctl = <0xa8>; + colmod = <0x05>; + invctr = <7>; + vmctr1 = <0x0e>; + pwctr1 = [a2 02 84]; + pwctr2 = [c1]; + pwctr3 = [0a 00]; + pwctr4 = [8a 2a]; + pwctr5 = [8a ee]; + frmctr1 = [01 26 2e]; + frmctr2 = [01 26 2e]; + frmctr3 = [01 26 2e 01 26 2e]; + gamctrp1 = [0f 1a 0f 18 2f 28 20 22 1f 1b 23 37 00 07 02 10]; + gamctrn1 = [0f 1b 0f 17 33 2c 29 2e 30 2e 30 3b 00 07 03 10]; + caset = [00 01 00 a0]; + raset = [00 1a 00 69]; + }; + }; + + zephyr,user { + io-channels = <&adc 1>; + /* + * Schematic: ADC_IN on P0.03/AIN1, divider R22=390K high side + * and R23=100K low side -> 4.9:1. With ADC_GAIN_1_6 and + * internal 0.6V ref, full-scale is 3600 mV. + */ + vbat-mv-multiplier = <17728>; + }; +}; + +®0 { + status = "okay"; +}; + +®1 { + regulator-initial-mode = ; +}; + +&adc { + status = "okay"; + #address-cells = <1>; + #size-cells = <0>; + + channel@1 { + reg = <1>; + zephyr,gain = "ADC_GAIN_1_6"; + zephyr,reference = "ADC_REF_INTERNAL"; + zephyr,acquisition-time = ; + zephyr,input-positive = ; + zephyr,resolution = <12>; + }; +}; + +&gpiote { + status = "okay"; +}; + +&gpio0 { + status = "okay"; +}; + +&gpio1 { + status = "okay"; +}; + +&i2c0 { + compatible = "nordic,nrf-twim"; + status = "okay"; + clock-frequency = ; + pinctrl-0 = <&i2c0_default>; + pinctrl-1 = <&i2c0_sleep>; + pinctrl-names = "default", "sleep"; + + #include "../../common/sensors-i2c.dtsi" +}; + +&uart0 { + compatible = "nordic,nrf-uarte"; + status = "okay"; + current-speed = <115200>; + pinctrl-0 = <&uart0_default>; + pinctrl-1 = <&uart0_sleep>; + pinctrl-names = "default", "sleep"; + + gnss: gnss { + compatible = "gnss-nmea-generic"; + }; +}; + +/* SPI3 - ST7735S 0.96" 160x80 TFT display */ +&spi3 { + compatible = "nordic,nrf-spim"; + status = "okay"; + pinctrl-0 = <&spi3_default>; + pinctrl-1 = <&spi3_sleep>; + pinctrl-names = "default", "sleep"; + cs-gpios = <&gpio0 22 GPIO_ACTIVE_LOW>; /* Schematic net CS */ +}; + +/* SPI2 - SX1262 LoRa radio */ +&spi2 { + compatible = "nordic,nrf-spim"; + status = "okay"; + cs-gpios = <&gpio0 5 GPIO_ACTIVE_LOW>; /* Schematic net LoRa_NSS */ + pinctrl-0 = <&spi2_default>; + pinctrl-1 = <&spi2_sleep>; + pinctrl-names = "default", "sleep"; + + lora: lora@0 { + compatible = "semtech,sx1262"; + reg = <0>; + spi-max-frequency = <8000000>; + + reset-gpios = <&gpio0 16 GPIO_ACTIVE_LOW>; /* LoRa_RST */ + busy-gpios = <&gpio0 19 GPIO_ACTIVE_HIGH>; /* LoRa_BUSY */ + dio1-gpios = <&gpio0 21 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; /* DIO1 */ + + /* + * KCT8103L PA/FEM control: + * - PA_CSD P0.12 -> CSD, active-HIGH FEM enable. + * - PA_CTX P1.09 -> CTX, HIGH for TX in the existing ZephCore + * SX1262 tx-enable model. MeshCore uses the same net for + * TX/LNA-bypass control. + * - PA_CPS is not an MCU GPIO on T096; it is the SX1262 DIO2 + * net into the KCT8103L CPS pin, so use dio2-tx-enable. + */ + antenna-enable-gpios = <&gpio0 12 GPIO_ACTIVE_HIGH>; /* PA_CSD */ + tx-enable-gpios = <&gpio1 9 GPIO_ACTIVE_HIGH>; /* PA_CTX */ + dio2-tx-enable; /* PA_CPS */ + + dio3-tcxo-voltage = ; + tcxo-power-startup-delay-ms = <5>; + rx-boosted; + }; +}; + +/* + * External SPI flash footprint is present in the schematic (F_SPI_* nets), + * but the memory device and Zephyr-compatible parameters are not confirmed. + */ +&qspi { + status = "disabled"; +}; + +zephyr_udc0: &usbd { + compatible = "nordic,nrf-usbd"; + status = "okay"; + + cdc_acm_uart: cdc_acm_uart { + compatible = "zephyr,cdc-acm-uart"; + }; +}; + +/* SoftDevice s140 v6.1.1 flash layout (app@0x26000, bootloader@0xF4000) */ +#include "../../common/nrf52_partitions_sdv6.dtsi" +#include "../../common/nrf52_wakeup.dtsi" diff --git a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.yaml b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.yaml new file mode 100644 index 0000000..7126295 --- /dev/null +++ b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.yaml @@ -0,0 +1,20 @@ +identifier: heltec_t096/nrf52840 +name: Heltec Mesh Node T096 +type: mcu +arch: arm +toolchain: + - zephyr + - gnuarmemb + - xtools +ram: 256 +flash: 1024 +supported: + - adc + - ble + - gpio + - i2c + - spi + - usb_device + - lora + - gnss +vendor: heltec diff --git a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840_defconfig b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840_defconfig new file mode 100644 index 0000000..98c300e --- /dev/null +++ b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840_defconfig @@ -0,0 +1,21 @@ +# Copyright (c) 2026 ZephCore +# SPDX-License-Identifier: MIT + +# Enable MPU +CONFIG_ARM_MPU=y + +# Enable hardware stack protection +CONFIG_HW_STACK_PROTECTION=y + +# Enable GPIO +CONFIG_GPIO=y + +# Enable UART driver +CONFIG_SERIAL=y + +# Enable console +CONFIG_CONSOLE=y + +# Build UF2 by default +CONFIG_BUILD_OUTPUT_UF2=y +CONFIG_USE_DT_CODE_PARTITION=y diff --git a/zephcore/boards/supported_boards.md b/zephcore/boards/supported_boards.md index 06f958b..53931ce 100644 --- a/zephcore/boards/supported_boards.md +++ b/zephcore/boards/supported_boards.md @@ -19,12 +19,15 @@ xiao_nrf52840 lilygo_techo promicro_sx1262 heltec_t114 +heltec_t096 gat562_30s ``` > **RAK WisMesh Pocket** (WisBlock pocket): use `-b rak4631` — same board string and firmware as **RAK4631**. > > **Heltec T114** screenless build: append `boards/nrf52840/heltec_t114/no_display.conf` to `EXTRA_CONF_FILE` for units without the TFT module. +> +> **Heltec Mesh Node T096** (`heltec_t096`): nRF52840 with SX1262 + KCT8103L PA/FEM, UC6580 GNSS, and ST7735S 160x80 TFT companion display. The external SPI flash footprint is documented in the board notes but left disabled until the device parameters are confirmed. ## ESP32 diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 02ce179..a76ca77 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -489,12 +489,26 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { strcpy(reply, "> strict"); } - } else if (memcmp(config, "tx", 2) == 0 && (config[2] == 0 || config[2] == ' ')) { + } else if (strcmp(config, "tx apc") == 0) { if (_callbacks->isAPCEnabled()) { int8_t apc = _callbacks->getAPCReduction(); float margin = _callbacks->getAPCMargin(); int effective = (int)_prefs->tx_power_dbm - (int)apc; - snprintf(reply, CLI_REPLY_SIZE, "> %ddBm (max=%d apc=-%d margin=%.1f target=%d)", + snprintf(reply, CLI_REPLY_SIZE, + "> apc=on effective=%ddBm max=%d reduction=%d margin=%.1f target=%d", + effective, (int)_prefs->tx_power_dbm, (int)apc, (double)margin, + (int)_callbacks->getAPCTargetMargin()); + } else { + snprintf(reply, CLI_REPLY_SIZE, "> apc=off max=%ddBm target=%d", + (int)_prefs->tx_power_dbm, (int)_callbacks->getAPCTargetMargin()); + } + } else if (strcmp(config, "tx") == 0) { + if (_callbacks->isAPCEnabled()) { + int8_t apc = _callbacks->getAPCReduction(); + float margin = _callbacks->getAPCMargin(); + int effective = (int)_prefs->tx_power_dbm - (int)apc; + snprintf(reply, CLI_REPLY_SIZE, + "> %ddBm (apc=on max=%d reduction=%d margin=%.1f target=%d)", effective, (int)_prefs->tx_power_dbm, (int)apc, (double)margin, (int)_callbacks->getAPCTargetMargin()); } else { @@ -784,21 +798,23 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch strcpy(reply, "Error: range 6-30 dB"); } } else if (memcmp(config, "tx ", 3) == 0) { - if (memcmp(&config[3], "apc", 3) == 0) { + if (strcmp(&config[3], "apc") == 0) { _prefs->apc_enabled = 1; _callbacks->setAPCEnabled(true); savePrefs(); snprintf(reply, CLI_REPLY_SIZE, "OK - tx power=%d dBm (apc=on)", (int)_prefs->tx_power_dbm); } else { - int val = atoi(&config[3]); + char *end = nullptr; + long parsed = strtol(&config[3], &end, 10); int max_tx = 30; #ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM max_tx = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; #endif - if (val < -9 || val > max_tx) { + if (end == &config[3] || *end != '\0' || parsed < -9 || parsed > max_tx) { snprintf(reply, CLI_REPLY_SIZE, "Error: range -9 to %d dBm, or 'apc'", max_tx); } else { + int val = (int)parsed; _prefs->apc_enabled = 0; _prefs->tx_power_dbm = (int8_t)val; savePrefs(); diff --git a/zephcore/helpers/ui-button/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c index 78c4afc..6b0c336 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -56,6 +56,25 @@ LOG_MODULE_REGISTER(ui_pages, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); #define CONTENT_Y (SEP_Y + 4) /* below separator */ #define MAX_CHARS (DISP_W / (FONT_W ? FONT_W : 1)) +/* Compact RGB565 palette for small color TFTs. These are used only through + * mc_display_has_color(); monochrome displays keep the existing CFB path. */ +#define UI_COLOR_HEADER_BG 0x018c /* deep teal/blue */ +#define UI_COLOR_TITLE 0x5dff /* readable cyan */ +#define UI_COLOR_LABEL 0x8c71 /* muted blue-gray */ +#define UI_COLOR_VALUE MC_COLOR_WHITE +#define UI_COLOR_OK 0x07e0 +#define UI_COLOR_ACTIVE 0x05ff +#define UI_COLOR_WARN 0xffa0 +#define UI_COLOR_ERROR MC_COLOR_RED +#define UI_COLOR_DIM 0x4208 +#define UI_COLOR_DISABLED MC_COLOR_GRAY +#define UI_COLOR_TX MC_COLOR_ORANGE +#define UI_COLOR_RX UI_COLOR_ACTIVE + +#define COLOR_FONT_W 6 +#define COLOR_FONT_H 8 +#define ACTIVITY_GRAPH_SAMPLES 16 + /* Vertically center `total` rows of text within the content area and * return the y pixel position for row `idx` (0-based). This replaces * the old hand-picked offsets (CONTENT_Y+16, +28, +34, ...) that were @@ -76,6 +95,14 @@ static inline int centered_row(int idx, int total) /* ========== Global State ========== */ static struct ui_state state; +static uint8_t activity_rx[ACTIVITY_GRAPH_SAMPLES]; +static uint8_t activity_tx[ACTIVITY_GRAPH_SAMPLES]; +static uint8_t activity_head; +static bool activity_initialized; +static uint32_t activity_last_rx; +static uint32_t activity_last_tx; +static uint32_t activity_last_sample_ms; + /* ========== Active Pages (role-dependent) ========== */ /* Repeater: minimal pages — status, radio, shutdown. * Companion: full page set matching Arduino UI. @@ -95,7 +122,9 @@ static const enum ui_page active_pages[] = { UI_PAGE_BLUETOOTH, UI_PAGE_ADVERT, UI_PAGE_GPS, +#ifdef CONFIG_ZEPHCORE_UI_BUZZER UI_PAGE_BUZZER, +#endif UI_PAGE_LEDS, UI_PAGE_SENSORS, UI_PAGE_OFFGRID, @@ -139,6 +168,7 @@ static char time_source_tag(enum time_sync_source src) static void render_top_bar(void) { + bool color = mc_display_has_color(); /* Right side: "HH:MM XX%" — clock (with 1-char source tag) then * battery, right-aligned. Built first so the node name can be clipped * to the space that remains on the left, preventing overlap. */ @@ -171,15 +201,33 @@ static void render_top_bar(void) int right_x = DISP_W; if (right[0]) { - right_x = DISP_W - ((int)strlen(right) * FONT_W); - mc_display_text(right_x, TOP_BAR_Y, right, false); + int fw = color ? COLOR_FONT_W : FONT_W; + uint16_t batt_color = UI_COLOR_VALUE; + + if (state.battery_mv > 0) { + uint8_t pct = state.battery_pct; + + if (pct == 0) { + pct = calc_battery_pct(state.battery_mv); + } + batt_color = (pct <= 15) ? UI_COLOR_ERROR : + (pct <= 30) ? UI_COLOR_WARN : UI_COLOR_OK; + } + + right_x = DISP_W - ((int)strlen(right) * fw); + if (color) { + mc_display_color_text(right_x, TOP_BAR_Y, right, batt_color); + } else { + mc_display_text(right_x, TOP_BAR_Y, right, false); + } } /* Node name on the left, clipped to the gap before the right block * (one char-width of padding so it never touches the clock). */ if (state.node_name[0]) { char name[16]; - int max_chars = (right_x - FONT_W) / FONT_W; + int fw = color ? COLOR_FONT_W : FONT_W; + int max_chars = (right_x - fw) / fw; if (max_chars > (int)sizeof(name) - 1) { max_chars = sizeof(name) - 1; @@ -189,7 +237,11 @@ static void render_top_bar(void) } strncpy(name, state.node_name, max_chars); name[max_chars] = '\0'; - mc_display_text(0, TOP_BAR_Y, name, false); + if (color) { + mc_display_color_text(0, TOP_BAR_Y, name, UI_COLOR_TITLE); + } else { + mc_display_text(0, TOP_BAR_Y, name, false); + } } } @@ -200,6 +252,7 @@ static void render_page_indicator(void) /* Centered dots matching Arduino layout: * Each dot spaced 10px apart, centered on screen. * Current page = filled 3x3 block, others = single pixel. */ + bool color = mc_display_has_color(); int total = ACTIVE_PAGE_COUNT; int dot_spacing = 10; int total_width = (total - 1) * dot_spacing; @@ -210,15 +263,29 @@ static void render_page_indicator(void) if (i == current_page_idx) { /* Current page: filled 3x3 block */ - mc_display_fill_rect(x - 1, DOTS_Y, 3, 3); + if (color) { + mc_display_color_fill_rect(x - 2, DOTS_Y - 1, 5, 5, + UI_COLOR_ACTIVE); + } else { + mc_display_fill_rect(x - 1, DOTS_Y, 3, 3); + } } else { /* Other pages: single pixel */ - mc_display_fill_rect(x, DOTS_Y + 1, 1, 1); + if (color) { + mc_display_color_fill_rect(x, DOTS_Y + 1, 1, 1, + UI_COLOR_DIM); + } else { + mc_display_fill_rect(x, DOTS_Y + 1, 1, 1); + } } } /* Separator line below dots */ - mc_display_hline(0, SEP_Y, DISP_W); + if (color) { + mc_display_color_fill_rect(0, SEP_Y, DISP_W, 1, UI_COLOR_DIM); + } else { + mc_display_hline(0, SEP_Y, DISP_W); + } } /* ========== Helper: Center text ========== */ @@ -234,6 +301,181 @@ static void draw_centered(int y, const char *text) mc_display_text(x, y, text, false); } +static void draw_centered_color(int y, const char *text, uint16_t color) +{ + int len = (int)strlen(text); + int x = (DISP_W - (len * COLOR_FONT_W)) / 2; + + if (x < 0) { + x = 0; + } + mc_display_color_text(x, y, text, color); +} + +static void draw_color_segments_at(int x, int y, const char *label, + const char *value, uint16_t value_color) +{ + mc_display_color_text(x, y, label, UI_COLOR_LABEL); + x += (int)strlen(label) * 6; + mc_display_color_text(x, y, value, value_color); +} + +static void draw_color_segments(int y, const char *label, + const char *value, uint16_t value_color) +{ + draw_color_segments_at(0, y, label, value, value_color); +} + +static int color_text_width(const char *text) +{ + return (int)strlen(text) * COLOR_FONT_W; +} + +static void draw_badge(int x, int y, const char *text, uint16_t color) +{ + int w = color_text_width(text) + 4; + + mc_display_color_fill_rect(x, y - 1, w, COLOR_FONT_H + 2, UI_COLOR_DIM); + mc_display_color_text(x + 2, y, text, color); +} + +static void draw_metric_bar(int x, int y, int w, int h, int value, + int max_value, uint16_t color) +{ + if (w <= 0 || h <= 0) { + return; + } + if (max_value <= 0) { + max_value = 1; + } + if (value < 0) { + value = 0; + } + if (value > max_value) { + value = max_value; + } + + int filled = (w * value) / max_value; + + mc_display_color_fill_rect(x, y, w, h, UI_COLOR_DIM); + if (filled > 0) { + mc_display_color_fill_rect(x, y, filled, h, color); + } +} + +static void fmt_compact_count(char *buf, size_t len, uint32_t value) +{ + if (value >= 1000000U) { + snprintf(buf, len, "%luM", (unsigned long)(value / 1000000U)); + } else if (value >= 1000U) { + snprintf(buf, len, "%luk", (unsigned long)(value / 1000U)); + } else { + snprintf(buf, len, "%lu", (unsigned long)value); + } +} + +static bool use_compact_color_home(void) +{ + return mc_display_has_color() && DISP_W <= 180 && DISP_H <= 100; +} + +static uint8_t clamp_activity_delta(uint32_t delta) +{ + return (delta > 15U) ? 15U : (uint8_t)delta; +} + +static void sample_activity_graph(void) +{ + uint32_t now = k_uptime_get_32(); + uint32_t rx = state.lora_packets_rx; + uint32_t tx = state.lora_packets_tx; + uint32_t rx_delta = 0; + uint32_t tx_delta = 0; + + if (!activity_initialized) { + activity_last_rx = rx; + activity_last_tx = tx; + activity_last_sample_ms = now; + activity_initialized = true; + return; + } + + if (rx >= activity_last_rx) { + rx_delta = rx - activity_last_rx; + } + if (tx >= activity_last_tx) { + tx_delta = tx - activity_last_tx; + } + + /* Age the graph slowly during quiet periods, but do not shift it on + * every incidental repaint. Counter wrap/reset simply records zero. */ + if (rx_delta == 0 && tx_delta == 0 && + (now - activity_last_sample_ms) < 5000U) { + return; + } + + activity_last_rx = rx; + activity_last_tx = tx; + activity_last_sample_ms = now; + activity_head = (activity_head + 1U) % ACTIVITY_GRAPH_SAMPLES; + activity_rx[activity_head] = clamp_activity_delta(rx_delta); + activity_tx[activity_head] = clamp_activity_delta(tx_delta); +} + +static void draw_activity_graph(int x, int y, int w, int h) +{ + uint8_t max_v = 0; + int mid = y + h / 2; + int col_w = w / ACTIVITY_GRAPH_SAMPLES; + + if (col_w < 1) { + col_w = 1; + } + + sample_activity_graph(); + + mc_display_color_fill_rect(x, y, w, h, MC_COLOR_BLACK); + mc_display_color_fill_rect(x, mid, w, 1, UI_COLOR_DIM); + + for (int i = 0; i < ACTIVITY_GRAPH_SAMPLES; i++) { + uint8_t idx = (uint8_t)((activity_head + 1U + i) % + ACTIVITY_GRAPH_SAMPLES); + + if (activity_rx[idx] > max_v) { + max_v = activity_rx[idx]; + } + if (activity_tx[idx] > max_v) { + max_v = activity_tx[idx]; + } + } + if (max_v == 0) { + mc_display_color_text(x + w - 30, y + 1, "RX", UI_COLOR_RX); + mc_display_color_text(x + w - 14, y + 1, "TX", UI_COLOR_TX); + return; + } + + for (int i = 0; i < ACTIVITY_GRAPH_SAMPLES; i++) { + uint8_t idx = (uint8_t)((activity_head + 1U + i) % + ACTIVITY_GRAPH_SAMPLES); + int bx = x + i * col_w; + int draw_w = (col_w > 1) ? col_w - 1 : 1; + int tx_h = ((h / 2 - 1) * activity_tx[idx]) / max_v; + int rx_h = ((h / 2 - 1) * activity_rx[idx]) / max_v; + + if (tx_h > 0) { + mc_display_color_fill_rect(bx, mid - tx_h, draw_w, tx_h, + UI_COLOR_TX); + } + if (rx_h > 0) { + mc_display_color_fill_rect(bx, mid + 1, draw_w, rx_h, + UI_COLOR_RX); + } + } + + mc_display_color_text(x + w - 30, y + 1, "RX", UI_COLOR_RX); + mc_display_color_text(x + w - 14, y + 1, "TX", UI_COLOR_TX); +} + /* ========== Page Renderers ========== */ static void render_messages(void) @@ -241,6 +483,73 @@ static void render_messages(void) /* 3 centered rows: msg count, BLE status, offgrid status */ char buf[24]; + if (use_compact_color_home()) { + int y = CONTENT_Y; + uint16_t ble_color = state.ble_connected ? UI_COLOR_OK : UI_COLOR_WARN; + const char *ble = state.ble_connected ? "BLE OK" : "BLE ADV"; + int graph_h = 16; + int graph_y = DISP_H - graph_h - 2; + + draw_badge(0, y, ble, ble_color); + draw_badge(DISP_W - color_text_width(state.offgrid_enabled ? "GRID" : "LOCAL") - 4, + y, state.offgrid_enabled ? "GRID" : "LOCAL", + state.offgrid_enabled ? UI_COLOR_ACTIVE : UI_COLOR_DISABLED); + y += LINE_H + 2; + + mc_display_color_text(0, y, "MESSAGES", UI_COLOR_LABEL); + snprintf(buf, sizeof(buf), "%u", state.msg_count); + mc_display_color_text(DISP_W - color_text_width(buf), y, buf, + state.msg_count ? UI_COLOR_WARN : UI_COLOR_VALUE); + y += LINE_H; + + if (state.ble_connected) { + draw_centered_color(y, "Connected", UI_COLOR_OK); + } else { + mc_display_color_text(34, y, "Waiting for app", UI_COLOR_WARN); + } + y += LINE_H; + + snprintf(buf, sizeof(buf), "Offgrid %s", + state.offgrid_enabled ? "on" : "off"); + draw_centered_color(y, buf, + state.offgrid_enabled ? UI_COLOR_ACTIVE + : UI_COLOR_DISABLED); + draw_activity_graph(2, graph_y, DISP_W - 4, graph_h); + return; + } + + if (mc_display_has_color()) { + int y = CONTENT_Y; + uint16_t ble_color = state.ble_connected ? UI_COLOR_OK : UI_COLOR_WARN; + const char *ble = state.ble_connected ? "BLE OK" : "BLE ADV"; + + draw_badge(0, y, ble, ble_color); + draw_badge(DISP_W - color_text_width(state.offgrid_enabled ? "GRID" : "LOCAL") - 4, + y, state.offgrid_enabled ? "GRID" : "LOCAL", + state.offgrid_enabled ? UI_COLOR_ACTIVE : UI_COLOR_DISABLED); + y += LINE_H + 2; + + mc_display_color_text(0, y, "MESSAGES", UI_COLOR_LABEL); + snprintf(buf, sizeof(buf), "%u", state.msg_count); + mc_display_color_text(DISP_W - color_text_width(buf), y, buf, + state.msg_count ? UI_COLOR_WARN : UI_COLOR_VALUE); + y += LINE_H; + + if (state.ble_connected) { + draw_centered_color(y, "Connected", UI_COLOR_OK); + } else { + draw_centered_color(y, "Waiting for app", UI_COLOR_WARN); + } + y += LINE_H; + + snprintf(buf, sizeof(buf), "Offgrid %s", + state.offgrid_enabled ? "on" : "off"); + draw_centered_color(y, buf, + state.offgrid_enabled ? UI_COLOR_ACTIVE + : UI_COLOR_DISABLED); + return; + } + snprintf(buf, sizeof(buf), "MSG: %u", state.msg_count); draw_centered(centered_row(0, 3), buf); @@ -258,7 +567,12 @@ static void render_messages(void) static void render_recent(void) { if (state.recent_count == 0) { - draw_centered(centered_row(0, 1), "(no contacts heard)"); + if (mc_display_has_color()) { + draw_centered_color(centered_row(0, 1), "no contacts heard", + UI_COLOR_DISABLED); + } else { + draw_centered(centered_row(0, 1), "(no contacts heard)"); + } return; } @@ -287,54 +601,173 @@ static void render_recent(void) snprintf(buf, sizeof(buf), "%-13s %s", state.recent[i].name, time_str); - mc_display_text(0, y, buf, false); + if (mc_display_has_color()) { + uint16_t age_color = (age_s < 300) ? UI_COLOR_OK : + (age_s < 3600) ? UI_COLOR_WARN + : UI_COLOR_DISABLED; + + mc_display_color_text(0, y, state.recent[i].name, + UI_COLOR_VALUE); + mc_display_color_text(DISP_W - color_text_width(time_str), + y, time_str, age_color); + } else { + mc_display_text(0, y, buf, false); + } y += LINE_H; } } static void render_radio(void) { - char buf[28]; + char buf[32]; int y = CONTENT_Y; - - /* Line 1: FQ, SF, BW */ uint32_t freq_mhz = state.lora_freq_hz / 1000000; uint32_t freq_frac = (state.lora_freq_hz % 1000000 + 500) / 1000; uint16_t bw_int = state.lora_bw_khz_x10 / 10; uint16_t bw_frac = state.lora_bw_khz_x10 % 10; + const char *packet_state = state.lora_tx_active ? "TX" : + (state.lora_in_rx ? "RX" : + (state.lora_radio_ready ? "RDY" : "WAIT")); + const char *rx_mode = state.lora_rx_duty_cycle ? "DC" : "CONT"; + uint16_t warn_color = (state.lora_apc_enabled && state.lora_apc_reduction > 0) + ? UI_COLOR_WARN : UI_COLOR_OK; + + if (mc_display_has_color()) { + uint16_t state_color = state.lora_tx_active ? UI_COLOR_WARN : + state.lora_in_rx ? UI_COLOR_ACTIVE : + state.lora_radio_ready ? UI_COLOR_OK + : UI_COLOR_DISABLED; + uint16_t tx_color = (state.lora_apc_enabled && + state.lora_apc_reduction > 0) + ? UI_COLOR_WARN : UI_COLOR_OK; + int max_tx = state.lora_tx_power > 0 ? state.lora_tx_power : 22; + int eff_tx = state.lora_effective_tx_power > 0 + ? state.lora_effective_tx_power : state.lora_tx_power; + int badge_x; + + mc_display_color_fill_rect(0, y - 1, DISP_W, COLOR_FONT_H + 2, + UI_COLOR_HEADER_BG); + mc_display_color_text(2, y, "RADIO", UI_COLOR_TITLE); + badge_x = DISP_W - color_text_width(rx_mode) - 6; + draw_badge(badge_x, y, rx_mode, + state.lora_rx_duty_cycle ? UI_COLOR_WARN : UI_COLOR_ACTIVE); + badge_x -= color_text_width(packet_state) + 8; + draw_badge(badge_x, y, packet_state, state_color); + y += LINE_H; + + if (bw_frac) { + snprintf(buf, sizeof(buf), "%u.%03u BW%u.%u", + freq_mhz, freq_frac, bw_int, bw_frac); + } else { + snprintf(buf, sizeof(buf), "%u.%03u BW%u", + freq_mhz, freq_frac, bw_int); + } + draw_color_segments(y, "RF ", buf, UI_COLOR_VALUE); + y += LINE_H; + + snprintf(buf, sizeof(buf), "SF%u CR%u SW%02X P%u", + state.lora_sf, state.lora_cr, state.lora_sync_word, + state.lora_preamble_len); + draw_color_segments(y, "LoRa ", buf, UI_COLOR_VALUE); + y += LINE_H; + + if (state.lora_apc_enabled) { + snprintf(buf, sizeof(buf), "%d/%ddBm", + state.lora_effective_tx_power, state.lora_tx_power); + } else { + snprintf(buf, sizeof(buf), "%ddBm", state.lora_tx_power); + } + draw_color_segments(y, "TX ", buf, tx_color); + draw_metric_bar(DISP_W - 50, y + 2, 48, 5, eff_tx, max_tx, tx_color); + y += LINE_H; + + snprintf(buf, sizeof(buf), "red %d M%d.%d T%u", + state.lora_apc_reduction, + state.lora_apc_margin_x10 / 10, + abs(state.lora_apc_margin_x10 % 10), + state.lora_apc_target_margin); + draw_color_segments(y, "APC ", buf, warn_color); + draw_badge(DISP_W - color_text_width("APC") - 4, y, "APC", + !state.lora_apc_enabled ? UI_COLOR_DISABLED : + state.lora_apc_reduction > 0 ? UI_COLOR_WARN : UI_COLOR_OK); + y += LINE_H; + + char rx_count[6]; + char tx_count[6]; + char err_count[6]; + + fmt_compact_count(rx_count, sizeof(rx_count), state.lora_packets_rx); + fmt_compact_count(tx_count, sizeof(tx_count), state.lora_packets_tx); + fmt_compact_count(err_count, sizeof(err_count), state.lora_packets_err); + snprintf(buf, sizeof(buf), "NF%d R%s T%s E%s", + state.lora_noise_floor, rx_count, tx_count, err_count); + draw_color_segments(y, "PKT ", buf, + state.lora_packets_err ? UI_COLOR_ERROR + : UI_COLOR_VALUE); + return; + } if (bw_frac) { - snprintf(buf, sizeof(buf), "%u.%03u SF%u BW%u.%u", - freq_mhz, freq_frac, state.lora_sf, bw_int, bw_frac); + snprintf(buf, sizeof(buf), "%u.%03u BW%u.%u", + freq_mhz, freq_frac, bw_int, bw_frac); } else { - snprintf(buf, sizeof(buf), "%u.%03u SF%u BW%u", - freq_mhz, freq_frac, state.lora_sf, bw_int); + snprintf(buf, sizeof(buf), "%u.%03u BW%u", + freq_mhz, freq_frac, bw_int); } mc_display_text(0, y, buf, false); y += LINE_H; - /* Line 2: CR, TX Power */ - snprintf(buf, sizeof(buf), "CR:%u TX:%ddBm", state.lora_cr, state.lora_tx_power); + snprintf(buf, sizeof(buf), "SF%u CR%u SW%02X P%u", + state.lora_sf, state.lora_cr, state.lora_sync_word, + state.lora_preamble_len); mc_display_text(0, y, buf, false); y += LINE_H; - /* Line 3: Noise floor */ - snprintf(buf, sizeof(buf), "Noise: %ddBm", state.lora_noise_floor); + if (state.lora_apc_enabled) { + snprintf(buf, sizeof(buf), "TX:%d/%ddBm APC:on", + state.lora_effective_tx_power, state.lora_tx_power); + } else { + snprintf(buf, sizeof(buf), "TX:%ddBm APC:off", state.lora_tx_power); + } mc_display_text(0, y, buf, false); y += LINE_H; - /* Uptime */ - uint32_t up_s = (uint32_t)(k_uptime_get() / 1000); - uint32_t days = up_s / 86400; - uint32_t hours = (up_s % 86400) / 3600; - uint32_t mins = (up_s % 3600) / 60; + snprintf(buf, sizeof(buf), "R%d M%d.%d T%u %s/%s", + state.lora_apc_reduction, + state.lora_apc_margin_x10 / 10, + abs(state.lora_apc_margin_x10 % 10), + state.lora_apc_target_margin, packet_state, rx_mode); + mc_display_text(0, y, buf, false); + y += LINE_H; - snprintf(buf, sizeof(buf), "Up: %ud %uh %um", days, hours, mins); + snprintf(buf, sizeof(buf), "NF%d R%lu T%lu E%lu", + state.lora_noise_floor, + (unsigned long)state.lora_packets_rx, + (unsigned long)state.lora_packets_tx, + (unsigned long)state.lora_packets_err); mc_display_text(0, y, buf, false); } static void render_bluetooth(void) { + if (mc_display_has_color()) { + int y = CONTENT_Y; + uint16_t color = !state.ble_enabled ? UI_COLOR_DISABLED : + state.ble_connected ? UI_COLOR_OK : UI_COLOR_WARN; + + draw_badge(0, y, "BLE", color); + mc_display_color_text(32, y, + !state.ble_enabled ? "disabled" : + state.ble_connected ? "connected" : "advertising", + color); + y += LINE_H + 4; + draw_centered_color(y, + state.ble_enabled ? "Press to disable" + : "Press to enable", + UI_COLOR_VALUE); + return; + } + if (!state.ble_enabled) { draw_centered(centered_row(0, 2), "BLE: OFF"); draw_centered(centered_row(1, 2), "Press to Enable"); @@ -357,6 +790,25 @@ static void render_advert(void) bool just_sent = (state.advert_sent_time > 0 && (now - state.advert_sent_time) < 2000); + if (mc_display_has_color()) { + int y = CONTENT_Y; + + draw_badge(0, y, "ADV", just_sent ? UI_COLOR_OK : UI_COLOR_ACTIVE); + mc_display_color_text(32, y, "Zero-hop advert", UI_COLOR_VALUE); + y += LINE_H + 2; + + if (just_sent) { + draw_centered_color(y, + state.advert_was_flood ? "Flood sent" : "Sent", + UI_COLOR_OK); + } else { + draw_centered_color(y, "Press to send", UI_COLOR_VALUE); + } + y += LINE_H; + draw_centered_color(y, "2x press: flood", UI_COLOR_LABEL); + return; + } + draw_centered(centered_row(0, 3), "Send Zero-Hop Advert"); if (just_sent) { @@ -388,21 +840,39 @@ static void render_gps(void) { char buf[32]; int y = CONTENT_Y; + bool color = mc_display_has_color(); /* No GPS hardware on this board */ if (!state.gps_available) { - mc_display_text(0, y, "GPS: not detected", false); + if (color) { + draw_badge(0, y, "GPS", UI_COLOR_DISABLED); + mc_display_color_text(32, y, "not detected", UI_COLOR_DISABLED); + } else { + mc_display_text(0, y, "GPS: not detected", false); + } return; } /* GPS enabled/disabled state */ snprintf(buf, sizeof(buf), "GPS: %s", state.gps_enabled ? "on" : "off"); - mc_display_text(0, y, buf, false); + if (color) { + draw_badge(0, y, "GPS", state.gps_enabled ? UI_COLOR_OK + : UI_COLOR_DISABLED); + mc_display_color_text(32, y, state.gps_enabled ? "on" : "off", + state.gps_enabled ? UI_COLOR_OK + : UI_COLOR_DISABLED); + } else { + mc_display_text(0, y, buf, false); + } y += LINE_H; if (!state.gps_enabled) { - draw_centered(y + 8, "Press to Enable"); + if (color) { + draw_centered_color(y + 8, "Press to enable", UI_COLOR_VALUE); + } else { + draw_centered(y + 8, "Press to Enable"); + } return; } @@ -411,7 +881,11 @@ static void render_gps(void) /* ACQUIRING — actively searching for satellites */ snprintf(buf, sizeof(buf), "Searching... sat:%u", state.gps_satellites); - mc_display_text(0, y, buf, false); + if (color) { + draw_color_segments(y, "SAT ", buf, UI_COLOR_WARN); + } else { + mc_display_text(0, y, buf, false); + } y += LINE_H; /* Show last fix age if we have one */ @@ -420,9 +894,17 @@ static void render_gps(void) fmt_duration(tbuf, sizeof(tbuf), state.gps_last_fix_age_s); snprintf(buf, sizeof(buf), "Last fix: %s ago", tbuf); - mc_display_text(0, y, buf, false); + if (color) { + draw_color_segments(y, "FIX ", buf, UI_COLOR_VALUE); + } else { + mc_display_text(0, y, buf, false); + } } else { - mc_display_text(0, y, "No fix yet", false); + if (color) { + mc_display_color_text(0, y, "No fix yet", UI_COLOR_WARN); + } else { + mc_display_text(0, y, "No fix yet", false); + } } } else if (state.gps_state == 1) { /* STANDBY — sleeping between fix cycles */ @@ -431,10 +913,18 @@ static void render_gps(void) fmt_duration(tbuf, sizeof(tbuf), state.gps_last_fix_age_s); snprintf(buf, sizeof(buf), "Last fix: %s ago", tbuf); - mc_display_text(0, y, buf, false); + if (color) { + draw_color_segments(y, "FIX ", buf, UI_COLOR_VALUE); + } else { + mc_display_text(0, y, buf, false); + } y += LINE_H; } else { - mc_display_text(0, y, "No fix yet", false); + if (color) { + mc_display_color_text(0, y, "No fix yet", UI_COLOR_WARN); + } else { + mc_display_text(0, y, "No fix yet", false); + } y += LINE_H; } @@ -443,11 +933,19 @@ static void render_gps(void) fmt_duration(tbuf, sizeof(tbuf), state.gps_next_search_s); snprintf(buf, sizeof(buf), "Next search: %s", tbuf); - mc_display_text(0, y, buf, false); + if (color) { + draw_color_segments(y, "NEXT ", buf, UI_COLOR_LABEL); + } else { + mc_display_text(0, y, buf, false); + } } } else { /* OFF — shouldn't reach here if gps_enabled is true */ - mc_display_text(0, y, "GPS off", false); + if (color) { + mc_display_color_text(0, y, "GPS off", UI_COLOR_DISABLED); + } else { + mc_display_text(0, y, "GPS off", false); + } } } @@ -470,6 +968,21 @@ static void render_leds(void) char buf[24]; int y = CONTENT_Y; + if (mc_display_has_color()) { + draw_badge(0, y, "LED", state.leds_disabled ? UI_COLOR_DISABLED + : UI_COLOR_OK); + mc_display_color_text(32, y, + state.leds_disabled ? "off" : "on", + state.leds_disabled ? UI_COLOR_DISABLED + : UI_COLOR_OK); + y += LINE_H + 4; + draw_centered_color(y, + state.leds_disabled ? "Press to enable" + : "Press to disable", + UI_COLOR_VALUE); + return; + } + snprintf(buf, sizeof(buf), "LEDs: %s", state.leds_disabled ? "off" : "on"); mc_display_text(0, y, buf, false); @@ -483,6 +996,7 @@ static void render_sensors(void) { char buf[24]; int y = CONTENT_Y; + bool color = mc_display_has_color(); /* Lazy read: only fetch sensors when the user is actually looking at * this page. Render is event-driven (schedule_render only on real UI @@ -494,14 +1008,22 @@ static void render_sensors(void) int16_t t10 = (int16_t)(edata.temperature_c * 10); snprintf(buf, sizeof(buf), "Temp: %d.%d C", t10 / 10, abs(t10 % 10)); - mc_display_text(0, y, buf, false); + if (color) { + draw_color_segments(y, "TMP ", buf, UI_COLOR_VALUE); + } else { + mc_display_text(0, y, buf, false); + } y += LINE_H; } if (have_env && edata.has_pressure) { snprintf(buf, sizeof(buf), "Press: %u hPa", (unsigned)edata.pressure_hpa); - mc_display_text(0, y, buf, false); + if (color) { + draw_color_segments(y, "BAR ", buf, UI_COLOR_VALUE); + } else { + mc_display_text(0, y, buf, false); + } y += LINE_H; } @@ -509,7 +1031,11 @@ static void render_sensors(void) uint16_t h10 = (uint16_t)(edata.humidity_pct * 10); snprintf(buf, sizeof(buf), "Humid: %u.%u%%", h10 / 10, h10 % 10); - mc_display_text(0, y, buf, false); + if (color) { + draw_color_segments(y, "HUM ", buf, UI_COLOR_VALUE); + } else { + mc_display_text(0, y, buf, false); + } y += LINE_H; } @@ -518,7 +1044,18 @@ static void render_sensors(void) state.battery_pct > 0 ? state.battery_pct : calc_battery_pct(state.battery_mv), state.battery_mv); - mc_display_text(0, y, buf, false); + if (color) { + uint8_t pct = state.battery_pct > 0 ? state.battery_pct + : calc_battery_pct(state.battery_mv); + uint16_t batt_color = (pct <= 15) ? UI_COLOR_ERROR : + (pct <= 30) ? UI_COLOR_WARN : UI_COLOR_OK; + + snprintf(buf, sizeof(buf), "%u%% %umV", pct, state.battery_mv); + draw_color_segments(y, "BAT ", buf, batt_color); + draw_metric_bar(DISP_W - 44, y + 2, 42, 5, pct, 100, batt_color); + } else { + mc_display_text(0, y, buf, false); + } } static void render_offgrid(void) @@ -529,10 +1066,35 @@ static void render_offgrid(void) state.offgrid_confirm_time = 0; } - draw_centered(centered_row(0, 3), "Offgrid Mode"); - char buf[24]; + if (mc_display_has_color()) { + int y = CONTENT_Y; + + draw_badge(0, y, "GRID", state.offgrid_enabled ? UI_COLOR_ACTIVE + : UI_COLOR_DISABLED); + mc_display_color_text(38, y, "client repeat", + state.offgrid_enabled ? UI_COLOR_ACTIVE + : UI_COLOR_LABEL); + y += LINE_H + 4; + snprintf(buf, sizeof(buf), "Status %s", + state.offgrid_enabled ? "on" : "off"); + draw_centered_color(y, buf, + state.offgrid_enabled ? UI_COLOR_ACTIVE + : UI_COLOR_DISABLED); + y += LINE_H; + draw_centered_color(y, + state.offgrid_confirm_time != 0 ? + "Press to confirm" : + (state.offgrid_enabled ? "Press to disable" + : "Press to enable"), + state.offgrid_confirm_time != 0 ? UI_COLOR_WARN + : UI_COLOR_VALUE); + return; + } + + draw_centered(centered_row(0, 3), "Offgrid Mode"); + snprintf(buf, sizeof(buf), "Status: %s", state.offgrid_enabled ? "ON" : "OFF"); draw_centered(centered_row(1, 3), buf); @@ -554,6 +1116,21 @@ static void render_dfu(void) state.dfu_confirm_time = 0; } + if (mc_display_has_color()) { + int y = CONTENT_Y; + + draw_badge(0, y, "DFU", state.dfu_confirm_time != 0 + ? UI_COLOR_WARN : UI_COLOR_ACTIVE); + mc_display_color_text(32, y, "BLE update", UI_COLOR_VALUE); + y += LINE_H + 4; + draw_centered_color(y, + state.dfu_confirm_time != 0 ? + "Press to confirm" : "Press to run", + state.dfu_confirm_time != 0 ? UI_COLOR_WARN + : UI_COLOR_VALUE); + return; + } + draw_centered(centered_row(0, 2), "BLE DFU Update"); if (state.dfu_confirm_time != 0) { draw_centered(centered_row(1, 2), "Press to confirm"); @@ -570,6 +1147,21 @@ static void render_shutdown(void) state.shutdown_confirm_time = 0; } + if (mc_display_has_color()) { + int y = CONTENT_Y; + + draw_badge(0, y, "PWR", state.shutdown_confirm_time != 0 + ? UI_COLOR_WARN : UI_COLOR_ERROR); + mc_display_color_text(32, y, "power off", UI_COLOR_VALUE); + y += LINE_H + 4; + draw_centered_color(y, + state.shutdown_confirm_time != 0 ? + "Press to confirm" : "Press to run", + state.shutdown_confirm_time != 0 ? UI_COLOR_WARN + : UI_COLOR_VALUE); + return; + } + draw_centered(centered_row(0, 2), "Power Off"); if (state.shutdown_confirm_time != 0) { draw_centered(centered_row(1, 2), "Press to confirm"); @@ -582,9 +1174,15 @@ static void render_status(void) { char buf[28]; int y = CONTENT_Y; + bool color = mc_display_has_color(); /* Role label */ - draw_centered(y, "REPEATER"); + if (color) { + draw_badge(0, y, "MODE", UI_COLOR_ACTIVE); + mc_display_color_text(38, y, "repeater", UI_COLOR_VALUE); + } else { + draw_centered(y, "REPEATER"); + } y += LINE_H; /* Uptime */ @@ -594,7 +1192,11 @@ static void render_status(void) uint32_t mins = (up_s % 3600) / 60; snprintf(buf, sizeof(buf), "Up: %ud %uh %um", days, hours, mins); - mc_display_text(0, y, buf, false); + if (color) { + draw_color_segments(y, "UP ", buf, UI_COLOR_VALUE); + } else { + mc_display_text(0, y, buf, false); + } y += LINE_H; /* Clock — only if RTC has been synced (after Jan 1 2025) */ @@ -605,9 +1207,17 @@ static void render_status(void) uint8_t ss = day_sec % 60; snprintf(buf, sizeof(buf), "Time: %02u:%02u:%02u UTC", hh, mm, ss); - mc_display_text(0, y, buf, false); + if (color) { + draw_color_segments(y, "CLK ", buf, UI_COLOR_OK); + } else { + mc_display_text(0, y, buf, false); + } } else { - mc_display_text(0, y, "Time: not synced", false); + if (color) { + draw_color_segments(y, "CLK ", "not synced", UI_COLOR_WARN); + } else { + mc_display_text(0, y, "Time: not synced", false); + } } y += LINE_H; @@ -619,7 +1229,17 @@ static void render_status(void) pct = calc_battery_pct(state.battery_mv); } snprintf(buf, sizeof(buf), "Batt: %u%% (%umV)", pct, state.battery_mv); - mc_display_text(0, y, buf, false); + if (color) { + uint16_t batt_color = (pct <= 15) ? UI_COLOR_ERROR : + (pct <= 30) ? UI_COLOR_WARN : UI_COLOR_OK; + + snprintf(buf, sizeof(buf), "%u%% %umV", pct, state.battery_mv); + draw_color_segments(y, "BAT ", buf, batt_color); + draw_metric_bar(DISP_W - 44, y + 2, 42, 5, pct, 100, + batt_color); + } else { + mc_display_text(0, y, buf, false); + } } } diff --git a/zephcore/helpers/ui-button/ui_pages.h b/zephcore/helpers/ui-button/ui_pages.h index acae116..ee5992f 100644 --- a/zephcore/helpers/ui-button/ui_pages.h +++ b/zephcore/helpers/ui-button/ui_pages.h @@ -64,6 +64,20 @@ struct ui_state { uint8_t lora_cr; int8_t lora_tx_power; int16_t lora_noise_floor; + int8_t lora_effective_tx_power; + bool lora_apc_enabled; + int8_t lora_apc_reduction; + int16_t lora_apc_margin_x10; + uint8_t lora_apc_target_margin; + uint8_t lora_sync_word; + uint16_t lora_preamble_len; + bool lora_rx_duty_cycle; + bool lora_radio_ready; + bool lora_in_rx; + bool lora_tx_active; + uint32_t lora_packets_rx; + uint32_t lora_packets_tx; + uint32_t lora_packets_err; /* Bluetooth page */ bool ble_enabled; /* true = BLE active, false = serial mode */ diff --git a/zephcore/helpers/ui-button/ui_task.c b/zephcore/helpers/ui-button/ui_task.c index 713ae72..ee41a6d 100644 --- a/zephcore/helpers/ui-button/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -893,6 +893,46 @@ void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, uint16_t bw_khz_x10, s->lora_noise_floor = noise_floor; } +void ui_set_radio_runtime(int8_t effective_tx_power, bool apc_enabled, + int8_t apc_reduction, int16_t apc_margin_x10, + uint8_t apc_target_margin, uint8_t sync_word, + uint16_t preamble_len, bool rx_duty_cycle, + bool radio_ready, bool in_rx, bool tx_active) +{ + struct ui_state *s = get_state(); + + s->lora_effective_tx_power = effective_tx_power; + s->lora_apc_enabled = apc_enabled; + s->lora_apc_reduction = apc_reduction; + s->lora_apc_margin_x10 = apc_margin_x10; + s->lora_apc_target_margin = apc_target_margin; + s->lora_sync_word = sync_word; + s->lora_preamble_len = preamble_len; + s->lora_rx_duty_cycle = rx_duty_cycle; + s->lora_radio_ready = radio_ready; + s->lora_in_rx = in_rx; + s->lora_tx_active = tx_active; +} + +void ui_set_radio_stats(uint32_t packets_rx, uint32_t packets_tx, + uint32_t packets_err) +{ + struct ui_state *s = get_state(); + bool activity_changed = packets_rx != s->lora_packets_rx || + packets_tx != s->lora_packets_tx; + + s->lora_packets_rx = packets_rx; + s->lora_packets_tx = packets_tx; + s->lora_packets_err = packets_err; + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + if (ui_initialized && activity_changed && mc_display_has_color() && + ui_pages_current() == UI_PAGE_MESSAGES) { + schedule_render(); + } +#endif +} + void ui_set_gps_data(bool has_fix, uint8_t sats, int32_t lat_mdeg, int32_t lon_mdeg, int32_t alt_mm) { diff --git a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp index 056367a..e6e341a 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp @@ -218,6 +218,24 @@ extern "C" void ui_set_radio_params( } } +extern "C" void ui_set_radio_runtime(int8_t effective_tx_power, bool apc_enabled, + int8_t apc_reduction, int16_t apc_margin_x10, + uint8_t apc_target_margin, uint8_t sync_word, + uint16_t preamble_len, bool rx_duty_cycle, + bool radio_ready, bool in_rx, bool tx_active) +{ + (void)effective_tx_power; (void)apc_enabled; (void)apc_reduction; + (void)apc_margin_x10; (void)apc_target_margin; (void)sync_word; + (void)preamble_len; (void)rx_duty_cycle; (void)radio_ready; + (void)in_rx; (void)tx_active; +} + +extern "C" void ui_set_radio_stats(uint32_t pkt_recv, uint32_t pkt_sent, + uint32_t pkt_errors) +{ + ui_notify_radio_stats(pkt_recv, pkt_sent, pkt_errors); +} + extern "C" void ui_notify_radio_stats(uint32_t pkt_recv, uint32_t pkt_sent, uint32_t pkt_errors) { if (s_task) { diff --git a/zephcore/helpers/ui/cfb_font_0608.c b/zephcore/helpers/ui/cfb_font_0608.c index 869ecc1..e0c2cd5 100644 --- a/zephcore/helpers/ui/cfb_font_0608.c +++ b/zephcore/helpers/ui/cfb_font_0608.c @@ -970,6 +970,14 @@ static const uint8_t cfb_font_0608[224][6] = { { 0x0C, 0x51, 0x50, 0x51, 0x3C, 0x00 }, }; +const uint8_t *zephcore_font_6x8_glyph(uint8_t c) +{ + if (c < 32) { + c = '?'; + } + return cfb_font_0608[c - 32]; +} + FONT_ENTRY_DEFINE(cfb_font_6x8, 6, 8, CFB_FONT_MONO_VPACKED, diff --git a/zephcore/helpers/ui/display.c b/zephcore/helpers/ui/display.c index 0a1eba0..4fbe34e 100644 --- a/zephcore/helpers/ui/display.c +++ b/zephcore/helpers/ui/display.c @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +55,39 @@ static uint16_t disp_height; static uint8_t font_w; static uint8_t font_h; static bool is_epd; /* true for e-paper displays */ +static bool has_color; /* true when a raw RGB565 TFT is available */ + +const uint8_t *zephcore_font_6x8_glyph(uint8_t c); + +#define COLOR_TEXT_MAX_CHARS 32 +#define COLOR_MAX_OPS 72 +#define COLOR_MAX_WIDTH 320 + +enum color_op_type { + COLOR_OP_TEXT, + COLOR_OP_RECT, +}; + +struct color_op { + enum color_op_type type; + int16_t x; + int16_t y; + int16_t w; + int16_t h; + uint16_t color; + char text[COLOR_TEXT_MAX_CHARS]; +}; + +static struct color_op color_ops[COLOR_MAX_OPS]; +static uint8_t color_op_count; +static uint16_t color_line[COLOR_MAX_WIDTH]; + +#if DT_NODE_EXISTS(DT_NODELABEL(tft)) +static const struct device *color_dev = + DEVICE_DT_GET_OR_NULL(DT_NODELABEL(tft)); +#else +static const struct device *color_dev; +#endif /* Optional symmetric inset (pixels). Shrinks reported width/height and * offsets all draw primitives so panels with edge artefacts can hide them @@ -131,6 +165,159 @@ static inline void panel_vdd_enable(void) } } +static void color_overlay_probe(void) +{ + has_color = false; + + if (!color_dev || color_dev == disp_dev || !device_is_ready(color_dev)) { + return; + } + + struct display_capabilities caps; + + display_get_capabilities(color_dev, &caps); + if ((caps.current_pixel_format == PIXEL_FORMAT_RGB_565 || + caps.current_pixel_format == PIXEL_FORMAT_RGB_565X || + (caps.supported_pixel_formats & (PIXEL_FORMAT_RGB_565 | PIXEL_FORMAT_RGB_565X))) && + !(caps.screen_info & SCREEN_INFO_EPD)) { + has_color = true; + LOG_INF("display: color overlay enabled"); + } +} + +static bool color_queue(enum color_op_type type, int x, int y, int w, int h, + const char *text, uint16_t color) +{ + if (!has_color || color_op_count >= COLOR_MAX_OPS) { + return false; + } + + struct color_op *op = &color_ops[color_op_count++]; + + op->type = type; + op->x = (int16_t)x; + op->y = (int16_t)y; + op->w = (int16_t)w; + op->h = (int16_t)h; + op->color = color; + op->text[0] = '\0'; + if (text) { + strncpy(op->text, text, sizeof(op->text) - 1); + op->text[sizeof(op->text) - 1] = '\0'; + } + return true; +} + +static void color_write_rect_now(int x, int y, int w, int h, uint16_t color) +{ + if (!has_color || w <= 0 || h <= 0) { + return; + } + + x += DISP_INSET; + y += DISP_INSET; + if (x < 0) { + w += x; + x = 0; + } + if (y < 0) { + h += y; + y = 0; + } + if (x + w > (int)disp_width) { + w = (int)disp_width - x; + } + if (y + h > (int)disp_height) { + h = (int)disp_height - y; + } + if (w <= 0 || h <= 0) { + return; + } + if (w > COLOR_MAX_WIDTH) { + w = COLOR_MAX_WIDTH; + } + + uint16_t be = sys_cpu_to_be16(color); + for (int i = 0; i < w; i++) { + color_line[i] = be; + } + + const struct display_buffer_descriptor desc = { + .buf_size = (uint32_t)w * 2U, + .width = (uint16_t)w, + .height = 1U, + .pitch = (uint16_t)w, + }; + + for (int row = 0; row < h; row++) { + display_write(color_dev, (uint16_t)x, (uint16_t)(y + row), + &desc, color_line); + } +} + +static void color_write_char_now(int x, int y, uint8_t c, uint16_t color) +{ + uint16_t glyph_buf[6 * 8]; + const uint8_t *glyph = zephcore_font_6x8_glyph(c); + uint16_t fg = sys_cpu_to_be16(color); + uint16_t bg = sys_cpu_to_be16(MC_COLOR_BLACK); + + for (int row = 0; row < 8; row++) { + for (int col = 0; col < 6; col++) { + bool on = (glyph[col] >> row) & 0x01; + glyph_buf[row * 6 + col] = on ? fg : bg; + } + } + + const struct display_buffer_descriptor desc = { + .buf_size = sizeof(glyph_buf), + .width = 6, + .height = 8, + .pitch = 6, + }; + + display_write(color_dev, (uint16_t)(x + DISP_INSET), + (uint16_t)(y + DISP_INSET), &desc, glyph_buf); +} + +static void color_write_text_now(int x, int y, const char *text, uint16_t color) +{ + if (!has_color || !text) { + return; + } + + for (const char *p = text; *p; p++, x += 6) { + uint8_t c = (uint8_t)*p; + + if (c < 32) { + c = '?'; + } + if (x + 6 > (int)mc_display_width() || y + 8 > (int)mc_display_height()) { + break; + } + color_write_char_now(x, y, c, color); + } +} + +static void color_flush_ops(void) +{ + if (!has_color) { + color_op_count = 0; + return; + } + + for (uint8_t i = 0; i < color_op_count; i++) { + struct color_op *op = &color_ops[i]; + + if (op->type == COLOR_OP_RECT) { + color_write_rect_now(op->x, op->y, op->w, op->h, op->color); + } else { + color_write_text_now(op->x, op->y, op->text, op->color); + } + } + color_op_count = 0; +} + /* Auto-off work */ static struct k_work_delayable auto_off_work; @@ -228,6 +415,7 @@ int mc_display_init(void) LOG_INF("display: %ux%u%s", disp_width, disp_height, is_epd ? " (e-paper)" : ""); + color_overlay_probe(); /* OLED: blank before CFB init so stale VRAM isn't visible while we * build the first frame. EPD: driver init already performed a clean @@ -395,12 +583,18 @@ bool mc_display_is_epd(void) return is_epd; } +bool mc_display_has_color(void) +{ + return has_color; +} + void mc_display_clear(void) { if (!disp_initialized) { return; } + color_op_count = 0; if (is_epd) { epd_frame_hash = 2166136261u; } @@ -430,6 +624,17 @@ void mc_display_text(int x, int y, const char *text, bool invert) } } +void mc_display_color_text(int x, int y, const char *text, uint16_t color) +{ + if (!disp_initialized || !text) { + return; + } + + if (!color_queue(COLOR_OP_TEXT, x, y, 0, 0, text, color)) { + mc_display_text(x, y, text, false); + } +} + void mc_display_fill_rect(int x, int y, int w, int h) { if (!disp_initialized) { @@ -452,6 +657,17 @@ void mc_display_fill_rect(int x, int y, int w, int h) } } +void mc_display_color_fill_rect(int x, int y, int w, int h, uint16_t color) +{ + if (!disp_initialized) { + return; + } + + if (!color_queue(COLOR_OP_RECT, x, y, w, h, NULL, color)) { + mc_display_fill_rect(x, y, w, h); + } +} + void mc_display_hline(int x, int y, int w) { if (!disp_initialized) { @@ -564,6 +780,7 @@ void mc_display_finalize(void) } cfb_framebuffer_finalize(disp_dev); + color_flush_ops(); if (is_epd) { epd_last_frame_hash = epd_frame_hash; diff --git a/zephcore/helpers/ui/display.h b/zephcore/helpers/ui/display.h index 13cd622..c6b1810 100644 --- a/zephcore/helpers/ui/display.h +++ b/zephcore/helpers/ui/display.h @@ -83,6 +83,12 @@ bool mc_display_is_on(void); */ bool mc_display_is_epd(void); +/** + * @return true when a raw RGB565-capable color panel is available for + * optional color overlays. Monochrome displays return false. + */ +bool mc_display_has_color(void); + /** * Clear the framebuffer (fill with black). * Call before rendering a new frame. @@ -99,6 +105,25 @@ void mc_display_clear(void); */ void mc_display_text(int x, int y, const char *text, bool invert); +/* Common RGB565 colors for optional color-capable pages. */ +#define MC_COLOR_BLACK 0x0000 +#define MC_COLOR_WHITE 0xffff +#define MC_COLOR_GREEN 0x07e0 +#define MC_COLOR_CYAN 0x07ff +#define MC_COLOR_YELLOW 0xffe0 +#define MC_COLOR_ORANGE 0xfd20 +#define MC_COLOR_RED 0xf800 +#define MC_COLOR_BLUE 0x001f +#define MC_COLOR_GRAY 0x8410 + +/** + * Draw text using RGB565 color when supported. On non-color displays this + * falls back to mc_display_text(..., invert=false). + * + * Color overlays are flushed after the normal CFB frame in mc_display_finalize(). + */ +void mc_display_color_text(int x, int y, const char *text, uint16_t color); + /** * Draw a filled rectangle. * @@ -109,6 +134,12 @@ void mc_display_text(int x, int y, const char *text, bool invert); */ void mc_display_fill_rect(int x, int y, int w, int h); +/** + * Draw a filled rectangle using RGB565 color when supported. On non-color + * displays this falls back to mc_display_fill_rect(). + */ +void mc_display_color_fill_rect(int x, int y, int w, int h, uint16_t color); + /** * Draw a horizontal line. */ diff --git a/zephcore/helpers/ui/ui_headless_stubs.c b/zephcore/helpers/ui/ui_headless_stubs.c index 45450fc..c11a495 100644 --- a/zephcore/helpers/ui/ui_headless_stubs.c +++ b/zephcore/helpers/ui/ui_headless_stubs.c @@ -49,6 +49,25 @@ WEAK void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, ARG_UNUSED(cr); ARG_UNUSED(tx_power); ARG_UNUSED(noise_floor); } +WEAK void ui_set_radio_runtime(int8_t effective_tx_power, bool apc_enabled, + int8_t apc_reduction, int16_t apc_margin_x10, + uint8_t apc_target_margin, uint8_t sync_word, + uint16_t preamble_len, bool rx_duty_cycle, + bool radio_ready, bool in_rx, bool tx_active) +{ + ARG_UNUSED(effective_tx_power); ARG_UNUSED(apc_enabled); + ARG_UNUSED(apc_reduction); ARG_UNUSED(apc_margin_x10); + ARG_UNUSED(apc_target_margin); ARG_UNUSED(sync_word); + ARG_UNUSED(preamble_len); ARG_UNUSED(rx_duty_cycle); + ARG_UNUSED(radio_ready); ARG_UNUSED(in_rx); ARG_UNUSED(tx_active); +} + +WEAK void ui_set_radio_stats(uint32_t packets_rx, uint32_t packets_tx, + uint32_t packets_err) +{ + ARG_UNUSED(packets_rx); ARG_UNUSED(packets_tx); ARG_UNUSED(packets_err); +} + WEAK void ui_set_gps_data(bool has_fix, uint8_t sats, int32_t lat_mdeg, int32_t lon_mdeg, int32_t alt_mm) { diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index 5663ebc..ecc27bd 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -315,13 +315,38 @@ extern "C" void mesh_housekeeping_ui_refresh(void) ui_set_clock(s_rtc_clock->getCurrentTime()); ui_set_radio_params( - (uint32_t)(s_mesh->prefs.freq * 1000000.0f + 0.5f), - s_mesh->prefs.sf, - (uint16_t)(s_mesh->prefs.bw * 10.0f + 0.5f), - s_mesh->prefs.cr, - s_mesh->prefs.tx_power_dbm, + s_lora_radio->getActiveFrequencyHz(), + s_lora_radio->getActiveSpreadingFactor(), + s_lora_radio->getActiveBandwidthKHzX10(), + s_lora_radio->getActiveCodingRate(), + s_lora_radio->getConfiguredTxPower(), s_lora_radio->getNoiseFloor()); - ui_notify_radio_stats( + { + bool apc_enabled = false; + int8_t apc_reduction = 0; + int16_t apc_margin_x10 = 0; + uint8_t apc_target = s_mesh->prefs.apc_margin; + +#ifdef CONFIG_ZEPHCORE_APC + apc_enabled = s_mesh->isAPCEnabled(); + apc_reduction = s_mesh->getAPCReduction(); + apc_margin_x10 = (int16_t)(s_mesh->getAPCMargin() * 10.0f); + apc_target = s_mesh->getAPCTargetMargin(); +#endif + ui_set_radio_runtime( + s_lora_radio->getEffectiveTxPower(), + apc_enabled, + apc_reduction, + apc_margin_x10, + apc_target, + s_lora_radio->getActiveSyncWord(), + s_lora_radio->getActivePreambleLength(), + s_lora_radio->isRxDutyCycleEnabled(), + s_lora_radio->isRadioReady(), + s_lora_radio->isInRecvMode(), + s_lora_radio->isTxActive()); + } + ui_set_radio_stats( s_lora_radio->getPacketsRecv(), s_lora_radio->getPacketsSent(), s_lora_radio->getPacketsRecvErrors()); diff --git a/zephcore/helpers/ui/ui_task.h b/zephcore/helpers/ui/ui_task.h index c82eacf..be98a0a 100644 --- a/zephcore/helpers/ui/ui_task.h +++ b/zephcore/helpers/ui/ui_task.h @@ -84,6 +84,21 @@ void ui_set_ble_status(bool connected, const char *name); void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, uint16_t bw_khz_x10, uint8_t cr, int8_t tx_power, int16_t noise_floor); +/** + * Update extended live radio/APC details for display. + */ +void ui_set_radio_runtime(int8_t effective_tx_power, bool apc_enabled, + int8_t apc_reduction, int16_t apc_margin_x10, + uint8_t apc_target_margin, uint8_t sync_word, + uint16_t preamble_len, bool rx_duty_cycle, + bool radio_ready, bool in_rx, bool tx_active); + +/** + * Update radio packet counters for display. + */ +void ui_set_radio_stats(uint32_t packets_rx, uint32_t packets_tx, + uint32_t packets_err); + /** * Update GPS data for display. */ diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index b5b9af2..b268f9c 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -678,6 +678,27 @@ public: LOG_INF("TX power %d dBm requested (reboot to apply)", power_dbm); } +#ifdef CONFIG_ZEPHCORE_APC + int8_t getAPCReduction() const override { + return companion_mesh.getAPCReduction(); + } + float getAPCMargin() const override { + return companion_mesh.getAPCMargin(); + } + bool isAPCEnabled() const override { + return companion_mesh.isAPCEnabled(); + } + void setAPCEnabled(bool en) override { + companion_mesh.setAPCEnabled(en); + } + uint8_t getAPCTargetMargin() const override { + return companion_mesh.getAPCTargetMargin(); + } + void setAPCTargetMargin(uint8_t margin_db) override { + companion_mesh.setAPCTargetMargin(margin_db); + } +#endif + mesh::LocalIdentity& getSelfId() override { return companion_mesh.self_id; } void saveIdentity(const mesh::LocalIdentity& new_id) override { @@ -1107,12 +1128,39 @@ int main(void) /* Push initial state to UI display */ ui_set_node_name(companion_mesh.prefs.node_name); ui_set_radio_params( - (uint32_t)(companion_mesh.prefs.freq * 1000000.0f + 0.5f), - companion_mesh.prefs.sf, - (uint16_t)(companion_mesh.prefs.bw * 10.0f + 0.5f), - companion_mesh.prefs.cr, - companion_mesh.prefs.tx_power_dbm, + lora_radio.getActiveFrequencyHz(), + lora_radio.getActiveSpreadingFactor(), + lora_radio.getActiveBandwidthKHzX10(), + lora_radio.getActiveCodingRate(), + lora_radio.getConfiguredTxPower(), lora_radio.getNoiseFloor()); +#ifdef CONFIG_ZEPHCORE_APC + ui_set_radio_runtime( + lora_radio.getEffectiveTxPower(), + companion_mesh.isAPCEnabled(), + companion_mesh.getAPCReduction(), + (int16_t)(companion_mesh.getAPCMargin() * 10.0f), + companion_mesh.getAPCTargetMargin(), + lora_radio.getActiveSyncWord(), + lora_radio.getActivePreambleLength(), + lora_radio.isRxDutyCycleEnabled(), + lora_radio.isRadioReady(), + lora_radio.isInRecvMode(), + lora_radio.isTxActive()); +#else + ui_set_radio_runtime( + lora_radio.getEffectiveTxPower(), + false, 0, 0, companion_mesh.prefs.apc_margin, + lora_radio.getActiveSyncWord(), + lora_radio.getActivePreambleLength(), + lora_radio.isRxDutyCycleEnabled(), + lora_radio.isRadioReady(), + lora_radio.isInRecvMode(), + lora_radio.isTxActive()); +#endif + ui_set_radio_stats(lora_radio.getPacketsRecv(), + lora_radio.getPacketsSent(), + lora_radio.getPacketsRecvErrors()); ui_set_battery(zephyr_board.getBattMilliVolts(), 0); ui_set_gps_available(gps_is_available()); ui_set_gps_enabled(companion_mesh.prefs.gps_enabled != 0); @@ -1163,6 +1211,30 @@ int main(void) /* Apply RX boost and duty cycle from prefs */ lora_radio.setRxBoost(companion_mesh.prefs.rx_boost != 0); lora_radio.enableRxDutyCycle(companion_mesh.prefs.rx_duty_cycle != 0); +#ifdef CONFIG_ZEPHCORE_APC + ui_set_radio_runtime( + lora_radio.getEffectiveTxPower(), + companion_mesh.isAPCEnabled(), + companion_mesh.getAPCReduction(), + (int16_t)(companion_mesh.getAPCMargin() * 10.0f), + companion_mesh.getAPCTargetMargin(), + lora_radio.getActiveSyncWord(), + lora_radio.getActivePreambleLength(), + lora_radio.isRxDutyCycleEnabled(), + lora_radio.isRadioReady(), + lora_radio.isInRecvMode(), + lora_radio.isTxActive()); +#else + ui_set_radio_runtime( + lora_radio.getEffectiveTxPower(), + false, 0, 0, companion_mesh.prefs.apc_margin, + lora_radio.getActiveSyncWord(), + lora_radio.getActivePreambleLength(), + lora_radio.isRxDutyCycleEnabled(), + lora_radio.isRadioReady(), + lora_radio.isInRecvMode(), + lora_radio.isTxActive()); +#endif /* Restore runtime ADC multiplier override (0 = keep DT default) */ zephyr_board.setAdcMultiplier(companion_mesh.prefs.adc_multiplier);