Merge pull request #45 from matthew73210/master

feat(boards): add Heltec T096 companion support
This commit is contained in:
liquidraver
2026-07-03 19:31:24 +02:00
committed by GitHub
29 changed files with 1886 additions and 65 deletions
+1
View File
@@ -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 |
+2 -1
View File
@@ -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
fi
+1 -1
View File
@@ -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")
+65
View File
@@ -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
+13
View File
@@ -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
+25
View File
@@ -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.
+1
View File
@@ -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.
@@ -0,0 +1,6 @@
# Copyright (c) 2026 ZephCore
#
# SPDX-License-Identifier: MIT
config BOARD_HELTEC_T096
select SOC_NRF52840_QIAA
@@ -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.
@@ -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)
@@ -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
@@ -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
@@ -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 = <NRF_PSEL(SPIM_SCK, 1, 8)>,
<NRF_PSEL(SPIM_MOSI, 0, 11)>,
<NRF_PSEL(SPIM_MISO, 0, 14)>;
};
};
spi2_sleep: spi2_sleep {
group1 {
psels = <NRF_PSEL(SPIM_SCK, 1, 8)>,
<NRF_PSEL(SPIM_MOSI, 0, 11)>,
<NRF_PSEL(SPIM_MISO, 0, 14)>;
low-power-enable;
};
};
spi3_default: spi3_default {
group1 {
psels = <NRF_PSEL(SPIM_SCK, 0, 20)>,
<NRF_PSEL(SPIM_MOSI, 0, 17)>;
};
};
spi3_sleep: spi3_sleep {
group1 {
psels = <NRF_PSEL(SPIM_SCK, 0, 20)>,
<NRF_PSEL(SPIM_MOSI, 0, 17)>;
low-power-enable;
};
};
i2c0_default: i2c0_default {
group1 {
psels = <NRF_PSEL(TWIM_SDA, 0, 7)>,
<NRF_PSEL(TWIM_SCL, 0, 8)>;
};
};
i2c0_sleep: i2c0_sleep {
group1 {
psels = <NRF_PSEL(TWIM_SDA, 0, 7)>,
<NRF_PSEL(TWIM_SCL, 0, 8)>;
low-power-enable;
};
};
uart0_default: uart0_default {
group1 {
psels = <NRF_PSEL(UART_TX, 0, 25)>;
};
group2 {
psels = <NRF_PSEL(UART_RX, 0, 23)>;
bias-pull-up;
};
};
uart0_sleep: uart0_sleep {
group1 {
psels = <NRF_PSEL(UART_TX, 0, 25)>,
<NRF_PSEL(UART_RX, 0, 23)>;
low-power-enable;
};
};
};
@@ -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 <nordic/nrf52840_qiaa.dtsi>
#include <zephyr/dt-bindings/lora/sx126x.h>
#include <zephyr/dt-bindings/adc/nrf-saadc.h>
#include <zephyr/dt-bindings/input/input-event-codes.h>
#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 = <INPUT_KEY_0>;
label = "User Button";
};
};
user_btn_longpress: user_btn_longpress {
compatible = "zephyr,input-longpress";
input = <&buttons>;
input-codes = <INPUT_KEY_0>;
short-codes = <INPUT_KEY_A>;
long-codes = <INPUT_KEY_ENTER>;
long-delay-ms = <1000>;
};
user_btn_multitap {
compatible = "zephcore,input-multi-tap";
input-codes = <INPUT_KEY_A>;
tap-codes = <INPUT_KEY_1 INPUT_KEY_B INPUT_KEY_D INPUT_KEY_C INPUT_KEY_E>;
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>;
};
};
&reg0 {
status = "okay";
};
&reg1 {
regulator-initial-mode = <NRF5X_REG_MODE_DCDC>;
};
&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 = <ADC_ACQ_TIME(ADC_ACQ_TIME_MICROSECONDS, 10)>;
zephyr,input-positive = <NRF_SAADC_AIN1>;
zephyr,resolution = <12>;
};
};
&gpiote {
status = "okay";
};
&gpio0 {
status = "okay";
};
&gpio1 {
status = "okay";
};
&i2c0 {
compatible = "nordic,nrf-twim";
status = "okay";
clock-frequency = <I2C_BITRATE_FAST>;
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 = <SX126X_DIO3_TCXO_1V8>;
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"
@@ -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
@@ -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
+3
View File
@@ -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
+21 -5
View File
@@ -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();
+667 -47
View File
@@ -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<S> 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);
}
}
}
+14
View File
@@ -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 */
+40
View File
@@ -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)
{
@@ -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) {
+8
View File
@@ -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,
+217
View File
@@ -22,6 +22,7 @@
#include <zephyr/drivers/display.h>
#include <zephyr/drivers/regulator.h>
#include <zephyr/kernel.h>
#include <zephyr/sys/byteorder.h>
#include <string.h>
#include <stdio.h>
#include <limits.h>
@@ -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;
+31
View File
@@ -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.
*/
+19
View File
@@ -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)
{
+31 -6
View File
@@ -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());
+15
View File
@@ -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.
*/
+77 -5
View File
@@ -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);