From 43f455393e9ec5b4b0cce2fb18a014d1fb8d7b23 Mon Sep 17 00:00:00 2001 From: Matthew Burton <19491784+matthew73210@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:22:52 +0200 Subject: [PATCH] added colour and fixed set tx adc not sticking --- zephcore/adapters/radio/LoRaRadioBase.cpp | 65 ++++++ zephcore/adapters/radio/LoRaRadioBase.h | 13 ++ zephcore/app/CompanionMesh.h | 25 ++ .../boards/nrf52840/heltec_t096/board.conf | 4 + zephcore/helpers/CommonCLI.cpp | 26 ++- zephcore/helpers/ui-button/ui_pages.c | 112 +++++++-- zephcore/helpers/ui-button/ui_pages.h | 14 ++ zephcore/helpers/ui-button/ui_task.c | 31 +++ .../helpers/ui-joystick/joystick_ui_hooks.cpp | 18 ++ zephcore/helpers/ui/cfb_font_0608.c | 8 + zephcore/helpers/ui/display.c | 217 ++++++++++++++++++ zephcore/helpers/ui/display.h | 31 +++ zephcore/helpers/ui/ui_headless_stubs.c | 19 ++ zephcore/helpers/ui/ui_mesh_actions.cpp | 37 ++- zephcore/helpers/ui/ui_task.h | 15 ++ zephcore/src/main_companion.cpp | 82 ++++++- 16 files changed, 684 insertions(+), 33 deletions(-) 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/nrf52840/heltec_t096/board.conf b/zephcore/boards/nrf52840/heltec_t096/board.conf index 9f70dd5..362e4aa 100644 --- a/zephcore/boards/nrf52840/heltec_t096/board.conf +++ b/zephcore/boards/nrf52840/heltec_t096/board.conf @@ -10,6 +10,7 @@ # - 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" @@ -35,3 +36,6 @@ 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/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..da6a69f 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -95,7 +95,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, @@ -234,6 +236,16 @@ static void draw_centered(int y, const char *text) mc_display_text(x, y, text, false); } +static void draw_color_segments(int y, const char *label, + const char *value, uint16_t value_color) +{ + int x = 0; + + mc_display_color_text(x, y, label, MC_COLOR_CYAN); + x += (int)strlen(label) * 6; + mc_display_color_text(x, y, value, value_color); +} + /* ========== Page Renderers ========== */ static void render_messages(void) @@ -294,42 +306,108 @@ static void render_recent(void) 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 apc_color = state.lora_apc_enabled ? MC_COLOR_GREEN : MC_COLOR_GRAY; + uint16_t warn_color = (state.lora_apc_enabled && state.lora_apc_reduction > 0) + ? MC_COLOR_ORANGE : MC_COLOR_GREEN; + + if (mc_display_has_color()) { + mc_display_color_fill_rect(0, y - 1, DISP_W, FONT_H + 2, MC_COLOR_BLUE); + snprintf(buf, sizeof(buf), "companion %s/%s", packet_state, rx_mode); + mc_display_color_text(2, y, "RAD", MC_COLOR_YELLOW); + mc_display_color_text(26, y, buf, MC_COLOR_WHITE); + 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, MC_COLOR_WHITE); + 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, MC_COLOR_WHITE); + y += LINE_H; + + if (state.lora_apc_enabled) { + snprintf(buf, sizeof(buf), "%d/%ddBm APC on", + state.lora_effective_tx_power, state.lora_tx_power); + } else { + snprintf(buf, sizeof(buf), "%ddBm APC off", state.lora_tx_power); + } + draw_color_segments(y, "TX ", buf, apc_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); + y += LINE_H; + + 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); + draw_color_segments(y, "PKT ", buf, + state.lora_packets_err ? MC_COLOR_RED : MC_COLOR_WHITE); + 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); } 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 7d41f05..834df3c 100644 --- a/zephcore/helpers/ui-button/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -887,6 +887,37 @@ 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(); + + s->lora_packets_rx = packets_rx; + s->lora_packets_tx = packets_tx; + s->lora_packets_err = packets_err; +} + 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 5f2c56a..4494a8b 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);