From e6b08cafabbd58795a55b7adbc09b79b514c279c Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 21 Oct 2026 16:18:18 -0700 Subject: [PATCH] Add explicit SolarXiao Advanced carrier selection and capability reporting --- docs/cli_commands.md | 5 +- docs/index.md | 2 +- docs/solarxiao_power.md | 73 +++- examples/simple_repeater/main.cpp | 2 +- src/helpers/sensors/SolarXiaoCarrier.cpp | 93 +++++ src/helpers/sensors/SolarXiaoCarrier.h | 31 ++ src/helpers/sensors/SolarXiaoPower.cpp | 56 ++- src/helpers/sensors/SolarXiaoPower.h | 2 +- src/helpers/sensors/SolarXiaoPowerCore.cpp | 7 +- src/helpers/sensors/SolarXiaoPowerCore.h | 1 + test/test_solarxiao_carrier.py | 440 +++++++++++++++++++++ 11 files changed, 700 insertions(+), 12 deletions(-) create mode 100644 src/helpers/sensors/SolarXiaoCarrier.cpp create mode 100644 src/helpers/sensors/SolarXiaoCarrier.h create mode 100644 test/test_solarxiao_carrier.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index ad42c04af..57ce1a37c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -4,8 +4,9 @@ # CLI Commands -For SolarXiao carrier battery readings and charger controls, see -[SolarXiao MPPT and battery controls](solarxiao_power.md) (`get mppt`, `set mppt`). +For SolarXiao Advanced/MPPT carrier selection, live battery/MCU readings and +charger controls, see [SolarXiao carrier profiles](solarxiao_power.md) +(`get solar`, `get solar readings`, `set solar.board`, `get mppt`, `set mppt`). See [two LoRa profiles](radio_profiles.md) for `radio2`, `tempradio2`, scheduling, RX-only mode, cross-transmission policy, and optional radio preambles. diff --git a/docs/index.md b/docs/index.md index efb77b80b..7ea1db0e8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ Choose firmware with the [firmware picker](firmware_picker.md). For help with a ## Documentation -- [SolarXiao MPPT and battery controls](solarxiao_power.md) +- [SolarXiao Advanced/MPPT carrier profiles and battery controls](solarxiao_power.md) - [Feature switches by role](role_feature_switches.md) and [Full Companion features](full_companion_features.md) - [Firmware update guide](ota_user_guide.md), [easy LoRa OTA](ota_easy.md), and [scripted LoRa OTA](lora_ota_automation.md) diff --git a/docs/solarxiao_power.md b/docs/solarxiao_power.md index 8c9b6372e..e112c820f 100644 --- a/docs/solarxiao_power.md +++ b/docs/solarxiao_power.md @@ -1,10 +1,74 @@ -# SolarXiao MPPT and battery controls +# SolarXiao carrier profiles, MPPT and battery controls -SolarXiao Repeater firmware includes a native INA260 battery monitor and LTC4162 -charger interface in Full and Reduced builds. Use USB CLI or the authenticated +SolarXiao Repeater firmware supports the older BQ24650 Advanced carrier and the +LTC4162/INA260 MPPT carrier in Full and Reduced builds. Use USB CLI or the authenticated administrator CLI over LoRa. Ship mode and saved-policy reset require local USB. Commands do not change RF settings. +## Selecting the carrier + +Carrier selection is configured by the operator; missing I2C responses never +identify an Advanced board. `mppt` is the default for existing installations. +The firmware environment, hardware/OTA identity and flash layout are unchanged. + +``` +get solar.board +set solar.board advanced +get solar +get solar capabilities +get solar readings +get solar history +``` + +For the older SolarXiao 2W Advanced, choose `advanced`. This reports the BQ24650 +hardware capabilities and the live XIAO battery ADC and processor temperature. +The existing self telemetry stays on channel 1, with voltage and temperature as +different LPP types. It adds no invented charger flags, current, watts, battery +NTC temperature or panel measurements. `get solar battery` aliases the self +readings; the MCU temperature is not a battery or charger temperature. +If a node already reports battery voltage and MCU temperature on channel 1, +this profile adds no further physical measurements. It adds explicit carrier +selection and capability reporting; the voltage/temperature history commands +also already exist in builds that include telemetry history. + +The [2025 Advanced schematic](https://github.com/wehooper4/Meshtastic-Hardware/blob/main/XaioSeries/SolarXaio/Advanced/Standard/Schematic_SolarXaio_ADV_2025-10-23.pdf) +has no INA monitor or digital charger interface. Charger status pins drive LEDs, +NTC goes only to the charger, and current/voltage/MPP setpoints use hardware +resistors, switches and a solder jumper. An OTA update cannot measure those +signals or change those setpoints without additional hardware connections. + +Advanced mode suppresses native LTC4162/INA260 polling, register reads/writes, +replay and telemetry/history channels 2..13. `get mppt`, `set mppt ...` and +`mppt ...` explain that the Advanced carrier has no digital charger interface. +The generic sensor scanner still reserves native addresses 0x40 and 0x68, so it +cannot bypass that policy. Optional external sensor channels still start at 14. +Historical samples retained from a previous carrier selection describe the old +samples, not current sensor availability. + +Where telemetry history is included, `get solar history` points to the existing +`get telemetry.volt [1-7]` and `get telemetry.temp [1-7]` commands: 30-minute +samples, seven days in RAM. History starts filling after boot and resets at +reboot. Voltage trends can help assess a remote installation, but are not a +measurement of solar power, charge current or charge state. Radio/status and +history-transfer commands remain the existing role commands. + +The selection is stored separately in an eight-byte, versioned checked record +at `/solar_board`, using atomic publication and full public readback. A corrupt, +unreadable or unconfirmed selection blocks native access and replay. Self ADC +and MCU readings remain available. An explicit successful `set solar.board` +recovers selection. With an unavailable filesystem, the selection cannot be +saved and native access remains blocked. + +For the LTC4162 carrier, select `set solar.board mppt`. This re-enables native +access and reloads any retained `/solar_power` charger overrides. Changing the +carrier does not erase those overrides, change RF/PA settings, modify battery +protection, or write charger defaults. Advanced mode is not chip autodetection. +A remotely deployed node must first receive compatible firmware before these +commands can be used. Deployment still requires its installed target and +bootloader compatibility to be checked when it is reachable. + +## MPPT carrier commands + Start with: ``` @@ -127,6 +191,9 @@ INA260 uses its integrated shunt and does not use these sense-resistor values. ## Telemetry channels +The following carrier channels apply to MPPT mode. Advanced mode reports the +existing self voltage/MCU temperature on channel 1 and omits native channels. + Carrier telemetry requires the environmental telemetry permission. Channel 1 remains the XIAO internal ADC; it is separate from the 4S carrier battery and its existing startup/low-voltage policy is unchanged. diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 42581b2c5..e812265ed 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -215,7 +215,7 @@ void setup() { #endif mesh::leds::beginSettings(board, regulator_prefs_fs); #if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER - mesh::solar::beginSettings(regulator_prefs_fs); + mesh::solar::beginSettings(board, regulator_prefs_fs, MESH_ENABLE_TELEMETRY_HISTORY); #endif if (!mesh::power::beginRegulatorSettings(board, radio_driver, regulator_prefs_fs)) { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); diff --git a/src/helpers/sensors/SolarXiaoCarrier.cpp b/src/helpers/sensors/SolarXiaoCarrier.cpp new file mode 100644 index 000000000..0fcff7d32 --- /dev/null +++ b/src/helpers/sensors/SolarXiaoCarrier.cpp @@ -0,0 +1,93 @@ +#include "SolarXiaoCarrier.h" +#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER +#include +#include +#include + +namespace mesh { namespace solar { +namespace { +// Complete canonical records detect bad version, length, value or complement. +// The filesystem adapter also verifies atomic publication and public readback. +const uint8_t images[2][CarrierSelection::IMAGE_SIZE] = { + {'S','X','B',1,0,0xff,0xa5,0x5a}, + {'S','X','B',1,1,0xfe,0xa5,0x5a} +}; +} + +void CarrierSelection::begin() { + type=Mppt; saved=false; healthy=store.available(); + if (!healthy || !store.exists()) return; + uint8_t image[IMAGE_SIZE]; + if (!store.load(image,sizeof(image)) || image[4]>1 + || memcmp(image,images[image[4]],sizeof(image))) { + healthy=false; + return; + } + type=static_cast(image[4]); saved=true; +} + +bool CarrierSelection::select(Type requested) { + if (!store.available() || !store.save(images[requested],IMAGE_SIZE)) { + // A failed publication/readback may leave either record live on disk. + // Block native I2C/replay until an explicit selection succeeds or reloads. + healthy=false; + return false; + } + type=requested; saved=true; healthy=true; + return true; +} + +bool CarrierSelection::handle(const char* command,char* reply,size_t capacity, + float battery_v,float mcu_c,bool history_available) { + if (!command || !reply || !capacity) return false; + if (!strcmp(command,"get solar.board")) { + if (!healthy) snprintf(reply,capacity,"Err - carrier selection unconfirmed; set solar.board advanced|mppt"); + else snprintf(reply,capacity,"> %s source=%s (configured, not autodetected)", + type==Advanced?"advanced":"mppt",saved?"saved":"default"); + return true; + } + if (!strncmp(command,"set solar.board",15) && (!command[15] || command[15]==' ')) { + const char* value=command+15; + while (*value==' ') ++value; + if (strcmp(value,"advanced") && strcmp(value,"mppt")) { + snprintf(reply,capacity,"Err - use set solar.board advanced|mppt"); + } else if (!select(!strcmp(value,"advanced")?Advanced:Mppt)) { + snprintf(reply,capacity,"Err - carrier save unconfirmed; native access blocked; repeat selection"); + } else if (type==Advanced) { + snprintf(reply,capacity,"OK - Advanced saved; native LTC/INA access disabled; self ADC/MCU telemetry retained"); + } else { + snprintf(reply,capacity,"OK - MPPT saved; native access and retained charger overrides re-enabled"); + } + return true; + } + if (strncmp(command,"get solar",9) || (command[9] && command[9]!=' ')) return false; + const char* key=command+9; if (*key==' ') ++key; + if (!strcmp(key,"readings") || !strcmp(key,"battery")) { + char battery[24],temperature[24]; + if (isfinite(battery_v) && battery_v>0) snprintf(battery,sizeof(battery),"%.3fV",double(battery_v)); + else snprintf(battery,sizeof(battery),"unavailable"); + if (isfinite(mcu_c)) snprintf(temperature,sizeof(temperature),"%.2fC",double(mcu_c)); + else snprintf(temperature,sizeof(temperature),"unavailable"); + snprintf(reply,capacity,"> XIAO battery=%s MCU=%s (processor); self channel=1",battery,temperature); + } else if (!strcmp(key,"history")) { + snprintf(reply,capacity,history_available + ? "> get telemetry.volt [1-7]; get telemetry.temp [1-7]; 30min samples, 7d RAM history; voltage trend does not measure charge current" + : "Err - telemetry history is not included in this build"); + } else if (!healthy) { + snprintf(reply,capacity,"Err - carrier selection unconfirmed; self readings available; set solar.board advanced|mppt"); + } else if (!*key || !strcmp(key,"info")) { + snprintf(reply,capacity,"> carrier=%s source=%s charger=%s; %s", + type==Advanced?"advanced":"mppt",saved?"configured":"default", + type==Advanced?"BQ24650":"LTC4162", + type==Advanced?"ADC/MCU only; charger settings hardware-only":"use get mppt for live chip identity/readings"); + } else if (!strcmp(key,"capabilities")) { + snprintf(reply,capacity,type==Advanced + ? "> Advanced: battery ADC + MCU temp; solar V/I, battery I/W, NTC and charger flags unwired; settings hardware-only" + : "> MPPT: self ADC/MCU + INA260 V/A/W + LTC4162 readings/settings when reachable; use get mppt fields 0"); + } else { + snprintf(reply,capacity,"Err - use get solar info|readings|capabilities|history; get/set solar.board"); + } + return true; +} +}} // namespace mesh::solar +#endif diff --git a/src/helpers/sensors/SolarXiaoCarrier.h b/src/helpers/sensors/SolarXiaoCarrier.h new file mode 100644 index 000000000..0ac209f9d --- /dev/null +++ b/src/helpers/sensors/SolarXiaoCarrier.h @@ -0,0 +1,31 @@ +#pragma once + +#include "SolarXiaoPowerCore.h" + +namespace mesh { namespace solar { + +// Carrier identity is selected by the operator. A missing/unpowered I2C +// peripheral cannot distinguish the Advanced board from the MPPT board. +class CarrierSelection { +public: + enum Type : uint8_t { Mppt = 0, Advanced = 1 }; + static constexpr size_t IMAGE_SIZE = 8; + +private: + Store& store; + Type type = Mppt; + bool healthy = false; + bool saved = false; + bool select(Type requested); + +public: + explicit CarrierSelection(Store& persistence) : store(persistence) {} + void begin(); + bool nativeAllowed() const { return healthy && type == Mppt; } + bool isAdvanced() const { return healthy && type == Advanced; } + bool settingsHealthy() const { return healthy; } + bool handle(const char* command, char* reply, size_t capacity, + float xiao_battery_v, float mcu_c, bool history_available); +}; + +}} // namespace mesh::solar diff --git a/src/helpers/sensors/SolarXiaoPower.cpp b/src/helpers/sensors/SolarXiaoPower.cpp index 0bec32dbe..13e43ec27 100644 --- a/src/helpers/sensors/SolarXiaoPower.cpp +++ b/src/helpers/sensors/SolarXiaoPower.cpp @@ -1,7 +1,9 @@ #include "SolarXiaoPower.h" #if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER #include "SolarXiaoPowerCore.h" +#include "SolarXiaoCarrier.h" #include +#include #include #include @@ -55,21 +57,68 @@ public: } }; constexpr const char* CarrierStore::path; +class SelectionStore : public Store { +public: + FILESYSTEM* fs=nullptr; + static constexpr const char* path="/solar_board"; + bool available() const override { return fs!=nullptr; } + bool exists() const override { + bool present=false; + // Unknown metadata must force a complete checked read, never a default. + return fs && (!mesh::filePresence(fs,path,present) || present); + } + bool load(uint8_t* data,size_t size) override { + return storage::readFixedSettingsFile(fs,path,data,size); + } + bool save(const uint8_t* data,size_t size) override { + return storage::writeCheckedSettingsFile(fs,path,data,size) + == AtomicFileWriter::Outcome::Committed; + } +}; +constexpr const char* SelectionStore::path; +MainBoard* main_board=nullptr; +bool history_available=false; CarrierBus transport; CarrierStore persistence; +SelectionStore selection_store; +CarrierSelection selection(selection_store); Controller controller(transport,persistence); } -void beginSettings(FILESYSTEM* fs) { +void beginSettings(MainBoard& board,FILESYSTEM* fs,bool telemetry_history_available) { const volatile char* marker=meshcore_capability_solarxiao_power; (void)*marker; + main_board=&board; + history_available=telemetry_history_available; + selection_store.fs=fs; + selection.begin(); persistence.fs=fs; controller.begin(); } -void service() { controller.service(millis()); } +void service() { if (selection.nativeAllowed()) controller.service(millis()); } bool handleCommand(const char* command,char* reply,size_t capacity,bool local) { + float battery=NAN,temperature=NAN; + if (main_board && command && (!strcmp(command,"get solar readings") || !strcmp(command,"get solar battery"))) { + battery=main_board->getBattMilliVolts()/1000.0f; + temperature=main_board->getMCUTemperature(); + } + const bool native_before=selection.nativeAllowed(); + if (selection.handle(command,reply,capacity,battery,temperature,history_available)) { + if (!native_before && selection.nativeAllowed()) controller.reload(); + return true; + } + if (!command || !reply || !capacity) return false; + const bool mppt=(!strncmp(command,"get mppt",8) && (!command[8] || command[8]==' ')) + || !strncmp(command,"set mppt ",9) || !strncmp(command,"mppt ",5); + if (mppt && !selection.nativeAllowed()) { + snprintf(reply,capacity,selection.isAdvanced() + ? "Err - BQ24650 Advanced has no digital charger/INA interface; use get solar readings" + : "Err - carrier selection unconfirmed; set solar.board advanced|mppt"); + return true; + } return controller.handle(command,reply,capacity,local); } void addTelemetry(CayenneLPP& lpp) { + if (!selection.nativeAllowed()) return; Snapshot s; controller.snapshot(s); if (s.monitor) { lpp.addVoltage(2,s.battery_v); lpp.addCurrent(2,s.battery_a); lpp.addPower(2,s.battery_w); @@ -89,12 +138,13 @@ void addTelemetry(CayenneLPP& lpp) { for (unsigned i=0;i<3;++i) lpp.addGenericSensor(11+i,s.alerts[i]); } uint8_t voltageChannels(uint8_t* channels,uint8_t capacity) { + if (!selection.nativeAllowed()) return 0; const uint8_t count=capacity<4 ? capacity : 4; for (uint8_t i=0;channels && ifail_stat ? -5 : fs->files.count(path) ? 0 : LFS_ERR_NOENT; +} +''' + +SELECTOR_HARNESS = r''' +#include "SolarXiaoCarrier.h" +#include +#include +#include +#include +#include +using namespace mesh::solar; +struct MemoryStore : Store { + std::vector bytes; + bool present=false, available_now=true, fail_load=false; + bool fail_save=false, publish_before_failure=false; + unsigned loads=0,saves=0; + bool available() const override { return available_now; } + bool exists() const override { return present; } + bool load(uint8_t* out,size_t n) override { + ++loads; + if(fail_load || bytes.size()!=n)return false; + memcpy(out,bytes.data(),n);return true; + } + bool save(const uint8_t* data,size_t n) override { + ++saves; + if(!fail_save || publish_before_failure) {bytes.assign(data,data+n);present=true;} + return !fail_save; + } +}; +std::string cli(CarrierSelection& c,const char* cmd,float mv=3.876f, + float temp=27.25f,bool history=true) { + char reply[157]={}; + assert(c.handle(cmd,reply,sizeof(reply),mv,temp,history));return reply; +} +void has(const std::string& text,const char* wanted) { + assert(text.find(wanted)!=std::string::npos); +} +void blocked(CarrierSelection& c) { + assert(!c.nativeAllowed() && !c.settingsHealthy() && !c.isAdvanced()); + has(cli(c,"get solar.board"),"unconfirmed"); +} +int main(int argc,char** argv) { + assert(argc==2);const std::string scenario=argv[1]; + if(scenario=="persistence") { + MemoryStore store;CarrierSelection c(store); + assert(!c.nativeAllowed());c.begin(); + assert(c.settingsHealthy() && c.nativeAllowed() && !c.isAdvanced()); + assert(!store.loads && !store.saves && !store.present); + has(cli(c,"get solar.board"),"source=default"); + has(cli(c,"get solar.board"),"not autodetected"); + has(cli(c,"get solar capabilities"),"INA260");assert(!store.saves); + has(cli(c,"set solar.board advanced"),"OK"); + assert(c.isAdvanced() && !c.nativeAllowed() && store.saves==1); + assert(store.bytes.size()==CarrierSelection::IMAGE_SIZE); + CarrierSelection restarted(store);restarted.begin(); + assert(restarted.isAdvanced() && !restarted.nativeAllowed()); + has(cli(restarted,"get solar.board"),"source=saved"); + has(cli(restarted,"get solar info"),"BQ24650"); + has(cli(restarted,"get solar capabilities"),"unwired"); + assert(store.saves==1); + has(cli(restarted,"set solar.board mppt"),"retained charger overrides"); + CarrierSelection mppt(store);mppt.begin(); + assert(mppt.nativeAllowed() && !mppt.isAdvanced()); + has(cli(mppt,"get solar.board"),"mppt source=saved"); + } else if(scenario=="invalid-records") { + MemoryStore reference;CarrierSelection baseline(reference);baseline.begin(); + cli(baseline,"set solar.board advanced");const auto valid=reference.bytes; + for(size_t byte=0;byte guarded(capacity+16,'~'); + char* out=guarded.data()+8; + assert(c.handle(cmd,out,capacity,NAN,NAN,true)); + for(size_t i=0;i<8;++i)assert(guarded[i]=='~' && guarded[8+capacity+i]=='~'); + assert(memchr(out,0,capacity)!=nullptr); + } + } + } else assert(false); +} +''' + +SENSOR_MANAGER = r''' +#pragma once +#include +#include +#include +struct CayenneLPP { + std::vector channels; + void addVoltage(uint8_t c,float) {channels.push_back(c);} + void addCurrent(uint8_t c,float) {channels.push_back(c);} + void addPower(uint8_t c,float) {channels.push_back(c);} + void addTemperature(uint8_t c,float) {channels.push_back(c);} + void addGenericSensor(uint8_t c,float) {channels.push_back(c);} +}; +namespace mesh { +struct MainBoard { + uint16_t battery_mv=3850;float mcu_c=26.75f; + unsigned battery_reads=0,temperature_reads=0; + uint16_t getBattMilliVolts() {++battery_reads;return battery_mv;} + float getMCUTemperature() {++temperature_reads;return mcu_c;} +}; +struct SensorManager { + struct VoltageSensorReading {uint8_t channel;float voltage;bool valid;}; +}; +} +''' + +WIRE = r''' +#pragma once +#include +#include +#include +struct FakeWire { + uint16_t charger[256]={},ina[256]={}; + unsigned calls=0,reads=0,writes=0; + uint8_t address=0,reg=0;size_t cursor=0; + std::vector tx,rx; + FakeWire() { + charger[0x43]=0x46b4;charger[0x14]=2;charger[0x29]=1; + charger[0x4a]=1;charger[0x39]=7;charger[0x34]=256; + charger[0x3a]=17152;charger[0x3b]=8000;charger[0x3c]=7985; + charger[0x3d]=uint16_t(-100);charger[0x3e]=200; + charger[0x3f]=14000;charger[0x40]=10914; + ina[0xfe]=0x5449;ina[0xff]=0x2270; + ina[2]=10560;ina[1]=uint16_t(-8);ina[3]=13; + } + void beginTransmission(uint8_t a) {assert(a==0x68 || a==0x40);address=a;++calls;tx.clear();} + void write(uint8_t b) {tx.push_back(b);} + int endTransmission(bool stop=true) { + assert(!tx.empty());reg=tx[0]; + if(stop && tx.size()==3) { + assert(address==0x68);++writes;charger[reg]=tx[1]|(uint16_t(tx[2])<<8); + } + return 0; + } + int requestFrom(uint8_t a,uint8_t n) { + assert(a==address && n==2);++reads;cursor=0; + const uint16_t value=(a==0x68?charger:ina)[reg]; + rx={uint8_t(a==0x68?value:value>>8),uint8_t(a==0x68?value>>8:value)}; + return 2; + } + int available() {return int(rx.size()-cursor);} + int read() {return cursor +#include +#include +#include +#include +#include +#include +#include +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +static uint32_t now_ms=100; +uint32_t millis() {return now_ms;} +int digitalRead(int pin) {assert(pin==30 || pin==31);return 1;} +FakeWire Wire; +#include "@ADAPTER@" +using namespace mesh; +using namespace mesh::solar; +std::string cli(const char* command) { + char reply[157]={};assert(handleCommand(command,reply,sizeof(reply),false));return reply; +} +void has(const std::string& text,const char* wanted) { + assert(text.find(wanted)!=std::string::npos); +} +void nativeSilent() { + const auto calls=Wire.calls,reads=Wire.reads,writes=Wire.writes; + for(const char* command:{"get mppt", "get mppt info", "get mppt battery", + "get mppt raw 0x43", "get mppt fields 0", "set mppt mppt.enabled off", + "mppt reload", "mppt reset", "mppt bsr run", "mppt ship arm", + "mppt alerts clear limits 65535"}) has(cli(command),"Err"); + service();now_ms+=10000;service(); + CayenneLPP lpp;addTelemetry(lpp);assert(lpp.channels.empty()); + uint8_t channels[]={99,99,99,99,99}; + SensorManager::VoltageSensorReading values[5]; + for(auto& value:values)value={99,99,true}; + assert(!voltageChannels(channels,5));assert(!queryVoltages(values,5)); + assert(!voltageChannels(nullptr,0));assert(!queryVoltages(nullptr,0)); + for(auto c:channels)assert(c==99); + for(auto value:values)assert(value.channel==99 && value.voltage==99 && value.valid); + assert(calls==Wire.calls && reads==Wire.reads && writes==Wire.writes); +} +int main(int argc,char** argv) { + assert(argc==2);const std::string scenario=argv[1];MockFS fs;MainBoard board; + if(scenario=="advanced-runtime") { + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY);assert(!Wire.calls); + has(cli("set solar.board advanced"),"OK");nativeSilent(); + assert(fs.exists("/solar_board") && !fs.exists("/solar_power")); + const auto calls=Wire.calls; + has(cli("get solar readings"),"battery=3.850V MCU=26.75C"); + assert(board.battery_reads==1 && board.temperature_reads==1); + board.battery_mv=3991;board.mcu_c=-12.5f; + has(cli("get solar battery"),"battery=3.991V MCU=-12.50C"); + assert(board.battery_reads==2 && board.temperature_reads==2); + has(cli("get solar info"),"BQ24650");has(cli("get solar capabilities"),"unwired"); + has(cli("get solar history"),MESH_ENABLE_TELEMETRY_HISTORY?"30min":"not included"); + assert(board.battery_reads==2 && board.temperature_reads==2 && calls==Wire.calls); + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY);has(cli("get solar.board"),"advanced source=saved");nativeSilent(); + board.battery_mv=0;board.mcu_c=NAN; + has(cli("get solar readings"),"battery=unavailable MCU=unavailable"); + } else if(scenario=="invalid-runtime") { + fs.files["/solar_board"]={'b','a','d'};beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY);assert(!Wire.calls); + nativeSilent();has(cli("get solar.board"),"unconfirmed"); + has(cli("get solar readings"),"3.850V");assert(board.battery_reads==1); + has(cli("set solar.board advanced"),"OK");nativeSilent(); + beginSettings(board,nullptr,MESH_ENABLE_TELEMETRY_HISTORY);nativeSilent();has(cli("get solar.board"),"unconfirmed"); + has(cli("set solar.board mppt"),"unconfirmed");nativeSilent(); + has(cli("get solar battery"),"3.850V"); + } else if(scenario=="reenable-reload") { + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY);has(cli("set solar.board advanced"),"OK");nativeSilent(); + Settings overrides;overrides.defaults();overrides.masks[0x14]=2;overrides.values[0x14]=0; + uint8_t image[Settings::IMAGE_SIZE];overrides.encode(image); + fs.files["/solar_power"]=std::vector(image,image+sizeof(image)); + const auto calls=Wire.calls; + has(cli("set solar.board mppt"),"retained charger overrides"); + assert(Wire.calls==calls && !Wire.writes); + service();assert(Wire.calls>calls && Wire.writes==1 && !(Wire.charger[0x14]&2)); + has(cli("get mppt info"),"FST"); + CayenneLPP lpp;addTelemetry(lpp);assert(!lpp.channels.empty()); + assert(std::find(lpp.channels.begin(),lpp.channels.end(),2)!=lpp.channels.end()); + assert(std::find(lpp.channels.begin(),lpp.channels.end(),13)!=lpp.channels.end()); + uint8_t channels[5]={99,99,99,99,99}; + SensorManager::VoltageSensorReading values[5];values[4].channel=99; + assert(voltageChannels(channels,5)==4 && queryVoltages(values,5)==4); + for(unsigned i=0;i<4;++i)assert(channels[i]==i+2 && values[i].channel==i+2); + assert(values[0].valid && fabs(values[0].voltage-13.2f)<0.001f); + assert(channels[4]==99 && values[4].channel==99); + has(cli("set solar.board advanced"),"OK");nativeSilent(); + assert(fs.files["/solar_power"]==std::vector(image,image+sizeof(image))); + } else if(scenario=="selection-store-faults") { + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY);has(cli("set solar.board advanced"),"OK"); + const auto advanced=fs.files["/solar_board"]; + fs.fail_write=true;has(cli("set solar.board mppt"),"unconfirmed");nativeSilent(); + assert(fs.files["/solar_board"]==advanced);fs.fail_write=false; + cli("set solar.board advanced"); + fs.fail_rename_from="/solar_board.tmp"; + has(cli("set solar.board mppt"),"unconfirmed");nativeSilent(); + assert(fs.files["/solar_board"]==advanced);fs.fail_rename_from.clear(); + cli("set solar.board advanced");fs.corrupt_write=true; + has(cli("set solar.board mppt"),"unconfirmed");nativeSilent(); + assert(fs.files["/solar_board"]==advanced);fs.corrupt_write=false; + cli("set solar.board advanced");fs.fail_read_path="/solar_board"; + has(cli("set solar.board mppt"),"unconfirmed");nativeSilent(); + assert(fs.files["/solar_board"]!=advanced);fs.fail_read_path.clear(); + has(cli("set solar.board advanced"),"OK");nativeSilent(); + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY);assert(selection.isAdvanced());nativeSilent(); + } else if(scenario=="metadata-faults") { + fs.fail_stat=true; + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY); + has(cli("get solar.board"),"unconfirmed");nativeSilent(); + has(cli("set solar.board advanced"),"OK");nativeSilent(); + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY); + has(cli("get solar.board"),"advanced source=saved");nativeSilent(); + fs.fail_read_path="/solar_board"; + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY); + has(cli("get solar.board"),"unconfirmed");nativeSilent(); + fs.fail_read_path.clear(); + has(cli("set solar.board advanced"),"OK");nativeSilent(); + fs.files["/solar_board"][0]^=1; + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY); + has(cli("get solar.board"),"unconfirmed");nativeSilent(); + } else assert(false); +} +''' + + +class SolarXiaoCarrierTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise unittest.SkipTest("host C++11 compiler unavailable") + cls.temp = tempfile.TemporaryDirectory(prefix="solarxiao-carrier-test-") + cls.addClassCleanup(cls.temp.cleanup) + directory = Path(cls.temp.name) + (directory / "helpers").mkdir() + (directory / "helpers/IdentityStore.h").write_text(FILESYSTEM_WITH_METADATA, encoding="ascii") + (directory / "helpers/SensorManager.h").write_text(SENSOR_MANAGER, encoding="ascii") + (directory / "Wire.h").write_text(WIRE, encoding="ascii") + cls.binaries = {} + for name, harness, flags, extra in ( + ("selector", SELECTOR_HARNESS, [], []), + ("adapter-history", ADAPTER_HARNESS, ["-DNRF52_PLATFORM", "-DMESH_ENABLE_TELEMETRY_HISTORY=1", + "-DHIGH=1", "-DPIN_BOARD_SDA=30", "-DPIN_BOARD_SCL=31"], + [str(SENSORS / "SolarXiaoPowerCore.cpp")]), + ("adapter-no-history", ADAPTER_HARNESS, ["-DNRF52_PLATFORM", "-DMESH_ENABLE_TELEMETRY_HISTORY=0", + "-DHIGH=1", "-DPIN_BOARD_SDA=30", "-DPIN_BOARD_SCL=31"], + [str(SENSORS / "SolarXiaoPowerCore.cpp")]), + ): + cpp = directory / (name + ".cpp") + cpp.write_text(harness.replace("@ADAPTER@", str(SENSORS / "SolarXiaoPower.cpp")), encoding="ascii") + binary = directory / name + built = subprocess.run([cls.compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-DMESH_SOLARXIAO_POWER=1", *flags, "-I" + str(directory), + "-I" + str(ROOT / "src"), "-I" + str(SENSORS), str(cpp), + str(SENSORS / "SolarXiaoCarrier.cpp"), *extra, "-o", str(binary)], + text=True, capture_output=True, timeout=45) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + cls.binaries[name] = binary + + def run_scenario(self, binary, scenario): + result = subprocess.run([str(self.binaries[binary]), scenario], capture_output=True, + text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_explicit_selection_and_restart_persistence(self): + self.run_scenario("selector", "persistence") + + def test_corrupt_missing_and_unreadable_records_fail_closed(self): + self.run_scenario("selector", "invalid-records") + + def test_ambiguous_publication_requires_explicit_recovery(self): + self.run_scenario("selector", "save-failures") + + def test_unavailable_measurements_and_history_build_capability(self): + self.run_scenario("selector", "readings-history") + + def test_command_boundaries_and_every_reply_capacity(self): + self.run_scenario("selector", "parser-bounds") + + def test_actual_advanced_adapter_has_no_native_io_and_live_self_readings(self): + for binary in ("adapter-history", "adapter-no-history"): + with self.subTest(binary=binary): + self.run_scenario(binary, "advanced-runtime") + + def test_actual_adapter_blocks_native_io_with_unconfirmed_selection(self): + self.run_scenario("adapter-history", "invalid-runtime") + + def test_mppt_reenable_reloads_retained_policy_and_restores_telemetry(self): + self.run_scenario("adapter-history", "reenable-reload") + + def test_actual_selection_store_write_rename_checksum_and_public_read_faults(self): + self.run_scenario("adapter-history", "selection-store-faults") + + def test_history_capability_is_passed_from_the_role_translation_unit(self): + adapter = (SENSORS / "SolarXiaoPower.cpp").read_text(encoding="ascii") + self.assertNotRegex(adapter, r"(?m)^\s*#\s*(?:if|ifdef|ifndef)\b[^\n]*MESH_ENABLE_TELEMETRY_HISTORY") + role = (ROOT / "examples/simple_repeater/main.cpp").read_text(encoding="ascii") + self.assertRegex(role, r"mesh::solar::beginSettings\(board,\s*regulator_prefs_fs,\s*MESH_ENABLE_TELEMETRY_HISTORY") + + def test_metadata_errors_require_a_valid_full_record_or_block_native_access(self): + self.run_scenario("adapter-history", "metadata-faults") + + +if __name__ == "__main__": + unittest.main()