Add explicit SolarXiao Advanced carrier selection and capability reporting

This commit is contained in:
mikecarper committed 2026-10-21 16:18:18 -07:00
1 parent 204fc5265b
commit e6b08cafab
11 files changed
+700 -12

No files matched your search

+3 -2
View File
@@ -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.
+1 -1
View File
@@ -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)
+70 -3
View File
@@ -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.
+1 -1
View File
@@ -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");
+93
View File
@@ -0,0 +1,93 @@
#include "SolarXiaoCarrier.h"
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
#include <math.h>
#include <stdio.h>
#include <string.h>
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<Type>(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
+31
View File
@@ -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
+53 -3
View File
@@ -1,7 +1,9 @@
#include "SolarXiaoPower.h"
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
#include "SolarXiaoPowerCore.h"
#include "SolarXiaoCarrier.h"
#include <helpers/CheckedSettingsFile.h>
#include <helpers/FilePresence.h>
#include <Wire.h>
#include <math.h>
@@ -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 && i<count;++i) channels[i]=2+i;
return count;
}
uint8_t queryVoltages(SensorManager::VoltageSensorReading* readings,uint8_t capacity) {
if (!readings || !capacity) return 0;
if (!selection.nativeAllowed() || !readings || !capacity) return 0;
Snapshot s; controller.snapshot(s);
const float volts[]={s.battery_v,s.vin_v,s.vout_v,s.charger_vbat_v};
const uint8_t count=capacity<4 ? capacity : 4;
+1 -1
View File
@@ -7,7 +7,7 @@
namespace mesh { namespace solar {
// Stable carrier channels 2..13 precede optional environmental sensors.
constexpr uint8_t FIRST_OPTIONAL_CHANNEL = 14;
void beginSettings(FILESYSTEM* fs);
void beginSettings(MainBoard& board, FILESYSTEM* fs, bool telemetry_history_available);
void service();
bool handleCommand(const char* command, char* reply, size_t capacity, bool local);
void addTelemetry(CayenneLPP& telemetry);
+6 -1
View File
@@ -303,6 +303,11 @@ bool Controller::identify(uint16_t& identity) {
if (cells) last_cells=cells;
return true;
}
void Controller::reload() {
started=false;
begin();
next_service=0;
}
bool Controller::verifyWritableIdentity(char* reply,size_t capacity) {
uint16_t identity;
if (!identify(identity)) {snprintf(reply,capacity,"Err - charger unavailable; power SOLAR/VIN"); return false;}
@@ -523,7 +528,7 @@ bool Controller::action(const char* args,bool local,char* reply,size_t capacity)
return true;
}
if (!strcmp(args,"reload")) {
started=false; begin(); next_service=0;
reload();
snprintf(reply,capacity,storage_healthy?"OK - saved settings loaded; apply on service":"Err - saved settings invalid/unavailable"); return true;
}
if (!strcmp(args,"forget")) {
+1
View File
@@ -70,6 +70,7 @@ public:
settings.defaults();
}
void begin();
void reload();
void service(uint32_t now);
bool snapshot(Snapshot& result);
bool readMonitor(float& volts, float& amps, float& watts);
+440
View File
@@ -0,0 +1,440 @@
"""Exercise the real carrier selector and adapter with persistence/bus faults."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_regulator_settings import FILESYSTEM
ROOT = Path(__file__).resolve().parents[1]
SENSORS = ROOT / "src/helpers/sensors"
# Compile the production NRF52 FilePresence boundary, including the distinction
# between an absent record and failed filesystem metadata. Other file operations
# keep using the shared mock so metadata and full-read faults are independent.
FILESYSTEM_WITH_METADATA = FILESYSTEM.replace(
"struct MockFS;", "struct MockFS;\nstruct lfs_info {};\nconstexpr int LFS_ERR_NOENT=-2;\n"
"int lfs_stat(MockFS*,const char*,lfs_info*);"
).replace(
"unsigned writes=0,renames=0;",
"unsigned writes=0,renames=0;bool fail_stat=false;\n"
" void _lockFS() {}\n void _unlockFS() {}\n MockFS* _getFS() {return this;}"
) + r'''
inline int lfs_stat(MockFS* fs,const char* path,lfs_info*) {
return fs->fail_stat ? -5 : fs->files.count(path) ? 0 : LFS_ERR_NOENT;
}
'''
SELECTOR_HARNESS = r'''
#include "SolarXiaoCarrier.h"
#include <cassert>
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
using namespace mesh::solar;
struct MemoryStore : Store {
std::vector<uint8_t> 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<valid.size();++byte) {
MemoryStore store;store.present=true;store.bytes=valid;store.bytes[byte]^=0x40;
CarrierSelection c(store);c.begin();blocked(c);assert(!store.saves);
}
for(size_t size=0;size<=10;++size) {
if(size==valid.size())continue;
MemoryStore store;store.present=true;store.bytes=valid;store.bytes.resize(size);
CarrierSelection c(store);c.begin();blocked(c);assert(!store.saves);
}
MemoryStore store;store.present=true;store.bytes=valid;store.fail_load=true;
CarrierSelection c(store);c.begin();blocked(c);
store.fail_load=false;cli(c,"set solar.board advanced");assert(c.isAdvanced());
store.available_now=false;c.begin();blocked(c);
const unsigned saves=store.saves;has(cli(c,"set solar.board mppt"),"unconfirmed");
assert(store.saves==saves);blocked(c);
store.available_now=true;c.begin();assert(c.isAdvanced());
} else if(scenario=="save-failures") {
for(bool published:{false,true}) {
MemoryStore store;CarrierSelection c(store);c.begin();
store.fail_save=true;store.publish_before_failure=published;
has(cli(c,"set solar.board advanced"),"native access blocked");blocked(c);
assert(store.present==published);
store.fail_save=false;
has(cli(c,"set solar.board advanced"),"OK");assert(c.isAdvanced());
store.fail_save=true;store.publish_before_failure=published;
has(cli(c,"set solar.board mppt"),"unconfirmed");blocked(c);
assert(store.bytes[4]==(published?0:1));
store.fail_save=false;
cli(c,"set solar.board mppt");assert(c.nativeAllowed());
}
} else if(scenario=="readings-history") {
MemoryStore store;CarrierSelection c(store);c.begin();cli(c,"set solar.board advanced");
has(cli(c,"get solar readings"),"battery=3.876V MCU=27.25C");
has(cli(c,"get solar battery"),"processor");
has(cli(c,"get solar readings"),"self channel=1");
for(float value:{NAN,INFINITY,-INFINITY}) {
has(cli(c,"get solar readings",value,value),"battery=unavailable MCU=unavailable");
}
for(float value:{0.0f,-1.0f})
has(cli(c,"get solar battery",value,-15.5f),"battery=unavailable MCU=-15.50C");
has(cli(c,"get solar history"),"get telemetry.volt [1-7]");
has(cli(c,"get solar history"),"30min samples, 7d RAM history");
has(cli(c,"get solar history",3.8f,25,false),"not included");
store.fail_load=true;c.begin();blocked(c);
has(cli(c,"get solar readings"),"3.876V");
has(cli(c,"get solar history",3.8f,25,false),"not included");
} else if(scenario=="parser-bounds") {
MemoryStore store;CarrierSelection c(store);c.begin();char reply[157]={};
for(const char* unrelated:{"", "get solarpanel", "get solar.boardextra",
"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",
"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());
}
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"}) {
for(size_t capacity=1;capacity<=157;++capacity) {
std::vector<char> 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 <cstdint>
#include <vector>
#include <cmath>
struct CayenneLPP {
std::vector<uint8_t> 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 <cassert>
#include <cstdint>
#include <vector>
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<uint8_t> 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<rx.size()?rx[cursor++]:-1;}
};
extern FakeWire Wire;
'''
ADAPTER_HARNESS = r'''
#include <cassert>
#include <cmath>
#include <cstring>
#include <string>
#include <algorithm>
#include <helpers/IdentityStore.h>
#include <helpers/SensorManager.h>
#include <Wire.h>
#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<uint8_t>(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<uint8_t>(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()