From 804219e9feb4e894b36d2338f13fdded01cf89f9 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 21 Oct 2026 16:32:03 -0700 Subject: [PATCH] Detect SolarXiao MPPT automatically and support Base self telemetry --- docs/cli_commands.md | 6 +- docs/index.md | 2 +- docs/solarxiao_power.md | 79 +++++++--- src/helpers/sensors/SolarXiaoCarrier.cpp | 72 ++++++--- src/helpers/sensors/SolarXiaoCarrier.h | 17 ++- src/helpers/sensors/SolarXiaoPower.cpp | 16 +- test/test_solarxiao_carrier.py | 182 ++++++++++++++++++++++- 7 files changed, 318 insertions(+), 56 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 57ce1a37c..912f1290b 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -4,9 +4,9 @@ # CLI Commands -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`). +For SolarXiao Base/Advanced/MPPT automatic detection, manual overrides, live +battery/MCU readings and charger controls, see [SolarXiao carrier profiles](solarxiao_power.md) +(`get solar`, `get solar readings`, `set solar.board auto`, `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 7ea1db0e8..99d593ba8 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 Advanced/MPPT carrier profiles and battery controls](solarxiao_power.md) +- [SolarXiao Base/Advanced/MPPT detection 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 73e67c70a..67aacb80c 100644 --- a/docs/solarxiao_power.md +++ b/docs/solarxiao_power.md @@ -1,45 +1,81 @@ # SolarXiao carrier profiles, MPPT and battery controls -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 +SolarXiao Repeater firmware supports the CN3791 Base, older BQ24650 Advanced and +LTC4162/INA260 MPPT carriers 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 +## Automatic detection and manual overrides -Carrier selection is configured by the operator; missing I2C responses never -identify an Advanced board. `mppt` is the default for existing installations. +The default is `auto` when no carrier selection has been saved. Existing saved +`mppt` or `advanced` selections remain manual overrides after an update. +Use `set solar.board auto` to enable detection on such an installation. The firmware environment, hardware/OTA identity and flash layout are unchanged. ``` get solar.board -set solar.board advanced +set solar.board auto 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. +Auto mode checks the INA260 manufacturer's ID (`0xfe = 0x5449`) and device ID +(`0xff & 0xfff0 = 0x2270`) at carrier address `0x40`. An address ACK alone is not +enough: another device such as INA219 can share that address. Detection only +reads identity registers; it does not configure/reset either chip. The first +probe occurs in the loop after initialization, then retries every five seconds +until a valid identity is found. A positive result identifies MPPT for these +carrier designs and is retained until reboot or a new board selection. +Temporary communication loss does not relabel a previously detected MPPT. + +Without a verified INA260, auto reports `self-only` and keeps retrying. It does +not claim to distinguish Base from Advanced: neither has a digital identity, +and missing responses can also mean an MPPT power/bus fault. Charger access, +saved-override replay and native carrier telemetry stay blocked until a valid +INA260 identity is found. XIAO self readings remain available throughout. +The MPPT carrier's INA260 was previously read with USB power only; detection +does not require the LTC4162 charger to respond at SOLAR/VIN. + +Manual settings are available when the physical carrier is known: + +| Setting | Behavior | +| --- | --- | +| `set solar.board auto` | Verify INA260 before enabling MPPT; otherwise self-only, retrying. | +| `set solar.board base` | Base/CN3791 capabilities, self readings, no native I2C access. | +| `set solar.board advanced` | Advanced/BQ24650 capabilities, self readings, no native I2C access. | +| `set solar.board mppt` | Enable native access without requiring INA260 detection; existing charger write identity checks still apply. | + +For Base or Advanced, an explicit selection reports that design's 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 +this profile adds no further physical measurements. It adds carrier detection, +manual overrides and capability reporting; the voltage/temperature history commands also already exist in builds that include telemetry history. +The [2025 Base Standard schematic](https://github.com/wehooper4/Meshtastic-Hardware/blob/main/XaioSeries/SolarXaio/Base/Standard/Schematic_SolarXaio_Base_2025-10-23.pdf) +uses a CN3791 analog charger with no digital interface or INA monitor. Battery +voltage reaches the XIAO BAT input. Charge status drives an LED, and the other +charger signals are not connected to XIAO measurement/control inputs. Base +therefore needs no extra telemetry driver: only self battery voltage and MCU +temperature are available, just like Advanced. Base Lite publishes a README +but no schematic, so its exact connections are not independently qualified here. + 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, +Manual Base/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. +`mppt ...` explain that the selected carrier has no digital charger interface. +Pending auto mode only probes INA260 identity; it does not access the charger. 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 @@ -59,15 +95,24 @@ 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 +When auto verifies MPPT or a manual MPPT selection re-enables native access, +the firmware 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. +protection, or write charger defaults. The INA260 identity verifies the monitor; +LTC4162 chemistry and cell-count checks still independently guard charger writes. + +Radio target selection remains separate. Choose the existing +`solarxiao_30S_repeater` or `solarxiao_33S_repeater` firmware target to match the +fitted E22 radio module. The +published Base Standard schematic specifies M30S; the pictured 2W Advanced +uses M33S. Charger detection cannot identify the PA variant or select its TX +power limits. + 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. -See the [Advanced profile build/host validation](test-results/solarxiao_advanced_2026-10-21.md). +See the [earlier manual Advanced profile validation](test-results/solarxiao_advanced_2026-10-21.md). This adds no measurements beyond existing self voltage/MCU temperature and has not been installed on the remote Advanced board. diff --git a/src/helpers/sensors/SolarXiaoCarrier.cpp b/src/helpers/sensors/SolarXiaoCarrier.cpp index 0fcff7d32..a6c90458b 100644 --- a/src/helpers/sensors/SolarXiaoCarrier.cpp +++ b/src/helpers/sensors/SolarXiaoCarrier.cpp @@ -8,17 +8,20 @@ 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] = { +const uint8_t images[4][CarrierSelection::IMAGE_SIZE] = { {'S','X','B',1,0,0xff,0xa5,0x5a}, - {'S','X','B',1,1,0xfe,0xa5,0x5a} + {'S','X','B',1,1,0xfe,0xa5,0x5a}, + {'S','X','B',1,2,0xfd,0xa5,0x5a}, + {'S','X','B',1,3,0xfc,0xa5,0x5a} }; } void CarrierSelection::begin() { - type=Mppt; saved=false; healthy=store.available(); + type=Auto; saved=false; healthy=store.available(); + verified_mppt=false; probed=false; next_probe=0; if (!healthy || !store.exists()) return; uint8_t image[IMAGE_SIZE]; - if (!store.load(image,sizeof(image)) || image[4]>1 + if (!store.load(image,sizeof(image)) || image[4]>Base || memcmp(image,images[image[4]],sizeof(image))) { healthy=false; return; @@ -26,6 +29,17 @@ void CarrierSelection::begin() { type=static_cast(image[4]); saved=true; } +void CarrierSelection::service(Bus& bus,uint32_t now) { + if (!isAuto() || verified_mppt || (probed && static_cast(now-next_probe)<0)) return; + probed=true; next_probe=now+5000; + uint16_t manufacturer,device; + // These read-only identity registers do not configure the INA260 or charger. + // A simple address ACK could be a different device (including an INA219). + verified_mppt=bus.read(0x40,0xfe,manufacturer) && manufacturer==0x5449 + && bus.read(0x40,0xff,device) && (device&0xfff0)==0x2270; + // Latch a positive identity for this boot; transient loss must not relabel it. +} + 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. @@ -34,6 +48,7 @@ bool CarrierSelection::select(Type requested) { return false; } type=requested; saved=true; healthy=true; + verified_mppt=false; probed=false; next_probe=0; return true; } @@ -41,20 +56,31 @@ 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"); + if (!healthy) snprintf(reply,capacity,"Err - carrier selection unconfirmed; set solar.board auto|advanced|base|mppt"); + else if (type==Auto) snprintf(reply,capacity,"> auto source=%s result=%s",saved?"saved":"default", + verified_mppt?"mppt (INA260 ID verified)":"self-only (MPPT unconfirmed; Base/Advanced indistinguishable)"); + else snprintf(reply,capacity,"> %s source=%s (manual override)", + type==Advanced?"advanced":type==Base?"base":"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)) { + Type requested=Auto; + bool valid=true; + if (!strcmp(value,"auto")) requested=Auto; + else if (!strcmp(value,"advanced")) requested=Advanced; + else if (!strcmp(value,"base")) requested=Base; + else if (!strcmp(value,"mppt")) requested=Mppt; + else valid=false; + if (!valid) { + snprintf(reply,capacity,"Err - use set solar.board auto|advanced|base|mppt"); + } else if (!select(requested)) { 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 if (type==Auto) { + snprintf(reply,capacity,"OK - auto saved; verifying INA260; self ADC/MCU retained; verified MPPT reloads retained charger overrides"); + } else if (type==Advanced || type==Base) { + snprintf(reply,capacity,"OK - %s saved; native LTC/INA access disabled; self ADC/MCU telemetry retained",type==Base?"Base":"Advanced"); } else { snprintf(reply,capacity,"OK - MPPT saved; native access and retained charger overrides re-enabled"); } @@ -74,16 +100,24 @@ bool CarrierSelection::handle(const char* command,char* reply,size_t capacity, ? "> 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"); + snprintf(reply,capacity,"Err - carrier selection unconfirmed; self readings available; set solar.board auto|advanced|base|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"); + if (type==Auto) snprintf(reply,capacity,"> mode=auto source=%s %s",saved?"saved":"default", + verified_mppt?"carrier=mppt (INA260 verified); use get mppt for LTC4162 identity/readings" + :"result=self-only; INA260 not verified; Base/Advanced cannot be distinguished"); + else snprintf(reply,capacity,"> carrier=%s source=%s charger=%s; %s", + type==Advanced?"advanced":type==Base?"base":"mppt",saved?"configured":"default", + type==Advanced?"BQ24650":type==Base?"CN3791":"LTC4162", + type==Mppt?"use get mppt for live chip identity/readings":"ADC/MCU only; charger settings hardware-only"); } else if (!strcmp(key,"capabilities")) { - snprintf(reply,capacity,type==Advanced + const char* capabilities=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"); + : type==Base + ? "> Base: battery ADC + MCU temp; CN3791 analog charger has no digital readings/settings; other power signals unwired" + : nativeAllowed() + ? "> MPPT: self ADC/MCU + INA260 V/A/W + LTC4162 readings/settings when reachable; use get mppt fields 0" + : "> Auto: self ADC/MCU only until INA260 ID verifies MPPT; Base/Advanced have no digital identity; retrying detection"; + snprintf(reply,capacity,"%s",capabilities); } else { snprintf(reply,capacity,"Err - use get solar info|readings|capabilities|history; get/set solar.board"); } diff --git a/src/helpers/sensors/SolarXiaoCarrier.h b/src/helpers/sensors/SolarXiaoCarrier.h index 0ac209f9d..aa0b30fbc 100644 --- a/src/helpers/sensors/SolarXiaoCarrier.h +++ b/src/helpers/sensors/SolarXiaoCarrier.h @@ -4,25 +4,32 @@ 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. +// Only a verified INA260 identifies the MPPT carrier automatically. Missing +// responses leave self-only telemetry; Base and Advanced have no digital ID. class CarrierSelection { public: - enum Type : uint8_t { Mppt = 0, Advanced = 1 }; + // Preserve the original manual record values for installed selections. + enum Type : uint8_t { Mppt = 0, Advanced = 1, Auto = 2, Base = 3 }; static constexpr size_t IMAGE_SIZE = 8; private: Store& store; - Type type = Mppt; + Type type = Auto; bool healthy = false; bool saved = false; + bool verified_mppt = false; + bool probed = false; + uint32_t next_probe = 0; bool select(Type requested); public: explicit CarrierSelection(Store& persistence) : store(persistence) {} void begin(); - bool nativeAllowed() const { return healthy && type == Mppt; } + void service(Bus& bus, uint32_t now); + bool nativeAllowed() const { return healthy && (type == Mppt || (type == Auto && verified_mppt)); } bool isAdvanced() const { return healthy && type == Advanced; } + bool isBase() const { return healthy && type == Base; } + bool isAuto() const { return healthy && type == Auto; } 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); diff --git a/src/helpers/sensors/SolarXiaoPower.cpp b/src/helpers/sensors/SolarXiaoPower.cpp index 13e43ec27..456d1c17f 100644 --- a/src/helpers/sensors/SolarXiaoPower.cpp +++ b/src/helpers/sensors/SolarXiaoPower.cpp @@ -94,7 +94,12 @@ void beginSettings(MainBoard& board,FILESYSTEM* fs,bool telemetry_history_availa selection.begin(); persistence.fs=fs; controller.begin(); } -void service() { if (selection.nativeAllowed()) controller.service(millis()); } +void service() { + const bool native_before=selection.nativeAllowed(); + selection.service(transport,millis()); + if (!native_before && selection.nativeAllowed()) controller.reload(); + 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"))) { @@ -110,9 +115,14 @@ bool handleCommand(const char* command,char* reply,size_t capacity,bool local) { 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() + const char* error=selection.isAdvanced() ? "Err - BQ24650 Advanced has no digital charger/INA interface; use get solar readings" - : "Err - carrier selection unconfirmed; set solar.board advanced|mppt"); + : selection.isBase() + ? "Err - CN3791 Base has no digital charger/INA interface; use get solar readings" + : selection.isAuto() + ? "Err - auto has not verified INA260; self-only; use get solar.board or set solar.board mppt for manual override" + : "Err - carrier selection unconfirmed; set solar.board auto|advanced|base|mppt"; + snprintf(reply,capacity,"%s",error); return true; } return controller.handle(command,reply,capacity,local); diff --git a/test/test_solarxiao_carrier.py b/test/test_solarxiao_carrier.py index 54fdb3b8c..63b66bde1 100644 --- a/test/test_solarxiao_carrier.py +++ b/test/test_solarxiao_carrier.py @@ -53,6 +53,19 @@ struct MemoryStore : Store { return !fail_save; } }; +struct IdentityBus : Bus { + uint16_t manufacturer=0x5449,device=0x2270; + bool online=true;int fail_reg=-1; + unsigned reads=0,writes=0; + std::vector registers; + bool read(uint8_t address,uint8_t reg,uint16_t& value) override { + assert(address==0x40 && (reg==0xfe || reg==0xff)); + ++reads;registers.push_back(reg); + if(!online || reg==fail_reg)return false; + value=reg==0xfe?manufacturer:device;return true; + } + bool write(uint8_t,uint8_t,uint16_t) override {++writes;assert(false);return false;} +}; std::string cli(CarrierSelection& c,const char* cmd,float mv=3.876f, float temp=27.25f,bool history=true) { char reply[157]={}; @@ -70,11 +83,10 @@ int main(int argc,char** argv) { if(scenario=="persistence") { MemoryStore store;CarrierSelection c(store); assert(!c.nativeAllowed());c.begin(); - assert(c.settingsHealthy() && c.nativeAllowed() && !c.isAdvanced()); + assert(c.settingsHealthy() && c.isAuto() && !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,"get solar.board"),"auto");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); @@ -88,6 +100,14 @@ int main(int argc,char** argv) { CarrierSelection mppt(store);mppt.begin(); assert(mppt.nativeAllowed() && !mppt.isAdvanced()); has(cli(mppt,"get solar.board"),"mppt source=saved"); + cli(mppt,"set solar.board base");assert(mppt.isBase() && !mppt.nativeAllowed()); + CarrierSelection base(store);base.begin();assert(base.isBase()); + has(cli(base,"get solar info"),"CN3791"); + has(cli(base,"get solar capabilities"),"Base"); + cli(base,"set solar.board auto");assert(base.isAuto() && !base.nativeAllowed()); + CarrierSelection automatic(store);automatic.begin(); + assert(automatic.isAuto() && !automatic.nativeAllowed()); + has(cli(automatic,"get solar.board"),"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; @@ -143,17 +163,18 @@ int main(int argc,char** argv) { "set solar.boardextra advanced", "get mppt", "get SOLAR", "solar"}) { assert(!c.handle(unrelated,reply,sizeof(reply),3.8f,25,true)); } - for(const char* invalid:{"set solar.board", "set solar.board auto", + for(const char* invalid:{"set solar.board", "set solar.board unknown", "set solar.board ADVANCED", "set solar.board advanced extra", "set solar.board advanced ", "get solar unknown"}) { - has(cli(c,invalid),"Err");assert(!store.saves && c.nativeAllowed()); + has(cli(c,invalid),"Err");assert(!store.saves && c.isAuto() && !c.nativeAllowed()); } assert(!c.handle(nullptr,reply,sizeof(reply),3.8f,25,true)); assert(!c.handle("get solar",nullptr,sizeof(reply),3.8f,25,true)); assert(!c.handle("get solar",reply,0,3.8f,25,true)); for(const char* cmd:{"get solar", "get solar info", "get solar.board", "get solar capabilities", "get solar readings", "get solar battery", - "get solar history", "set solar.board advanced", "set solar.board mppt"}) { + "get solar history", "set solar.board advanced", "set solar.board mppt", + "set solar.board auto", "set solar.board base"}) { for(size_t capacity=1;capacity<=157;++capacity) { std::vector guarded(capacity+16,'~'); char* out=guarded.data()+8; @@ -162,6 +183,61 @@ int main(int argc,char** argv) { assert(memchr(out,0,capacity)!=nullptr); } } + } else if(scenario=="auto-positive") { + MemoryStore store;CarrierSelection c(store);IdentityBus bus;c.begin(); + assert(c.isAuto() && !c.nativeAllowed() && !bus.reads); + c.service(bus,0);assert(c.nativeAllowed() && bus.reads==2 && !bus.writes); + assert(bus.registers==std::vector({0xfe,0xff})); + assert(!store.saves);has(cli(c,"get solar.board"),"auto"); + bus.online=false;const unsigned reads=bus.reads; + c.service(bus,5000);c.service(bus,100000);assert(c.nativeAllowed() && bus.reads==reads); + cli(c,"set solar.board advanced");assert(!c.nativeAllowed()); + c.service(bus,100001);assert(bus.reads==reads); + cli(c,"set solar.board auto");assert(c.isAuto() && !c.nativeAllowed()); + bus.online=true;bus.device=0x227f;c.service(bus,100002); + assert(c.nativeAllowed() && bus.reads==reads+2); + c.begin();assert(c.isAuto() && !c.nativeAllowed()); + has(cli(c,"get solar.board"),"source=saved"); + c.service(bus,0xf0000000);assert(c.nativeAllowed()); + } else if(scenario=="auto-negative-retry") { + for(unsigned fault=0;fault<5;++fault) { + MemoryStore store;CarrierSelection c(store);IdentityBus bus;c.begin(); + if(fault==0)bus.online=false; + if(fault==1)bus.manufacturer=0x5448; + if(fault==2)bus.device=0x2260; + if(fault==3){bus.manufacturer=0;bus.device=0;} // ACK alone is not identity. + if(fault==4)bus.fail_reg=0xff; + c.service(bus,123);assert(!c.nativeAllowed() && !c.isAdvanced() && !c.isBase()); + const unsigned reads=bus.reads;assert(reads>=1 && reads<=2 && !bus.writes); + bus.online=true;bus.manufacturer=0x5449;bus.device=0x2270;bus.fail_reg=-1; + c.service(bus,124);c.service(bus,5122);assert(bus.reads==reads && !c.nativeAllowed()); + c.service(bus,5123);assert(c.nativeAllowed() && bus.reads==reads+2 && !bus.writes); + assert(!store.saves); + } + } else if(scenario=="auto-wrap-manual-silence") { + MemoryStore store;CarrierSelection c(store);IdentityBus bus;c.begin();bus.online=false; + const uint32_t start=UINT32_MAX-1000;c.service(bus,start); + assert(bus.reads>0 && !c.nativeAllowed());const unsigned reads=bus.reads; + bus.online=true;c.service(bus,UINT32_MAX);c.service(bus,0);c.service(bus,3998); + assert(bus.reads==reads && !c.nativeAllowed()); + c.service(bus,3999);assert(c.nativeAllowed() && bus.reads==reads+2); + for(const char* mode:{"set solar.board advanced","set solar.board base","set solar.board mppt"}) { + cli(c,mode);const unsigned before=bus.reads; + c.service(bus,10000);c.service(bus,0xf0000000);assert(bus.reads==before); + } + cli(c,"set solar.board auto");c.begin();assert(!c.nativeAllowed()); + store.fail_save=true;cli(c,"set solar.board mppt");blocked(c); + const unsigned before=bus.reads;c.service(bus,1);c.service(bus,99999); + assert(bus.reads==before && !bus.writes); + } else if(scenario=="legacy-manual-records") { + for(uint8_t mode:{0,1}) { + MemoryStore store;store.present=true; + store.bytes={'S','X','B',1,mode,uint8_t(0xff^mode),0xa5,0x5a}; + CarrierSelection c(store);IdentityBus bus;c.begin(); + assert(c.settingsHealthy() && c.nativeAllowed()==(mode==0)); + assert(c.isAdvanced()==(mode==1) && !c.isAuto() && !c.isBase()); + c.service(bus,0);c.service(bus,99999);assert(!bus.reads && !bus.writes && !store.saves); + } } else assert(false); } ''' @@ -200,6 +276,8 @@ WIRE = r''' struct FakeWire { uint16_t charger[256]={},ina[256]={}; unsigned calls=0,reads=0,writes=0; + bool online=true; + struct Read {uint8_t address,reg;};std::vector log; uint8_t address=0,reg=0;size_t cursor=0; std::vector tx,rx; FakeWire() { @@ -215,6 +293,7 @@ struct FakeWire { void write(uint8_t b) {tx.push_back(b);} int endTransmission(bool stop=true) { assert(!tx.empty());reg=tx[0]; + if(!online)return 1; if(stop && tx.size()==3) { assert(address==0x68);++writes;charger[reg]=tx[1]|(uint16_t(tx[2])<<8); } @@ -222,6 +301,7 @@ struct FakeWire { } int requestFrom(uint8_t a,uint8_t n) { assert(a==address && n==2);++reads;cursor=0; + log.push_back({a,reg}); 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; @@ -255,13 +335,13 @@ std::string cli(const char* command) { void has(const std::string& text,const char* wanted) { assert(text.find(wanted)!=std::string::npos); } -void nativeSilent() { +void nativeSilent(bool periodic=true) { 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(); + if(periodic){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]; @@ -351,6 +431,68 @@ int main(int argc,char** argv) { fs.files["/solar_board"][0]^=1; beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY); has(cli("get solar.board"),"unconfirmed");nativeSilent(); + } else if(scenario=="base-runtime") { + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY);assert(!Wire.calls); + has(cli("set solar.board base"),"OK");nativeSilent(); + has(cli("get solar info"),"CN3791");has(cli("get solar capabilities"),"Base"); + has(cli("get solar readings"),"battery=3.850V MCU=26.75C"); + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY); + assert(selection.isBase());nativeSilent(); + } else if(scenario=="auto-pending-replay") { + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY); + assert(!Wire.calls && selection.isAuto() && !selection.nativeAllowed()); + has(cli("get solar.board"),"auto");nativeSilent(false); + 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)); + Wire.ina[0xfe]=0;now_ms=100;service(); + assert(!selection.nativeAllowed() && !Wire.writes && Wire.calls>0); + for(auto item:Wire.log)assert(item.address==0x40 && (item.reg==0xfe || item.reg==0xff)); + const auto calls=Wire.calls;Wire.ina[0xfe]=0x5449; + now_ms=5099;service();assert(Wire.calls==calls && !Wire.writes);nativeSilent(false); + now_ms=5100;service();assert(selection.nativeAllowed()); + assert(Wire.writes==1 && !(Wire.charger[0x14]&2)); + CayenneLPP lpp;addTelemetry(lpp);assert(!lpp.channels.empty()); + overrides.values[0x14]=2;overrides.encode(image); + fs.files["/solar_power"]=std::vector(image,image+sizeof(image)); + now_ms=10100;service();assert(Wire.writes==1 && !(Wire.charger[0x14]&2)); + Wire.online=false;now_ms=15100;service();assert(selection.nativeAllowed()); + Wire.online=true; + has(cli("set solar.board auto"),"OK");assert(!selection.nativeAllowed());nativeSilent(false); + now_ms=15101;service();assert(selection.nativeAllowed() && Wire.writes==2 && (Wire.charger[0x14]&2)); + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY); + assert(selection.isAuto() && !selection.nativeAllowed());nativeSilent(false); + } else if(scenario=="auto-no-ack") { + Wire.online=false;beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY);assert(!Wire.calls); + now_ms=123;service();assert(!selection.nativeAllowed() && !Wire.writes);nativeSilent(false); + const auto calls=Wire.calls;Wire.online=true;now_ms=5122;service(); + assert(Wire.calls==calls && !selection.nativeAllowed()); + now_ms=5123;service();assert(selection.nativeAllowed() && !Wire.writes); + } else if(scenario=="late-auto-replay") { + beginSettings(board,&fs,MESH_ENABLE_TELEMETRY_HISTORY); + cli("set solar.board mppt");now_ms=100;service(); + assert(selection.nativeAllowed() && Wire.calls>0 && !Wire.writes); + cli("set solar.board advanced");nativeSilent(false); + 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)); + cli("set solar.board auto");assert(!selection.nativeAllowed()); + const uint32_t late=UINT32_MAX-1000; + now_ms=late;service(); + assert(selection.nativeAllowed() && Wire.writes==1 && !(Wire.charger[0x14]&2)); + auto calls=Wire.calls; + // The first replay must be immediate despite the previous low-timestamp + // service. Subsequent cycles must honor five seconds across millis wrap. + now_ms=late;service();now_ms=UINT32_MAX;service();now_ms=0;service(); + now_ms=3998;service();assert(Wire.calls==calls && Wire.writes==1); + Wire.charger[0x14]|=2; // Simulate a charger reset needing policy replay. + service();assert(Wire.calls==calls && Wire.writes==1); + now_ms=3999;service(); + assert(Wire.calls>calls && Wire.writes==2 && !(Wire.charger[0x14]&2)); + calls=Wire.calls;now_ms=4000;service();now_ms=8998;service(); + assert(Wire.calls==calls && Wire.writes==2); + now_ms=8999;service(); + assert(Wire.calls>calls && Wire.writes==2 && !(Wire.charger[0x14]&2)); } else assert(false); } ''' @@ -435,6 +577,30 @@ class SolarXiaoCarrierTests(unittest.TestCase): def test_metadata_errors_require_a_valid_full_record_or_block_native_access(self): self.run_scenario("adapter-history", "metadata-faults") + def test_auto_requires_exact_read_only_identity_and_latches_until_reselection(self): + self.run_scenario("selector", "auto-positive") + + def test_auto_retries_missing_wrong_and_ack_only_devices_without_charger_io(self): + self.run_scenario("selector", "auto-negative-retry") + + def test_auto_timer_wrap_and_all_manual_or_unhealthy_modes_are_silent(self): + self.run_scenario("selector", "auto-wrap-manual-silence") + + def test_original_manual_record_bytes_keep_their_meanings(self): + self.run_scenario("selector", "legacy-manual-records") + + def test_actual_base_adapter_has_no_native_io_or_invented_measurements(self): + self.run_scenario("adapter-history", "base-runtime") + + def test_actual_auto_adapter_reloads_overrides_only_after_positive_detection(self): + self.run_scenario("adapter-history", "auto-pending-replay") + + def test_actual_auto_adapter_recovers_from_an_initial_missing_device(self): + self.run_scenario("adapter-history", "auto-no-ack") + + def test_actual_late_auto_detection_replays_immediately_and_schedules_across_wrap(self): + self.run_scenario("adapter-history", "late-auto-replay") + if __name__ == "__main__": unittest.main()