From 1e83a377f3da2a398304459fbea4a61fb865d68c Mon Sep 17 00:00:00 2001 From: richardclli Date: Wed, 5 Aug 2026 10:56:32 +0800 Subject: [PATCH 01/55] feat(afhds3): iBUS2 support (#6914) Co-authored-by: xlong <984929678@qq.com> Co-authored-by: Peter Feerick Co-authored-by: Claude Sonnet 5 (cherry picked from commit 256dc9891797fb84670d0b816f624c48e510702d) --- radio/src/dataconstants.h | 1 + .../gui/colorlcd/module/afhds3_options.cpp | 161 +++- .../src/gui/colorlcd/module/afhds3_options.h | 8 + .../gui/colorlcd/module/afhds3_settings.cpp | 5 + radio/src/pulses/afhds3.cpp | 111 ++- radio/src/pulses/afhds3_config.h | 18 +- radio/src/pulses/afhds3_transport.h | 2 + radio/src/targets/common/arm/CMakeLists.txt | 2 + radio/src/telemetry/flysky_ibus2.cpp | 730 ++++++++++++++++++ radio/src/telemetry/flysky_ibus2.h | 36 + radio/src/telemetry/telemetry_sensors.cpp | 10 + radio/src/translations/i18n/cn.h | 1 + radio/src/translations/i18n/cz.h | 1 + radio/src/translations/i18n/da.h | 1 + radio/src/translations/i18n/de.h | 1 + radio/src/translations/i18n/en.h | 1 + radio/src/translations/i18n/es.h | 1 + radio/src/translations/i18n/fi.h | 1 + radio/src/translations/i18n/fr.h | 1 + radio/src/translations/i18n/he.h | 1 + radio/src/translations/i18n/it.h | 1 + radio/src/translations/i18n/jp.h | 1 + radio/src/translations/i18n/ko.h | 1 + radio/src/translations/i18n/nl.h | 1 + radio/src/translations/i18n/pl.h | 1 + radio/src/translations/i18n/pt.h | 1 + radio/src/translations/i18n/ru.h | 1 + radio/src/translations/i18n/se.h | 1 + radio/src/translations/i18n/tw.h | 1 + radio/src/translations/i18n/ua.h | 1 + radio/src/translations/sim_string_list.h | 1 + radio/src/translations/string_list.h | 1 + 32 files changed, 1091 insertions(+), 14 deletions(-) create mode 100644 radio/src/telemetry/flysky_ibus2.cpp create mode 100644 radio/src/telemetry/flysky_ibus2.h diff --git a/radio/src/dataconstants.h b/radio/src/dataconstants.h index 372e38c9971..392affc81c5 100644 --- a/radio/src/dataconstants.h +++ b/radio/src/dataconstants.h @@ -276,6 +276,7 @@ enum TelemetryProtocol PROTOCOL_TELEMETRY_CROSSFIRE, PROTOCOL_TELEMETRY_SPEKTRUM, PROTOCOL_TELEMETRY_FLYSKY_IBUS, + PROTOCOL_TELEMETRY_FLYSKY_IBUS2, PROTOCOL_TELEMETRY_HITEC, PROTOCOL_TELEMETRY_HOTT, PROTOCOL_TELEMETRY_MLINK, diff --git a/radio/src/gui/colorlcd/module/afhds3_options.cpp b/radio/src/gui/colorlcd/module/afhds3_options.cpp index 73f5bdc14a9..e310c6d1aad 100644 --- a/radio/src/gui/colorlcd/module/afhds3_options.cpp +++ b/radio/src/gui/colorlcd/module/afhds3_options.cpp @@ -29,9 +29,8 @@ static const lv_coord_t row_dsc[] = {LV_GRID_CONTENT, #define SET_DIRTY() -static const char* const _analog_outputs[] = { "PWM", "PPM" }; -static const char* const _bus_types[] = { "iBUS OUT", "iBUS IN", "SBUS" }; -static const char* const _v1_bus_types[] = { "PWM", "PPM", "SBUS", "iBUS IN", "iBUS OUT" }; +static const char* const _bus_types[] = { "iBUS OUT", "iBUS IN", "SBUS"}; +static const char* const _v1_bus_types[] = { "PWM", "PPM", "SBUS", "iBUS IN", "iBUS OUT", "iBUS2" }; static const char* const _v1_pwmfreq_types[] = { STR_ANALOG_SERVO, STR_DIGITAL_SERVO, "SR833HZ", "SFR1000HZ", STR_MULTI_CUSTOM }; static const char* const _v0_pwmfreq_types[] = { STR_ANALOG_SERVO, STR_DIGITAL_SERVO, STR_MULTI_CUSTOM }; @@ -118,6 +117,13 @@ PWMfrequencyChoice::PWMfrequencyChoice(Window* parent, uint8_t moduleIdx ) : num_edit->show(pwmvalue_type == 2); } +uint8_t GetDisplayPortType(uint8_t NP) { + if (NP == afhds3::SES_NPT_IBUS2_HUB_PORT) + return afhds3::SES_NPT_IBUS2; + else + return NP; +} + AFHDS3_Options::AFHDS3_Options(uint8_t moduleIdx) : Page(ICON_MODEL_SETUP) { cfg = afhds3::getConfig(moduleIdx); @@ -154,7 +160,7 @@ AFHDS3_Options::AFHDS3_Options(uint8_t moduleIdx) : Page(ICON_MODEL_SETUP) temp_str = STR_CH; temp_str += " 1"; new StaticText(line, rect_t{}, temp_str ); - new Choice(line, rect_t{}, _analog_outputs, + new Choice(line, rect_t{}, STR_FLYSKY_PULSE_PROTO, afhds3::SES_ANALOG_OUTPUT_PWM, afhds3::SES_ANALOG_OUTPUT_PPM, GET_SET_AND_SYNC(cfg, vCfg->AnalogOutput, afhds3::DirtyConfig::DC_RX_CMD_OUT_PWM_PPM_MODE)); @@ -191,8 +197,8 @@ AFHDS3_Options::AFHDS3_Options(uint8_t moduleIdx) : Page(ICON_MODEL_SETUP) portName += 'A' + i; new StaticText(line, rect_t{}, portName.c_str()); new Choice(line, rect_t{}, _v1_bus_types, afhds3::SES_NPT_PWM, - afhds3::SES_NPT_IBUS1_OUT, - GET_DEFAULT(vCfg->NewPortTypes[i]), + afhds3::SES_NPT_IBUS2, + GET_DEFAULT(GetDisplayPortType(vCfg->NewPortTypes[i])), [=](int32_t newValue) { if(!newValue) { @@ -201,14 +207,49 @@ AFHDS3_Options::AFHDS3_Options(uint8_t moduleIdx) : Page(ICON_MODEL_SETUP) } else { uint8_t j = 0; - for ( j = 0; j < SES_NPT_NB_MAX_PORTS; j++) { - if ( vCfg->NewPortTypes[j]== newValue && i != j ) - break; + bool isNewValueIBUS2 = (newValue == afhds3::SES_NPT_IBUS2 || newValue == afhds3::SES_NPT_IBUS2_HUB_PORT); + bool isNewValueIBUS1_IN = (newValue == afhds3::SES_NPT_IBUS1_IN); + bool isNewValueIBUS1_OUT = (newValue == afhds3::SES_NPT_IBUS1_OUT); + bool conflict = false; + for (j = 0; j < SES_NPT_NB_MAX_PORTS; j++) { + if (j == i) continue; + + uint8_t existingType = vCfg->NewPortTypes[j]; + bool isExistingIBUS1_IN = (existingType == afhds3::SES_NPT_IBUS1_IN); + bool isExistingIBUS1_OUT = (existingType == afhds3::SES_NPT_IBUS1_OUT); + bool isExistingIBUS2 = (existingType == afhds3::SES_NPT_IBUS2 || existingType == afhds3::SES_NPT_IBUS2_HUB_PORT); + + // Check for mutual exclusivity of iBUS1 and iBUS2 + if ((isNewValueIBUS1_IN || isNewValueIBUS1_OUT) && isExistingIBUS2) { + conflict = true; + break; + } + if (isNewValueIBUS2 && (isExistingIBUS1_IN || isExistingIBUS1_OUT)) { + conflict = true; + break; + } + + // Check for iBUS1 conflicts in the same direction + if (isNewValueIBUS1_IN && isExistingIBUS1_IN) { + conflict = true; + break; + } + if (isNewValueIBUS1_OUT && isExistingIBUS1_OUT) { + conflict = true; + break; + } + + // Check for duplicate entries of non-iBUS type + if (!isNewValueIBUS2 && !isNewValueIBUS1_IN && !isNewValueIBUS1_OUT && + existingType == newValue) { + conflict = true; + break; + } } - //The RX does not support two or more ports to output IBUS (the same is true for PPM and SBUS). if(j==SES_NPT_NB_MAX_PORTS ) { - vCfg->NewPortTypes[i] = newValue; + if (!conflict) + vCfg->NewPortTypes[i] = newValue; DIRTY_CMD(cfg, afhds3::DirtyConfig::DC_RX_CMD_PORT_TYPE_V1); } } @@ -232,3 +273,101 @@ AFHDS3_Options::AFHDS3_Options(uint8_t moduleIdx) : Page(ICON_MODEL_SETUP) DIRTY_CMD(cfg, afhds3::DirtyConfig::DC_RX_CMD_RSSI_CHANNEL_SETUP); }); } + +AFHDS3_Sensors::AFHDS3_Sensors(uint8_t moduleIdx) : Page(ICON_MODEL_SETUP) +{ + cfg = afhds3::getConfig(moduleIdx); + std::string title = + moduleIdx == INTERNAL_MODULE ? STR_INTERNALRF : STR_EXTERNALRF; + header->setTitle(title); + + title = "AFHDS3 ("; + title += STR_TELEMETRY_SENSORS; + title += ")"; + header->setTitle2(title); + + body->setFlexLayout(LV_FLEX_FLOW_COLUMN, PAD_TINY); + + FlexGridLayout grid(col_dsc, row_dsc, PAD_TINY); + + bool ibus2_mode = false; + + if (cfg->version == 0) { + return; + } + auto vCfg = &cfg->v1; + + for (uint8_t j = 0; j < SES_NPT_NB_MAX_PORTS; j++) { + if (vCfg->NewPortTypes[j] == afhds3::SES_NPT_IBUS2 || vCfg->NewPortTypes[j] == afhds3::SES_NPT_IBUS2_HUB_PORT) { + ibus2_mode = true; + break; + } + } + std::string temp_str; + auto line = body->newLine(grid); + + if (!ibus2_mode) { + new StaticText(line, rect_t{}, STR_IBUS2_SENSORS_MODE_ONLY); + return; + } + + if (cfg->others.sensorOnLine & (1 << afhds3::DirtyIbus2Sensor::IBUS2_SENSOR_GPS)) + { + temp_str = STR_IMU; + temp_str += " "; + temp_str += STR_CURRENTSENSOR; + new StaticText(line, rect_t{}, temp_str); + auto imu_btn = new TextButton(line, rect_t{}, STR_CALIBRATION); + imu_btn->setPressHandler([=]() -> uint8_t { + DIRTY_CMD(cfg, afhds3::DirtyConfig::DC_RX_CMD_CALIB_GYRO); + return 0; + }); + + temp_str = STR_SENSOR_DIST; + temp_str += " "; + temp_str += STR_CURRENTSENSOR; + line = body->newLine(grid); + new StaticText(line, rect_t{}, temp_str); + auto dist_btn = new TextButton(line, rect_t{}, STR_RESET); + dist_btn->setPressHandler([=]() -> uint8_t { + DIRTY_CMD(cfg, afhds3::DirtyConfig::DC_RX_CMD_CALIB_DIST); + return 0; + }); + } + + if (cfg->others.sensorOnLine & (1 << afhds3::DirtyIbus2Sensor::IBUS2_SENSOR_GPS) || cfg->others.sensorOnLine & (1 << afhds3::DirtyIbus2Sensor::IBUS2_SENSOR_PRES)) { + line = body->newLine(grid); + new StaticText(line, rect_t{}, STR_ALTSENSOR); + auto alt_btn = new TextButton(line, rect_t{}, STR_RESET); + alt_btn->setPressHandler([=]() -> uint8_t { + DIRTY_CMD(cfg, afhds3::DirtyConfig::DC_RX_CMD_CALIB_ALT); + return 0; + }); + } + + // if (cfg->others.sensorOnLine & (1 << afhds3::DirtyIbus2Sensor::IBUS2_SENSOR_RPM)) { + // temp_str = "RPM"; + // temp_str += " "; + // temp_str += STR_CURRENTSENSOR; + // line = body->newLine(grid); + // new StaticText(line, rect_t{}, temp_str); + // new NumberEdit(line, rect_t{0, 0, EdgeTxStyles::EDIT_FLD_WIDTH_NARROW, 0}, 1, 12, GET_SET_DEFAULT(cfg->others.calibData[afhds3::DirtyIbus2Sensor::IBUS2_SENSOR_RPM])); + // } + + if (cfg->others.sensorOnLine & (1 << afhds3::DirtyIbus2Sensor::IBUS2_SENSOR_IBC)) { + // temp_str = "IBC01"; + // temp_str += " Auto "; + // temp_str += "Clear"; + // line = body->newLine(grid); + // new StaticText(line, rect_t{}, temp_str); + // new ToggleSwitch(line, rect_t{}, GET_SET_DEFAULT(cfg->others.calibData[afhds3::DirtyIbus2Sensor::IBUS2_SENSOR_IBC])); + + temp_str = "IBC01"; + temp_str += " "; + temp_str += STR_CALIBRATION; + line = body->newLine(grid); + new StaticText(line, rect_t{}, temp_str); + auto edit = new NumberEdit(line, rect_t{0, 0, EdgeTxStyles::EDIT_FLD_WIDTH_NARROW, 0}, 0, 120, GET_SET_DEFAULT(cfg->others.calibData[afhds3::DirtyIbus2Sensor::IBUS2_SENSOR_IBC]), PREC1); + edit->setSuffix("v"); + } +} diff --git a/radio/src/gui/colorlcd/module/afhds3_options.h b/radio/src/gui/colorlcd/module/afhds3_options.h index 3a7497b5987..2ec2239e790 100644 --- a/radio/src/gui/colorlcd/module/afhds3_options.h +++ b/radio/src/gui/colorlcd/module/afhds3_options.h @@ -31,3 +31,11 @@ class AFHDS3_Options : public Page public: AFHDS3_Options(uint8_t moduleIdx); }; + +class AFHDS3_Sensors : public Page +{ + afhds3::Config_u* cfg; + + public: + AFHDS3_Sensors(uint8_t moduleIdx); +}; diff --git a/radio/src/gui/colorlcd/module/afhds3_settings.cpp b/radio/src/gui/colorlcd/module/afhds3_settings.cpp index 97664c2f70f..a4bb68becd5 100644 --- a/radio/src/gui/colorlcd/module/afhds3_settings.cpp +++ b/radio/src/gui/colorlcd/module/afhds3_settings.cpp @@ -87,6 +87,11 @@ AFHDS3Settings::AFHDS3Settings(Window* parent, const FlexGridLayout& g, return 0; }); + new TextButton(afhds3TypeForm, rect_t{}, STR_TELEMETRY_SENSORS, [=]() { + new AFHDS3_Sensors(moduleIdx); + return 0; + }); + bool hasPowerOption = false; int maxPower; if (moduleIdx == INTERNAL_MODULE) { diff --git a/radio/src/pulses/afhds3.cpp b/radio/src/pulses/afhds3.cpp index a0203ef306b..5f57b57dc31 100644 --- a/radio/src/pulses/afhds3.cpp +++ b/radio/src/pulses/afhds3.cpp @@ -29,6 +29,7 @@ #include "../definitions.h" #include "telemetry/telemetry.h" +#include "telemetry/flysky_ibus2.h" #include "mixer_scheduler.h" #include "hal/module_driver.h" #include "hal/module_port.h" @@ -285,6 +286,8 @@ class ProtoState bool syncSettings(); + bool sensorCalibration(); + // void requestInfoAndRun(bool send = false); uint8_t setFailSafe(int16_t* target, uint8_t rfchannelcount=AFHDS3_MAX_CHANNELS); @@ -413,6 +416,18 @@ bool ProtoState::hasTelemetry() return cfg.v1.IsTwoWay; } +uint8_t ibus_type[SES_NPT_NB_MAX_PORTS] = {SES_NPT_IBUS1_IN}; +void setIbusType(uint8_t* ibus_type_buf) +{ + for(uint8_t i = 0; i< SES_NPT_NB_MAX_PORTS; i++) { + if (ibus_type_buf[i] == afhds3::SES_NPT_IBUS2 || ibus_type_buf[i] == afhds3::SES_NPT_IBUS2_HUB_PORT) { + ibus_type[i] = afhds3::SES_NPT_IBUS2; + } else { + ibus_type[i] = afhds3::SES_NPT_IBUS1_IN; + } + } +} + void ProtoState::setupFrame() { bool trsp_error = false; @@ -559,6 +574,8 @@ void ProtoState::setupFrame() // Sync config, with commands if (syncSettings()) { return; } + if (sensorCalibration()) { return; } + // Send channels data sendChannelsData(); } else { @@ -705,6 +722,7 @@ void ProtoState::parseData(uint8_t* rxBuffer, uint8_t rxBufferCount) this->rx_state = true; DIRTY_CMD( cfg, DC_RX_CMD_GET_RX_VERSION ); trsp.enqueue( COMMAND::MODULE_VERSION, FRAME_TYPE::REQUEST_GET_DATA ); + setIbusType(cfg->v1.NewPortTypes); // modelcfgGet = true; // cfg.others.isConnected = true; // cfg.others.lastUpdated = get_tmr10ms(); @@ -756,7 +774,19 @@ void ProtoState::parseData(uint8_t* rxBuffer, uint8_t rxBufferCount) break; } telemetry[0] = 0; - ::processFlySkyAFHDS3Sensor( telemetry, len-3 ); + uint8_t ibus_version = SES_NPT_IBUS1_IN; + for(uint8_t i = 0; i < SES_NPT_NB_MAX_PORTS; i++) { + // If ibus2 is configured, ignore ibus1. + if (ibus_type[i] == SES_NPT_IBUS2 || ibus_type[i] == SES_NPT_IBUS2_HUB_PORT) { + ibus_version = SES_NPT_IBUS2; + break; + } + } + if (ibus_version == afhds3::SES_NPT_IBUS2) { + ::processFlySkyIbus2AFHDS3Sensor(telemetry, len-3); + } else { + ::processFlySkyAFHDS3Sensor(telemetry, len-3); + } telemetry += len; } } @@ -812,6 +842,20 @@ void ProtoState::parseData(uint8_t* rxBuffer, uint8_t rxBufferCount) clearDirtyFlag(DC_RX_CMD_GET_RX_VERSION); } }break; + case RX_CMD_CODE_IBUS2_SET_PARAM: + { + uint8_t len = *data++; + ::Ibus2ParamCheck(data, len); + } + break; + case RX_CMD_CODE_IBUS2_GET_PARAM: + { + uint8_t len = *data++; + ::Ibus2ParamCheck(data, len); + // cfg->others.calibData[IBUS2_SENSOR_IBC] = getIbus2IbcState(); + // DIRTY_CMD(cfg, DC_RX_CMD_CLEAR_IBC); + } + break; default: break; } @@ -836,6 +880,50 @@ inline bool isPWM(uint8_t mode) return !(mode & 2); } +bool ProtoState::sensorCalibration() { + auto *cfg = this->getConfig(); + + static uint8_t data[30] = {0}; + uint8_t len = 0; + + uint8_t sensor_online = flyskyIbus2SensorOnLine(); + cfg->others.sensorOnLine = sensor_online; + + if (checkDirtyFlag(DC_RX_CMD_CALIB_GYRO)) { + + ::flySkyIbus2CalGpsGyro(data, &len); + trsp.putFrame( COMMAND::SEND_COMMAND, FRAME_TYPE::REQUEST_SET_EXPECT_DATA, data, len); + clearDirtyFlag(DC_RX_CMD_CALIB_GYRO); + return true; + } + + if (checkDirtyFlag(DC_RX_CMD_CALIB_ALT)) { + ::flySkyIbus2CalGpsAlt(); + clearDirtyFlag(DC_RX_CMD_CALIB_ALT); + return true; + } + + if (checkDirtyFlag(DC_RX_CMD_CALIB_DIST) ){ + ::flySkyIbus2CalGpsDist(); + clearDirtyFlag(DC_RX_CMD_CALIB_DIST); + return true; + } + + static uint32_t ibc_update_tick = 0; + static short last_ibc_v = cfg->others.calibData[IBUS2_SENSOR_IBC]; + if (last_ibc_v != cfg->others.calibData[IBUS2_SENSOR_IBC] ) { + if (timersGetMsTick() - ibc_update_tick > 2000) { // 2-second check + ibc_update_tick = timersGetMsTick(); + last_ibc_v = cfg->others.calibData[IBUS2_SENSOR_IBC]; + ::flySkyIbus2CalibIBC(data, &len, cfg->others.calibData[IBUS2_SENSOR_IBC]); + trsp.putFrame( COMMAND::SEND_COMMAND, FRAME_TYPE::REQUEST_SET_EXPECT_DATA, data, len); + return true; + } + } + + return false; +} + bool ProtoState::syncSettings() { @@ -888,7 +976,26 @@ bool ProtoState::syncSettings() { // TRACE("AFHDS3 [RX_CMD_PORT_TYPE_V1]"); uint8_t data[] = { (uint8_t)(RX_CMD_PORT_TYPE_V1&0xFF), (uint8_t)((RX_CMD_PORT_TYPE_V1>>8)&0xFF), 4, 0, 0, 0, 0 }; - std::memcpy(&data[3], &cfg->v1.NewPortTypes, SES_NPT_NB_MAX_PORTS); + setIbusType(cfg->v1.NewPortTypes); + // If pure is upgraded from multiple ibus2 to ibus2 hub + uint8_t tempPortTypes[SES_NPT_NB_MAX_PORTS] = {0}; + std::memcpy(tempPortTypes, cfg->v1.NewPortTypes, SES_NPT_NB_MAX_PORTS); + + uint8_t ibus2Count = 0; + for (uint8_t i = 0; i < SES_NPT_NB_MAX_PORTS; i++) { + if (tempPortTypes[i] == afhds3::SES_NPT_IBUS2) { + ibus2Count++; + } + } + if (ibus2Count >= 2) { + for (uint8_t i = 0; i < SES_NPT_NB_MAX_PORTS; i++) { + if (tempPortTypes[i] == afhds3::SES_NPT_IBUS2) { + tempPortTypes[i] = afhds3::SES_NPT_IBUS2_HUB_PORT; + } + } + } + + std::memcpy(&data[3], tempPortTypes, SES_NPT_NB_MAX_PORTS); trsp.putFrame(COMMAND::SEND_COMMAND, FRAME_TYPE::REQUEST_SET_EXPECT_DATA, data, sizeof(data)); return true; } diff --git a/radio/src/pulses/afhds3_config.h b/radio/src/pulses/afhds3_config.h index ea31c0733cc..c8ddda25c1b 100644 --- a/radio/src/pulses/afhds3_config.h +++ b/radio/src/pulses/afhds3_config.h @@ -100,7 +100,21 @@ enum DirtyConfig { DC_RX_CMD_BUS_DIRECTION, DC_RX_CMD_BUS_FAILSAFE, DC_RX_CMD_GET_VERSION, - DC_RX_CMD_GET_RX_VERSION + DC_RX_CMD_GET_RX_VERSION, + DC_RX_CMD_CALIB_GYRO, + DC_RX_CMD_CALIB_ALT, + DC_RX_CMD_CALIB_DIST, + DC_RX_CMD_CALIB_RPM, + DC_RX_CMD_CALIB_IBC_V, + DC_RX_CMD_CLEAR_IBC, +}; + +enum DirtyIbus2Sensor { + IBUS2_SENSOR_RPM, + IBUS2_SENSOR_GPS, + IBUS2_SENSOR_IBC, + IBUS2_SENSOR_PRES, + IBUS2_SENSOR_NUM, }; #define SET_AND_SYNC(cfg, value, dirtyCmd) [=](int32_t newValue) { value = newValue; cfg->others.dirtyFlag |= (uint32_t) 1 << dirtyCmd; } @@ -137,6 +151,8 @@ PACK(struct sDATA_Others { tmr10ms_t lastUpdated; // last updated time bool isConnected; // specify if receiver is connected uint32_t dirtyFlag; // mapped to commands that need to be issued to sync settings + short calibData[4]; // sensor Calibration + uint8_t sensorOnLine; }); union Config_u diff --git a/radio/src/pulses/afhds3_transport.h b/radio/src/pulses/afhds3_transport.h index 4595ab791b9..91a909bdab2 100644 --- a/radio/src/pulses/afhds3_transport.h +++ b/radio/src/pulses/afhds3_transport.h @@ -71,6 +71,8 @@ enum COMMAND : uint8_t { #define RX_CMD_IBUS_DIRECTION ( 0x7020 ) //IBUS INPUT or OUTPUT #define RX_CMD_BUS_FAILSAFE ( 0x702A ) #define RX_CMD_GET_VERSION ( 0x701F ) +#define RX_CMD_CODE_IBUS2_SET_PARAM ( 0x7025 ) +#define RX_CMD_CODE_IBUS2_GET_PARAM ( 0x7026 ) enum RX_CMDRESULT: uint8_t { diff --git a/radio/src/targets/common/arm/CMakeLists.txt b/radio/src/targets/common/arm/CMakeLists.txt index 2bb691fd4dd..564ace9c2c0 100644 --- a/radio/src/targets/common/arm/CMakeLists.txt +++ b/radio/src/targets/common/arm/CMakeLists.txt @@ -228,6 +228,7 @@ endif() if(MULTIMODULE OR AFHDS3) set(SRC ${SRC} telemetry/flysky_ibus.cpp) + set(SRC ${SRC} telemetry/flysky_ibus2.cpp) endif() if(AFHDS2 AND INTERNAL_MODULE_AFHDS2A) @@ -259,6 +260,7 @@ endif() if(MULTIMODULE OR AFHDS3) set(SRC ${SRC} telemetry/flysky_ibus.cpp ) + set(SRC ${SRC} telemetry/flysky_ibus2.cpp) endif() if(GHOST) diff --git a/radio/src/telemetry/flysky_ibus2.cpp b/radio/src/telemetry/flysky_ibus2.cpp new file mode 100644 index 00000000000..eee6f078518 --- /dev/null +++ b/radio/src/telemetry/flysky_ibus2.cpp @@ -0,0 +1,730 @@ +/* + * Copyright (C) EdgeTX + * + * Based on code named + * opentx - https://github.com/opentx/opentx + * th9x - http://code.google.com/p/th9x + * er9x - http://code.google.com/p/er9x + * gruvin9x - http://code.google.com/p/gruvin9x + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +#include + +#include "edgetx.h" +#include "pulses/afhds3_config.h" +#include "pulses/afhds3_transport.h" + +#define FLYSKY_TELEMETRY_LENGTH (2 + 7 * 4) +#define ALT_PRECISION 15 +#define PRESSURE_MASK 0x7FFFF +#define REMAP_CONST 0x1000 // Some part of OpenTX does not like sensor with + // id and instance 0, remap to 0x1000 +#define IBUS2_CALIB_SET_PARAM 0x1234 +#define IBUS2_CALIB_IBC01 0x0003 +#define IBUS2_CALIB_RPM 0xD001 + +enum { + GPS_MSG_TYPE_PACK1 = 1, + GPS_MSG_TYPE_PACK2, + GPS_MSG_TYPE_PACK3, + GPS_MSG_TYPE_PACK4, + GPS_MSG_TYPE_CALI = 0x0F, + GPS_MSG_TYPE_NUM, +}; + +enum { + GPS_STATE_GET_POSITION = 3, + GPS_STATE_NUM, +}; + +typedef struct { + unsigned char NbSatellites; + unsigned char PositionStatus; //- + unsigned char DateDay; + unsigned char DateMonth; + unsigned short DateYear; // Year from 2000 + unsigned short Speed; // In 1/100th of meters + unsigned long UTCTime; // In milliseconds from midnight + int32_t Latitude; // In 1/1000000th of degree from -90 to +90 degrees + int32_t Longitude; // In 1/1000000th of degree from -180 to +180 degrees + int32_t Direction; // True direction in 1/10 of degree (0=North) + int32_t Altitude; // In 1/100th of meters + float Pitch; //- + float Roll; //- + float Yaw; //- + int32_t Distance; //- + int16_t Acceleration; // acc + int16_t HeightChange; // +} Ibus2Gps_t; + +typedef struct { + int32_t Latitude; // In 1/1000000th of degree from -90 to +90 degrees + int32_t Longitude; // In 1/1000000th of degree from -180 to +180 degrees + int32_t Altitude; // In 1/100th of meters + int32_t Boar_Altitude; +} StartPos_t; + +typedef struct { + short Voltage; // unit 0.1V + short Current; // unit 0.1A + uint16_t UsedCapacity; // unit 1mAh + uint16_t RunTime; // unit 1second + short AverageCurrent; // unit 0.1A + short MaxVoltage; // unit 0.1V + short MinVoltage; // unit 0.1V + short MaxCurrent; // unit 0.1A +} Ibus2Ibc_t; + +typedef struct flysky_ibus2 { + uint8_t id; + uint16_t type; + uint8_t ParameterData[16]; +} Ibus2Param_t; + +Ibus2Gps_t GPSData = {0}; +Ibus2Ibc_t IBCDate = {0}; +StartPos_t StartPos = {0}; +Ibus2Param_t Ibus2DevPara = {0}; + +struct FlySkySensor { + const uint16_t type; + const TelemetryUnit unit; + const uint8_t precision; + const char* name; +}; +// clang-format off +enum +{ + IBDT_INT_VOLTAGE = 0x00 | REMAP_CONST, + IBDT_TEMPERATURE = 0x01, // Temperature + IBDT_ROTATION_SPEED = 0x02, // RPM + IBDT_EXT_VOLTAGE = 0x03, // Sensor voltage + IBDT_GPS = 0x40, // GPS + IBDT_PRESSURE = 0x41, // Pressure + IBDT_COMPASS = 0x42, + IBDT_IBC01 = 0x43, // Voltage and current sensor + IBDT_REDUNDANT_RECEIVER = 0x44, + IBDT_REDUNDANT_RECEIVER_E = 0x45, + IBDT_FLIGHT = 0x70, + IBDT_TX_VOLTAGE = 0x7F, + IBDT_PRESSURE_SENSOR = 0x80, + IBDT_AIRSPEED_SENSOR = 0x81, + IBDT_EXT_BVD = 0xA0, + IBDT_HUB_1_PORTS_PWM = 0xE0, // 1-port hub in PWM mode + IBDT_HUB_2_PORTS_PWM = 0xE1, // 2-port hub in PWM mode + IBDT_HUB_3_PORTS_PWM = 0xE2, // 3-port hub in PWM mode + IBDT_HUB_4_PORTS_PWM = 0xE3, // 4-port hub in PWM mode + IBDT_HUB_5_PORTS_PWM = 0xE4, // 5-port hub in PWM mode + IBDT_HUB_6_PORTS_PWM = 0xE5, // 6-port hub in PWM mode + IBDT_HUB_7_PORTS_PWM = 0xE6, // 7-port hub in PWM mode + IBDT_8_PORTS_PWM_ADAPTER = 0xE7, // 8-port PWM adapter + IBDT_9_PORTS_PWM_ADAPTER = 0xE8, // 9-port PWM adapter + IBDT_10_PORTS_PWM_ADAPTER = 0xE9, // 10-port PWM adapter + IBDT_11_PORTS_PWM_ADAPTER = 0xEA, // 11-port PWM adapter + IBDT_12_PORTS_PWM_ADAPTER = 0xEB, // 12-port PWM adapter + IBDT_13_PORTS_PWM_ADAPTER = 0xEC, // 13-port PWM adapter + IBDT_14_PORTS_PWM_ADAPTER = 0xED, // 14-port PWM adapter + IBDT_15_PORTS_PWM_ADAPTER = 0xEE, // 15-port PWM adapter + IBDT_16_PORTS_PWM_ADAPTER = 0xEF, // 16-port PWM adapter + IBDT_17_PORTS_PWM_ADAPTER = 0xF0, // 17-port PWM adapter + IBDT_HUB_1_PORTS_HUB = 0xF1, // 1-port hub in hub mode + IBDT_HUB_2_PORTS_HUB = 0xF2, // 2-port hub in hub mode + IBDT_HUB_3_PORTS_HUB = 0xF3, // 3-port hub in hub mode + IBDT_HUB_4_PORTS_HUB = 0xF4, // 4-port hub in hub mode + IBDT_HUB_5_PORTS_HUB = 0xF5, // 5-port hub in hub mode + IBDT_HUB_6_PORTS_HUB = 0xF6, // 6-port hub in hub mode + IBDT_HUB_7_PORTS_HUB = 0xF7, // 7-port hub in hub mode + IBDT_DIGITAL_SERVO = 0xF8, + IBDT_SNR = 0xFA, + IBDT_BK_NOISE = 0xFB, + IBDT_RSSI = 0xFC, + IBDT_SERVO_HUB = 0xFD, // i-Bus 1 only + IBDT_SIGNAL_STRENGTH = 0xFE, + IBDT_NONE = 0xFF, + + VIRTUAL_ALT = 0x1041, // virtual + VIRTUAL_REL_ALT = 0x1141, // virtual + VIRTUAL_GPS_STAS = 0x1040, + VIRTUAL_GPS_TIME = 0x1140, + VIRTUAL_GPS_LAT = 0x1240, + VIRTUAL_GPS_LON = 0x1340, + VIRTUAL_GPS_KMH = 0x1440, + VIRTUAL_GPS_PITCH = 0x1540, + VIRTUAL_GPS_ROLL = 0x1640, + VIRTUAL_GPS_YAW = 0x1740, + VIRTUAL_GPS_DIST = 0x1840, + VIRTUAL_GPS_ACC = 0x1940, + VIRTUAL_GPS_SPEED = 0x1A40, + VIRTUAL_GPS_ALT = 0x1B40, + VIRTUAL_GPS_REL_ALT = 0x1C40, + + VIRTUAL_IBC_VOLTS = 0x1043, + VIRTUAL_IBC_CURR = 0x1143, + VIRTUAL_IBC_CAPA = 0x1243, + VIRTUAL_IBC_RUMTIME = 0x1343, + VIRTUAL_IBC_AVG_CURR = 0x1443, +}; +// clang-format on + +#define FS(type, name, unit, precision) {type, unit, precision, name} + +extern int32_t getALT(uint32_t value); + +void flyskyIbus2GPS(const uint8_t* pData, uint8_t len, uint8_t id); +void flyskyIbus2IBC(const uint8_t* pData, uint8_t len, uint8_t id); +void setIbus2Param(uint8_t* pData); +void getIbus2Param(uint8_t* pData); + +void sendFlyskytelemtry(uint16_t type, uint8_t id, int32_t value); + +int32_t GpsSensorDistanceGet(); +int32_t GpsSensorHeighthGet(); + +uint8_t flyskyGpsId = 0; +uint8_t flyskyIbcId = 0; +uint8_t flyskyRpmId = 0; +bool reset_gps_dist = false; +bool reset_gps_alt = false; +bool reset_baro_alt = false; + +uint32_t gps_update_tick = 0; +uint32_t ibc_update_tick = 0; +uint32_t rpm_update_tick = 0; +uint32_t pres_update_tick = 0; + +// clang-format off +const FlySkySensor flySkySensors[] = { + // flysky start + FS(IBDT_INT_VOLTAGE, STR_SENSOR_BATT, UNIT_VOLTS, 2), + FS(IBDT_TEMPERATURE, STR_SENSOR_TEMP1, UNIT_CELSIUS, 1), + FS(IBDT_ROTATION_SPEED, STR_SENSOR_RPM, UNIT_RAW, 0), + FS(IBDT_EXT_VOLTAGE, STR_SENSOR_A3, UNIT_VOLTS, 2), + FS(IBDT_GPS, STR_SENSOR_GPS, UNIT_GPS, 2), + + FS(VIRTUAL_GPS_STAS, STR_SENSOR_SATELLITES, UNIT_RAW, 0), + FS(VIRTUAL_GPS_TIME, STR_SENSOR_GPSDATETIME, UNIT_DATETIME, 0), + FS(VIRTUAL_GPS_LAT, STR_SENSOR_GPS, UNIT_GPS_LATITUDE, 0), + FS(VIRTUAL_GPS_LON, STR_SENSOR_GPS, UNIT_GPS_LONGITUDE, 0), + FS(VIRTUAL_GPS_KMH, STR_SENSOR_GSPD, UNIT_KMH, 1), + FS(VIRTUAL_GPS_PITCH, STR_SENSOR_PITCH, UNIT_DEGREE, 1), + FS(VIRTUAL_GPS_ROLL, STR_SENSOR_ROLL, UNIT_DEGREE, 1), + FS(VIRTUAL_GPS_YAW, STR_SENSOR_YAW, UNIT_DEGREE, 1), + FS(VIRTUAL_GPS_DIST, STR_SENSOR_DIST, UNIT_METERS, 2), + FS(VIRTUAL_GPS_ACC, STR_SENSOR_ACC, UNIT_RAW, 0), + FS(VIRTUAL_GPS_SPEED, STR_SENSOR_GSPD, UNIT_SPEED, 2), + FS(VIRTUAL_GPS_ALT, STR_SENSOR_GPSALT, UNIT_METERS, 0), + FS(VIRTUAL_GPS_REL_ALT, "G_RH", UNIT_METERS, 0), + + FS(IBDT_PRESSURE, STR_SENSOR_PRES, UNIT_RAW, 2), + FS(VIRTUAL_ALT, STR_SENSOR_ALT, UNIT_METERS, 2), + FS(VIRTUAL_REL_ALT, "P_RH", UNIT_METERS, 2), + FS(IBDT_COMPASS, "COMPASS", UNIT_RAW, 2), + FS(IBDT_IBC01, "IBC", UNIT_RAW, 2), + FS(VIRTUAL_IBC_VOLTS, STR_SENSOR_BATT1_VOLTAGE, UNIT_VOLTS, 1), + FS(VIRTUAL_IBC_CURR, STR_SENSOR_CURR, UNIT_AMPS, 1), + FS(VIRTUAL_IBC_CAPA, STR_SENSOR_CAPACITY, UNIT_MAH, 0), + FS(VIRTUAL_IBC_AVG_CURR, "AVG_CURR", UNIT_AMPS, 1), + + FS(IBDT_REDUNDANT_RECEIVER, "REDUNDANT", UNIT_RAW, 0), + FS(IBDT_REDUNDANT_RECEIVER_E, "REDUNDANT_E", UNIT_RAW, 0), + FS(IBDT_FLIGHT, "FLIGHT", UNIT_RAW, 0), + FS(IBDT_TX_VOLTAGE, STR_SENSOR_TXV, UNIT_VOLTS, 2), + FS(IBDT_PRESSURE_SENSOR, "PRESSURE_SENSOR", UNIT_RAW, 2), + FS(IBDT_AIRSPEED_SENSOR, "AIRSPEED_SENSOR", UNIT_RAW, 2), + FS(IBDT_EXT_BVD, "BVD", UNIT_VOLTS, 2), + FS(IBDT_HUB_1_PORTS_PWM, "HUB_PWM1", UNIT_RAW, 0), + FS(IBDT_HUB_2_PORTS_PWM, "HUB_PWM2", UNIT_RAW, 0), + FS(IBDT_HUB_3_PORTS_PWM, "HUB_PWM3", UNIT_RAW, 0), + FS(IBDT_HUB_4_PORTS_PWM, "HUB_PWM4", UNIT_RAW, 0), + FS(IBDT_HUB_5_PORTS_PWM, "HUB_PWM5", UNIT_RAW, 0), + FS(IBDT_HUB_6_PORTS_PWM, "HUB_PWM6", UNIT_RAW, 0), + FS(IBDT_HUB_7_PORTS_PWM, "HUB_PWM7", UNIT_RAW, 0), + FS(IBDT_8_PORTS_PWM_ADAPTER, "ADAPTER_8", UNIT_RAW, 0), + FS(IBDT_9_PORTS_PWM_ADAPTER, "ADAPTER_9", UNIT_RAW, 0), + FS(IBDT_10_PORTS_PWM_ADAPTER, "ADAPTER_10", UNIT_RAW, 0), + FS(IBDT_11_PORTS_PWM_ADAPTER, "ADAPTER_11", UNIT_RAW, 0), + FS(IBDT_12_PORTS_PWM_ADAPTER, "ADAPTER_12", UNIT_RAW, 0), + FS(IBDT_13_PORTS_PWM_ADAPTER, "ADAPTER_13", UNIT_RAW, 0), + FS(IBDT_14_PORTS_PWM_ADAPTER, "ADAPTER_14", UNIT_RAW, 0), + FS(IBDT_15_PORTS_PWM_ADAPTER, "ADAPTER_15", UNIT_RAW, 0), + FS(IBDT_16_PORTS_PWM_ADAPTER, "ADAPTER_16", UNIT_RAW, 0), + FS(IBDT_17_PORTS_PWM_ADAPTER, "ADAPTER_17", UNIT_RAW, 0), + FS(IBDT_HUB_1_PORTS_HUB, "HUB1", UNIT_VOLTS, 3), + FS(IBDT_HUB_2_PORTS_HUB, "HUB2", UNIT_VOLTS, 3), + FS(IBDT_HUB_3_PORTS_HUB, "HUB3", UNIT_VOLTS, 3), + FS(IBDT_HUB_4_PORTS_HUB, "HUB4", UNIT_VOLTS, 3), + FS(IBDT_HUB_5_PORTS_HUB, "HUB5", UNIT_VOLTS, 3), + FS(IBDT_HUB_6_PORTS_HUB, "HUB6", UNIT_VOLTS, 3), + FS(IBDT_HUB_7_PORTS_HUB, "HUB7", UNIT_VOLTS, 3), + FS(IBDT_DIGITAL_SERVO, "DSERVO", UNIT_RAW, 0), + FS(IBDT_SNR, STR_SENSOR_RX_SNR, UNIT_DB, 0), + FS(IBDT_BK_NOISE, STR_SENSOR_RX_NOISE, UNIT_DBM, 0), + FS(IBDT_RSSI, STR_SENSOR_RSSI, UNIT_DBM, 0), + FS(IBDT_SERVO_HUB, "SERVO_HUB", UNIT_RAW, 0), + FS(IBDT_SIGNAL_STRENGTH, STR_SENSOR_RX_QUALITY, UNIT_PERCENT, 0), + FS(IBDT_NONE, NULL, UNIT_RAW, 0), +}; +// clang-format off + +inline int setFlyskyTelemetryValue( int16_t type, uint8_t instance, int32_t value, uint32_t unit, uint32_t prec) +{ + return setTelemetryValue(PROTOCOL_TELEMETRY_FLYSKY_IBUS2, type, 0, instance, value, unit, prec ); +} + + +void processFlySkyIbus2AFHDS3Sensor(const uint8_t * packet, uint8_t len ) +{ + uint16_t type = (packet[0] << 8) | packet[1]; + uint8_t id = packet[2]; + int32_t value=0; + type = type ? type : IBDT_INT_VOLTAGE; // Remapped + + // TRACE("[IBUS] type x%02X, len %d", type, len); + if(len == 1) + { + value = packet[3]; // type 0xfe SIGNAL_STRENGTH + } + else if (len == 2) + { + value = (packet[4] << 8) | packet[3]; + } + else if(len == 4) + { + value = (packet[6] << 24) | (packet[5] << 16) | (packet[4] << 8) | packet[3]; // PRESSURE + } else { + // gps & ibc01 + } + + if (IBDT_BK_NOISE == type || IBDT_RSSI == type) + { + value = -value; + } + + if ( (IBDT_EXT_VOLTAGE == type) ) + { + if ( id&0x80 ) + { + type = IBDT_EXT_BVD; + } + } + else if(IBDT_RSSI == type || IBDT_BK_NOISE == type || IBDT_SNR == type) + { + if( value>=0 ) + value = (value+2)/4; + else + value = (value-2)/4; + } + else if (IBDT_SIGNAL_STRENGTH == type) + { + telemetryData.rssi.set( value ); + if (value > 0) telemetryStreaming = TELEMETRY_TIMEOUT10ms; + } + else if (IBDT_PRESSURE == type) + { + int32_t alt = getALT(value); + // int16_t temp = (value >> 19); + if (reset_baro_alt) { + reset_baro_alt = !reset_baro_alt; + StartPos.Boar_Altitude = alt; + } else { + int32_t RH = alt - StartPos.Boar_Altitude; + type = VIRTUAL_REL_ALT; + sendFlyskytelemtry(type, id, RH); + // TRACE("[IBUS2] RH = %d", RH); + } + + type = VIRTUAL_ALT; + sendFlyskytelemtry(type, id, alt); + + pres_update_tick = timersGetMsTick(); + value &= PRESSURE_MASK; + type = IBDT_PRESSURE; + sendFlyskytelemtry(type, id, value); + return; + } else if (IBDT_ROTATION_SPEED == type) { + // Adjust the rotational speed based on the number of blades + // RPM = value; + flyskyRpmId = id; + rpm_update_tick = timersGetMsTick(); + } else if (IBDT_GPS == type) { + flyskyGpsId = id; + flyskyIbus2GPS(&packet[3], len, id); + return; + } else if (IBDT_IBC01 == type) { + flyskyIbus2IBC(&packet[3], len, id); + return; + } + + if(IBDT_TEMPERATURE == type) + { + value -= 400; // Temperature sensors have 40 degree offset + } + + sendFlyskytelemtry(type, id, value); +} + +void flyskyIbus2GPS(const uint8_t * pData, uint8_t len, uint8_t id) { + static uint16_t PSValue; + uint16_t n = 0; + int32_t value = 0; + uint16_t type = 0; + static bool get_start = true; // Get the location at startup + + gps_update_tick = timersGetMsTick(); + + PSValue = pData[n] | (pData[n+1] << 8); + if( GPS_MSG_TYPE_PACK1 == (PSValue & 0x000f) ) + { + GPSData.NbSatellites = (PSValue >> 4) & 0x003F ; n++; + GPSData.PositionStatus = (PSValue >> 10) & 0x003F ;n++; + value = GPSData.NbSatellites; + type = VIRTUAL_GPS_STAS; + sendFlyskytelemtry(type, id, value); + + PSValue = pData[n] | (pData[n+1] << 8); + GPSData.DateDay = (PSValue & 0x001F); + GPSData.DateMonth = (PSValue >> 5) & 0x000F; n++; + GPSData.DateYear = (PSValue >> 9) & 0x007F; n++; + value = GPSData.DateYear << 24 | GPSData.DateMonth << 16 | GPSData.DateDay << 8 | 0xff ; + type = VIRTUAL_GPS_TIME; + sendFlyskytelemtry(type, id, value); + + PSValue = pData[n] | (pData[n+1] << 8); + GPSData.Speed = (PSValue); n++;n++; + value = GPSData.Speed; + type = VIRTUAL_GPS_SPEED; + sendFlyskytelemtry(type, id, value); + + GPSData.UTCTime = pData[n] | (pData[n+1] << 8) | (pData[n+2] << 16) | (pData[n+3] << 24); + n++;n++;n++;n++; + // value = GPSData.UTCTime; + uint32_t total_seconds = GPSData.UTCTime; + uint8_t sec = total_seconds % 60; + uint32_t total_minutes = total_seconds / 60; + uint8_t min = total_minutes % 60; + uint8_t hour = total_minutes / 60; + type = VIRTUAL_GPS_TIME; + value = 0x00; + value = hour << 24 | min << 16 | sec << 8; + sendFlyskytelemtry(type, id, value); + // TRACE("[IBUS2] utc %d %d %d %d ", GPSData.DateYear, GPSData.DateMonth, GPSData.DateDay, GPSData.UTCTime); + + PSValue = pData[n] | (pData[n+1] << 8); + GPSData.Altitude = (PSValue); n++;n++; + type = VIRTUAL_GPS_ALT; + value = PSValue; + sendFlyskytelemtry(type, id, value); + + PSValue = pData[n] | (pData[n+1] << 8); + GPSData.Direction = (PSValue); n++;n++; + + type = VIRTUAL_GPS_REL_ALT; + if (!get_start) { + value = GpsSensorHeighthGet(); + } else { + value = 0; + } + sendFlyskytelemtry(type, id, value); + } + else if( GPS_MSG_TYPE_PACK2 == (PSValue & 0x000f) ) + { + GPSData.Pitch = (PSValue >> 4) & 0x0FFF ; n++;n++; + value = GPSData.Pitch - 1800; + type = VIRTUAL_GPS_PITCH; + sendFlyskytelemtry(type, id, value); + + PSValue = pData[n] | (pData[n+1] << 8); + GPSData.Roll = (PSValue); n++;n++; + value = GPSData.Roll - 1800; + type = VIRTUAL_GPS_ROLL; + sendFlyskytelemtry(type, id, value); + + PSValue = pData[n] | (pData[n+1] << 8); + GPSData.Yaw = (PSValue); n++;n++; + value = 3600 - GPSData.Yaw; + type = VIRTUAL_GPS_YAW; + sendFlyskytelemtry(type, id, value); + + GPSData.Latitude = (pData[n] | (pData[n+1] << 8) | (pData[n+2] << 16) | (pData[n+3] << 24)); + value = GPSData.Latitude; + type = VIRTUAL_GPS_LAT; + sendFlyskytelemtry(type, id, value); + + n++;n++;n++;n++; + GPSData.Longitude = (pData[n] | (pData[n+1] << 8) | (pData[n+2] << 16) | (pData[n+3] << 24)); + value = GPSData.Longitude; + type = VIRTUAL_GPS_LON; + sendFlyskytelemtry(type, id, value); + + + type = VIRTUAL_GPS_DIST; + if (!get_start) { + value = GpsSensorDistanceGet(); + } else { + value = 0; + } + sendFlyskytelemtry(type, id, value); + + } + + if (get_start && GPSData.PositionStatus == 3) { + get_start = false; + StartPos.Altitude = GPSData.Altitude; + StartPos.Latitude = GPSData.Latitude; + StartPos.Longitude = GPSData.Longitude; + } else { + if (reset_gps_alt) { + StartPos.Altitude = GPSData.Altitude; + reset_gps_alt = false; + } + if (reset_gps_dist) { + StartPos.Latitude = GPSData.Latitude; + StartPos.Longitude = GPSData.Longitude; + reset_gps_dist = false; + } + } +} + +void flyskyIbus2IBC(const uint8_t * pData, uint8_t len, uint8_t id) { + int32_t value = 0; + uint16_t type = 0; + memcpy(&IBCDate, pData, sizeof(IBCDate)); + + ibc_update_tick = timersGetMsTick(); + + type = VIRTUAL_IBC_VOLTS; + value = IBCDate.Voltage; + sendFlyskytelemtry(type, id, value); + + type = VIRTUAL_IBC_CURR; + value = IBCDate.Current; + sendFlyskytelemtry(type, id, value); + + type = VIRTUAL_IBC_CAPA; + value = IBCDate.UsedCapacity; + sendFlyskytelemtry(type, id, value); + + type = VIRTUAL_IBC_AVG_CURR; + value = IBCDate.AverageCurrent; + sendFlyskytelemtry(type, id, value); + + flyskyIbcId = id; +} + +uint8_t getIbcVoltags(void) { + return IBCDate.Voltage; +} + +void sendFlyskytelemtry(uint16_t type, uint8_t id, int32_t value) { + for (const FlySkySensor * sensor = flySkySensors; sensor->type; sensor++) + { + if (sensor->type != type) continue; + + if (sensor->unit == UNIT_VOLTS) value = (int16_t) value; // Voltage types are unsigned 16bit integers + + // Remapped to single GPS sensor: + if (type == VIRTUAL_GPS_LON) type = VIRTUAL_GPS_LAT; + setFlyskyTelemetryValue(type, id, value, sensor->unit, sensor->precision); + return; + } +} + +const FlySkySensor * getFlySkyIbus2Sensor(uint16_t id) +{ + for (const FlySkySensor * sensor = flySkySensors; sensor->type; sensor++) { + if (id == sensor->type) + return sensor; + } + return nullptr; +} + +void flySkyIbus2SetDefault(int index, uint16_t id, uint8_t subId, uint8_t instance) +{ + TelemetrySensor &telemetrySensor = g_model.telemetrySensors[index]; + telemetrySensor.id = id; + telemetrySensor.subId = subId; + telemetrySensor.instance = instance; + + const FlySkySensor * sensor = getFlySkyIbus2Sensor(id); + if (sensor) { + TelemetryUnit unit = sensor->unit; + uint8_t prec = min(2, sensor->precision); + telemetrySensor.init(sensor->name, unit, prec); + + if (unit == UNIT_RPMS) { + telemetrySensor.custom.ratio = 1; + telemetrySensor.custom.offset = 1; + } else if (unit == UNIT_GPS_LATITUDE || unit == UNIT_GPS_LONGITUDE) { + telemetrySensor.unit = UNIT_GPS; + } + } + else { + telemetrySensor.init(id); + } + + storageDirty(EE_MODEL); +} + +double deg2rad(double deg) { + return deg * M_PI / 180.0; +} + +static double GetDistance(double lat1, double lng1, double lat2, double lng2) +{ + lat1 =lat1/1000000; lng1= lng1/1000000; + lat2 =lat2/1000000; lng2= lng2/1000000; + + double radLat1 = deg2rad(lat1); + double radLat2 = deg2rad(lat2); + + double a = radLat1 - radLat2; + double b = deg2rad(lng1) - deg2rad(lng2); + + float s = 2.0f * asin(sqrt(pow(sin(a/2),2) + + cos(radLat1)*cos(radLat2)*pow(sin(b/2),2))); + + s = s * EARTH_RADIUS * 100; //cm + return s; +} + +int32_t GpsSensorDistanceGet() +{ + double lats ,lngs, late, lnge; + + lats = StartPos.Latitude; + lngs = StartPos.Longitude; + late = GPSData.Latitude; + lnge = GPSData.Longitude; + return GetDistance(lats, lngs, late, lnge); +} + +int32_t GpsSensorHeighthGet() +{ + return GPSData.Altitude - StartPos.Altitude; +} + +void flySkyIbus2CalGpsGyro(uint8_t* packet, uint8_t* len) +{ + Ibus2DevPara.type = IBUS2_CALIB_SET_PARAM; + Ibus2DevPara.id = flyskyGpsId; + Ibus2DevPara.ParameterData[0] = 0x55; + Ibus2DevPara.ParameterData[1] = 0x01; + Ibus2DevPara.ParameterData[2] = 0x01; + setIbus2Param(packet); + *len = 22; +} + +void flySkyIbus2CalGpsAlt() +{ + reset_gps_alt = true; + reset_baro_alt = true; +} + +void flySkyIbus2CalGpsDist() +{ + reset_gps_dist = true; +} + +void flySkyIbus2ReadParamRPM(uint8_t* packet, uint8_t* len) +{ + Ibus2DevPara.type = IBUS2_CALIB_RPM; + Ibus2DevPara.id = flyskyRpmId; + getIbus2Param(packet); + *len = 6; +} + +void flySkyIbus2CalibIBC(uint8_t* packet, uint8_t* len, short voltags) +{ + Ibus2DevPara.type = IBUS2_CALIB_SET_PARAM; + Ibus2DevPara.id = flyskyIbcId; + Ibus2DevPara.ParameterData[0] = (uint8_t)(voltags & 0xff); + Ibus2DevPara.ParameterData[1] = (uint8_t)(voltags >> 8); + setIbus2Param(packet); + *len = 22; +} + +void setIbus2Param(uint8_t* pData) { + uint8_t n = 0; + pData[n++] = ( uint8_t )( RX_CMD_CODE_IBUS2_SET_PARAM ); + pData[n++] = ( uint8_t )( RX_CMD_CODE_IBUS2_SET_PARAM >> 8 ); + pData[n++] = 19; //data length + pData[n++] = Ibus2DevPara.id; + pData[n++] = Ibus2DevPara.type; + pData[n++] = Ibus2DevPara.type >> 8; + pData[n++] = Ibus2DevPara.ParameterData[0]; + pData[n++] = Ibus2DevPara.ParameterData[1]; + pData[n++] = Ibus2DevPara.ParameterData[2]; + pData[n++] = Ibus2DevPara.ParameterData[3]; + pData[n++] = Ibus2DevPara.ParameterData[4]; + pData[n++] = Ibus2DevPara.ParameterData[5]; + pData[n++] = Ibus2DevPara.ParameterData[6]; + pData[n++] = Ibus2DevPara.ParameterData[7]; + pData[n++] = Ibus2DevPara.ParameterData[8]; + pData[n++] = Ibus2DevPara.ParameterData[9]; + pData[n++] = Ibus2DevPara.ParameterData[10]; + pData[n++] = Ibus2DevPara.ParameterData[11]; + pData[n++] = Ibus2DevPara.ParameterData[12]; + pData[n++] = Ibus2DevPara.ParameterData[13]; + pData[n++] = Ibus2DevPara.ParameterData[14]; + pData[n++] = Ibus2DevPara.ParameterData[15]; +} + +void getIbus2Param(uint8_t* pData) { + uint8_t n = 0; + pData[n++] = ( uint8_t )(RX_CMD_CODE_IBUS2_GET_PARAM); + pData[n++] = ( uint8_t )(RX_CMD_CODE_IBUS2_GET_PARAM >> 8); + pData[n++] = 3;//data length + pData[n++] = Ibus2DevPara.id; + pData[n++] = Ibus2DevPara.type; + pData[n++] = Ibus2DevPara.type >> 8; +} + +bool ibc_state = false; +void Ibus2ParamCheck(uint8_t* packet, uint8_t len) { + if (len != 4) { + return; + } + uint8_t id = packet[0]; + uint8_t code = packet[3]; + if (id == flyskyIbcId) { + ibc_state = code; + } else if (id == flyskyGpsId) { + // todo + } else if (id == flyskyRpmId) { + // todo + } +} + +bool getIbus2IbcState() { + return ibc_state; +} + +uint8_t flyskyIbus2SensorOnLine() { + uint32_t now = timersGetMsTick(); + uint8_t sensor_on_line = 0; + if (now - ibc_update_tick < 2000) { + sensor_on_line |= 1 << afhds3::IBUS2_SENSOR_IBC; + } + if (now - gps_update_tick < 2000) { + sensor_on_line |= 1 << afhds3::IBUS2_SENSOR_GPS; + } + if (now - rpm_update_tick < 2000) { + sensor_on_line |= 1 << afhds3::IBUS2_SENSOR_RPM; + } + if (now - pres_update_tick < 2000) { + sensor_on_line |= 1 << afhds3::IBUS2_SENSOR_PRES; + } + + return sensor_on_line; +} diff --git a/radio/src/telemetry/flysky_ibus2.h b/radio/src/telemetry/flysky_ibus2.h new file mode 100644 index 00000000000..e41a0cedbd8 --- /dev/null +++ b/radio/src/telemetry/flysky_ibus2.h @@ -0,0 +1,36 @@ +/* + * Copyright (C) EdgeTX + * + * Based on code named + * opentx - https://github.com/opentx/opentx + * th9x - http://code.google.com/p/th9x + * er9x - http://code.google.com/p/er9x + * gruvin9x - http://code.google.com/p/gruvin9x + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +#pragma once + +void flySkyIbus2SetDefault(int index, uint16_t id, uint8_t subId, + uint8_t instance); + +void processFlySkyIbus2AFHDS3Sensor(const uint8_t* packet, uint8_t len); + +void flySkyIbus2CalGpsGyro(uint8_t* packet, uint8_t* len); +void flySkyIbus2CalibIBC(uint8_t* packet, uint8_t* len, short voltags); +void flySkyIbus2CalGpsAlt(); +void flySkyIbus2CalGpsDist(); +void Ibus2ParamCheck(uint8_t* packet, uint8_t len); +void flySkyIbus2ReadParamRPM(uint8_t* packet, uint8_t* len); +bool getIbus2IbcState(); +uint8_t flyskyIbus2SensorOnLine(); diff --git a/radio/src/telemetry/telemetry_sensors.cpp b/radio/src/telemetry/telemetry_sensors.cpp index 72264890fdb..4cab970be5c 100644 --- a/radio/src/telemetry/telemetry_sensors.cpp +++ b/radio/src/telemetry/telemetry_sensors.cpp @@ -51,6 +51,10 @@ #include "flysky_ibus.h" #endif +#if defined(AFHDS3) + #include "flysky_ibus2.h" +#endif + TelemetryItem telemetryItems[MAX_TELEMETRY_SENSORS]; bool allowNewSensors; @@ -553,6 +557,12 @@ int setTelemetryValue(TelemetryProtocol protocol, uint16_t id, uint8_t subId, break; #endif +#if defined(AFHDS3) + case PROTOCOL_TELEMETRY_FLYSKY_IBUS2: + flySkyIbus2SetDefault(index, id, subId, instance); + break; +#endif + #if defined(AFHDS2) && defined(RADIO_NV14_FAMILY) case PROTOCOL_TELEMETRY_FLYSKY_NV14: flySkyNv14SetDefault(index, id, subId, instance); diff --git a/radio/src/translations/i18n/cn.h b/radio/src/translations/i18n/cn.h index cd09250998e..5e5d5acc369 100644 --- a/radio/src/translations/i18n/cn.h +++ b/radio/src/translations/i18n/cn.h @@ -712,6 +712,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Actual power") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "Power source") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "使用 FlySky RSSI 值无需重缩放") #define TR_GPS_COORDS_FORMAT TR("方向角", "方向角格式") #define TR_VARIO TR("变化率", "高度变化率") diff --git a/radio/src/translations/i18n/cz.h b/radio/src/translations/i18n/cz.h index 6fc8ee1a8ff..1b3de2c4e7c 100644 --- a/radio/src/translations/i18n/cz.h +++ b/radio/src/translations/i18n/cz.h @@ -1067,6 +1067,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Aktuální výkon") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", TR("Power src.", "Power source")) +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_ANTENNACONFIRM1 "Opravdu přepnout?" #define TR_ANTENNA_MODES_1 "Interní" #define TR_ANTENNA_MODES_2 "Zeptat se" diff --git a/radio/src/translations/i18n/da.h b/radio/src/translations/i18n/da.h index 24528f91f93..04f4e12d9b1 100644 --- a/radio/src/translations/i18n/da.h +++ b/radio/src/translations/i18n/da.h @@ -717,6 +717,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Akt. strøm", "Aktuel strøm") #define TR_AFHDS3_POWER_SOURCE TR("Strøm", "Strøm kilde") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "Brug FlySky RSSI værdi uden reskalering") #define TR_GPS_COORDS_FORMAT TR("GPS kordi.", "Kordinat format") #define TR_VARIO TR("Højde", "Højdemåler") diff --git a/radio/src/translations/i18n/de.h b/radio/src/translations/i18n/de.h index c023c1bec1f..a1da68348cf 100644 --- a/radio/src/translations/i18n/de.h +++ b/radio/src/translations/i18n/de.h @@ -712,6 +712,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Ist-Leist.", "Aktuelle Leistung") #define TR_AFHDS3_POWER_SOURCE TR("Versorg.", "Spannungsquelle") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "Verwende FlySky RSSI (skalierungsfrei)") #define TR_GPS_COORDS_FORMAT TR("GPS-Koord.", "GPS-Koordinaten-Format") #define TR_VARIO TR("Vario", "Variometer") diff --git a/radio/src/translations/i18n/en.h b/radio/src/translations/i18n/en.h index afeb36a80ad..8584902a482 100644 --- a/radio/src/translations/i18n/en.h +++ b/radio/src/translations/i18n/en.h @@ -713,6 +713,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Actual power") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "Power source") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "Use FlySky RSSI value without rescalling") #define TR_GPS_COORDS_FORMAT TR("GPS Coords", "Coordinate format") #define TR_VARIO TR("Vario", "Variometer") diff --git a/radio/src/translations/i18n/es.h b/radio/src/translations/i18n/es.h index a6442768c42..0a536e302a0 100644 --- a/radio/src/translations/i18n/es.h +++ b/radio/src/translations/i18n/es.h @@ -1064,6 +1064,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Actual power") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "Power source") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_ANTENNACONFIRM1 "ANTENA EXT." #define TR_ANTENNA_MODES_1 "Interna" #define TR_ANTENNA_MODES_2 "Preguntar" diff --git a/radio/src/translations/i18n/fi.h b/radio/src/translations/i18n/fi.h index e0d38978cdc..649a4181563 100644 --- a/radio/src/translations/i18n/fi.h +++ b/radio/src/translations/i18n/fi.h @@ -1064,6 +1064,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Actual power") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "Power source") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_ANTENNACONFIRM1 "EXT. ANTENNA" #define TR_ANTENNA_MODES_1 "Internal" #define TR_ANTENNA_MODES_2 "Ask" diff --git a/radio/src/translations/i18n/fr.h b/radio/src/translations/i18n/fr.h index a7ce7be96c5..4e375b58262 100644 --- a/radio/src/translations/i18n/fr.h +++ b/radio/src/translations/i18n/fr.h @@ -1067,6 +1067,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Puissance actuelle") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "Source puissance") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_ANTENNACONFIRM1 "Vraiment changer?" #define TR_ANTENNA_MODES_1 "Interne" #define TR_ANTENNA_MODES_2 "Demander" diff --git a/radio/src/translations/i18n/he.h b/radio/src/translations/i18n/he.h index 0054bc6f5ab..05c06289dac 100644 --- a/radio/src/translations/i18n/he.h +++ b/radio/src/translations/i18n/he.h @@ -715,6 +715,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Actual power") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "Power source") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "Use FlySky RSSI value without rescalling") #define TR_GPS_COORDS_FORMAT TR("GPS Coords", "Coordinate format") #define TR_VARIO TR("Vario", "הגדרות תדר") diff --git a/radio/src/translations/i18n/it.h b/radio/src/translations/i18n/it.h index 29beaf6fbff..01b795ced4a 100644 --- a/radio/src/translations/i18n/it.h +++ b/radio/src/translations/i18n/it.h @@ -712,6 +712,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Pot. att.", "Potenza Attuale") #define TR_AFHDS3_POWER_SOURCE TR("Fonte pot.", "Fonte Potenza") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "Usa valore RSSI FlySky senza scalare") #define TR_GPS_COORDS_FORMAT TR("GPS Coords", "Formato Coordinate") #define TR_VARIO TR("Vario", "Variometro") diff --git a/radio/src/translations/i18n/jp.h b/radio/src/translations/i18n/jp.h index 19c762041fc..f8672f6df22 100644 --- a/radio/src/translations/i18n/jp.h +++ b/radio/src/translations/i18n/jp.h @@ -711,6 +711,7 @@ #define TR_AFHDS3_ONE_TO_MANY "マルチキャスト" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "実電力") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "出力ソース") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "再コールせずFlySky RSSI値を使用") #define TR_GPS_COORDS_FORMAT TR("GPS Coords", "GPS座標形式") #define TR_VARIO TR("Vario", "バリオメーター") diff --git a/radio/src/translations/i18n/ko.h b/radio/src/translations/i18n/ko.h index daeded79943..1e6eaa7eb66 100644 --- a/radio/src/translations/i18n/ko.h +++ b/radio/src/translations/i18n/ko.h @@ -740,6 +740,7 @@ #define TR_AFHDS3_ACTUAL_POWER TR("실제 출력", "실제 RF 출력") #define TR_AFHDS3_POWER_SOURCE TR("전원 소스", "전원 공급 방식") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI 값", "RSSI 값을 리스케일 없이 사용") #define TR_GPS_COORDS_FORMAT TR("GPS 좌표", "좌표 표시 형식") #define TR_VARIO TR("바리오", "수직 속도계") diff --git a/radio/src/translations/i18n/nl.h b/radio/src/translations/i18n/nl.h index 6716d80e40c..6b1f42b62d0 100644 --- a/radio/src/translations/i18n/nl.h +++ b/radio/src/translations/i18n/nl.h @@ -1065,6 +1065,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Actual power") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "Power source") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_ANTENNACONFIRM1 "Antennes wisselen?" #define TR_ANTENNA_MODES_1 "Internal" #define TR_ANTENNA_MODES_2 "Ask" diff --git a/radio/src/translations/i18n/pl.h b/radio/src/translations/i18n/pl.h index f3288da5d7c..80f1075f28b 100644 --- a/radio/src/translations/i18n/pl.h +++ b/radio/src/translations/i18n/pl.h @@ -1062,6 +1062,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Actual power") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "Power source") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_ANTENNACONFIRM1 "EXT. ANTENNA" #define TR_ANTENNA_MODES_1 "Internal" #define TR_ANTENNA_MODES_2 "Ask" diff --git a/radio/src/translations/i18n/pt.h b/radio/src/translations/i18n/pt.h index d5f597c6770..7640a49ea10 100644 --- a/radio/src/translations/i18n/pt.h +++ b/radio/src/translations/i18n/pt.h @@ -712,6 +712,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Actual power") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "Power source") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "Use FlySky RSSI value without rescalling") #define TR_GPS_COORDS_FORMAT TR("GPS Coords", "Coordinate format") #define TR_VARIO TR("Vario", "Variometer") diff --git a/radio/src/translations/i18n/ru.h b/radio/src/translations/i18n/ru.h index 2c4003c1133..4181ec4e083 100644 --- a/radio/src/translations/i18n/ru.h +++ b/radio/src/translations/i18n/ru.h @@ -714,6 +714,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Мультикаст" #define TR_AFHDS3_ACTUAL_POWER TR("Акт. мощность", "Фактич мощность") #define TR_AFHDS3_POWER_SOURCE TR("Источ питания", "Источ питания") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "FlySky RSSI") #define TR_GPS_COORDS_FORMAT TR("Формат коорд GPS", "Формат коорд") #define TR_VARIO TR("Вариометр", "Вариометр") diff --git a/radio/src/translations/i18n/se.h b/radio/src/translations/i18n/se.h index beb27fd3cdc..75c2a3bc8eb 100644 --- a/radio/src/translations/i18n/se.h +++ b/radio/src/translations/i18n/se.h @@ -1074,6 +1074,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Actual power") #define TR_AFHDS3_POWER_SOURCE "Strömkälla" +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_ANTENNACONFIRM1 "EXT. ANTENN" #define TR_ANTENNA_MODES_1 "Intern" diff --git a/radio/src/translations/i18n/tw.h b/radio/src/translations/i18n/tw.h index 29140891611..85ec2c48afb 100644 --- a/radio/src/translations/i18n/tw.h +++ b/radio/src/translations/i18n/tw.h @@ -709,6 +709,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Act. pow", "Actual power") #define TR_AFHDS3_POWER_SOURCE TR("Power src.", "Power source") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "使用 FlySky RSSI 值無需重縮放") #define TR_GPS_COORDS_FORMAT TR("方向角", "方向角格式") #define TR_VARIO TR("變化率", "高度變化率") diff --git a/radio/src/translations/i18n/ua.h b/radio/src/translations/i18n/ua.h index f517cbe2438..8509ee7df59 100644 --- a/radio/src/translations/i18n/ua.h +++ b/radio/src/translations/i18n/ua.h @@ -713,6 +713,7 @@ #define TR_AFHDS3_ONE_TO_MANY "Multicast" #define TR_AFHDS3_ACTUAL_POWER TR("Факт.потужн.", "Фактична потужність") #define TR_AFHDS3_POWER_SOURCE TR("Джерело живл.", "Джерело живлення") +#define TR_IBUS2_SENSORS_MODE_ONLY "Only in the iBUS2 mode can the sensors be set." #define TR_FLYSKY_TELEMETRY TR("FlySky RSSI #", "Use FlySky RSSI") /* use english */ #define TR_GPS_COORDS_FORMAT TR("Формат коорд.", "Формат координат") #define TR_VARIO TR("Варіо", "Варіометр") diff --git a/radio/src/translations/sim_string_list.h b/radio/src/translations/sim_string_list.h index a35b7861d8f..aebf2ed43a3 100644 --- a/radio/src/translations/sim_string_list.h +++ b/radio/src/translations/sim_string_list.h @@ -361,6 +361,7 @@ #define STR_AFHDS3_ONE_TO_ONE_TELEMETRY currentLangStrings->STR_AFHDS3_ONE_TO_ONE_TELEMETRY #define STR_AFHDS3_POWER_SOURCE currentLangStrings->STR_AFHDS3_POWER_SOURCE #define STR_AFHDS3_RX_FREQ currentLangStrings->STR_AFHDS3_RX_FREQ +#define STR_IBUS2_SENSORS_MODE_ONLY currentLangStrings->STR_IBUS2_SENSORS_MODE_ONLY #endif #if defined(BLUETOOTH) diff --git a/radio/src/translations/string_list.h b/radio/src/translations/string_list.h index 3a624410ba2..dbb01b0d941 100644 --- a/radio/src/translations/string_list.h +++ b/radio/src/translations/string_list.h @@ -360,6 +360,7 @@ STR(AFHDS3_ONE_TO_MANY) STR(AFHDS3_ONE_TO_ONE_TELEMETRY) STR(AFHDS3_POWER_SOURCE) STR(AFHDS3_RX_FREQ) +STR(IBUS2_SENSORS_MODE_ONLY) #endif #if defined(BLUETOOTH) From afce1ef2bca8f206f765a6d91ffcba5b56bca72d Mon Sep 17 00:00:00 2001 From: helloradiosky <157123396+helloradiosky@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:19:09 +0800 Subject: [PATCH 02/55] chore(v16): add support for more IMU hardware (#7482) Co-authored-by: Claude Sonnet 4.6 (cherry picked from commit 19aa6d5f209188e5b26b46d4de7e15beabb1961c) --- radio/src/drivers/icm42627.cpp | 57 ++++++++++++++++---------- radio/src/drivers/icm42627.h | 2 + radio/src/targets/horus/CMakeLists.txt | 3 ++ radio/src/targets/horus/board.cpp | 47 +++++++++++++-------- radio/src/targets/horus/hal.h | 1 - 5 files changed, 71 insertions(+), 39 deletions(-) diff --git a/radio/src/drivers/icm42627.cpp b/radio/src/drivers/icm42627.cpp index ad796fd8a82..b040c34e346 100644 --- a/radio/src/drivers/icm42627.cpp +++ b/radio/src/drivers/icm42627.cpp @@ -31,8 +31,9 @@ static etx_i2c_bus_t s_i2c_bus; static uint16_t s_i2c_addr; static constexpr uint32_t RESET_DELAY_MS = 150; -static constexpr uint32_t READY_TIMEOUT_MS = 200; -static constexpr uint32_t POWER_DELAY_MS = 20; +static constexpr uint32_t READY_TIMEOUT_MS = 500; +static constexpr uint32_t POWER_ON_SETTLE_US = 200; +static constexpr uint32_t GYRO_STARTUP_DELAY_MS = 45; static constexpr uint32_t CONFIG_DELAY_MS = 1; static constexpr uint8_t BURST_READ_LEN = 14; @@ -75,7 +76,10 @@ static int gyro42627Init(etx_i2c_bus_t bus, uint16_t addr) return -1; } - if (i2c_dev_ready(s_i2c_bus, s_i2c_addr) < 0) { + // The chip can take longer than a couple of quick retries to start + // acknowledging on I2C after power-up, so give the initial probe the + // same generous retry window used after a soft reset below. + if (wait_for_device_ready(READY_TIMEOUT_MS) < 0) { TRACE("ICM42627: device did not acknowledge on I2C"); return -1; } @@ -103,17 +107,28 @@ static int gyro42627Init(etx_i2c_bus_t bus, uint16_t addr) return -1; } - if (write_cmd(ICM42627_PWR_MGMT0_REG, ICM42627_PWR_STAGE1) < 0) { - TRACE("ICM42627: failed to write PWR_MGMT0 stage 1"); + // Per datasheet section 12.6 ("Register Values Modification"), registers + // other than ODR/FSR/mode selects must only be changed while the + // accel/gyro are off. Configure the signal path here, while PWR_MGMT0 + // still has gyro and accel powered down, then power them on last. + if (write_cmd(ICM42627_BANK_SEL_REG, ICM42627_BANK1) < 0) { + TRACE("ICM42627: failed to select register bank 1"); return -1; } - delay_ms(POWER_DELAY_MS); + delay_ms(CONFIG_DELAY_MS); - if (write_cmd(ICM42627_PWR_MGMT0_REG, ICM42627_PWR_STAGE2) < 0) { - TRACE("ICM42627: failed to write PWR_MGMT0 stage 2"); + if (write_cmd(ICM42627_GYRO_STATIC2_REG, ICM42627_GYRO_STATIC2) < 0) { + TRACE("ICM42627: failed to write GYRO_STATIC2"); + write_cmd(ICM42627_BANK_SEL_REG, ICM42627_BANK0); + return -1; + } + delay_ms(CONFIG_DELAY_MS); + + if (write_cmd(ICM42627_BANK_SEL_REG, ICM42627_BANK0) < 0) { + TRACE("ICM42627: failed to restore register bank 0"); return -1; } - delay_ms(POWER_DELAY_MS); + delay_ms(CONFIG_DELAY_MS); if (write_cmd(ICM42627_GYRO_CONFIG0_REG, ICM42627_GYRO_CONFIG) < 0) { TRACE("ICM42627: failed to write GYRO_CONFIG0"); @@ -145,25 +160,23 @@ static int gyro42627Init(etx_i2c_bus_t bus, uint16_t addr) } delay_ms(CONFIG_DELAY_MS); - if (write_cmd(ICM42627_BANK_SEL_REG, ICM42627_BANK1) < 0) { - TRACE("ICM42627: failed to select register bank 1"); - return -1; - } - delay_ms(CONFIG_DELAY_MS); - - if (write_cmd(ICM42627_GYRO_STATIC2_REG, ICM42627_GYRO_STATIC2) < 0) { - TRACE("ICM42627: failed to write GYRO_STATIC2"); + // Power on gyro and accel in Low Noise mode last, now that the signal + // path is fully configured. The datasheet requires no register writes + // for 200us after an OFF -> mode transition, and the gyro must be kept + // on for at least 45ms before its output is valid. + if (write_cmd(ICM42627_PWR_MGMT0_REG, ICM42627_PWR_STAGE1) < 0) { + TRACE("ICM42627: failed to write PWR_MGMT0 stage 1"); return -1; } - delay_ms(CONFIG_DELAY_MS); + delay_us(POWER_ON_SETTLE_US); - if (write_cmd(ICM42627_BANK_SEL_REG, ICM42627_BANK0) < 0) { - TRACE("ICM42627: failed to restore register bank 0"); + if (write_cmd(ICM42627_PWR_MGMT0_REG, ICM42627_PWR_STAGE2) < 0) { + TRACE("ICM42627: failed to write PWR_MGMT0 stage 2"); return -1; } - delay_ms(CONFIG_DELAY_MS); + delay_ms(GYRO_STARTUP_DELAY_MS); - // Re-read WHO_AM_I after reset and banked configuration to catch a device + // Re-read WHO_AM_I after reset and configuration to catch a device // that stopped responding even though the initial probe succeeded. if (read_cmd(ICM42627_WHO_AM_I_REG, &who_am_i) < 0 || who_am_i != ICM42627_WHO_AM_I) { diff --git a/radio/src/drivers/icm42627.h b/radio/src/drivers/icm42627.h index 70c9395a6aa..1b9df0f26b7 100644 --- a/radio/src/drivers/icm42627.h +++ b/radio/src/drivers/icm42627.h @@ -23,6 +23,8 @@ #include "hal/imu.h" +#define ICM42627_I2C_BASE_ADDR 0x68 + #define ICM42627_WHO_AM_I_REG 0x75 #define ICM42627_WHO_AM_I 0x20 diff --git a/radio/src/targets/horus/CMakeLists.txt b/radio/src/targets/horus/CMakeLists.txt index 85da2636908..3d42b8868a1 100644 --- a/radio/src/targets/horus/CMakeLists.txt +++ b/radio/src/targets/horus/CMakeLists.txt @@ -123,6 +123,8 @@ if (PCB STREQUAL X10) set(AUX2_SERIAL OFF) set(CSD203_SENSOR ON) set(FLYSKY_GIMBAL ON) + add_definitions(-DIMU_ICM42627) + add_definitions(-DIMU_SC7U22) if (BLUETOOTH) set(AUX_SERIAL OFF) endif() @@ -301,6 +303,7 @@ add_library(board OBJECT EXCLUDE_FROM_ALL targets/common/arm/stm32/heartbeat_driver.cpp drivers/lsm6ds.cpp drivers/icm42627.cpp + drivers/sc7u22.cpp targets/common/arm/stm32/mixer_scheduler_driver.cpp targets/common/arm/stm32/module_timer_driver.cpp targets/common/arm/stm32/sticks_pwm_driver.cpp diff --git a/radio/src/targets/horus/board.cpp b/radio/src/targets/horus/board.cpp index 04308fd289e..2cc56427a88 100644 --- a/radio/src/targets/horus/board.cpp +++ b/radio/src/targets/horus/board.cpp @@ -52,8 +52,22 @@ #include "csd203_sensor.h" #endif -#if defined(IMU) && defined(IMU_I2C_BUS) && defined(IMU_I2C_ADDRESS) +#if defined(IMU) && defined(IMU_I2C_BUS) && \ + (defined(IMU_I2C_ADDRESS) || defined(IMU_ICM42627) || \ + defined(IMU_SC7U22) || defined(IMU_LSM6DS33)) #define HAS_IMU + #include "gyro.h" + #include "stm32_i2c_driver.h" + #if defined(IMU_ICM42627) + #include "drivers/icm42627.h" + #endif + #if defined(IMU_SC7U22) + #include "drivers/sc7u22.h" + #endif + #if defined(IMU_LSM6DS33) || \ + (!defined(IMU_ICM42627) && !defined(IMU_SC7U22) && defined(IMU_I2C_ADDRESS)) + #include "drivers/lsm6ds.h" + #endif #endif HardwareOptions hardwareOptions; @@ -133,25 +147,26 @@ void audioInit() #endif #if defined(HAS_IMU) -#include "gyro.h" -#include "stm32_i2c_driver.h" -#include "drivers/icm42627.h" -#include "drivers/lsm6ds.h" +static const etx_imu_t _imu_candidates[] = { +#if defined(IMU_ICM42627) + { &imu_icm42627_driver, IMU_I2C_BUS, ICM42627_I2C_BASE_ADDR }, + { &imu_icm42627_driver, IMU_I2C_BUS, ICM42627_I2C_BASE_ADDR + 1 }, +#endif +#if defined(IMU_SC7U22) + { &imu_sc7u22_driver, IMU_I2C_BUS, SC7U22_I2C_BASE_ADDR }, + { &imu_sc7u22_driver, IMU_I2C_BUS, SC7U22_I2C_BASE_ADDR + 1 }, +#endif +#if defined(IMU_LSM6DS33) || \ + (!defined(IMU_ICM42627) && !defined(IMU_SC7U22) && defined(IMU_I2C_ADDRESS)) + { &imu_lsm6ds_driver, IMU_I2C_BUS, IMU_I2C_ADDRESS }, +#endif +}; static void gyroInit() { -#if defined(RADIO_V16) - const etx_imu_driver_t *driver = &imu_icm42627_driver; -#else - const etx_imu_driver_t *driver = &imu_lsm6ds_driver; -#endif - - const etx_imu_t candidates[] = { - { driver, IMU_I2C_BUS, IMU_I2C_ADDRESS }, - }; - - gyroStart(imuDetect(candidates, DIM(candidates))); + gyroStart(imuDetect(_imu_candidates, DIM(_imu_candidates))); } + #endif void boardInit() diff --git a/radio/src/targets/horus/hal.h b/radio/src/targets/horus/hal.h index 4143114907a..0057bafe346 100644 --- a/radio/src/targets/horus/hal.h +++ b/radio/src/targets/horus/hal.h @@ -1007,7 +1007,6 @@ #define IMU_I2C_ADDRESS 0x6A #elif defined(RADIO_V16) #define IMU_I2C_BUS I2C_Bus_1 - #define IMU_I2C_ADDRESS 0x69 #elif !defined(AUX_SERIAL) && defined(IMU_LSM6DS33) #define IMU_I2C_BUS I2C_Bus_2 #define IMU_I2C_ADDRESS 0x6A From b130e3702fa9bd8ce4700474c99dae8c8f9cc6fd Mon Sep 17 00:00:00 2001 From: philmoz Date: Wed, 22 Jul 2026 09:38:29 +1000 Subject: [PATCH 03/55] chore(color): reduce busy wait time on LCD refresh (#7483) This changes the flush contract: flushLcd() no longer calls lv_disp_flush_ready() after invoking the driver callback, so each LCD driver must now signal completion itself via lcdFlushed(). Upstream updated every driver that needed it, but two on 2.12 were missed: - boards/helloradio-h750/lcd_driver.cpp (v12) already had the call on main, added there by work that never reached 2.12, so it was not part of the upstream diff. The call is added here, making startLcdRefresh byte-identical to main's. - targets/c14/lcd_driver.cpp is missing the call on main as well, so c14 is affected upstream too and needs the same fix there. Both are synchronous busy-wait drivers that relied on flushLcd() readying the display for them. Without the call LVGL never learns the flush completed and the display pipeline stalls after the first frame - the radio reaches the splash screen and stops, and because boot_lcd.cpp carries the same contract change, the bootloader stops redrawing too and so never appears to notice a USB connection. Also drops trailing whitespace on one line of startLcdRefresh to match main; the remaining whitespace drift in the commented-out init block is left alone to keep this diff focused. LCD_VERTICAL_INVERT is tested with defined() rather than for a non-zero value: on 2.12 it is a bare #define on PCBX10/TX16S, so upstream's '#if LCD_VERTICAL_INVERT' would silently take the wrong branch here. The simulator hunk targets opentxsimulator.cpp rather than main's simulib.cpp, which does not exist on 2.12. Co-authored-by: Claude Opus 5 (cherry picked from commit 50d12c7ceaedc0a3f0547c34bfb6b124ffb7e166) --- .../src/boards/helloradio-h750/lcd_driver.cpp | 4 +- radio/src/boards/jumper-h750/lcd_driver.cpp | 20 +--- radio/src/boards/jumper-h750/lcd_driver.h | 6 +- radio/src/boards/rm-h750/lcd_driver_480.cpp | 48 ++------ radio/src/boards/rm-h750/lcd_driver_480.h | 14 +-- radio/src/boards/rm-h750/lcd_driver_800.cpp | 44 ++------ radio/src/boards/rm-h750/lcd_driver_800.h | 7 -- radio/src/gui/colorlcd/boot_lcd.cpp | 104 ++++++++---------- radio/src/gui/colorlcd/boot_menu.cpp | 97 ++++++++-------- radio/src/gui/colorlcd/lcd.cpp | 91 +++++++-------- radio/src/gui/colorlcd/lcd.h | 9 +- radio/src/gui/common/stdlcd/lcd_common.h | 2 + radio/src/targets/c14/lcd_driver.cpp | 2 + radio/src/targets/horus/lcd_driver.cpp | 104 +++++++++--------- .../src/targets/horus/lcd_st7796s_driver.cpp | 13 +-- radio/src/targets/pa01/lcd_driver.cpp | 2 + radio/src/targets/pl18/lcd_driver.cpp | 14 +-- radio/src/targets/simu/opentxsimulator.cpp | 6 +- radio/src/targets/st16/lcd_driver.cpp | 12 +- .../src/targets/stm32h7s78-dk/lcd_driver.cpp | 12 +- radio/src/targets/taranis/board.h | 1 - 21 files changed, 235 insertions(+), 377 deletions(-) diff --git a/radio/src/boards/helloradio-h750/lcd_driver.cpp b/radio/src/boards/helloradio-h750/lcd_driver.cpp index 59ed4df889c..c9843406ea4 100644 --- a/radio/src/boards/helloradio-h750/lcd_driver.cpp +++ b/radio/src/boards/helloradio-h750/lcd_driver.cpp @@ -105,7 +105,7 @@ static void memory_write(const uint16_t* data, uint32_t length) static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, const rect_t ©_area) -{ +{ (void)disp_drv; // TODO: replace through some smarter mechanism without busy wait @@ -120,6 +120,8 @@ static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, set_row_addr(y1, y2); memory_write(buffer, copy_area.w * copy_area.h); + + lcdFlushed(); } lcdSpiInitFucPtr lcdInitFunction; diff --git a/radio/src/boards/jumper-h750/lcd_driver.cpp b/radio/src/boards/jumper-h750/lcd_driver.cpp index 013e91ff61a..e01c91dc7f8 100644 --- a/radio/src/boards/jumper-h750/lcd_driver.cpp +++ b/radio/src/boards/jumper-h750/lcd_driver.cpp @@ -24,9 +24,7 @@ #include "stm32_hal.h" #include "stm32_gpio_driver.h" #include "edgetx_types.h" -#include "dma2d.h" #include "hal.h" -#include "delays_driver.h" #include "debug.h" #include "lcd.h" #include "lcd_driver.h" @@ -35,15 +33,12 @@ #include "stm32_gpio.h" #include "hal/gpio.h" -uint8_t TouchControllerType = 0; // 0: other; 1: CST836U static volatile uint16_t lcd_phys_w = LCD_H; static volatile uint16_t lcd_phys_h = LCD_W; static LTDC_HandleTypeDef hltdc; static void* initialFrameBuffer = nullptr; -static volatile uint8_t _frame_addr_reloaded = 0; - static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, const rect_t ©_area) { @@ -54,16 +49,11 @@ static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, // faster than cleaning by address SCB_CleanDCache(); -// LTDC_Layer1->CFBAR &= ~(LTDC_LxCFBAR_CFBADD); LTDC_Layer1->CFBAR = (uint32_t)buffer; // reload shadow registers on vertical blank - _frame_addr_reloaded = 0; LTDC->SRCR = LTDC_SRCR_VBR; - __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); - // wait for reload - // TODO: replace through some smarter mechanism without busy wait - while(_frame_addr_reloaded == 0); + __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); } lcdSpiInitFucPtr lcdInitFunction; @@ -71,8 +61,6 @@ lcdSpiInitFucPtr lcdOffFunction; lcdSpiInitFucPtr lcdOnFunction; uint32_t lcdPixelClock; -volatile uint8_t LCD_ReadBuffer[24] = { 0, 0 }; - static void LCD_Delay(void) { volatile unsigned int i; @@ -1884,7 +1872,6 @@ void LCD_Init_LTDC() { // Trigger on last line HAL_LTDC_ProgramLineEvent(&hltdc, lcd_phys_h); - __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); } void LCD_LayerInit() { @@ -2009,5 +1996,6 @@ extern "C" void LTDC_IRQHandler(void) { __HAL_LTDC_CLEAR_FLAG(&hltdc, LTDC_FLAG_LI); __HAL_LTDC_DISABLE_IT(&hltdc, LTDC_IT_LI); - _frame_addr_reloaded = 1; -} \ No newline at end of file + + lcdFlushed(); +} diff --git a/radio/src/boards/jumper-h750/lcd_driver.h b/radio/src/boards/jumper-h750/lcd_driver.h index 6ffeb1e9c14..d42f435ddee 100644 --- a/radio/src/boards/jumper-h750/lcd_driver.h +++ b/radio/src/boards/jumper-h750/lcd_driver.h @@ -19,8 +19,7 @@ * GNU General Public License for more details. */ -#ifndef __LCD_DRIVER_H__ -#define __LCD_DRIVER_H__ +#pragma once #define HBP ( 24 ) // TODO use names from FlySky #define VBP ( 10 ) @@ -66,12 +65,9 @@ extern lcdSpiInitFucPtr lcdOnFunction; #define LCD_READ_DATA_PIN() LL_GPIO_IsInputPinSet(LCD_SPI_GPIO, LCD_SPI_MOSI_GPIO_PIN) - #define HORIZONTAL_SYNC_WIDTH ( 4 ) #define HORIZONTAL_BACK_PORCH ( 24 ) #define HORIZONTAL_FRONT_PORCH ( 140 - HORIZONTAL_BACK_PORCH ) #define VERTICAL_SYNC_HEIGHT ( 2 ) #define VERTICAL_BACK_PORCH ( 10 ) #define VERTICAL_FRONT_PORCH ( 22 - VERTICAL_BACK_PORCH ) - -#endif \ No newline at end of file diff --git a/radio/src/boards/rm-h750/lcd_driver_480.cpp b/radio/src/boards/rm-h750/lcd_driver_480.cpp index c8b7612896f..59365365492 100644 --- a/radio/src/boards/rm-h750/lcd_driver_480.cpp +++ b/radio/src/boards/rm-h750/lcd_driver_480.cpp @@ -22,9 +22,7 @@ #include "stm32_hal_ll.h" #include "stm32_hal.h" #include "edgetx_types.h" -#include "dma2d.h" #include "hal.h" -#include "delays_driver.h" #include "debug.h" #include "lcd.h" #include "lcd_driver_480.h" @@ -34,15 +32,9 @@ #include "stm32_gpio.h" #include "stm32_qspi.h" -uint8_t TouchControllerType = 0; // 0: other; 1: CST836U -static volatile uint16_t lcd_phys_w = LCD_PHYS_W; -static volatile uint16_t lcd_phys_h = LCD_PHYS_H; - static LTDC_HandleTypeDef hltdc; static void* initialFrameBuffer = nullptr; -static volatile uint8_t _frame_addr_reloaded = 0; - static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, const rect_t ©_area) { @@ -55,23 +47,11 @@ static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, LTDC_Layer1->CFBAR = (uint32_t)buffer; // reload shadow registers on vertical blank - _frame_addr_reloaded = 0; LTDC->SRCR = LTDC_SRCR_VBR; __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); - - // wait for reload - // TODO: replace through some smarter mechanism without busy wait - while(_frame_addr_reloaded == 0); } -lcdSpiInitFucPtr lcdInitFunction; -lcdSpiInitFucPtr lcdOffFunction; -lcdSpiInitFucPtr lcdOnFunction; -uint32_t lcdPixelClock; - -volatile uint8_t LCD_ReadBuffer[24] = { 0, 0 }; - static void LCD_Delay(void) { volatile unsigned int i; @@ -424,13 +404,13 @@ void LCD_Init_LTDC() { /* Configure accumulated vertical back porch */ hltdc.Init.AccumulatedVBP = VSH+VBP-1; /* Configure accumulated active width */ - hltdc.Init.AccumulatedActiveW = lcd_phys_w + HBP+HSW-1; + hltdc.Init.AccumulatedActiveW = LCD_PHYS_W + HBP+HSW-1; /* Configure accumulated active height */ - hltdc.Init.AccumulatedActiveH = lcd_phys_h + VBP+VSH-1; + hltdc.Init.AccumulatedActiveH = LCD_PHYS_H + VBP+VSH-1; /* Configure total width */ - hltdc.Init.TotalWidth = lcd_phys_w + HBP + HFP + HSW -1; + hltdc.Init.TotalWidth = LCD_PHYS_W + HBP + HFP + HSW -1; /* Configure total height */ - hltdc.Init.TotalHeigh = lcd_phys_h + VBP + VFP+VSH-1; + hltdc.Init.TotalHeigh = LCD_PHYS_H + VBP + VFP+VSH-1; HAL_LTDC_Init(&hltdc); @@ -439,8 +419,7 @@ void LCD_Init_LTDC() { NVIC_EnableIRQ(LTDC_IRQn); // Trigger on last line - HAL_LTDC_ProgramLineEvent(&hltdc, lcd_phys_h); - __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); + HAL_LTDC_ProgramLineEvent(&hltdc, LCD_PHYS_H); } void LCD_LayerInit() { @@ -448,9 +427,9 @@ void LCD_LayerInit() { /* Windowing configuration */ layer.WindowX0 = 0; - layer.WindowX1 = lcd_phys_w; + layer.WindowX1 = LCD_PHYS_W; layer.WindowY0 = 0; - layer.WindowY1 = lcd_phys_h; + layer.WindowY1 = LCD_PHYS_H; /* Pixel Format configuration*/ layer.PixelFormat = LTDC_PIXEL_FORMAT_RGB565; @@ -468,8 +447,8 @@ void LCD_LayerInit() { layer.BlendingFactor1 = LTDC_BLENDING_FACTOR1_CA; layer.BlendingFactor2 = LTDC_BLENDING_FACTOR2_CA; - layer.ImageWidth = lcd_phys_w; - layer.ImageHeight = lcd_phys_h; + layer.ImageWidth = LCD_PHYS_W; + layer.ImageHeight = LCD_PHYS_H; /* Start Address configuration : the LCD Frame buffer is defined on SDRAM w/ Offset */ layer.FBStartAdress = (intptr_t)initialFrameBuffer; @@ -508,12 +487,9 @@ void lcdInit(void) /* Send LCD initialization commands */ TRACE("LCD INIT (default): ST7365"); boardLcdType = "ST7365 (Default)"; - lcdInitFunction = LCD_ST7365_Init; - lcdOffFunction = LCD_ST7365_Off; - lcdOnFunction = LCD_ST7365_On; __HAL_RCC_LTDC_CLK_ENABLE(); - lcdInitFunction(); + LCD_ST7365_Init(); LCD_Init_LTDC(); LCD_LayerInit(); @@ -533,6 +509,6 @@ extern "C" void LTDC_IRQHandler(void) { __HAL_LTDC_CLEAR_FLAG(&hltdc, LTDC_FLAG_LI); __HAL_LTDC_DISABLE_IT(&hltdc, LTDC_IT_LI); - _frame_addr_reloaded = 1; -} + lcdFlushed(); +} diff --git a/radio/src/boards/rm-h750/lcd_driver_480.h b/radio/src/boards/rm-h750/lcd_driver_480.h index dbb2e52d1dc..c8debb9309c 100644 --- a/radio/src/boards/rm-h750/lcd_driver_480.h +++ b/radio/src/boards/rm-h750/lcd_driver_480.h @@ -19,10 +19,7 @@ * GNU General Public License for more details. */ -#ifndef __LCD_DRIVER_H__ -#define __LCD_DRIVER_H__ - -#include "bsp_io.h" +#pragma once #define HBP ( 20 ) #define VBP ( 10 ) @@ -35,9 +32,6 @@ #define LCD_DELAY() LCD_Delay() -typedef void (*lcdSpiInitFucPtr)(void); -typedef unsigned int LcdReadIDFucPtr( void ); - #define SET_IO_INPUT( PORT, PIN ) LL_GPIO_SetPinMode( PORT, PIN, LL_GPIO_MODE_INPUT ) #define SET_IO_OUTPUT( PORT, PIN ) LL_GPIO_SetPinMode( PORT, PIN, LL_GPIO_MODE_OUTPUT ) @@ -57,9 +51,3 @@ typedef unsigned int LcdReadIDFucPtr( void ); #define LCD_MOSI_AS_OUTPUT() SET_IO_OUTPUT( LCD_SPI_MOSI_GPIO, LCD_SPI_MOSI_GPIO_PIN ) #define LCD_READ_DATA_PIN() LL_GPIO_IsInputPinSet(LCD_SPI_MOSI_GPIO, LCD_SPI_MOSI_GPIO_PIN) - -#endif - - - - diff --git a/radio/src/boards/rm-h750/lcd_driver_800.cpp b/radio/src/boards/rm-h750/lcd_driver_800.cpp index d6790db7491..8064d802c89 100644 --- a/radio/src/boards/rm-h750/lcd_driver_800.cpp +++ b/radio/src/boards/rm-h750/lcd_driver_800.cpp @@ -22,9 +22,7 @@ #include "stm32_hal_ll.h" #include "stm32_hal.h" #include "edgetx_types.h" -#include "dma2d.h" #include "hal.h" -#include "delays_driver.h" #include "debug.h" #include "lcd.h" #include "lcd_driver_800.h" @@ -34,15 +32,9 @@ #include "stm32_gpio.h" #include "stm32_qspi.h" -uint8_t TouchControllerType = 0; // 0: other; 1: CST836U -static const uint16_t lcd_phys_w = LCD_PHYS_W; -static const uint16_t lcd_phys_h = LCD_PHYS_H; - static LTDC_HandleTypeDef hltdc; static void* initialFrameBuffer = nullptr; -static volatile uint8_t _frame_addr_reloaded = 0; - static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, const rect_t ©_area) { @@ -55,23 +47,11 @@ static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, LTDC_Layer1->CFBAR = (uint32_t)buffer; // reload shadow registers on vertical blank - _frame_addr_reloaded = 0; LTDC->SRCR = LTDC_SRCR_VBR; __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); - - // wait for reload - // TODO: replace through some smarter mechanism without busy wait - while(_frame_addr_reloaded == 0); } -lcdSpiInitFucPtr lcdInitFunction; -lcdSpiInitFucPtr lcdOffFunction; -lcdSpiInitFucPtr lcdOnFunction; -uint32_t lcdPixelClock; - -volatile uint8_t LCD_ReadBuffer[24] = { 0, 0 }; - static void LCD_AF_GPIOConfig(void) { const gpio_t _lcd_af_gpios[] = { @@ -121,8 +101,6 @@ static void lcdReset() { delay_ms(100); } - - void LCD_Init_LTDC() { hltdc.Instance = LTDC; @@ -166,13 +144,13 @@ void LCD_Init_LTDC() { /* Configure accumulated vertical back porch */ hltdc.Init.AccumulatedVBP = VSH+VBP-1; /* Configure accumulated active width */ - hltdc.Init.AccumulatedActiveW = lcd_phys_w + HBP+HSW-1; + hltdc.Init.AccumulatedActiveW = LCD_PHYS_W + HBP+HSW-1; /* Configure accumulated active height */ - hltdc.Init.AccumulatedActiveH = lcd_phys_h + VBP+VSH-1; + hltdc.Init.AccumulatedActiveH = LCD_PHYS_H + VBP+VSH-1; /* Configure total width */ - hltdc.Init.TotalWidth = lcd_phys_w + HBP + HFP + HSW -1; + hltdc.Init.TotalWidth = LCD_PHYS_W + HBP + HFP + HSW -1; /* Configure total height */ - hltdc.Init.TotalHeigh = lcd_phys_h + VBP + VFP+VSH-1; + hltdc.Init.TotalHeigh = LCD_PHYS_H + VBP + VFP+VSH-1; HAL_LTDC_Init(&hltdc); @@ -181,8 +159,7 @@ void LCD_Init_LTDC() { NVIC_EnableIRQ(LTDC_IRQn); // Trigger on last line - HAL_LTDC_ProgramLineEvent(&hltdc, lcd_phys_h); - __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); + HAL_LTDC_ProgramLineEvent(&hltdc, LCD_PHYS_H); } void LCD_LayerInit() { @@ -190,9 +167,9 @@ void LCD_LayerInit() { /* Windowing configuration */ layer.WindowX0 = 0; - layer.WindowX1 = lcd_phys_w; + layer.WindowX1 = LCD_PHYS_W; layer.WindowY0 = 0; - layer.WindowY1 = lcd_phys_h; + layer.WindowY1 = LCD_PHYS_H; /* Pixel Format configuration*/ layer.PixelFormat = LTDC_PIXEL_FORMAT_RGB565; @@ -210,8 +187,8 @@ void LCD_LayerInit() { layer.BlendingFactor1 = LTDC_BLENDING_FACTOR1_CA; layer.BlendingFactor2 = LTDC_BLENDING_FACTOR2_CA; - layer.ImageWidth = lcd_phys_w; - layer.ImageHeight = lcd_phys_h; + layer.ImageWidth = LCD_PHYS_W; + layer.ImageHeight = LCD_PHYS_H; /* Start Address configuration : the LCD Frame buffer is defined on SDRAM w/ Offset */ layer.FBStartAdress = (intptr_t)initialFrameBuffer; @@ -271,5 +248,6 @@ extern "C" void LTDC_IRQHandler(void) { __HAL_LTDC_CLEAR_FLAG(&hltdc, LTDC_FLAG_LI); __HAL_LTDC_DISABLE_IT(&hltdc, LTDC_IT_LI); - _frame_addr_reloaded = 1; + + lcdFlushed(); } diff --git a/radio/src/boards/rm-h750/lcd_driver_800.h b/radio/src/boards/rm-h750/lcd_driver_800.h index 9b700f931e4..3188a3ec4e1 100644 --- a/radio/src/boards/rm-h750/lcd_driver_800.h +++ b/radio/src/boards/rm-h750/lcd_driver_800.h @@ -32,13 +32,6 @@ #define HFP ( 16 ) #define VFP ( 8 ) -typedef void (*lcdSpiInitFucPtr)(void); -typedef unsigned int LcdReadIDFucPtr( void ); - -extern lcdSpiInitFucPtr lcdInitFunction; -extern lcdSpiInitFucPtr lcdOffFunction; -extern lcdSpiInitFucPtr lcdOnFunction; - #define SET_IO_INPUT( PORT, PIN ) LL_GPIO_SetPinMode( PORT, PIN, LL_GPIO_MODE_INPUT ) #define SET_IO_OUTPUT( PORT, PIN ) LL_GPIO_SetPinMode( PORT, PIN, LL_GPIO_MODE_OUTPUT ) diff --git a/radio/src/gui/colorlcd/boot_lcd.cpp b/radio/src/gui/colorlcd/boot_lcd.cpp index 8ac0d817bad..13389895279 100644 --- a/radio/src/gui/colorlcd/boot_lcd.cpp +++ b/radio/src/gui/colorlcd/boot_lcd.cpp @@ -33,46 +33,40 @@ #include "board.h" #include "etx_lv_theme.h" -pixel_t LCD_FIRST_FRAME_BUFFER[DISPLAY_BUFFER_SIZE] __SDRAM; -pixel_t LCD_SECOND_FRAME_BUFFER[DISPLAY_BUFFER_SIZE] __SDRAM; +pixel_t LCD_FIRST_FRAME_BUFFER[DISPLAY_BUFFER_SIZE] __SDRAM __ALIGNED(64); +pixel_t LCD_SECOND_FRAME_BUFFER[DISPLAY_BUFFER_SIZE] __SDRAM __ALIGNED(64); -BitmapBuffer lcdBuffer1(BMP_RGB565, LCD_W, LCD_H, - (uint16_t*)LCD_FIRST_FRAME_BUFFER); -BitmapBuffer lcdBuffer2(BMP_RGB565, LCD_W, LCD_H, - (uint16_t*)LCD_SECOND_FRAME_BUFFER); - -BitmapBuffer* lcdFront = &lcdBuffer1; -BitmapBuffer* lcd = &lcdBuffer2; +BitmapBuffer lcd(BMP_RGB565, LCD_W, LCD_H, (uint16_t*)LCD_FIRST_FRAME_BUFFER); static lv_disp_draw_buf_t disp_buf; static lv_disp_drv_t disp_drv; - static lv_disp_t disp; // Call backs static void (*lcd_flush_cb)(lv_disp_drv_t*, uint16_t* buffer, const rect_t& area) = nullptr; +extern "C" void lcdFlushed() +{ + lv_disp_flush_ready(&disp_drv); +} + void lcdSetFlushCb(void (*cb)(lv_disp_drv_t*, uint16_t*, const rect_t&)) { lcd_flush_cb = cb; } -static lv_disp_drv_t* refr_disp = nullptr; - static void flushLcd(lv_disp_drv_t* disp_drv, const lv_area_t* area, lv_color_t* color_p) { if (lcd_flush_cb) { - refr_disp = disp_drv; - rect_t copy_area = {area->x1, area->y1, area->x2 - area->x1 + 1, area->y2 - area->y1 + 1}; lcd_flush_cb(disp_drv, (uint16_t*)color_p, copy_area); + } else { + lcdFlushed(); } - - lv_disp_flush_ready(disp_drv); } static void clear_frame_buffers() @@ -83,24 +77,27 @@ static void clear_frame_buffers() static void init_lvgl_disp_drv() { - lv_disp_draw_buf_init(&disp_buf, lcdFront->getData(), lcd->getData(), LCD_W * LCD_H); + int direct_mode = 1; +#if defined(LCD_VERTICAL_INVERT) +#if defined(RADIO_F16) + direct_mode = (hardwareOptions.pcbrev > 0) ? 1 : 0; +#else + direct_mode = 0; +#endif +#endif + + lv_disp_draw_buf_init(&disp_buf, + LCD_FIRST_FRAME_BUFFER, + direct_mode ? LCD_SECOND_FRAME_BUFFER : nullptr, + LCD_W * LCD_H); lv_disp_drv_init(&disp_drv); /*Basic initialization*/ disp_drv.draw_buf = &disp_buf; /*Set an initialized buffer*/ disp_drv.flush_cb = flushLcd; /*Set a flush callback to draw to the display*/ - disp_drv.wait_cb = nullptr; /*Set a wait callback*/ disp_drv.hor_res = LCD_W; /*Set the horizontal resolution in pixels*/ disp_drv.ver_res = LCD_H; /*Set the vertical resolution in pixels*/ - disp_drv.full_refresh = 0; - -#if !defined(LCD_VERTICAL_INVERT) - disp_drv.direct_mode = 1; -#elif defined(RADIO_F16) - disp_drv.direct_mode = (hardwareOptions.pcbrev > 0) ? 1 : 0; -#else - disp_drv.direct_mode = 0; -#endif + disp_drv.direct_mode = direct_mode; } void lcdInitDisplayDriver() @@ -119,7 +116,7 @@ void lcdInitDisplayDriver() // Clear buffers first clear_frame_buffers(); - lcdSetInitalFrameBuffer(lcdFront->getData()); + lcdSetInitalFrameBuffer(lcd.getData()); // Init hardware LCD driver lcdInit(); @@ -141,32 +138,24 @@ void lcdInitDisplayDriver() } lv_draw_ctx_t* draw_ctx = disp_drv.draw_ctx; - lcd->setDrawCtx(draw_ctx); - lcdFront->setDrawCtx(draw_ctx); + lcd.setDrawCtx(draw_ctx); + static lv_area_t screen_area = {0, 0, LCD_W - 1, LCD_H - 1}; + draw_ctx->buf = disp_drv.draw_buf->buf_act; + draw_ctx->buf_area = &screen_area; + draw_ctx->clip_area = &screen_area; + #if defined(LCD_BACKLIGHT_INIT_DELAY_MS) delay_ms(LCD_BACKLIGHT_INIT_DELAY_MS); #endif - + backlightInit(); } -void lcdClear() { lcd->clear(); } +void lcdClear() { lcd.clear(); } // Direct drawing - used by boot loader -void lcdInitDirectDrawing() -{ - static lv_area_t screen_area = {0, 0, LCD_W - 1, LCD_H - 1}; - - lv_draw_ctx_t* draw_ctx = disp_drv.draw_ctx; - draw_ctx->buf = disp_drv.draw_buf->buf_act; - draw_ctx->buf_area = &screen_area; - draw_ctx->clip_area = &screen_area; - lcd->setData((pixel_t*)draw_ctx->buf); - lcd->reset(); -} - // // Private code copied and adapted from LVGL / lv_refr.c // (_call_flush_cb & _draw_buf_flush) @@ -182,21 +171,12 @@ static void _call_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, drv->flush_cb(drv, &offset_area, color_p); } -static void _draw_buf_flush(lv_disp_t* disp) +void lcdRefresh() { + /* Flush the rendered content to the display */ + lv_disp_t* disp = _lv_refr_get_disp_refreshing(); lv_disp_draw_buf_t* draw_buf = lv_disp_get_draw_buf(disp); - - /*Flush the rendered content to the display*/ lv_draw_ctx_t* draw_ctx = disp->driver->draw_ctx; - if (draw_ctx->wait_for_finish) draw_ctx->wait_for_finish(draw_ctx); - - /* In double buffered mode wait until the other buffer is freed - * and driver is ready to receive the new buffer */ - if (draw_buf->buf1 && draw_buf->buf2) { - while (draw_buf->flushing) { - if (disp->driver->wait_cb) disp->driver->wait_cb(disp->driver); - } - } draw_buf->flushing = 1; draw_buf->flushing_last = 1; @@ -208,15 +188,17 @@ static void _draw_buf_flush(lv_disp_t* disp) /*If there are 2 buffers swap them. */ if (draw_buf->buf1 && draw_buf->buf2) { + /* In double buffered mode wait until the other buffer is freed + * and driver is ready to receive the new buffer */ + while (draw_buf->flushing); if (draw_buf->buf_act == draw_buf->buf1) draw_buf->buf_act = draw_buf->buf2; else draw_buf->buf_act = draw_buf->buf1; } -} -void lcdRefresh() -{ - lv_disp_t* d = _lv_refr_get_disp_refreshing(); - _draw_buf_flush(d); + // Update draw context and lcd bitmap + draw_ctx->buf = draw_buf->buf_act; + lcd.setData((pixel_t*)draw_buf->buf_act); + lcd.reset(); } diff --git a/radio/src/gui/colorlcd/boot_menu.cpp b/radio/src/gui/colorlcd/boot_menu.cpp index 24bdc1c3033..4086d8b62b9 100644 --- a/radio/src/gui/colorlcd/boot_menu.cpp +++ b/radio/src/gui/colorlcd/boot_menu.cpp @@ -55,7 +55,7 @@ constexpr coord_t PROGRESS_W = LCD_W * 3 / 4; constexpr coord_t PROGRESS_X = (LCD_W - PROGRESS_W) / 2; LAYOUT_VAL_SCALED(PROGRESS_H, 31) -extern BitmapBuffer * lcd; +extern BitmapBuffer lcd; #if defined(USB_SW_GPIO) #include "hal/gpio.h" @@ -74,28 +74,28 @@ void blExit(void) static void bootloaderDrawTitle(const char* text) { - lcd->drawText(LCD_W / 2, TITLE_Y1, text, CENTERED | BL_FOREGROUND); - lcd->drawSolidFilledRect(PAD_LARGE, TITLE_Y2, LCD_W - PAD_LARGE * 2, LINE_H, BL_FOREGROUND); + lcd.drawText(LCD_W / 2, TITLE_Y1, text, CENTERED | BL_FOREGROUND); + lcd.drawSolidFilledRect(PAD_LARGE, TITLE_Y2, LCD_W - PAD_LARGE * 2, LINE_H, BL_FOREGROUND); } static void bootloaderDrawFooter() { - lcd->drawSolidFilledRect(PAD_LARGE, LCD_H - FOOTER_LY, LCD_W - PAD_LARGE * 2, LINE_H, BL_FOREGROUND); + lcd.drawSolidFilledRect(PAD_LARGE, LCD_H - FOOTER_LY, LCD_W - PAD_LARGE * 2, LINE_H, BL_FOREGROUND); } static void bootloaderDrawVerFooter() { bootloaderDrawFooter(); if (LCD_W < LCD_H) - lcd->drawText(LCD_W / 2, LCD_H - FOOTER_Y2 + PAD_TINY, TR_BL_CURRENT_FW, CENTERED | BL_FOREGROUND); + lcd.drawText(LCD_W / 2, LCD_H - FOOTER_Y2 + PAD_TINY, TR_BL_CURRENT_FW, CENTERED | BL_FOREGROUND); const char* fw_ver = getFirmwareVersion(); if (!fw_ver) fw_ver = TR_BL_NO_VERSION; - lcd->drawText(LCD_W / 2, LCD_H - FOOTER_Y1, fw_ver, CENTERED | BL_FOREGROUND); + lcd.drawText(LCD_W / 2, LCD_H - FOOTER_Y1, fw_ver, CENTERED | BL_FOREGROUND); } static void bootloaderDrawBackground() { - lcd->clear(BL_BACKGROUND); + lcd.clear(BL_BACKGROUND); } bool bootloaderRadioMenu(uint32_t menuItem, event_t event) @@ -157,14 +157,12 @@ void bootloaderInitScreen() void bootloaderDrawDFUScreen() { - lcdInitDirectDrawing(); bootloaderDrawBackground(); - lcd->drawText(LCD_W / 2, LCD_H / 2, TR_BL_DFU_MODE, CENTERED | BL_FOREGROUND); + lcd.drawText(LCD_W / 2, LCD_H / 2, TR_BL_DFU_MODE, CENTERED | BL_FOREGROUND); } void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) { - lcdInitDirectDrawing(); bootloaderDrawBackground(); bootloaderDrawTitle(BOOTLOADER_TITLE); @@ -173,9 +171,9 @@ void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) const char* msg = (st == ST_START) ? TR_BL_PLUG_USB : TR_BL_COPY_UF2; - lcd->drawText(LCD_W / 2, LCD_H / 2 - TITLE_Y1, msg, CENTERED | BL_FOREGROUND); + lcd.drawText(LCD_W / 2, LCD_H / 2 - TITLE_Y1, msg, CENTERED | BL_FOREGROUND); if (st != ST_USB) - lcd->drawText(LCD_W / 2, LCD_H / 2 + TITLE_Y1, LV_SYMBOL_NEW_LINE " " TR_BL_EXIT_KEY, CENTERED | BL_FOREGROUND); + lcd.drawText(LCD_W / 2, LCD_H / 2 + TITLE_Y1, LV_SYMBOL_NEW_LINE " " TR_BL_EXIT_KEY, CENTERED | BL_FOREGROUND); bootloaderDrawVerFooter(); @@ -184,11 +182,11 @@ void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) const coord_t pb_y = (LCD_H - PROGRESS_H) / 2; const char* msg = (st == ST_FLASH_DONE) ? TR_BL_WRITING_COMPL : TR_BL_WRITING_FW; - lcd->drawText(PROGRESS_X, pb_y - TITLE_Y1, msg, BL_FOREGROUND); - lcd->drawRect(PROGRESS_X, pb_y, PROGRESS_W, PROGRESS_H, LINE_H, SOLID, BL_SELECTED); + lcd.drawText(PROGRESS_X, pb_y - TITLE_Y1, msg, BL_FOREGROUND); + lcd.drawRect(PROGRESS_X, pb_y, PROGRESS_W, PROGRESS_H, LINE_H, SOLID, BL_SELECTED); LcdFlags color = (st == ST_FLASH_DONE) ? BL_GREEN : BL_RED; - lcd->drawSolidFilledRect(PROGRESS_X + PAD_SMALL, pb_y + PAD_SMALL, ((PROGRESS_W - PAD_SMALL * 2) * opt) / 100, PROGRESS_H - PAD_SMALL * 2, color); + lcd.drawSolidFilledRect(PROGRESS_X + PAD_SMALL, pb_y + PAD_SMALL, ((PROGRESS_W - PAD_SMALL * 2) * opt) / 100, PROGRESS_H - PAD_SMALL * 2, color); } } @@ -247,8 +245,8 @@ void bootloaderInitScreen() void bootloaderDrawItem(const char* symbol, const char* text, coord_t& y, coord_t& lastX, coord_t symXO = 0) { - lcd->drawText(SYM_X - symXO, y, symbol, BL_FOREGROUND); - coord_t nx = lcd->drawText(LBL_X, y, text, BL_FOREGROUND) + BOX_GAP; + lcd.drawText(SYM_X - symXO, y, symbol, BL_FOREGROUND); + coord_t nx = lcd.drawText(LBL_X, y, text, BL_FOREGROUND) + BOX_GAP; if (nx > lastX) lastX = nx; y += SYM_H; } @@ -256,12 +254,11 @@ void bootloaderDrawItem(const char* symbol, const char* text, coord_t& y, coord_ void bootloaderDrawSelected(coord_t boxX2, int opt) { boxX2 -= OPTBOX_X; - lcd->drawSolidRect(OPTBOX_X, BOX_Y + (opt * SYM_H), boxX2, BOX_H, PAD_TINY, BL_SELECTED); + lcd.drawSolidRect(OPTBOX_X, BOX_Y + (opt * SYM_H), boxX2, BOX_H, PAD_TINY, BL_SELECTED); } void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) { - lcdInitDirectDrawing(); bootloaderDrawBackground(); coord_t y = SYM_Y, boxX2 = 0; @@ -286,9 +283,9 @@ void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) bootloaderDrawSelected(boxX2, opt); - lcd->drawBitmap(USB_ICN_X, LCD_H - USB_ICN_Y, (const BitmapBuffer*)&BMP_PLUG_USB); - lcd->drawText(USB_TXT_X, LCD_H - USB_TXT_Y1, TR_BL_USB_PLUGIN, USB_TXT_ALIGN | BL_FOREGROUND); - lcd->drawText(USB_TXT_X, LCD_H - USB_TXT_Y2, TR_BL_USB_MASS_STORE, USB_TXT_ALIGN | BL_FOREGROUND); + lcd.drawBitmap(USB_ICN_X, LCD_H - USB_ICN_Y, (const BitmapBuffer*)&BMP_PLUG_USB); + lcd.drawText(USB_TXT_X, LCD_H - USB_TXT_Y1, TR_BL_USB_PLUGIN, USB_TXT_ALIGN | BL_FOREGROUND); + lcd.drawText(USB_TXT_X, LCD_H - USB_TXT_Y2, TR_BL_USB_MASS_STORE, USB_TXT_ALIGN | BL_FOREGROUND); bootloaderDrawVerFooter(); } @@ -301,7 +298,7 @@ void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) bootloaderDrawSelected(boxX2, opt); bootloaderDrawFooter(); - int pos = lcd->drawText(FOOTER_X1, LCD_H - FOOTER_Y1, LV_SYMBOL_SD_CARD " " TR_BL_ERASE_KEY, FOOTER_ALIGN1 | BL_FOREGROUND); + int pos = lcd.drawText(FOOTER_X1, LCD_H - FOOTER_Y1, LV_SYMBOL_SD_CARD " " TR_BL_ERASE_KEY, FOOTER_ALIGN1 | BL_FOREGROUND); #if LANDSCAPE pos = 0; #else @@ -309,12 +306,12 @@ void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) pos = (pos - FOOTER_X1) / 2; } #endif - lcd->drawText(FOOTER_X2 - pos, LCD_H - FOOTER_Y2, LV_SYMBOL_NEW_LINE " " TR_BL_EXIT_KEY, FOOTER_ALIGN2 | BL_FOREGROUND); + lcd.drawText(FOOTER_X2 - pos, LCD_H - FOOTER_Y2, LV_SYMBOL_NEW_LINE " " TR_BL_EXIT_KEY, FOOTER_ALIGN2 | BL_FOREGROUND); } else if (st == ST_CLEAR_FLASH) { bootloaderDrawTitle(TR_BL_ERASE_INT_FLASH); - lcd->drawText(LCD_W / 2, SYM_Y, TR_BL_ERASE_FLASH_MSG, CENTERED | BL_FOREGROUND); + lcd.drawText(LCD_W / 2, SYM_Y, TR_BL_ERASE_FLASH_MSG, CENTERED | BL_FOREGROUND); bootloaderDrawFooter(); } #endif @@ -327,7 +324,7 @@ void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) bootloaderDrawSelected(boxX2, opt); bootloaderDrawFooter(); - int pos = lcd->drawText(FOOTER_X1, LCD_H - FOOTER_Y1, LV_SYMBOL_USB " " TR_BL_TOGGLE_KEY, FOOTER_ALIGN1 | BL_FOREGROUND); + int pos = lcd.drawText(FOOTER_X1, LCD_H - FOOTER_Y1, LV_SYMBOL_USB " " TR_BL_TOGGLE_KEY, FOOTER_ALIGN1 | BL_FOREGROUND); #if LANDSCAPE pos = 0; #else @@ -335,13 +332,13 @@ void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) pos = (pos - FOOTER_X1) / 2; } #endif - lcd->drawText(FOOTER_X2 - pos, LCD_H - FOOTER_Y2, LV_SYMBOL_NEW_LINE " " TR_BL_EXIT_KEY, FOOTER_ALIGN2 | BL_FOREGROUND); + lcd.drawText(FOOTER_X2 - pos, LCD_H - FOOTER_Y2, LV_SYMBOL_NEW_LINE " " TR_BL_EXIT_KEY, FOOTER_ALIGN2 | BL_FOREGROUND); } #endif else if (st == ST_USB) { y = (LCD_H - BMP_USB_PLUGGED.height()) / 2; - lcd->drawBitmap(USB_PLG_X, y, (const BitmapBuffer*)&BMP_USB_PLUGGED); - lcd->drawText(USB_TXT_X, y + USB_PLG_TXT_YO, TR_BL_USB_CONNECTED, USB_TXT_ALIGN | BL_FOREGROUND); + lcd.drawBitmap(USB_PLG_X, y, (const BitmapBuffer*)&BMP_USB_PLUGGED); + lcd.drawText(USB_TXT_X, y + USB_PLG_TXT_YO, TR_BL_USB_CONNECTED, USB_TXT_ALIGN | BL_FOREGROUND); } else if (st == ST_FILE_LIST || st == ST_DIR_CHECK || st == ST_FLASH_CHECK || st == ST_FLASHING || st == ST_FLASH_DONE) { @@ -356,37 +353,37 @@ void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) opt = 100; // Completed > 100% } - lcd->drawRect(PROGRESS_X, (LCD_H - PROGRESS_H) / 2, PROGRESS_W, PROGRESS_H, LINE_H, SOLID, BL_SELECTED); - lcd->drawSolidFilledRect(PROGRESS_X + PAD_SMALL, (LCD_H - PROGRESS_H) / 2 + PAD_SMALL, ((PROGRESS_W - PAD_SMALL * 2) * opt) / 100, PROGRESS_H - PAD_SMALL * 2, color); + lcd.drawRect(PROGRESS_X, (LCD_H - PROGRESS_H) / 2, PROGRESS_W, PROGRESS_H, LINE_H, SOLID, BL_SELECTED); + lcd.drawSolidFilledRect(PROGRESS_X + PAD_SMALL, (LCD_H - PROGRESS_H) / 2 + PAD_SMALL, ((PROGRESS_W - PAD_SMALL * 2) * opt) / 100, PROGRESS_H - PAD_SMALL * 2, color); } else if (st == ST_DIR_CHECK) { if (opt == FR_NO_PATH) { - lcd->drawText(LCD_W / 2, LCD_H / 2, LV_SYMBOL_CLOSE " " TR_BL_DIR_MISSING, CENTERED | BL_FOREGROUND); + lcd.drawText(LCD_W / 2, LCD_H / 2, LV_SYMBOL_CLOSE " " TR_BL_DIR_MISSING, CENTERED | BL_FOREGROUND); } else { - lcd->drawText(LCD_W / 2, LCD_H / 2, LV_SYMBOL_CLOSE " " TR_BL_DIR_EMPTY, CENTERED | BL_FOREGROUND); + lcd.drawText(LCD_W / 2, LCD_H / 2, LV_SYMBOL_CLOSE " " TR_BL_DIR_EMPTY, CENTERED | BL_FOREGROUND); } } else if (st == ST_FLASH_CHECK) { bootloaderDrawFilename(str, 0, true); if (opt == FC_ERROR) { - lcd->drawText(LCD_W / 2, LCD_H / 2, LV_SYMBOL_CLOSE " " TR_BL_INVALID_FIRMWARE, CENTERED | BL_FOREGROUND); + lcd.drawText(LCD_W / 2, LCD_H / 2, LV_SYMBOL_CLOSE " " TR_BL_INVALID_FIRMWARE, CENTERED | BL_FOREGROUND); } else if (opt == FC_OK) { VersionTag tag; memset(&tag, 0, sizeof(tag)); getFileFirmwareVersion(&tag); if (strcmp(tag.flavour, FLAVOUR)) { - lcd->drawText(LCD_W / 2, LCD_H / 2, LV_SYMBOL_CLOSE " " TR_BL_INVALID_FIRMWARE, CENTERED | BL_FOREGROUND); + lcd.drawText(LCD_W / 2, LCD_H / 2, LV_SYMBOL_CLOSE " " TR_BL_INVALID_FIRMWARE, CENTERED | BL_FOREGROUND); } else { - lcd->drawText(VERCHK_X, VERCHK_Y, TR_BL_FORK, RIGHT | BL_FOREGROUND); - lcd->drawSizedText(VERCHK_X + PAD_MEDIUM, VERCHK_Y, tag.fork, 6, BL_FOREGROUND); + lcd.drawText(VERCHK_X, VERCHK_Y, TR_BL_FORK, RIGHT | BL_FOREGROUND); + lcd.drawSizedText(VERCHK_X + PAD_MEDIUM, VERCHK_Y, tag.fork, 6, BL_FOREGROUND); - lcd->drawText(VERCHK_X, VERCHK_Y + EdgeTxStyles::STD_FONT_HEIGHT, TR_BL_VERSION, RIGHT | BL_FOREGROUND); - lcd->drawText(VERCHK_X + PAD_MEDIUM, VERCHK_Y + EdgeTxStyles::STD_FONT_HEIGHT, tag.version, BL_FOREGROUND); + lcd.drawText(VERCHK_X, VERCHK_Y + EdgeTxStyles::STD_FONT_HEIGHT, TR_BL_VERSION, RIGHT | BL_FOREGROUND); + lcd.drawText(VERCHK_X + PAD_MEDIUM, VERCHK_Y + EdgeTxStyles::STD_FONT_HEIGHT, tag.version, BL_FOREGROUND); - lcd->drawText(VERCHK_X, VERCHK_Y + EdgeTxStyles::STD_FONT_HEIGHT * 2, TR_BL_RADIO, RIGHT | BL_FOREGROUND); - lcd->drawText(VERCHK_X + PAD_MEDIUM, VERCHK_Y + EdgeTxStyles::STD_FONT_HEIGHT * 2, tag.flavour, BL_FOREGROUND); + lcd.drawText(VERCHK_X, VERCHK_Y + EdgeTxStyles::STD_FONT_HEIGHT * 2, TR_BL_RADIO, RIGHT | BL_FOREGROUND); + lcd.drawText(VERCHK_X + PAD_MEDIUM, VERCHK_Y + EdgeTxStyles::STD_FONT_HEIGHT * 2, tag.flavour, BL_FOREGROUND); - lcd->drawText(VERCHK_ICN_X, VERCHK_Y + EdgeTxStyles::STD_FONT_HEIGHT, LV_SYMBOL_OK, BL_GREEN); + lcd.drawText(VERCHK_ICN_X, VERCHK_Y + EdgeTxStyles::STD_FONT_HEIGHT, LV_SYMBOL_OK, BL_GREEN); } } } @@ -397,16 +394,16 @@ void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) int align = FOOTER_ALIGN2; if ( st != ST_DIR_CHECK && (st != ST_FLASH_CHECK || opt == FC_OK)) { if (st == ST_FILE_LIST) { - pos = lcd->drawText(FOOTER_X1, LCD_H - FOOTER_Y1, LV_SYMBOL_CHARGE " " TR_BL_SELECT_KEY, FOOTER_ALIGN1 | BL_FOREGROUND); + pos = lcd.drawText(FOOTER_X1, LCD_H - FOOTER_Y1, LV_SYMBOL_CHARGE " " TR_BL_SELECT_KEY, FOOTER_ALIGN1 | BL_FOREGROUND); } else if (st == ST_FLASH_CHECK && opt == FC_OK) { - pos = lcd->drawText(FOOTER_X1, LCD_H - FOOTER_Y1, LV_SYMBOL_CHARGE " " TR_BL_FLASH_KEY, FOOTER_ALIGN1 | BL_FOREGROUND); + pos = lcd.drawText(FOOTER_X1, LCD_H - FOOTER_Y1, LV_SYMBOL_CHARGE " " TR_BL_FLASH_KEY, FOOTER_ALIGN1 | BL_FOREGROUND); } else if (st == ST_FLASHING) { - pos = lcd->drawText(LCD_W / 2, LCD_H - FOOTER_Y1, LV_SYMBOL_CHARGE " " TR_BL_WRITING_FW, CENTERED | BL_FOREGROUND); + pos = lcd.drawText(LCD_W / 2, LCD_H - FOOTER_Y1, LV_SYMBOL_CHARGE " " TR_BL_WRITING_FW, CENTERED | BL_FOREGROUND); } else if (st == ST_FLASH_DONE) { - pos = lcd->drawText(FOOTER_X1, LCD_H - FOOTER_Y1, LV_SYMBOL_CHARGE " " TR_BL_WRITING_COMPL, FOOTER_ALIGN1 | BL_FOREGROUND); + pos = lcd.drawText(FOOTER_X1, LCD_H - FOOTER_Y1, LV_SYMBOL_CHARGE " " TR_BL_WRITING_COMPL, FOOTER_ALIGN1 | BL_FOREGROUND); } } #if LANDSCAPE @@ -419,18 +416,18 @@ void bootloaderDrawScreen(BootloaderState st, int opt, const char* str) } #endif if (st != ST_FLASHING) { - lcd->drawText(FOOTER_X2 - pos, LCD_H - FOOTER_Y2, LV_SYMBOL_NEW_LINE " " TR_BL_EXIT_KEY, align | BL_FOREGROUND); + lcd.drawText(FOOTER_X2 - pos, LCD_H - FOOTER_Y2, LV_SYMBOL_NEW_LINE " " TR_BL_EXIT_KEY, align | BL_FOREGROUND); } } } void bootloaderDrawFilename(const char* str, uint8_t line, bool selected) { - lcd->drawText(FILENAM_X1, FILENAM_Y1 + (line * FILENAM_H), LV_SYMBOL_FILE, BL_FOREGROUND); - lcd->drawText(FILENAM_X2, FILENAM_Y1 + (line * FILENAM_H), str, BL_FOREGROUND); + lcd.drawText(FILENAM_X1, FILENAM_Y1 + (line * FILENAM_H), LV_SYMBOL_FILE, BL_FOREGROUND); + lcd.drawText(FILENAM_X2, FILENAM_Y1 + (line * FILENAM_H), str, BL_FOREGROUND); if (selected) { - lcd->drawSolidRect(FILESEL_X, BOX_Y + (line * FILENAM_H), FILESEL_W, BOX_H, PAD_TINY, BL_SELECTED); + lcd.drawSolidRect(FILESEL_X, BOX_Y + (line * FILENAM_H), FILESEL_W, BOX_H, PAD_TINY, BL_SELECTED); } } diff --git a/radio/src/gui/colorlcd/lcd.cpp b/radio/src/gui/colorlcd/lcd.cpp index e554305167a..4fc15b849c4 100644 --- a/radio/src/gui/colorlcd/lcd.cpp +++ b/radio/src/gui/colorlcd/lcd.cpp @@ -40,49 +40,43 @@ char* get_lvgl_mem(int nbytes) } #endif -pixel_t LCD_FIRST_FRAME_BUFFER[DISPLAY_BUFFER_SIZE] __SDRAM; -pixel_t LCD_SECOND_FRAME_BUFFER[DISPLAY_BUFFER_SIZE] __SDRAM; - -BitmapBuffer lcdBuffer1(BMP_RGB565, LCD_W, LCD_H, - (uint16_t*)LCD_FIRST_FRAME_BUFFER); -BitmapBuffer lcdBuffer2(BMP_RGB565, LCD_W, LCD_H, - (uint16_t*)LCD_SECOND_FRAME_BUFFER); - -static BitmapBuffer* lcdFront = &lcdBuffer1; -static BitmapBuffer* lcd = &lcdBuffer2; +pixel_t LCD_FIRST_FRAME_BUFFER[DISPLAY_BUFFER_SIZE] __SDRAM __ALIGNED(64); +pixel_t LCD_SECOND_FRAME_BUFFER[DISPLAY_BUFFER_SIZE] __SDRAM __ALIGNED(64); static lv_disp_draw_buf_t disp_buf; static lv_disp_drv_t disp_drv; // Call backs -static void (*lcd_wait_cb)(lv_disp_drv_t*) = nullptr; static void (*lcd_flush_cb)(lv_disp_drv_t*, uint16_t* buffer, const rect_t& area) = nullptr; -void lcdSetWaitCb(void (*cb)(lv_disp_drv_t*)) { lcd_wait_cb = cb; } - void lcdSetFlushCb(void (*cb)(lv_disp_drv_t*, uint16_t*, const rect_t&)) { lcd_flush_cb = cb; } -static lv_disp_drv_t* refr_disp = nullptr; +#if defined(SIMU) +// Only used in simulator to prevent lock up when closing simulator +// TODO: find a better way to handle this +static void (*lcd_wait_cb)(lv_disp_drv_t*) = nullptr; +void lcdSetWaitCb(void (*cb)(lv_disp_drv_t*)) { lcd_wait_cb = cb; } +#endif + +extern "C" void lcdFlushed() +{ + lv_disp_flush_ready(&disp_drv); +} + +void lcdRefresh() {} static void flushLcd(lv_disp_drv_t* disp_drv, const lv_area_t* area, lv_color_t* color_p) { -#if !defined(LCD_VERTICAL_INVERT) || defined(RADIO_F16) -#if defined(RADIO_F16) - if (hardwareOptions.pcbrev > 0) -#endif - { - // we're only interested in the last flush in direct mode - if (!lv_disp_flush_is_last(disp_drv)) { - lv_disp_flush_ready(disp_drv); - return; - } + // we're only interested in the last flush in direct mode + if (disp_drv->direct_mode && !lv_disp_flush_is_last(disp_drv)) { + lv_disp_flush_ready(disp_drv); + return; } -#endif #if defined(DEBUG_WINDOWS) if (area->x1 != 0 || area->x2 != LCD_W - 1 || area->y1 != 0 || @@ -95,15 +89,13 @@ static void flushLcd(lv_disp_drv_t* disp_drv, const lv_area_t* area, #endif if (lcd_flush_cb) { - refr_disp = disp_drv; - rect_t copy_area = {area->x1, area->y1, area->x2 - area->x1 + 1, area->y2 - area->y1 + 1}; lcd_flush_cb(disp_drv, (uint16_t*)color_p, copy_area); + } else { + lcdFlushed(); } - - lv_disp_flush_ready(disp_drv); } static void clear_frame_buffers() @@ -114,25 +106,30 @@ static void clear_frame_buffers() static void init_lvgl_disp_drv() { - lv_disp_draw_buf_init(&disp_buf, lcdFront->getData(), lcd->getData(), + int direct_mode = 1; +#if defined(LCD_VERTICAL_INVERT) +#if defined(RADIO_F16) + direct_mode = (hardwareOptions.pcbrev > 0) ? 1 : 0; +#else + direct_mode = 0; +#endif +#endif + + lv_disp_draw_buf_init(&disp_buf, + LCD_FIRST_FRAME_BUFFER, + direct_mode ? LCD_SECOND_FRAME_BUFFER : nullptr, LCD_W * LCD_H); lv_disp_drv_init(&disp_drv); /*Basic initialization*/ disp_drv.draw_buf = &disp_buf; /*Set an initialized buffer*/ disp_drv.flush_cb = flushLcd; /*Set a flush callback to draw to the display*/ +#if defined(SIMU) disp_drv.wait_cb = lcd_wait_cb; /*Set a wait callback*/ +#endif disp_drv.hor_res = LCD_W; /*Set the horizontal resolution in pixels*/ disp_drv.ver_res = LCD_H; /*Set the vertical resolution in pixels*/ - disp_drv.full_refresh = 0; - -#if !defined(LCD_VERTICAL_INVERT) - disp_drv.direct_mode = 1; -#elif defined(RADIO_F16) - disp_drv.direct_mode = (hardwareOptions.pcbrev > 0) ? 1 : 0; -#else - disp_drv.direct_mode = 0; -#endif + disp_drv.direct_mode = direct_mode; } void lcdInitDisplayDriver() @@ -153,7 +150,7 @@ void lcdInitDisplayDriver() // Clear buffers first clear_frame_buffers(); - lcdSetInitalFrameBuffer(lcdFront->getData()); + lcdSetInitalFrameBuffer(LCD_FIRST_FRAME_BUFFER); // Init hardware LCD driver lcdInit(); @@ -167,17 +164,3 @@ void lcdInitDisplayDriver() // remove all styles on default screen (makes it transparent as well) lv_obj_remove_style_all(lv_scr_act()); } - -void lcdFlushed() -{ - // Only used in simulator - - // its possible to get here before flushLcd is ever called. - // so check for nullptr first. (Race condition if you put breakpoints in - // startup code) - if (refr_disp != nullptr) lv_disp_flush_ready(refr_disp); -} - -#if defined(SIMU) -void lcdRefresh() {} -#endif diff --git a/radio/src/gui/colorlcd/lcd.h b/radio/src/gui/colorlcd/lcd.h index 4b325ecfd24..caef6dd19a1 100644 --- a/radio/src/gui/colorlcd/lcd.h +++ b/radio/src/gui/colorlcd/lcd.h @@ -39,17 +39,16 @@ struct _lv_disp_drv_t; typedef _lv_disp_drv_t lv_disp_drv_t; // Call backs -void lcdSetWaitCb(void (*cb)(lv_disp_drv_t *)); void lcdSetFlushCb(void (*cb)(lv_disp_drv_t *, uint16_t*, const rect_t&)); +#if defined(SIMU) +void lcdSetWaitCb(void (*cb)(lv_disp_drv_t *)); +#endif // Init LVGL and its display driver void lcdInitDisplayDriver(); void lcdClear(); -// Patch the draw context to allow for direct drawing -void lcdInitDirectDrawing(); - void lcdRefresh(); -void lcdFlushed(); +extern "C" void lcdFlushed(); diff --git a/radio/src/gui/common/stdlcd/lcd_common.h b/radio/src/gui/common/stdlcd/lcd_common.h index de0fb2754df..7193b0bd186 100644 --- a/radio/src/gui/common/stdlcd/lcd_common.h +++ b/radio/src/gui/common/stdlcd/lcd_common.h @@ -173,3 +173,5 @@ void drawTelemetryTopBar(); void lcdDraw1bitBitmap(coord_t x, coord_t y, const unsigned char * img, uint8_t idx, LcdFlags att=0); uint8_t getTextWidth(const char * s, uint8_t len=0, LcdFlags flags=0); + +void lcdFlushed(); diff --git a/radio/src/targets/c14/lcd_driver.cpp b/radio/src/targets/c14/lcd_driver.cpp index 68059ff969a..5732540c7dc 100644 --- a/radio/src/targets/c14/lcd_driver.cpp +++ b/radio/src/targets/c14/lcd_driver.cpp @@ -63,6 +63,8 @@ static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, // wait for reload // TODO: replace through some smarter mechanism without busy wait while(_frame_addr_reloaded == 0); + + lcdFlushed(); } diff --git a/radio/src/targets/horus/lcd_driver.cpp b/radio/src/targets/horus/lcd_driver.cpp index c6aab824fe3..40e6992e3fe 100644 --- a/radio/src/targets/horus/lcd_driver.cpp +++ b/radio/src/targets/horus/lcd_driver.cpp @@ -32,6 +32,7 @@ #include "lcd.h" #include +#include #if defined(RADIO_T18) #define HBP 43 @@ -60,17 +61,29 @@ static void* initialFrameBuffer = nullptr; #if defined(LCD_VERTICAL_INVERT) typedef uint16_t pixel_t; -static pixel_t _LCD_BUF_1[DISPLAY_BUFFER_SIZE] __SDRAM; -static pixel_t _LCD_BUF_2[DISPLAY_BUFFER_SIZE] __SDRAM; - -static pixel_t _line_buffer[LCD_W]; +static pixel_t _LCD_BUF_1[DISPLAY_BUFFER_SIZE] __SDRAM __ALIGNED(64); +#if defined(RADIO_F16) +// F16 can be either normal or inverted +static pixel_t _LCD_BUF_2[DISPLAY_BUFFER_SIZE] __SDRAM __ALIGNED(64); +#else +// LVGL will only use one buffer when display is inverted so reuse 2nd +// buffer here +extern pixel_t LCD_SECOND_FRAME_BUFFER[DISPLAY_BUFFER_SIZE]; +#define _LCD_BUF_2 LCD_SECOND_FRAME_BUFFER +#endif +// Frame buffer pointers static uint16_t* _front_buffer = _LCD_BUF_1; static uint16_t* _back_buffer = _LCD_BUF_2; +// Vector to save areas that need to be copied from front buffer to back buffer +static std::vector dma_areas; + // Copy 2 pixels at once to speed up a little static void _copy_rotate_180(uint16_t* dst, uint16_t* src, const rect_t& copy_area) { + static pixel_t _line_buffer[LCD_W]; + coord_t x1 = LCD_W - copy_area.w - copy_area.x; coord_t y1 = LCD_H - copy_area.h - copy_area.y; @@ -106,50 +119,44 @@ static void _copy_rotate_180(uint16_t* dst, uint16_t* src, const rect_t& copy_ar dst -= LCD_W; } } - -static void _rotate_area_180(lv_area_t& area) -{ - lv_coord_t tmp_coord; - tmp_coord = area.y2; - area.y2 = LCD_H - area.y1 - 1; - area.y1 = LCD_H - tmp_coord - 1; - tmp_coord = area.x2; - area.x2 = LCD_W - area.x1 - 1; - area.x1 = LCD_W - tmp_coord - 1; -} #endif -static volatile uint8_t _frame_addr_reloaded = 0; - static void _update_frame_buffer_addr(uint16_t* addr) { LTDC_Layer1->CFBAR = (uint32_t)addr; - // reload shadow registers on vertical blank - _frame_addr_reloaded = 0; LTDC->SRCR = LTDC_SRCR_VBR; - // wait for reload - // TODO: replace through some smarter mechanism without busy wait - while(_frame_addr_reloaded == 0); + __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); } static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, const rect_t ©_area) { #if defined(LCD_VERTICAL_INVERT) -#if defined(RADIO_F16) - if(hardwareOptions.pcbrev > 0) { - // Direct mode + if (disp_drv->direct_mode) { + // Direct mode / not inverted _update_frame_buffer_addr(buffer); - } else -#endif - { + return; + } + + // Copy areas that were updated last frame to the back buffer + if (dma_areas.size() > 0) { + for (auto a: dma_areas) { + DMACopyBitmap(_back_buffer, LCD_W, LCD_H, a.x1, a.y1, + _front_buffer, LCD_W, LCD_H, a.x1, a.y1, + a.x2 - a.x1 + 1, a.y2 - a.y1 + 1); + } + dma_areas.clear(); + DMAWait(); + } + + // Copy changes from LVGL to LCD back buffer with 180 degree rotation _copy_rotate_180(_back_buffer, buffer, copy_area); + // Check for last LVGL update before we need to swap buffers if (lv_disp_flush_is_last(disp_drv)) { - - // swap back/front + // swap back/front buffers if (_front_buffer == _LCD_BUF_1) { _front_buffer = _LCD_BUF_2; _back_buffer = _LCD_BUF_1; @@ -161,30 +168,22 @@ static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, // Trigger async refresh _update_frame_buffer_addr(_front_buffer); - // Copy refreshed & rotated areas into new back buffer - uint16_t* src = _front_buffer; - uint16_t* dst = _back_buffer; - + // Save areas that need to be updated next time around lv_disp_t* disp = _lv_refr_get_disp_refreshing(); for(int i = 0; i < disp->inv_p; i++) { if(disp->inv_area_joined[i]) continue; + // Rotate area 180 degrees lv_area_t refr_area; - lv_area_copy(&refr_area, &disp->inv_areas[i]); - - // TRACE("Vert invert refresh {%d,%d,%d,%d}", refr_area.x1, - // refr_area.y1, refr_area.x2 - refr_area.x1 + 1, refr_area.y2 - refr_area.y1 + 1); + refr_area.x1 = LCD_W - disp->inv_areas[i].x2 - 1; + refr_area.x2 = LCD_W - disp->inv_areas[i].x1 - 1; + refr_area.y1 = LCD_H - disp->inv_areas[i].y2 - 1; + refr_area.y2 = LCD_H - disp->inv_areas[i].y1 - 1; - _rotate_area_180(refr_area); - - auto area_w = refr_area.x2 - refr_area.x1 + 1; - auto area_h = refr_area.y2 - refr_area.y1 + 1; - - DMACopyBitmap(dst, LCD_W, LCD_H, refr_area.x1, refr_area.y1, - src, LCD_W, LCD_H, refr_area.x1, refr_area.y1, - area_w, area_h); + dma_areas.push_back(refr_area); } - } + } else { + lv_disp_flush_ready(disp_drv); } #else // Direct mode @@ -337,7 +336,6 @@ void LCD_Init_LTDC() // Trigger on last line HAL_LTDC_ProgramLineEvent(&hltdc, LCD_PHYS_H); - __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); #if 0 DMA2D_ITConfig(DMA2D_CR_TCIE, ENABLE); @@ -425,8 +423,8 @@ void lcdInit() { #if defined(LCD_VERTICAL_INVERT) // Clear buffer first - memset(_LCD_BUF_1, 0, sizeof(_LCD_BUF_1)); - memset(_LCD_BUF_2, 0, sizeof(_LCD_BUF_2)); + memset(_LCD_BUF_1, 0, DISPLAY_BUFFER_SIZE * sizeof(pixel_t)); + memset(_LCD_BUF_2, 0, DISPLAY_BUFFER_SIZE * sizeof(pixel_t)); #endif // Initialize the LCD @@ -439,11 +437,11 @@ void lcdInit() lcdSetFlushCb(startLcdRefresh); } - extern "C" void LTDC_IRQHandler(void) { // clear interrupt flag __HAL_LTDC_CLEAR_FLAG(&hltdc, LTDC_FLAG_LI); - _frame_addr_reloaded = 1; -} + __HAL_LTDC_DISABLE_IT(&hltdc, LTDC_IT_LI); + lcdFlushed(); +} diff --git a/radio/src/targets/horus/lcd_st7796s_driver.cpp b/radio/src/targets/horus/lcd_st7796s_driver.cpp index 7fabd4d6eab..d1b2e8faee6 100644 --- a/radio/src/targets/horus/lcd_st7796s_driver.cpp +++ b/radio/src/targets/horus/lcd_st7796s_driver.cpp @@ -36,24 +36,17 @@ static LTDC_HandleTypeDef hltdc; static void* initialFrameBuffer = nullptr; -static volatile uint8_t _frame_addr_reloaded = 0; - static void startLcdRefresh(lv_disp_drv_t* disp_drv, uint16_t* buffer, const rect_t& copy_area) { (void)disp_drv; (void)copy_area; - LTDC_Layer1->CFBAR &= ~(LTDC_LxCFBAR_CFBADD); LTDC_Layer1->CFBAR = (uint32_t)buffer; // reload shadow registers on vertical blank - _frame_addr_reloaded = 0; LTDC->SRCR = LTDC_SRCR_VBR; - __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); - // wait for reload - // TODO: replace through some smarter mechanism without busy wait - while (_frame_addr_reloaded == 0); + __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); } uint32_t lcdPixelClock; @@ -389,7 +382,6 @@ void LCD_Init_LTDC() // Trigger on last line HAL_LTDC_ProgramLineEvent(&hltdc, LCD_W); - __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); } void LCD_LayerInit() @@ -466,5 +458,6 @@ extern "C" void LTDC_IRQHandler(void) { __HAL_LTDC_CLEAR_FLAG(&hltdc, LTDC_FLAG_LI); __HAL_LTDC_DISABLE_IT(&hltdc, LTDC_IT_LI); - _frame_addr_reloaded = 1; + + lcdFlushed(); } diff --git a/radio/src/targets/pa01/lcd_driver.cpp b/radio/src/targets/pa01/lcd_driver.cpp index da2ede11821..055d17ab3b5 100644 --- a/radio/src/targets/pa01/lcd_driver.cpp +++ b/radio/src/targets/pa01/lcd_driver.cpp @@ -120,6 +120,8 @@ static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, set_row_addr(y1, y2); memory_write(buffer, copy_area.w * copy_area.h); + + lcdFlushed(); } lcdSpiInitFucPtr lcdInitFunction; diff --git a/radio/src/targets/pl18/lcd_driver.cpp b/radio/src/targets/pl18/lcd_driver.cpp index 070bdb5f19a..e597c56219e 100644 --- a/radio/src/targets/pl18/lcd_driver.cpp +++ b/radio/src/targets/pl18/lcd_driver.cpp @@ -40,24 +40,17 @@ static void* initialFrameBuffer = nullptr; #define GPIO_AF_LTDC GPIO_AF14 -static volatile uint8_t _frame_addr_reloaded = 0; - static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, const rect_t ©_area) { (void)disp_drv; (void)copy_area; - LTDC_Layer1->CFBAR &= ~(LTDC_LxCFBAR_CFBADD); LTDC_Layer1->CFBAR = (uint32_t)buffer; // reload shadow registers on vertical blank - _frame_addr_reloaded = 0; LTDC->SRCR = LTDC_SRCR_VBR; - __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); - // wait for reload - // TODO: replace through some smarter mechanism without busy wait - while(_frame_addr_reloaded == 0); + __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); } lcdSpiInitFucPtr lcdInitFunction; @@ -2763,7 +2756,6 @@ void LCD_Init_LTDC() { // Trigger on last line HAL_LTDC_ProgramLineEvent(&hltdc, lcd_phys_h); - __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); } void LCD_LayerInit() { @@ -2887,6 +2879,6 @@ extern "C" void LTDC_IRQHandler(void) { __HAL_LTDC_CLEAR_FLAG(&hltdc, LTDC_FLAG_LI); __HAL_LTDC_DISABLE_IT(&hltdc, LTDC_IT_LI); - _frame_addr_reloaded = 1; -} + lcdFlushed(); +} diff --git a/radio/src/targets/simu/opentxsimulator.cpp b/radio/src/targets/simu/opentxsimulator.cpp index fcc6ca9563a..d296328ee92 100644 --- a/radio/src/targets/simu/opentxsimulator.cpp +++ b/radio/src/targets/simu/opentxsimulator.cpp @@ -20,6 +20,7 @@ */ #include "opentxsimulator.h" +#include "simpgmspace.h" #include "edgetx.h" #include "simulcd.h" #include "simuaudio.h" @@ -511,7 +512,10 @@ void OpenTxSimulator::touchEvent(int type, int x, int y) void OpenTxSimulator::lcdFlushed() { - ::lcdFlushed(); + // lcdFlushed() now calls lv_disp_flush_ready() unconditionally, so it must not + // run before lcdInitDisplayDriver() has set up disp_drv, nor after shutdown. + if (simuIsRunning()) + ::lcdFlushed(); } void OpenTxSimulator::setTrainerTimeout(uint16_t ms) diff --git a/radio/src/targets/st16/lcd_driver.cpp b/radio/src/targets/st16/lcd_driver.cpp index 941b506576c..b647f590303 100644 --- a/radio/src/targets/st16/lcd_driver.cpp +++ b/radio/src/targets/st16/lcd_driver.cpp @@ -40,8 +40,6 @@ static volatile uint16_t lcd_phys_h = LCD_PHYS_H; static LTDC_HandleTypeDef hltdc; static void* initialFrameBuffer = nullptr; -static volatile uint8_t _frame_addr_reloaded = 0; - static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, const rect_t ©_area) { @@ -53,16 +51,10 @@ static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, SCB_CleanDCache(); LTDC_Layer1->CFBAR = (uint32_t)buffer; - // reload shadow registers on vertical blank - _frame_addr_reloaded = 0; LTDC->SRCR = LTDC_SRCR_VBR; __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); - - // wait for reload - // TODO: replace through some smarter mechanism without busy wait - while(_frame_addr_reloaded == 0); } lcdSpiInitFucPtr lcdInitFunction; @@ -2760,7 +2752,6 @@ void LCD_Init_LTDC() { // Trigger on last line HAL_LTDC_ProgramLineEvent(&hltdc, lcd_phys_h); - __HAL_LTDC_ENABLE_IT(&hltdc, LTDC_IT_LI); } void LCD_LayerInit() { @@ -2885,5 +2876,6 @@ extern "C" void LTDC_IRQHandler(void) { __HAL_LTDC_CLEAR_FLAG(&hltdc, LTDC_FLAG_LI); __HAL_LTDC_DISABLE_IT(&hltdc, LTDC_IT_LI); - _frame_addr_reloaded = 1; + + lcdFlushed(); } diff --git a/radio/src/targets/stm32h7s78-dk/lcd_driver.cpp b/radio/src/targets/stm32h7s78-dk/lcd_driver.cpp index 4139306c57d..e5b0ec465cf 100644 --- a/radio/src/targets/stm32h7s78-dk/lcd_driver.cpp +++ b/radio/src/targets/stm32h7s78-dk/lcd_driver.cpp @@ -417,8 +417,6 @@ extern "C" void backlightInit() LPTIMx_PWM_Init(&hlcd_lptim); } -static volatile uint8_t _frame_addr_reloaded = 0; - static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, const rect_t ©_area) { @@ -427,18 +425,11 @@ static void startLcdRefresh(lv_disp_drv_t *disp_drv, uint16_t *buffer, SCB_CleanDCache(); - LTDC_Layer1->CFBAR &= ~(LTDC_LxCFBAR_CFBADD); LTDC_Layer1->CFBAR = (uint32_t)buffer; - // reload shadow registers on vertical blank - _frame_addr_reloaded = 0; LTDC->SRCR = LTDC_SRCR_VBR; __HAL_LTDC_ENABLE_IT(&hlcd_ltdc, LTDC_IT_LI); - - // wait for reload - // TODO: replace through some smarter mechanism without busy wait - while(_frame_addr_reloaded == 0); } extern "C" void lcdSetInitalFrameBuffer(void* fbAddress) @@ -460,5 +451,6 @@ extern "C" void LTDC_IRQHandler(void) // clear interrupt flag __HAL_LTDC_CLEAR_FLAG(&hlcd_ltdc, LTDC_FLAG_LI); __HAL_LTDC_DISABLE_IT(&hlcd_ltdc, LTDC_IT_LI); - _frame_addr_reloaded = 1; + + lcdFlushed(); } diff --git a/radio/src/targets/taranis/board.h b/radio/src/targets/taranis/board.h index c87e2ab62ae..39d5db47bbd 100644 --- a/radio/src/targets/taranis/board.h +++ b/radio/src/targets/taranis/board.h @@ -322,7 +322,6 @@ void lcdSetInvert(bool invert); #ifdef __cplusplus void lcdSetContrast(bool useDefault = false); #endif -void lcdFlushed(); // Top LCD driver #if defined(TOPLCD_GPIO) From 8100bfd21eb55241ded597c7b6b997d5efbb78d9 Mon Sep 17 00:00:00 2001 From: philmoz Date: Thu, 23 Jul 2026 13:23:52 +1000 Subject: [PATCH 04/55] fix(color): memory leak when creating a blank model (#7541) (cherry picked from commit 1b12ee92830498fc08f16c08726f648e0704ddfd) --- radio/src/edgetx.cpp | 1 - radio/src/gui/colorlcd/mainview/layout.cpp | 3 ++ .../gui/colorlcd/mainview/screen_setup.cpp | 5 +-- radio/src/gui/colorlcd/model/model_select.cpp | 2 + radio/src/gui/colorlcd/radio/hw_inputs.cpp | 4 +- radio/src/model_init.cpp | 1 - radio/src/storage/sdcard_common.cpp | 41 +++++++++---------- radio/src/storage/storage.h | 2 - 8 files changed, 27 insertions(+), 32 deletions(-) diff --git a/radio/src/edgetx.cpp b/radio/src/edgetx.cpp index 8933a7716d1..5691f71933a 100644 --- a/radio/src/edgetx.cpp +++ b/radio/src/edgetx.cpp @@ -1577,7 +1577,6 @@ void edgeTxInit() #endif // defined(GUI) #if defined(COLORLCD) - LayoutFactory::deleteCustomScreens(); LayoutFactory::loadCustomScreens(); #endif diff --git a/radio/src/gui/colorlcd/mainview/layout.cpp b/radio/src/gui/colorlcd/mainview/layout.cpp index e0a914920f0..8713d18dddb 100644 --- a/radio/src/gui/colorlcd/mainview/layout.cpp +++ b/radio/src/gui/colorlcd/mainview/layout.cpp @@ -149,6 +149,9 @@ void LayoutFactory::loadDefaultLayout() // void LayoutFactory::loadCustomScreens() { + // Delete old screens + deleteCustomScreens(); + unsigned i = 0; auto viewMain = ViewMain::instance(); diff --git a/radio/src/gui/colorlcd/mainview/screen_setup.cpp b/radio/src/gui/colorlcd/mainview/screen_setup.cpp index 1797cee2227..80b4024b27f 100644 --- a/radio/src/gui/colorlcd/mainview/screen_setup.cpp +++ b/radio/src/gui/colorlcd/mainview/screen_setup.cpp @@ -207,10 +207,7 @@ void ScreenSetupPage::build(Window* window) // Remove this screen from the model g_model.removeScreenLayout(customScreenIndex); - // Delete all custom screens - LayoutFactory::deleteCustomScreens(); - - // ... and reload + // Delete and reload custom screens LayoutFactory::loadCustomScreens(); // adjust index if last screen deleted diff --git a/radio/src/gui/colorlcd/model/model_select.cpp b/radio/src/gui/colorlcd/model/model_select.cpp index 2c85f7634a0..ab531732e6f 100644 --- a/radio/src/gui/colorlcd/model/model_select.cpp +++ b/radio/src/gui/colorlcd/model/model_select.cpp @@ -668,6 +668,8 @@ void ModelLabelsWindow::newModel() luaExecStandalone(path); } #endif + } else { + LayoutFactory::loadDefaultLayout(); } // Main view layout diff --git a/radio/src/gui/colorlcd/radio/hw_inputs.cpp b/radio/src/gui/colorlcd/radio/hw_inputs.cpp index 78ec17a64c7..9ff57c72cf4 100644 --- a/radio/src/gui/colorlcd/radio/hw_inputs.cpp +++ b/radio/src/gui/colorlcd/radio/hw_inputs.cpp @@ -122,10 +122,8 @@ HWPots::HWPots(Window* parent) : potsChanged = false; setCloseHandler([=]() { - if (potsChanged) { - LayoutFactory::deleteCustomScreens(); + if (potsChanged) LayoutFactory::loadCustomScreens(); - } }); new StaticText(this, {P_NM_X, -PAD_TINY, 0, 0}, STR_NAME, COLOR_THEME_PRIMARY1_INDEX, FONT(XS)); diff --git a/radio/src/model_init.cpp b/radio/src/model_init.cpp index a718330e577..74f0992be84 100644 --- a/radio/src/model_init.cpp +++ b/radio/src/model_init.cpp @@ -147,7 +147,6 @@ void applyDefaultTemplate() g_model.resetScreenData(); LayoutFactory::deleteCustomScreens(); LayoutFactory::deleteTopBarWidgets(); - LayoutFactory::loadDefaultLayout(); #endif // enable switch warnings diff --git a/radio/src/storage/sdcard_common.cpp b/radio/src/storage/sdcard_common.cpp index 962e96abb25..168d3a84de4 100644 --- a/radio/src/storage/sdcard_common.cpp +++ b/radio/src/storage/sdcard_common.cpp @@ -39,7 +39,22 @@ void getModelPath(char * path, const char * filename, const char* pathName) strcpy(&path[len + 1], filename); } -void storageEraseAll(bool warn) +static void forceSave() +{ + storageDirty(EE_GENERAL); + storageDirty(EE_MODEL); + storageCheck(true); +} + +static void storageFormat() +{ + sdCheckAndCreateDirectory(RADIO_PATH); + sdCheckAndCreateDirectory(MODELS_PATH); + generalDefault(); + setModelDefaults(); +} + +static void storageEraseAll() { TRACE("storageEraseAll"); @@ -55,24 +70,11 @@ void storageEraseAll(bool warn) g_eeGeneral.blOffBright = 20; #endif - if (warn) { - ALERT(STR_STORAGE_WARNING, STR_BAD_RADIO_DATA, AU_BAD_RADIODATA); - } - + ALERT(STR_STORAGE_WARNING, STR_BAD_RADIO_DATA, AU_BAD_RADIODATA); RAISE_ALERT(STR_STORAGE_WARNING, STR_STORAGE_FORMAT, STR_PRESS_ANY_KEY_TO_SKIP, AU_NONE); storageFormat(); - storageDirty(EE_GENERAL); - storageDirty(EE_MODEL); - storageCheck(true); -} - -void storageFormat() -{ - sdCheckAndCreateDirectory(RADIO_PATH); - sdCheckAndCreateDirectory(MODELS_PATH); - generalDefault(); - setModelDefaults(); + forceSave(); } void storageCheck(bool immediately) @@ -161,10 +163,7 @@ const char * createModel() if (index > 0) { setModelDefaults(index); memcpy(g_eeGeneral.currModelFilename, filename, sizeof(g_eeGeneral.currModelFilename)); - - storageDirty(EE_GENERAL); - storageDirty(EE_MODEL); - storageCheck(true); + forceSave(); } postModelLoad(false); @@ -210,7 +209,7 @@ void storageReadAll() g_eeGeneral.modelCustomScriptsDisabled = true; if (loadRadioSettings() != nullptr) { - storageEraseAll(true); + storageEraseAll(); } #if !defined(STORAGE_MODELSLIST) else { diff --git a/radio/src/storage/storage.h b/radio/src/storage/storage.h index e7c4d33b7d3..ae3de9d28d2 100644 --- a/radio/src/storage/storage.h +++ b/radio/src/storage/storage.h @@ -47,8 +47,6 @@ extern tmr10ms_t rambackupDirtyTime10ms; // // Generic storage interface // -void storageEraseAll(bool warn); -void storageFormat(); void storageReadAll(); void storageCheck(bool immediately); From da6ed73106200975580dfa49a2a976c16fa72c46 Mon Sep 17 00:00:00 2001 From: Sergey Yugoman Date: Wed, 12 Aug 2026 08:18:29 +0300 Subject: [PATCH 05/55] chore(cpn): improve SDL2 bundling and add SDL3 support for macOS builds (#7549) Co-authored-by: Sergey Yugoman Co-authored-by: Peter Feerick <5500713+pfeerick@users.noreply.github.com> (cherry picked from commit fcdf92d35b215eb6b92b66c52598c7f5a536c6d2) --- .gitignore | 1 + companion/src/CMakeLists.txt | 105 +++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/.gitignore b/.gitignore index e668536ba73..653ea9b3872 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ compile_commands.json /.vs CMakeUserPresets.json /site/ +*.dmg .devcontainer/devcontainer-lock.json diff --git a/companion/src/CMakeLists.txt b/companion/src/CMakeLists.txt index e9149d2e6a4..b967aba1749 100644 --- a/companion/src/CMakeLists.txt +++ b/companion/src/CMakeLists.txt @@ -558,6 +558,111 @@ elseif(APPLE) # Run macdeployqt install(SCRIPT ${qt_deploy_companion} COMPONENT Runtime) + # Bundle SDL2 (copy the real dylib; IMPORTED_RUNTIME_ARTIFACTS keeps + # Homebrew symlinks and breaks the app inside a .dmg). Homebrew's + # sdl2-compat also loads SDL3 from @loader_path/libSDL3.dylib at runtime. + if(TARGET SDL2::SDL2) + get_target_property(_sdl2_location SDL2::SDL2 IMPORTED_LOCATION) + if(NOT _sdl2_location) + get_target_property(_sdl2_location SDL2::SDL2 IMPORTED_LOCATION_RELEASE) + endif() + if(_sdl2_location AND _sdl2_location MATCHES "\\.framework(/|$)") + # Genuine upstream SDL2.framework (CI's setup-sdl2.sh, or a manual framework + # install): no SDL3 dlopen shim to worry about, and IMPORTED_RUNTIME_ARTIFACTS + # copies frameworks correctly — it's only the Homebrew dylib case below whose + # symlinks it mangles. This is what CI has shipped all along. + message(STATUS "Bundling SDL2 framework: ${_sdl2_location}") + install(IMPORTED_RUNTIME_ARTIFACTS SDL2::SDL2 + FRAMEWORK DESTINATION "${companion_app_dir}/Contents/Frameworks" + LIBRARY DESTINATION "${companion_app_dir}/Contents/Frameworks" + COMPONENT Runtime) + elseif(_sdl2_location) + message(STATUS "Bundling SDL2 library: ${_sdl2_location}") + get_filename_component(_sdl2_real "${_sdl2_location}" REALPATH) + get_filename_component(_sdl2_name "${_sdl2_location}" NAME) + install(FILES "${_sdl2_real}" + DESTINATION "${companion_app_dir}/Contents/Frameworks" + RENAME "${_sdl2_name}" + COMPONENT Runtime) + + set(_sdl3_real "") + find_package(SDL3 CONFIG QUIET) + if(TARGET SDL3::SDL3-shared) + get_target_property(_sdl3_location SDL3::SDL3-shared IMPORTED_LOCATION_RELEASE) + if(NOT _sdl3_location) + get_target_property(_sdl3_location SDL3::SDL3-shared IMPORTED_LOCATION) + endif() + if(_sdl3_location) + get_filename_component(_sdl3_real "${_sdl3_location}" REALPATH) + endif() + endif() + if(NOT _sdl3_real) + set(_sdl3_hints "") + if(DEFINED ENV{HOMEBREW_PREFIX}) + list(APPEND _sdl3_hints + "$ENV{HOMEBREW_PREFIX}/opt/sdl3/lib" + "$ENV{HOMEBREW_PREFIX}/lib") + endif() + find_program(_brew_EXECUTABLE brew) + if(_brew_EXECUTABLE) + execute_process( + COMMAND "${_brew_EXECUTABLE}" --prefix sdl3 + OUTPUT_VARIABLE _brew_sdl3_prefix + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE _brew_sdl3_result + ) + if(_brew_sdl3_result EQUAL 0 AND _brew_sdl3_prefix) + list(APPEND _sdl3_hints "${_brew_sdl3_prefix}/lib") + endif() + endif() + find_library(_sdl3_library + NAMES SDL3.0 SDL3 + HINTS ${_sdl3_hints} + NO_DEFAULT_PATH + ) + if(NOT _sdl3_library) + find_library(_sdl3_library NAMES SDL3.0 SDL3) + endif() + if(_sdl3_library) + get_filename_component(_sdl3_real "${_sdl3_library}" REALPATH) + endif() + endif() + if(_sdl3_real) + install(FILES "${_sdl3_real}" + DESTINATION "${companion_app_dir}/Contents/Frameworks" + RENAME "libSDL3.dylib" + COMPONENT Runtime) + else() + message(WARNING "SDL3 not found; packaged Companion may crash if linked against sdl2-compat") + endif() + + install(CODE " + set(_fw \"\${CMAKE_INSTALL_PREFIX}/${companion_app_dir}/Contents/Frameworks\") + execute_process( + COMMAND install_name_tool -id \"@executable_path/../Frameworks/${_sdl2_name}\" \"\${_fw}/${_sdl2_name}\" + RESULT_VARIABLE _sdl2_id_result + ERROR_VARIABLE _sdl2_id_error + ) + if(NOT _sdl2_id_result EQUAL 0) + message(FATAL_ERROR \"install_name_tool failed for ${_sdl2_name}: \${_sdl2_id_error}\") + endif() + if(EXISTS \"\${_fw}/libSDL3.dylib\") + execute_process( + COMMAND install_name_tool -id \"@executable_path/../Frameworks/libSDL3.dylib\" \"\${_fw}/libSDL3.dylib\" + RESULT_VARIABLE _sdl3_id_result + ERROR_VARIABLE _sdl3_id_error + ) + if(NOT _sdl3_id_result EQUAL 0) + message(FATAL_ERROR \"install_name_tool failed for libSDL3.dylib: \${_sdl3_id_error}\") + endif() + endif() + " COMPONENT Runtime) + else() + message(WARNING "SDL2::SDL2 has no IMPORTED_LOCATION; SDL2 will not be bundled") + endif() + endif() + # Fix Code Signing and rename bundle install(CODE " set(app_dir \"\${CMAKE_INSTALL_PREFIX}/${companion_app_dir}\") From 2500062306444bebec99fb83e9cf4b676c54e7ef Mon Sep 17 00:00:00 2001 From: Peter Feerick Date: Wed, 22 Jul 2026 09:46:24 +1000 Subject: [PATCH 06/55] fix(cpn): use JSON lookup for CFS (#7579) Co-authored-by: Claude Sonnet 5 (cherry picked from commit 19708ce792e331b4dff9c11ec6e539244054b685) --- companion/src/firmwares/rawsource.cpp | 4 ++-- companion/src/firmwares/rawswitch.cpp | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/companion/src/firmwares/rawsource.cpp b/companion/src/firmwares/rawsource.cpp index d92557d8cce..3ebb6d411bf 100644 --- a/companion/src/firmwares/rawsource.cpp +++ b/companion/src/firmwares/rawsource.cpp @@ -241,7 +241,7 @@ QString RawSource::toString(const ModelData * model, const GeneralSettings * con dfltName = Boards::getSwitchInfo(index - 1, board).name.c_str(); if (Boards::isSwitchFunc(index - 1, board)) { if (model) { - int fsindex = Boards::getSwitchTagNum(index - 1, board) - 1; + int fsindex = Boards::getCFSIndexForSwitch(index - 1, board); if (fsindex >= 0 && fsindex < CPN_MAX_SWITCHES_FUNCTION) custName = QString(model->customSwitches[fsindex].name).trimmed(); } @@ -372,7 +372,7 @@ bool RawSource::isAvailable(const ModelData * const model, const GeneralSettings return false; if (type == SOURCE_TYPE_SWITCH && b.isSwitchFunc(abs(index) - 1, board) && - !model->isFunctionSwitchSourceAllowed(b.getSwitchTagNum(abs(index) - 1, board) - 1)) + !model->isFunctionSwitchSourceAllowed(b.getCFSIndexForSwitch(abs(index) - 1, board))) return false; if (type == SOURCE_TYPE_VIRTUAL_INPUT && !model->isInputValid(abs(index) - 1)) diff --git a/companion/src/firmwares/rawswitch.cpp b/companion/src/firmwares/rawswitch.cpp index 1960486268e..1b078175266 100644 --- a/companion/src/firmwares/rawswitch.cpp +++ b/companion/src/firmwares/rawswitch.cpp @@ -92,8 +92,9 @@ QString RawSwitch::toString(Board::Type board, const GeneralSettings * const gen swName = Boards::getSwitchInfo(qr.quot, board).name.c_str(); if (Boards::isSwitchFunc(qr.quot, board)) { if (modelData) { - int fsindex = Boards::getSwitchTagNum(qr.quot, board) - 1; - custName = QString(modelData->customSwitches[fsindex].name).trimmed(); + int fsindex = Boards::getCFSIndexForSwitch(qr.quot, board); + if (fsindex >= 0 && fsindex < CPN_MAX_SWITCHES_FUNCTION) + custName = QString(modelData->customSwitches[fsindex].name).trimmed(); } } else { From c5098dbc36b3869d7175febd5df5c82f7b2e1d7e Mon Sep 17 00:00:00 2001 From: richardclli Date: Tue, 11 Aug 2026 06:12:23 +0800 Subject: [PATCH 07/55] chore(spiflash): improve the efficiency of "is block erased" detection (#7582) (cherry picked from commit 2848cc1cae494f9f75f5e725aeac2e3c93d63355) --- .../targets/common/arm/stm32/spi_flash.cpp | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/radio/src/targets/common/arm/stm32/spi_flash.cpp b/radio/src/targets/common/arm/stm32/spi_flash.cpp index c14a6532580..7e8f77210f3 100644 --- a/radio/src/targets/common/arm/stm32/spi_flash.cpp +++ b/radio/src/targets/common/arm/stm32/spi_flash.cpp @@ -270,8 +270,10 @@ uint32_t flashSpiGetSize() bool flashSpiIsErased(uint32_t address) { - // verify sector alignment - if((address & FLASH_SECTOR_MASK) != 0) + static uint8_t temp_buffer[FLASH_SECTOR_SIZE] __DMA_NO_CACHE; + + // Verify sector alignment + if ((address & FLASH_SECTOR_MASK) != 0) return false; flash_wait_for_not_busy(); @@ -283,17 +285,25 @@ bool flashSpiIsErased(uint32_t address) flash_put_cmd_addr(FLASH_CMD_READ, address); } - bool ret = true; - for(uint32_t i = 0; i < FLASH_SECTOR_SIZE; i++) { - uint8_t byte = flash_rw_byte(0xFF); - if (byte != 0xff) { - ret = false; - break; + // Check first 4 bytes + for (uint32_t i = 0; i < 4; i++) { + if (flash_rw_byte(0xFF) != 0xff) { + flash_unselect(); + return false; } } + // First 4 bytes all 0xFF, now need to check the remaining using DMA + flash_dma_read_bytes(temp_buffer, FLASH_SECTOR_SIZE - 4); flash_unselect(); - return ret; + + for (uint32_t i = 0; i < FLASH_SECTOR_SIZE - 4; i++) { + if (temp_buffer[i] != 0xff) { + return false; + } + } + + return true; } uint32_t flashSpiRead(uint32_t address, uint8_t* data, uint32_t size) From 3b446a60a2ed8ad7e4780b7cdc648b09d75d72c4 Mon Sep 17 00:00:00 2001 From: 3djc <5167938+3djc@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:59:37 +0200 Subject: [PATCH 08/55] fix(bw128): startup animation for 'regular' leds (#7591) (cherry picked from commit a82f44a2684f38fba6036f980c4beb90b0142a05) --- radio/src/gui/128x64/startup_shutdown.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/radio/src/gui/128x64/startup_shutdown.cpp b/radio/src/gui/128x64/startup_shutdown.cpp index a8866e1259d..a1f11e218e4 100644 --- a/radio/src/gui/128x64/startup_shutdown.cpp +++ b/radio/src/gui/128x64/startup_shutdown.cpp @@ -60,10 +60,10 @@ void drawStartupAnimation(uint32_t duration, uint32_t totalDuration) fsLedRGB(j, 0xFFFFFF); rgbLedColorApply(); #else - setFSLedON(j); + fsLedOn(j); #endif #if defined(RADIO_FAMILY_T20) - setFSLedON(j + steps); + fsLedOn(j + steps); #endif } } @@ -104,14 +104,14 @@ void drawShutdownAnimation(uint32_t duration, uint32_t totalDuration, } rgbLedColorApply(); #else - setFSLedOFF(j); + fsLedOff(j); #if defined(RADIO_FAMILY_T20) - setFSLedOFF(j + steps); + fsLedOff(j + steps); #endif if (steps - index2 > j) { - setFSLedON(j); + fsLedOn(j); #if defined(RADIO_FAMILY_T20) - setFSLedON(j + steps); + fsLedOn(j + steps); #endif } #endif From c9d16b22476efe22fc4f32bb67d592e1b66ed036 Mon Sep 17 00:00:00 2001 From: "jb.justcode" Date: Wed, 12 Aug 2026 03:00:06 +0200 Subject: [PATCH 09/55] fix(color): can not exit Ghost module config page with no module attached (#7599) Adapted for 2.12, which has no virtual Window::doKeyShortcut(). Upstream overrides that single virtual with an empty body to neutralise the SYS/MDL/ TELE shortcuts on this page; on 2.12 Page overrides the six onPress*/ onLongPress* hooks directly, so those six are neutralised individually instead. Without this the build fails on every colour target with "marked 'override', but does not override". (cherry picked from commit cffcbc42a8611adb7a4e06493025555a39b22541) --- .../src/gui/colorlcd/radio/radio_ghost_module_config.cpp | 6 ++++++ radio/src/gui/colorlcd/radio/radio_ghost_module_config.h | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/radio/src/gui/colorlcd/radio/radio_ghost_module_config.cpp b/radio/src/gui/colorlcd/radio/radio_ghost_module_config.cpp index 2dd2fa8a36c..da07a3ebc2e 100644 --- a/radio/src/gui/colorlcd/radio/radio_ghost_module_config.cpp +++ b/radio/src/gui/colorlcd/radio/radio_ghost_module_config.cpp @@ -63,6 +63,8 @@ class GhostModuleConfigWindow : public Window void checkEvents() override { + Window::checkEvents(); + for (uint8_t i = 0; i < GHST_MENU_LINES; i += 1) { menuLines[i][1]->setText(""); @@ -167,6 +169,7 @@ void RadioGhostModuleConfig::onLongPressRTN() reusableBuffer.ghostMenu.menuAction = GHST_MENU_CTRL_CLOSE; moduleState[EXTERNAL_MODULE].counter = GHST_MENU_CONTROL; sleep_ms(10); + deleteLater(); #if defined(TRIMS_EMULATE_BUTTONS) setHatsAsKeys(false); // switch trims back to normal #endif @@ -195,6 +198,9 @@ void RadioGhostModuleConfig::checkEvents() void RadioGhostModuleConfig::init() { memclear(&reusableBuffer.ghostMenu, sizeof(reusableBuffer.ghostMenu)); + strAppend((char *)&reusableBuffer.ghostMenu.line[1].menuText, + STR_WAITING_FOR_MODULE, 0); + reusableBuffer.ghostMenu.line[1].lineFlags = GHST_LINE_FLAGS_VALUE_EDIT; reusableBuffer.ghostMenu.buttonAction = GHST_BTN_NONE; reusableBuffer.ghostMenu.menuAction = GHST_MENU_CTRL_OPEN; moduleState[EXTERNAL_MODULE].counter = GHST_MENU_CONTROL; diff --git a/radio/src/gui/colorlcd/radio/radio_ghost_module_config.h b/radio/src/gui/colorlcd/radio/radio_ghost_module_config.h index 9bf183f5e3e..67f209957c3 100644 --- a/radio/src/gui/colorlcd/radio/radio_ghost_module_config.h +++ b/radio/src/gui/colorlcd/radio/radio_ghost_module_config.h @@ -39,5 +39,13 @@ class RadioGhostModuleConfig: public Page void checkEvents() override; void onCancel() override; void onLongPressRTN() override; + // 2.12 has no virtual Window::doKeyShortcut() to override - Page overrides + // these six hooks directly, so neutralise them individually instead. + void onPressSYS() override {} + void onLongPressSYS() override {} + void onPressMDL() override {} + void onLongPressMDL() override {} + void onPressTELE() override {} + void onLongPressTELE() override {} #endif }; From 008026da84615c88288a8bc07fd37c3d625bd041 Mon Sep 17 00:00:00 2001 From: Peter Feerick Date: Tue, 11 Aug 2026 08:26:35 +1000 Subject: [PATCH 10/55] fix(tools): install locales and fix re-run issues in Ubuntu setup scripts (#7603) Co-authored-by: Claude Opus 5 (cherry picked from commit 81db17e5a7a41f11668d7d8697a9597d24a2735b) --- tools/setup_buildenv_ubuntu22.04.sh | 39 ++++++++++++++++++++++++----- tools/setup_buildenv_ubuntu24.04.sh | 39 ++++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/tools/setup_buildenv_ubuntu22.04.sh b/tools/setup_buildenv_ubuntu22.04.sh index 9958e9e1437..9efe0d983ba 100755 --- a/tools/setup_buildenv_ubuntu22.04.sh +++ b/tools/setup_buildenv_ubuntu22.04.sh @@ -18,13 +18,22 @@ do fi done +if ! command -v lsb_release >/dev/null 2>&1; then + echo "ERROR: lsb_release not found, so the Ubuntu version cannot be checked." + echo "Install it with: sudo apt-get install lsb-release" + echo "Terminating the script now." + exit 1 +fi + if [[ $(lsb_release -rs) != "22.04" ]]; then echo "ERROR: Not running on Ubuntu 22.04!" echo "Terminating the script now." exit 1 fi -echo "=== Step $((STEP++)): Setting up package repositories ===" +echo "=== Step $((STEP++)): Setting up package repositories. Please provide sudo credentials, when asked ===" +# Refresh in case package lists are stale +sudo apt-get -y update sudo apt-get -y install --no-install-recommends software-properties-common gpg gpg-agent wget ca-certificates sudo mkdir -p /etc/apt/keyrings # Set up Kitware repository for newer cmake @@ -46,7 +55,7 @@ if [[ $PAUSEAFTEREACHLINE == "true" ]]; then read fi -echo "=== Step $((STEP++)): Updating Ubuntu package lists. Please provide sudo credentials, when asked ===" +echo "=== Step $((STEP++)): Updating Ubuntu package lists for the newly added repositories ===" sudo apt-get -y update if [[ $PAUSEAFTEREACHLINE == "true" ]]; then echo "Step finished. Please check the output above and press Enter to continue or Ctrl+C to stop." @@ -63,6 +72,7 @@ sudo apt-get -y install --no-install-recommends \ unzip \ file \ gawk \ + m4 \ libsdl2-dev \ python3-dev \ python3-pip \ @@ -76,6 +86,7 @@ sudo apt-get -y install --no-install-recommends \ python-is-python3 \ dfu-util \ nodejs \ + locales \ stlink-tools \ openocd \ pv @@ -91,6 +102,18 @@ if [[ $PAUSEAFTEREACHLINE == "true" ]]; then read fi +echo "=== Step $((STEP++)): Generating locales used by the custom function sorter ===" +# cfn_sorter.sh sorts with std::locale, which aborts if a locale is missing +sudo locale-gen \ + zh_CN.UTF-8 cs_CZ.UTF-8 da_DK.UTF-8 de_DE.UTF-8 es_ES.UTF-8 en_US.UTF-8 \ + fi_FI.UTF-8 fr_FR.UTF-8 he_IL.UTF-8 it_IT.UTF-8 ja_JP.UTF-8 ko_KR.UTF-8 \ + nl_NL.UTF-8 pl_PL.UTF-8 pt_PT.UTF-8 ru_RU.UTF-8 sv_SE.UTF-8 zh_TW.UTF-8 \ + uk_UA.UTF-8 +if [[ $PAUSEAFTEREACHLINE == "true" ]]; then + echo "Step finished. Please check the output above and press Enter to continue or Ctrl+C to stop." + read +fi + echo "=== Step $((STEP++)): Installing Python packages ===" sudo python3 -m pip install \ asciitree \ @@ -137,6 +160,8 @@ if [[ $PAUSEAFTEREACHLINE == "true" ]]; then fi echo "=== Step $((STEP++)): Moving GNU Arm Embedded Toolchains to /opt ===" +# Remove first: mv into an existing directory nests instead of replacing +sudo rm -rf /opt/gcc-arm-none-eabi sudo mv arm-gnu-toolchain-${GCC_ARM_VERSION}-x86_64-arm-none-eabi /opt/gcc-arm-none-eabi if [[ $PAUSEAFTEREACHLINE == "true" ]]; then echo "Step finished. Please press Enter to continue or Ctrl+C to stop." @@ -144,10 +169,12 @@ if [[ $PAUSEAFTEREACHLINE == "true" ]]; then fi echo "=== Step $((STEP++)): Adding GNU Arm Embedded Toolchain and Qt to PATH of current user ===" -cat >> ~/.bashrc << EOF -export PATH="/opt/gcc-arm-none-eabi/bin:\$PATH" -export PATH="/opt/qt/${QT_VERSION}/gcc_64/bin:\$PATH" -EOF +# Only append if missing, so re-runs do not duplicate +touch ~/.bashrc +GCC_PATH_LINE="export PATH=\"/opt/gcc-arm-none-eabi/bin:\$PATH\"" +QT_PATH_LINE="export PATH=\"/opt/qt/${QT_VERSION}/gcc_64/bin:\$PATH\"" +grep -qxF "${GCC_PATH_LINE}" ~/.bashrc || echo "${GCC_PATH_LINE}" >> ~/.bashrc +grep -qxF "${QT_PATH_LINE}" ~/.bashrc || echo "${QT_PATH_LINE}" >> ~/.bashrc if [[ $PAUSEAFTEREACHLINE == "true" ]]; then echo "Step finished. Please press Enter to continue or Ctrl+C to stop." read diff --git a/tools/setup_buildenv_ubuntu24.04.sh b/tools/setup_buildenv_ubuntu24.04.sh index 28ce23f618e..02ce986a0ee 100755 --- a/tools/setup_buildenv_ubuntu24.04.sh +++ b/tools/setup_buildenv_ubuntu24.04.sh @@ -18,13 +18,22 @@ do fi done +if ! command -v lsb_release >/dev/null 2>&1; then + echo "ERROR: lsb_release not found, so the Ubuntu version cannot be checked." + echo "Install it with: sudo apt-get install lsb-release" + echo "Terminating the script now." + exit 1 +fi + if [[ $(lsb_release -rs) != "24.04" ]]; then echo "ERROR: Not running on Ubuntu 24.04!" echo "Terminating the script now." exit 1 fi -echo "=== Step $((STEP++)): Setting up package repositories ===" +echo "=== Step $((STEP++)): Setting up package repositories. Please provide sudo credentials, when asked ===" +# Refresh in case package lists are stale +sudo apt-get -y update sudo apt-get -y install --no-install-recommends software-properties-common gpg gpg-agent wget ca-certificates sudo mkdir -p /etc/apt/keyrings # Set up Kitware repository for newer cmake @@ -42,7 +51,7 @@ if [[ $PAUSEAFTEREACHLINE == "true" ]]; then read fi -echo "=== Step $((STEP++)): Updating Ubuntu package lists. Please provide sudo credentials, when asked ===" +echo "=== Step $((STEP++)): Updating Ubuntu package lists for the newly added repositories ===" sudo apt-get -y update if [[ $PAUSEAFTEREACHLINE == "true" ]]; then echo "Step finished. Please check the output above and press Enter to continue or Ctrl+C to stop." @@ -59,6 +68,7 @@ sudo apt-get -y install --no-install-recommends \ unzip \ file \ gawk \ + m4 \ libsdl2-dev \ python3-dev \ python3-pip \ @@ -72,6 +82,7 @@ sudo apt-get -y install --no-install-recommends \ python-is-python3 \ dfu-util \ nodejs \ + locales \ stlink-tools \ openocd \ pv @@ -87,6 +98,18 @@ if [[ $PAUSEAFTEREACHLINE == "true" ]]; then read fi +echo "=== Step $((STEP++)): Generating locales used by the custom function sorter ===" +# cfn_sorter.sh sorts with std::locale, which aborts if a locale is missing +sudo locale-gen \ + zh_CN.UTF-8 cs_CZ.UTF-8 da_DK.UTF-8 de_DE.UTF-8 es_ES.UTF-8 en_US.UTF-8 \ + fi_FI.UTF-8 fr_FR.UTF-8 he_IL.UTF-8 it_IT.UTF-8 ja_JP.UTF-8 ko_KR.UTF-8 \ + nl_NL.UTF-8 pl_PL.UTF-8 pt_PT.UTF-8 ru_RU.UTF-8 sv_SE.UTF-8 zh_TW.UTF-8 \ + uk_UA.UTF-8 +if [[ $PAUSEAFTEREACHLINE == "true" ]]; then + echo "Step finished. Please check the output above and press Enter to continue or Ctrl+C to stop." + read +fi + echo "=== Step $((STEP++)): Installing Python packages ===" # Python 3.12+ enforces PEP 668 (managed environment); --break-system-packages allows pip installs outside a venv sudo python3 -m pip install --break-system-packages --ignore-installed \ @@ -134,6 +157,8 @@ if [[ $PAUSEAFTEREACHLINE == "true" ]]; then fi echo "=== Step $((STEP++)): Moving GNU Arm Embedded Toolchains to /opt ===" +# Remove first: mv into an existing directory nests instead of replacing +sudo rm -rf /opt/gcc-arm-none-eabi sudo mv arm-gnu-toolchain-${GCC_ARM_VERSION}-x86_64-arm-none-eabi /opt/gcc-arm-none-eabi if [[ $PAUSEAFTEREACHLINE == "true" ]]; then echo "Step finished. Please press Enter to continue or Ctrl+C to stop." @@ -141,10 +166,12 @@ if [[ $PAUSEAFTEREACHLINE == "true" ]]; then fi echo "=== Step $((STEP++)): Adding GNU Arm Embedded Toolchain and Qt to PATH of current user ===" -cat >> ~/.bashrc << EOF -export PATH="/opt/gcc-arm-none-eabi/bin:\$PATH" -export PATH="/opt/qt/${QT_VERSION}/gcc_64/bin:\$PATH" -EOF +# Only append if missing, so re-runs do not duplicate +touch ~/.bashrc +GCC_PATH_LINE="export PATH=\"/opt/gcc-arm-none-eabi/bin:\$PATH\"" +QT_PATH_LINE="export PATH=\"/opt/qt/${QT_VERSION}/gcc_64/bin:\$PATH\"" +grep -qxF "${GCC_PATH_LINE}" ~/.bashrc || echo "${GCC_PATH_LINE}" >> ~/.bashrc +grep -qxF "${QT_PATH_LINE}" ~/.bashrc || echo "${QT_PATH_LINE}" >> ~/.bashrc if [[ $PAUSEAFTEREACHLINE == "true" ]]; then echo "Step finished. Please press Enter to continue or Ctrl+C to stop." read From c54aafb3ab265714d556b30ff2e962f1167ab438 Mon Sep 17 00:00:00 2001 From: "jb.justcode" Date: Wed, 12 Aug 2026 02:14:55 +0200 Subject: [PATCH 11/55] fix(ghost): do not memcpy MSP frames into the module buffer (#7608) Co-authored-by: Claude Fable 5 (cherry picked from commit 36a36c52e7beb692ac837b9b894f4483e3aed988) --- radio/src/pulses/ghost.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/radio/src/pulses/ghost.cpp b/radio/src/pulses/ghost.cpp index 9dc8997ee26..188057664f9 100644 --- a/radio/src/pulses/ghost.cpp +++ b/radio/src/pulses/ghost.cpp @@ -201,7 +201,12 @@ static void ghostSendPulses(void* ctx, uint8_t* buffer, int16_t* channels, uint8 *p_data++ = getGhostModuleAddr(); // and length *p_data++ = 12; - memcpy(p_data, f_data, 12); + // byte-wise: this buffer is non-cacheable memory on some targets, where + // the unaligned wide accesses memcpy emits at this offset are faulting + volatile uint8_t* dst = p_data; + for (uint8_t i = 0; i < 12; i++) { + *dst++ = f_data[i]; + } p_data += 12; f_data += 12; len -= 12; } From 1808503aca5e4fe154888efff6f0253c2910119c Mon Sep 17 00:00:00 2001 From: "jb.justcode" Date: Sun, 16 Aug 2026 05:23:48 +0200 Subject: [PATCH 12/55] fix(ghost): use a usable baudrate for the telemetry mirror (#7610) (cherry picked from commit 245da4599a68fc81e3de154ae47a1b422f2fb22d) --- radio/src/serial.cpp | 10 ++++++++++ radio/src/telemetry/ghost.h | 1 + 2 files changed, 11 insertions(+) diff --git a/radio/src/serial.cpp b/radio/src/serial.cpp index 1f09287cdbc..3148ca55a9b 100644 --- a/radio/src/serial.cpp +++ b/radio/src/serial.cpp @@ -46,6 +46,10 @@ #include "telemetry/crossfire.h" #endif +#if defined(GHOST) + #include "telemetry/ghost.h" +#endif + #if defined(DEBUG_SEGGER_RTT) #include "thirdparty/Segger/SEGGER/SEGGER_RTT.h" #endif @@ -300,6 +304,12 @@ static void serialSetupPort(int mode, etx_serial_init& params) params.baudrate = CROSSFIRE_TELEM_MIRROR_BAUDRATE; break; } +#endif +#if defined(GHOST) + if (isModuleGhost(EXTERNAL_MODULE) || isModuleGhost(INTERNAL_MODULE)) { + params.baudrate = GHOST_TELEM_MIRROR_BAUDRATE; + break; + } #endif params.baudrate = FRSKY_TELEM_MIRROR_BAUDRATE; break; diff --git a/radio/src/telemetry/ghost.h b/radio/src/telemetry/ghost.h index 817c50f3b1e..3e0afd794f6 100644 --- a/radio/src/telemetry/ghost.h +++ b/radio/src/telemetry/ghost.h @@ -119,6 +119,7 @@ enum GhostTelemetryBaudrates }; #endif #define GHOST_BAUDRATE 400000 +#define GHOST_TELEM_MIRROR_BAUDRATE 115200 #define GHOST_PERIOD 4000 /* us */ enum GhostLineFlags From fdb986c11751995d13c7751e0a1e7bccae719def Mon Sep 17 00:00:00 2001 From: 3djc <5167938+3djc@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:08:19 +0200 Subject: [PATCH 13/55] fix(radio): addressable RGB LED signal left floating between frames (#7620) (cherry picked from commit 5e265601e08e94c4bf4414f50a793db236bc9975) --- .../src/targets/common/arm/stm32/stm32_dma.h | 26 ++++ .../targets/common/arm/stm32/stm32_ws2812.cpp | 122 ++++++++++-------- .../targets/common/arm/stm32/stm32_ws2812.h | 5 - 3 files changed, 91 insertions(+), 62 deletions(-) diff --git a/radio/src/targets/common/arm/stm32/stm32_dma.h b/radio/src/targets/common/arm/stm32/stm32_dma.h index f12b4a849fc..6294ed1c39b 100644 --- a/radio/src/targets/common/arm/stm32/stm32_dma.h +++ b/radio/src/targets/common/arm/stm32/stm32_dma.h @@ -46,6 +46,18 @@ inline static bool stm32_dma_check_ht_flag(DMA_TypeDef* DMAx, uint32_t DMA_Strea return true; } +// A stream cannot be re-enabled while any of its event flags is still set +inline static void stm32_dma_clear_flags(DMA_TypeDef* DMAx, uint32_t DMA_Stream) +{ + LL_DMA_ClearFlag_TC(DMAx, DMA_Stream); + LL_DMA_ClearFlag_HT(DMAx, DMA_Stream); + LL_DMA_ClearFlag_DTE(DMAx, DMA_Stream); + LL_DMA_ClearFlag_ULE(DMAx, DMA_Stream); + LL_DMA_ClearFlag_USE(DMAx, DMA_Stream); + LL_DMA_ClearFlag_SUSP(DMAx, DMA_Stream); + LL_DMA_ClearFlag_TO(DMAx, DMA_Stream); +} + #else // STM32H7RS inline static bool stm32_dma_check_tc_flag(DMA_TypeDef* DMAx, uint32_t DMA_Stream) @@ -128,6 +140,20 @@ inline static bool stm32_dma_check_ht_flag(DMA_TypeDef* DMAx, uint32_t DMA_Strea return true; } +// FEIF | DMEIF | TEIF | HTIF | TCIF of one stream, in LIFCR (0-3) / HIFCR (4-7). +// A stream cannot be re-enabled while any of its event flags is still set. +inline static void stm32_dma_clear_flags(DMA_TypeDef* DMAx, uint32_t DMA_Stream) +{ + static const uint8_t _flag_pos[] = {0, 6, 16, 22}; + uint32_t mask = 0x3DUL << _flag_pos[DMA_Stream & 0x3]; + + if (DMA_Stream <= LL_DMA_STREAM_3) { + DMAx->LIFCR = mask; + } else { + DMAx->HIFCR = mask; + } +} + #endif // !STM32H7RS void stm32_dma_enable_clock(DMA_TypeDef* DMAx); diff --git a/radio/src/targets/common/arm/stm32/stm32_ws2812.cpp b/radio/src/targets/common/arm/stm32/stm32_ws2812.cpp index 801d482e5aa..146897f5c7f 100644 --- a/radio/src/targets/common/arm/stm32/stm32_ws2812.cpp +++ b/radio/src/targets/common/arm/stm32/stm32_ws2812.cpp @@ -21,11 +21,10 @@ #include "stm32_ws2812.h" #include "stm32_dma.h" +#include "stm32_gpio.h" -#if defined(DEBUG_WS2812) - // LED_STRIP_DEBUG_GPIO && LED_STRIP_DEBUG_GPIO_PIN - #include "hal.h" -#endif +// LED_STRIP_LENGTH (and LED_STRIP_DEBUG_GPIO when DEBUG_WS2812) +#include "hal.h" #include "definitions.h" @@ -41,10 +40,7 @@ static uint8_t _r_offset; static uint8_t _g_offset; static uint8_t _b_offset; -// DMA buffer contains data for 2 LEDs and is filled -// half by half on DMA HT and TC IRQs -#define WS2821_DMA_BUFFER_HALF_LEN (WS2812_BYTES_PER_LED * 8) -#define WS2821_DMA_BUFFER_LEN (WS2821_DMA_BUFFER_HALF_LEN * 2) +#define WS2812_SLOTS_PER_LED (WS2812_BYTES_PER_LED * 8) #define WS2812_FREQ 800000UL // 800 kHz #define WS2812_TIMER_PERIOD 20UL @@ -52,6 +48,17 @@ static uint8_t _b_offset; #define WS2812_ZERO (1 * WS2812_TIMER_PERIOD / 4) #define WS2812_DMA_IRQ_PRIO 3 +// Zero slots appended to the frame: the line stays low while they are sent, +// so the end-of-frame IRQ may run late without emitting a spurious bit. +#define WS2812_TRAIL_SLOTS 4 + +#if !defined(LED_STRIP_LENGTH) + #define LED_STRIP_LENGTH 1 +#endif + +#define WS2812_FRAME_SLOTS \ + (LED_STRIP_LENGTH * WS2812_SLOTS_PER_LED + WS2812_TRAIL_SLOTS) + // Debug facility #if defined(LED_STRIP_DEBUG_GPIO) && defined(LED_STRIP_DEBUG_GPIO_PIN) @@ -82,15 +89,17 @@ static void _led_dbg_init() { typedef uint16_t led_timer_value_t; uint8_t pulse_inc = 1; -// DMA buffer contains pulses for 2 LED at a time -// (allows for refill at HT and TC) +// The whole frame is emitted in a single DMA transfer: the buffer is filled +// before the transfer starts and no refill deadline exists, so an ISR delayed +// by a higher priority IRQ (SDMMC, USB) can no longer corrupt a frame. #if defined(STM32_SUPPORT_32BIT_TIMERS) -static led_timer_value_t _led_dma_buffer[WS2821_DMA_BUFFER_LEN * 2] __DMA_NO_CACHE; +static led_timer_value_t _led_dma_buffer[WS2812_FRAME_SLOTS * 2] __DMA_NO_CACHE; #else -static led_timer_value_t _led_dma_buffer[WS2821_DMA_BUFFER_LEN] __DMA_NO_CACHE; +static led_timer_value_t _led_dma_buffer[WS2812_FRAME_SLOTS] __DMA_NO_CACHE; #endif -static uint8_t _led_seq_cnt; +// Number of slots actually sent, from the strip length given at init +static uint32_t _frame_slots; static void _fill_byte(uint8_t c, led_timer_value_t* dma_buffer) { @@ -109,57 +118,34 @@ static void _fill_pulses(const uint8_t* colors, led_timer_value_t* dma_buffer, u } } -static inline uint32_t _calc_offset(uint8_t tc) -{ - return tc * WS2821_DMA_BUFFER_HALF_LEN * pulse_inc; -} - -static void _update_dma_buffer(const stm32_pulse_timer_t* tim, uint8_t tc) +static void _end_of_frame(const stm32_pulse_timer_t* tim) { WS2812_DBG_HIGH; - if (_led_seq_cnt < _led_strip_len) { - - auto idx = WS2812_BYTES_PER_LED * _led_seq_cnt; - auto offset = _calc_offset(tc); - _fill_pulses(&_led_colors[idx], &_led_dma_buffer[offset], WS2812_BYTES_PER_LED); - _led_seq_cnt++; - - } else if(_led_seq_cnt < _led_strip_len + WS2812_TRAILING_RESET) { - - // no need to reset the buffer after 2 cycles - if (_led_seq_cnt < _led_strip_len + 2) { - auto offset = _calc_offset(tc); - auto size = WS2821_DMA_BUFFER_HALF_LEN * sizeof(led_timer_value_t) * pulse_inc; - memset(&_led_dma_buffer[offset], 0, size); - } - _led_seq_cnt++; - } else { + LL_DMA_DisableIT_TC(tim->DMAx, tim->DMA_Stream); + LL_DMA_DisableStream(tim->DMAx, tim->DMA_Stream); - LL_DMA_DisableIT_TC(tim->DMAx, tim->DMA_Stream); - LL_DMA_DisableIT_HT(tim->DMAx, tim->DMA_Stream); - LL_DMA_DisableStream(tim->DMAx, tim->DMA_Stream); + uint32_t timeout = 1000; + while (LL_DMA_IsEnabledStream(tim->DMAx, tim->DMA_Stream) && timeout--) { + __NOP(); // Wait + } - uint32_t timeout = 1000; - while (LL_DMA_IsEnabledStream(tim->DMAx, tim->DMA_Stream) && timeout--) { - __NOP(); // Wait - } + // Stop the request source, but leave the channel and the counter running + // with a null compare value: the output is then actively held low between + // frames. Disabling the channel releases the pad instead (OSSR = 0), the + // line floats, and a WS2812 that misses its reset gap keeps counting bits + // across frames -- which shifts the whole strip by one LED. + LL_TIM_DisableDMAReq_UPDATE(tim->TIMx); + stm32_pulse_set_cmp_val(tim, 0); - LL_TIM_CC_DisableChannel(tim->TIMx, tim->TIM_Channel); - } WS2812_DBG_LOW; } void ws2812_dma_isr(const stm32_pulse_timer_t* tim) { - if (LL_DMA_IsEnabledIT_HT(tim->DMAx, tim->DMA_Stream) && - stm32_dma_check_ht_flag(tim->DMAx, tim->DMA_Stream)) { - _update_dma_buffer(tim, 0); - } - if (LL_DMA_IsEnabledIT_TC(tim->DMAx, tim->DMA_Stream) && stm32_dma_check_tc_flag(tim->DMAx, tim->DMA_Stream)) { - _update_dma_buffer(tim, 1); + _end_of_frame(tim); } } @@ -191,12 +177,17 @@ static void _init_timer(const stm32_pulse_timer_t* tim) stm32_pulse_config_output(tim, true, LL_TIM_OCMODE_PWM1, 0); LL_TIM_SetAutoReload(tim->TIMx, WS2812_TIMER_PERIOD - 1); + // Insurance for the windows where the timer does not drive the pad (boot, + // de-init): a floating WS2812 input can miss the inter-frame reset. + LL_GPIO_SetPinPull(gpio_get_port(tim->GPIO), 1 << gpio_get_pin(tim->GPIO), + LL_GPIO_PULL_DOWN); + // pulse driver uses DMA to ARR, but we need CCRx _led_set_dma_periph_addr(tim); - LL_DMA_SetMode(tim->DMAx, tim->DMA_Stream, LL_DMA_MODE_CIRCULAR); - LL_DMA_SetDataLength(tim->DMAx, tim->DMA_Stream, WS2821_DMA_BUFFER_LEN); - LL_DMA_SetMemoryAddress(tim->DMAx, tim->DMA_Stream, (uint32_t)_led_dma_buffer); + // One shot per frame: NDTR and the memory address are re-programmed by + // ws2812_update() before every transfer. + LL_DMA_SetMode(tim->DMAx, tim->DMA_Stream, LL_DMA_MODE_NORMAL); // we need to use a higher prio to avoid having // issues with some other things used during boot @@ -209,8 +200,11 @@ void ws2812_init(const stm32_pulse_timer_t* timer, uint8_t* strip_colors, WS2812_DBG_INIT; pulse_inc = IS_TIM_32B_COUNTER_INSTANCE(timer->TIMx) ? 2 : 1; + if (strip_len > LED_STRIP_LENGTH) strip_len = LED_STRIP_LENGTH; + _led_colors = strip_colors; _led_strip_len = strip_len; + _frame_slots = strip_len * WS2812_SLOTS_PER_LED + WS2812_TRAIL_SLOTS; memset(_led_colors, 0, strip_len * WS2812_BYTES_PER_LED); memset(_led_dma_buffer, 0, sizeof(_led_dma_buffer)); @@ -219,6 +213,12 @@ void ws2812_init(const stm32_pulse_timer_t* timer, uint8_t* strip_colors, _b_offset = type & 0b11; _init_timer(timer); + + // Drive the data line low right away, and keep it driven until the first + // frame: an idle WS2812 input must never be left floating. + stm32_pulse_set_cmp_val(timer, 0); + LL_TIM_CC_EnableChannel(timer->TIMx, timer->TIM_Channel); + LL_TIM_EnableCounter(timer->TIMx); } void ws2812_set_color_in_buf(uint8_t* buf, uint8_t led, @@ -258,13 +258,21 @@ void ws2812_update(const stm32_pulse_timer_t* tim) WS2812_DBG_HIGH; if (!stm32_pulse_if_not_running_disable(tim)) return; - _led_seq_cnt = 0; - memset(_led_dma_buffer, 0, sizeof(_led_dma_buffer)); + // Build the whole frame up front, trailing slots left at 0 + _fill_pulses(_led_colors, _led_dma_buffer, + _led_strip_len * WS2812_BYTES_PER_LED); + memset(&_led_dma_buffer[_led_strip_len * WS2812_SLOTS_PER_LED * pulse_inc], + 0, WS2812_TRAIL_SLOTS * sizeof(led_timer_value_t) * pulse_inc); + + // NDTR and the address are not reloaded by enabling the stream again + stm32_dma_clear_flags(tim->DMAx, tim->DMA_Stream); + LL_DMA_SetMemoryAddress(tim->DMAx, tim->DMA_Stream, (uint32_t)_led_dma_buffer); + LL_DMA_SetDataLength(tim->DMAx, tim->DMA_Stream, _frame_slots); - LL_DMA_EnableIT_HT(tim->DMAx, tim->DMA_Stream); LL_DMA_EnableIT_TC(tim->DMAx, tim->DMA_Stream); LL_DMA_EnableStream(tim->DMAx, tim->DMA_Stream); + LL_TIM_SetCounter(tim->TIMx, 0); LL_TIM_EnableDMAReq_UPDATE(tim->TIMx); LL_TIM_CC_EnableChannel(tim->TIMx, tim->TIM_Channel); LL_TIM_EnableCounter(tim->TIMx); diff --git a/radio/src/targets/common/arm/stm32/stm32_ws2812.h b/radio/src/targets/common/arm/stm32/stm32_ws2812.h index 505c52cfdaa..86f733d24e1 100644 --- a/radio/src/targets/common/arm/stm32/stm32_ws2812.h +++ b/radio/src/targets/common/arm/stm32/stm32_ws2812.h @@ -30,11 +30,6 @@ // RGB #define WS2812_BYTES_PER_LED 3 -// Number of LED periods used for trailing reset -#if !defined(WS2812_TRAILING_RESET) -# define WS2812_TRAILING_RESET 10 -#endif - void ws2812_init(const stm32_pulse_timer_t* timer, uint8_t* strip_colors, uint8_t strip_len, uint8_t type); void ws2812_update(const stm32_pulse_timer_t* timer); From 1f0d6f6d69875a4b30c5f660ecc6d7e810df75c8 Mon Sep 17 00:00:00 2001 From: helloradiosky <157123396+helloradiosky@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:03:07 +0800 Subject: [PATCH 14/55] fix(v14): restore correct RTC battery voltage reading (#7621) (cherry picked from commit 1d42488cfbb1bd0a261eb95a384f5ef0be35366a) --- .../targets/common/arm/stm32/stm32_adc.cpp | 10 ++++- radio/src/targets/taranis/hal.h | 39 ++++++++++--------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/radio/src/targets/common/arm/stm32/stm32_adc.cpp b/radio/src/targets/common/arm/stm32/stm32_adc.cpp index f5a02d363a9..a87603f5672 100644 --- a/radio/src/targets/common/arm/stm32/stm32_adc.cpp +++ b/radio/src/targets/common/arm/stm32/stm32_adc.cpp @@ -567,7 +567,11 @@ static inline DMA_Stream_TypeDef* _dma_get_stream(DMA_TypeDef *DMAx, uint32_t St #define DMA_Stream0_IT_MASK (uint32_t)(DMA_LISR_FEIF0 | DMA_LISR_DMEIF0 | \ DMA_LISR_TEIF0 | DMA_LISR_HTIF0 | \ DMA_LISR_TCIF0) - + +#define DMA_Stream3_IT_MASK (uint32_t)(DMA_LISR_FEIF3 | DMA_LISR_DMEIF3 | \ + DMA_LISR_TEIF3 | DMA_LISR_HTIF3 | \ + DMA_LISR_TCIF3) + #define DMA_Stream4_IT_MASK (uint32_t)(DMA_HISR_FEIF4 | DMA_HISR_DMEIF4 | \ DMA_HISR_TEIF4 | DMA_HISR_HTIF4 | \ DMA_HISR_TCIF4) @@ -582,7 +586,9 @@ static void adc_dma_clear_flags(DMA_TypeDef* DMAx, uint32_t stream) if (stream == LL_DMA_STREAM_4) { /* Reset interrupt pending bits for DMA2 Stream4 */ WRITE_REG(DMAx->HIFCR, DMA_Stream4_IT_MASK); - + } else if (stream == LL_DMA_STREAM_3) { + /* Reset interrupt pending bits for DMA2 Stream3 (ADC2 on F4) */ + WRITE_REG(DMAx->LIFCR, DMA_Stream3_IT_MASK); } else if (stream == LL_DMA_STREAM_0) { /* Reset interrupt pending bits for DMA2 Stream0 */ WRITE_REG(DMAx->LIFCR, DMA_Stream0_IT_MASK); diff --git a/radio/src/targets/taranis/hal.h b/radio/src/targets/taranis/hal.h index e53440a17f2..491eb15ebaa 100644 --- a/radio/src/targets/taranis/hal.h +++ b/radio/src/targets/taranis/hal.h @@ -1387,7 +1387,7 @@ #endif // ADC -#if !defined(RADIO_FAMILY_T20) && !defined(RADIO_BUMBLEBEE) +#if !defined(RADIO_FAMILY_T20) && !defined(RADIO_BUMBLEBEE) && !defined(RADIO_V14) && !defined(RADIO_V14LCD) #define ADC_MAIN ADC1 #define ADC_DMA DMA2 #define ADC_DMA_CHANNEL LL_DMA_CHANNEL_0 @@ -1758,38 +1758,39 @@ #define ADC_GPIOC_PINS (ADC_GPIO_PIN_BATT) #define ADC_VREF_PREC2 330 #elif defined(RADIO_V14LCD) || defined(RADIO_V14) + #define ADC_MAIN ADC2 + #define ADC_DMA DMA2 + #define ADC_DMA_CHANNEL LL_DMA_CHANNEL_1 + #define ADC_DMA_STREAM LL_DMA_STREAM_3 + #define ADC_DMA_STREAM_IRQ DMA2_Stream3_IRQn + #define ADC_DMA_STREAM_IRQHandler DMA2_Stream3_IRQHandler #define ADC_GPIO_PIN_STICK_RV LL_GPIO_PIN_0 // PA.00 #define ADC_GPIO_PIN_STICK_RH LL_GPIO_PIN_1 // PA.01 #define ADC_GPIO_PIN_STICK_LH LL_GPIO_PIN_2 // PA.02 #define ADC_GPIO_PIN_STICK_LV LL_GPIO_PIN_3 // PA.03 - #define ADC_CHANNEL_STICK_RV LL_ADC_CHANNEL_0 // ADC123_IN0 -> ADC1_IN0 - #define ADC_CHANNEL_STICK_RH LL_ADC_CHANNEL_1 // ADC123_IN1 -> ADC1_IN1 - #define ADC_CHANNEL_STICK_LH LL_ADC_CHANNEL_2 // ADC123_IN2 -> ADC1_IN2 - #define ADC_CHANNEL_STICK_LV LL_ADC_CHANNEL_3 // ADC123_IN3 -> ADC1_IN3 + #define ADC_CHANNEL_STICK_RV LL_ADC_CHANNEL_0 // ADC123_IN0 -> ADC2_IN0 + #define ADC_CHANNEL_STICK_RH LL_ADC_CHANNEL_1 // ADC123_IN1 -> ADC2_IN1 + #define ADC_CHANNEL_STICK_LH LL_ADC_CHANNEL_2 // ADC123_IN2 -> ADC2_IN2 + #define ADC_CHANNEL_STICK_LV LL_ADC_CHANNEL_3 // ADC123_IN3 -> ADC2_IN3 #define ADC_GPIO_PIN_POT1 LL_GPIO_PIN_0 // PB.00 #define ADC_GPIO_PIN_POT2 LL_GPIO_PIN_6 // PA.06 #define ADC_GPIO_PIN_POT3 LL_GPIO_PIN_2 // PC.02 - #define ADC_CHANNEL_POT1 LL_ADC_CHANNEL_8 // ADC12_IN8 -> ADC1_IN8 - #define ADC_CHANNEL_POT2 LL_ADC_CHANNEL_6 // ADC12_IN6 -> ADC1_IN6 - #define ADC_CHANNEL_POT3 LL_ADC_CHANNEL_12 // ADC123_IN12 -> ADC1_IN12 + #define ADC_CHANNEL_POT1 LL_ADC_CHANNEL_8 // ADC12_IN8 -> ADC2_IN8 + #define ADC_CHANNEL_POT2 LL_ADC_CHANNEL_6 // ADC12_IN6 -> ADC2_IN6 + #define ADC_CHANNEL_POT3 LL_ADC_CHANNEL_12 // ADC123_IN12 -> ADC2_IN12 #define ADC_GPIO_PIN_SWE LL_GPIO_PIN_7 // PA.07 #define ADC_GPIO_PIN_SWB LL_GPIO_PIN_5 // PA.05 #define ADC_GPIO_PIN_SWC LL_GPIO_PIN_0 // PC.00 #define ADC_GPIO_PIN_SWF LL_GPIO_PIN_5 // PC.05 - #define ADC_CHANNEL_SWE LL_ADC_CHANNEL_7 // ADC12_IN7 -> ADC1_IN7 - #define ADC_CHANNEL_SWB LL_ADC_CHANNEL_5 // ADC12_IN5 -> ADC1_IN5 - #define ADC_CHANNEL_SWC LL_ADC_CHANNEL_10 // ADC123_IN10 -> ADC1_IN10 - #define ADC_CHANNEL_SWF LL_ADC_CHANNEL_15 // ADC12_IN15 -> ADC1_IN15 + #define ADC_CHANNEL_SWE LL_ADC_CHANNEL_7 // ADC12_IN7 -> ADC2_IN7 + #define ADC_CHANNEL_SWB LL_ADC_CHANNEL_5 // ADC12_IN5 -> ADC2_IN5 + #define ADC_CHANNEL_SWC LL_ADC_CHANNEL_10 // ADC123_IN10 -> ADC2_IN10 + #define ADC_CHANNEL_SWF LL_ADC_CHANNEL_15 // ADC12_IN15 -> ADC2_IN15 #define ADC_GPIOA_PINS (ADC_GPIO_PIN_STICK_RV | ADC_GPIO_PIN_STICK_RH | ADC_GPIO_PIN_STICK_LH | ADC_GPIO_PIN_STICK_LV | ADC_GPIO_PIN_POT2 | ADC_GPIO_PIN_SWE | ADC_GPIO_PIN_SWB) #define ADC_GPIOB_PINS (ADC_GPIO_PIN_POT1) #define ADC_GPIOC_PINS (ADC_GPIO_PIN_POT3 | ADC_GPIO_PIN_SWC | ADC_GPIO_PIN_SWF) - #define ADC_EXT ADC3 - #define ADC_EXT_DMA DMA2 - #define ADC_EXT_DMA_CHANNEL LL_DMA_CHANNEL_2 - #define ADC_EXT_DMA_STREAM LL_DMA_STREAM_0 - #define ADC_EXT_DMA_STREAM_IRQ DMA2_Stream0_IRQn - #define ADC_EXT_DMA_STREAM_IRQHandler DMA2_Stream0_IRQHandler - #define ADC_EXT_CHANNELS { ADC_CHANNEL_POT3 } + #define ADC_EXT ADC1 + #define ADC_EXT_CHANNELS { ADC_CHANNEL_RTC_BAT } #define ADC_EXT_SAMPTIME LL_ADC_SAMPLINGTIME_56CYCLES #define ADC_VREF_PREC2 330 #elif defined(PCBX7) From 8ef5f31645394af753055a1722733503e061a1a1 Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Wed, 19 Aug 2026 20:47:59 -0400 Subject: [PATCH 15/55] fix(telem): CRSF RSSI uses incorrect default units (#7622) Adapted for 2.12, which predates the string cleanup in #7590 and so still wraps the sensor names in STR_DEF(). Only the three UNIT_DB -> UNIT_DBM changes were taken; the resulting file is identical to the author's original. (cherry picked from commit b67be42d039043ef5525eb85f6016b4a9112752c) --- radio/src/telemetry/crossfire.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/radio/src/telemetry/crossfire.cpp b/radio/src/telemetry/crossfire.cpp index 5b7508a59a7..263d474b082 100644 --- a/radio/src/telemetry/crossfire.cpp +++ b/radio/src/telemetry/crossfire.cpp @@ -41,14 +41,14 @@ struct CrossfireSensor { }; const CrossfireSensor crossfireSensors[] = { - CS(LINK_ID, 0, STR_DEF(STR_SENSOR_RX_RSSI1), UNIT_DB, 0), - CS(LINK_ID, 1, STR_DEF(STR_SENSOR_RX_RSSI2), UNIT_DB, 0), + CS(LINK_ID, 0, STR_DEF(STR_SENSOR_RX_RSSI1), UNIT_DBM, 0), + CS(LINK_ID, 1, STR_DEF(STR_SENSOR_RX_RSSI2), UNIT_DBM, 0), CS(LINK_ID, 2, STR_DEF(STR_SENSOR_RX_QUALITY), UNIT_PERCENT, 0), CS(LINK_ID, 3, STR_DEF(STR_SENSOR_RX_SNR), UNIT_DB, 0), CS(LINK_ID, 4, STR_DEF(STR_SENSOR_ANTENNA), UNIT_RAW, 0), CS(LINK_ID, 5, STR_DEF(STR_SENSOR_RF_MODE), UNIT_RAW, 0), CS(LINK_ID, 6, STR_DEF(STR_SENSOR_TX_POWER), UNIT_MILLIWATTS, 0), - CS(LINK_ID, 7, STR_DEF(STR_SENSOR_TX_RSSI), UNIT_DB, 0), + CS(LINK_ID, 7, STR_DEF(STR_SENSOR_TX_RSSI), UNIT_DBM, 0), CS(LINK_ID, 8, STR_DEF(STR_SENSOR_TX_QUALITY), UNIT_PERCENT, 0), CS(LINK_ID, 9, STR_DEF(STR_SENSOR_TX_SNR), UNIT_DB, 0), CS(LINK_RX_ID, 0, STR_DEF(STR_SENSOR_RX_RSSI_PERC), UNIT_PERCENT, 0), From f02b649b8db15c418dc841dce8bde9a6c978ca9f Mon Sep 17 00:00:00 2001 From: philmoz Date: Mon, 10 Aug 2026 10:34:56 +1000 Subject: [PATCH 16/55] fix(color): theme preview showing error icon when there are no theme images (#7624) (cherry picked from commit 6095dab56f9aa7608c38fa690dd9cd5325f0fb00) --- radio/src/gui/colorlcd/libui/file_carosell.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radio/src/gui/colorlcd/libui/file_carosell.cpp b/radio/src/gui/colorlcd/libui/file_carosell.cpp index 2b749abe207..5ace86a26a6 100644 --- a/radio/src/gui/colorlcd/libui/file_carosell.cpp +++ b/radio/src/gui/colorlcd/libui/file_carosell.cpp @@ -56,7 +56,7 @@ void FileCarosell::setSelected(int n) if (n >= 0 && n < (int)_fileNames.size()) { fp->setFile(_fileNames[selected].c_str()); } else - fp->setFile(""); + fp->setFile(nullptr); } message->show(selected == -1); From 850002adb2c51cd4bcf224180066fbaf9094cd46 Mon Sep 17 00:00:00 2001 From: Peter Feerick Date: Wed, 12 Aug 2026 07:25:11 +1000 Subject: [PATCH 17/55] fix(v14): OLED brightness wraps above 235 (#7633) Adapted for 2.12: MinContrast/MaxContrast are firmware capabilities in OpenTxFirmware::getCapability (companion/src/firmwares/opentx/opentxinterface.cpp) rather than board capabilities in Boards::getCapability, so the OLED branches were applied there instead. Logic is identical. Co-authored-by: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 (cherry picked from commit b6e52f5ee3850fb476ef6f8bb204252420fcc56b) --- .../src/firmwares/opentx/opentxinterface.cpp | 8 ++++++-- companion/src/generaledit/generalsetup.cpp | 9 +++++++++ companion/src/generaledit/generalsetup.ui | 17 ++--------------- radio/src/targets/taranis/hal.h | 2 +- radio/src/targets/taranis/lcd_driver_spi.cpp | 6 +++--- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/companion/src/firmwares/opentx/opentxinterface.cpp b/companion/src/firmwares/opentx/opentxinterface.cpp index b2b3b1b523f..ffa7698e0f3 100644 --- a/companion/src/firmwares/opentx/opentxinterface.cpp +++ b/companion/src/firmwares/opentx/opentxinterface.cpp @@ -161,12 +161,16 @@ int OpenTxFirmware::getCapability(::Capability capability) case MaxVolume: return 23; case MaxContrast: - if (IS_TARANIS_SMALL(board)) + if (Boards::getCapability(board, Board::LcdOLED)) + return 254; + else if (IS_TARANIS_SMALL(board)) return 30; else return 45; case MinContrast: - if (IS_TARANIS_X9(board)) + if (Boards::getCapability(board, Board::LcdOLED)) + return 2; + else if (IS_TARANIS_X9(board)) return 0; else return 10; diff --git a/companion/src/generaledit/generalsetup.cpp b/companion/src/generaledit/generalsetup.cpp index c67aff9e680..35b3640a92a 100644 --- a/companion/src/generaledit/generalsetup.cpp +++ b/companion/src/generaledit/generalsetup.cpp @@ -263,6 +263,15 @@ ui(new Ui::GeneralSetup) ui->contrastSB->setMaximum(firmware->getCapability(MaxContrast)); ui->contrastSB->setValue(generalSettings.contrast); + if (Boards::getCapability(board, Board::LcdOLED)) { + // OLED radios have no backlight - "contrast" is the panel brightness + ui->label_contrast->setText(tr("Brightness")); + ui->BLBright_SB->hide(); + ui->BLBright_SB->setDisabled(true); + ui->label_BLBright->hide(); + ui->blAlarm_LB->setText(tr("Flash display on alarm")); + } + ui->battwarningDSB->setValue((double)generalSettings.vBatWarn / 10); ui->backlightautoSB->setValue(generalSettings.backlightDelay * 5); ui->inactimerSB->setValue(generalSettings.inactivityTimer); diff --git a/companion/src/generaledit/generalsetup.ui b/companion/src/generaledit/generalsetup.ui index c17ef1e1aac..3583613fc54 100644 --- a/companion/src/generaledit/generalsetup.ui +++ b/companion/src/generaledit/generalsetup.ui @@ -1166,12 +1166,7 @@ - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><style type="text/css"> -p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">LCD Screen Contrast</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Values can be 20-45</span></p></body></html> + 1 @@ -1604,9 +1599,6 @@ p, li { white-space: pre-wrap; } 0 - - - Contrast @@ -1905,12 +1897,7 @@ p, li { white-space: pre-wrap; } - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><style type="text/css"> -p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">LCD Screen Contrast</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Values can be 20-45</span></p></body></html> + ms diff --git a/radio/src/targets/taranis/hal.h b/radio/src/targets/taranis/hal.h index 491eb15ebaa..2e8688901f6 100644 --- a/radio/src/targets/taranis/hal.h +++ b/radio/src/targets/taranis/hal.h @@ -2617,7 +2617,7 @@ #define BACKLIGHT_CCMR1 TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2 // Channel 1, PWM #define BACKLIGHT_CCER TIM_CCER_CC1E #define BACKLIGHT_COUNTER_REGISTER BACKLIGHT_TIMER->CCR1 -#elif defined(RADIO_T8) || defined(RADIO_TPROV2) || defined(RADIO_TPROS) || defined(RADIO_FAMILY_T20) || defined(RADIO_T14) || defined(RADIO_BUMBLEBEE) || defined(RADIO_GX12) +#elif defined(RADIO_T8) || defined(RADIO_TPROV2) || defined(RADIO_TPROS) || defined(RADIO_FAMILY_T20) || defined(RADIO_T14) || defined(RADIO_BUMBLEBEE) || defined(RADIO_GX12) || defined(RADIO_V14) // No backlight: OLED display #elif defined(RADIO_COMMANDO8) #define BACKLIGHT_TIMER_FREQ (PERI1_FREQUENCY * TIMER_MULT_APB1) diff --git a/radio/src/targets/taranis/lcd_driver_spi.cpp b/radio/src/targets/taranis/lcd_driver_spi.cpp index 0f28ff2e528..aba687ecdb0 100644 --- a/radio/src/targets/taranis/lcd_driver_spi.cpp +++ b/radio/src/targets/taranis/lcd_driver_spi.cpp @@ -37,7 +37,7 @@ #if defined(OLED_SCREEN) #define LCD_CONTRAST_OFFSET 0 -#elif defined(RADIO_FAMILY_JUMPER_T12) || defined(MANUFACTURER_RADIOMASTER) || defined(RADIO_COMMANDO8) || defined(RADIO_TPRO) || defined(RADIO_T12MAX) || defined(RADIO_V14) || defined(RADIO_V14LCD) +#elif defined(RADIO_FAMILY_JUMPER_T12) || defined(MANUFACTURER_RADIOMASTER) || defined(RADIO_COMMANDO8) || defined(RADIO_TPRO) || defined(RADIO_T12MAX) || defined(RADIO_V14LCD) #define LCD_CONTRAST_OFFSET -10 #else #define LCD_CONTRAST_OFFSET 160 @@ -177,7 +177,7 @@ void lcdStart() lcdWriteCommand(0x0A); // Set Vop lcdWriteCommand(0xa6); // Set display mode #else - #if defined(RADIO_V14) || defined(RADIO_V14LCD) + #if defined(RADIO_V14LCD) lcdWriteCommand(0xe2); // (14) Soft reset lcdWriteCommand(0xa1); // Set seg lcdWriteCommand(0xc0); // Set com @@ -439,7 +439,7 @@ void lcdSetRefVolt(uint8_t val) WAIT_FOR_DMA_END(); #endif -#if defined(RADIO_V14) || defined(RADIO_V14LCD) +#if defined(RADIO_V14LCD) lcdWriteCommand(0x81); // Set Vop lcdWriteCommand(val+LCD_CONTRAST_OFFSET+20);// 0-255 #else From cd90ab4fc1cc04b4441ddcb7a6d674bf6a158e42 Mon Sep 17 00:00:00 2001 From: richardclli Date: Wed, 19 Aug 2026 08:41:34 +0800 Subject: [PATCH 18/55] feat(flysky): add build option to choose use SDIO or SPI flash if available (#7634) Co-authored-by: Peter Feerick <5500713+pfeerick@users.noreply.github.com> (cherry picked from commit 5a75b5146d5af3deb06a85dcab40a98044528678) --- radio/src/targets/pl18/CMakeLists.txt | 30 ++++++++++++++++++--------- radio/src/targets/pl18/hal.h | 6 ------ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/radio/src/targets/pl18/CMakeLists.txt b/radio/src/targets/pl18/CMakeLists.txt index 91ae787eb5d..e438b071494 100644 --- a/radio/src/targets/pl18/CMakeLists.txt +++ b/radio/src/targets/pl18/CMakeLists.txt @@ -1,6 +1,5 @@ -option(DISK_CACHE "Enable SD card disk cache" OFF) option(UNEXPECTED_SHUTDOWN "Enable the Unexpected Shutdown screen" ON) -option(STICK_DEAD_ZONE "Enable sticks dead zone" YES) +option(STICK_DEAD_ZONE "Enable sticks dead zone" ON) option(PXX1 "PXX1 protocol support" ON) option(PXX2 "PXX2 protocol support" OFF) option(AFHDS3 "AFHDS3 TX Module" ON) @@ -8,6 +7,7 @@ option(MULTIMODULE "DIY Multiprotocol TX Module (https://github.com/pascallanger option(GHOST "Ghost TX Module" ON) option(MODULE_SIZE_STD "Standard size TX Module" ON) option(USE_RTC_CLOCK "RTC Clock" ON) +option(PREFER_SDIO "Prefer SDIO Storage" ON) set(PWR_BUTTON "PRESS" CACHE STRING "Pwr button type (PRESS/SWITCH)") set(CPU_TYPE STM32F4) @@ -46,7 +46,7 @@ add_definitions(-DSTM32_SUPPORT_32BIT_TIMERS) if(PCBREV STREQUAL PL18U) set(FLAVOUR pl18u) add_definitions(-DRADIO_PL18U) - set(DISK_CACHE ON) + set(HAS_SDIO ON) set(WIRELESS_CHARGER YES) set(FLYSKY_GIMBAL ON) set(LED_STRIP ON) @@ -59,7 +59,7 @@ if(PCBREV STREQUAL PL18U) elseif (PCBREV STREQUAL EL18) set(FLAVOUR el18) add_definitions(-DRADIO_EL18 -DRADIO_NV14_FAMILY -DUSE_HATS_AS_KEYS) - set(DISK_CACHE ON) + set(HAS_SDIO ON) set(FLYSKY_GIMBAL ON) set(USE_VS1053B ON) set(AFHDS3 ON) @@ -71,7 +71,7 @@ elseif(PCBREV STREQUAL NV14) set(FLAVOUR nv14) add_definitions(-DRADIO_NV14 -DRADIO_NV14_FAMILY) add_definitions(-DUSE_HATS_AS_KEYS -DAFHDS2_BAUDRATE=${PCB_RF_BAUD}) - set(DISK_CACHE ON) + set(HAS_SDIO ON) set(FLYSKY_GIMBAL ON) set(USE_VS1053B ON) set(AFHDS2 ON) @@ -82,7 +82,7 @@ elseif(PCBREV STREQUAL NV14) set(DEFAULT_INTERNAL_MODULE FLYSKY_AFHDS2A CACHE STRING "Default internal module") elseif(PCBREV STREQUAL NB4P) set(FLAVOUR nb4p) - add_definitions(-DRADIO_NB4P -DSPI_FLASH) + add_definitions(-DRADIO_NB4P) set(ROTARY_ENCODER YES) set(LED_STRIP ON) set(AFHDS3 ON) @@ -93,7 +93,7 @@ elseif(PCBREV STREQUAL NB4P) set(DEFAULT_INTERNAL_MODULE MULTIMODULE CACHE STRING "Default internal module") elseif(PCBREV STREQUAL PL18EV) set(FLAVOUR pl18ev) - add_definitions(-DRADIO_PL18EV -DSPI_FLASH -DUSE_HATS_AS_KEYS) + add_definitions(-DRADIO_PL18EV -DUSE_HATS_AS_KEYS) set(WIRELESS_CHARGER YES) set(FLYSKY_GIMBAL ON) set(LED_STRIP ON) @@ -101,7 +101,7 @@ elseif(PCBREV STREQUAL PL18EV) set(KEY_DRIVER key_driver.cpp) else() set(FLAVOUR pl18) - add_definitions(-DRADIO_PL18 -DSPI_FLASH -DUSE_HATS_AS_KEYS) + add_definitions(-DRADIO_PL18 -DUSE_HATS_AS_KEYS) set(WIRELESS_CHARGER YES) set(FLYSKY_GIMBAL ON) set(LED_STRIP ON) @@ -136,6 +136,12 @@ add_definitions(-DPWR_BUTTON_${PWR_BUTTON}) add_definitions(-DCROSSFIRE_NATIVE) add_definitions(-DHARDWARE_EXTERNAL_MODULE) +if(PREFER_SDIO) + if(HAS_SDIO) + set(STORAGE_USE_SDIO ON) + endif() +endif() + if(INTERNAL_GPS) add_definitions(-DGPS_USART_BAUDRATE=${INTERNAL_GPS_BAUDRATE}) endif() @@ -187,11 +193,15 @@ if(ROTARY_ENCODER) ) endif() -if(DISK_CACHE) +if(STORAGE_USE_SDIO) set(BOARD_COMMON_SRC ${BOARD_COMMON_SRC} disk_cache.cpp ) add_definitions(-DDISK_CACHE) + add_definitions(-DSTORAGE_USE_SDIO) +else() + add_definitions(-DSPI_FLASH) + add_definitions(-DSTORAGE_USE_SPI_FLASH) endif() # Bootloader board library @@ -218,7 +228,7 @@ add_library(board OBJECT EXCLUDE_FROM_ALL targets/common/arm/stm32/trainer_driver.cpp targets/common/arm/stm32/spi_flash.cpp targets/common/arm/stm32/diskio_spi_flash.cpp - drivers/frftl.cpp + drivers/frftl.cpp ) set(FIRMWARE_SRC ${FIRMWARE_SRC} $) diff --git a/radio/src/targets/pl18/hal.h b/radio/src/targets/pl18/hal.h index 1e815bdad61..4e114e5fc2a 100644 --- a/radio/src/targets/pl18/hal.h +++ b/radio/src/targets/pl18/hal.h @@ -726,12 +726,6 @@ #define FLASH_SPI_DMA_TX_STREAM LL_DMA_STREAM_5 #define FLASH_SPI_DMA_RX_STREAM LL_DMA_STREAM_6 -#if defined(RADIO_NV14_FAMILY) || defined(RADIO_PL18U) - #define STORAGE_USE_SDIO -#else - #define STORAGE_USE_SPI_FLASH -#endif - // SDRAM #define SDRAM_RCC_AHB1Periph (RCC_AHB1Periph_GPIOC | RCC_AHB1Periph_GPIOD | RCC_AHB1Periph_GPIOE | RCC_AHB1Periph_GPIOF | RCC_AHB1Periph_GPIOG | RCC_AHB1Periph_GPIOH) #define SDRAM_RCC_AHB3Periph RCC_AHB3Periph_FMC From e71cb63a57323fec80842fbba281c43d59f0d7d3 Mon Sep 17 00:00:00 2001 From: Abhishek Praveenkumar Muttath <63782877+ABHISHEK22415@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:23:18 -0700 Subject: [PATCH 19/55] fix(tools): update check_translations to i18n structure, utf-8 terminal encoding (#7636) Co-authored-by: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 (cherry picked from commit c8499366a2b55f7d7623a49c339ec87ba5154ddd) --- radio/src/translations/i18n/pt.h | 1 - tools/check_translations.py | 66 +++++++++++++++++++++----------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/radio/src/translations/i18n/pt.h b/radio/src/translations/i18n/pt.h index 7640a49ea10..cf78110de46 100644 --- a/radio/src/translations/i18n/pt.h +++ b/radio/src/translations/i18n/pt.h @@ -83,7 +83,6 @@ #define TR_MAIN_MENU_MODEL_SETTINGS "Editar Modelo" #define TR_MAIN_MENU_RADIO_SETTINGS "Editar Rádio" #define TR_MAIN_MENU_SCREEN_SETTINGS "Editar Tela" -#define TR_MAIN_MENU_RESET_TELEMETRY "Reset Telem." #define TR_MAIN_MENU_STATISTICS "Estatist." #define TR_MAIN_MENU_ABOUT_EDGETX "Sobre EdgeTX" #define TR_MAIN_VIEW_X "Screen " diff --git a/tools/check_translations.py b/tools/check_translations.py index f9f6b8b63e0..8ee4c1d58f9 100755 --- a/tools/check_translations.py +++ b/tools/check_translations.py @@ -61,14 +61,15 @@ def parse_bootloader_file(self, file_path: Path): current_language = None in_translation_block = False conditional_depth = 0 - + past_translation_block = False + for i, line in enumerate(lines): line = line.strip() - + # Skip empty lines and comments if not line or line.startswith('//'): continue - + # Check for translation language blocks translation_match = re.match(r'#(?:if|elif)\s+defined\(TRANSLATIONS_([A-Z]+)\)', line) if translation_match: @@ -76,12 +77,12 @@ def parse_bootloader_file(self, file_path: Path): in_translation_block = True conditional_depth = 0 continue - + # Check for else block (default/English) if re.match(r'#else', line) and in_translation_block and conditional_depth == 0: current_language = "EN" # Default language continue - + # Track conditional compilation depth if re.match(r'#if', line) and in_translation_block: conditional_depth += 1 @@ -93,19 +94,32 @@ def parse_bootloader_file(self, file_path: Path): # This is the end of the translation block in_translation_block = False current_language = None + past_translation_block = True continue - + + # Past the language chain, a #define TR_BL_x is a fallback default + # (e.g. "#if !defined(COLORLCD) && !defined(TR_BL_X)") that fills the + # key in for every language that didn't already define it explicitly. + if past_translation_block: + define_match = re.match(r'#define\s+(TR_BL_\w+)', line) + if define_match: + key = define_match.group(1) + self.bootloader_keys.add(key) + for lang in self.bootloader_translations: + self.bootloader_translations[lang].add(key) + continue + # Skip non-translation lines if not in_translation_block or not current_language: continue - + # Parse #define statements define_match = re.match(r'#define\s+(TR_BL_\w+)', line) if define_match: key = define_match.group(1) self.bootloader_translations[current_language].add(key) self.bootloader_keys.add(key) - + self.checked_files.append(str(file_path)) return True @@ -138,22 +152,19 @@ def check_bootloader_translations(self, translations_dir: Path) -> bool: def check_language_translations(self, translations_dir: Path) -> List[str]: """Check individual language translation files.""" languages_found = [] - - # Look for .h files that are language files (excluding special files) - exclude_files = {"bl_translations.h", "untranslated.h"} - - for h_file in translations_dir.glob("*.h"): - if h_file.name in exclude_files: - continue - + + # Language headers live under i18n/; translations_dir itself only has shared headers. + i18n_dir = translations_dir / "i18n" + + for h_file in i18n_dir.glob("*.h"): # Skip files that are clearly not language files if h_file.name.startswith("tts_"): continue - + language = self.parse_language_file(h_file) if language: languages_found.append(language) - + return languages_found def analyze(self) -> Dict[str, any]: @@ -190,11 +201,13 @@ def analyze(self) -> Dict[str, any]: "extra": len(results["bootloader"]["extra_keys"][lang]) } - # Analyze language file translations + # Diff against EN rather than the union: unlike bl_translations.h, i18n/*.h + # has no fallback macros, so a key EN lacks is genuinely extra elsewhere. + language_reference = self.language_translations.get("EN", self.language_keys) for lang in language_languages: lang_keys = self.language_translations[lang] - results["language_files"]["missing_keys"][lang] = self.language_keys - lang_keys - results["language_files"]["extra_keys"][lang] = lang_keys - self.language_keys + results["language_files"]["missing_keys"][lang] = language_reference - lang_keys + results["language_files"]["extra_keys"][lang] = lang_keys - language_reference results["language_files"]["summary"][lang] = { "total": len(lang_keys), "missing": len(results["language_files"]["missing_keys"][lang]), @@ -319,7 +332,16 @@ def main(): help="Check only individual language files (*.h)") args = parser.parse_args() - + + # The report uses Unicode status glyphs (checkmarks, crosses, bullets). + # Consoles that default to a non-UTF-8 encoding (e.g. Windows cp1252) would + # otherwise raise UnicodeEncodeError and crash the tool. Force UTF-8 output + # where the stream supports reconfiguration. + try: + sys.stdout.reconfigure(encoding="utf-8") + except (AttributeError, ValueError): + pass + checker = TranslationChecker() # Find translations directory From f7a50d7e667664e9406ebf27ba5b58a83139148d Mon Sep 17 00:00:00 2001 From: philmoz Date: Wed, 12 Aug 2026 15:18:46 +1000 Subject: [PATCH 20/55] fix(tools): YAML generation leaking build options between targets (#7638) Upstream regenerated t15pro and tx15, the two descriptors the leak had corrupted on main. On 2.12 a different file is affected, so it is regenerated here in the same commit: yaml_datastructs_x10.cpp had YAML_PADDING(4) where ModuleData.antennaMode belongs. That field was lost by 9506ec7867 ("feat(v12): support for HelloRadioSky V12" (#7113)), which regenerated the descriptors through the shared build tree this commit replaces - v12's cached options leaked into x10's generation and dropped the field. main never saw it: its own v12 support (#7114) regenerated cleanly and has carried antennaMode throughout. yaml_datastructs_x10.cpp is used by x10, x10express, tx16s, v16 and x12s, so on all of those the per-module antenna selection was silently neither written to nor read from model files, reverting to the default on every load. Padding accounting is unchanged - PADDING(4) becomes PADDING(2) plus a 2-bit antennaMode - so no following field shifts and existing model files stay readable. Files written before this fix carry no antennaMode key and load with the default, which is what they did already. Regenerating all 25 targets with the fixed script leaves the other 24 descriptors byte-identical, confirming x10 was the only casualty on 2.12. The tools/generate-yaml.sh resolution keeps 2.12's FLAVOR list, which includes gx15 and omits tx16s, and takes main's per-target BUILD_DIR restructure. The requirements.txt hunk is dropped - asciitree is already installed by tools/setup_buildenv_ubuntu24.04.sh on 2.12. Co-authored-by: philmoz Co-authored-by: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Co-authored-by: Claude Opus 5 (cherry picked from commit 5c64f0a9dbf63c6770e4d453870812c29883e8e7) --- .../storage/yaml/yaml_datastructs_t15pro.cpp | 2 +- .../src/storage/yaml/yaml_datastructs_tx15.cpp | 2 +- radio/src/storage/yaml/yaml_datastructs_x10.cpp | 3 ++- radio/src/targets/t22/CMakeLists.txt | 5 ++--- radio/src/targets/tx15/CMakeLists.txt | 3 +-- radio/src/targets/tx16smk3/CMakeLists.txt | 5 ++--- radio/src/targets/v12/CMakeLists.txt | 5 ++--- tools/generate-yaml.sh | 17 ++++++++++------- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/radio/src/storage/yaml/yaml_datastructs_t15pro.cpp b/radio/src/storage/yaml/yaml_datastructs_t15pro.cpp index 058aa58aa03..253887ecec5 100644 --- a/radio/src/storage/yaml/yaml_datastructs_t15pro.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_t15pro.cpp @@ -447,7 +447,7 @@ static const struct YamlNode struct_RadioData[] = { YAML_CUSTOM("rotEncDirection",r_rotEncDirection,nullptr), YAML_UNSIGNED( "rotEncMode", 3 ), YAML_PADDING( 2 ), - YAML_UNSIGNED( "stickDeadZone", 3 ), + YAML_PADDING( 3 ), YAML_SIGNED( "imuMax", 8 ), YAML_SIGNED( "imuOffset", 8 ), YAML_UNSIGNED( "imuInvert", 8 ), diff --git a/radio/src/storage/yaml/yaml_datastructs_tx15.cpp b/radio/src/storage/yaml/yaml_datastructs_tx15.cpp index 058aa58aa03..253887ecec5 100644 --- a/radio/src/storage/yaml/yaml_datastructs_tx15.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_tx15.cpp @@ -447,7 +447,7 @@ static const struct YamlNode struct_RadioData[] = { YAML_CUSTOM("rotEncDirection",r_rotEncDirection,nullptr), YAML_UNSIGNED( "rotEncMode", 3 ), YAML_PADDING( 2 ), - YAML_UNSIGNED( "stickDeadZone", 3 ), + YAML_PADDING( 3 ), YAML_SIGNED( "imuMax", 8 ), YAML_SIGNED( "imuOffset", 8 ), YAML_UNSIGNED( "imuInvert", 8 ), diff --git a/radio/src/storage/yaml/yaml_datastructs_x10.cpp b/radio/src/storage/yaml/yaml_datastructs_x10.cpp index 93aff2958ed..e0da39a0ce0 100644 --- a/radio/src/storage/yaml/yaml_datastructs_x10.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_x10.cpp @@ -725,7 +725,8 @@ static const struct YamlNode struct_ModuleData[] = { YAML_UNSIGNED( "channelsStart", 8 ), YAML_SIGNED_CUST( "channelsCount", 8, r_channelsCount, w_channelsCount ), YAML_ENUM("failsafeMode", 4, enum_FailsafeModes, NULL), - YAML_PADDING( 4 ), + YAML_PADDING( 2 ), + YAML_ENUM("antennaMode", 2, enum_AntennaModes, NULL), YAML_UNION("mod", 200, union_anonymous_4_elmts, select_mod_type), YAML_END }; diff --git a/radio/src/targets/t22/CMakeLists.txt b/radio/src/targets/t22/CMakeLists.txt index cc2fd140b73..12dae3a68df 100644 --- a/radio/src/targets/t22/CMakeLists.txt +++ b/radio/src/targets/t22/CMakeLists.txt @@ -7,6 +7,7 @@ option(GHOST "Ghost TX Module" ON) option(MODULE_SIZE_STD "Standard size TX Module" ON) option(LUA_MIXER "Enable LUA mixer/model scripts support" ON) option(DISK_CACHE "Enable SD card disk cache" ON) +option(STICK_DEAD_ZONE "Enable sticks dead zone" OFF) set(FIRMWARE_QSPI YES) set(FIRMWARE_FORMAT_UF2 YES) @@ -27,8 +28,6 @@ set(INTERNAL_MODULE_AFHDS3 NO) set(ROTARY_ENCODER YES) set(FUNCTION_SWITCHES_WITH_RGB YES) -#option(STICKS_DEAD_ZONE "Enable sticks dead zone" YES) - # IMU support set(IMU ON) @@ -78,7 +77,7 @@ add_definitions(-DRTCLOCK) add_definitions(-DGPS_USART_BAUDRATE=${INTERNAL_GPS_BAUDRATE}) add_definitions(-DPWR_BUTTON_${PWR_BUTTON}) -if(STICKS_DEAD_ZONE) +if(STICK_DEAD_ZONE) add_definitions(-DSTICK_DEAD_ZONE) endif() diff --git a/radio/src/targets/tx15/CMakeLists.txt b/radio/src/targets/tx15/CMakeLists.txt index d54086d2171..d1a8025b1fc 100644 --- a/radio/src/targets/tx15/CMakeLists.txt +++ b/radio/src/targets/tx15/CMakeLists.txt @@ -10,6 +10,7 @@ option(DISK_CACHE "Enable SD card disk cache" ON) option(BLUETOOTH "FrSky BT module support" OFF) option(FLYSKY_GIMBAL "Serial gimbal support" OFF) option(INTERNAL_GPS "Support for internal GPS" ON) +option(STICK_DEAD_ZONE "Enable sticks dead zone" OFF) set(FIRMWARE_QSPI YES) set(FIRMWARE_FORMAT_UF2 YES) @@ -37,8 +38,6 @@ if(BLUETOOTH) set(FLYSKY_GIMBAL OFF) endif() -#option(STICK_DEAD_ZONE "Enable sticks dead zone" YES) - # IMU support set(IMU ON) add_definitions(-DIMU_ICM4207C) diff --git a/radio/src/targets/tx16smk3/CMakeLists.txt b/radio/src/targets/tx16smk3/CMakeLists.txt index 115810f37bb..8b118785493 100644 --- a/radio/src/targets/tx16smk3/CMakeLists.txt +++ b/radio/src/targets/tx16smk3/CMakeLists.txt @@ -9,6 +9,7 @@ option(LUA_MIXER "Enable LUA mixer/model scripts support" ON) option(DISK_CACHE "Enable SD card disk cache" ON) option(BLUETOOTH "FrSky BT module support" OFF) option(INTERNAL_GPS "Support for internal GPS" ON) +option(STICK_DEAD_ZONE "Enable sticks dead zone" OFF) set(FIRMWARE_QSPI YES) set(FIRMWARE_FORMAT_UF2 YES) @@ -39,8 +40,6 @@ else() set(AUX2_SERIAL ON) endif() -#option(STICKS_DEAD_ZONE "Enable sticks dead zone" YES) - # IMU support set(IMU ON) add_definitions(-DIMU_ICM4207C) @@ -95,7 +94,7 @@ add_definitions(-DAUDIO -DVOICE -DRTCLOCK) add_definitions(-DGPS_USART_BAUDRATE=${INTERNAL_GPS_BAUDRATE}) add_definitions(-DPWR_BUTTON_${PWR_BUTTON}) -if(STICKS_DEAD_ZONE) +if(STICK_DEAD_ZONE) add_definitions(-DSTICK_DEAD_ZONE) endif() diff --git a/radio/src/targets/v12/CMakeLists.txt b/radio/src/targets/v12/CMakeLists.txt index e859956d211..c700dc20772 100644 --- a/radio/src/targets/v12/CMakeLists.txt +++ b/radio/src/targets/v12/CMakeLists.txt @@ -7,6 +7,7 @@ option(GHOST "Ghost TX Module" ON) option(MODULE_SIZE_STD "Standard size TX Module" ON) option(LUA_MIXER "Enable LUA mixer/model scripts support" ON) option(DISK_CACHE "Enable SD card disk cache" ON) +option(STICK_DEAD_ZONE "Enable sticks dead zone" OFF) set(FIRMWARE_QSPI YES) set(FIRMWARE_FORMAT_UF2 YES) @@ -28,8 +29,6 @@ set(INTERNAL_EXTERNAL_ANT YES) set(ROTARY_ENCODER YES) set(FUNCTION_SWITCHES_WITH_RGB YES) -#option(STICKS_DEAD_ZONE "Enable sticks dead zone" YES) - # IMU support (not populated in final) set(IMU OFF) @@ -88,7 +87,7 @@ add_definitions(-DPWR_BUTTON_DUAL) #add_definitions(-DINTERNAL_MODULE_PXX1) -if(STICKS_DEAD_ZONE) +if(STICK_DEAD_ZONE) add_definitions(-DSTICK_DEAD_ZONE) endif() diff --git a/tools/generate-yaml.sh b/tools/generate-yaml.sh index 04fe8e6e465..435355a63fc 100755 --- a/tools/generate-yaml.sh +++ b/tools/generate-yaml.sh @@ -11,13 +11,13 @@ if [[ -n ${GCC_ARM} ]] ; then export PATH=${GCC_ARM}:$PATH fi -: ${FLAVOR:="x10;t15;tx16s;pl18;nv14;pl18u;nb4p;v12;x9d;x9dp2019;x9e;xlite;xlites;x7;tpro;t20;f16;gx12;st16;c14;pa01;tx15;t15pro;tx16smk3;t22;gx15"} -: ${SRCDIR:=$(dirname "$(pwd)/$0")/..} +: ${FLAVOR:="x10;t15;pl18;nv14;pl18u;nb4p;v12;x9d;x9dp2019;x9e;xlite;xlites;x7;tpro;t20;f16;gx12;st16;c14;pa01;tx15;t15pro;tx16smk3;t22;gx15"} +: ${SRCDIR:="$(dirname "$SCRIPT_DIR")"} : ${COMMON_OPTIONS:="-DCMAKE_BUILD_TYPE=$BUILD_TYPE -DCMAKE_RULE_MESSAGES=OFF -Wno-dev -DDISABLE_COMPANION=YES -DCMAKE_MESSAGE_LOG_LEVEL=WARNING"} # wipe build directory clean -rm -rf build && mkdir -p build && cd build +cmake -E rm -rf build target_names=$(echo "$FLAVOR" | tr '[:upper:]' '[:lower:]' | tr ';' '\n') @@ -33,9 +33,12 @@ do exit 1 fi - cmake ${BUILD_OPTIONS} "${SRCDIR}" - make native-configure - make -C native yaml_data + # Own build tree per target - a shared one leaks cached options between them + BUILD_DIR="build/${target_name}" - rm -f CMakeCache.txt arm-none-eabi/CMakeCache.txt + cmake ${BUILD_OPTIONS} -S "${SRCDIR}" -B "${BUILD_DIR}" + cmake --build "${BUILD_DIR}" --target native-configure + cmake --build "${BUILD_DIR}/native" --target yaml_data + + cmake -E rm -rf "${BUILD_DIR}" done From 62debac88ca87e649e68c52ae834e1b00daef3eb Mon Sep 17 00:00:00 2001 From: philmoz Date: Sun, 16 Aug 2026 13:46:10 +1000 Subject: [PATCH 21/55] fix(color): spectrum analyser UI elements clipped (#7650) (cherry picked from commit 2025ac727483be3f15bab01abad21e3440f6df6f) --- .../radio/radio_spectrum_analyser.cpp | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/radio/src/gui/colorlcd/radio/radio_spectrum_analyser.cpp b/radio/src/gui/colorlcd/radio/radio_spectrum_analyser.cpp index 0353c1c6077..f8e7e8e9272 100644 --- a/radio/src/gui/colorlcd/radio/radio_spectrum_analyser.cpp +++ b/radio/src/gui/colorlcd/radio/radio_spectrum_analyser.cpp @@ -28,7 +28,7 @@ LAYOUT_VAL_SCALED(SCALE_HEIGHT, 15) constexpr coord_t SPECTRUM_HEIGHT = LCD_H - EdgeTxStyles::MENU_HEADER_HEIGHT - SCALE_HEIGHT - - EdgeTxStyles::UI_ELEMENT_HEIGHT; + EdgeTxStyles::UI_ELEMENT_HEIGHT - PAD_OUTLINE * 2; // Empirical full-scale value of bars/max/peak samples. Used to map sample // values to chart pixels so bars fill the available height on any LCD. @@ -62,7 +62,7 @@ class SpectrumFooterWindow : public Window SpectrumFooterWindow(Window* parent, const rect_t& rect, int moduleIdx) : Window(parent, rect) { - padAll(PAD_ZERO); + padAll(PAD_OUTLINE); if (isModuleMultimodule(moduleIdx)) { char label[16]; @@ -71,7 +71,7 @@ class SpectrumFooterWindow : public Window sprintf(label, "T: %dMHz", int(reusableBuffer.spectrumAnalyser.freq / 1000000)); (new StaticText( - this, rect_t{PAD_TINY, 0, FLD_W, EdgeTxStyles::UI_ELEMENT_HEIGHT}, + this, rect_t{0, 0, FLD_W, 0}, label)) ->padTop(PAD_MEDIUM); @@ -80,13 +80,13 @@ class SpectrumFooterWindow : public Window int(reusableBuffer.spectrumAnalyser.span / 1000000)); (new StaticText( this, - rect_t{PAD_TINY + FLD_W, 0, FLD_W, EdgeTxStyles::UI_ELEMENT_HEIGHT}, + rect_t{FLD_W + PAD_OUTLINE, 0, FLD_W, 0}, label)) ->padTop(PAD_MEDIUM); } else { // Frequency auto freq = new NumberEdit( - this, rect_t{PAD_TINY, 0, FLD_W, 0}, + this, rect_t{0, 0, FLD_W, 0}, reusableBuffer.spectrumAnalyser.freqMin, reusableBuffer.spectrumAnalyser.freqMax, GET_DEFAULT(reusableBuffer.spectrumAnalyser.freq / 1000000), @@ -96,7 +96,7 @@ class SpectrumFooterWindow : public Window // Span auto span = new NumberEdit( - this, rect_t{PAD_TINY + FLD_W, 0, FLD_W, 0}, 1, + this, rect_t{FLD_W + PAD_OUTLINE, 0, FLD_W, 0}, 1, reusableBuffer.spectrumAnalyser.spanMax, GET_DEFAULT(reusableBuffer.spectrumAnalyser.span / 1000000), SET_VALUE(reusableBuffer.spectrumAnalyser.span, newValue * 1000000)); @@ -107,8 +107,7 @@ class SpectrumFooterWindow : public Window // Tracker auto tracker = new NumberEdit( this, - rect_t{(PAD_TINY + FLD_W) * 2, 0, FLD_W, - EdgeTxStyles::UI_ELEMENT_HEIGHT}, + rect_t{(FLD_W + PAD_OUTLINE) * 2, 0, FLD_W, 0}, (reusableBuffer.spectrumAnalyser.freq - reusableBuffer.spectrumAnalyser.span / 2) / 1000000, @@ -122,7 +121,7 @@ class SpectrumFooterWindow : public Window tracker->setDefault(reusableBuffer.spectrumAnalyser.freqDefault); } - static constexpr coord_t FLD_W = (LCD_W - PAD_TINY * 4) / 3; + static constexpr coord_t FLD_W = (LCD_W - PAD_OUTLINE * 4) / 3; }; class SpectrumScaleWindow : public Window @@ -172,11 +171,6 @@ class SpectrumWindow : public Window public: SpectrumWindow(Window* parent, const rect_t& rect) : Window(parent, rect) { - lv_style_init(&style); - lv_style_set_line_width(&style, PAD_THREE); - lv_style_set_line_opa(&style, LV_OPA_COVER); - lv_style_set_line_color(&style, makeLvColor(COLOR_THEME_ACTIVE)); - lv_coord_t w = width() - 1; for (int i = 0; i < SPECTRUM_HEIGHT / LINE_SPACE; i += 1) { lv_coord_t y = height() - LINE_SPACE - (i * LINE_SPACE); @@ -200,7 +194,8 @@ class SpectrumWindow : public Window maxLines[i] = line; line = lv_line_create(lvobj); - lv_obj_add_style(line, &style, LV_PART_MAIN); + etx_obj_add_style(line, styles->graph_line, LV_PART_MAIN); + etx_obj_add_style(line, styles->line_color[COLOR_THEME_ACTIVE_INDEX], LV_PART_MAIN); barLines[i] = line; line = lv_line_create(lvobj); @@ -308,7 +303,6 @@ class SpectrumWindow : public Window protected: static LAYOUT_VAL_SCALED(LINE_SPACE, 40) static LAYOUT_VAL_SCALED(WARN_YO, 20) - lv_style_t style; lv_point_t maxPts[2 * LCD_W / 4]; lv_point_t barPts[2 * LCD_W / 4]; lv_point_t peakPts[2 * LCD_W / 4]; @@ -349,7 +343,7 @@ void RadioSpectrumAnalyser::buildBody(Window* window) new SpectrumScaleWindow(window, {0, SPECTRUM_HEIGHT, LCD_W, SCALE_HEIGHT}); new SpectrumFooterWindow(window, {0, SPECTRUM_HEIGHT + SCALE_HEIGHT, LCD_W, - EdgeTxStyles::UI_ELEMENT_HEIGHT}, + EdgeTxStyles::UI_ELEMENT_HEIGHT + PAD_OUTLINE * 2}, moduleIdx); } From a59d0a89de2096f32a8313319bfa3d9040b13785 Mon Sep 17 00:00:00 2001 From: Peter Feerick Date: Sun, 16 Aug 2026 13:46:47 +1000 Subject: [PATCH 22/55] fix(cpn): tie macOS bundle to its own SDL copies, and verify it (#7653) Adapted for 2.12, which never received the build_companion composite action (added upstream by #6435, milestone 3.0). The action is imported here from main and the three Companion workflows are rewired through it, keeping 2.12's reusable-workflow layout, runners and 'ci: skip-cpn-*' guards. The docs/ hunk is dropped - 2.12 has no docs tree. action.yml is seeded at its pre-#7654 state so that #7654 lands next as a faithful diff; after both, the file matches origin/main except for the setup-python input described below. The bundle scripts needed adjusting for 2.12's macOS layout, which differs from main's: main sets OUTPUT_NAME on the companion target and names the bundle directory for the final product, while 2.12 leaves the executable as 'companion' and renames companion.app during install. So bundle_sdl_fixup derives the executable name from the target's OUTPUT_NAME (falling back to the target name), and bundle_verify checks the post-rename bundle. Both forms are also correct on main. .gitignore's 'build*/' and 'cmake-build-*/' rules are anchored to the repository root here. Unanchored they matched at any depth, including the new .github/actions/build_companion/ directory, so setup-msvc.ps1 had to be forced in with 'git add -f' and the next script added there would have been silently ignored. origin/main already anchors both; this brings 2.12 back in line rather than fixing anything upstream. 'output/' is added for the same reason - the action builds Companion into an output/ directory at the repository root, and main already ignores it. Also folded in, as deliberate drift corrections rather than upstream changes: - Qt 6.9.0 -> 6.9.3 (the action's default) on macOS and Windows - macOS artifact renamed edgetx-cpn-osx-* -> edgetx-cpn-macos-* - CMAKE_OSX_DEPLOYMENT_TARGET 10.15 -> 11.0 in tools/build-companion.sh, matching main and the documented macOS 12+ support floor The CMAKE_PREFIX_PATH forwarding upstream adds to tools/build-companion.sh has to be quoted here. main's script passes ${BUILD_OPTIONS} straight to cmake, where a ';' arriving via a variable is just a character; 2.12's assembles the command into a string and eval's it, so an unquoted value splits the configure call at CMake's list separator - cmake then runs with no source directory and the remaining options run as a command. The bare ';' Linux produces, where the action skips the Qt install step, is dropped rather than forwarded as an empty list. - edgetx-dev container pinned :latest -> :2.12 across all four workflows - a setup-python input on the action (default true, so main's behaviour is unchanged) so macOS and Windows can keep the interpreter that carries the codegen packages; 2.12 builds the simulator plugins from source and needs them, whereas main's Companion jobs download prebuilt WASM modules Co-authored-by: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Co-authored-by: Claude Opus 5 (cherry picked from commit c562f486d06f2685a9263abfd959bbd425974d14) --- .github/actions/build_companion/action.yml | 98 ++++++++++++++++ .../actions/build_companion/setup-msvc.ps1 | 39 +++++++ .github/actions/build_companion/setup-sdl.sh | 108 ++++++++++++++++++ .github/workflows/build_fw.yml | 4 +- .github/workflows/companion.yml | 1 + .github/workflows/linux_cpn.yml | 54 ++------- .github/workflows/macosx_cpn.yml | 76 ++---------- .github/workflows/nightly.yml | 2 +- .github/workflows/win_cpn-64.yml | 82 +++---------- .gitignore | 5 +- companion/src/CMakeLists.txt | 60 +++++++++- .../targets/mac/bundle_sdl_fixup.cmake.in | 65 +++++++++++ companion/targets/mac/bundle_verify.cmake.in | 92 +++++++++++++++ tools/build-companion.sh | 15 ++- 14 files changed, 508 insertions(+), 193 deletions(-) create mode 100644 .github/actions/build_companion/action.yml create mode 100644 .github/actions/build_companion/setup-msvc.ps1 create mode 100755 .github/actions/build_companion/setup-sdl.sh create mode 100644 companion/targets/mac/bundle_sdl_fixup.cmake.in create mode 100644 companion/targets/mac/bundle_verify.cmake.in diff --git a/.github/actions/build_companion/action.yml b/.github/actions/build_companion/action.yml new file mode 100644 index 00000000000..0c6787a4a85 --- /dev/null +++ b/.github/actions/build_companion/action.yml @@ -0,0 +1,98 @@ +name: 'Build Companion' +inputs: + os: + description: 'OS / Architecture' + required: true + qt-version: + description: 'Qt version' + default: '6.9.3' + qt-arch: + description: 'Qt architecture' + required: runner.os != 'Linux' + sdl2-version: + description: 'Upstream SDL2 version (Windows only)' + default: '2.32.8' + sdl3-version: + description: 'SDL3 version built from source for macOS' + default: '3.4.14' + sdl2-compat-version: + description: 'sdl2-compat version built from source for macOS' + default: '2.32.70' + macos-deployment-target: + description: 'Minimum macOS version the SDL libraries are built for' + default: '11.0' + setup-python: + description: >- + Let install-qt-action provision Python. Set false when the workflow has already + installed its own interpreter plus the build's Python packages, otherwise the + one install-qt-action selects shadows it and the codegen imports fail. + default: 'true' + +runs: + using: "composite" + steps: + + - name: Install Qt + if: runner.os != 'Linux' + uses: jdpurcell/install-qt-action@v5 + env: + AQT_CONFIG: ${{ github.workspace }}/tools/aqt-settings.ini + with: + version: ${{ inputs.qt-version }} + modules: 'qtmultimedia qtserialport' + arch: ${{ inputs.qt-arch }} + setup-python: ${{ inputs.setup-python }} + cache: true + cache-key-prefix: 'install-qt-action-${{ inputs.qt-arch }}' + + # macOS builds SDL from source (see setup-sdl.sh) - cache it so only the first + # run per version pays for it + - name: Cache SDL build + if: runner.os == 'macOS' + uses: actions/cache@v6 + with: + path: ${{ runner.temp }}/sdl + key: sdl-${{ runner.os }}-${{ inputs.sdl3-version }}-${{ inputs.sdl2-compat-version }}-${{ inputs.macos-deployment-target }} + + - name: Setup SDL + if: runner.os != 'Linux' + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + SDL2_VERSION: ${{ inputs.sdl2-version }} + SDL3_VERSION: ${{ inputs.sdl3-version }} + SDL2_COMPAT_VERSION: ${{ inputs.sdl2-compat-version }} + MACOS_DEPLOYMENT_TARGET: ${{ inputs.macos-deployment-target }} + run: | + "${ACTION_PATH}/setup-sdl.sh" + + - name: Setup MSVC + if: runner.os == 'Windows' + shell: pwsh + run: ${{ github.action_path }}/setup-msvc.ps1 + + - name: Install NSIS + if: runner.os == 'Windows' + uses: repolevedavaj/install-nsis@v1.2.1 + with: + nsis-version: '3.11' + + - name: Build + shell: bash + env: + CMAKE_BUILD_TYPE: 'Release' + run: | + mkdir output && \ + CMAKE_PREFIX_PATH="$QT_ROOT_DIR;$SDL2_ROOT" \ + tools/build-companion.sh "$(pwd)" "$(pwd)/output/" + + - name: Compose release filename + run: echo "artifact_name=edgetx-cpn-${{ inputs.os }}-${GITHUB_REF##*/}" >> $GITHUB_ENV + shell: bash + + - name: Archive production artifacts + uses: actions/upload-artifact@v7 + with: + name: "${{ env.artifact_name }}" + path: ${{github.workspace}}/output + retention-days: 15 diff --git a/.github/actions/build_companion/setup-msvc.ps1 b/.github/actions/build_companion/setup-msvc.ps1 new file mode 100644 index 00000000000..29d5d6aa694 --- /dev/null +++ b/.github/actions/build_companion/setup-msvc.ps1 @@ -0,0 +1,39 @@ +# setup-msvc.ps1 +# Runs vcvarsall.bat and exports only the new/changed environment +# variables to GitHub Actions, avoiding clobbering existing ones. + +param( + [string]$Arch = "x64" +) + +# Find VS installation +$vsWhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" +$vsPath = & $vsWhere -latest -property installationPath +$vcvars = Join-Path $vsPath "VC\Auxiliary\Build\vcvarsall.bat" + +if (-not (Test-Path $vcvars)) { + Write-Error "vcvarsall.bat not found at: $vcvars" + exit 1 +} + +# Snapshot environment before +$before = @{} +Get-ChildItem env: | ForEach-Object { $before[$_.Name] = $_.Value } + +# Run vcvarsall.bat and import its environment into this process +cmd /c "`"$vcvars`" $Arch && set" | ForEach-Object { + if ($_ -match '^([^=]+)=(.*)$') { + [System.Environment]::SetEnvironmentVariable($Matches[1], $Matches[2]) + } +} + +# Export only changed/new variables to GitHub Actions +$count = 0 +Get-ChildItem env: | ForEach-Object { + if ($before[$_.Name] -ne $_.Value) { + "$($_.Name)=$($_.Value)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV + $count++ + } +} + +Write-Host "Exported $count environment variables to GITHUB_ENV" diff --git a/.github/actions/build_companion/setup-sdl.sh b/.github/actions/build_companion/setup-sdl.sh new file mode 100755 index 00000000000..c2894140fc0 --- /dev/null +++ b/.github/actions/build_companion/setup-sdl.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# setup-sdl.sh +# Provisions SDL for Companion builds. +# +# Windows: upstream SDL2 MSVC development binaries. +# macOS: sdl2-compat + SDL3 built from source. +# Linux: provided by the Docker container. +# +# macOS builds against sdl2-compat rather than classic SDL2 because that is what users get +# from Homebrew, and it is the configuration that exposed the SDL3 bundling bug - CI needs to +# exercise the same code path Companion ships through. +# +# Homebrew itself cannot be used here: its bottles are single-architecture and target the +# runner's macOS version, while Companion ships a universal binary for macOS 11.0+. Building +# from source is what keeps both properties. + +set -euo pipefail + +SDL2_VERSION="${SDL2_VERSION:?SDL2_VERSION must be set}" + +build_cmake_project() { + local name="$1" url="$2" srcdir="$3" + shift 3 + + echo "Building ${name} (universal, macOS ${MACOS_DEPLOYMENT_TARGET}+)..." + curl -fsSL -o "${RUNNER_TEMP}/${name}.tar.gz" "${url}" + mkdir -p "${RUNNER_TEMP}/src" + tar -xzf "${RUNNER_TEMP}/${name}.tar.gz" -C "${RUNNER_TEMP}/src" + + cmake -S "${RUNNER_TEMP}/src/${srcdir}" -B "${RUNNER_TEMP}/build-${name}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET}" \ + -DCMAKE_INSTALL_PREFIX="${SDL_ROOT}" \ + -DCMAKE_PREFIX_PATH="${SDL_ROOT}" \ + "$@" + cmake --build "${RUNNER_TEMP}/build-${name}" --parallel + cmake --install "${RUNNER_TEMP}/build-${name}" +} + +case "$RUNNER_OS" in + Windows) + ARCHIVE="SDL2-devel-${SDL2_VERSION}-VC.zip" + URL="https://github.com/libsdl-org/SDL/releases/download/release-${SDL2_VERSION}/${ARCHIVE}" + DEST="${RUNNER_TEMP}/SDL2" + + echo "Downloading SDL2 ${SDL2_VERSION} (Windows/MSVC)..." + curl -fsSL -o "${RUNNER_TEMP}/${ARCHIVE}" "${URL}" + unzip -q "${RUNNER_TEMP}/${ARCHIVE}" -d "${DEST}" + + SDL2_ROOT="${DEST}/SDL2-${SDL2_VERSION}" + echo "SDL2_ROOT=${SDL2_ROOT}" >> "${GITHUB_ENV}" + ;; + + macOS) + SDL3_VERSION="${SDL3_VERSION:?SDL3_VERSION must be set}" + SDL2_COMPAT_VERSION="${SDL2_COMPAT_VERSION:?SDL2_COMPAT_VERSION must be set}" + MACOS_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET:-11.0}" + SDL_ROOT="${RUNNER_TEMP}/sdl" + STAMP="${SDL_ROOT}/.stamp-${SDL3_VERSION}-${SDL2_COMPAT_VERSION}-${MACOS_DEPLOYMENT_TARGET}" + + if [[ -f "${STAMP}" ]]; then + echo "Reusing cached SDL build in ${SDL_ROOT}" + else + rm -rf "${SDL_ROOT}" + + # SDL3 first - sdl2-compat needs its headers and CMake package to configure + build_cmake_project "SDL3" \ + "https://github.com/libsdl-org/SDL/releases/download/release-${SDL3_VERSION}/SDL3-${SDL3_VERSION}.tar.gz" \ + "SDL3-${SDL3_VERSION}" \ + -DSDL_SHARED=ON -DSDL_STATIC=OFF -DSDL_TESTS=OFF -DSDL_EXAMPLES=OFF + + build_cmake_project "sdl2-compat" \ + "https://github.com/libsdl-org/sdl2-compat/releases/download/release-${SDL2_COMPAT_VERSION}/sdl2-compat-${SDL2_COMPAT_VERSION}.tar.gz" \ + "sdl2-compat-${SDL2_COMPAT_VERSION}" \ + -DSDL2COMPAT_STATIC=OFF -DSDL2COMPAT_TESTS=OFF + + touch "${STAMP}" + fi + + # Both live under one prefix, so a single entry on CMAKE_PREFIX_PATH finds + # SDL2 (sdl2-compat) and SDL3 alike. + echo "SDL2_ROOT=${SDL_ROOT}" >> "${GITHUB_ENV}" + + echo "Verifying universal slices and deployment target..." + for lib in "${SDL_ROOT}"/lib/libSDL2-2.0.0.dylib "${SDL_ROOT}"/lib/libSDL3.0.dylib; do + [[ -f "${lib}" ]] || { echo "::error::Expected ${lib} to exist"; exit 1; } + archs="$(lipo -archs "${lib}")" + case "${archs}" in + *x86_64*arm64*|*arm64*x86_64*) ;; + *) echo "::error::${lib} is not universal (got: ${archs})"; exit 1 ;; + esac + echo " $(basename "${lib}"): ${archs}" + vtool -show-build "${lib}" | grep -E 'minos|platform' || true + done + ;; + + Linux) + echo "Skipping — SDL is provided by the Docker container" + ;; + + *) + echo "::error::Unsupported RUNNER_OS: ${RUNNER_OS}" + exit 1 + ;; +esac + +echo "SDL setup complete for ${RUNNER_OS}" diff --git a/.github/workflows/build_fw.yml b/.github/workflows/build_fw.yml index f2580b465cb..790df7eb32b 100644 --- a/.github/workflows/build_fw.yml +++ b/.github/workflows/build_fw.yml @@ -59,7 +59,7 @@ jobs: - pa01 - t22 container: - image: ghcr.io/edgetx/edgetx-dev:latest + image: ghcr.io/edgetx/edgetx-dev:2.12 volumes: - ${{ github.workspace }}:/src steps: @@ -114,7 +114,7 @@ jobs: - t22 - c14 container: - image: ghcr.io/edgetx/edgetx-dev:latest + image: ghcr.io/edgetx/edgetx-dev:2.12 volumes: - ${{ github.workspace }}:/src steps: diff --git a/.github/workflows/companion.yml b/.github/workflows/companion.yml index 7aa6021cabe..5df0fec988e 100644 --- a/.github/workflows/companion.yml +++ b/.github/workflows/companion.yml @@ -12,6 +12,7 @@ on: - '.github/workflows/linux_cpn.yml' - '.github/workflows/macosx_cpn.yml' - '.github/workflows/win_cpn-64.yml' + - '.github/actions/build_companion/**' - 'companion/**' - 'tools/build-companion.sh' diff --git a/.github/workflows/linux_cpn.yml b/.github/workflows/linux_cpn.yml index eeca8b395be..5798782be9a 100644 --- a/.github/workflows/linux_cpn.yml +++ b/.github/workflows/linux_cpn.yml @@ -4,10 +4,6 @@ on: workflow_call: workflow_dispatch: -env: - # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) - BUILD_TYPE: Release - jobs: build: runs-on: ubuntu-latest @@ -15,56 +11,20 @@ jobs: github.event_name != 'pull_request' || !contains(github.event.pull_request.labels.*.name, 'ci: skip-cpn-linux') container: - image: ghcr.io/edgetx/edgetx-dev:latest + image: ghcr.io/edgetx/edgetx-dev:2.12 volumes: - ${{ github.workspace }}:/src + env: + CMAKE_BUILD_PARALLEL_LEVEL: 4 + steps: - name: Check out the repo uses: actions/checkout@v6 with: submodules: recursive - - name: Build - working-directory: ${{github.workspace}} - shell: bash - env: - CMAKE_BUILD_PARALLEL_LEVEL: 4 - run: | - mkdir output && mkdir logs && \ - tools/build-companion.sh "$(pwd)" "$(pwd)/output/" 2>&1 | tee logs/build-main.log - - - name: Collect build logs - if: always() - shell: bash - run: | - # Create logs directory if it doesn't exist - mkdir -p logs - - # Copy all build logs from the build directory - if [ -d "build" ]; then - find build -name "*.log" -type f -exec cp {} logs/ \; 2>/dev/null || true - find build -name "CMakeOutput.log" -type f -exec cp {} logs/ \; 2>/dev/null || true - find build -name "CMakeError.log" -type f -exec cp {} logs/ \; 2>/dev/null || true - fi - - - name: Compose release filename - if: always() - # https://stackoverflow.com/questions/58033366/how-to-get-current-branch-within-github-actions - run: echo "artifact_name=edgetx-cpn-linux-${GITHUB_REF##*/}" >> $GITHUB_ENV - - - name: Archive production artifacts - if: success() - uses: actions/upload-artifact@v7 - with: - name: "${{ env.artifact_name }}" - path: ${{github.workspace}}/output - retention-days: 15 - - - name: Archive build logs - if: always() - uses: actions/upload-artifact@v7 + - name: Setup and Build + uses: ./.github/actions/build_companion with: - name: "${{ env.artifact_name }}-logs" - path: ${{github.workspace}}/logs - retention-days: 30 + os: 'linux' diff --git a/.github/workflows/macosx_cpn.yml b/.github/workflows/macosx_cpn.yml index fbd14fdc683..81732e5fb45 100644 --- a/.github/workflows/macosx_cpn.yml +++ b/.github/workflows/macosx_cpn.yml @@ -4,17 +4,16 @@ on: workflow_call: workflow_dispatch: -env: - # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) - BUILD_TYPE: Release - QT_VERSION: "6.9.0" - jobs: build: runs-on: macos-15-intel if: | github.event_name != 'pull_request' || !contains(github.event.pull_request.labels.*.name, 'ci: skip-cpn-macos') + + env: + CMAKE_BUILD_PARALLEL_LEVEL: 3 + steps: - name: Select XCode version uses: maxim-lobanov/setup-xcode@v1 @@ -31,68 +30,13 @@ jobs: with: python-version: '3.12' - - name: Create Build Environment - # Some projects don't allow in-source building, so create a separate build directory - # We'll use this as our working directory for all subsequent commands - run: cmake -E make_directory ${{github.workspace}}/build - - - name: Install Qt - uses: jdpurcell/install-qt-action@v5 - env: - AQT_CONFIG: ${{ github.workspace }}/tools/aqt-settings.ini - with: - version: ${{ env.QT_VERSION }} - modules: 'qtmultimedia qtserialport' - setup-python: 'false' - cache: true - cache-key-prefix: 'install-qt-action-macOS' - - name: Install dependencies run: python3 -m pip install --upgrade pip Pillow lz4 clang jinja2 - - name: Install SDL2 - run: brew install SDL2 - - - name: Build - working-directory: ${{github.workspace}} - shell: bash - env: - CMAKE_BUILD_PARALLEL_LEVEL: 3 - run: | - mkdir output && mkdir logs && \ - tools/build-companion.sh "$(pwd)" "$(pwd)/output/" 2>&1 | tee logs/build-main.log - - - name: Collect build logs - if: always() - shell: bash - run: | - # Create logs directory if it doesn't exist - mkdir -p logs - - # Copy all build logs from the build directory - if [ -d "build" ]; then - find build -name "*.log" -type f -exec cp {} logs/ \; 2>/dev/null || true - find build -name "CMakeOutput.log" -type f -exec cp {} logs/ \; 2>/dev/null || true - find build -name "CMakeError.log" -type f -exec cp {} logs/ \; 2>/dev/null || true - fi - - - name: Compose release filename - if: always() - # https://stackoverflow.com/questions/58033366/how-to-get-current-branch-within-github-actions - run: echo "artifact_name=edgetx-cpn-osx-${GITHUB_REF##*/}" >> $GITHUB_ENV - - - name: Archive production artifacts - if: success() - uses: actions/upload-artifact@v7 + - name: Setup and Build + uses: ./.github/actions/build_companion with: - name: "${{ env.artifact_name }}" - path: ${{github.workspace}}/output - retention-days: 15 - - - name: Archive build logs - if: always() - uses: actions/upload-artifact@v7 - with: - name: "${{ env.artifact_name }}-logs" - path: ${{github.workspace}}/logs - retention-days: 30 + os: 'macos' + qt-arch: 'clang_64' + # Setup Python 3.12 above already provisioned the codegen packages + setup-python: 'false' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 52997ea91a5..327b100f93b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -41,7 +41,7 @@ jobs: - t15pro - gx15 container: - image: ghcr.io/edgetx/edgetx-dev:latest + image: ghcr.io/edgetx/edgetx-dev:2.12 volumes: - ${{ github.workspace }}:/src steps: diff --git a/.github/workflows/win_cpn-64.yml b/.github/workflows/win_cpn-64.yml index 5c0cf9d5376..3f7a933599a 100644 --- a/.github/workflows/win_cpn-64.yml +++ b/.github/workflows/win_cpn-64.yml @@ -5,12 +5,7 @@ on: workflow_dispatch: env: - # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) - CMAKE_BUILD_TYPE: "Release" CMAKE_GENERATOR: "Ninja" - QT_VERSION: "6.9.0" - QT_ARCH: "win64_msvc2022_64" - SDL_VERSION: "2.32.8" CXX: "clang++" CC: "clang" @@ -24,78 +19,27 @@ jobs: run: shell: bash + env: + CMAKE_BUILD_PARALLEL_LEVEL: 4 + steps: - name: Check out the repo uses: actions/checkout@v6 with: submodules: recursive - - name: Install Qt - uses: jdpurcell/install-qt-action@v5 - env: - AQT_CONFIG: ${{ github.workspace }}/tools/aqt-settings.ini - with: - # Caching disabled due to intermittent "Path Validation Error" - cache: false - cache-key-prefix: 'install-qt-action-${{ env.QT_ARCH }}' - version: ${{ env.QT_VERSION }} - arch: ${{ env.QT_ARCH }} - modules: 'qtmultimedia qtserialport' - - - name: Setup SDL2 - id: setup-sdl2 - uses: libsdl-org/setup-sdl@a966470e7d9b6a5c93eec3f08d5dac370325c860 + - name: Setup Python 3.12 + uses: actions/setup-python@v6 with: - version: ${{ env.SDL_VERSION }} - build-type: "Release" + python-version: '3.12' - name: Install Python Dependencies - run: | - echo $PATH - $Python3_ROOT_DIR/python3.exe -m pip install clang jinja2 lz4 pillow - - - name: Setup MSVC - uses: ilammy/msvc-dev-cmd@v1 - - - name: Build - env: - CMAKE_BUILD_PARALLEL_LEVEL: 4 - run: | - mkdir output && mkdir logs && \ - CMAKE_PREFIX_PATH="$QT_ROOT_DIR;$SDL2_ROOT" \ - tools/build-companion.sh "$(pwd)" "$(pwd)/output/" 2>&1 | tee logs/build-main.log - - - name: Collect build logs - if: always() - shell: bash - run: | - # Create logs directory if it doesn't exist - mkdir -p logs - - # Copy all build logs from the build directory - if [ -d "build" ]; then - find build -name "*.log" -type f -exec cp {} logs/ \; 2>/dev/null || true - find build -name "CMakeOutput.log" -type f -exec cp {} logs/ \; 2>/dev/null || true - find build -name "CMakeError.log" -type f -exec cp {} logs/ \; 2>/dev/null || true - fi - - - name: Compose release filename - if: always() - # https://stackoverflow.com/questions/58033366/how-to-get-current-branch-within-github-actions - run: echo "artifact_name=edgetx-cpn-win64-${GITHUB_REF##*/}" >> $GITHUB_ENV - - - name: Archive production artifacts - if: success() - uses: actions/upload-artifact@v7 - with: - name: "${{ env.artifact_name }}" - path: ${{github.workspace}}/output - retention-days: 15 + run: python -m pip install --upgrade pip clang jinja2 lz4 pillow - - name: Archive build logs - if: always() - uses: actions/upload-artifact@v7 + - name: Setup and Build + uses: ./.github/actions/build_companion with: - name: "${{ env.artifact_name }}-logs" - path: ${{github.workspace}}/logs - retention-days: 30 + os: 'win64' + qt-arch: 'win64_msvc2022_64' + # Python 3.12 above already provisioned the codegen packages + setup-python: 'false' diff --git a/.gitignore b/.gitignore index 653ea9b3872..ac99bce916a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,8 @@ /*.user /*.user.* /doc/ -build*/ -cmake-build-*/ +/build*/ +/cmake-build-*/ /debian/ /*.vscode radio/src/tests/googletest* @@ -18,5 +18,6 @@ compile_commands.json /.vs CMakeUserPresets.json /site/ +output/ *.dmg .devcontainer/devcontainer-lock.json diff --git a/companion/src/CMakeLists.txt b/companion/src/CMakeLists.txt index b967aba1749..ac63bed1dd4 100644 --- a/companion/src/CMakeLists.txt +++ b/companion/src/CMakeLists.txt @@ -558,26 +558,35 @@ elseif(APPLE) # Run macdeployqt install(SCRIPT ${qt_deploy_companion} COMPONENT Runtime) + option(COMPANION_VERIFY_BUNDLE + "Fail the install if the macOS bundle references libraries outside itself" ON) + set(COMPANION_VERIFY_BUNDLE_ALLOW "" CACHE STRING + "Regex of absolute library paths the bundle verification may ignore") + # Bundle SDL2 (copy the real dylib; IMPORTED_RUNTIME_ARTIFACTS keeps # Homebrew symlinks and breaks the app inside a .dmg). Homebrew's # sdl2-compat also loads SDL3 from @loader_path/libSDL3.dylib at runtime. + set(_bundle_expects_sdl3 FALSE) + set(_bundled_sdl2 FALSE) if(TARGET SDL2::SDL2) get_target_property(_sdl2_location SDL2::SDL2 IMPORTED_LOCATION) if(NOT _sdl2_location) get_target_property(_sdl2_location SDL2::SDL2 IMPORTED_LOCATION_RELEASE) endif() if(_sdl2_location AND _sdl2_location MATCHES "\\.framework(/|$)") - # Genuine upstream SDL2.framework (CI's setup-sdl2.sh, or a manual framework - # install): no SDL3 dlopen shim to worry about, and IMPORTED_RUNTIME_ARTIFACTS - # copies frameworks correctly — it's only the Homebrew dylib case below whose - # symlinks it mangles. This is what CI has shipped all along. + # Genuine upstream SDL2.framework (a manual framework install): no SDL3 dlopen + # shim to worry about, and IMPORTED_RUNTIME_ARTIFACTS copies frameworks + # correctly — it's only the dylib case below whose symlinks it mangles. + # CI no longer takes this path; setup-sdl.sh builds sdl2-compat + SDL3 instead. message(STATUS "Bundling SDL2 framework: ${_sdl2_location}") install(IMPORTED_RUNTIME_ARTIFACTS SDL2::SDL2 FRAMEWORK DESTINATION "${companion_app_dir}/Contents/Frameworks" LIBRARY DESTINATION "${companion_app_dir}/Contents/Frameworks" COMPONENT Runtime) + set(_bundled_sdl2 TRUE) elseif(_sdl2_location) message(STATUS "Bundling SDL2 library: ${_sdl2_location}") + set(_bundled_sdl2 TRUE) get_filename_component(_sdl2_real "${_sdl2_location}" REALPATH) get_filename_component(_sdl2_name "${_sdl2_location}" NAME) install(FILES "${_sdl2_real}" @@ -598,6 +607,10 @@ elseif(APPLE) endif() if(NOT _sdl3_real) set(_sdl3_hints "") + # SDL3 usually lives right next to SDL2 (setup-sdl.sh installs both to one prefix) - + # check there before falling back to Homebrew heuristics. + get_filename_component(_sdl2_libdir "${_sdl2_location}" DIRECTORY) + list(APPEND _sdl3_hints "${_sdl2_libdir}") if(DEFINED ENV{HOMEBREW_PREFIX}) list(APPEND _sdl3_hints "$ENV{HOMEBREW_PREFIX}/opt/sdl3/lib" @@ -633,8 +646,12 @@ elseif(APPLE) DESTINATION "${companion_app_dir}/Contents/Frameworks" RENAME "libSDL3.dylib" COMPONENT Runtime) + set(_bundle_expects_sdl3 TRUE) else() - message(WARNING "SDL3 not found; packaged Companion may crash if linked against sdl2-compat") + message(FATAL_ERROR + "SDL3 not found, but the bundled SDL2 (${_sdl2_location}) is sdl2-compat, which " + "dlopens libSDL3.dylib at runtime - Companion would not start without it. Install " + "SDL3, or point CMAKE_PREFIX_PATH/HOMEBREW_PREFIX at a prefix that has it.") endif() install(CODE " @@ -661,6 +678,28 @@ elseif(APPLE) else() message(WARNING "SDL2::SDL2 has no IMPORTED_LOCATION; SDL2 will not be bundled") endif() + + # Point the executable at the SDL copies bundled above. qt_deploy_runtime_dependencies + # ran before they existed, and install_name_tool -id only fixes each dylib's own id, so + # nothing else guarantees this. Runs for both the framework and dylib branches, and must + # stay ahead of the re-sign below so the signature covers the rewritten load commands. + # Skipped when nothing was bundled - rewriting to Frameworks would then point the + # executable at files that are not there. + if(_bundled_sdl2) + # The bundle executable is the target's OUTPUT_NAME when one is set, and the + # target name otherwise - 2.12 does not set OUTPUT_NAME, so it is "companion". + get_target_property(_companion_exe_name ${COMPANION_NAME} OUTPUT_NAME) + if(NOT _companion_exe_name) + set(_companion_exe_name "${COMPANION_NAME}") + endif() + + configure_file( + "${COMPANION_TARGETS_DIR}/bundle_sdl_fixup.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/bundle_sdl_fixup.cmake" + @ONLY + ) + install(SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/bundle_sdl_fixup.cmake" COMPONENT Runtime) + endif() endif() # Fix Code Signing and rename bundle @@ -674,6 +713,17 @@ elseif(APPLE) file(RENAME \"\${app_dir}\" \"\${CMAKE_INSTALL_PREFIX}/${COMPANION_OSX_APP_BUNDLE_NAME}.app\") " COMPONENT Runtime) + # Last gate: a bundle that still points at /opt/homebrew or a build-machine Qt prefix + # starts on the machine that built it and nowhere else. Fail here rather than ship it. + if(COMPANION_VERIFY_BUNDLE) + configure_file( + "${COMPANION_TARGETS_DIR}/bundle_verify.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/bundle_verify.cmake" + @ONLY + ) + install(SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/bundle_verify.cmake" COMPONENT Runtime) + endif() + else() set(LINUXDEPLOY_APPIMAGE "linuxdeploy-x86_64.AppImage") set(LINUXDEPLOY_PLUGIN_QT "linuxdeploy-plugin-qt-x86_64.AppImage") diff --git a/companion/targets/mac/bundle_sdl_fixup.cmake.in b/companion/targets/mac/bundle_sdl_fixup.cmake.in new file mode 100644 index 00000000000..51863795e1d --- /dev/null +++ b/companion/targets/mac/bundle_sdl_fixup.cmake.in @@ -0,0 +1,65 @@ +# Rewrite absolute SDL load commands in the Companion executable to the bundled copies. +# +# Configured from companion/targets/mac/bundle_sdl_fixup.cmake.in - do not edit the copy in +# the build tree. +# +# Companion used to run fixup_bundle, which walked every Mach-O in the bundle and rewrote all +# of their load commands in one pass. That was replaced by qt_deploy_runtime_dependencies, +# which runs *before* the SDL install(FILES) steps, and by an install_name_tool -id call that +# only fixes each dylib's own id. Nothing in that chain guarantees the executable actually +# references the SDL copies sitting in Contents/Frameworks, so make it explicit here. + +set(_app "${CMAKE_INSTALL_PREFIX}/@companion_app_dir@") +set(_bin "${_app}/Contents/MacOS/@_companion_exe_name@") + +if(NOT EXISTS "${_bin}") + message(FATAL_ERROR "Companion executable not found for SDL fixup: ${_bin}") +endif() + +execute_process( + COMMAND otool -L "${_bin}" + OUTPUT_VARIABLE _otool_out + ERROR_VARIABLE _otool_err + RESULT_VARIABLE _otool_res +) +if(NOT _otool_res EQUAL 0) + message(FATAL_ERROR "otool -L failed for ${_bin}: ${_otool_err}") +endif() + +string(REPLACE "\n" ";" _lines "${_otool_out}") +foreach(_line IN LISTS _lines) + # dependency lines are tab-indented: "\t (compatibility version ..., current ...)" + string(REGEX MATCH "^\t([^ ]+)" _matched "${_line}") + if(NOT _matched) + continue() + endif() + set(_ref "${CMAKE_MATCH_1}") + + # @rpath / @executable_path / @loader_path already resolve inside the bundle + if(NOT _ref MATCHES "^/") + continue() + endif() + if(NOT _ref MATCHES "SDL2|SDL3") + continue() + endif() + + # Keep the framework tail (SDL2.framework/Versions/A/SDL2) so the rewritten reference + # matches the layout install(IMPORTED_RUNTIME_ARTIFACTS) produced; plain dylibs land + # directly in Frameworks under their own name. + if(_ref MATCHES "/(SDL[23]\\.framework/.*)$") + set(_tail "${CMAKE_MATCH_1}") + else() + get_filename_component(_tail "${_ref}" NAME) + endif() + + set(_new "@executable_path/../Frameworks/${_tail}") + message(STATUS "Rewriting SDL reference: ${_ref} -> ${_new}") + execute_process( + COMMAND install_name_tool -change "${_ref}" "${_new}" "${_bin}" + ERROR_VARIABLE _change_err + RESULT_VARIABLE _change_res + ) + if(NOT _change_res EQUAL 0) + message(FATAL_ERROR "install_name_tool -change failed for ${_ref}: ${_change_err}") + endif() +endforeach() diff --git a/companion/targets/mac/bundle_verify.cmake.in b/companion/targets/mac/bundle_verify.cmake.in new file mode 100644 index 00000000000..a550238d892 --- /dev/null +++ b/companion/targets/mac/bundle_verify.cmake.in @@ -0,0 +1,92 @@ +# Verify the installed Companion .app carries no references to libraries outside itself. +# +# Configured from companion/targets/mac/bundle_verify.cmake.in - do not edit the copy in the +# build tree. +# +# A .dmg that links against /opt/homebrew, /usr/local/Cellar or a build-machine Qt prefix +# starts fine on the machine that built it and fails everywhere else, which is exactly how the +# sdl2-compat breakage reached users. Catch it at install time instead. +# +# Disable with -DCOMPANION_VERIFY_BUNDLE=OFF; widen with -DCOMPANION_VERIFY_BUNDLE_ALLOW=. + +set(_app "${CMAKE_INSTALL_PREFIX}/@COMPANION_OSX_APP_BUNDLE_NAME@.app") +set(_allow "@COMPANION_VERIFY_BUNDLE_ALLOW@") +set(_expect_sdl3 "@_bundle_expects_sdl3@") + +if(NOT IS_DIRECTORY "${_app}") + message(FATAL_ERROR "Bundle not found for verification: ${_app}") +endif() + +# sdl2-compat dlopens "@loader_path/libSDL3.dylib" - invisible to otool, so check by hand +if(_expect_sdl3 AND NOT EXISTS "${_app}/Contents/Frameworks/libSDL3.dylib") + message(FATAL_ERROR + "Contents/Frameworks/libSDL3.dylib is missing. Companion was linked against " + "sdl2-compat, which loads SDL3 at runtime, so the bundle will not start.") +endif() + +file(GLOB_RECURSE _candidates "${_app}/Contents/*") + +set(_offenders "") +set(_checked 0) +foreach(_file IN LISTS _candidates) + if(IS_SYMLINK "${_file}" OR IS_DIRECTORY "${_file}") + continue() + endif() + + execute_process( + COMMAND otool -L "${_file}" + OUTPUT_VARIABLE _otool_out + ERROR_VARIABLE _otool_err + RESULT_VARIABLE _otool_res + ) + # non-Mach-O files (plists, icons, translations) simply fail the scan - skip them + if(NOT _otool_res EQUAL 0 OR _otool_out MATCHES "is not an object file") + continue() + endif() + math(EXPR _checked "${_checked} + 1") + + string(REPLACE "\n" ";" _lines "${_otool_out}") + foreach(_line IN LISTS _lines) + string(REGEX MATCH "^\t([^ ]+)" _matched "${_line}") + if(NOT _matched) + continue() + endif() + set(_ref "${CMAKE_MATCH_1}") + + if(NOT _ref MATCHES "^/") + continue() # @rpath / @executable_path / @loader_path resolve inside the bundle + endif() + if(_ref MATCHES "^/usr/lib/" OR _ref MATCHES "^/System/") + continue() # shipped with macOS + endif() + if(_allow AND _ref MATCHES "${_allow}") + continue() + endif() + + file(RELATIVE_PATH _rel "${_app}" "${_file}") + list(APPEND _offenders "${_rel}: ${_ref}") + endforeach() +endforeach() + +if(_offenders) + list(REMOVE_DUPLICATES _offenders) + string(REPLACE ";" "\n " _report "${_offenders}") + message(FATAL_ERROR + "Bundle references libraries outside itself - this .dmg would not start on another " + "machine:\n ${_report}\n" + "Re-run with -DCOMPANION_VERIFY_BUNDLE=OFF to package anyway.") +endif() + +message(STATUS "Bundle verification: ${_checked} Mach-O files, no external references") + +# The bundle seal is a separate concern from the per-Mach-O signatures: dyld does not consult +# CodeResources when loading, so a stale seal does not stop the app launching. It would matter +# for Gatekeeper/notarization, so report it - but do not fail a build over it. +execute_process( + COMMAND codesign --verify --deep --strict --verbose=2 "${_app}" + ERROR_VARIABLE _codesign_err + RESULT_VARIABLE _codesign_res +) +if(NOT _codesign_res EQUAL 0) + message(WARNING "Bundle signature is not valid (not fatal): ${_codesign_err}") +endif() diff --git a/tools/build-companion.sh b/tools/build-companion.sh index 44e076cd3ee..cd4b6aa78f9 100755 --- a/tools/build-companion.sh +++ b/tools/build-companion.sh @@ -30,7 +30,20 @@ fi COMMON_OPTIONS="${COMMON_OPTIONS} -DCMAKE_BUILD_TYPE=Release -DCMAKE_MESSAGE_LOG_LEVEL=WARNING -Wno-dev -DGVARS=YES -DHELI=YES -DLUA=YES" if [ "$(uname)" = "Darwin" ]; then - COMMON_OPTIONS="${COMMON_OPTIONS} -DCMAKE_OSX_DEPLOYMENT_TARGET='10.15'" + COMMON_OPTIONS="${COMMON_OPTIONS} -DCMAKE_OSX_DEPLOYMENT_TARGET='11.0'" +fi + +# find_package(... CONFIG) skips the lib/cmake/ search pattern for prefixes that only +# come from the CMAKE_PREFIX_PATH env var (not -D) - forward it so CI's source-built SDL3 is +# actually found instead of silently missing from the bundle. +# +# The value must be quoted: unlike main's script this one assembles its cmake command line +# into a string and eval's it, so CMake's ';' list separator would otherwise be re-parsed as +# a shell command separator and split the configure call in two. Skip the forward entirely +# when the value holds no actual paths - on Linux the action skips the Qt install step, so +# CMAKE_PREFIX_PATH arrives as a bare ';'. +if [[ -n "${CMAKE_PREFIX_PATH:-}" && -n "${CMAKE_PREFIX_PATH//;/}" ]]; then + COMMON_OPTIONS="${COMMON_OPTIONS} -DCMAKE_PREFIX_PATH='${CMAKE_PREFIX_PATH}'" fi # Generate EDGETX_VERSION_SUFFIX if not already set From d1480514818689d8fd97f9010bf6aa3980c996ec Mon Sep 17 00:00:00 2001 From: Peter Feerick Date: Sun, 16 Aug 2026 11:29:17 +1000 Subject: [PATCH 23/55] fix(ci): restore build log artifact upload for Companion CI (#7654) Co-authored-by: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 (cherry picked from commit a4299e75ed9944ab184e4333246875d06fb19dab) --- .github/actions/build_companion/action.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/actions/build_companion/action.yml b/.github/actions/build_companion/action.yml index 0c6787a4a85..73a1c390c31 100644 --- a/.github/actions/build_companion/action.yml +++ b/.github/actions/build_companion/action.yml @@ -87,12 +87,34 @@ runs: tools/build-companion.sh "$(pwd)" "$(pwd)/output/" - name: Compose release filename + if: always() run: echo "artifact_name=edgetx-cpn-${{ inputs.os }}-${GITHUB_REF##*/}" >> $GITHUB_ENV shell: bash + - name: Collect build logs + if: always() + shell: bash + run: | + mkdir -p logs + + if [ -d "build" ]; then + find build -name "*.log" -type f -exec cp {} logs/ \; 2>/dev/null || true + find build -name "CMakeOutput.log" -type f -exec cp {} logs/ \; 2>/dev/null || true + find build -name "CMakeError.log" -type f -exec cp {} logs/ \; 2>/dev/null || true + fi + - name: Archive production artifacts uses: actions/upload-artifact@v7 with: name: "${{ env.artifact_name }}" path: ${{github.workspace}}/output retention-days: 15 + + - name: Archive build logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: "${{ env.artifact_name }}-logs" + path: ${{github.workspace}}/logs + retention-days: 30 + if-no-files-found: ignore From 640d356ed07c2ff3d410ad3828675759ee1f9172 Mon Sep 17 00:00:00 2001 From: philmoz Date: Tue, 18 Aug 2026 19:35:22 +1000 Subject: [PATCH 24/55] fix(color): USB SD card mode may crash to emergency mode (#7657) Adapted for 2.12: also restores the refreshWidgetSelectTimer() call when entering widget select, which main already had but 2.12 was missing (it carried a '// TODO: start timer' comment instead). Without it the new deadline is never seeded and widget select would cancel itself on the next GUI cycle. Same adaptation as the 2.11 backport. (cherry picked from commit 3e1e1859bc91f3b82f9425bacdf0a54cdcb39978) --- radio/src/gui/colorlcd/mainview/view_main.cpp | 54 +++++++------------ radio/src/gui/colorlcd/mainview/view_main.h | 6 +-- 2 files changed, 22 insertions(+), 38 deletions(-) diff --git a/radio/src/gui/colorlcd/mainview/view_main.cpp b/radio/src/gui/colorlcd/mainview/view_main.cpp index e55c70aae16..6a0f10b51bd 100644 --- a/radio/src/gui/colorlcd/mainview/view_main.cpp +++ b/radio/src/gui/colorlcd/mainview/view_main.cpp @@ -29,6 +29,15 @@ #include "screen_setup.h" #include "widget.h" +static void saveViewId(unsigned view) +{ + if (view != g_model.view) { + TRACE("save view #%d", view); + g_model.view = view; + storageDirty(EE_MODEL); + } +} + static void tile_view_deleted_cb(lv_event_t* e) { lv_obj_t* target = lv_event_get_target(e); @@ -42,15 +51,6 @@ static void tile_view_deleted_cb(lv_event_t* e) } } -static void saveViewId(unsigned view) -{ - if (view != g_model.view) { - TRACE("save view #%d", view); - g_model.view = view; - storageDirty(EE_MODEL); - } -} - static void tile_view_scroll_begin(lv_event_t * e) { lv_anim_t* a = (lv_anim_t*)lv_event_get_param(e); @@ -97,8 +97,7 @@ ViewMain::ViewMain() : lv_obj_set_user_data(tile_view, this); lv_obj_add_event_cb(tile_view, tile_view_scroll_begin, LV_EVENT_SCROLL_BEGIN, NULL); lv_obj_add_event_cb(tile_view, tile_view_scroll, LV_EVENT_SCROLL, nullptr); - lv_obj_add_event_cb(tile_view, tile_view_scroll_end, LV_EVENT_SCROLL_END, - nullptr); + lv_obj_add_event_cb(tile_view, tile_view_scroll_end, LV_EVENT_SCROLL_END, nullptr); // create last to be on top topbar = new TopBar(this); @@ -284,25 +283,20 @@ void ViewMain::onCancel() void ViewMain::refreshWidgetSelectTimer() { - if (!widget_select_timer) { - widget_select_timer = lv_timer_create(ViewMain::ws_timer, 10 * 1000, this); - } else { - lv_timer_reset(widget_select_timer); - } + widgetSelectCancelTime = get_tmr10ms() + 1000; // 10 seconds } -bool ViewMain::enableWidgetSelect(bool enable) +void ViewMain::enableWidgetSelect(bool enable) { TRACE("enableWidgetSelect(%d)", enable); - // TODO: start timer - if (widget_select == enable) return false; + if (widget_select == enable) return; widget_select = enable; lv_obj_t* tile = lv_tileview_get_tile_act(tile_view); - if (!tile) return true; + if (!tile) return; auto cont_obj = lv_obj_get_child(tile, 0); - if (!cont_obj) return true; + if (!cont_obj) return; auto cont = (WidgetsContainer*)lv_obj_get_user_data(cont_obj); @@ -316,18 +310,15 @@ bool ViewMain::enableWidgetSelect(bool enable) lv_obj_clear_flag(tile_view, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(tile_view, LV_OBJ_FLAG_SCROLL_CHAIN_HOR); lv_obj_clear_flag(tile_view, LV_OBJ_FLAG_SCROLL_CHAIN_VER); + + refreshWidgetSelectTimer(); } else { lv_obj_add_flag(tile_view, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(tile_view, LV_OBJ_FLAG_SCROLL_CHAIN_HOR); lv_obj_add_flag(tile_view, LV_OBJ_FLAG_SCROLL_CHAIN_VER); - if (widget_select_timer) { - lv_timer_del(widget_select_timer); - widget_select_timer = nullptr; - } + widgetSelectCancelTime = 0; } - - return true; } void ViewMain::openMenu() @@ -335,13 +326,6 @@ void ViewMain::openMenu() viewMainMenu = QuickMenu::openQuickMenu([=]() { viewMainMenu = nullptr; }); } -void ViewMain::ws_timer(lv_timer_t* t) -{ - ViewMain* view = (ViewMain*)t->user_data; - if (!view) return; - view->enableWidgetSelect(false); -} - bool ViewMain::onLongPress() { if (isAppMode()) { @@ -413,6 +397,8 @@ void ViewMain::_refreshWidgets() if (customScreens[i]) customScreens[i]->refreshWidgets(isVisible); } + if (widget_select && widgetSelectCancelTime < get_tmr10ms()) + enableWidgetSelect(false); } } diff --git a/radio/src/gui/colorlcd/mainview/view_main.h b/radio/src/gui/colorlcd/mainview/view_main.h index dc78c81c9e7..1b89476af04 100644 --- a/radio/src/gui/colorlcd/mainview/view_main.h +++ b/radio/src/gui/colorlcd/mainview/view_main.h @@ -44,7 +44,7 @@ class ViewMain : public NavWindow void addMainView(WidgetsContainer* view, uint32_t viewId); void updateTopbarVisibility(); - bool enableWidgetSelect(bool enable); + void enableWidgetSelect(bool enable); // Get the available space in the middle of the screen // (without topbar) @@ -84,7 +84,7 @@ class ViewMain : public NavWindow lv_obj_t* tile_view = nullptr; TopBar* topbar = nullptr; bool widget_select = false; - lv_timer_t* widget_select_timer = nullptr; + tmr10ms_t widgetSelectCancelTime = 0; QuickMenu* viewMainMenu = nullptr; void deleteLater(bool detach = true, bool trash = true) override; @@ -95,8 +95,6 @@ class ViewMain : public NavWindow void _refreshWidgets(); - static void ws_timer(lv_timer_t* t); - #if defined(HARDWARE_KEYS) void doKeyShortcut(event_t event); void onPressSYS() override; From 18152f3b69b8d1e9fe6236639a848585a73b61a2 Mon Sep 17 00:00:00 2001 From: Neil Horne <15316949+elecpower@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:50:40 +1000 Subject: [PATCH 25/55] fix(cpn): changing default model flags modified (#7659) (cherry picked from commit a0c1aa7a2b2ee10366ff9c39aa51c5518c20a5e2) --- companion/src/mdichild.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/companion/src/mdichild.cpp b/companion/src/mdichild.cpp index e6b4d849d48..105252c9985 100644 --- a/companion/src/mdichild.cpp +++ b/companion/src/mdichild.cpp @@ -1290,7 +1290,7 @@ void MdiChild::setDefault() int row = getCurrentModel(); if (!radioData.models[row].isEmpty() && radioData.generalSettings.currModelIndex != (unsigned)row) { radioData.setCurrentModel(row); - refresh(); + setModified(); } } From e031bcc3fdd726406566d9cc5a39e8e8aa1f9110 Mon Sep 17 00:00:00 2001 From: Neil Horne <15316949+elecpower@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:04:12 +1000 Subject: [PATCH 26/55] fix(simu): gracefully handle non-existent radio profile SD path (#7661) Adapted for 2.12, which has no WASM simulator - companion/src/simulation/ wasmsimulatorinterface.cpp does not exist here (it arrived upstream with #6435, milestone 3.0). The equivalent seam is OpenTxSimulator::setSdPath(). Scope is reduced deliberately: 2.12's simulator does not crash on a bad SD path the way the WASI runtime does - simuFatfsSetPaths() and the file ops behind it use std::filesystem error_code overloads and simply fail. Upstream clears the path because an empty preopen dir is safe for WASI; on 2.12 an empty path makes simuFatfsSetPaths() fall back to the current working directory, which would silently treat the wrong directory as the SD card. So the guard warns and leaves the path alone. (cherry picked from commit 80003fbc46ecbddc79288583533e82f3d05af3ea) --- radio/src/targets/simu/opentxsimulator.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/radio/src/targets/simu/opentxsimulator.cpp b/radio/src/targets/simu/opentxsimulator.cpp index d296328ee92..a8bf0b1e0a4 100644 --- a/radio/src/targets/simu/opentxsimulator.cpp +++ b/radio/src/targets/simu/opentxsimulator.cpp @@ -34,6 +34,7 @@ #include "boards/generic_stm32/rgb_leds.h" #include +#include #include #if !defined(MAX_LOGICAL_SWITCHES) && defined(NUM_CSW) @@ -302,6 +303,16 @@ void OpenTxSimulator::stop() void OpenTxSimulator::setSdPath(const QString & sdPath, const QString & settingsPath) { QMutexLocker lckr(&m_mtxSettings); + + // DO NOT trust the sd path as it could be invalid. + // Unlike the WASM simulator this one does not crash on a bad path - the + // std::filesystem calls behind simuFatfs* just fail - so the path is kept + // rather than cleared: an empty value here makes simuFatfsSetPaths() fall + // back to the current working directory, which would silently treat the + // wrong directory as the SD card. Warn and carry on instead. + if (!sdPath.isEmpty() && !QDir(sdPath).exists()) + qWarning() << "SD path" << sdPath << "does not exist"; + simuSdDirectory = sdPath; simuSettingsDirectory = settingsPath; } From fc7bd2e88a6ae7e1bb03bb502477f9ae2881edee Mon Sep 17 00:00:00 2001 From: philmoz Date: Sun, 16 Aug 2026 19:10:50 +1000 Subject: [PATCH 27/55] fix(color): Outputs widget does not update after changing 'Extended Limits' (#7662) (cherry picked from commit 0c07a3d648a929732190ab9b7378a7f6c272ab55) --- radio/src/gui/colorlcd/widgets/outputs.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/radio/src/gui/colorlcd/widgets/outputs.cpp b/radio/src/gui/colorlcd/widgets/outputs.cpp index 972254ceee9..b856207e3c9 100644 --- a/radio/src/gui/colorlcd/widgets/outputs.cpp +++ b/radio/src/gui/colorlcd/widgets/outputs.cpp @@ -102,8 +102,9 @@ class ChannelValue : public Window int16_t value = channelOutputs[channel]; - if (value != lastValue) { + if (value != lastValue || lastExtendedLimits != g_model.extendedLimits) { lastValue = value; + lastExtendedLimits = g_model.extendedLimits; std::string s; if (g_eeGeneral.ppmunit == PPM_US) @@ -151,6 +152,7 @@ class ChannelValue : public Window int16_t lastValue = OUTPUT_INVALID_VALUE; int16_t lastScaledValue = OUTPUT_INVALID_VALUE; std::string lastText; + bool lastExtendedLimits = false; bool chanHasName = false; lv_style_t style; lv_obj_t* valueLabel = nullptr; From f74b64447dd66f1e57b02c5ada0229d20bf21ccf Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 19 Aug 2026 02:23:31 +0200 Subject: [PATCH 28/55] chore(telem): unify antenna number with RSSI number for ELRS (#7667) (cherry picked from commit 7461bfc9ec3fcd24d05056cc923dad202b5799a2) --- radio/src/telemetry/crossfire.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/radio/src/telemetry/crossfire.cpp b/radio/src/telemetry/crossfire.cpp index 263d474b082..31e26d6fdb5 100644 --- a/radio/src/telemetry/crossfire.cpp +++ b/radio/src/telemetry/crossfire.cpp @@ -305,6 +305,9 @@ void processCrossfireTelemetryFrame(uint8_t module, uint8_t* rxBuffer, 1000, 2000, 250, 50}; value = ((unsigned)value < DIM(power_values) ? power_values[value] : 0); + } else if (i == RX_ANTENNA_INDEX && + crossfireModuleStatus[module].isELRS) { + value += 1; // 0 = Antenna 1, 1 = Antenna 2 } processCrossfireTelemetryValue(i, value); if (i == RX_QUALITY_INDEX) { From b37f779dabde7e49d082e89711337e461b8895a3 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:14:00 +0000 Subject: [PATCH 29/55] fix(build): use the renamed STICK_DEAD_ZONE option on gx15 and t15pro Not an upstream cherry-pick. #7638 replaced the stale commented-out declaration with a real option(... OFF) for t22, tx15, tx16smk3 and v12, but missed two targets: gx15 - still on the old plural name in both places, so if(STICKS_DEAD_ZONE) was always false t15pro - already used the singular name in the guard, but left its option() declaration commented out In both cases the option was never declared, so the guard was dead and the sticks dead zone could not be enabled at all. Declaring it explicitly is also what stops the value leaking in from another target's CMake cache, which is the bug #7638 set out to fix. No functional change: the option defaults OFF, so -DSTICK_DEAD_ZONE is still never added and neither yaml_datastructs_gx15.cpp nor yaml_datastructs_t15pro.cpp is affected - both already carry YAML_PADDING(3) in that slot, matching t22. origin/main has the same stale declarations, so it wants the same fix. --- radio/src/targets/gx15/CMakeLists.txt | 5 ++--- radio/src/targets/t15pro/CMakeLists.txt | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/radio/src/targets/gx15/CMakeLists.txt b/radio/src/targets/gx15/CMakeLists.txt index c62778cf6f4..7ff18a8818e 100644 --- a/radio/src/targets/gx15/CMakeLists.txt +++ b/radio/src/targets/gx15/CMakeLists.txt @@ -7,6 +7,7 @@ option(GHOST "Ghost TX Module" ON) option(MODULE_SIZE_STD "Standard size TX Module" ON) option(LUA_MIXER "Enable LUA mixer/model scripts support" ON) option(DISK_CACHE "Enable SD card disk cache" ON) +option(STICK_DEAD_ZONE "Enable sticks dead zone" OFF) set(FIRMWARE_QSPI YES) set(FIRMWARE_FORMAT_UF2 YES) @@ -27,8 +28,6 @@ set(INTERNAL_MODULE_AFHDS3 NO) set(ROTARY_ENCODER YES) set(FUNCTION_SWITCHES_WITH_RGB YES) -#option(STICKS_DEAD_ZONE "Enable sticks dead zone" YES) - # IMU support set(IMU ON) add_definitions(-DIMU_ICM4207C) @@ -82,7 +81,7 @@ add_definitions(-DRTCLOCK) add_definitions(-DPWR_BUTTON_${PWR_BUTTON}) add_definitions(-DGPS_USART_BAUDRATE=${INTERNAL_GPS_BAUDRATE}) -if(STICKS_DEAD_ZONE) +if(STICK_DEAD_ZONE) add_definitions(-DSTICK_DEAD_ZONE) endif() diff --git a/radio/src/targets/t15pro/CMakeLists.txt b/radio/src/targets/t15pro/CMakeLists.txt index 18987cdb15a..4b66cffa70f 100644 --- a/radio/src/targets/t15pro/CMakeLists.txt +++ b/radio/src/targets/t15pro/CMakeLists.txt @@ -7,6 +7,7 @@ option(GHOST "Ghost TX Module" ON) option(MODULE_SIZE_STD "Standard size TX Module" ON) option(LUA_MIXER "Enable LUA mixer/model scripts support" ON) option(DISK_CACHE "Enable SD card disk cache" ON) +option(STICK_DEAD_ZONE "Enable sticks dead zone" OFF) set(FIRMWARE_QSPI YES) set(FIRMWARE_FORMAT_UF2 YES) @@ -28,8 +29,6 @@ set(ROTARY_ENCODER YES) set(FUNCTION_SWITCHES YES) set(FUNCTION_SWITCHES_RGB_LEDS YES) -#option(STICK_DEAD_ZONE "Enable sticks dead zone" YES) - # IMU support set(IMU ON) From f29c56d7213d8950b2765eb71af7f813afb61bbf Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:51:06 +0000 Subject: [PATCH 30/55] fix(cpn): V12 missing from the STM32H7 board list IS_STM32H7() is hand-maintained on this branch and the HelloRadioSky V12 was never added to it, so Companion treated an H7 radio as F2/F4 - IS_STM32F2F4() is simply the negation of the H5 and H7 tests. Three capabilities were wrong as a result, all of which the firmware gates on defined(STM32H7): Gvars 9, should be 15 (gvars.h) Sensors 60, should be 75 (dataconstants.h) SportMaxBaudRate 400000, should be 0 The first two under-report what the radio supports, so models built in Companion could not use the full range. The third is a smaller change of behaviour rather than a plain fix: callers treat "< 400000" as the cue to default an internal CRSF or Ghost module to 115k rather than 400k, so V12 now defaults the same way as every other H7 radio. The V12 target sets CPU_TYPE STM32H7 in its CMakeLists.txt and runs on a helloradio-h750 board, so H7 is not in question. main is not affected. It has no IS_STM32H7() at all - companion there reads the MCU from the generated hw_defs/*.json, which already marks v12 as STM32H7, so it cannot drift from the firmware the way this list has. Co-authored-by: Claude Opus 5 --- companion/src/firmwares/boards.h | 1 + 1 file changed, 1 insertion(+) diff --git a/companion/src/firmwares/boards.h b/companion/src/firmwares/boards.h index 49a40c0c420..4e20f58e5fa 100644 --- a/companion/src/firmwares/boards.h +++ b/companion/src/firmwares/boards.h @@ -892,6 +892,7 @@ inline bool IS_STM32H7(Board::Type board) { return IS_FLYSKY_PA01(board) || IS_FLYSKY_ST16(board) || + IS_HELLORADIOSKY_V12(board) || IS_JUMPER_T15PRO(board) || IS_JUMPER_T22(board) || IS_RADIOMASTER_TX15(board) || From 331576c08ecaecb6d7b335f960a7cca735265764 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:34:28 +0000 Subject: [PATCH 31/55] chore(ci): update actions to their current major versions build_fw was warning that actions/upload-artifact/merge@v4 targets Node.js 20 and is being forced onto Node.js 24, so the rest were checked at the same time. actions/checkout v6 -> v7 actions/download-artifact v4 -> v8 actions/setup-python v6 -> v7 actions/upload-artifact/merge v4 -> v7 (the deprecation warning) actions/stale v8 -> v11 softprops/action-gh-release v2 -> v3 repolevedavaj/install-nsis v1.2.1 -> v1 Already current, left alone: actions/cache@v6, actions/upload-artifact@v7, maxim-lobanov/setup-xcode@v1, montudor/action-zip@v1, orhun/git-cliff-action@v4 and jdpurcell/install-qt-action@v5 - that last one publishes its major versions as branches rather than tags, so v5 is the moving reference even though the newest tag reads v4.1.1. install-nsis is pinned to the major rather than a point release; v1 currently resolves to the same commit as v1.3.0. release-drafter.yml is deliberately untouched here to avoid colliding with the release notes work; its checkout and action-gh-release references are updated there instead. Two of these warrant an eye on the first run. softprops/action-gh-release crosses a major version and is what publishes the nightly release, and the composite action now differs from origin/main by the install-nsis pin as well as the setup-python input, so main wants the same sweep. Co-authored-by: Claude Opus 5 --- .github/actions/build_companion/action.yml | 2 +- .github/workflows/auto_close.yml | 2 +- .github/workflows/build_fw.yml | 6 +++--- .github/workflows/linux_cpn.yml | 2 +- .github/workflows/macosx_cpn.yml | 4 ++-- .github/workflows/nightly.yml | 8 ++++---- .github/workflows/sync_repos.yml | 2 +- .github/workflows/validate_fw_json.yml | 2 +- .github/workflows/win_cpn-64.yml | 4 ++-- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/actions/build_companion/action.yml b/.github/actions/build_companion/action.yml index 73a1c390c31..87bbd444a42 100644 --- a/.github/actions/build_companion/action.yml +++ b/.github/actions/build_companion/action.yml @@ -73,7 +73,7 @@ runs: - name: Install NSIS if: runner.os == 'Windows' - uses: repolevedavaj/install-nsis@v1.2.1 + uses: repolevedavaj/install-nsis@v1 with: nsis-version: '3.11' diff --git a/.github/workflows/auto_close.yml b/.github/workflows/auto_close.yml index ebcd607c8fe..3252aaed847 100644 --- a/.github/workflows/auto_close.yml +++ b/.github/workflows/auto_close.yml @@ -7,7 +7,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@v11 with: # Issue closure message close-issue-message: 'This issue has been closed due to no activity in over four weeks after having been marked as closure pending.' diff --git a/.github/workflows/build_fw.yml b/.github/workflows/build_fw.yml index 790df7eb32b..e30b7575356 100644 --- a/.github/workflows/build_fw.yml +++ b/.github/workflows/build_fw.yml @@ -64,7 +64,7 @@ jobs: - ${{ github.workspace }}:/src steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive # fetch-depth: 0 # https://github.com/actions/checkout#Fetch-all-history-for-all-tags-and-branches @@ -119,7 +119,7 @@ jobs: - ${{ github.workspace }}:/src steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive @@ -153,7 +153,7 @@ jobs: run: echo "artifact_name=edgetx-firmware-${GITHUB_REF##*/}" >> $GITHUB_ENV - name: Merge firmware artifact packages - uses: actions/upload-artifact/merge@v4 + uses: actions/upload-artifact/merge@v7 with: name: "${{ env.artifact_name }}" pattern: firmware-* diff --git a/.github/workflows/linux_cpn.yml b/.github/workflows/linux_cpn.yml index 5798782be9a..775d9b9d293 100644 --- a/.github/workflows/linux_cpn.yml +++ b/.github/workflows/linux_cpn.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive diff --git a/.github/workflows/macosx_cpn.yml b/.github/workflows/macosx_cpn.yml index 81732e5fb45..6be4e061d8f 100644 --- a/.github/workflows/macosx_cpn.yml +++ b/.github/workflows/macosx_cpn.yml @@ -21,12 +21,12 @@ jobs: xcode-version: '16.0' - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive - name: Setup Python 3.12 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.12' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 327b100f93b..b71d1118dad 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -46,7 +46,7 @@ jobs: - ${{ github.workspace }}:/src steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive @@ -77,12 +77,12 @@ jobs: contents: write steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 # Need full history for changelog - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: edgetx-firmware-nightly-* path: edgetx-firmware-nightly @@ -123,7 +123,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Deploy nightly release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: nightly prerelease: true diff --git a/.github/workflows/sync_repos.yml b/.github/workflows/sync_repos.yml index d6800048bb7..d35b7f61fe4 100644 --- a/.github/workflows/sync_repos.yml +++ b/.github/workflows/sync_repos.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.SYNC_PAT }} diff --git a/.github/workflows/validate_fw_json.yml b/.github/workflows/validate_fw_json.yml index dac63c1e653..674fca98093 100644 --- a/.github/workflows/validate_fw_json.yml +++ b/.github/workflows/validate_fw_json.yml @@ -22,7 +22,7 @@ jobs: name: Validate fw.json file runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Validate fw.json syntax and schema run: python3 tools/validate-json.py --syntax-only diff --git a/.github/workflows/win_cpn-64.yml b/.github/workflows/win_cpn-64.yml index 3f7a933599a..1aec71bfeef 100644 --- a/.github/workflows/win_cpn-64.yml +++ b/.github/workflows/win_cpn-64.yml @@ -24,12 +24,12 @@ jobs: steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive - name: Setup Python 3.12 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.12' From b8e72dbca50a0eb317015508783bc7c19ccc6aab Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:27:37 +0000 Subject: [PATCH 32/55] feat(radio): allow disabling USB charge while the radio is running (on compatible hardware) (#7564) Squashed from the 2.12-targeted branch 3djc/usb-charge-control rather than cherry-picked from main, as RadioData bitfield allocation and the Companion general setup UI have diverged between the branches. Equivalent to commit a51946c4e22e551c1d7bfdbfacf3c877a2884480 on main. --- .../firmwares/edgetx/yaml_generalsettings.cpp | 2 ++ companion/src/firmwares/generalsettings.h | 1 + companion/src/generaledit/generalsetup.cpp | 19 +++++++++++ companion/src/generaledit/generalsetup.h | 1 + companion/src/generaledit/generalsetup.ui | 32 +++++++++++++------ radio/src/boards/rm-h750/board.h | 4 +++ .../src/boards/rm-h750/usb_charger_driver.cpp | 17 +++++++++- radio/src/datastructs_private.h | 7 ++++ radio/src/gui/colorlcd/radio/radio_setup.cpp | 17 ++++++++++ radio/src/main.cpp | 8 +++++ .../storage/yaml/yaml_datastructs_gx15.cpp | 3 +- .../storage/yaml/yaml_datastructs_tx15.cpp | 3 +- .../yaml/yaml_datastructs_tx16smk3.cpp | 3 +- radio/src/targets/gx15/CMakeLists.txt | 1 + radio/src/targets/simu/led_driver.cpp | 3 ++ radio/src/targets/tx15/CMakeLists.txt | 1 + radio/src/targets/tx16smk3/CMakeLists.txt | 1 + radio/src/translations/i18n/cn.h | 1 + radio/src/translations/i18n/cz.h | 1 + radio/src/translations/i18n/da.h | 1 + radio/src/translations/i18n/de.h | 1 + radio/src/translations/i18n/en.h | 1 + radio/src/translations/i18n/es.h | 1 + radio/src/translations/i18n/fi.h | 1 + radio/src/translations/i18n/fr.h | 1 + radio/src/translations/i18n/he.h | 1 + radio/src/translations/i18n/it.h | 1 + radio/src/translations/i18n/jp.h | 1 + radio/src/translations/i18n/ko.h | 1 + radio/src/translations/i18n/nl.h | 1 + radio/src/translations/i18n/pl.h | 1 + radio/src/translations/i18n/pt.h | 1 + radio/src/translations/i18n/ru.h | 1 + radio/src/translations/i18n/se.h | 1 + radio/src/translations/i18n/tw.h | 1 + radio/src/translations/i18n/ua.h | 1 + radio/src/translations/sim_string_list.h | 1 + radio/src/translations/string_list.h | 1 + 38 files changed, 131 insertions(+), 13 deletions(-) diff --git a/companion/src/firmwares/edgetx/yaml_generalsettings.cpp b/companion/src/firmwares/edgetx/yaml_generalsettings.cpp index bbf58c7c206..59fcefa7bbb 100644 --- a/companion/src/firmwares/edgetx/yaml_generalsettings.cpp +++ b/companion/src/firmwares/edgetx/yaml_generalsettings.cpp @@ -253,6 +253,7 @@ Node convert::encode(const GeneralSettings& rhs) node["disableRssiPoweroffAlarm"] = (int)rhs.disableRssiPoweroffAlarm; node["disableTrainerPoweroffAlarm"] = (int)rhs.disableTrainerPoweroffAlarm; node["USBMode"] = rhs.usbMode; + node["usbChargeDisabled"] = (int)rhs.usbChargeDisabled; node["hatsMode"] = hatsModeLut << rhs.hatsMode; node["stickDeadZone"] = rhs.stickDeadZone; node["jackMode"] = rhs.jackMode; @@ -524,6 +525,7 @@ bool convert::decode(const Node& node, GeneralSettings& rhs) node["disableRssiPoweroffAlarm"] >> rhs.disableRssiPoweroffAlarm; node["disableTrainerPoweroffAlarm"] >> rhs.disableTrainerPoweroffAlarm; node["USBMode"] >> rhs.usbMode; + node["usbChargeDisabled"] >> rhs.usbChargeDisabled; node["hatsMode"] >> hatsModeLut >> rhs.hatsMode; node["stickDeadZone"] >> rhs.stickDeadZone; node["jackMode"] >> rhs.jackMode; diff --git a/companion/src/firmwares/generalsettings.h b/companion/src/firmwares/generalsettings.h index c36fefe19b3..3dbe6464468 100644 --- a/companion/src/firmwares/generalsettings.h +++ b/companion/src/firmwares/generalsettings.h @@ -318,6 +318,7 @@ class GeneralSettings { bool disableRssiPoweroffAlarm; bool disableTrainerPoweroffAlarm; unsigned int usbMode; + bool usbChargeDisabled; unsigned int hatsMode; unsigned int stickDeadZone; unsigned int jackMode; diff --git a/companion/src/generaledit/generalsetup.cpp b/companion/src/generaledit/generalsetup.cpp index 35b3640a92a..695d6ccd11e 100644 --- a/companion/src/generaledit/generalsetup.cpp +++ b/companion/src/generaledit/generalsetup.cpp @@ -185,6 +185,17 @@ ui(new Ui::GeneralSetup) ui->usbModeCB->hide(); } + // "Charge while radio on" is only available on RadioMaster radios + // that expose the charger-enable pin (rm-h750 based targets). + if (IS_RADIOMASTER_TX16SMK3(board) || IS_RADIOMASTER_TX15(board) || + IS_RADIOMASTER_GX15(board)) { + ui->usbChargeChkB->setChecked(!generalSettings.usbChargeDisabled); // Default is zero=checked + } + else { + ui->usbChargeLabel->hide(); + ui->usbChargeChkB->hide(); + } + if (IS_FLYSKY_EL18(board) || IS_FLYSKY_NV14(board) || IS_FAMILY_PL18(board)) { ui->hatsModeCB->setModel(panelFilteredModels->getItemModel(FIM_HATSMODE)); ui->hatsModeCB->setField(generalSettings.hatsMode, this); @@ -536,6 +547,14 @@ void GeneralSetupPanel::on_usbModeCB_currentIndexChanged(int index) } } +void GeneralSetupPanel::on_usbChargeChkB_stateChanged(int) +{ + if (!lock) { + generalSettings.usbChargeDisabled = !ui->usbChargeChkB->isChecked(); + emit modified(); + } +} + void GeneralSetupPanel::on_jackModeCB_currentIndexChanged(int index) { if (!lock) { diff --git a/companion/src/generaledit/generalsetup.h b/companion/src/generaledit/generalsetup.h index d0d3249122c..55ed9b43625 100644 --- a/companion/src/generaledit/generalsetup.h +++ b/companion/src/generaledit/generalsetup.h @@ -62,6 +62,7 @@ class GeneralSetupPanel : public GeneralPanel void on_hapticLengthCB_currentIndexChanged(int index); void on_backlightswCB_currentIndexChanged(int index); void on_usbModeCB_currentIndexChanged(int index); + void on_usbChargeChkB_stateChanged(int); void on_jackModeCB_currentIndexChanged(int index); void on_backlightColor_SL_valueChanged(); void on_mavbaud_CB_currentIndexChanged(int index); diff --git a/companion/src/generaledit/generalsetup.ui b/companion/src/generaledit/generalsetup.ui index 3583613fc54..fbe9c63a610 100644 --- a/companion/src/generaledit/generalsetup.ui +++ b/companion/src/generaledit/generalsetup.ui @@ -1224,6 +1224,20 @@ + + + + Charge while radio on + + + + + + + + + + @@ -1301,7 +1315,7 @@ - + PPM Units @@ -1456,7 +1470,7 @@ Acceptable values are 3v..12v - + @@ -1611,7 +1625,7 @@ p, li { white-space: pre-wrap; } - + 0.-- @@ -1719,7 +1733,7 @@ p, li { white-space: pre-wrap; } - + Qt::Orientation::Vertical @@ -1978,7 +1992,7 @@ p, li { white-space: pre-wrap; } - + Jack Mode @@ -2449,14 +2463,14 @@ Mode 4: - + Hats Mode - + @@ -2531,14 +2545,14 @@ Mode 4: - + Key lock - + diff --git a/radio/src/boards/rm-h750/board.h b/radio/src/boards/rm-h750/board.h index 46f4ef02f46..a987c59600c 100644 --- a/radio/src/boards/rm-h750/board.h +++ b/radio/src/boards/rm-h750/board.h @@ -241,4 +241,8 @@ void usbChargerInit(); bool usbChargerLed(); #endif +#if defined(USB_CHARGE_CONTROL) +void usbChargerEnableCharge(bool enable); +#endif + #endif // _BOARD_H_ diff --git a/radio/src/boards/rm-h750/usb_charger_driver.cpp b/radio/src/boards/rm-h750/usb_charger_driver.cpp index d733bb290fa..3e45e0757ab 100644 --- a/radio/src/boards/rm-h750/usb_charger_driver.cpp +++ b/radio/src/boards/rm-h750/usb_charger_driver.cpp @@ -25,7 +25,15 @@ #include "board.h" #include "hal/usb_driver.h" -//TODO: charger enable control +#if defined(USB_CHARGE_CONTROL) +void usbChargerEnableCharge(bool enable) +{ + if (enable) + gpio_clear(UCHARGER_EN_GPIO); + else + gpio_set(UCHARGER_EN_GPIO); +} +#endif void usbChargerInit() { @@ -36,6 +44,13 @@ void usbChargerInit() bool usbChargerLed() { +#if defined(USB_CHARGE_CONTROL) + // When charging is disabled via the "Charge while radio on" setting, + // the charger-enable pin is driven high to hold the charger off, so it is + // not charging. + if (gpio_read(UCHARGER_EN_GPIO)) + return false; +#endif #if defined UCHARGER_INVERTED return (!gpio_read(UCHARGER_GPIO) && usbPlugged()); #else diff --git a/radio/src/datastructs_private.h b/radio/src/datastructs_private.h index 3fa6b75692c..7beb924003c 100644 --- a/radio/src/datastructs_private.h +++ b/radio/src/datastructs_private.h @@ -1162,7 +1162,14 @@ PACK(struct RadioData { NOBACKUP(uint8_t keyLockEnabled:1); #if defined(COLORLCD) +#if defined(USB_CHARGE_CONTROL) + // 0 = charge while USB active (default), 1 = disable charging (drive + // UCHARGER_EN_GPIO high) when USB is plugged in SD/Joystick/VCP mode + NOBACKUP(uint8_t usbChargeDisabled:1); + NOBACKUP(uint8_t spare:2 SKIP); +#else NOBACKUP(uint8_t spare:3 SKIP); +#endif #elif LCD_W == 128 uint8_t invertLCD:1; // Invert B&W LCD display #else diff --git a/radio/src/gui/colorlcd/radio/radio_setup.cpp b/radio/src/gui/colorlcd/radio/radio_setup.cpp index 5b46f6d9c71..bfadd2ff0be 100644 --- a/radio/src/gui/colorlcd/radio/radio_setup.cpp +++ b/radio/src/gui/colorlcd/radio/radio_setup.cpp @@ -922,6 +922,23 @@ static SetupLineDef setupLines[] = { GET_SET_DEFAULT(g_eeGeneral.USBMode)); } }, +#if defined(USB_CHARGE_CONTROL) + { + // Charge while radio on + STR_DEF(STR_USB_CHARGE), + [](Window* parent, coord_t x, coord_t y) { + new ToggleSwitch(parent, {x, y, 0, 0}, + GET_INVERTED(g_eeGeneral.usbChargeDisabled), + [](uint8_t newValue) { + g_eeGeneral.usbChargeDisabled = !newValue; + SET_DIRTY(); + // take effect now, not only on the next plug + if (usbPlugged()) + usbChargerEnableCharge(!g_eeGeneral.usbChargeDisabled); + }); + } + }, +#endif #if defined(ROTARY_ENCODER_NAVIGATION) && !defined(USE_HATS_AS_KEYS) { STR_DEF(STR_ROTARY_ENC_MODE), diff --git a/radio/src/main.cpp b/radio/src/main.cpp index 7ae1b24812e..71d80c11190 100644 --- a/radio/src/main.cpp +++ b/radio/src/main.cpp @@ -153,10 +153,18 @@ void handleUsbConnection() TRACE("USB unplugged"); closeUsbMenu(); _pluggedUsb = false; +#if defined(USB_CHARGE_CONTROL) + usbChargerEnableCharge(true); +#endif } else if (!_pluggedUsb && usbPlugged()) { TRACE("USB plugged"); _pluggedUsb = true; _usbDisabled = false; +#if defined(USB_CHARGE_CONTROL) + // Apply on plug, not on usbStart(): the mode popup can sit open for a long + // time and the radio would charge until a mode is picked + usbChargerEnableCharge(!g_eeGeneral.usbChargeDisabled); +#endif } if (!_usbDisabled && !usbStarted() && usbPlugged()) { diff --git a/radio/src/storage/yaml/yaml_datastructs_gx15.cpp b/radio/src/storage/yaml/yaml_datastructs_gx15.cpp index 253887ecec5..a2531d0f9b7 100644 --- a/radio/src/storage/yaml/yaml_datastructs_gx15.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_gx15.cpp @@ -472,7 +472,8 @@ static const struct YamlNode struct_RadioData[] = { YAML_UNSIGNED( "modelQuickSelect", 1 ), YAML_UNSIGNED( "oneLogPerDay", 1 ), YAML_UNSIGNED( "keyLockEnabled", 1 ), - YAML_PADDING( 3 ), + YAML_UNSIGNED( "usbChargeDisabled", 1 ), + YAML_PADDING( 2 ), YAML_UNSIGNED( "pwrOffIfInactive", 8 ), YAML_ARRAY("keyShortcuts", 8, 6, struct_QuickMenuPage, NULL), YAML_ARRAY("qmFavorites", 8, 12, struct_QuickMenuPage, NULL), diff --git a/radio/src/storage/yaml/yaml_datastructs_tx15.cpp b/radio/src/storage/yaml/yaml_datastructs_tx15.cpp index 253887ecec5..a2531d0f9b7 100644 --- a/radio/src/storage/yaml/yaml_datastructs_tx15.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_tx15.cpp @@ -472,7 +472,8 @@ static const struct YamlNode struct_RadioData[] = { YAML_UNSIGNED( "modelQuickSelect", 1 ), YAML_UNSIGNED( "oneLogPerDay", 1 ), YAML_UNSIGNED( "keyLockEnabled", 1 ), - YAML_PADDING( 3 ), + YAML_UNSIGNED( "usbChargeDisabled", 1 ), + YAML_PADDING( 2 ), YAML_UNSIGNED( "pwrOffIfInactive", 8 ), YAML_ARRAY("keyShortcuts", 8, 6, struct_QuickMenuPage, NULL), YAML_ARRAY("qmFavorites", 8, 12, struct_QuickMenuPage, NULL), diff --git a/radio/src/storage/yaml/yaml_datastructs_tx16smk3.cpp b/radio/src/storage/yaml/yaml_datastructs_tx16smk3.cpp index c083b188376..72e85c373a5 100644 --- a/radio/src/storage/yaml/yaml_datastructs_tx16smk3.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_tx16smk3.cpp @@ -473,7 +473,8 @@ static const struct YamlNode struct_RadioData[] = { YAML_UNSIGNED( "modelQuickSelect", 1 ), YAML_UNSIGNED( "oneLogPerDay", 1 ), YAML_UNSIGNED( "keyLockEnabled", 1 ), - YAML_PADDING( 3 ), + YAML_UNSIGNED( "usbChargeDisabled", 1 ), + YAML_PADDING( 2 ), YAML_UNSIGNED( "pwrOffIfInactive", 8 ), YAML_ARRAY("keyShortcuts", 8, 6, struct_QuickMenuPage, NULL), YAML_ARRAY("qmFavorites", 8, 12, struct_QuickMenuPage, NULL), diff --git a/radio/src/targets/gx15/CMakeLists.txt b/radio/src/targets/gx15/CMakeLists.txt index 7ff18a8818e..73f0cb77b3f 100644 --- a/radio/src/targets/gx15/CMakeLists.txt +++ b/radio/src/targets/gx15/CMakeLists.txt @@ -47,6 +47,7 @@ add_definitions(-DFIRMWARE_QSPI) add_definitions(-DFIRMWARE_FORMAT_UF2) add_definitions(-DUSE_CUSTOM_EXTI_IRQ) add_definitions(-DUSB_CHARGER) +add_definitions(-DUSB_CHARGE_CONTROL) set(FLAVOUR gx15) add_definitions(-DRADIO_GX15) diff --git a/radio/src/targets/simu/led_driver.cpp b/radio/src/targets/simu/led_driver.cpp index 645515182a4..0e553550b59 100644 --- a/radio/src/targets/simu/led_driver.cpp +++ b/radio/src/targets/simu/led_driver.cpp @@ -24,6 +24,9 @@ #include "definitions.h" bool usbChargerLed() { return true; } +#if defined(USB_CHARGE_CONTROL) +void usbChargerEnableCharge(bool enable) {} +#endif void ledRed() {} void ledGreen() {} void ledBlue() {} diff --git a/radio/src/targets/tx15/CMakeLists.txt b/radio/src/targets/tx15/CMakeLists.txt index d1a8025b1fc..74c0f7dbd4f 100644 --- a/radio/src/targets/tx15/CMakeLists.txt +++ b/radio/src/targets/tx15/CMakeLists.txt @@ -57,6 +57,7 @@ add_definitions(-DFIRMWARE_QSPI) add_definitions(-DFIRMWARE_FORMAT_UF2) add_definitions(-DUSE_CUSTOM_EXTI_IRQ) add_definitions(-DUSB_CHARGER) +add_definitions(-DUSB_CHARGE_CONTROL) set(FLAVOUR tx15) add_definitions(-DRADIO_TX15) diff --git a/radio/src/targets/tx16smk3/CMakeLists.txt b/radio/src/targets/tx16smk3/CMakeLists.txt index 8b118785493..93b2922ad7b 100644 --- a/radio/src/targets/tx16smk3/CMakeLists.txt +++ b/radio/src/targets/tx16smk3/CMakeLists.txt @@ -60,6 +60,7 @@ add_definitions(-DFIRMWARE_QSPI) add_definitions(-DFIRMWARE_FORMAT_UF2) add_definitions(-DUSE_CUSTOM_EXTI_IRQ) add_definitions(-DUSB_CHARGER) +add_definitions(-DUSB_CHARGE_CONTROL) add_definitions(-DLUMINOSITY_SENSOR) set(FLAVOUR tx16smk3) diff --git a/radio/src/translations/i18n/cn.h b/radio/src/translations/i18n/cn.h index 5e5d5acc369..ba6a1866492 100644 --- a/radio/src/translations/i18n/cn.h +++ b/radio/src/translations/i18n/cn.h @@ -986,6 +986,7 @@ #define TR_SENSOR "传感器" #define TR_COUNTRY_CODE "国别码" #define TR_USBMODE "USB模式" +#define TR_USB_CHARGE "开机时充电" #define TR_JACK_MODE "教练插口模式" #define TR_VOICE_LANGUAGE "播报语言" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/cz.h b/radio/src/translations/i18n/cz.h index 1b3de2c4e7c..31639cccbc2 100644 --- a/radio/src/translations/i18n/cz.h +++ b/radio/src/translations/i18n/cz.h @@ -983,6 +983,7 @@ #define TR_SENSOR "SENZOR" #define TR_COUNTRY_CODE "Kód regionu" #define TR_USBMODE "Režim USB" +#define TR_USB_CHARGE "Nabíjení za chodu" #define TR_JACK_MODE "Režim Jack" #define TR_VOICE_LANGUAGE "Jazyk hlasu" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/da.h b/radio/src/translations/i18n/da.h index 04f4e12d9b1..5883e4f8a63 100644 --- a/radio/src/translations/i18n/da.h +++ b/radio/src/translations/i18n/da.h @@ -991,6 +991,7 @@ #define TR_SENSOR "SENSOR" #define TR_COUNTRY_CODE "Lande kode" #define TR_USBMODE "USB tilstand" +#define TR_USB_CHARGE "Oplad med radio tændt" #define TR_JACK_MODE "Jack tilstand" #define TR_VOICE_LANGUAGE "Stemme sprog" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/de.h b/radio/src/translations/i18n/de.h index a1da68348cf..523744eeb59 100644 --- a/radio/src/translations/i18n/de.h +++ b/radio/src/translations/i18n/de.h @@ -985,6 +985,7 @@ #define TR_SENSOR TR_BW_COL("SENSOR", "Sensor") #define TR_COUNTRY_CODE "Landescode" #define TR_USBMODE "USB Modus" +#define TR_USB_CHARGE "Laden wenn Sender an" #define TR_JACK_MODE "Klinken-Modus" #define TR_VOICE_LANGUAGE "Sprachansagen" #define TR_TEXT_LANGUAGE "Textsprache" diff --git a/radio/src/translations/i18n/en.h b/radio/src/translations/i18n/en.h index 8584902a482..3871b8a8225 100644 --- a/radio/src/translations/i18n/en.h +++ b/radio/src/translations/i18n/en.h @@ -987,6 +987,7 @@ #define TR_SENSOR "SENSOR" #define TR_COUNTRY_CODE "Country code" #define TR_USBMODE "USB mode" +#define TR_USB_CHARGE "Charge while radio on" #define TR_JACK_MODE "Jack mode" #define TR_VOICE_LANGUAGE "Voice language" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/es.h b/radio/src/translations/i18n/es.h index 0a536e302a0..5ee42f63830 100644 --- a/radio/src/translations/i18n/es.h +++ b/radio/src/translations/i18n/es.h @@ -979,6 +979,7 @@ #define TR_SENSOR "SENSOR" #define TR_COUNTRY_CODE "Código país" #define TR_USBMODE "Modo USB" +#define TR_USB_CHARGE "Cargar con radio ON" #define TR_JACK_MODE "Modo Jack" #define TR_VOICE_LANGUAGE "Idioma voces" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/fi.h b/radio/src/translations/i18n/fi.h index 649a4181563..8173741c13f 100644 --- a/radio/src/translations/i18n/fi.h +++ b/radio/src/translations/i18n/fi.h @@ -979,6 +979,7 @@ #define TR_SENSOR "SENSORI" #define TR_COUNTRY_CODE "Maatunnus" #define TR_USBMODE "USB-tila" +#define TR_USB_CHARGE "Lataa radio päällä" #define TR_JACK_MODE "Jack Mode" #define TR_VOICE_LANGUAGE "Äänen kieli" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/fr.h b/radio/src/translations/i18n/fr.h index 4e375b58262..20d8b0d6f0c 100644 --- a/radio/src/translations/i18n/fr.h +++ b/radio/src/translations/i18n/fr.h @@ -983,6 +983,7 @@ #define TR_SENSOR "CAPTEUR" #define TR_COUNTRY_CODE TR("Zone géo.", "Zone géographique") #define TR_USBMODE "Mode USB" +#define TR_USB_CHARGE "Charge radio allumée" #define TR_JACK_MODE "Mode Jack" #define TR_VOICE_LANGUAGE TR("Langue voix", "Langue annonces vocales") #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/he.h b/radio/src/translations/i18n/he.h index 05c06289dac..bc6fe414b7e 100644 --- a/radio/src/translations/i18n/he.h +++ b/radio/src/translations/i18n/he.h @@ -989,6 +989,7 @@ #define TR_SENSOR "חיישן" #define TR_COUNTRY_CODE "קוד ארץ" #define TR_USBMODE "USB מצב" +#define TR_USB_CHARGE "טעינה כאשר השלט פועל" #define TR_JACK_MODE "Jack mode" #define TR_VOICE_LANGUAGE "שפת שמע" #define TR_TEXT_LANGUAGE "שפת הטקסט" diff --git a/radio/src/translations/i18n/it.h b/radio/src/translations/i18n/it.h index 01b795ced4a..3d09308b611 100644 --- a/radio/src/translations/i18n/it.h +++ b/radio/src/translations/i18n/it.h @@ -990,6 +990,7 @@ #define TR_SENSOR "SENSORE" #define TR_COUNTRY_CODE TR("Cod. Paese", "Codice del Paese") #define TR_USBMODE "Modo USB" +#define TR_USB_CHARGE "Carica con radio accesa" #define TR_JACK_MODE "Modo JACK" #define TR_VOICE_LANGUAGE "Lingua vocale" #define TR_TEXT_LANGUAGE "Lingua testo" diff --git a/radio/src/translations/i18n/jp.h b/radio/src/translations/i18n/jp.h index f8672f6df22..0be75883b0a 100644 --- a/radio/src/translations/i18n/jp.h +++ b/radio/src/translations/i18n/jp.h @@ -985,6 +985,7 @@ #define TR_SENSOR "センサー" #define TR_COUNTRY_CODE "カントリーコード" #define TR_USBMODE "USBモード" +#define TR_USB_CHARGE "電源オン時に充電" #define TR_JACK_MODE "Jackモード" #define TR_VOICE_LANGUAGE "音声言語" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/ko.h b/radio/src/translations/i18n/ko.h index 1e6eaa7eb66..2e39b382ed0 100644 --- a/radio/src/translations/i18n/ko.h +++ b/radio/src/translations/i18n/ko.h @@ -1030,6 +1030,7 @@ #define TR_SENSOR "센서" #define TR_COUNTRY_CODE "국가 코드" #define TR_USBMODE "USB 모드" +#define TR_USB_CHARGE "전원 켜짐 시 충전" #define TR_JACK_MODE "잭 모드" #define TR_VOICE_LANGUAGE "음성 언어" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/nl.h b/radio/src/translations/i18n/nl.h index 6b1f42b62d0..8063bdf010d 100644 --- a/radio/src/translations/i18n/nl.h +++ b/radio/src/translations/i18n/nl.h @@ -981,6 +981,7 @@ #define TR_SENSOR "SENSOR" #define TR_COUNTRY_CODE "Landcode" #define TR_USBMODE "USB Mode" +#define TR_USB_CHARGE "Laden als zender aan" #define TR_JACK_MODE "Jack Mode" #define TR_VOICE_LANGUAGE "Taal" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/pl.h b/radio/src/translations/i18n/pl.h index 80f1075f28b..9ff0fce5aa0 100644 --- a/radio/src/translations/i18n/pl.h +++ b/radio/src/translations/i18n/pl.h @@ -978,6 +978,7 @@ #define TR_SENSOR "SENSOR" #define TR_COUNTRY_CODE "Kod regionu" #define TR_USBMODE "Tryb USB" +#define TR_USB_CHARGE "Ładuj gdy radio włączone" #define TR_JACK_MODE "Tryb Jack" #define TR_VOICE_LANGUAGE "Język głosu" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/pt.h b/radio/src/translations/i18n/pt.h index cf78110de46..514b88483d2 100644 --- a/radio/src/translations/i18n/pt.h +++ b/radio/src/translations/i18n/pt.h @@ -986,6 +986,7 @@ #define TR_SENSOR "SENSOR" #define TR_COUNTRY_CODE "Código pais" #define TR_USBMODE "Modo USB" +#define TR_USB_CHARGE "Carregar com rádio ON" #define TR_JACK_MODE "Jack mode" #define TR_VOICE_LANGUAGE "Idoma da Voz" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/ru.h b/radio/src/translations/i18n/ru.h index 4181ec4e083..bef8b02d5b2 100644 --- a/radio/src/translations/i18n/ru.h +++ b/radio/src/translations/i18n/ru.h @@ -988,6 +988,7 @@ #define TR_SENSOR "СЕНСОР" #define TR_COUNTRY_CODE "Код страны" #define TR_USBMODE "Режим USB" +#define TR_USB_CHARGE "Зарядка при вкл. пульте" #define TR_JACK_MODE "Режим разъема" #define TR_VOICE_LANGUAGE "Язык озвучки" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/se.h b/radio/src/translations/i18n/se.h index 75c2a3bc8eb..ac524a162f9 100644 --- a/radio/src/translations/i18n/se.h +++ b/radio/src/translations/i18n/se.h @@ -990,6 +990,7 @@ #define TR_SENSOR "SENSOR" #define TR_COUNTRY_CODE "Landskod" #define TR_USBMODE "USB-läge" +#define TR_USB_CHARGE "Ladda när radion är på" #define TR_JACK_MODE "Uttagsläge" #define TR_VOICE_LANGUAGE "Röstspråk" #define TR_TEXT_LANGUAGE "Textspråk" diff --git a/radio/src/translations/i18n/tw.h b/radio/src/translations/i18n/tw.h index 85ec2c48afb..052a0ccc0a1 100644 --- a/radio/src/translations/i18n/tw.h +++ b/radio/src/translations/i18n/tw.h @@ -983,6 +983,7 @@ #define TR_SENSOR "傳感器" #define TR_COUNTRY_CODE "國別碼" #define TR_USBMODE "USB模式" +#define TR_USB_CHARGE "開機時充電" #define TR_JACK_MODE "教練插口模式" #define TR_VOICE_LANGUAGE "播報語言" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/i18n/ua.h b/radio/src/translations/i18n/ua.h index 8509ee7df59..b021986ca72 100644 --- a/radio/src/translations/i18n/ua.h +++ b/radio/src/translations/i18n/ua.h @@ -987,6 +987,7 @@ #define TR_SENSOR "СЕНСОР" #define TR_COUNTRY_CODE "Код країни" #define TR_USBMODE "Режим USB" +#define TR_USB_CHARGE "Заряджати коли увімкнено" #define TR_JACK_MODE "Режим коннектора" #define TR_VOICE_LANGUAGE "Мова голосу" #define TR_TEXT_LANGUAGE "Text language" diff --git a/radio/src/translations/sim_string_list.h b/radio/src/translations/sim_string_list.h index aebf2ed43a3..fdaaa1c5391 100644 --- a/radio/src/translations/sim_string_list.h +++ b/radio/src/translations/sim_string_list.h @@ -1038,6 +1038,7 @@ #define STR_UPDATE_RX_OPTIONS currentLangStrings->STR_UPDATE_RX_OPTIONS #define STR_UPDATE_TX_OPTIONS currentLangStrings->STR_UPDATE_TX_OPTIONS #define STR_US currentLangStrings->STR_US +#define STR_USB_CHARGE currentLangStrings->STR_USB_CHARGE #define STR_USB_JOYSTICK currentLangStrings->STR_USB_JOYSTICK #define STR_USB_MASS_STORAGE currentLangStrings->STR_USB_MASS_STORAGE #define STR_USB_SERIAL currentLangStrings->STR_USB_SERIAL diff --git a/radio/src/translations/string_list.h b/radio/src/translations/string_list.h index dbb01b0d941..0ebecbbab6e 100644 --- a/radio/src/translations/string_list.h +++ b/radio/src/translations/string_list.h @@ -1042,6 +1042,7 @@ STR(UNSUPPORTED_RX) STR(UPDATE_RX_OPTIONS) STR(UPDATE_TX_OPTIONS) STR(US) +STR(USB_CHARGE) STR(USB_JOYSTICK) STR(USB_MASS_STORAGE) STR(USB_SERIAL) From ab30180b11eab3645766fe4369eb7b7d23be4796 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:44:37 +0000 Subject: [PATCH 33/55] fix(color): sign-compare warning Regression introduced in #6998 --- radio/src/gui/colorlcd/model/model_outputs.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radio/src/gui/colorlcd/model/model_outputs.cpp b/radio/src/gui/colorlcd/model/model_outputs.cpp index 09dab83d224..ab3b055705d 100644 --- a/radio/src/gui/colorlcd/model/model_outputs.cpp +++ b/radio/src/gui/colorlcd/model/model_outputs.cpp @@ -231,7 +231,7 @@ void ModelOutputsPage::build(Window* window) STR_TRIMS2OFFSETS, STR_ADD_ALL_TRIMS_TO_SUBTRIMS, [=] { moveTrimsToOffsets(); - for (int i = 0; i < outputButtons.size(); i += 1) + for (size_t i = 0; i < outputButtons.size(); i += 1) outputButtons[i]->refresh(); }); return 0; From cfb250e2374ac8e581fa2d5e99e7664b7fe68319 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:24:55 +0000 Subject: [PATCH 34/55] fix(cli): drop dead spModuleInit/DeInit compiled on EXTERNAL_MODULE-only targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal/external module OR-guard was added in #5703 without a matching case for HARDWARE_EXTERNAL_MODULE-only targets (pl18/pl18ev/pl18u) in cliSerialPassthrough's switch — the functions were compiled but never called there, producing -Wunused-function warnings. Serial passthrough over an external module isn't implemented yet, so narrow the guard to match the only call site. Co-Authored-By: Claude Sonnet 5 --- radio/src/cli.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radio/src/cli.cpp b/radio/src/cli.cpp index e098a1766cf..ad3323afa0d 100644 --- a/radio/src/cli.cpp +++ b/radio/src/cli.cpp @@ -1233,7 +1233,7 @@ static void _sp_Tx(uint8_t* buf, uint32_t len) } } -#if defined(HARDWARE_INTERNAL_MODULE) || defined(HARDWARE_EXTERNAL_MODULE) +#if defined(HARDWARE_INTERNAL_MODULE) static etx_module_state_t *spModuleState = nullptr; static void spModuleInit(int port_n, int baudrate) From 9ebb9dffa45b8e98d2b5c0b539257fd36afbb8fd Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:25:09 +0000 Subject: [PATCH 35/55] fix(keys): guard key-lock combo state behind KEYS_LOCK_KEY1/2 s_keys_lock_combo_cnt was declared unconditionally under #if !defined(BOOT) (from #7561), but only read/written inside the block further down that's guarded by defined(KEYS_LOCK_KEY1) && defined(KEYS_LOCK_KEY2). Targets without that key combo defined in hal.h (nb4p, nv14, el18, pa01, pl18-family, v12/v14/v16) got a -Wunused-variable warning. Widen the declaration's guard to match, mirroring the same fix already on main via #7565. Co-Authored-By: Claude Sonnet 5 --- radio/src/keys.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radio/src/keys.cpp b/radio/src/keys.cpp index 36a2fe7dce7..acfd564a7ea 100644 --- a/radio/src/keys.cpp +++ b/radio/src/keys.cpp @@ -183,7 +183,7 @@ void Key::killEvents() static Key keys[MAX_KEYS]; static Key trim_keys[MAX_TRIMS * 2]; -#if !defined(BOOT) +#if !defined(BOOT) && defined(KEYS_LOCK_KEY1) && defined(KEYS_LOCK_KEY2) // Per-target key combo (from hal.h KEYS_LOCK_KEY1/KEY2) long-pressed together // toggles a software key lock. static bool s_keys_locked = false; @@ -541,7 +541,7 @@ bool keysPollingCycle() event_t evt = keys[i].input(keys_input & (1 << i)); if (evt) { evt = keyMapping(evt | i); -#if !defined(BOOT) +#if !defined(BOOT) && defined(KEYS_LOCK_KEY1) && defined(KEYS_LOCK_KEY2) if (evt) { if (!s_keys_locked) { pushEvent(evt); From ad05ce89f8ce1493c8853ae7113dc4b3b9793caa Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:25:43 +0000 Subject: [PATCH 36/55] fix(lua): avoid always-true BLING_LED_STRIP_LENGTH comparison on bling-less targets Introduced in #6095: on targets with only a CFS LED strip and no bling strip (BLING_LED_STRIP_LENGTH == 0, e.g. gx12/mt12/tx12mk2/boxer sharing the RADIO_GX12 hal config), `id >= BLING_LED_STRIP_LENGTH` is always true for the unsigned uint8_t id, triggering -Wtype-limits. Nest the bling-specific check and offset adjustment inside their own BLING_LED_STRIP_LENGTH > 0 guard, with an early return for the bling case, so bling-less targets never compile the tautological comparison at all; the CFS-handling body stays written once, shared by both paths, matching existing runtime behavior. Co-Authored-By: Claude Sonnet 5 --- radio/src/lua/api_general.cpp | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/radio/src/lua/api_general.cpp b/radio/src/lua/api_general.cpp index 31a785631e2..7256377e0fa 100644 --- a/radio/src/lua/api_general.cpp +++ b/radio/src/lua/api_general.cpp @@ -2930,18 +2930,20 @@ static int luaSetRgbLedColor(lua_State * L) uint8_t b = luaL_checkunsigned(L, 4); #if CFS_LED_STRIP_LENGTH > 0 - if (id >= BLING_LED_STRIP_LENGTH) { - id -= BLING_LED_STRIP_LENGTH; - uint8_t swIdx = switchGetSwitchFromCustomIdx(id / CFS_LEDS_PER_SWITCH); - if (g_model.getSwitchType(swIdx) == SWITCH_NONE) { - rgbSetLedColor(id + CFS_LED_STRIP_START, r, g, b); - } else { - lua_pushboolean(L, false); - return 1; - } - } else { +#if BLING_LED_STRIP_LENGTH > 0 + if (id < BLING_LED_STRIP_LENGTH) { rgbSetLedColor(id + BLING_LED_STRIP_START, r, g, b); + lua_pushboolean(L, true); + return 1; + } + id -= BLING_LED_STRIP_LENGTH; +#endif + uint8_t swIdx = switchGetSwitchFromCustomIdx(id / CFS_LEDS_PER_SWITCH); + if (g_model.getSwitchType(swIdx) != SWITCH_NONE) { + lua_pushboolean(L, false); + return 1; } + rgbSetLedColor(id + CFS_LED_STRIP_START, r, g, b); #else rgbSetLedColor(id + BLING_LED_STRIP_START, r, g, b); #endif From fd5b18efd7c8617f42d10b8db3a6b021bc58f6ee Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:25:58 +0000 Subject: [PATCH 37/55] fix(pa01): avoid always-true LED_CHARGING_START comparison in ledChargingInfo Introduced in #6392: pa01 defines LED_CHARGING_START as 0, so `i >= LED_CHARGING_START` with the loop's unsigned uint8_t i is always true, triggering -Wtype-limits. Use a plain int loop variable so the comparisons are ordinary signed comparisons instead of testing a constant. Co-Authored-By: Claude Sonnet 5 --- radio/src/targets/pa01/battery_driver.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radio/src/targets/pa01/battery_driver.cpp b/radio/src/targets/pa01/battery_driver.cpp index 27d92c067b0..ca44ac7bbd5 100644 --- a/radio/src/targets/pa01/battery_driver.cpp +++ b/radio/src/targets/pa01/battery_driver.cpp @@ -216,7 +216,7 @@ void ledChargingInfo(uint16_t chargeState) { ledIdx = LED_CHARGING_START; else ledIdx += LED_CHARGING_START > LED_CHARGING_END ? -1 : 1; - for (uint8_t i = 0; i < LED_STRIP_LENGTH; i++) { + for (int i = 0; i < LED_STRIP_LENGTH; i++) { uint8_t green = 0; if (CHARGE_FINISHED == chargeState) { if (LED_CHARGING_START > LED_CHARGING_END && i <= LED_CHARGING_START && i >= LED_CHARGING_END) From 50f8e3d49c788efa11302b2b460e2e0c10f6ed10 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:56:58 +0000 Subject: [PATCH 38/55] fix(dsmp): use MODULE_TYPE_LEMON_DSMP, not nonexistent MODULE_TYPE_DSMP isExternalModuleAvailable()'s DSMP-disabled guard referenced MODULE_TYPE_DSMP, which doesn't exist as an enum value (only MODULE_TYPE_LEMON_DSMP does). Currently dead since DSMP is always defined on STM32 targets, but would fail to compile if DSMP were ever disabled. This restores the fix from d584237890, which was accidentally reverted by 853adfd8 ("chore: formatting") before PR #7469 was squash-merged. --- radio/src/gui/gui_common.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radio/src/gui/gui_common.cpp b/radio/src/gui/gui_common.cpp index 63140e85f48..2d965d2d73c 100644 --- a/radio/src/gui/gui_common.cpp +++ b/radio/src/gui/gui_common.cpp @@ -1077,7 +1077,7 @@ bool isExternalModuleAvailable(int moduleType) #endif #if !defined(DSMP) - if (moduleType == MODULE_TYPE_DSMP) return false; + if (moduleType == MODULE_TYPE_LEMON_DSMP) return false; #endif #if !defined(SBUS) From a927b1d1292565cc989cf6a06db345fd28ad6bcb Mon Sep 17 00:00:00 2001 From: 3djc <5167938+3djc@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:17:16 +0200 Subject: [PATCH 39/55] fix(gx15): enable built in mic (#7703) (cherry picked from commit 5d69da66fead92e817600e1db44d5ede5f6f3043) --- radio/src/targets/gx15/CMakeLists.txt | 1 + radio/src/targets/gx15/hal.h | 25 +++++++++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/radio/src/targets/gx15/CMakeLists.txt b/radio/src/targets/gx15/CMakeLists.txt index 73f0cb77b3f..28f17e8f084 100644 --- a/radio/src/targets/gx15/CMakeLists.txt +++ b/radio/src/targets/gx15/CMakeLists.txt @@ -156,6 +156,7 @@ add_library(board OBJECT EXCLUDE_FROM_ALL ${BOARD_COMMON_SRC} ${BOARD_SRC_DIR}/tp_gt911.cpp ${BOARD_SRC_DIR}/audio_driver.cpp + ${BOARD_SRC_DIR}/pdm_software_driver.cpp drivers/icm42607C.cpp drivers/sc7u22.cpp drivers/tas2505.cpp diff --git a/radio/src/targets/gx15/hal.h b/radio/src/targets/gx15/hal.h index aaaea2ab84e..0a19981be2d 100644 --- a/radio/src/targets/gx15/hal.h +++ b/radio/src/targets/gx15/hal.h @@ -29,7 +29,7 @@ SDMMC uses own DMA controler DMA1 Stream0: LED_STRIP_TIMER_DMA_STREAM Stream1: INTMODULE_DMA_STREAM -Stream2: FLYSKY_HALL_DMA_Stream_RX +Stream2: PDM_CAPTURE_DMA_STREAM Stream3: TELEMETRY_DMA_Stream_RX Stream4: I2S_DMA_Stream Stream5: INTMODULE_RX_DMA_STREAM @@ -55,7 +55,7 @@ TIM5: EXTMODULE_TIMER TIM8: TRAINER_TIMER TIM12: MIXER_SCHEDULER_TIMER TIM14: MS_TIMER -TIM15: PDM +TIM15: TIM16: TIM17: ROTARY_ENCODER_TIMER */ @@ -199,10 +199,6 @@ TIM17: ROTARY_ENCODER_TIMER #define SWITCHES_GPIO_REG_J #define SWITCHES_GPIO_PIN_J PCA95XX_P3 -// MEMS -#define PDM_DATA GPIO_PIN(GPIOD, 4) -#define PDM_CLK GPIO_PIN(GPIOD, 13) - // Expanders #define IO_INT_GPIO GPIO_PIN(GPIOB, 3) #define IO_RESET_GPIO GPIO_PIN(GPIOG, 10) @@ -396,6 +392,23 @@ TIM17: ROTARY_ENCODER_TIMER #define I2S_DMA_Stream_IRQn DMA1_Stream4_IRQn #define I2S_DMA_Stream_IRQHandler DMA1_Stream4_IRQHandler +#if !defined(FLYSKY_GIMBAL) + #define PDM_CLOCK GPIO_PIN(GPIOE, 5) + #define PDM_CLOCK_GPIO_AF LL_GPIO_AF_6 + #define PDM_SAI_BLOCK SAI1_Block_A + #define PDM_SAI_KER_FREQ 48000000 // PLL1Q (see system_clock.c) + #define PDM_CLOCK_FREQ 1600000 // 1.6 MHz → MCKDIV=30, R=100 → 16 kHz PCM direct + #define PDM_DATA GPIO_PIN(GPIOE, 4) + #define PDM_DATA_GPIO_PORT GPIOE + #define PDM_DATA_GPIO_PIN 4 + #define PDM_DATA_IDR_MASK (1U << PDM_DATA_GPIO_PIN) + #define PDM_CAPTURE_DMA DMA1 + #define PDM_CAPTURE_DMA_STREAM LL_DMA_STREAM_2 + #define PDM_CAPTURE_DMA_REQUEST LL_DMAMUX1_REQ_TIM15_UP + #define PDM_CAPTURE_TIMER TIM15 + #define PDM_CAPTURE_TIMER_FREQ (PERI2_FREQUENCY * TIMER_MULT_APB2) +#endif + // I2C Bus #define I2C_B1 I2C4 #define I2C_B1_SDA_GPIO GPIO_PIN(GPIOD, 13) // PD.13 From 0c70b8b721278efda558ce41184698af0c8a1a6a Mon Sep 17 00:00:00 2001 From: 3djc <5167938+3djc@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:20:43 +0200 Subject: [PATCH 40/55] feat(radio): improve built in mic recording clarity and UI (#7704) (cherry picked from commit da6f73abcf66dbc1918e92add684ddce4fb50742) (cherry picked from commit 0c09e72adf44360772613a21bf42929399debc8b) --- .../gui/colorlcd/radio/radio_mic_recorder.cpp | 325 +++++++++++++++++- .../gui/colorlcd/radio/radio_mic_recorder.h | 19 +- radio/src/pdm_wav_recorder.cpp | 179 ++++++++-- radio/src/pdm_wav_recorder.h | 15 +- 4 files changed, 496 insertions(+), 42 deletions(-) diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp index 296c6b4c396..b71fea5271d 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp @@ -25,6 +25,7 @@ #include +#include "audio.h" #include "button.h" #include "dialog.h" #include "edgetx.h" @@ -33,9 +34,164 @@ #include "static.h" #include "timers_driver.h" + static constexpr coord_t MIC_BTN_W = 260; static constexpr coord_t MIC_BTN_H = EdgeTxStyles::UI_ELEMENT_HEIGHT * 2 + PAD_LARGE; +// Mirrored peak envelope, drawn as two polylines around a centre line. Fed one +// column at a time while recording, then re-filled from the finished file. +class WaveformView : public Window +{ + public: + static constexpr uint16_t MAX_COLS = 400; + + WaveformView(Window* parent, const rect_t& rect) : Window(parent, rect) + { + padAll(PAD_ZERO); + + // Widen the columns rather than leaving the trace short of the edge: + // 800 px screens need more than MAX_COLS columns at 2 px. + colStep = (coord_t)((width() + MAX_COLS - 1) / MAX_COLS); + if (colStep < 2) colStep = 2; + maxCols = (uint16_t)((width() - 1) / colStep + 1); + if (maxCols > MAX_COLS) maxCols = MAX_COLS; + traceW = (coord_t)((maxCols - 1) * colStep); + + const lv_coord_t mid = height() / 2; + basePts[0] = {0, mid}; + basePts[1] = {traceW, mid}; + baseLine = lv_line_create(lvobj); + etx_obj_add_style(baseLine, styles->graph_dashed, LV_PART_MAIN); + lv_line_set_points(baseLine, basePts, 2); + + topLine = lv_line_create(lvobj); + etx_obj_add_style(topLine, styles->graph_line, LV_PART_MAIN); + etx_obj_add_style(topLine, styles->line_color[COLOR_THEME_SECONDARY1_INDEX], + LV_PART_MAIN); + + botLine = lv_line_create(lvobj); + etx_obj_add_style(botLine, styles->graph_line, LV_PART_MAIN); + etx_obj_add_style(botLine, styles->line_color[COLOR_THEME_SECONDARY1_INDEX], + LV_PART_MAIN); + + cursorLine = lv_line_create(lvobj); + etx_obj_add_style(cursorLine, styles->graph_line, LV_PART_MAIN); + etx_obj_add_style(cursorLine, styles->line_color[COLOR_THEME_WARNING_INDEX], + LV_PART_MAIN); + lv_obj_add_flag(cursorLine, LV_OBJ_FLAG_HIDDEN); + + clear(); + } + + uint16_t columns() const { return maxCols; } + + void clear() + { + count = 0; + merge = 1; + acc = 0; + accCount = 0; + setCursor(-1); + redraw(); + } + + // Playback position, 0..1000 across the view. Negative hides the cursor. + void setCursor(int32_t permille) + { + if (permille < 0) { + if (cursorAt >= 0) lv_obj_add_flag(cursorLine, LV_OBJ_FLAG_HIDDEN); + cursorAt = -1; + return; + } + if (permille > 1000) permille = 1000; + + const lv_coord_t x = (lv_coord_t)((permille * traceW) / 1000); + if (cursorAt >= 0 && x == cursorPts[0].x) return; + + cursorPts[0] = {x, 0}; + cursorPts[1] = {x, (lv_coord_t)(height() - 1)}; + lv_line_set_points(cursorLine, cursorPts, 2); + lv_obj_clear_flag(cursorLine, LV_OBJ_FLAG_HIDDEN); + cursorAt = permille; + } + + // Live feed. Once the view is full, columns are merged pairwise so the whole + // take stays visible at half the time resolution. + void push(uint8_t level) + { + if (level > acc) acc = level; + if (++accCount < merge) return; + if (count >= maxCols) compact(); + cols[count++] = acc; + acc = 0; + accCount = 0; + redraw(); + } + + void setEnvelope(const uint8_t* levels, uint16_t n) + { + if (n > maxCols) n = maxCols; + memcpy(cols, levels, n); + count = n; + redraw(); + } + + protected: + coord_t colStep = 2; + coord_t traceW = 0; + uint16_t maxCols = 0; + uint16_t count = 0; + uint16_t merge = 1; + uint16_t accCount = 0; + uint8_t acc = 0; + uint8_t cols[MAX_COLS] = {0}; + + lv_point_t topPts[MAX_COLS]; + lv_point_t botPts[MAX_COLS]; + lv_point_t basePts[2]; + lv_point_t cursorPts[2]; + int32_t cursorAt = -1; + lv_obj_t* cursorLine = nullptr; + lv_obj_t* topLine = nullptr; + lv_obj_t* botLine = nullptr; + lv_obj_t* baseLine = nullptr; + + void compact() + { + const uint16_t n = count / 2; + for (uint16_t i = 0; i < n; i++) { + const uint8_t a = cols[2 * i], b = cols[2 * i + 1]; + cols[i] = a > b ? a : b; + } + count = n; + merge *= 2; + } + + void redraw() + { + if (count < 2) { + lv_obj_add_flag(topLine, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(botLine, LV_OBJ_FLAG_HIDDEN); + return; + } + + const lv_coord_t mid = height() / 2; + const int32_t half = height() / 2 - 1; + + for (uint16_t i = 0; i < count; i++) { + const lv_coord_t x = (lv_coord_t)(i * colStep); + const lv_coord_t a = (lv_coord_t)((cols[i] * half) / 255); + topPts[i] = {x, (lv_coord_t)(mid - a)}; + botPts[i] = {x, (lv_coord_t)(mid + a)}; + } + + lv_line_set_points(topLine, topPts, count); + lv_line_set_points(botLine, botPts, count); + lv_obj_clear_flag(topLine, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(botLine, LV_OBJ_FLAG_HIDDEN); + } +}; + RadioMicRecorder::RadioMicRecorder() : Page(ICON_RADIO_TOOLS) { @@ -55,6 +211,7 @@ RadioMicRecorder::~RadioMicRecorder() // on a destroyed instance. lv_async_call_cancel(&RadioMicRecorder::asyncProcessPendingRename, this); if (recorder.isRecording()) recorder.stop(); + if (isPlayingTake()) audioQueue.stopAll(); pdmStop(); } @@ -76,28 +233,102 @@ void RadioMicRecorder::buildBody(Window* window) const coord_t w = window->width(); const coord_t h = window->height(); + const coord_t btnY = h - MIC_BTN_H - PAD_LARGE * 2; + const coord_t bigH = EdgeTxStyles::UI_ELEMENT_HEIGHT + PAD_MEDIUM; + const coord_t infoH = EdgeTxStyles::STD_FONT_HEIGHT; + const coord_t infoY = btnY - infoH - PAD_SMALL; + const coord_t waveY = PAD_SMALL + bigH + PAD_SMALL; + bigLabel = new StaticText( window, - {0, h / 3 - 20, w, 40}, + {0, PAD_SMALL, w, bigH}, "", COLOR_THEME_PRIMARY1_INDEX, FONT(XL)); lv_obj_set_style_text_align(bigLabel->getLvObj(), LV_TEXT_ALIGN_CENTER, 0); + waveform = new WaveformView( + window, {PAD_LARGE, waveY, w - PAD_LARGE * 2, infoY - waveY - PAD_SMALL}); + waveform->hide(); + infoLabel = new StaticText( window, - {0, h / 3 + 30, w, 24}, + {0, infoY, w, infoH}, "", COLOR_THEME_SECONDARY1_INDEX, FONT(STD)); lv_obj_set_style_text_align(infoLabel->getLvObj(), LV_TEXT_ALIGN_CENTER, 0); actionButton = new TextButton( window, - {(w - MIC_BTN_W) / 2, - h - MIC_BTN_H - PAD_LARGE * 2, - MIC_BTN_W, MIC_BTN_H}, + {(w - MIC_BTN_W) / 2, btnY, MIC_BTN_W, MIC_BTN_H}, "", [this]() { onActionPressed(); return 0; }); actionButton->setFont(FONT_XL_INDEX); + + const coord_t revW = (w - PAD_LARGE * 4) / 3; + + playButton = new TextButton( + window, {PAD_LARGE, btnY, revW, MIC_BTN_H}, STR_PLAY_FILE, [this]() { + onPlayPressed(); + return 0; + }); + playButton->setWrap(); + + saveButton = new TextButton( + window, {PAD_LARGE * 2 + revW, btnY, revW, MIC_BTN_H}, STR_SAVE_AS, + [this]() { + askSaveAs(); + return 0; + }); + saveButton->setWrap(); + + redoButton = new TextButton( + window, {PAD_LARGE * 3 + revW * 2, btnY, revW, MIC_BTN_H}, STR_RECORD, + [this]() { + discardTake(); + enterCountdown(); + return 0; + }); + redoButton->setWrap(); + + playButton->hide(); + saveButton->hide(); + redoButton->hide(); +} + +void RadioMicRecorder::showReviewButtons(bool reviewing) +{ + actionButton->show(!reviewing); + playButton->show(reviewing); + saveButton->show(reviewing); + redoButton->show(reviewing); + lv_group_focus_obj(reviewing ? playButton->getLvObj() + : actionButton->getLvObj()); +} + +bool RadioMicRecorder::isPlayingTake() const +{ + return audioQueue.isPlaying(ID_PLAY_FROM_SD_MANAGER); +} + +void RadioMicRecorder::onPlayPressed() +{ + if (isPlayingTake()) { + audioQueue.stopAll(); + } else { + audioQueue.stopAll(); + audioQueue.playFile(filename, 0, ID_PLAY_FROM_SD_MANAGER); + playStart = get_tmr10ms(); + } + playingShown = isPlayingTake(); + if (!playingShown) waveform->setCursor(-1); + refreshUI(); +} + +// Drops the take the user just rejected, unless it was already saved under a +// name of its own. +void RadioMicRecorder::discardTake() +{ + if (!takeSaved && filename[0]) f_unlink(filename); } void RadioMicRecorder::onEvent(event_t event) @@ -105,6 +336,7 @@ void RadioMicRecorder::onEvent(event_t event) if (event == EVT_KEY_LONG(KEY_EXIT)) { killEvents(event); if (recorder.isRecording()) recorder.stop(); + if (isPlayingTake()) audioQueue.stopAll(); onCancel(); } } @@ -115,12 +347,15 @@ void RadioMicRecorder::onActionPressed() case State::IDLE: enterCountdown(); break; case State::COUNTDOWN: enterIdle(); break; // cancel case State::RECORDING: stopRecording(); break; + case State::REVIEW: break; // handled by the review buttons } } void RadioMicRecorder::enterIdle() { if (recorder.isRecording()) recorder.stop(); + showReviewButtons(false); + waveform->hide(); state = State::IDLE; stateStart = get_tmr10ms(); refreshUI(); @@ -128,6 +363,9 @@ void RadioMicRecorder::enterIdle() void RadioMicRecorder::enterCountdown() { + if (isPlayingTake()) audioQueue.stopAll(); + showReviewButtons(false); + waveform->hide(); state = State::COUNTDOWN; stateStart = get_tmr10ms(); refreshUI(); @@ -135,6 +373,7 @@ void RadioMicRecorder::enterCountdown() void RadioMicRecorder::enterRecording() { + takeSaved = false; pickNextFilename(); FRESULT res = recorder.start(filename, 0); if (res != FR_OK) { @@ -146,6 +385,11 @@ void RadioMicRecorder::enterRecording() actionButton->setText(STR_RECORD); return; } + waveform->clear(); + waveform->show(); + lastWaveSamples = 0; + takeSamples = 0; + (void)recorder.takePeakLevel(); // drop the level left over from the last take state = State::RECORDING; stateStart = get_tmr10ms(); refreshUI(); @@ -154,10 +398,30 @@ void RadioMicRecorder::enterRecording() void RadioMicRecorder::stopRecording() { recorder.stop(); - PdmWavRecorder::trimSilence(filename); - state = State::IDLE; + + uint32_t clipped = 0; + const uint16_t cols = waveform->columns(); + uint8_t env[WaveformView::MAX_COLS]; + if (PdmWavRecorder::finalise(filename, env, cols, &takeSamples, &clipped) == FR_OK) + waveform->setEnvelope(env, cols); + TRACE("mic: %u/1000 samples clipped at capture", (unsigned)clipped); + + enterReview(); +} + +void RadioMicRecorder::enterReview() +{ + playingShown = false; + showReviewButtons(true); + waveform->show(); + state = State::REVIEW; stateStart = get_tmr10ms(); refreshUI(); +} + +void RadioMicRecorder::askSaveAs() +{ + if (isPlayingTake()) audioQueue.stopAll(); const char* base = strrchr(filename, '/'); base = base ? base + 1 : filename; @@ -173,7 +437,7 @@ void RadioMicRecorder::stopRecording() strcpy(dir, SOUNDS_PATH "/"); strncpy(dir + SOUNDS_PATH_LNG_OFS, currentLanguagePack->id, 2); snprintf(pendingRename, sizeof(pendingRename), "%s%s.wav", dir, newName.c_str()); - if (strcmp(pendingRename, filename) == 0) { refreshUI(); return; } + if (strcmp(pendingRename, filename) == 0) { takeSaved = true; refreshUI(); return; } // Defer the overwrite check until LabelDialog has finished closing — // creating a modal now would leave us stacked on top of LabelDialog, @@ -201,6 +465,7 @@ void RadioMicRecorder::applyRename() f_rename(filename, pendingRename); strncpy(filename, pendingRename, sizeof(filename) - 1); filename[sizeof(filename) - 1] = '\0'; + takeSaved = true; refreshUI(); } @@ -242,6 +507,21 @@ void RadioMicRecorder::refreshUI() actionButton->setText(STR_STOP); break; } + + case State::REVIEW: { + const uint32_t s = takeSamples / PdmWavRecorder::DST_RATE; + snprintf(buf, sizeof(buf), "%02u:%02u", (unsigned)(s / 60U), + (unsigned)(s % 60U)); + bigLabel->setText(buf); + if (takeSaved) { + snprintf(buf, sizeof(buf), "%s %s", STR_SAVED, filename); + infoLabel->setText(buf); + } else { + infoLabel->setText(filename); + } + playButton->setText(isPlayingTake() ? STR_STOP : STR_PLAY_FILE); + break; + } } } @@ -258,10 +538,33 @@ void RadioMicRecorder::checkEvents() } } else if (state == State::RECORDING) { if (!recorder.isRecording()) { - state = State::IDLE; - stateStart = get_tmr10ms(); + stopRecording(); // recorder ended on its own (write error / limit) + } else { + const uint32_t written = recorder.getSamplesWritten(); + if (written != lastWaveSamples) { + lastWaveSamples = written; + waveform->push(recorder.takePeakLevel()); + } + refreshUI(); + } + } else if (state == State::REVIEW) { + const bool playing = isPlayingTake(); + + // The audio queue does not expose a play position, so the cursor runs off + // the wall clock: playback is sample-accurate once started, so the only + // error is the queue's own start latency. + if (playing && takeSamples) { + const uint32_t elapsed10 = (uint32_t)(get_tmr10ms() - playStart); + const uint32_t total10 = takeSamples / (PdmWavRecorder::DST_RATE / 100U); + waveform->setCursor(total10 ? (int32_t)((elapsed10 * 1000U) / total10) + : 1000); + } + + if (playing != playingShown) { + playingShown = playing; + if (!playing) waveform->setCursor(-1); + refreshUI(); } - refreshUI(); } } diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.h b/radio/src/gui/colorlcd/radio/radio_mic_recorder.h index 53f8704073d..6ea703f5051 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.h +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.h @@ -29,6 +29,7 @@ class TextButton; class StaticText; +class WaveformView; class RadioMicRecorder : public Page { @@ -37,7 +38,7 @@ class RadioMicRecorder : public Page ~RadioMicRecorder() override; protected: - enum class State : uint8_t { IDLE, COUNTDOWN, RECORDING }; + enum class State : uint8_t { IDLE, COUNTDOWN, RECORDING, REVIEW }; static constexpr uint32_t COUNTDOWN_SECONDS = 5; static constexpr int PATH_MAX_LEN = sizeof(SOUNDS_PATH) + 14; // "/lang/rec_00.wav\0" @@ -48,9 +49,19 @@ class RadioMicRecorder : public Page char pendingRename[PATH_MAX_LEN] = {0}; PdmWavRecorder recorder; + bool takeSaved = false; + bool playingShown = false; + uint32_t lastWaveSamples = 0; + uint32_t takeSamples = 0; + tmr10ms_t playStart = 0; + StaticText* bigLabel = nullptr; StaticText* infoLabel = nullptr; TextButton* actionButton = nullptr; + TextButton* playButton = nullptr; + TextButton* saveButton = nullptr; + TextButton* redoButton = nullptr; + WaveformView* waveform = nullptr; void buildHeader(Window* window); void buildBody(Window* window); @@ -58,10 +69,16 @@ class RadioMicRecorder : public Page void onEvent(event_t event) override; void onActionPressed(); + void onPlayPressed(); void enterIdle(); void enterCountdown(); void enterRecording(); + void enterReview(); void stopRecording(); + void discardTake(); + void askSaveAs(); + void showReviewButtons(bool reviewing); + bool isPlayingTake() const; void processPendingRename(); void applyRename(); void refreshUI(); diff --git a/radio/src/pdm_wav_recorder.cpp b/radio/src/pdm_wav_recorder.cpp index 8b0d646a2b9..6bebe66ccd3 100644 --- a/radio/src/pdm_wav_recorder.cpp +++ b/radio/src/pdm_wav_recorder.cpp @@ -23,6 +23,7 @@ #if defined(PDM_CLOCK) +#include #include #include "os/task.h" @@ -109,6 +110,13 @@ bool PdmWavRecorder::tickLocked() if (n > remaining) n = remaining; } + uint16_t peak = peakSinceRead; + for (uint32_t i = 0; i < n; i++) { + const int32_t a = pcm[i] < 0 ? -(int32_t)pcm[i] : (int32_t)pcm[i]; + if (a > (int32_t)peak) peak = (uint16_t)a; + } + peakSinceRead = peak; + UINT w = 0; FRESULT res = f_write(&file, pcm, n * sizeof(int16_t), &w); if (res != FR_OK || w != n * sizeof(int16_t)) { @@ -155,8 +163,66 @@ FRESULT PdmWavRecorder::stop() return f_close(&file); } -FRESULT PdmWavRecorder::trimSilence(const char* path) +uint8_t PdmWavRecorder::takePeakLevel() { + const uint16_t peak = peakSinceRead; + peakSinceRead = 0; + return (uint8_t)(peak >> 7); +} + +// --------------------------------------------------------------------------- +// Post-recording clean-up. +// +// The CIC decimator leaves two artefacts worth fixing offline, where cost does +// not matter: a residual DC/rumble term (the mic's ones-density is never +// exactly 50%), and sinc^3 passband droop -- about -2.7 dB at 4 kHz and +// -11.8 dB at 8 kHz for R=100, N=3, which is what makes takes sound muffled. +// Level is then set from the measured peak instead of the fixed capture gain. +// --------------------------------------------------------------------------- + +// One-pole high-pass, -3 dB at ~80 Hz for a 16 kHz rate: exp(-2*pi*80/16000). +static constexpr float PP_HPF_A = 0.969f; + +// Symmetric 3-tap droop compensator (-a, 1+2a, -a): +3.5 dB at 4 kHz, +// +6 dB at 8 kHz, unity at DC. +static constexpr float PP_COMP_A = 0.25f; + +static constexpr float PP_NORM_TARGET = 29500.0f; // ~-0.9 dBFS +static constexpr float PP_MAX_GAIN = 8.0f; +static constexpr uint32_t PP_FADE_SAMPLES = PdmWavRecorder::DST_RATE / 125; // 8 ms + +namespace { +struct PostFilter +{ + float hpX = 0.0f, hpY = 0.0f; + float d1 = 0.0f, d2 = 0.0f; + bool primed = false; + + float process(int16_t sample) + { + const float x = (float)sample; + if (!primed) { hpX = x; primed = true; } // no step at the first sample + const float y = PP_HPF_A * (hpY + x - hpX); + hpX = x; + hpY = y; + const float out = (1.0f + 2.0f * PP_COMP_A) * d1 - PP_COMP_A * (y + d2); + d2 = d1; + d1 = y; + return out; + } +}; +} // namespace + +FRESULT PdmWavRecorder::finalise(const char* path, uint8_t* env, uint16_t cols, + uint32_t* totalSamples, + uint32_t* clippedPermille) +{ + // 4 KB of static buffer rather than a stack one: a pass over the card costs + // far more in transfers than in bytes, and everything that needs to see + // every sample shares these two passes. + static int16_t ioBuf[2048]; + static constexpr uint32_t CHUNK = sizeof(ioBuf) / sizeof(ioBuf[0]); + // Threshold tracks the post-CIC gain so trim stays calibrated when // PDM_POST_GAIN_SHIFT is changed. Baseline 4000 was tuned at shift=4. static constexpr int32_t _silenceRaw = @@ -164,7 +230,10 @@ FRESULT PdmWavRecorder::trimSilence(const char* path) static constexpr int16_t SILENCE_THRESHOLD = _silenceRaw > 32767 ? (int16_t)32767 : (int16_t)_silenceRaw; static constexpr uint32_t PAD_SAMPLES = DST_RATE / 100; // 10 ms - static constexpr uint32_t CHUNK = 256; + + if (env && cols) memset(env, 0, cols); + if (totalSamples) *totalSamples = 0; + if (clippedPermille) *clippedPermille = 0; FIL f; FRESULT res = f_open(&f, path, FA_READ | FA_WRITE); @@ -181,47 +250,101 @@ FRESULT PdmWavRecorder::trimSilence(const char* path) const uint32_t total = dataBytes / 2; if (total == 0) { f_close(&f); return FR_OK; } - int16_t buf[CHUNK]; + // Pass 1 (sequential read): silence bounds, capture clipping, filtered peak. + PostFilter flt; uint32_t firstActive = UINT32_MAX; uint32_t lastActive = 0; + uint32_t clipped = 0; + float peak = 0.0f; for (uint32_t base = 0; base < total; base += CHUNK) { const uint32_t n = (base + CHUNK <= total) ? CHUNK : total - base; - f_lseek(&f, 44 + base * 2); - res = f_read(&f, buf, n * 2, &br); + res = f_read(&f, ioBuf, n * 2, &br); if (res != FR_OK) { f_close(&f); return res; } const uint32_t got = br / 2; for (uint32_t i = 0; i < got; i++) { - const int16_t abs = buf[i] < 0 ? (int16_t)-buf[i] : buf[i]; - if (abs > SILENCE_THRESHOLD) { + const int16_t s = ioBuf[i]; + const int32_t a = s < 0 ? -(int32_t)s : (int32_t)s; + if (a >= 32767) clipped++; + if (a > SILENCE_THRESHOLD) { if (firstActive == UINT32_MAX) firstActive = base + i; lastActive = base + i; } + const float v = fabsf(flt.process(s)); + if (v > peak) peak = v; } + if (got < n) break; + } + + uint32_t trimStart = 0, trimEnd = total - 1; + if (firstActive != UINT32_MAX) { + trimStart = firstActive > PAD_SAMPLES ? firstActive - PAD_SAMPLES : 0; + trimEnd = (lastActive + PAD_SAMPLES < total) ? lastActive + PAD_SAMPLES + : total - 1; + } + const uint32_t newTotal = trimEnd - trimStart + 1; + + if (clippedPermille) *clippedPermille = (clipped * 1000U) / total; + if (totalSamples) *totalSamples = newTotal; + + const bool doFilter = newTotal >= 2 * PP_FADE_SAMPLES; + + float gain = 1.0f; + if (doFilter && peak > 1.0f) { + gain = PP_NORM_TARGET / peak; + if (gain > PP_MAX_GAIN) gain = PP_MAX_GAIN; } - if (firstActive == UINT32_MAX) { f_close(&f); return FR_OK; } // all silent - - const uint32_t trimStart = firstActive > PAD_SAMPLES ? firstActive - PAD_SAMPLES : 0; - const uint32_t trimEnd = (lastActive + PAD_SAMPLES < total) ? lastActive + PAD_SAMPLES : total - 1; - const uint32_t newTotal = trimEnd - trimStart + 1; - - if (trimStart == 0 && trimEnd == total - 1) { f_close(&f); return FR_OK; } - - if (trimStart > 0) { - uint32_t src = trimStart, dst = 0; - while (src <= trimEnd) { - const uint32_t n = (src + CHUNK - 1 <= trimEnd) ? CHUNK : trimEnd - src + 1; - f_lseek(&f, 44 + src * 2); - res = f_read(&f, buf, n * 2, &br); - if (res != FR_OK) { f_close(&f); return res; } - UINT bw = 0; - f_lseek(&f, 44 + dst * 2); - res = f_write(&f, buf, br, &bw); - if (res != FR_OK) { f_close(&f); return res; } - src += n; - dst += n; + // Pass 2: shift to the front, filter, normalise, fade, fill the envelope. + flt = PostFilter(); + uint32_t src = trimStart, dst = 0; + + while (src <= trimEnd) { + const uint32_t n = (src + CHUNK - 1 <= trimEnd) ? CHUNK : trimEnd - src + 1; + f_lseek(&f, 44 + src * 2); + res = f_read(&f, ioBuf, n * 2, &br); + if (res != FR_OK) { f_close(&f); return res; } + const uint32_t got = br / 2; + + for (uint32_t i = 0; i < got; i++) { + const uint32_t idx = dst + i; + int32_t out; + + if (doFilter) { + float v = flt.process(ioBuf[i]) * gain; + if (idx < PP_FADE_SAMPLES) { + v *= (float)idx / (float)PP_FADE_SAMPLES; + } else if (idx >= newTotal - PP_FADE_SAMPLES) { + v *= (float)(newTotal - idx) / (float)PP_FADE_SAMPLES; + } + if (v > 32767.0f) v = 32767.0f; + if (v < -32768.0f) v = -32768.0f; + out = (int32_t)lrintf(v); + } else { + out = ioBuf[i]; + } + ioBuf[i] = (int16_t)out; + + if (env && cols) { + int32_t a = out < 0 ? -out : out; + if (a > 32767) a = 32767; + const uint16_t c = (uint16_t)(((uint64_t)idx * cols) / newTotal); + const uint8_t lvl = (uint8_t)(a >> 7); + if (lvl > env[c]) env[c] = lvl; + } } + + UINT bw = 0; + f_lseek(&f, 44 + dst * 2); + res = f_write(&f, ioBuf, got * 2, &bw); + if (res != FR_OK || bw != got * 2) { + f_close(&f); + return res != FR_OK ? res : FR_DISK_ERR; + } + + src += got; + dst += got; + if (got < n) break; } f_lseek(&f, 44 + newTotal * 2); diff --git a/radio/src/pdm_wav_recorder.h b/radio/src/pdm_wav_recorder.h index 5eec2ece5eb..bca5e8eb49f 100644 --- a/radio/src/pdm_wav_recorder.h +++ b/radio/src/pdm_wav_recorder.h @@ -43,8 +43,18 @@ class PdmWavRecorder // Called from the audio task every ~4 ms. static void audioTick(); - // Trim leading/trailing silence in-place and patch the WAV header. - static FRESULT trimSilence(const char* path); + // Finish a take in one go, in two passes over the card: trim leading and + // trailing silence, high-pass, compensate CIC droop, normalise, fade the + // edges, patch the WAV header, and report the peak envelope (one 0..255 + // level per column), the final length and how much of the capture hit + // int16 saturation (a high value means PDM_POST_GAIN_SHIFT is too hot). + static FRESULT finalise(const char* path, uint8_t* env, uint16_t cols, + uint32_t* totalSamples = nullptr, + uint32_t* clippedPermille = nullptr); + + // Peak of everything written since the previous call, 0..255 of full scale. + // Feeds the live waveform; a lost update just costs one display column. + uint8_t takePeakLevel(); bool isRecording() const { return recording; } uint32_t getSamplesWritten() const { return samplesWritten; } @@ -62,6 +72,7 @@ class PdmWavRecorder volatile uint32_t samplesWritten = 0; uint32_t maxSamples = 0; // 0 = open-ended volatile bool recording = false; + volatile uint16_t peakSinceRead = 0; }; #endif // PDM_CLOCK From 8b21616e92a50973248a0a158b50303a0a12b503 Mon Sep 17 00:00:00 2001 From: philmoz Date: Tue, 25 Aug 2026 11:54:36 +1000 Subject: [PATCH 41/55] fix(color): HSV & RGB color edit bars do not scale correctly to 800x480 LCD size (#7695) (cherry picked from commit faa60953cb8d0fba104c918ca9a2920b177836bb) (cherry picked from commit 4ef75e2d1adbe41eb78b3f237ffc7ba1d785bc6d) --- .../gui/colorlcd/controls/color_editor.cpp | 32 +++++++++++-------- .../src/gui/colorlcd/controls/color_editor.h | 2 +- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/radio/src/gui/colorlcd/controls/color_editor.cpp b/radio/src/gui/colorlcd/controls/color_editor.cpp index fc1c9ff3dc4..384dcbe6a5a 100644 --- a/radio/src/gui/colorlcd/controls/color_editor.cpp +++ b/radio/src/gui/colorlcd/controls/color_editor.cpp @@ -52,21 +52,21 @@ class ColorBar : public FormField int valueToScreen(int val) { - auto h = height() - 4; // exclude border + auto maxY = height() - PAD_BORDER * 2 - 1; // exclude border - int scaledValue = (val * h + maxValue / 2) / maxValue; + int scaledValue = (val * maxY + maxValue / 2) / maxValue; return scaledValue; } uint32_t screenToValue(int pos) { - auto h = height() - 4; // exclude border + auto maxY = height() - PAD_BORDER * 2 - 1; // exclude border // range check - pos = min(pos, h); + pos = min(pos, maxY); pos = max(pos, 0); - uint32_t scaledValue = ((pos * maxValue + h / 2) / h); + uint32_t scaledValue = ((pos * maxValue + maxY / 2) / maxY); return scaledValue; } @@ -147,17 +147,23 @@ class ColorBar : public FormField auto area = dsc->draw_area; lv_point_t p1, p2; - int h = area->y2 - area->y1 - 4; + int h = area->y2 - area->y1 - PAD_BORDER * 2; + lv_coord_t x1 = area->x1 + PAD_BORDER; + lv_coord_t x2 = area->x2 + 1 - PAD_BORDER; + lv_coord_t y = area->y1 + PAD_BORDER; // draw background gradient for (int i = 0; i <= h; i += 1) { - p1.y = p2.y = i + area->y1 + 2; - if (i == 0 || i == h) { - p1.x = area->x1 + 3; - p2.x = area->x2 - 2; + p1.y = p2.y = i + y; + if (i < PAD_BORDER) { + p1.x = x1 + PAD_BORDER - i; + p2.x = x2 - PAD_BORDER + i; + } else if (i > h - PAD_BORDER) { + p1.x = x1 + PAD_BORDER - (h - i); + p2.x = x2 - PAD_BORDER + (h - i); } else { - p1.x = area->x1 + 2; - p2.x = area->x2 - 1; + p1.x = x1; + p2.x = x2; } auto c = bar->getRGB(bar->screenToValue(i)); line_dsc.color = lv_color_make(GET_RED32(c), GET_GREEN32(c), GET_BLUE32(c)); @@ -170,7 +176,7 @@ class ColorBar : public FormField cursor_area.x2 = cursor_area.x1 + ColorEditor::CRSR_SZ - 1; auto pos = bar->valueToScreen(bar->value); - cursor_area.y1 = area->y1 + pos - PAD_THREE; + cursor_area.y1 = area->y1 + PAD_BORDER + pos - (ColorEditor::CRSR_SZ / 2); cursor_area.y2 = cursor_area.y1 + ColorEditor::CRSR_SZ - 1; lv_draw_rect_dsc_t cursor_dsc; diff --git a/radio/src/gui/colorlcd/controls/color_editor.h b/radio/src/gui/colorlcd/controls/color_editor.h index 27f6f01e560..b285fb15175 100644 --- a/radio/src/gui/colorlcd/controls/color_editor.h +++ b/radio/src/gui/colorlcd/controls/color_editor.h @@ -52,7 +52,7 @@ class ColorEditor : public Window static LAYOUT_VAL_SCALED(BAR_HEIGHT_OFFSET, 25) static LAYOUT_VAL_SCALED(LBL_YO, 9) static LAYOUT_VAL_SCALED(VAL_XO, 10) - static LAYOUT_VAL_SCALED_EVEN(CRSR_SZ, 10) + static LAYOUT_VAL_SCALED_ODD(CRSR_SZ, 11) protected: ColorType* _colorType = nullptr; From fdd5b69189f107b46cd8b7feb35cb1676dce74dd Mon Sep 17 00:00:00 2001 From: philmoz Date: Tue, 25 Aug 2026 12:06:53 +1000 Subject: [PATCH 42/55] fix(color): manage models buttons incorrect after renaming or removing labels (#7705) (cherry picked from commit 7f6bbd1fb97bf707552877532ecfc7e3048a1e67) (cherry picked from commit 92f73a207aac9d2a50a93ba81197b620159afffa) --- radio/src/gui/colorlcd/model/model_select.cpp | 9 ++++++++- radio/src/storage/modelslist.h | 8 -------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/radio/src/gui/colorlcd/model/model_select.cpp b/radio/src/gui/colorlcd/model/model_select.cpp index ab531732e6f..c3e36fb378e 100644 --- a/radio/src/gui/colorlcd/model/model_select.cpp +++ b/radio/src/gui/colorlcd/model/model_select.cpp @@ -280,10 +280,15 @@ class ModelsPageBody : public Window } } - void reload() + void clearButtons() { modelButtons.clear(); clear(); + } + + void reload() + { + clearButtons(); update(); } @@ -865,6 +870,7 @@ void ModelLabelsWindow::buildBody(Window *window) }); auto labels = getLabels(); lblselector->setNames(labels); + mdlselector->clearButtons(); updateFilteredLabels(modelslabels.filteredLabels(), false); } }); @@ -889,6 +895,7 @@ void ModelLabelsWindow::buildBody(Window *window) lblselector->setSelected(newset); if (g_eeGeneral.labelSingleSelect && selected == lblselector->getActiveItem()) lblselector->setActiveItem(-1); + mdlselector->clearButtons(); updateFilteredLabels(newset); }); return 0; diff --git a/radio/src/storage/modelslist.h b/radio/src/storage/modelslist.h index b26575753f2..4e5285fc4b2 100644 --- a/radio/src/storage/modelslist.h +++ b/radio/src/storage/modelslist.h @@ -87,12 +87,6 @@ class ModelCell bool fetchRfData(); }; -typedef struct { - std::string icon; - // Anything else? -} SLabelDetail; - -typedef std::vector> ModelLabelsVector; typedef std::vector LabelsVector; typedef std::vector ModelsVector; typedef enum { @@ -250,7 +244,5 @@ class ModelsList : public ModelsVector bool loadYamlDirScanner(); }; -ModelLabelsVector getUniqueLabels(); - extern ModelsList modelslist; extern ModelMap modelslabels; From 4e9a514f105baf0a12ef0ae373c9f249efddd7e7 Mon Sep 17 00:00:00 2001 From: philmoz Date: Tue, 25 Aug 2026 12:37:30 +1000 Subject: [PATCH 43/55] fix(color): number edit keyboard is not sized to suit display, cursor position is wrong (#7670) (cherry picked from commit 298e6e6d324aeaa56ed21e778fbb4db13aee3f2e) (cherry picked from commit 6f7befb5f6bec91ffb92633f57743064effad130) --- .../src/gui/colorlcd/libui/keyboard_number.cpp | 18 +++++++++--------- radio/src/gui/colorlcd/libui/numberedit.cpp | 5 ----- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/radio/src/gui/colorlcd/libui/keyboard_number.cpp b/radio/src/gui/colorlcd/libui/keyboard_number.cpp index de9efde635c..4fe6b59a453 100644 --- a/radio/src/gui/colorlcd/libui/keyboard_number.cpp +++ b/radio/src/gui/colorlcd/libui/keyboard_number.cpp @@ -20,7 +20,7 @@ #include "numberedit.h" -constexpr coord_t KEYBOARD_HEIGHT = 90; +LAYOUT_VAL_SCALED(KEYBOARD_HEIGHT, 90); NumberKeyboard* NumberKeyboard::_instance = nullptr; static const char* const number_kb_map[] = {"<<", "-", "+", ">>", "\n", @@ -71,42 +71,42 @@ void NumberKeyboard::handleEvent(const char* btn) void NumberKeyboard::decLarge() { - ((NumberEdit*)field)->onEvent(EVT_VIRTUAL_KEY_BACKWARD); + field->onEvent(EVT_VIRTUAL_KEY_BACKWARD); } void NumberKeyboard::decSmall() { - ((NumberEdit*)field)->onEvent(EVT_VIRTUAL_KEY_MINUS); + field->onEvent(EVT_VIRTUAL_KEY_MINUS); } void NumberKeyboard::incSmall() { - ((NumberEdit*)field)->onEvent(EVT_VIRTUAL_KEY_PLUS); + field->onEvent(EVT_VIRTUAL_KEY_PLUS); } void NumberKeyboard::incLarge() { - ((NumberEdit*)field)->onEvent(EVT_VIRTUAL_KEY_FORWARD); + field->onEvent(EVT_VIRTUAL_KEY_FORWARD); } void NumberKeyboard::setMIN() { - ((NumberEdit*)field)->onEvent(EVT_VIRTUAL_KEY_MIN); + field->onEvent(EVT_VIRTUAL_KEY_MIN); } void NumberKeyboard::setMAX() { - ((NumberEdit*)field)->onEvent(EVT_VIRTUAL_KEY_MAX); + field->onEvent(EVT_VIRTUAL_KEY_MAX); } void NumberKeyboard::setDEF() { - ((NumberEdit*)field)->onEvent(EVT_VIRTUAL_KEY_DEFAULT); + field->onEvent(EVT_VIRTUAL_KEY_DEFAULT); } void NumberKeyboard::changeSign() { - ((NumberEdit*)field)->onEvent(EVT_VIRTUAL_KEY_SIGN); + field->onEvent(EVT_VIRTUAL_KEY_SIGN); } #if defined(HARDWARE_KEYS) diff --git a/radio/src/gui/colorlcd/libui/numberedit.cpp b/radio/src/gui/colorlcd/libui/numberedit.cpp index 2b8a7ff0b11..53e229261e3 100644 --- a/radio/src/gui/colorlcd/libui/numberedit.cpp +++ b/radio/src/gui/colorlcd/libui/numberedit.cpp @@ -32,11 +32,6 @@ class NumberArea : public FormField { lv_obj_clear_flag(lvobj, LV_OBJ_FLAG_CLICK_FOCUSABLE); - if (parent->getTextFlags() & CENTERED) - etx_obj_add_style(lvobj, styles->text_align_center, LV_PART_MAIN); - else - etx_obj_add_style(lvobj, styles->text_align_right, LV_PART_MAIN); - // Allow encoder acceleration lv_obj_add_flag(lvobj, LV_OBJ_FLAG_ENCODER_ACCEL); From 0845a44fbf299d5d34e430ab02a4d5368951b8b2 Mon Sep 17 00:00:00 2001 From: Raphael Coeffic Date: Wed, 26 Aug 2026 05:06:17 +0200 Subject: [PATCH 44/55] fix(trainer): SBUS trainer context ownership (#7699) Co-authored-by: raphaelcoeffic <1050031+raphaelcoeffic@users.noreply.github.com> (cherry picked from commit 878f414975c4c50ef77791f2036f702b0e120176) --- radio/src/gui/gui_common.cpp | 2 +- radio/src/sbus.cpp | 61 +++++++++++++------ radio/src/sbus.h | 21 ++++--- radio/src/serial.cpp | 43 +++++++++++-- radio/src/serial.h | 9 +++ .../common/arm/stm32/stm32_serial_driver.cpp | 3 +- .../common/arm/stm32/stm32_usart_driver.cpp | 6 ++ radio/src/trainer.cpp | 51 ++++++++++++---- 8 files changed, 152 insertions(+), 44 deletions(-) diff --git a/radio/src/gui/gui_common.cpp b/radio/src/gui/gui_common.cpp index 2d965d2d73c..c524929fedd 100644 --- a/radio/src/gui/gui_common.cpp +++ b/radio/src/gui/gui_common.cpp @@ -1172,7 +1172,7 @@ bool isTrainerModeAvailable(int mode) #endif if (mode == TRAINER_MODE_MASTER_SERIAL) { - return serialGetModePort(UART_MODE_SBUS_TRAINER) >= 0; + return serialGetSbusTrainerPort() >= 0; } if ((mode == TRAINER_MODE_MASTER_BLUETOOTH || diff --git a/radio/src/sbus.cpp b/radio/src/sbus.cpp index 5a86b235367..10bcab83229 100644 --- a/radio/src/sbus.cpp +++ b/radio/src/sbus.cpp @@ -36,32 +36,16 @@ #define SBUS_CH_CENTER 0x3E0 +// Current SBUS trainer input source. Only ever written from the main task +// (see sbusTrainerAcquire() / sbusTrainerRelease()), only ever read from the +// USART IRQ, hence no atomics: what makes this safe is the arm/disarm order. static const etx_serial_driver_t* _sbus_drv = nullptr; static void* _sbus_ctx = nullptr; -static bool _sbus_aux_enabled = false; static void sbusProcessFrame(int16_t* pulses, uint8_t* sbus, uint32_t size); -void sbusSetReceiveCtx(void* ctx, const etx_serial_driver_t* drv) +static void sbusFrameReceived(void*) { - _sbus_ctx = ctx; - _sbus_drv = drv; -} - -void sbusAuxFrameReceived(void*) -{ - if (!_sbus_aux_enabled) return; - sbusFrameReceived(nullptr); -} - -void sbusAuxSetEnabled(bool enabled) { _sbus_aux_enabled = enabled; } - -void sbusFrameReceived(void*) -{ - if (!_sbus_drv || !_sbus_ctx || !_sbus_drv->copyRxBuffer || - !_sbus_drv->getBufferedBytes) - return; - if (_sbus_drv->getBufferedBytes(_sbus_ctx) != SBUS_FRAME_SIZE) { _sbus_drv->clearRxBuffer(_sbus_ctx); return; @@ -74,6 +58,43 @@ void sbusFrameReceived(void*) sbusProcessFrame(trainerInput, frame, received); } +bool sbusTrainerAcquire(void* ctx, const etx_serial_driver_t* drv) +{ + sbusTrainerRelease(); + + // sbusFrameReceived() relies on all of these being available + if (!ctx || !drv || !drv->getBufferedBytes || !drv->copyRxBuffer || + !drv->clearRxBuffer || !drv->setIdleCb) + return false; + + _sbus_ctx = ctx; + _sbus_drv = drv; + + // Arm last: the IDLE IRQ may fire as soon as this returns + drv->setIdleCb(ctx, sbusFrameReceived, nullptr); + return true; +} + +void sbusTrainerRelease() +{ + auto drv = _sbus_drv; + auto ctx = _sbus_ctx; + if (!drv || !ctx) return; + + // Disarm first: no further callback can start once this returns + drv->setIdleCb(ctx, nullptr, nullptr); + + _sbus_ctx = nullptr; + _sbus_drv = nullptr; +} + +void sbusTrainerReleaseCtx(void* ctx) +{ + if (ctx && ctx == _sbus_ctx) sbusTrainerRelease(); +} + +bool sbusTrainerActive() { return _sbus_ctx != nullptr; } + // Range for pulses (ppm input) is [-512:+512] static void sbusProcessFrame(int16_t* pulses, uint8_t* sbus, uint32_t size) { diff --git a/radio/src/sbus.h b/radio/src/sbus.h index e8089d0fcb6..4c79db86270 100644 --- a/radio/src/sbus.h +++ b/radio/src/sbus.h @@ -25,13 +25,20 @@ #define SBUS_BAUDRATE 100000 -// SBUS serial driver + context -void sbusSetReceiveCtx(void* ctx, const etx_serial_driver_t* drv); +// The SBUS trainer receiver is a single consumer (trainerInput[]): only one +// source can feed it at a time. Which one is decided by the trainer mode, so +// the trainer owns the context and arms/disarms the IDLE callback itself. -// SBUS AUX idle callback -void sbusAuxFrameReceived(void* param); +// Claim the SBUS trainer receiver for a serial port. +// Returns false if the port cannot be used as SBUS trainer input. +bool sbusTrainerAcquire(void* ctx, const etx_serial_driver_t* drv); -// Enable / disable SBUS AUX -void sbusAuxSetEnabled(bool enabled); +// Release the SBUS trainer receiver (no-op if nothing is claimed) +void sbusTrainerRelease(); -void sbusFrameReceived(void* param); +// Is a source currently feeding the SBUS trainer receiver? +bool sbusTrainerActive(); + +// Release the SBUS trainer receiver, but only if 'ctx' is the current owner. +// Used when tearing down a serial port that may or may not be in use. +void sbusTrainerReleaseCtx(void* ctx); diff --git a/radio/src/serial.cpp b/radio/src/serial.cpp index 3148ca55a9b..e6d77b740f6 100644 --- a/radio/src/serial.cpp +++ b/radio/src/serial.cpp @@ -223,10 +223,8 @@ static void serialSetCallBacks(int mode, void* ctx, const etx_serial_port_t* por case UART_MODE_SBUS_TRAINER: case UART_MODE_SBUS_TRAINER_INV: - sbusSetReceiveCtx(ctx, drv); - if (drv && drv->setIdleCb) { - drv->setIdleCb(ctx, sbusAuxFrameReceived, nullptr); - } + // Nothing to do: the trainer claims the port when (and only when) the + // trainer mode selects it as input source. See sbusTrainerAcquire(). break; case UART_MODE_TELEMETRY: @@ -438,6 +436,10 @@ void serialInit(uint8_t port_nr, int mode) if (!port) return; if (state->port) { +#if !defined(BOOT) + // Drop the trainer input before the driver context goes away + sbusTrainerReleaseCtx(state->usart_ctx); +#endif auto drv = state->port->uart; if (drv && drv->deinit && state->usart_ctx) { drv->deinit(state->usart_ctx); @@ -499,6 +501,35 @@ void serialInit(uint8_t port_nr, int mode) #endif } +#if !defined(BOOT) +int serialGetSbusTrainerPort() +{ + int port_nr = serialGetModePort(UART_MODE_SBUS_TRAINER); + +#if defined(STM32H7) || defined(STM32H7RS) || defined(STM32H5) + // Ports without a hardware inverter rely on the USART inverting RX itself, + // which only exists on these families (see stm32_serial_init()). Elsewhere + // the mode cannot work, so do not offer such a port as trainer input even + // if a configuration written on another radio selects it. + if (port_nr < 0) port_nr = serialGetModePort(UART_MODE_SBUS_TRAINER_INV); +#endif + + return port_nr; +} + +bool serialGetPortCtx(uint8_t port_nr, void** ctx, + const etx_serial_driver_t** drv) +{ + auto state = getSerialPortState(port_nr); + if (!state || !state->port || !state->usart_ctx || !state->port->uart) + return false; + + *ctx = state->usart_ctx; + *drv = state->port->uart; + return true; +} +#endif + void initSerialPorts() { for (uint8_t port_nr = 0; port_nr < MAX_AUX_SERIAL; port_nr++) { @@ -544,6 +575,10 @@ void serialStop(uint8_t port_nr) if (!state) return; if (state->port) { +#if !defined(BOOT) + // Drop the trainer input before the driver context goes away + sbusTrainerReleaseCtx(state->usart_ctx); +#endif auto port = state->port; auto drv = port->uart; if (drv && drv->deinit) { diff --git a/radio/src/serial.h b/radio/src/serial.h index 47a307d2b18..f579d31a1e2 100644 --- a/radio/src/serial.h +++ b/radio/src/serial.h @@ -45,6 +45,15 @@ void initSerialPorts(); void serialInit(uint8_t port_nr, int mode); void serialStop(uint8_t port_nr); +#if !defined(BOOT) +// Serial port configured as SBUS trainer input (inverted or not), or -1 +int serialGetSbusTrainerPort(); + +// Fetch the driver instance backing a serial port. False if not initialised. +bool serialGetPortCtx(uint8_t port_nr, void** ctx, + const etx_serial_driver_t** drv); +#endif + // // Functions used by debug.h // diff --git a/radio/src/targets/common/arm/stm32/stm32_serial_driver.cpp b/radio/src/targets/common/arm/stm32/stm32_serial_driver.cpp index b37eaa659fb..2a097327eb0 100644 --- a/radio/src/targets/common/arm/stm32/stm32_serial_driver.cpp +++ b/radio/src/targets/common/arm/stm32/stm32_serial_driver.cpp @@ -315,7 +315,8 @@ static void* stm32_serial_init(void* hw_def, const etx_serial_init* params) static void stm32_serial_deinit(void* ctx) { auto st = (stm32_serial_state*)ctx; - if (!st) return; + // !st->sp: state already freed, make de-init idempotent + if (!st || !st->sp) return; stm32_usart_deinit(st->sp->usart); stm32_serial_free_state(st); diff --git a/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp b/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp index 01aca83d10d..e4d394e06fb 100644 --- a/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp +++ b/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp @@ -528,7 +528,13 @@ void stm32_usart_deinit(const stm32_usart_t* usart) } if ((int32_t)(usart->IRQn) >= 0) { + // An IRQ latched before (or in flight across) NVIC_DisableIRQ() would + // still be taken, and would then access a de-initialised, clock-gated + // peripheral. Barrier + clear pending closes that window. NVIC_DisableIRQ(usart->IRQn); + __DSB(); + __ISB(); + NVIC_ClearPendingIRQ(usart->IRQn); } LL_USART_DeInit(usart->USARTx); disable_usart_clock(usart->USARTx); diff --git a/radio/src/trainer.cpp b/radio/src/trainer.cpp index c845d459c30..6b1f115752f 100644 --- a/radio/src/trainer.cpp +++ b/radio/src/trainer.cpp @@ -22,6 +22,7 @@ #include "hal/trainer_driver.h" #include "edgetx.h" +#include "serial.h" // Timer gets decremented in per10ms() #define TRAINER_IN_VALID_TIMEOUT 100 // 1s @@ -90,6 +91,7 @@ void checkTrainerSignalWarning() } } +static bool trainer_init_aux_sbus(); static void trainer_init_module_sbus(); static void trainer_stop_module_sbus(); @@ -102,7 +104,7 @@ void stopTrainer() break; case TRAINER_MODE_MASTER_SERIAL: - sbusAuxSetEnabled(false); + sbusTrainerRelease(); break; case TRAINER_MODE_MASTER_CPPM_EXTERNAL_MODULE: @@ -139,7 +141,7 @@ void checkTrainerSettings() break; case TRAINER_MODE_MASTER_SERIAL: - sbusAuxSetEnabled(true); + trainer_init_aux_sbus(); break; case TRAINER_MODE_MASTER_CPPM_EXTERNAL_MODULE: @@ -155,6 +157,14 @@ void checkTrainerSettings() _on_change_cb(currentTrainerMode, requiredTrainerMode); } currentTrainerMode = requiredTrainerMode; + + } else if (currentTrainerMode == TRAINER_MODE_MASTER_SERIAL && + !sbusTrainerActive()) { + // The serial port backing this mode can be (re-)configured at any time + // from the radio settings, which drops us as its user. Pick it up again + // as soon as it is usable, rather than relying on every caller of + // serialInit() to notify us. + trainer_init_aux_sbus(); } } @@ -173,6 +183,20 @@ static const etx_serial_init sbusTrainerParams = { static etx_module_state_t* sbus_trainer_mod_st = nullptr; +// SBUS trainer input from an AUX serial port +static bool trainer_init_aux_sbus() +{ + int port_nr = serialGetSbusTrainerPort(); + if (port_nr < 0) return false; + + void* ctx; + const etx_serial_driver_t* drv; + if (!serialGetPortCtx(port_nr, &ctx, &drv)) return false; + + return sbusTrainerAcquire(ctx, drv); +} + +// SBUS trainer input from the external module bay static void trainer_init_module_sbus() { if (sbus_trainer_mod_st) return; @@ -186,23 +210,28 @@ static void trainer_init_module_sbus() EXTERNAL_MODULE, ETX_MOD_PORT_SPORT, &sbusTrainerParams, false); } - if (sbus_trainer_mod_st) { - // configure IDLE IRQ callback - auto drv = modulePortGetSerialDrv(sbus_trainer_mod_st->rx); - auto ctx = modulePortGetCtx(sbus_trainer_mod_st->rx); + if (!sbus_trainer_mod_st) return; - sbusSetReceiveCtx(ctx, drv); - if (drv && drv->setIdleCb) - drv->setIdleCb(ctx, sbusFrameReceived, nullptr); + auto drv = modulePortGetSerialDrv(sbus_trainer_mod_st->rx); + auto ctx = modulePortGetCtx(sbus_trainer_mod_st->rx); - // switch ON - modulePortSetPower(EXTERNAL_MODULE, true); + if (!sbusTrainerAcquire(ctx, drv)) { + modulePortDeInit(sbus_trainer_mod_st); + sbus_trainer_mod_st = nullptr; + return; } + + // switch ON + modulePortSetPower(EXTERNAL_MODULE, true); } static void trainer_stop_module_sbus() { if (!sbus_trainer_mod_st) return; + + // release before modulePortDeInit() invalidates the driver context + sbusTrainerRelease(); + modulePortDeInit(sbus_trainer_mod_st); modulePortSetPower(EXTERNAL_MODULE,false); sbus_trainer_mod_st = nullptr; From 71f7dd566ac02249537b6abd768403c4a89f03c6 Mon Sep 17 00:00:00 2001 From: 3djc <5167938+3djc@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:42:23 +0200 Subject: [PATCH 45/55] feat(mic): add start/end trim, auto trim and clip last bit of noise when tapping or clicking stop (#7706) (cherry picked from commit ccc838739baff94bfddc21763e6efc88ef0feb8f) --- .../src/boards/rm-h750/pdm_software_driver.h | 7 +- .../gui/colorlcd/radio/radio_mic_recorder.cpp | 395 ++++++++++++++-- .../gui/colorlcd/radio/radio_mic_recorder.h | 19 +- radio/src/pdm_wav_recorder.cpp | 436 ++++++++++++++++-- radio/src/pdm_wav_recorder.h | 42 +- radio/src/translations/i18n/cn.h | 3 + radio/src/translations/i18n/cz.h | 3 + radio/src/translations/i18n/da.h | 3 + radio/src/translations/i18n/de.h | 3 + radio/src/translations/i18n/en.h | 3 + radio/src/translations/i18n/es.h | 3 + radio/src/translations/i18n/fi.h | 3 + radio/src/translations/i18n/fr.h | 3 + radio/src/translations/i18n/he.h | 3 + radio/src/translations/i18n/it.h | 3 + radio/src/translations/i18n/jp.h | 3 + radio/src/translations/i18n/ko.h | 3 + radio/src/translations/i18n/nl.h | 3 + radio/src/translations/i18n/pl.h | 3 + radio/src/translations/i18n/pt.h | 3 + radio/src/translations/i18n/ru.h | 3 + radio/src/translations/i18n/se.h | 3 + radio/src/translations/i18n/tw.h | 3 + radio/src/translations/i18n/ua.h | 3 + radio/src/translations/sim_string_list.h | 3 + radio/src/translations/string_list.h | 3 + 26 files changed, 892 insertions(+), 70 deletions(-) diff --git a/radio/src/boards/rm-h750/pdm_software_driver.h b/radio/src/boards/rm-h750/pdm_software_driver.h index d7833470ded..32780124fe9 100644 --- a/radio/src/boards/rm-h750/pdm_software_driver.h +++ b/radio/src/boards/rm-h750/pdm_software_driver.h @@ -28,9 +28,10 @@ #define PDM_PCM_DECIMATION 100 // Post-CIC gain (left shift before int16 saturation; +6 dB per step). -// Default calibrated at 4. Override per target in hal.h before this header -// is reached. trimSilence() in pdm_wav_recorder.cpp scales its threshold -// from this same value, so changing it keeps trim in sync automatically. +// Lowering this for headroom needs finalise()'s normaliser to be silence +// aware first: a cooler capture needs more make-up gain, and on a take with +// no speech that gain lands entirely on the room noise. The absolute levels +// in pdm_wav_recorder.cpp scale off this value. #ifndef PDM_POST_GAIN_SHIFT #define PDM_POST_GAIN_SHIFT 5 #endif diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp index b71fea5271d..587aae12890 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp @@ -30,6 +30,8 @@ #include "dialog.h" #include "edgetx.h" #include "ff.h" +#include "form.h" +#include "hal/rotary_encoder.h" #include "sdcard.h" #include "static.h" #include "timers_driver.h" @@ -38,17 +40,46 @@ static constexpr coord_t MIC_BTN_W = 260; static constexpr coord_t MIC_BTN_H = EdgeTxStyles::UI_ELEMENT_HEIGHT * 2 + PAD_LARGE; +// Review needs two rows in the space the single record button occupies. +static constexpr coord_t REV_BTN_H = EdgeTxStyles::UI_ELEMENT_HEIGHT + PAD_MEDIUM; + // Mirrored peak envelope, drawn as two polylines around a centre line. Fed one // column at a time while recording, then re-filled from the finished file. -class WaveformView : public Window +// It doubles as the trim editor: a marker line dragged straight over the trace, +// with the part that would be cut greyed out. +class WaveformView : public FormField { public: static constexpr uint16_t MAX_COLS = 400; - - WaveformView(Window* parent, const rect_t& rect) : Window(parent, rect) + static constexpr coord_t KNOB_W = PAD_LARGE * 2; + static constexpr coord_t KNOB_H = PAD_MEDIUM; + + WaveformView(Window* parent, const rect_t& rect, + std::function onMove, std::function onConfirm, + std::function onDismiss) : + FormField(parent, rect), + moveHandler(std::move(onMove)), + confirmHandler(std::move(onConfirm)), + dismissHandler(std::move(onDismiss)) { padAll(PAD_ZERO); + // No scrolling: a horizontal drag has to reach the marker, not pan the view. + lv_obj_clear_flag(lvobj, LV_OBJ_FLAG_SCROLLABLE); + // No click focus either: LVGL clears the group's edit mode on every focus + // call, so a touch would otherwise leave the encoder stuck in navigation. + lv_obj_clear_flag(lvobj, LV_OBJ_FLAG_CLICK_FOCUSABLE); + lv_obj_add_flag(lvobj, LV_OBJ_FLAG_ENCODER_ACCEL); + lv_obj_add_event_cb(lvobj, WaveformView::on_pressing, LV_EVENT_PRESSING, this); + lv_obj_add_event_cb(lvobj, WaveformView::on_released, LV_EVENT_RELEASED, this); + lv_obj_add_event_cb(lvobj, WaveformView::on_key, LV_EVENT_KEY, this); + etx_obj_add_style(lvobj, styles->outline, LV_PART_MAIN | LV_STATE_EDITED); + etx_obj_add_style(lvobj, styles->outline_color_edit, + LV_PART_MAIN | LV_STATE_EDITED); + setFocusHandler([this](bool focus) { + if (!focus && isEditMode()) setEditMode(false); + }); + // Widen the columns rather than leaving the trace short of the edge: // 800 px screens need more than MAX_COLS columns at 2 px. colStep = (coord_t)((width() + MAX_COLS - 1) / MAX_COLS); @@ -80,6 +111,32 @@ class WaveformView : public Window LV_PART_MAIN); lv_obj_add_flag(cursorLine, LV_OBJ_FLAG_HIDDEN); + // Created last so they sit on top of the trace. + cutShade = lv_obj_create(lvobj); + lv_obj_clear_flag(cutShade, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(cutShade, LV_OBJ_FLAG_SCROLLABLE); + etx_bg_color(cutShade, COLOR_THEME_DISABLED_INDEX, LV_PART_MAIN); + lv_obj_set_style_bg_opa(cutShade, LV_OPA_60, LV_PART_MAIN); + lv_obj_set_style_border_width(cutShade, 0, LV_PART_MAIN); + lv_obj_set_style_radius(cutShade, 0, LV_PART_MAIN); + lv_obj_add_flag(cutShade, LV_OBJ_FLAG_HIDDEN); + + trimLine = lv_line_create(lvobj); + etx_obj_add_style(trimLine, styles->graph_line, LV_PART_MAIN); + etx_obj_add_style(trimLine, styles->line_color[COLOR_THEME_EDIT_INDEX], + LV_PART_MAIN); + lv_obj_set_style_line_width(trimLine, PAD_THREE, LV_PART_MAIN); + lv_obj_add_flag(trimLine, LV_OBJ_FLAG_HIDDEN); + + trimKnob = lv_obj_create(lvobj); + lv_obj_clear_flag(trimKnob, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(trimKnob, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(trimKnob, KNOB_W, KNOB_H); + etx_solid_bg(trimKnob, COLOR_THEME_EDIT_INDEX); + lv_obj_set_style_border_width(trimKnob, 0, LV_PART_MAIN); + etx_obj_add_style(trimKnob, styles->rounded, LV_PART_MAIN); + lv_obj_add_flag(trimKnob, LV_OBJ_FLAG_HIDDEN); + clear(); } @@ -115,6 +172,31 @@ class WaveformView : public Window cursorAt = permille; } + // Trim editing: the marker takes the focus so the encoder drags it directly, + // and a touch anywhere on the trace drops it at that point. + void showTrim(int32_t permille, bool cutBefore) + { + trimShown = true; + cutLeft = cutBefore; + lv_obj_clear_flag(trimLine, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(trimKnob, LV_OBJ_FLAG_HIDDEN); + setTrim(permille); + lv_group_add_obj((lv_group_t*)lv_group_get_default(), lvobj); + lv_group_focus_obj(lvobj); + setEditMode(true); + } + + void hideTrim() + { + if (!trimShown) return; + trimShown = false; + if (isEditMode()) setEditMode(false); + lv_group_remove_obj(lvobj); + lv_obj_add_flag(trimLine, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(trimKnob, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(cutShade, LV_OBJ_FLAG_HIDDEN); + } + // Live feed. Once the view is full, columns are merged pairwise so the whole // take stays visible at half the time resolution. void push(uint8_t level) @@ -150,11 +232,122 @@ class WaveformView : public Window lv_point_t botPts[MAX_COLS]; lv_point_t basePts[2]; lv_point_t cursorPts[2]; + lv_point_t trimPts[2]; int32_t cursorAt = -1; lv_obj_t* cursorLine = nullptr; lv_obj_t* topLine = nullptr; lv_obj_t* botLine = nullptr; lv_obj_t* baseLine = nullptr; + lv_obj_t* trimLine = nullptr; + lv_obj_t* trimKnob = nullptr; + lv_obj_t* cutShade = nullptr; + + std::function moveHandler; + std::function confirmHandler; + std::function dismissHandler; + bool trimShown = false; + bool cutLeft = false; + int32_t trimAt = -1; + + void setTrim(int32_t permille) + { + trimAt = permille; + + const lv_coord_t x = (lv_coord_t)((permille * traceW) / 1000); + trimPts[0] = {x, 0}; + trimPts[1] = {x, (lv_coord_t)(height() - 1)}; + lv_line_set_points(trimLine, trimPts, 2); + lv_coord_t knobX = x - KNOB_W / 2; + if (knobX < 0) knobX = 0; + if (knobX > traceW - KNOB_W) knobX = traceW - KNOB_W; + lv_obj_set_pos(trimKnob, knobX, 0); + + const lv_coord_t w = cutLeft ? x : (lv_coord_t)(traceW - x); + if (w > 0) { + lv_obj_set_pos(cutShade, cutLeft ? 0 : x, 0); + lv_obj_set_size(cutShade, w, height()); + lv_obj_clear_flag(cutShade, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(cutShade, LV_OBJ_FLAG_HIDDEN); + } + } + + void moveTo(int32_t permille) + { + if (permille < 0) permille = 0; + if (permille > 1000) permille = 1000; + if (permille == trimAt) return; + setTrim(permille); + if (moveHandler) moveHandler(permille); + } + + static void on_pressing(lv_event_t* e) + { + auto wv = (WaveformView*)lv_event_get_user_data(e); + if (!wv || wv->deleted() || !wv->trimShown || wv->traceW <= 0) return; + + auto indev = (lv_indev_t*)lv_event_get_param(e); + if (!indev || lv_indev_get_type(indev) != LV_INDEV_TYPE_POINTER) return; + + lv_area_t coords; + lv_obj_get_coords(lv_event_get_target(e), &coords); + lv_point_t point; + lv_indev_get_point(indev, &point); + + const int32_t x = point.x - coords.x1; + wv->moveTo((x * 1000) / wv->traceW); + wv->rearmEdit(); + } + + static void on_released(lv_event_t* e) + { + auto wv = (WaveformView*)lv_event_get_user_data(e); + if (wv && !wv->deleted()) wv->rearmEdit(); + } + + // Anything that focuses an object drops edit mode group wide, so take it back + // after a touch: the encoder has to keep driving the marker. + void rearmEdit() + { + if (!trimShown || isEditMode()) return; + auto g = (lv_group_t*)lv_obj_get_group(lvobj); + if (g && lv_group_get_focused(g) == lvobj) setEditMode(true); + } + + static void on_key(lv_event_t* e) + { + auto wv = (WaveformView*)lv_event_get_user_data(e); + if (!wv || wv->deleted() || !wv->trimShown) return; + + const uint32_t key = lv_event_get_key(e); + if (key != LV_KEY_LEFT && key != LV_KEY_RIGHT) return; + + // One detent moves the marker by one column, more when spun fast. + int32_t step = wv->maxCols ? 1000 / wv->maxCols : 1; + if (step < 1) step = 1; + step += rotaryEncoderGetAccel() / 4; + wv->moveTo(wv->trimAt + (key == LV_KEY_RIGHT ? step : -step)); + } + + // A touch only drops the marker; ENTER is what confirms the trim. + void onClicked() override + { + if (!trimShown) { + FormField::onClicked(); + return; + } + if (lv_indev_get_type(lv_indev_get_act()) == LV_INDEV_TYPE_POINTER) return; + if (confirmHandler) confirmHandler(); + } + + void onCancel() override + { + if (trimShown && dismissHandler) { + dismissHandler(); + return; + } + FormField::onCancel(); + } void compact() { @@ -234,9 +427,11 @@ void RadioMicRecorder::buildBody(Window* window) const coord_t h = window->height(); const coord_t btnY = h - MIC_BTN_H - PAD_LARGE * 2; + const coord_t rowY2 = h - REV_BTN_H - PAD_LARGE; // play / save / record + const coord_t rowY1 = rowY2 - REV_BTN_H - PAD_SMALL; // the trim actions const coord_t bigH = EdgeTxStyles::UI_ELEMENT_HEIGHT + PAD_MEDIUM; const coord_t infoH = EdgeTxStyles::STD_FONT_HEIGHT; - const coord_t infoY = btnY - infoH - PAD_SMALL; + const coord_t infoY = rowY1 - infoH - PAD_SMALL; const coord_t waveY = PAD_SMALL + bigH + PAD_SMALL; bigLabel = new StaticText( @@ -246,7 +441,9 @@ void RadioMicRecorder::buildBody(Window* window) lv_obj_set_style_text_align(bigLabel->getLvObj(), LV_TEXT_ALIGN_CENTER, 0); waveform = new WaveformView( - window, {PAD_LARGE, waveY, w - PAD_LARGE * 2, infoY - waveY - PAD_SMALL}); + window, {PAD_LARGE, waveY, w - PAD_LARGE * 2, infoY - waveY - PAD_SMALL}, + [this](int v) { onTrimMoved(v); }, [this]() { onApplyTrim(); }, + [this]() { exitTrim(); }); waveform->hide(); infoLabel = new StaticText( @@ -265,44 +462,159 @@ void RadioMicRecorder::buildBody(Window* window) actionButton->setFont(FONT_XL_INDEX); const coord_t revW = (w - PAD_LARGE * 4) / 3; + const coord_t revX[3] = {PAD_LARGE, (coord_t)(PAD_LARGE * 2 + revW), + (coord_t)(PAD_LARGE * 3 + revW * 2)}; + + autoTrimButton = new TextButton( + window, {revX[0], rowY1, revW, REV_BTN_H}, STR_AUTO_TRIM, [this]() { + onAutoTrim(); + return 0; + }); + + trimStartButton = new TextButton( + window, {revX[1], rowY1, revW, REV_BTN_H}, STR_TRIM_START, [this]() { + enterTrim(TrimMode::START); + return 0; + }); + + trimEndButton = new TextButton( + window, {revX[2], rowY1, revW, REV_BTN_H}, STR_TRIM_END, [this]() { + enterTrim(TrimMode::END); + return 0; + }); playButton = new TextButton( - window, {PAD_LARGE, btnY, revW, MIC_BTN_H}, STR_PLAY_FILE, [this]() { + window, {revX[0], rowY2, revW, REV_BTN_H}, STR_PLAY_FILE, [this]() { onPlayPressed(); return 0; }); - playButton->setWrap(); saveButton = new TextButton( - window, {PAD_LARGE * 2 + revW, btnY, revW, MIC_BTN_H}, STR_SAVE_AS, - [this]() { + window, {revX[1], rowY2, revW, REV_BTN_H}, STR_SAVE_AS, [this]() { askSaveAs(); return 0; }); - saveButton->setWrap(); redoButton = new TextButton( - window, {PAD_LARGE * 3 + revW * 2, btnY, revW, MIC_BTN_H}, STR_RECORD, - [this]() { + window, {revX[2], rowY2, revW, REV_BTN_H}, STR_RECORD, [this]() { discardTake(); enterCountdown(); return 0; }); - redoButton->setWrap(); - playButton->hide(); - saveButton->hide(); - redoButton->hide(); + applyButton = new TextButton( + window, {revX[0], rowY2, revW, REV_BTN_H}, STR_OK, [this]() { + onApplyTrim(); + return 0; + }); + + cancelButton = new TextButton( + window, {revX[1], rowY2, revW, REV_BTN_H}, STR_CANCEL, [this]() { + exitTrim(); + return 0; + }); + + updateButtons(); + built = true; +} + +// The marker runs 0..1000 over the take so its range never has to change as +// the take gets shorter. +uint32_t RadioMicRecorder::trimSample() const +{ + if (takeSamples == 0) return 0; + return (uint32_t)(((uint64_t)trimPermille * (takeSamples - 1)) / 1000U); +} + +void RadioMicRecorder::enterTrim(TrimMode mode) +{ + if (isPlayingTake()) audioQueue.stopAll(); + if (takeSamples < 2) return; + + trimMode = mode; + trimPermille = (mode == TrimMode::START) ? 0 : 1000; + waveform->setCursor(-1); + waveform->showTrim(trimPermille, mode == TrimMode::START); + updateButtons(); + refreshUI(); +} + +void RadioMicRecorder::exitTrim() +{ + trimMode = TrimMode::NONE; + waveform->hideTrim(); + waveform->setCursor(-1); + updateButtons(); + refreshUI(); } -void RadioMicRecorder::showReviewButtons(bool reviewing) +void RadioMicRecorder::onTrimMoved(int value) { - actionButton->show(!reviewing); - playButton->show(reviewing); - saveButton->show(reviewing); - redoButton->show(reviewing); - lv_group_focus_obj(reviewing ? playButton->getLvObj() - : actionButton->getLvObj()); + trimPermille = value; + refreshUI(); +} + +void RadioMicRecorder::onApplyTrim() +{ + const uint32_t cut = trimSample(); + const TrimMode mode = trimMode; + + exitTrim(); + + if (mode == TrimMode::START) { + applyTrim(cut, takeSamples - 1); + } else { + applyTrim(0, cut); + } +} + +// Trims are sample surgery on the finished file: cut, refade the new edge, +// refresh the trace. Playback stops because the length just changed. +void RadioMicRecorder::applyTrim(uint32_t from, uint32_t to) +{ + if (isPlayingTake()) audioQueue.stopAll(); + waveform->hideTrim(); + waveform->setCursor(-1); + + const uint16_t cols = waveform->columns(); + uint8_t env[WaveformView::MAX_COLS]; + if (PdmWavRecorder::cut(filename, from, to, env, cols, &takeSamples) == FR_OK) + waveform->setEnvelope(env, cols); + + playingShown = false; + refreshUI(); +} + +void RadioMicRecorder::onAutoTrim() +{ + uint32_t from = 0, to = 0; + if (PdmWavRecorder::silenceBounds(filename, &from, &to) == FR_OK) + applyTrim(from, to); +} + +void RadioMicRecorder::updateButtons() +{ + const bool review = (state == State::REVIEW); + const bool trimming = review && trimMode != TrimMode::NONE; + + actionButton->show(!review); + autoTrimButton->show(review && !trimming); + trimStartButton->show(review && !trimming); + trimEndButton->show(review && !trimming); + playButton->show(review && !trimming); + saveButton->show(review && !trimming); + redoButton->show(review && !trimming); + applyButton->show(trimming); + cancelButton->show(trimming); + + if (!built) return; + // While trimming the marker itself holds the focus. + if (trimming) + return; + else if (review) + lv_group_focus_obj(playButton->getLvObj()); + else + lv_group_focus_obj(actionButton->getLvObj()); } bool RadioMicRecorder::isPlayingTake() const @@ -354,9 +666,11 @@ void RadioMicRecorder::onActionPressed() void RadioMicRecorder::enterIdle() { if (recorder.isRecording()) recorder.stop(); - showReviewButtons(false); - waveform->hide(); state = State::IDLE; + trimMode = TrimMode::NONE; + waveform->hideTrim(); + updateButtons(); + waveform->hide(); stateStart = get_tmr10ms(); refreshUI(); } @@ -364,9 +678,11 @@ void RadioMicRecorder::enterIdle() void RadioMicRecorder::enterCountdown() { if (isPlayingTake()) audioQueue.stopAll(); - showReviewButtons(false); - waveform->hide(); state = State::COUNTDOWN; + trimMode = TrimMode::NONE; + waveform->hideTrim(); + updateButtons(); + waveform->hide(); stateStart = get_tmr10ms(); refreshUI(); } @@ -402,7 +718,9 @@ void RadioMicRecorder::stopRecording() uint32_t clipped = 0; const uint16_t cols = waveform->columns(); uint8_t env[WaveformView::MAX_COLS]; - if (PdmWavRecorder::finalise(filename, env, cols, &takeSamples, &clipped) == FR_OK) + // Silence is left in place; Auto-trim is the deliberate action. + if (PdmWavRecorder::finalise(filename, env, cols, false, &takeSamples, + &clipped) == FR_OK) waveform->setEnvelope(env, cols); TRACE("mic: %u/1000 samples clipped at capture", (unsigned)clipped); @@ -412,9 +730,11 @@ void RadioMicRecorder::stopRecording() void RadioMicRecorder::enterReview() { playingShown = false; - showReviewButtons(true); - waveform->show(); state = State::REVIEW; + trimMode = TrimMode::NONE; + waveform->hideTrim(); + updateButtons(); + waveform->show(); stateStart = get_tmr10ms(); refreshUI(); } @@ -509,6 +829,21 @@ void RadioMicRecorder::refreshUI() } case State::REVIEW: { + if (trimMode != TrimMode::NONE) { + // Show what would be left, so the marker reads as a result not a cut. + const uint32_t cut = trimSample(); + const uint32_t kept = (trimMode == TrimMode::START) + ? takeSamples - cut + : cut + 1; + const uint32_t k = kept / PdmWavRecorder::DST_RATE; + snprintf(buf, sizeof(buf), "%02u:%02u", (unsigned)(k / 60U), + (unsigned)(k % 60U)); + bigLabel->setText(buf); + infoLabel->setText(trimMode == TrimMode::START ? STR_TRIM_START + : STR_TRIM_END); + break; + } + const uint32_t s = takeSamples / PdmWavRecorder::DST_RATE; snprintf(buf, sizeof(buf), "%02u:%02u", (unsigned)(s / 60U), (unsigned)(s % 60U)); diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.h b/radio/src/gui/colorlcd/radio/radio_mic_recorder.h index 6ea703f5051..d27c9ed6958 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.h +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.h @@ -39,6 +39,7 @@ class RadioMicRecorder : public Page protected: enum class State : uint8_t { IDLE, COUNTDOWN, RECORDING, REVIEW }; + enum class TrimMode : uint8_t { NONE, START, END }; static constexpr uint32_t COUNTDOWN_SECONDS = 5; static constexpr int PATH_MAX_LEN = sizeof(SOUNDS_PATH) + 14; // "/lang/rec_00.wav\0" @@ -61,6 +62,15 @@ class RadioMicRecorder : public Page TextButton* playButton = nullptr; TextButton* saveButton = nullptr; TextButton* redoButton = nullptr; + TextButton* autoTrimButton = nullptr; + TextButton* trimStartButton = nullptr; + TextButton* trimEndButton = nullptr; + TextButton* applyButton = nullptr; + TextButton* cancelButton = nullptr; + bool built = false; + + TrimMode trimMode = TrimMode::NONE; + int trimPermille = 0; WaveformView* waveform = nullptr; void buildHeader(Window* window); @@ -70,6 +80,13 @@ class RadioMicRecorder : public Page void onActionPressed(); void onPlayPressed(); + void onAutoTrim(); + void enterTrim(TrimMode mode); + void exitTrim(); + void onTrimMoved(int value); + void onApplyTrim(); + void applyTrim(uint32_t from, uint32_t to); + uint32_t trimSample() const; void enterIdle(); void enterCountdown(); void enterRecording(); @@ -77,7 +94,7 @@ class RadioMicRecorder : public Page void stopRecording(); void discardTake(); void askSaveAs(); - void showReviewButtons(bool reviewing); + void updateButtons(); bool isPlayingTake() const; void processPendingRename(); void applyRename(); diff --git a/radio/src/pdm_wav_recorder.cpp b/radio/src/pdm_wav_recorder.cpp index 6bebe66ccd3..4677744e10c 100644 --- a/radio/src/pdm_wav_recorder.cpp +++ b/radio/src/pdm_wav_recorder.cpp @@ -187,8 +187,49 @@ static constexpr float PP_HPF_A = 0.969f; // +6 dB at 8 kHz, unity at DC. static constexpr float PP_COMP_A = 0.25f; +// 4 KB of static buffer rather than a stack one: a pass over the card costs +// far more in transfers than in bytes. +static int16_t ioBuf[2048]; +static constexpr uint32_t CHUNK = sizeof(ioBuf) / sizeof(ioBuf[0]); + +// Silence is judged against the take's own voice peak, not an absolute level: +// capture gain is fixed, so a quiet talker never crossed a fixed threshold and +// the trim either did nothing or kept only the loudest syllables. +// Levels below were calibrated at PDM_POST_GAIN_SHIFT == 5; scaling them off +// it keeps the trim calibrated when the capture gain is changed. +#define PDM_LEVEL(x) (((x) * (1 << PDM_POST_GAIN_SHIFT)) / 32) + +static constexpr int32_t SILENCE_RATIO = 8; // -18 dB below the voice peak +static constexpr int32_t SILENCE_FLOOR = PDM_LEVEL(200); +static constexpr uint32_t COARSE_MAX = 512; // whole-file level map +static uint16_t coarseLevel[COARSE_MAX]; +// Asymmetric on purpose: speech tails off below the threshold (trailing +// consonants, breath), so cutting 10 ms past the last loud sample clips it. +// Stop-press detection. The press is a short burst, much louder than the +// speech before it, sitting at the very end of the take. Anything that is +// long, or not near the end, is treated as speech and left alone. +static constexpr uint32_t SUB_SAMPLES = 256; // 16 ms +static constexpr uint32_t TAIL_RING = 128; // ~2.0 s +static constexpr uint32_t CLICK_WINDOW_BLOCKS = 94; // ~1.5 s +static constexpr uint32_t CLICK_MAX_BLOCKS = 25; // ~0.4 s +static constexpr uint32_t CLICK_GUARD_BLOCKS = 2; // ~32 ms +static constexpr uint32_t CLICK_LOOKBACK = 8; // ~128 ms +static constexpr int32_t CLICK_MIN_LEVEL = PDM_LEVEL(300); // ~-40 dBFS at shift 5 +static constexpr uint32_t CLICK_GAP_BLOCKS = 38; // ~0.6 s hold +static constexpr uint32_t CLICK_MAX_BURSTS = 4; +static constexpr uint32_t CLICK_PAUSE_BLOCKS = 10; // ~0.16 s + +static constexpr uint32_t PAD_HEAD_SAMPLES = PdmWavRecorder::DST_RATE / 10; // 100 ms +static constexpr uint32_t PAD_TAIL_SAMPLES = PdmWavRecorder::DST_RATE / 5; // 200 ms + static constexpr float PP_NORM_TARGET = 29500.0f; // ~-0.9 dBFS -static constexpr float PP_MAX_GAIN = 8.0f; +static constexpr float PP_MAX_GAIN = 4.0f; + +// Gain is also capped so it never lifts the take's own noise floor past this +// RMS (~-38 dBFS). The stop click used to be the loudest thing in every file +// and quietly held the gain near 1; with it gone, a quiet take would other- +// wise be boosted until room tone and CIC hiss become the loudest thing. +static constexpr float PP_NOISE_CEILING = 400.0f; // output level, not capture static constexpr uint32_t PP_FADE_SAMPLES = PdmWavRecorder::DST_RATE / 125; // 8 ms namespace { @@ -213,27 +254,170 @@ struct PostFilter }; } // namespace -FRESULT PdmWavRecorder::finalise(const char* path, uint8_t* env, uint16_t cols, - uint32_t* totalSamples, - uint32_t* clippedPermille) +// Reads the 44-byte header and returns the sample count, or 0 on any problem. +static uint32_t readWavTotal(FIL* f) +{ + uint8_t hdr[44]; + UINT br = 0; + if (f_lseek(f, 0) != FR_OK) return 0; + if (f_read(f, hdr, sizeof(hdr), &br) != FR_OK || br != sizeof(hdr)) return 0; + const uint32_t dataBytes = + (uint32_t)hdr[40] | ((uint32_t)hdr[41] << 8) | + ((uint32_t)hdr[42] << 16) | ((uint32_t)hdr[43] << 24); + return dataBytes / 2; +} + +static void patchWavHeader(FIL* f, uint32_t samples) +{ + uint8_t tmp[4]; + UINT bw = 0; + writeLE32(tmp, 36 + samples * 2); + f_lseek(f, 4); + f_write(f, tmp, 4, &bw); + writeLE32(tmp, samples * 2); + f_lseek(f, 40); + f_write(f, tmp, 4, &bw); +} + +FRESULT PdmWavRecorder::silenceBounds(const char* path, uint32_t* from, + uint32_t* to) { - // 4 KB of static buffer rather than a stack one: a pass over the card costs - // far more in transfers than in bytes, and everything that needs to see - // every sample shares these two passes. - static int16_t ioBuf[2048]; - static constexpr uint32_t CHUNK = sizeof(ioBuf) / sizeof(ioBuf[0]); - - // Threshold tracks the post-CIC gain so trim stays calibrated when - // PDM_POST_GAIN_SHIFT is changed. Baseline 4000 was tuned at shift=4. - static constexpr int32_t _silenceRaw = - ((int32_t)4000 * (1 << PDM_POST_GAIN_SHIFT)) / 16; - static constexpr int16_t SILENCE_THRESHOLD = - _silenceRaw > 32767 ? (int16_t)32767 : (int16_t)_silenceRaw; - static constexpr uint32_t PAD_SAMPLES = DST_RATE / 100; // 10 ms + if (!from || !to) return FR_INVALID_PARAMETER; + + FIL f; + FRESULT res = f_open(&f, path, FA_READ); + if (res != FR_OK) return res; + + const uint32_t total = readWavTotal(&f); + if (total == 0) { f_close(&f); return FR_DISK_ERR; } + + uint32_t firstActive = UINT32_MAX, lastActive = 0; + UINT br = 0; + int32_t silence = SILENCE_FLOOR; + + for (int pass = 0; pass < 2; pass++) { + int32_t peak = 0; + f_lseek(&f, 44); + for (uint32_t base = 0; base < total; base += CHUNK) { + const uint32_t n = (base + CHUNK <= total) ? CHUNK : total - base; + res = f_read(&f, ioBuf, n * 2, &br); + if (res != FR_OK) { f_close(&f); return res; } + const uint32_t got = br / 2; + for (uint32_t i = 0; i < got; i++) { + const int32_t a = ioBuf[i] < 0 ? -(int32_t)ioBuf[i] : (int32_t)ioBuf[i]; + if (pass == 0) { + if (a > peak) peak = a; + } else if (a > silence) { + if (firstActive == UINT32_MAX) firstActive = base + i; + lastActive = base + i; + } + } + if (got < n) break; + } + if (pass == 0) { + silence = peak / SILENCE_RATIO; + if (silence < SILENCE_FLOOR) silence = SILENCE_FLOOR; + } + } + + if (firstActive == UINT32_MAX) { // nothing above the threshold + *from = 0; + *to = total - 1; + } else { + *from = firstActive > PAD_HEAD_SAMPLES ? firstActive - PAD_HEAD_SAMPLES : 0; + *to = (lastActive + PAD_TAIL_SAMPLES < total) ? lastActive + PAD_TAIL_SAMPLES + : total - 1; + } + return f_close(&f); +} +FRESULT PdmWavRecorder::cut(const char* path, uint32_t from, uint32_t to, + uint8_t* env, uint16_t cols, + uint32_t* totalSamples) +{ + if (env && cols) memset(env, 0, cols); + + FIL f; + FRESULT res = f_open(&f, path, FA_READ | FA_WRITE); + if (res != FR_OK) return res; + + const uint32_t total = readWavTotal(&f); + if (total == 0) { f_close(&f); return FR_DISK_ERR; } + + if (to >= total) to = total - 1; + if (to <= from) { f_close(&f); return FR_INVALID_PARAMETER; } + + const uint32_t newTotal = to - from + 1; + if (newTotal * 1000U < MIN_TAKE_MS * DST_RATE) { + f_close(&f); + return FR_INVALID_PARAMETER; + } + if (totalSamples) *totalSamples = newTotal; + + const uint32_t fade = + newTotal / 4 < PP_FADE_SAMPLES ? newTotal / 4 : PP_FADE_SAMPLES; + + uint32_t src = from, dst = 0; + UINT br = 0; + + while (src <= to) { + const uint32_t n = (src + CHUNK - 1 <= to) ? CHUNK : to - src + 1; + f_lseek(&f, 44 + src * 2); + res = f_read(&f, ioBuf, n * 2, &br); + if (res != FR_OK) { f_close(&f); return res; } + const uint32_t got = br / 2; + + for (uint32_t i = 0; i < got; i++) { + const uint32_t idx = dst + i; + int32_t v = ioBuf[i]; + + if (fade) { + if (idx < fade) { + v = (int32_t)((int64_t)v * (int32_t)idx / (int32_t)fade); + } else if (idx >= newTotal - fade) { + v = (int32_t)((int64_t)v * (int32_t)(newTotal - idx) / (int32_t)fade); + } + } + ioBuf[i] = (int16_t)v; + + if (env && cols) { + int32_t a = v < 0 ? -v : v; + if (a > 32767) a = 32767; + const uint16_t c = (uint16_t)(((uint64_t)idx * cols) / newTotal); + const uint8_t lvl = (uint8_t)(a >> 7); + if (lvl > env[c]) env[c] = lvl; + } + } + + UINT bw = 0; + f_lseek(&f, 44 + dst * 2); + res = f_write(&f, ioBuf, got * 2, &bw); + if (res != FR_OK || bw != got * 2) { + f_close(&f); + return res != FR_OK ? res : FR_DISK_ERR; + } + + src += got; + dst += got; + if (got < n) break; + } + + f_lseek(&f, 44 + newTotal * 2); + f_truncate(&f); + patchWavHeader(&f, newTotal); + + return f_close(&f); +} + +FRESULT PdmWavRecorder::finalise(const char* path, uint8_t* env, uint16_t cols, + bool trimSilence, uint32_t* totalSamples, + uint32_t* clippedPermille, + PressDiag* diag) +{ if (env && cols) memset(env, 0, cols); if (totalSamples) *totalSamples = 0; if (clippedPermille) *clippedPermille = 0; + if (diag) *diag = PressDiag{0, 0, 0, 0}; FIL f; FRESULT res = f_open(&f, path, FA_READ | FA_WRITE); @@ -252,35 +436,215 @@ FRESULT PdmWavRecorder::finalise(const char* path, uint8_t* env, uint16_t cols, // Pass 1 (sequential read): silence bounds, capture clipping, filtered peak. PostFilter flt; - uint32_t firstActive = UINT32_MAX; - uint32_t lastActive = 0; uint32_t clipped = 0; float peak = 0.0f; + float quietestRms = 0.0f; + bool haveRms = false; + + const uint32_t totalBlocks = (total + SUB_SAMPLES - 1) / SUB_SAMPLES; + // A take shorter than the window is searched whole rather than skipped. + const uint32_t firstTailBlock = + totalBlocks > CLICK_WINDOW_BLOCKS ? totalBlocks - CLICK_WINDOW_BLOCKS : 0; + + const uint32_t coarseSize = + total / COARSE_MAX + 1 > SUB_SAMPLES ? total / COARSE_MAX + 1 : SUB_SAMPLES; + const uint32_t coarseCount = (total + coarseSize - 1) / coarseSize; + memset(coarseLevel, 0, sizeof(coarseLevel)); + int32_t coarsePeak = 0; + + int32_t blockPeak[TAIL_RING] = {0}; + int32_t peakBeforeWindow = 0; + int32_t roomLevel = INT32_MAX; // quietest block: the take's own noise floor + int32_t subPeak = 0; for (uint32_t base = 0; base < total; base += CHUNK) { const uint32_t n = (base + CHUNK <= total) ? CHUNK : total - base; res = f_read(&f, ioBuf, n * 2, &br); if (res != FR_OK) { f_close(&f); return res; } const uint32_t got = br / 2; + float sumSq = 0.0f; for (uint32_t i = 0; i < got; i++) { + const uint32_t idx = base + i; const int16_t s = ioBuf[i]; const int32_t a = s < 0 ? -(int32_t)s : (int32_t)s; if (a >= 32767) clipped++; - if (a > SILENCE_THRESHOLD) { - if (firstActive == UINT32_MAX) firstActive = base + i; - lastActive = base + i; + if (a > subPeak) subPeak = a; + if ((idx + 1) % SUB_SAMPLES == 0 || idx + 1 == total) { + const uint32_t b = idx / SUB_SAMPLES; + blockPeak[b % TAIL_RING] = subPeak; + if (b < firstTailBlock && subPeak > peakBeforeWindow) + peakBeforeWindow = subPeak; + if (subPeak < roomLevel) roomLevel = subPeak; + subPeak = 0; + } + const float v = flt.process(s); + const float m = fabsf(v); + if (m > peak) peak = m; + sumSq += v * v; + + int32_t mi = (int32_t)m; + if (mi > 32767) mi = 32767; + if (mi > coarsePeak) coarsePeak = mi; + if ((idx + 1) % coarseSize == 0 || idx + 1 == total) { + coarseLevel[idx / coarseSize] = (uint16_t)coarsePeak; + coarsePeak = 0; } - const float v = fabsf(flt.process(s)); - if (v > peak) peak = v; + } + if (got == CHUNK) { // quietest full block is the noise floor + const float rms = sqrtf(sumSq / (float)CHUNK); + if (!haveRms || rms < quietestRms) { quietestRms = rms; haveRms = true; } } if (got < n) break; } + // Locate the stop press: the earliest block in the trailing window that + // stands clear of everything before it, provided the burst it starts is + // short enough to be a press and not the last word. + // Pressing the screen makes two bursts, not one: the finger landing and + // the finger lifting, with a decay dip between them. Walk back from the end + // over as many adjacent bursts as qualify, judging each against the voice + // level rather than against the other burst. + uint32_t clickStart = 0; + if (totalBlocks > CLICK_MAX_BLOCKS + CLICK_GUARD_BLOCKS + 2) { + if (roomLevel == INT32_MAX) roomLevel = 0; + const int32_t audible = roomLevel * 3 > CLICK_MIN_LEVEL ? roomLevel * 3 + : CLICK_MIN_LEVEL; + const int32_t voiceLevel = + peakBeforeWindow > audible ? peakBeforeWindow : audible; + if (diag) diag->voiceLevel = voiceLevel; + + uint32_t searchEnd = totalBlocks; + uint32_t cutBlock = 0; + int32_t burstPeak = 0; + bool haveCut = false; + + for (uint32_t iter = 0; iter < CLICK_MAX_BURSTS; iter++) { + // Between the landing and the lift there is decay, not silence. Once a + // burst is known, only something of comparable size counts as the next + // one, so that decay is crossed as a gap instead of ending the walk. + const int32_t seek = + haveCut && burstPeak / 8 > audible ? burstPeak / 8 : audible; + + uint32_t last = 0; + bool found = false; + for (uint32_t b = searchEnd; b-- > firstTailBlock;) { + if (blockPeak[b % TAIL_RING] > seek) { last = b; found = true; break; } + } + if (!found) break; + if (haveCut && cutBlock - last > CLICK_GAP_BLOCKS) break; + + int32_t eventPeak = blockPeak[last % TAIL_RING]; + uint32_t onset = last; + while (onset > firstTailBlock) { + const int32_t half = eventPeak / 2; + const int32_t limit = half > audible ? half : audible; + const int32_t prev = blockPeak[(onset - 1) % TAIL_RING]; + if (prev <= limit) break; + onset--; + if (prev > eventPeak) eventPeak = prev; + } + + if (diag && iter == 0) { + diag->burstPeak = eventPeak; + diag->burstMs = ((last - onset + 1) * SUB_SAMPLES * 1000U) / DST_RATE; + } + + if (onset <= firstTailBlock) break; // start not in view + if (last - onset + 1 > CLICK_MAX_BLOCKS) break; // too long to be a press + // No loudness test here on purpose: it would depend on where the window + // boundary happens to fall relative to the speech. Whether this really + // is a press is decided once, below, against the voice measured ahead + // of the whole run of bursts. + + cutBlock = onset; + if (eventPeak > burstPeak) burstPeak = eventPeak; + haveCut = true; + searchEnd = onset; + } + + if (haveCut) { + // The window is wide enough to hold a slow press-and-hold, which means + // it can also hold the tail of the voice. Re-measure the voice over + // everything ahead of the press before accepting it. + int32_t voiceCheck = peakBeforeWindow; + for (uint32_t b = firstTailBlock; b < cutBlock; b++) { + const int32_t l = blockPeak[b % TAIL_RING]; + if (l > voiceCheck) voiceCheck = l; + } + if (voiceCheck < audible) voiceCheck = audible; + if (diag) diag->voiceLevel = voiceCheck; + + // How much quiet sits ahead of the press. + const int32_t quiet = + voiceCheck / 8 > audible ? voiceCheck / 8 : audible; + uint32_t gapBlocks = 0; + for (uint32_t b = cutBlock; b-- > firstTailBlock;) { + if (blockPeak[b % TAIL_RING] > quiet) break; + gapBlocks++; + } + + // Twice the voice settles it outright. When capture clips, both the + // press and the voice sit at full scale and no ratio can separate + // them, so a burst merely as loud as the voice counts too - provided + // it follows a pause, which a word carried on from speech does not. + const bool louder = burstPeak >= voiceCheck * 2; + // Comparable, not greater: when both saturate, which of them reads a + // few counts higher is arbitrary. + const bool afterPause = + burstPeak * 4 >= voiceCheck * 3 && gapBlocks >= CLICK_PAUSE_BLOCKS; + + if (louder || afterPause) { + const uint32_t guard = cutBlock > firstTailBlock + CLICK_GUARD_BLOCKS + ? CLICK_GUARD_BLOCKS + : 0; + clickStart = (cutBlock - guard) * SUB_SAMPLES; + } + } + } + + if (diag && clickStart > 0) diag->cutSamples = total - clickStart; + + // Silence bounds, from the level map and a threshold set by the take's own + // voice peak. The press is excluded from both, so neither the threshold nor + // the padding is measured from it. + const uint32_t pressBlock = + clickStart > 0 ? clickStart / coarseSize : coarseCount; + + int32_t voicePeak = 0; + for (uint32_t b = 0; b < pressBlock && b < coarseCount; b++) { + if (coarseLevel[b] > voicePeak) voicePeak = coarseLevel[b]; + } + + int32_t silence = voicePeak / SILENCE_RATIO; + if (silence < roomLevel * 4) silence = roomLevel * 4; + if (silence < SILENCE_FLOOR) silence = SILENCE_FLOOR; + + uint32_t firstActive = UINT32_MAX, lastActive = 0; + if (trimSilence) { + for (uint32_t b = 0; b < pressBlock && b < coarseCount; b++) { + if (coarseLevel[b] <= silence) continue; + if (firstActive == UINT32_MAX) firstActive = b * coarseSize; + lastActive = (b + 1) * coarseSize - 1; + } + if (lastActive >= total) lastActive = total - 1; + } + uint32_t trimStart = 0, trimEnd = total - 1; if (firstActive != UINT32_MAX) { - trimStart = firstActive > PAD_SAMPLES ? firstActive - PAD_SAMPLES : 0; - trimEnd = (lastActive + PAD_SAMPLES < total) ? lastActive + PAD_SAMPLES - : total - 1; + trimStart = firstActive > PAD_HEAD_SAMPLES ? firstActive - PAD_HEAD_SAMPLES : 0; + trimEnd = (lastActive + PAD_TAIL_SAMPLES < total) ? lastActive + PAD_TAIL_SAMPLES + : total - 1; + } + if (clickStart > 0 && trimEnd >= clickStart) trimEnd = clickStart - 1; + + // If nothing cleared the threshold, the bounds can cross. Keep everything ahead of the press instead. + if (trimEnd <= trimStart) { + trimStart = 0; + trimEnd = clickStart > 0 ? clickStart - 1 : total - 1; + } + if (trimEnd <= trimStart) { + trimStart = 0; + trimEnd = total - 1; } const uint32_t newTotal = trimEnd - trimStart + 1; @@ -289,9 +653,23 @@ FRESULT PdmWavRecorder::finalise(const char* path, uint8_t* env, uint16_t cols, const bool doFilter = newTotal >= 2 * PP_FADE_SAMPLES; + // Measured over what survives the trim: the press is about to go, and + // normalising to it would leave the voice quiet. + float keptPeak = 0.0f; + for (uint32_t b = trimStart / coarseSize; + b <= trimEnd / coarseSize && b < coarseCount; b++) { + if ((float)coarseLevel[b] > keptPeak) keptPeak = (float)coarseLevel[b]; + } + if (keptPeak <= 1.0f) keptPeak = peak; + float gain = 1.0f; - if (doFilter && peak > 1.0f) { - gain = PP_NORM_TARGET / peak; + if (doFilter && keptPeak > 1.0f) { + gain = PP_NORM_TARGET / keptPeak; + if (haveRms && quietestRms > 1.0f) { + float noiseLimit = PP_NOISE_CEILING / quietestRms; + if (noiseLimit < 1.0f) noiseLimit = 1.0f; // never attenuate for noise + if (gain > noiseLimit) gain = noiseLimit; + } if (gain > PP_MAX_GAIN) gain = PP_MAX_GAIN; } diff --git a/radio/src/pdm_wav_recorder.h b/radio/src/pdm_wav_recorder.h index bca5e8eb49f..645ff801634 100644 --- a/radio/src/pdm_wav_recorder.h +++ b/radio/src/pdm_wav_recorder.h @@ -43,14 +43,42 @@ class PdmWavRecorder // Called from the audio task every ~4 ms. static void audioTick(); - // Finish a take in one go, in two passes over the card: trim leading and - // trailing silence, high-pass, compensate CIC droop, normalise, fade the - // edges, patch the WAV header, and report the peak envelope (one 0..255 - // level per column), the final length and how much of the capture hit - // int16 saturation (a high value means PDM_POST_GAIN_SHIFT is too hot). + // The tap or ENT press that ends a take is audible at the end of it. + // finalise() locates that burst and cuts from its onset, rather than + // dropping a fixed slice that would eat speech on a prompt stop. + + // Shortest take a trim is allowed to leave behind. + static constexpr uint32_t MIN_TAKE_MS = 200; + + // What the press detector saw. Reported so the review screen can show it. + struct PressDiag { + uint32_t cutSamples; // removed from the end as a press, 0 if none found + int32_t voiceLevel; // level the burst had to beat + int32_t burstPeak; // peak of the last loud event + uint32_t burstMs; // how long that event ran + }; + + // Finish a take in one go, in two passes over the card: remove the stop + // press, high-pass, compensate CIC droop, normalise, fade the edges, patch + // the WAV header, and report the peak envelope (one 0..255 level per + // column), the final length and how much of the capture hit int16 + // saturation (a high value means PDM_POST_GAIN_SHIFT is too hot). + // trimSilence additionally cuts leading and trailing silence; left to the + // Auto-trim button so the take is kept whole until asked otherwise. static FRESULT finalise(const char* path, uint8_t* env, uint16_t cols, - uint32_t* totalSamples = nullptr, - uint32_t* clippedPermille = nullptr); + bool trimSilence, uint32_t* totalSamples = nullptr, + uint32_t* clippedPermille = nullptr, + PressDiag* diag = nullptr); + + // Cut the take down to [from, to] in place, fading the new edges and + // refreshing the envelope. Pure sample surgery — no filtering or + // normalisation, so repeated trims never stack DSP on the same audio. + static FRESULT cut(const char* path, uint32_t from, uint32_t to, + uint8_t* env, uint16_t cols, + uint32_t* totalSamples = nullptr); + + // Silence bounds of the file as it stands, for the auto-trim button. + static FRESULT silenceBounds(const char* path, uint32_t* from, uint32_t* to); // Peak of everything written since the previous call, 0..255 of full scale. // Feeds the live waveform; a lost update just costs one display column. diff --git a/radio/src/translations/i18n/cn.h b/radio/src/translations/i18n/cn.h index ba6a1866492..33e9a99bbc3 100644 --- a/radio/src/translations/i18n/cn.h +++ b/radio/src/translations/i18n/cn.h @@ -978,6 +978,9 @@ #define TR_GET_READY "准备好..." #define TR_SAVED "已保存:" #define TR_SAVE_AS "另存为" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "打开失败" #define TR_TURN_OFF_RECEIVER "请关闭接收机" #define TR_STOPPING "正在停止..." diff --git a/radio/src/translations/i18n/cz.h b/radio/src/translations/i18n/cz.h index 31639cccbc2..87cd7aae99f 100644 --- a/radio/src/translations/i18n/cz.h +++ b/radio/src/translations/i18n/cz.h @@ -975,6 +975,9 @@ #define TR_GET_READY "Připravit se..." #define TR_SAVED "Uloženo:" #define TR_SAVE_AS "Uložit jako" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "Chyba otevření" #define TR_TURN_OFF_RECEIVER "Vypněte přijímač" #define TR_STOPPING "Zastavuji..." diff --git a/radio/src/translations/i18n/da.h b/radio/src/translations/i18n/da.h index 5883e4f8a63..9b316fdda9a 100644 --- a/radio/src/translations/i18n/da.h +++ b/radio/src/translations/i18n/da.h @@ -983,6 +983,9 @@ #define TR_GET_READY "Gør dig klar..." #define TR_SAVED "Gemt:" #define TR_SAVE_AS "Gem som" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "Åbn fejl" #define TR_TURN_OFF_RECEIVER "Sluk modtager" #define TR_STOPPING "Standser..." diff --git a/radio/src/translations/i18n/de.h b/radio/src/translations/i18n/de.h index 523744eeb59..c6425407c3b 100644 --- a/radio/src/translations/i18n/de.h +++ b/radio/src/translations/i18n/de.h @@ -977,6 +977,9 @@ #define TR_GET_READY "Bereit machen..." #define TR_SAVED "Gespeichert:" #define TR_SAVE_AS "Speichern als" +#define TR_AUTO_TRIM "Auto-Schnitt" +#define TR_TRIM_START "Anfang kürzen" +#define TR_TRIM_END "Ende kürzen" #define TR_OPEN_ERROR "Öffnungsfehler" #define TR_TURN_OFF_RECEIVER "Empf. ausschalten" #define TR_STOPPING "Stoppe..." diff --git a/radio/src/translations/i18n/en.h b/radio/src/translations/i18n/en.h index 3871b8a8225..29c42a0fa03 100644 --- a/radio/src/translations/i18n/en.h +++ b/radio/src/translations/i18n/en.h @@ -979,6 +979,9 @@ #define TR_GET_READY "Get ready..." #define TR_SAVED "Saved:" #define TR_SAVE_AS "Save as" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "Open error" #define TR_TURN_OFF_RECEIVER "Turn off receiver" #define TR_STOPPING "Stopping..." diff --git a/radio/src/translations/i18n/es.h b/radio/src/translations/i18n/es.h index 5ee42f63830..a448a47647f 100644 --- a/radio/src/translations/i18n/es.h +++ b/radio/src/translations/i18n/es.h @@ -971,6 +971,9 @@ #define TR_GET_READY "Prepárate..." #define TR_SAVED "Guardado:" #define TR_SAVE_AS "Guardar como" +#define TR_AUTO_TRIM "Auto-recorte" +#define TR_TRIM_START "Recortar inicio" +#define TR_TRIM_END "Recortar fin" #define TR_OPEN_ERROR "Error al abrir" #define TR_TURN_OFF_RECEIVER "Apaga el receptor" #define TR_STOPPING "Parando..." diff --git a/radio/src/translations/i18n/fi.h b/radio/src/translations/i18n/fi.h index 8173741c13f..e4f42775a99 100644 --- a/radio/src/translations/i18n/fi.h +++ b/radio/src/translations/i18n/fi.h @@ -971,6 +971,9 @@ #define TR_GET_READY "Valmistaudu..." #define TR_SAVED "Tallennettu:" #define TR_SAVE_AS "Tallenna nimellä" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "Avausvirhe" #define TR_TURN_OFF_RECEIVER "Turn off receiver" #define TR_STOPPING "Stopping..." diff --git a/radio/src/translations/i18n/fr.h b/radio/src/translations/i18n/fr.h index 20d8b0d6f0c..9d999c5118c 100644 --- a/radio/src/translations/i18n/fr.h +++ b/radio/src/translations/i18n/fr.h @@ -975,6 +975,9 @@ #define TR_GET_READY "Tenez-vous prêt..." #define TR_SAVED "Enregistré:" #define TR_SAVE_AS "Enregistrer sous" +#define TR_AUTO_TRIM "Auto-rogner" +#define TR_TRIM_START "Rogner début" +#define TR_TRIM_END "Rogner fin" #define TR_OPEN_ERROR "Erreur ouverture" #define TR_TURN_OFF_RECEIVER "Éteindre Récepteur." #define TR_STOPPING "Arrêt..." diff --git a/radio/src/translations/i18n/he.h b/radio/src/translations/i18n/he.h index bc6fe414b7e..d13dd9bcff1 100644 --- a/radio/src/translations/i18n/he.h +++ b/radio/src/translations/i18n/he.h @@ -981,6 +981,9 @@ #define TR_GET_READY "...היה מוכן" #define TR_SAVED ":נשמר" #define TR_SAVE_AS "שמור בשם" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "שגיאת פתיחה" #define TR_TURN_OFF_RECEIVER "כיבוי מקלט" #define TR_STOPPING "...עוצר" diff --git a/radio/src/translations/i18n/it.h b/radio/src/translations/i18n/it.h index 3d09308b611..16aa7b49950 100644 --- a/radio/src/translations/i18n/it.h +++ b/radio/src/translations/i18n/it.h @@ -982,6 +982,9 @@ #define TR_GET_READY "Preparati..." #define TR_SAVED "Salvato:" #define TR_SAVE_AS "Salva come" +#define TR_AUTO_TRIM "Auto-taglio" +#define TR_TRIM_START "Taglia inizio" +#define TR_TRIM_END "Taglia fine" #define TR_OPEN_ERROR "Errore apertura" #define TR_TURN_OFF_RECEIVER "Spegni la RX" #define TR_STOPPING "Fermando..." diff --git a/radio/src/translations/i18n/jp.h b/radio/src/translations/i18n/jp.h index 0be75883b0a..4f01731bee3 100644 --- a/radio/src/translations/i18n/jp.h +++ b/radio/src/translations/i18n/jp.h @@ -977,6 +977,9 @@ #define TR_GET_READY "Get ready..." #define TR_SAVED "Saved:" #define TR_SAVE_AS "Save as" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "Open error" #define TR_TURN_OFF_RECEIVER "受信機 電源OFF" #define TR_STOPPING "停止中..." diff --git a/radio/src/translations/i18n/ko.h b/radio/src/translations/i18n/ko.h index 2e39b382ed0..387134c4cb4 100644 --- a/radio/src/translations/i18n/ko.h +++ b/radio/src/translations/i18n/ko.h @@ -1021,6 +1021,9 @@ #define TR_GET_READY "준비하세요..." #define TR_SAVED "저장됨:" #define TR_SAVE_AS "다른 이름으로 저장" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "열기 오류" #define TR_TURN_OFF_RECEIVER "수신기 전원 끄기" diff --git a/radio/src/translations/i18n/nl.h b/radio/src/translations/i18n/nl.h index 8063bdf010d..31a11dd73f8 100644 --- a/radio/src/translations/i18n/nl.h +++ b/radio/src/translations/i18n/nl.h @@ -973,6 +973,9 @@ #define TR_GET_READY "Maak je klaar..." #define TR_SAVED "Opgeslagen:" #define TR_SAVE_AS "Opslaan als" +#define TR_AUTO_TRIM "Auto-trimmen" +#define TR_TRIM_START "Begin trimmen" +#define TR_TRIM_END "Einde trimmen" #define TR_OPEN_ERROR "Fout bij openen" #define TR_TURN_OFF_RECEIVER "Turn off receiver" #define TR_STOPPING "Stopping..." diff --git a/radio/src/translations/i18n/pl.h b/radio/src/translations/i18n/pl.h index 9ff0fce5aa0..3213543053f 100644 --- a/radio/src/translations/i18n/pl.h +++ b/radio/src/translations/i18n/pl.h @@ -970,6 +970,9 @@ #define TR_GET_READY "Przygotuj się..." #define TR_SAVED "Zapisano:" #define TR_SAVE_AS "Zapisz jako" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "Błąd otwarcia" #define TR_TURN_OFF_RECEIVER "Wyłącz odbiornik" #define TR_STOPPING "Stopping..." diff --git a/radio/src/translations/i18n/pt.h b/radio/src/translations/i18n/pt.h index 514b88483d2..7a7408cc311 100644 --- a/radio/src/translations/i18n/pt.h +++ b/radio/src/translations/i18n/pt.h @@ -978,6 +978,9 @@ #define TR_GET_READY "Prepare-se..." #define TR_SAVED "Guardado:" #define TR_SAVE_AS "Guardar como" +#define TR_AUTO_TRIM "Auto-corte" +#define TR_TRIM_START "Cortar início" +#define TR_TRIM_END "Cortar fim" #define TR_OPEN_ERROR "Erro ao abrir" #define TR_TURN_OFF_RECEIVER "Turn off receiver" #define TR_STOPPING "Stopping..." diff --git a/radio/src/translations/i18n/ru.h b/radio/src/translations/i18n/ru.h index bef8b02d5b2..344b2e4d35e 100644 --- a/radio/src/translations/i18n/ru.h +++ b/radio/src/translations/i18n/ru.h @@ -980,6 +980,9 @@ #define TR_GET_READY "Приготовьтесь..." #define TR_SAVED "Сохранено:" #define TR_SAVE_AS "Сохранить как" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "Ошибка открытия" #define TR_TURN_OFF_RECEIVER "Выкл приемник" #define TR_STOPPING "Остановка..." diff --git a/radio/src/translations/i18n/se.h b/radio/src/translations/i18n/se.h index ac524a162f9..1ff15db11aa 100644 --- a/radio/src/translations/i18n/se.h +++ b/radio/src/translations/i18n/se.h @@ -982,6 +982,9 @@ #define TR_GET_READY "Gör dig redo..." #define TR_SAVED "Sparad:" #define TR_SAVE_AS "Spara som" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "Öppningsfel" #define TR_TURN_OFF_RECEIVER "Stäng av mottagare" #define TR_STOPPING "Stoppar..." diff --git a/radio/src/translations/i18n/tw.h b/radio/src/translations/i18n/tw.h index 052a0ccc0a1..5ac9d5bd13e 100644 --- a/radio/src/translations/i18n/tw.h +++ b/radio/src/translations/i18n/tw.h @@ -975,6 +975,9 @@ #define TR_GET_READY "準備好..." #define TR_SAVED "已保存:" #define TR_SAVE_AS "另存為" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "打開失敗" #define TR_TURN_OFF_RECEIVER "請關閉接收機" #define TR_STOPPING "正在停止..." diff --git a/radio/src/translations/i18n/ua.h b/radio/src/translations/i18n/ua.h index b021986ca72..5460524c997 100644 --- a/radio/src/translations/i18n/ua.h +++ b/radio/src/translations/i18n/ua.h @@ -979,6 +979,9 @@ #define TR_GET_READY "Готуйтесь..." #define TR_SAVED "Збережено:" #define TR_SAVE_AS "Зберегти як" +#define TR_AUTO_TRIM "Auto-trim" +#define TR_TRIM_START "Trim start" +#define TR_TRIM_END "Trim end" #define TR_OPEN_ERROR "Помилка відкриття" #define TR_TURN_OFF_RECEIVER "Вимкнути приймач" #define TR_STOPPING "Зупинка..." diff --git a/radio/src/translations/sim_string_list.h b/radio/src/translations/sim_string_list.h index fdaaa1c5391..9b58afde9c5 100644 --- a/radio/src/translations/sim_string_list.h +++ b/radio/src/translations/sim_string_list.h @@ -244,6 +244,7 @@ // Main Menu #if defined(PDM_CLOCK) +#define STR_AUTO_TRIM currentLangStrings->STR_AUTO_TRIM #define STR_MIC_RECORDER currentLangStrings->STR_MIC_RECORDER #define STR_PUSH_TO_RECORD currentLangStrings->STR_PUSH_TO_RECORD #define STR_RECORD currentLangStrings->STR_RECORD @@ -254,6 +255,8 @@ #define STR_SAVED currentLangStrings->STR_SAVED #define STR_SAVE_AS currentLangStrings->STR_SAVE_AS #define STR_OPEN_ERROR currentLangStrings->STR_OPEN_ERROR +#define STR_TRIM_END currentLangStrings->STR_TRIM_END +#define STR_TRIM_START currentLangStrings->STR_TRIM_START #endif #else // !COLORLCD diff --git a/radio/src/translations/string_list.h b/radio/src/translations/string_list.h index 0ebecbbab6e..51c98b9d783 100644 --- a/radio/src/translations/string_list.h +++ b/radio/src/translations/string_list.h @@ -243,6 +243,7 @@ STR(MAIN_VIEW_9) // Main Menu #if defined(PDM_CLOCK) +STR(AUTO_TRIM) STR(MIC_RECORDER) STR(PUSH_TO_RECORD) STR(RECORD) @@ -253,6 +254,8 @@ STR(GET_READY) STR(SAVED) STR(SAVE_AS) STR(OPEN_ERROR) +STR(TRIM_END) +STR(TRIM_START) #endif #else // !COLORLCD From 42e546c808915fcf3bd859455f7c5b64591d0e26 Mon Sep 17 00:00:00 2001 From: philmoz Date: Wed, 26 Aug 2026 09:16:16 +1000 Subject: [PATCH 46/55] fix(color): in manage models, long press on selected model button clears checked state (#7707) (cherry picked from commit 94d5a89e338b18a11459fcca9fe612fb0fa309f4) --- radio/src/gui/colorlcd/model/model_select.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/radio/src/gui/colorlcd/model/model_select.cpp b/radio/src/gui/colorlcd/model/model_select.cpp index c3e36fb378e..874e689db9d 100644 --- a/radio/src/gui/colorlcd/model/model_select.cpp +++ b/radio/src/gui/colorlcd/model/model_select.cpp @@ -136,7 +136,6 @@ class ModelButton : public Button return true; } } - showNoImgMsg(); } return false; @@ -265,10 +264,12 @@ class ModelsPageBody : public Window // Long Press Handler for Models button->setLongPressHandler([=]() -> uint8_t { - button->setFocused(); - focusedModel = model; + if (model != focusedModel) { + button->setFocused(); + focusedModel = model; + } openMenu(); - return 0; + return model == modelslist.getCurrentModel(); }); } From 564535fe9b05e3d6c7a75c0343ab0beabc3ab923 Mon Sep 17 00:00:00 2001 From: 3djc <5167938+3djc@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:08:36 +0200 Subject: [PATCH 47/55] chore: remove a build warning (#7710) (cherry picked from commit ab3b90d9e5b53be4676b4496d8b06bf8e140db8d) --- radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp index 587aae12890..e212a88d72c 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp @@ -137,12 +137,12 @@ class WaveformView : public FormField etx_obj_add_style(trimKnob, styles->rounded, LV_PART_MAIN); lv_obj_add_flag(trimKnob, LV_OBJ_FLAG_HIDDEN); - clear(); + resetTrace(); } uint16_t columns() const { return maxCols; } - void clear() + void resetTrace() { count = 0; merge = 1; @@ -701,7 +701,7 @@ void RadioMicRecorder::enterRecording() actionButton->setText(STR_RECORD); return; } - waveform->clear(); + waveform->resetTrace(); waveform->show(); lastWaveSamples = 0; takeSamples = 0; From 2eb13b876ac01be503f2b97a919cbcb2cbe36990 Mon Sep 17 00:00:00 2001 From: philmoz Date: Wed, 26 Aug 2026 19:39:45 +1000 Subject: [PATCH 48/55] fix(color): modifying model labels may corrupt model yaml files (#7709) Co-authored-by: philmoz Co-authored-by: Peter Feerick Co-authored-by: Claude Sonnet 5 (cherry picked from commit 35662d02a19ec113557bd653fb6018b211312ace) --- radio/src/datastructs_private.h | 5 +- radio/src/storage/modelslist.cpp | 214 ++++++++++++------ radio/src/storage/modelslist.h | 5 +- .../storage/yaml/yaml_datastructs_128x64.cpp | 3 +- .../src/storage/yaml/yaml_datastructs_c14.cpp | 3 +- .../src/storage/yaml/yaml_datastructs_f16.cpp | 3 +- .../storage/yaml/yaml_datastructs_gx12.cpp | 3 +- .../storage/yaml/yaml_datastructs_gx15.cpp | 3 +- .../storage/yaml/yaml_datastructs_nb4p.cpp | 3 +- .../storage/yaml/yaml_datastructs_nv14.cpp | 3 +- .../storage/yaml/yaml_datastructs_pa01.cpp | 3 +- .../storage/yaml/yaml_datastructs_pl18.cpp | 3 +- .../storage/yaml/yaml_datastructs_pl18u.cpp | 3 +- .../storage/yaml/yaml_datastructs_st16.cpp | 3 +- .../src/storage/yaml/yaml_datastructs_t15.cpp | 3 +- .../storage/yaml/yaml_datastructs_t15pro.cpp | 3 +- .../src/storage/yaml/yaml_datastructs_t20.cpp | 3 +- .../src/storage/yaml/yaml_datastructs_t22.cpp | 3 +- .../storage/yaml/yaml_datastructs_tpro.cpp | 3 +- .../storage/yaml/yaml_datastructs_tx15.cpp | 3 +- .../yaml/yaml_datastructs_tx16smk3.cpp | 3 +- .../src/storage/yaml/yaml_datastructs_v12.cpp | 3 +- .../src/storage/yaml/yaml_datastructs_x10.cpp | 3 +- .../src/storage/yaml/yaml_datastructs_x9d.cpp | 3 +- .../yaml/yaml_datastructs_x9dp2019.cpp | 3 +- .../src/storage/yaml/yaml_datastructs_x9e.cpp | 3 +- .../storage/yaml/yaml_datastructs_xlite.cpp | 3 +- .../storage/yaml/yaml_datastructs_xlites.cpp | 3 +- radio/src/tests/modelslist.cpp | 166 ++++++++++++++ 29 files changed, 368 insertions(+), 97 deletions(-) create mode 100644 radio/src/tests/modelslist.cpp diff --git a/radio/src/datastructs_private.h b/radio/src/datastructs_private.h index 7beb924003c..f7aeb1bfd25 100644 --- a/radio/src/datastructs_private.h +++ b/radio/src/datastructs_private.h @@ -715,8 +715,9 @@ PACK(struct customSwitch { #endif PACK(struct PartialModel { + CUST_ATTR(semver,nullptr,w_semver); ModelHeader header; - TimerData timers[MAX_TIMERS]; + ModuleData moduleData[NUM_MODULES]; }); /* @@ -756,8 +757,10 @@ PACK(struct USBJoystickChData { }); PACK(struct ModelData { + // Must match start of PartialModel CUST_ATTR(semver,nullptr,w_semver); ModelHeader header; + TimerData timers[MAX_TIMERS]; uint8_t telemetryProtocol:3; uint8_t thrTrim:1; // Enable Throttle Trim diff --git a/radio/src/storage/modelslist.cpp b/radio/src/storage/modelslist.cpp index 4ffe397e331..92b9b541588 100644 --- a/radio/src/storage/modelslist.cpp +++ b/radio/src/storage/modelslist.cpp @@ -102,11 +102,11 @@ void ModelCell::setModelId(uint8_t moduleIdx, uint8_t id) modelId[moduleIdx] = id; } -void ModelCell::setRfData(ModelData *model) +void ModelCell::setRfData(ModelHeader *header, ModuleData* modData) { for (uint8_t i = 0; i < NUM_MODULES; i++) { - modelId[i] = model->header.modelId[i]; - setRfModuleData(i, &(model->moduleData[i])); + modelId[i] = header->modelId[i]; + setRfModuleData(i, &(modData[i])); TRACE("<%s/%i> : %X,%X,%X", strlen(modelName) ? modelName : modelFilename, i, moduleData[i].type, moduleData[i].subType, modelId[i]); } @@ -660,17 +660,6 @@ bool ModelMap::renameLabel(const std::string &from, std::string to, } } - ModelData *modeldata = (ModelData *)malloc(sizeof(ModelData)); - if (!modeldata) { - TRACE("Labels: Out Of Memory"); - if (progress != nullptr) progress("", 100); // Kill progress dialog - return true; - } - - // Force a write of any changes in memory - storageCheck(true); - - bool fault = false; ModelsVector mods = getModelsByLabel(from); // Find all models to be renamed // Scan all these models first, recombine their labels to a csv, @@ -685,22 +674,26 @@ bool ModelMap::renameLabel(const std::string &from, std::string to, if(curlen + csvto.size() - csvfrom.size() > LABELS_LENGTH - 1) { TRACE("Labels: Rename Error! Labels too long on %s", model->modelName); if (progress != nullptr) progress("", 100); // Kill progress dialog - free(modeldata); return true; } } + // Force a write of any changes in memory + storageCheck(true); + + bool fault = false; int i = 0; for (const auto &modcell : mods) { if (progress != nullptr) { progress(modcell->modelFilename, (i++) * 100 / mods.size()); } - readModelYaml(modcell->modelFilename, (uint8_t *)modeldata, - sizeof(ModelData)); + PartialModel partial; + memclear(&partial, sizeof(PartialModel)); + readModelYaml(modcell->modelFilename, (uint8_t*)&partial, sizeof(PartialModel)); // Separate Curent CSV - LabelsVector lbls = ModelMap::fromCSV(modeldata->header.labels); + LabelsVector lbls = ModelMap::fromCSV(partial.header.labels); // Replace from->to strings for (auto &lbl : lbls) { @@ -714,20 +707,14 @@ bool ModelMap::renameLabel(const std::string &from, std::string to, lbls.resize(std::distance(lbls.begin(), last)); // Write back - strncpy(modeldata->header.labels, ModelMap::toCSV(lbls).c_str(), LABELS_LENGTH); - modeldata->header.labels[LABELS_LENGTH-1] = '\0'; - - char path[256]; - getModelPath(path, modcell->modelFilename); + strAppend(partial.header.labels, ModelMap::toCSV(lbls).c_str(), LABELS_LENGTH - 1); if (modcell == modelslist.getCurrentModel()) { - // If working on the current model, write current data to file instead - memcpy(g_model.header.labels, modeldata->header.labels, LABELS_LENGTH); - fault = (writeFileYaml(path, get_modeldata_nodes(), - (uint8_t *)&g_model, 0) != NULL); + // If working on the current model, copy new labels mark dirty + memcpy(g_model.header.labels, partial.header.labels, LABELS_LENGTH); + storageDirty(EE_MODEL); } else { - fault = (writeFileYaml(path, get_modeldata_nodes(), - (uint8_t *)modeldata, 0) != NULL); + fault = writeModelLabels(modcell, partial.header.labels); } #if defined(SIMU) sleep_ms(100); @@ -745,8 +732,6 @@ bool ModelMap::renameLabel(const std::string &from, std::string to, // Make sure to leave at 100, to kill rename dialog if (progress != nullptr) progress("", 100); - free(modeldata); - // Issue a rescan all of all models. modelslist.clear(); modelslist.load(); @@ -808,6 +793,122 @@ bool ModelMap::removeModels(ModelCell *cell) return rv; } +/** + * @brief Update the labels in an existing model yaml file on SD card + * + * @param cell Model to update + * @return true Success + * @return false Failure + */ +bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) +{ + TRACE("Updating labels in %s",cell->modelFilename); + + UINT bytes_cnt; + char buf[512]; + char tempPath[256]; + FIL out; + FIL file; + + // Read exiting model header + PartialModel partial; + memclear(&partial, sizeof(PartialModel)); + readModelYaml(cell->modelFilename, (uint8_t*)&partial, sizeof(PartialModel)); + + // Update header with new labels + strAppend(partial.header.labels, labels, LABELS_LENGTH - 1); + // Remove module data - only want to write the header + memclear(&partial.moduleData, sizeof(ModuleData) * NUM_MODULES); + + // Write new header to a temp file + getModelPath(tempPath, "tmp.yml"); + if (writeFileYaml(tempPath, get_partialmodel_nodes(), (uint8_t *)&partial, 0) != NULL) { + TRACE("ERROR writing temp model file"); + f_unlink(tempPath); + return false; + } + + // Open tmp file for appending + FRESULT result = f_open(&out, tempPath, FA_OPEN_EXISTING | FA_WRITE | FA_OPEN_APPEND); + if (result != FR_OK) { + TRACE("ERROR opening temp file"); + f_unlink(tempPath); + return false; + } + + // Copy rest of model yaml from original file to temp file + getModelPath(buf, cell->modelFilename); + result = f_open(&file, buf, FA_OPEN_EXISTING | FA_READ); + if (result != FR_OK) { + f_close(&out); + f_unlink(tempPath); + return false; + } + + // Read old header - assumes header fits within first 512 bytes + // header has not changed significantly for a long time so should be safe + result = f_read(&file, buf, sizeof(buf), &bytes_cnt); + if (result != FR_OK) { + f_close(&out); + f_close(&file); + f_unlink(tempPath); + return false; + } + + // Find header section + int n = 0; + while (n < sizeof(buf) - 7 && strncmp(&buf[n], "header:", 7) != 0) + n += 1; + + if (n >= sizeof(buf) - 7) { + TRACE("ERROR model header not found in %s", cell->modelFilename); + f_close(&out); + f_close(&file); + f_unlink(tempPath); + return false; + } + + // Skip header section - look for next section after 'header:' + do { + // Skip current line + while (n < sizeof(buf) && buf[n] != '\n') n += 1; + n += 1; + } while ((n < sizeof(buf)) && buf[n] == ' '); + + if (n >= sizeof(buf)) { + TRACE("ERROR could not match model header in %s", cell->modelFilename); + f_close(&out); + f_close(&file); + f_unlink(tempPath); + return false; + } + + // Write remainder of first buffer after header + result = f_write(&out, &buf[n], sizeof(buf) - n, &bytes_cnt); + // Block copy the rest of the original file to the temp file + while (result == FR_OK && bytes_cnt != 0) { + result = f_read(&file, buf, sizeof(buf), &bytes_cnt); + if (result == FR_OK && bytes_cnt != 0) + result = f_write(&out, buf, bytes_cnt, &bytes_cnt); + } + + f_close(&out); + f_close(&file); + + if (result != FR_OK) { + TRACE("ERROR copying to temp file"); + f_unlink(tempPath); + return false; + } + + // Delete original file and rename temp file + getModelPath(buf, cell->modelFilename); + f_unlink(buf); + f_rename(tempPath, buf); + + return true; +} + /** * @brief Opens a model.yml File and writes labels data into it. * @details If the cell is current model then write the labels data to g_model @@ -829,25 +930,7 @@ bool ModelMap::updateModelFile(ModelCell *cell) return false; } - ModelData *modeldata = (ModelData *)malloc(sizeof(ModelData)); - if (!modeldata) { - TRACE("Labels: Out Of Memory"); - return true; - } - - bool fault = false; - readModelYaml(cell->modelFilename, (uint8_t *)modeldata, sizeof(ModelData)); - - strncpy(modeldata->header.labels, ModelMap::toCSV(getLabelsByModel(cell)).c_str(), - LABELS_LENGTH - 1); - modeldata->header.labels[LABELS_LENGTH - 1] = '\0'; - - char path[256]; - getModelPath(path, cell->modelFilename); - fault = (writeFileYaml(path, get_modeldata_nodes(), (uint8_t *)modeldata, 0) != - NULL); - - free(modeldata); + bool fault = writeModelLabels(cell, ModelMap::toCSV(getLabelsByModel(cell)).c_str()); #if defined(DEBUG_TIMERS) DEBUG_TIMER_SAMPLE(debugTimerYamlScan); @@ -930,28 +1013,23 @@ void ModelMap::updateModelCell(ModelCell *cell) { modelslabels.removeModels(cell); - ModelData *model = (ModelData *)malloc(sizeof(ModelData)); - if (!model) { - TRACE("Labels: Out Of Memory"); - return; - } - TRACE("Labels: Updating model %s", cell->modelFilename); - readModelYaml(cell->modelFilename, (uint8_t *)model, sizeof(ModelData)); - strncpy(cell->modelName, model->header.name, LEN_MODEL_NAME); - cell->modelName[LEN_MODEL_NAME] = '\0'; - strncpy(cell->modelBitmap, model->header.bitmap, LEN_BITMAP_NAME); - cell->modelBitmap[LEN_BITMAP_NAME] = '\0'; - LabelsVector labels = ModelMap::fromCSV(model->header.labels); + + PartialModel partial; + memclear(&partial, sizeof(PartialModel)); + readModelYaml(cell->modelFilename, (uint8_t*)&partial, sizeof(PartialModel)); + + strAppend(cell->modelName, partial.header.name, LEN_MODEL_NAME); + strAppend(cell->modelBitmap, partial.header.bitmap, LEN_BITMAP_NAME); + LabelsVector labels = ModelMap::fromCSV(partial.header.labels); for(const auto &lbl : labels ) { modelslabels.addLabelToModel(lbl,cell); } // Save Module Data - cell->setRfData(model); + cell->setRfData(&partial.header, partial.moduleData); cell->_isDirty = false; - free(model); } /** @@ -1326,13 +1404,11 @@ void ModelsList::updateCurrentModelCell() { if (currentModel) { #if LEN_BITMAP_NAME > 0 - strncpy(currentModel->modelBitmap, g_model.header.bitmap, LEN_BITMAP_NAME); - currentModel->modelBitmap[LEN_BITMAP_NAME] = '\0'; + strAppend(currentModel->modelBitmap, g_model.header.bitmap, LEN_BITMAP_NAME); #endif - strncpy(currentModel->modelFilename, g_eeGeneral.currModelFilename, LEN_MODEL_FILENAME); - currentModel->modelFilename[LEN_MODEL_FILENAME] = '\0'; + strAppend(currentModel->modelFilename, g_eeGeneral.currModelFilename, LEN_MODEL_FILENAME); currentModel->setModelName(g_model.header.name); - currentModel->setRfData(&g_model); + currentModel->setRfData(&g_model.header, g_model.moduleData); modelslabels.setDirty(); } else { TRACE("ModelList Error - No Current Model"); diff --git a/radio/src/storage/modelslist.h b/radio/src/storage/modelslist.h index 4e5285fc4b2..a29f99c71f9 100644 --- a/radio/src/storage/modelslist.h +++ b/radio/src/storage/modelslist.h @@ -43,7 +43,7 @@ #define DEFAULT_MODEL_SORT NAME_ASC -struct ModelData; +struct ModelHeader; struct ModuleData; struct SimpleModuleData { @@ -80,7 +80,7 @@ class ModelCell void setModelName(char *name); void setModelName(char *name, uint8_t len); - void setRfData(ModelData *model); + void setRfData(ModelHeader *header, ModuleData* moduleData); void setModelId(uint8_t moduleIdx, uint8_t id); void setRfModuleData(uint8_t moduleIdx, ModuleData *modData); @@ -168,6 +168,7 @@ class ModelMap : protected std::multimap ModelCell *); // Should only be called from ModelsList remove model bool updateModelFile(ModelCell *); void sortModelsBy(ModelsVector &mv, ModelsSortBy sortby); + bool writeModelLabels(ModelCell*, const char*); void clear() { diff --git a/radio/src/storage/yaml/yaml_datastructs_128x64.cpp b/radio/src/storage/yaml/yaml_datastructs_128x64.cpp index 007a02f3652..fad80f5e1bd 100644 --- a/radio/src/storage/yaml/yaml_datastructs_128x64.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_128x64.cpp @@ -877,8 +877,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 96, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 96, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_c14.cpp b/radio/src/storage/yaml/yaml_datastructs_c14.cpp index 4732c417ee3..4ef832fd7c8 100644 --- a/radio/src/storage/yaml/yaml_datastructs_c14.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_c14.cpp @@ -966,8 +966,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_f16.cpp b/radio/src/storage/yaml/yaml_datastructs_f16.cpp index 737ba38f92f..765f94090d2 100644 --- a/radio/src/storage/yaml/yaml_datastructs_f16.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_f16.cpp @@ -967,8 +967,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_gx12.cpp b/radio/src/storage/yaml/yaml_datastructs_gx12.cpp index 4a3a8c029b8..4517ad8c93c 100644 --- a/radio/src/storage/yaml/yaml_datastructs_gx12.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_gx12.cpp @@ -925,8 +925,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 96, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 96, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_gx15.cpp b/radio/src/storage/yaml/yaml_datastructs_gx15.cpp index a2531d0f9b7..08a369c420d 100644 --- a/radio/src/storage/yaml/yaml_datastructs_gx15.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_gx15.cpp @@ -1015,8 +1015,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_nb4p.cpp b/radio/src/storage/yaml/yaml_datastructs_nb4p.cpp index 5955ec54b07..d9ac4df4bda 100644 --- a/radio/src/storage/yaml/yaml_datastructs_nb4p.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_nb4p.cpp @@ -957,8 +957,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_nv14.cpp b/radio/src/storage/yaml/yaml_datastructs_nv14.cpp index a96e30cf133..918909b82ab 100644 --- a/radio/src/storage/yaml/yaml_datastructs_nv14.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_nv14.cpp @@ -965,8 +965,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_pa01.cpp b/radio/src/storage/yaml/yaml_datastructs_pa01.cpp index 846201b7fba..c96e0936b7c 100644 --- a/radio/src/storage/yaml/yaml_datastructs_pa01.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_pa01.cpp @@ -1014,8 +1014,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_pl18.cpp b/radio/src/storage/yaml/yaml_datastructs_pl18.cpp index d00d18f1aa6..8eb4d6c2599 100644 --- a/radio/src/storage/yaml/yaml_datastructs_pl18.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_pl18.cpp @@ -965,8 +965,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_pl18u.cpp b/radio/src/storage/yaml/yaml_datastructs_pl18u.cpp index 6793a80d279..54bb3aa2128 100644 --- a/radio/src/storage/yaml/yaml_datastructs_pl18u.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_pl18u.cpp @@ -957,8 +957,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_st16.cpp b/radio/src/storage/yaml/yaml_datastructs_st16.cpp index 058aa58aa03..9af5eb55d21 100644 --- a/radio/src/storage/yaml/yaml_datastructs_st16.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_st16.cpp @@ -1014,8 +1014,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_t15.cpp b/radio/src/storage/yaml/yaml_datastructs_t15.cpp index 59e9418a3e7..153c25ee0ec 100644 --- a/radio/src/storage/yaml/yaml_datastructs_t15.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_t15.cpp @@ -992,8 +992,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_t15pro.cpp b/radio/src/storage/yaml/yaml_datastructs_t15pro.cpp index 253887ecec5..f8b43873a66 100644 --- a/radio/src/storage/yaml/yaml_datastructs_t15pro.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_t15pro.cpp @@ -1014,8 +1014,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_t20.cpp b/radio/src/storage/yaml/yaml_datastructs_t20.cpp index 47acf972e53..e5f97c7c95b 100644 --- a/radio/src/storage/yaml/yaml_datastructs_t20.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_t20.cpp @@ -903,8 +903,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 96, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 96, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_t22.cpp b/radio/src/storage/yaml/yaml_datastructs_t22.cpp index 253887ecec5..f8b43873a66 100644 --- a/radio/src/storage/yaml/yaml_datastructs_t22.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_t22.cpp @@ -1014,8 +1014,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_tpro.cpp b/radio/src/storage/yaml/yaml_datastructs_tpro.cpp index 07f24dd8995..2488e36f16c 100644 --- a/radio/src/storage/yaml/yaml_datastructs_tpro.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_tpro.cpp @@ -903,8 +903,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 96, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 96, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_tx15.cpp b/radio/src/storage/yaml/yaml_datastructs_tx15.cpp index a2531d0f9b7..08a369c420d 100644 --- a/radio/src/storage/yaml/yaml_datastructs_tx15.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_tx15.cpp @@ -1015,8 +1015,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_tx16smk3.cpp b/radio/src/storage/yaml/yaml_datastructs_tx16smk3.cpp index 72e85c373a5..148deeb0370 100644 --- a/radio/src/storage/yaml/yaml_datastructs_tx16smk3.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_tx16smk3.cpp @@ -1016,8 +1016,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_v12.cpp b/radio/src/storage/yaml/yaml_datastructs_v12.cpp index bfc5a885c94..6a4d2b60066 100644 --- a/radio/src/storage/yaml/yaml_datastructs_v12.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_v12.cpp @@ -1010,8 +1010,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_x10.cpp b/radio/src/storage/yaml/yaml_datastructs_x10.cpp index e0da39a0ce0..e73618ae7c9 100644 --- a/radio/src/storage/yaml/yaml_datastructs_x10.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_x10.cpp @@ -967,8 +967,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 1048, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_x9d.cpp b/radio/src/storage/yaml/yaml_datastructs_x9d.cpp index 86d0afca491..9dc0fdd0e11 100644 --- a/radio/src/storage/yaml/yaml_datastructs_x9d.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_x9d.cpp @@ -880,8 +880,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 192, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_x9dp2019.cpp b/radio/src/storage/yaml/yaml_datastructs_x9dp2019.cpp index 86d0afca491..9dc0fdd0e11 100644 --- a/radio/src/storage/yaml/yaml_datastructs_x9dp2019.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_x9dp2019.cpp @@ -880,8 +880,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 192, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_x9e.cpp b/radio/src/storage/yaml/yaml_datastructs_x9e.cpp index 5def72c269e..0ea5b2c9e05 100644 --- a/radio/src/storage/yaml/yaml_datastructs_x9e.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_x9e.cpp @@ -881,8 +881,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 192, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 136, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_xlite.cpp b/radio/src/storage/yaml/yaml_datastructs_xlite.cpp index a079cbb8632..33702e84f1d 100644 --- a/radio/src/storage/yaml/yaml_datastructs_xlite.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_xlite.cpp @@ -878,8 +878,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 96, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 96, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/storage/yaml/yaml_datastructs_xlites.cpp b/radio/src/storage/yaml/yaml_datastructs_xlites.cpp index 30ce8aed82b..d0db0d825c2 100644 --- a/radio/src/storage/yaml/yaml_datastructs_xlites.cpp +++ b/radio/src/storage/yaml/yaml_datastructs_xlites.cpp @@ -883,8 +883,9 @@ static const struct YamlNode struct_ModelData[] = { YAML_END }; static const struct YamlNode struct_PartialModel[] = { + YAML_CUSTOM("semver",nullptr,w_semver), YAML_STRUCT("header", 96, struct_ModelHeader, NULL), - YAML_ARRAY("timers", 96, 3, struct_TimerData, NULL), + YAML_ARRAY("moduleData", 232, 2, struct_ModuleData, NULL), YAML_END }; diff --git a/radio/src/tests/modelslist.cpp b/radio/src/tests/modelslist.cpp new file mode 100644 index 00000000000..1d2bb39884a --- /dev/null +++ b/radio/src/tests/modelslist.cpp @@ -0,0 +1,166 @@ +/* + * Copyright (C) EdgeTX + * + * Based on code named + * opentx - https://github.com/opentx/opentx + * th9x - http://code.google.com/p/th9x + * er9x - http://code.google.com/p/er9x + * gruvin9x - http://code.google.com/p/gruvin9x + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +// Targeted tests for the PartialModel/ModelHeader restructuring in PR #7709 +// ("fix(color): modifying model labels may corrupt model yaml files"). +// +// These deliberately avoid FatFS/SD-card I/O and instead exercise the two +// things that PR touches which can silently drift out of sync across the +// 27 hand-edited yaml_datastructs_*.cpp files: the PartialModel YAML node +// table, and the strAppend() length constants used to write header.labels. + +#include "gtests.h" + +#include "storage/yaml/yaml_tree_walker.h" +#include "storage/yaml/yaml_parser.h" +#include "storage/yaml/yaml_datastructs.h" + +// Find an immediate child of an array/struct YamlNode by tag. +static const YamlNode* findChild(const YamlNode* root, const char* tag) +{ + const YamlNode* child = root->u._array.child; + for (; child->type != YDT_NONE; child++) { + if (child->tag && strcmp(child->tag, tag) == 0) return child; + } + return nullptr; +} + +// The PR hand-edited struct_PartialModel's "moduleData" YAML_ARRAY entry in +// 27 generated files to read `YAML_ARRAY("moduleData", , , +// struct_ModuleData, NULL)`, replacing the old "timers" entry. must +// equal sizeof(ModuleData)*8 and the element count must equal NUM_MODULES, +// or the YAML (de)serializer will misalign every field after moduleData in +// any file that uses struct_PartialModel (readModelYaml/writeModelLabels). +TEST(PartialModel, ModuleDataNodeMatchesStructLayout) +{ + const YamlNode* root = get_partialmodel_nodes(); + const YamlNode* moduleData = findChild(root, "moduleData"); + ASSERT_NE(moduleData, nullptr); + + EXPECT_EQ(moduleData->elmts, NUM_MODULES); + EXPECT_EQ(moduleData->size, sizeof(ModuleData) * 8); +} + +// Same idea for the "header" entry: its declared bit size must equal +// sizeof(ModelHeader)*8, or moduleData (and thus every array element after +// it) parses starting at the wrong bit offset. +TEST(PartialModel, HeaderNodeMatchesStructLayout) +{ + const YamlNode* root = get_partialmodel_nodes(); + const YamlNode* header = findChild(root, "header"); + ASSERT_NE(header, nullptr); + + EXPECT_EQ(header->size, sizeof(ModelHeader) * 8); +} + +// PartialModel must be layout-compatible with the start of ModelData, per +// the comment added at datastructs_private.h ModelData::header ("Must match +// start of PartialModel") -- readModelCell()/updateModelCell() rely on this +// so that data read into a PartialModel (a stack buffer) reflects the same +// bytes that would land in the corresponding fields of a full ModelData. +TEST(PartialModel, HeaderOffsetMatchesModelData) +{ + EXPECT_EQ(offsetof(PartialModel, header), offsetof(ModelData, header)); +} + +// End-to-end: parse the same YAML "header:" block once into a PartialModel +// and once into a full ModelData, and check they agree field-for-field. +// This is the strongest guard against the two generated tables silently +// drifting apart, because it doesn't hardcode any bit offsets itself -- it +// just requires both parsers to produce the same result for the same input. +TEST(PartialModel, HeaderParsesIdenticallyToModelData) +{ + static const char yaml[] = + "header: \n" + " name: \"Tst Name\"\n" + " modelId: [3, 5]\n" +#if LEN_BITMAP_NAME > 0 + " bitmap: \"pic.bmp\"\n" +#endif +#if defined(STORAGE_MODELSLIST) + " labels: \"alpha,bravo\"\n" +#endif + ; + + PartialModel partial; + memclear(&partial, sizeof(partial)); + { + YamlTreeWalker tree; + tree.reset(get_partialmodel_nodes(), (uint8_t*)&partial); + YamlParser yp; + yp.init(YamlTreeWalker::get_parser_calls(), &tree); + yp.parse(yaml, sizeof(yaml) - 1); + } + + ModelData model; + memclear(&model, sizeof(model)); + { + YamlTreeWalker tree; + tree.reset(get_modeldata_nodes(), (uint8_t*)&model); + YamlParser yp; + yp.init(YamlTreeWalker::get_parser_calls(), &tree); + yp.parse(yaml, sizeof(yaml) - 1); + } + + EXPECT_STREQ(partial.header.name, model.header.name); + EXPECT_EQ(0, memcmp(partial.header.modelId, model.header.modelId, + sizeof(model.header.modelId))); +#if LEN_BITMAP_NAME > 0 + EXPECT_STREQ(partial.header.bitmap, model.header.bitmap); +#endif +#if defined(STORAGE_MODELSLIST) + EXPECT_STREQ(partial.header.labels, model.header.labels); +#endif + + // Sanity: the fixture actually populated something non-zero, otherwise a + // parser that silently no-ops on both sides would pass trivially. + EXPECT_STREQ("Tst Name", partial.header.name); +} + +// Behavioural contract for ModelHeader::labels: it holds the CSV-joined +// list of *every* label attached to a model (declared as +// char[LABELS_LENGTH], currently 100 bytes) -- as distinct from the length +// of one individual label name (LABEL_LENGTH, 16 bytes). A model with +// several labels attached should keep all of them when this field gets +// (re)written, up to the field's own declared capacity, not just as much +// as would fit in a single label name. +// +// header.labels only exists on targets with STORAGE_MODELSLIST. +#if defined(STORAGE_MODELSLIST) +TEST(PartialModel, LabelsFieldRetainsFullCsvUpToItsOwnCapacity) +{ + PartialModel partial; + memclear(&partial, sizeof(partial)); + + // A multi-label CSV comfortably inside LABELS_LENGTH, but longer than a + // single LABEL_LENGTH-sized name -- i.e. more than one label attached. + const char* csv = "alpha,bravo,charlie,delta,echo"; + ASSERT_GT(strlen(csv), (size_t)LABEL_LENGTH); + ASSERT_LT(strlen(csv), (size_t)LABELS_LENGTH); + + strAppend(partial.header.labels, csv, LABELS_LENGTH - 1); + + EXPECT_STREQ(csv, partial.header.labels) + << "header.labels should retain the full label list up to " + "LABELS_LENGTH-1 characters, not just a single label name's " + "worth."; +} +#endif // defined(STORAGE_MODELSLIST) From 56dba486dbc07161431b4d736ace3c0cf2a3518b Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:53:54 +0000 Subject: [PATCH 49/55] chore: bump version (cherry picked from commit e5fc3949cb605f1c0001e668a13af016a75f69ae) (cherry picked from commit 9e9a999f7248de01988dfd535554471c875bd4d0) (cherry picked from commit 33d7c103ad59f68e35c7b763c9b68d417b7b0733) (cherry picked from commit c1e55b0345a8a719ac84a27d98bbc906db7405ac) --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5ab08db76f3..22af0ad2a95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ project(EdgeTX) set(VERSION_MAJOR "2") set(VERSION_MINOR "12") -set(VERSION_REVISION "2") +set(VERSION_REVISION "3") set(CODENAME "Queen Anne's Revenge") string(TIMESTAMP BUILD_YEAR "%Y") From fbd4b7d4facf0996c590d8b9ffbceb37d58dae20 Mon Sep 17 00:00:00 2001 From: Peter Feerick Date: Thu, 27 Aug 2026 08:44:37 +1000 Subject: [PATCH 50/55] test: guard active model's screen/widget data against cross-model label edits (#7712) Co-authored-by: Claude Sonnet 5 (cherry picked from commit d219690578e4bc4b498326f1a6573a7880ab3760) --- radio/src/tests/modelslist_labels.cpp | 177 ++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 radio/src/tests/modelslist_labels.cpp diff --git a/radio/src/tests/modelslist_labels.cpp b/radio/src/tests/modelslist_labels.cpp new file mode 100644 index 00000000000..1388015918a --- /dev/null +++ b/radio/src/tests/modelslist_labels.cpp @@ -0,0 +1,177 @@ +/* + * Copyright (C) EdgeTX + * + * Based on code named + * opentx - https://github.com/opentx/opentx + * th9x - http://code.google.com/p/th9x + * er9x - http://code.google.com/p/er9x + * gruvin9x - http://code.google.com/p/gruvin9x + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +// Custom-screen and topbar-widget layout data (CustomScreenData / +// TopBarPersistentData) is stored per-process in static globals rather than +// inside ModelData itself (see ModelData::getScreenData()/getTopbarData()), +// because that data can be large and only the currently loaded model's copy +// needs to be resident. Any code that reads or writes a *full* ModelData +// buffer -- as opposed to the header-only PartialModel -- for a model other +// than the one currently loaded therefore risks reading/writing those +// globals on behalf of the wrong model. +// +// Label management (renaming/adding/removing labels) routinely touches +// models other than the currently loaded one. These tests exercise that +// label-management path through its public ModelMap API and assert the +// currently loaded model's screen/topbar data is left alone, regardless of +// what label edits are made to other models on disk. + +#include "gtests.h" +#include "location.h" + +#include + +#include "storage/modelslist.h" +#include "storage/sdcard_common.h" +#include "storage/sdcard_yaml.h" +#include "storage/yaml/yaml_datastructs.h" + +#if defined(COLORLCD) + +namespace fs = std::filesystem; + +class ModelMapFsTest : public ::testing::Test +{ + protected: + fs::path scratchDir; + + void SetUp() override + { + scratchDir = fs::temp_directory_path() / + fs::path("edgetx-gtest-modelslist-labels"); + std::error_code ec; + fs::remove_all(scratchDir, ec); + fs::create_directories(scratchDir / "MODELS", ec); + ASSERT_FALSE(ec) << "could not create scratch MODELS directory"; + + simuFatfsSetPaths(scratchDir.string().c_str(), nullptr); + + modelslist.clear(); + modelslabels.clear(); + memclear(&g_model, sizeof(g_model)); + } + + void TearDown() override + { + modelslist.clear(); + modelslabels.clear(); + + simuFatfsSetPaths(TESTS_PATH, nullptr); + + std::error_code ec; + fs::remove_all(scratchDir, ec); + } + + // Writes a standalone model YAML file to /MODELS/ + // with the given label list and screen/topbar marker values. This must be + // called *before* the active model's own screen/topbar markers are set on + // g_model: since getScreenData()/getTopbarData() are shared globals, using + // them here to build this "other" model's fixture is itself writing + // through the same storage g_model uses, exactly like the code under test + // does. The test bodies set the active model's markers afterwards, which + // simulates that model actually being loaded, and is what the assertions + // check survives the label edit untouched. + void writeFixtureModel(const char* filename, const char* labels, + const char* screenLayoutId, const char* widgetName) + { + ModelData model; + memclear(&model, sizeof(model)); + // A real model always has a name; an entirely-default header (no name, + // no labels, zeroed modelId) is omitted from the written YAML altogether + // as a compaction optimisation, which would leave this fixture without + // a "header:" section at all. Giving it a name keeps the fixture + // realistic and avoids that unrelated edge case. + strAppend(model.header.name, filename, LEN_MODEL_NAME); + strAppend(model.header.labels, labels, LABELS_LENGTH - 1); + model.getScreenData(0)->LayoutId = screenLayoutId; + model.getTopbarData()->zones[0].widgetName = widgetName; + + char path[256]; + getModelPath(path, filename); + ASSERT_EQ(writeFileYaml(path, get_modeldata_nodes(), (uint8_t*)&model, 0), + (const char*)nullptr) + << "failed writing fixture model " << filename; + } +}; + +TEST_F(ModelMapFsTest, RenamingLabelOnOtherModelLeavesActiveScreenDataUntouched) +{ + // Seed a second, non-active model on disk with a label and screen/topbar + // data distinct from the active model's. + writeFixtureModel("model0002.yml", "Foo", "OtherLayout", "OtherWidget"); + ModelCell* other = modelslist.addModel("model0002.yml", false); + ASSERT_NE(other, nullptr); + ASSERT_FALSE(modelslabels.addLabelToModel("Foo", other, false)); + + // Now "load" the active model: register its cell and mark it current, and + // set its screen/topbar data to its own values. + ModelCell* active = modelslist.addModel("model0001.yml", false); + ASSERT_NE(active, nullptr); + modelslist.setCurrentModel(active); + g_model.getScreenData(0)->LayoutId = "ActiveLayout"; + g_model.getTopbarData()->zones[0].widgetName = "ActiveWidget"; + + // Rename a label that only exists on the other, non-active model. + modelslabels.renameLabel("Foo", "Bar"); + + // The active model's screen/topbar data must be unaffected by editing an + // unrelated model's labels. + EXPECT_STREQ(g_model.getScreenData(0)->LayoutId.c_str(), "ActiveLayout"); + EXPECT_STREQ(g_model.getTopbarData()->zones[0].widgetName.c_str(), + "ActiveWidget"); + + // The other model's file on disk should reflect the renamed label. Read + // back only the header (PartialModel), not a full ModelData, so this + // verification step doesn't itself touch the shared screen/topbar globals. + PartialModel partial; + memclear(&partial, sizeof(partial)); + readModelYaml("model0002.yml", (uint8_t*)&partial, sizeof(PartialModel)); + EXPECT_STREQ(partial.header.labels, "Bar"); +} + +TEST_F(ModelMapFsTest, + AddingLabelToOtherModelWithFileUpdateLeavesActiveScreenDataUntouched) +{ + writeFixtureModel("model0002.yml", "", "OtherLayout", "OtherWidget"); + ModelCell* other = modelslist.addModel("model0002.yml", false); + ASSERT_NE(other, nullptr); + + ModelCell* active = modelslist.addModel("model0001.yml", false); + ASSERT_NE(active, nullptr); + modelslist.setCurrentModel(active); + g_model.getScreenData(0)->LayoutId = "ActiveLayout"; + g_model.getTopbarData()->zones[0].widgetName = "ActiveWidget"; + + // update=true drives ModelMap::updateModelFile(), the second call site + // that reads/writes a non-active model's file on disk. + EXPECT_FALSE(modelslabels.addLabelToModel("Baz", other, true)); + + EXPECT_STREQ(g_model.getScreenData(0)->LayoutId.c_str(), "ActiveLayout"); + EXPECT_STREQ(g_model.getTopbarData()->zones[0].widgetName.c_str(), + "ActiveWidget"); + + PartialModel partial; + memclear(&partial, sizeof(partial)); + readModelYaml("model0002.yml", (uint8_t*)&partial, sizeof(PartialModel)); + EXPECT_STREQ(partial.header.labels, "Baz"); +} + +#endif // defined(COLORLCD) From fdbb2cdd6879a6babd81c3e9491b7fb876de8117 Mon Sep 17 00:00:00 2001 From: Peter Feerick Date: Thu, 27 Aug 2026 09:13:27 +1000 Subject: [PATCH 51/55] fix(storage): correctness bugs in ModelMap::writeModelLabels (#7713) Co-authored-by: Claude Sonnet 5 (cherry picked from commit 356250226595d9ba8f28aaed9a0ea3d22c81c118) --- radio/src/storage/modelslist.cpp | 42 ++++++++++------ radio/src/storage/modelslist.h | 17 ++++--- radio/src/tests/modelslist.cpp | 83 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 22 deletions(-) diff --git a/radio/src/storage/modelslist.cpp b/radio/src/storage/modelslist.cpp index 92b9b541588..25dda7387ca 100644 --- a/radio/src/storage/modelslist.cpp +++ b/radio/src/storage/modelslist.cpp @@ -714,7 +714,7 @@ bool ModelMap::renameLabel(const std::string &from, std::string to, memcpy(g_model.header.labels, partial.header.labels, LABELS_LENGTH); storageDirty(EE_MODEL); } else { - fault = writeModelLabels(modcell, partial.header.labels); + fault = !writeModelLabels(modcell, partial.header.labels); } #if defined(SIMU) sleep_ms(100); @@ -855,12 +855,16 @@ bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) return false; } + // Bound searches/writes below by bytes actually read, not sizeof(buf) - + // the rest of buf is uninitialized for a file shorter than 512 bytes. + int len = (int)bytes_cnt; + // Find header section int n = 0; - while (n < sizeof(buf) - 7 && strncmp(&buf[n], "header:", 7) != 0) + while (n < len - 7 && strncmp(&buf[n], "header:", 7) != 0) n += 1; - if (n >= sizeof(buf) - 7) { + if (n >= len - 7) { TRACE("ERROR model header not found in %s", cell->modelFilename); f_close(&out); f_close(&file); @@ -871,11 +875,11 @@ bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) // Skip header section - look for next section after 'header:' do { // Skip current line - while (n < sizeof(buf) && buf[n] != '\n') n += 1; + while (n < len && buf[n] != '\n') n += 1; n += 1; - } while ((n < sizeof(buf)) && buf[n] == ' '); + } while ((n < len) && buf[n] == ' '); - if (n >= sizeof(buf)) { + if (n >= len) { TRACE("ERROR could not match model header in %s", cell->modelFilename); f_close(&out); f_close(&file); @@ -883,19 +887,26 @@ bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) return false; } - // Write remainder of first buffer after header - result = f_write(&out, &buf[n], sizeof(buf) - n, &bytes_cnt); + // Write remainder of first buffer after header - check for short + // writes (e.g. SD full), which f_write() can return FR_OK for. + UINT written; + UINT to_write = (UINT)(len - n); + result = f_write(&out, &buf[n], to_write, &written); + bool short_write = (result == FR_OK && written != to_write); + // Block copy the rest of the original file to the temp file - while (result == FR_OK && bytes_cnt != 0) { + while (result == FR_OK && !short_write && bytes_cnt != 0) { result = f_read(&file, buf, sizeof(buf), &bytes_cnt); - if (result == FR_OK && bytes_cnt != 0) - result = f_write(&out, buf, bytes_cnt, &bytes_cnt); + if (result == FR_OK && bytes_cnt != 0) { + result = f_write(&out, buf, bytes_cnt, &written); + short_write = (result == FR_OK && written != bytes_cnt); + } } f_close(&out); f_close(&file); - if (result != FR_OK) { + if (result != FR_OK || short_write) { TRACE("ERROR copying to temp file"); f_unlink(tempPath); return false; @@ -904,7 +915,10 @@ bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) // Delete original file and rename temp file getModelPath(buf, cell->modelFilename); f_unlink(buf); - f_rename(tempPath, buf); + if (f_rename(tempPath, buf) != FR_OK) { + TRACE("ERROR renaming temp file to %s", cell->modelFilename); + return false; + } return true; } @@ -930,7 +944,7 @@ bool ModelMap::updateModelFile(ModelCell *cell) return false; } - bool fault = writeModelLabels(cell, ModelMap::toCSV(getLabelsByModel(cell)).c_str()); + bool fault = !writeModelLabels(cell, ModelMap::toCSV(getLabelsByModel(cell)).c_str()); #if defined(DEBUG_TIMERS) DEBUG_TIMER_SAMPLE(debugTimerYamlScan); diff --git a/radio/src/storage/modelslist.h b/radio/src/storage/modelslist.h index a29f99c71f9..424851f6b03 100644 --- a/radio/src/storage/modelslist.h +++ b/radio/src/storage/modelslist.h @@ -157,6 +157,15 @@ class ModelMap : protected std::multimap const std::string &from, const std::string &to); + bool writeModelLabels(ModelCell*, const char*); + + void clear() + { + _isDirty = true; + labels.clear(); + std::multimap::clear(); + } + protected: ModelsSortBy _sortOrder = DEFAULT_MODEL_SORT; bool _isDirty = true; @@ -168,14 +177,6 @@ class ModelMap : protected std::multimap ModelCell *); // Should only be called from ModelsList remove model bool updateModelFile(ModelCell *); void sortModelsBy(ModelsVector &mv, ModelsSortBy sortby); - bool writeModelLabels(ModelCell*, const char*); - - void clear() - { - _isDirty = true; - labels.clear(); - std::multimap::clear(); - } int getIndexByLabel(const std::string &str) { diff --git a/radio/src/tests/modelslist.cpp b/radio/src/tests/modelslist.cpp index 1d2bb39884a..3f23eea4f8e 100644 --- a/radio/src/tests/modelslist.cpp +++ b/radio/src/tests/modelslist.cpp @@ -164,3 +164,86 @@ TEST(PartialModel, LabelsFieldRetainsFullCsvUpToItsOwnCapacity) "worth."; } #endif // defined(STORAGE_MODELSLIST) + +// Real FatFS round-trip tests for ModelMap::writeModelLabels() itself - +// the raw file-surgery function these PartialModel tests don't exercise. +#if defined(SIMU) && defined(STORAGE_MODELSLIST) + +#include "storage/modelslist.h" +#include "storage/sdcard_common.h" + +#include +#include + +static void writeRawModelFile(const char* modelFilename, const std::string& content) +{ + char path[256]; + getModelPath(path, modelFilename); + std::filesystem::create_directories( + std::filesystem::path(simuFatfsGetRealPath(path)).parent_path()); + std::ofstream f(simuFatfsGetRealPath(path), std::ios::binary); + f << content; +} + +static size_t modelFileSize(const char* modelFilename) +{ + char path[256]; + getModelPath(path, modelFilename); + return std::filesystem::file_size(simuFatfsGetRealPath(path)); +} + +// writeModelLabels() locates "header:" then raw-copies everything from the +// next unindented line onward, so a top-level "body:" key after the header +// is enough to pin that boundary without needing a real, schema-valid +// model file. +TEST(ModelsList, WriteModelLabelsPreservesBodySize) +{ + ModelMap map; + + const char* fileA = "wml_test_a.yml"; + const char* fileB = "wml_test_b.yml"; + + // Both fixtures are well under 512 bytes (a single short f_read/EOF), and + // differ only in body length. writeModelLabels() must reproduce that + // exact size difference in its output - if it instead always pads the + // first chunk out to sizeof(buf), both outputs collapse to the same size + // regardless of the real body length (the uninitialized-memory bug). + std::string bodyA = "body:\n marker: AAAA\n"; + std::string bodyB = bodyA + std::string(300, 'X'); + ASSERT_LT(bodyA.size() + 40, 512u); + ASSERT_LT(bodyB.size() + 40, 512u); + + writeRawModelFile(fileA, "header:\n name: OldName\n labels: \n" + bodyA); + writeRawModelFile(fileB, "header:\n name: OldName\n labels: \n" + bodyB); + + ModelCell cellA(fileA); + ModelCell cellB(fileB); + + EXPECT_TRUE(map.writeModelLabels(&cellA, "NewLabel")); + EXPECT_TRUE(map.writeModelLabels(&cellB, "NewLabel")); + + size_t sizeA = modelFileSize(fileA); + size_t sizeB = modelFileSize(fileB); + + // The two headers are generated from identical inputs, so the size + // delta between the outputs must equal the body length delta exactly. + EXPECT_EQ(sizeB - sizeA, bodyB.size() - bodyA.size()); + + std::filesystem::remove(simuFatfsGetRealPath(std::string(MODELS_PATH) + "/" + fileA)); + std::filesystem::remove(simuFatfsGetRealPath(std::string(MODELS_PATH) + "/" + fileB)); +} + +TEST(ModelsList, WriteModelLabelsFailsCleanlyWithoutHeader) +{ + ModelMap map; + const char* file = "wml_test_noheader.yml"; + + writeRawModelFile(file, "notheader:\n foo: bar\n"); + ModelCell cell(file); + + EXPECT_FALSE(map.writeModelLabels(&cell, "NewLabel")); + + std::filesystem::remove(simuFatfsGetRealPath(std::string(MODELS_PATH) + "/" + file)); +} + +#endif // #if defined(SIMU) && defined(STORAGE_MODELSLIST) From 348433cb9967d7fa25a0350e6668e8508b96d2f0 Mon Sep 17 00:00:00 2001 From: philmoz Date: Thu, 27 Aug 2026 09:18:01 +1000 Subject: [PATCH 52/55] chore(color): use standard window lifecycle logic for the audio recorder (#7714) (cherry picked from commit 2b9b8fd4799f82cdb48114b0d61f0cd2f890423a) Co-authored-by: Claude Sonnet 5 --- radio/src/gui/colorlcd/libui/dialog.cpp | 2 +- .../gui/colorlcd/radio/radio_mic_recorder.cpp | 38 ++++--------------- .../gui/colorlcd/radio/radio_mic_recorder.h | 7 +--- 3 files changed, 10 insertions(+), 37 deletions(-) diff --git a/radio/src/gui/colorlcd/libui/dialog.cpp b/radio/src/gui/colorlcd/libui/dialog.cpp index 3fbaba3baa8..74b4c420543 100644 --- a/radio/src/gui/colorlcd/libui/dialog.cpp +++ b/radio/src/gui/colorlcd/libui/dialog.cpp @@ -211,8 +211,8 @@ LabelDialog::LabelDialog(const char *label, int length, const char* title, }); new TextButton(box, rect_t{0, 0, 96, 0}, STR_SAVE, [=]() { - if (saveHandler != nullptr) saveHandler(this->label); deleteLater(); + if (saveHandler != nullptr) saveHandler(this->label); return 0; }); } diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp index e212a88d72c..cf6fac9d53e 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp @@ -396,21 +396,15 @@ RadioMicRecorder::RadioMicRecorder() : enterIdle(); } -RadioMicRecorder::~RadioMicRecorder() +void RadioMicRecorder::deleteLater(bool detach, bool trash) { - // An async call scheduled from the LabelDialog confirm path may still be - // pending when the page is closed (e.g. user long-presses EXIT while the - // dialog is being dismissed). Cancelling ensures the callback never fires - // on a destroyed instance. - lv_async_call_cancel(&RadioMicRecorder::asyncProcessPendingRename, this); - if (recorder.isRecording()) recorder.stop(); - if (isPlayingTake()) audioQueue.stopAll(); - pdmStop(); -} + if (!deleted()) { + if (recorder.isRecording()) recorder.stop(); + if (isPlayingTake()) audioQueue.stopAll(); + pdmStop(); -void RadioMicRecorder::asyncProcessPendingRename(void* ctx) -{ - static_cast(ctx)->processPendingRename(); + Page::deleteLater(detach, trash); + } } void RadioMicRecorder::buildHeader(Window* window) @@ -643,16 +637,6 @@ void RadioMicRecorder::discardTake() if (!takeSaved && filename[0]) f_unlink(filename); } -void RadioMicRecorder::onEvent(event_t event) -{ - if (event == EVT_KEY_LONG(KEY_EXIT)) { - killEvents(event); - if (recorder.isRecording()) recorder.stop(); - if (isPlayingTake()) audioQueue.stopAll(); - onCancel(); - } -} - void RadioMicRecorder::onActionPressed() { switch (state) { @@ -758,13 +742,7 @@ void RadioMicRecorder::askSaveAs() strncpy(dir + SOUNDS_PATH_LNG_OFS, currentLanguagePack->id, 2); snprintf(pendingRename, sizeof(pendingRename), "%s%s.wav", dir, newName.c_str()); if (strcmp(pendingRename, filename) == 0) { takeSaved = true; refreshUI(); return; } - - // Defer the overwrite check until LabelDialog has finished closing — - // creating a modal now would leave us stacked on top of LabelDialog, - // which corrupts the lv_group chain when LabelDialog deletes itself. - // The page dtor cancels this async call if the user bails out before - // it fires (see ~RadioMicRecorder). - lv_async_call(&RadioMicRecorder::asyncProcessPendingRename, this); + processPendingRename(); }); } diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.h b/radio/src/gui/colorlcd/radio/radio_mic_recorder.h index d27c9ed6958..ba627f402ef 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.h +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.h @@ -35,7 +35,6 @@ class RadioMicRecorder : public Page { public: RadioMicRecorder(); - ~RadioMicRecorder() override; protected: enum class State : uint8_t { IDLE, COUNTDOWN, RECORDING, REVIEW }; @@ -76,7 +75,7 @@ class RadioMicRecorder : public Page void buildHeader(Window* window); void buildBody(Window* window); void checkEvents() override; - void onEvent(event_t event) override; + void deleteLater(bool detach = true, bool trash = true) override; void onActionPressed(); void onPlayPressed(); @@ -100,10 +99,6 @@ class RadioMicRecorder : public Page void applyRename(); void refreshUI(); void pickNextFilename(); - - // Stable callback address so the dtor can cancel a pending lv_async call - // scheduled from the LabelDialog confirm path. - static void asyncProcessPendingRename(void* ctx); }; #endif // PDM_CLOCK From b9d16909a3319dfe83846ce62aa8776f64c84ca9 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:53:54 +0000 Subject: [PATCH 53/55] chore: bump version (cherry picked from commit e5fc3949cb605f1c0001e668a13af016a75f69ae) (cherry picked from commit 9e9a999f7248de01988dfd535554471c875bd4d0) (cherry picked from commit 33d7c103ad59f68e35c7b763c9b68d417b7b0733) (cherry picked from commit c1e55b0345a8a719ac84a27d98bbc906db7405ac) From 5ce49ecc7412ec665894dce4699d6a84dd350f5a Mon Sep 17 00:00:00 2001 From: philmoz Date: Thu, 27 Aug 2026 20:33:19 +1000 Subject: [PATCH 54/55] fix(color): UI issues adding and removing labels (#7715) (cherry picked from commit 4f6e0223c94676de07e115c41cb2fe749a905064) --- radio/src/gui/colorlcd/model/model_select.cpp | 14 ++++++-------- radio/src/storage/modelslist.cpp | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/radio/src/gui/colorlcd/model/model_select.cpp b/radio/src/gui/colorlcd/model/model_select.cpp index 874e689db9d..6ab31fb6a85 100644 --- a/radio/src/gui/colorlcd/model/model_select.cpp +++ b/radio/src/gui/colorlcd/model/model_select.cpp @@ -689,14 +689,8 @@ void ModelLabelsWindow::newLabel() new LabelDialog(tmpLabel, LABEL_LENGTH, STR_ENTER_LABEL, [=](std::string label) { int newlabindex = modelslabels.addLabel(label); if (newlabindex >= 0) { - std::set newset; - newset.insert(newlabindex); auto labels = getLabels(); lblselector->setNames(labels); - lblselector->setSelected(newset); - if (g_eeGeneral.labelSingleSelect) - lblselector->setActiveItem(newlabindex); - updateFilteredLabels(newset); } }); } @@ -894,8 +888,12 @@ void ModelLabelsWindow::buildBody(Window *window) std::set newset; lblselector->setNames(labels); lblselector->setSelected(newset); - if (g_eeGeneral.labelSingleSelect && selected == lblselector->getActiveItem()) - lblselector->setActiveItem(-1); + if (g_eeGeneral.labelSingleSelect) { + if (selected == lblselector->getActiveItem()) + lblselector->setActiveItem(-1); + else + newset.insert(lblselector->getActiveItem()); + } mdlselector->clearButtons(); updateFilteredLabels(newset); }); diff --git a/radio/src/storage/modelslist.cpp b/radio/src/storage/modelslist.cpp index 25dda7387ca..c34635f51d0 100644 --- a/radio/src/storage/modelslist.cpp +++ b/radio/src/storage/modelslist.cpp @@ -358,8 +358,8 @@ int ModelMap::addLabel(std::string lbl) if (lbl == STR_UNLABELEDMODEL) return -1; // Limit maximum label length, TODO... Truncate UTF8 Properly - lbl = lbl.substr(0, LABEL_LENGTH); removeYAMLChars(lbl); + lbl = lbl.substr(0, LABEL_LENGTH); if (lbl.size() == 0) return -1; // Add a new label if it doesn't already exist in the list From 505e75c13e1a1fc23e20d6c829f7dd93845a537e Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:53:54 +0000 Subject: [PATCH 55/55] chore: bump version (cherry picked from commit e5fc3949cb605f1c0001e668a13af016a75f69ae) (cherry picked from commit 9e9a999f7248de01988dfd535554471c875bd4d0) (cherry picked from commit 33d7c103ad59f68e35c7b763c9b68d417b7b0733) (cherry picked from commit c1e55b0345a8a719ac84a27d98bbc906db7405ac)