Enable SolarXiao Room Server power profiles and late-detection history

This commit is contained in:
mikecarper committed 2026-10-21 17:02:01 -07:00
1 parent ad15f2ea08
commit 9aa7974567
9 files changed
+625 -9

No files matched your search

+26 -6
View File
@@ -1,7 +1,8 @@
# SolarXiao carrier profiles, MPPT and battery controls
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
SolarXiao Repeater and QSPI OTA Room Server firmware support 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.
@@ -82,7 +83,7 @@ resistors, switches and a solder jumper. An OTA update cannot measure those
signals or change those setpoints without additional hardware connections.
Manual Basic mode suppresses native LTC4162/INA260 polling, register reads/writes,
replay and telemetry/history channels 2..13. `get mppt`, `set mppt ...` and
replay and native measurements on channels 2..13. `get mppt`, `set mppt ...` and
`mppt ...` explain that Basic Base/Advanced layouts have 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
@@ -90,6 +91,14 @@ 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.
Voltage history reserves native channel IDs 2..5 at boot, even while Auto is
pending or Basic is selected. This is schema allocation, not chip access or a
measurement. It lets later detection, charger input power or a manual mode
change supply valid samples without restarting or clearing unrelated history.
Inactive readings remain missing, and empty channels do not appear in populated
history results. Native and optional I2C voltage history use the existing
`get telemetry.volt.i2c <channel> [1-4]` reader: four days of 30-minute RAM samples.
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
@@ -110,9 +119,20 @@ carrier does not erase those overrides, change RF/PA settings, modify battery
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
Radio target and role selection remain separate from the power profile:
| Fitted module | Repeater | Room Server |
| --- | --- | --- |
| E22 M30S | `solarxiao_30S_repeater` | `solarxiao_30S_room_server_lora_ota` |
| E22 M33S | `solarxiao_33S_repeater` | `solarxiao_33S_room_server_lora_ota` |
All three power profiles work in these role builds. Room uses its existing
onboard-QSPI OTA target, identity and application layout, with the matching
board/storage-aware OTAFIX bootloader. The older plain `*_room_server` targets
remain separate compatibility/recovery recipes; this native interface is
qualified on the QSPI OTA Room targets. No legacy identity is remapped.
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.
+7
View File
@@ -1,4 +1,5 @@
#include <helpers/RegulatorSettings.h>
#include <helpers/sensors/SolarXiaoPower.h>
#include <helpers/LedSettings.h>
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
@@ -147,6 +148,9 @@ void setup() {
if (volatile_primary_fs) regulator_prefs_fs = nullptr;
#endif
mesh::leds::beginSettings(board, regulator_prefs_fs);
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
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");
}
@@ -407,6 +411,9 @@ void loop() {
#endif
rtc_clock.tick();
mesh::power::serviceRegulatorSettings();
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
mesh::solar::service();
#endif
board.loop();
if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot();
#if MESH_USB_CONSOLE_COOPERATIVE
+9
View File
@@ -214,6 +214,14 @@ def requirements(platform, defines, target):
if platform == "NRF52_PLATFORM":
parts["loop_and_callback_stacks"] = integer(defines, "MESH_NRF52_LOOP_STACK_WORDS", 2048) * 4 + 3072
parts["core_usb_filesystems_sensors"] = 12288
if (integer(defines, "MESH_SOLARXIAO_POWER", 0)
and integer(defines, "MESH_ENABLE_TELEMETRY_HISTORY", 1)):
# History reserves native channels before Auto detection. Its
# 16-channel ceiling includes native and optional monitors, not
# 16 optional channels plus four native ones. Bind these values
# to the real ExternalVoltageHistory allocation in the host test.
# Retain the existing core allowance and add allocator overhead.
parts["solarxiao_voltage_history"] = 16 * 360 + 32
if "OTA_RAK15002_SD_STORE" in defines:
# New optional RAK SD allocates one SdFs volume and separate
# staging/archive FsFile handles lazily; production bounds the
@@ -406,6 +414,7 @@ def requirements(platform, defines, target):
if "expanded_message_previews" in parts:
largest = max(largest, 8192 + parts["expanded_message_previews"])
for name in ("client_table", "flood_filter_table", "room_flood_rule_table", "neighbor_history",
"solarxiao_voltage_history",
"ota_context", "screen_objects_and_history", "ota_encoder_workspace",
"ota_generic_source_buffers", "ethernet_or_external_ota_workspaces", "ota_target_name_lookup",
"ethernet_companion_worker"):
+2 -1
View File
@@ -146,7 +146,8 @@ 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;
// History allocates its schema once at boot, before Auto can verify MPPT.
// Reserve stable IDs without I/O; inactive/unavailable samples stay missing.
const uint8_t count=capacity<4 ? capacity : 4;
for (uint8_t i=0;channels && i<count;++i) channels[i]=2+i;
return count;
+2
View File
@@ -12,6 +12,8 @@ void service();
bool handleCommand(const char* command, char* reply, size_t capacity, bool local);
void addTelemetry(CayenneLPP& telemetry);
uint8_t voltageChannels(uint8_t* channels, uint8_t capacity);
// The channel list reserves history schema without I/O; only queryVoltages
// returns measured samples, and remains gated by the active power profile.
uint8_t queryVoltages(SensorManager::VoltageSensorReading* readings, uint8_t capacity);
}}
#endif
+3 -2
View File
@@ -371,9 +371,10 @@ void nativeSilent(bool periodic=true) {
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(channels,5)==4);assert(!queryVoltages(values,5));
assert(!voltageChannels(nullptr,0));assert(!queryVoltages(nullptr,0));
for(auto c:channels)assert(c==99);
for(unsigned i=0;i<4;++i)assert(channels[i]==i+2);
assert(channels[4]==99);
for(auto value:values)assert(value.channel==99 && value.voltage==99 && value.valid);
assert(calls==Wire.calls && reads==Wire.reads && writes==Wire.writes);
}
+470
View File
@@ -0,0 +1,470 @@
"""Run real SolarXiao room telemetry/history and role-initialization boundaries."""
from pathlib import Path
import configparser
import os
import re
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
from test_solarxiao_carrier import FILESYSTEM_WITH_METADATA, WIRE
ROOT = Path(__file__).resolve().parents[1]
ROOM = ROOT / "examples/simple_room_server"
SENSORS = ROOT / "src/helpers/sensors"
def conditional_block(source, marker):
start = source.index(marker)
depth = 0
for directive in re.finditer(r"(?m)^\s*#\s*(if|ifdef|ifndef|endif)\b[^\n]*(?:\n|$)", source[start:]):
if directive.group(1) == "endif":
depth -= 1
if depth == 0:
return source[start:start + directive.end()]
else:
depth += 1
raise AssertionError("unterminated production feature block: " + marker)
SENSOR_BOUNDARY = r'''
#pragma once
#include <cstdint>
#include <vector>
#include <cassert>
#include <cmath>
#include <helpers/sensors/LPPDataHelpers.h>
struct CayenneLPP {
std::vector<uint8_t> data;size_t capacity;
explicit CayenneLPP(size_t cap=180):capacity(cap) {}
void reset() {data.clear();}
size_t getSize() const {return data.size();}
const uint8_t* getBuffer() const {return data.data();}
void add(uint8_t ch,uint8_t type) {
const size_t size=LPPData::getDataSize(type);
if(data.size()+2+size>capacity)return;
data.push_back(ch);data.push_back(type);data.insert(data.end(),size,0);
}
void addVoltage(uint8_t c,float) {add(c,LPP_VOLTAGE);}
void addCurrent(uint8_t c,float) {add(c,LPP_CURRENT);}
void addPower(uint8_t c,float) {add(c,LPP_POWER);}
void addTemperature(uint8_t c,float) {add(c,LPP_TEMPERATURE);}
void addGenericSensor(uint8_t c,float) {add(c,LPP_GENERIC_SENSOR);}
};
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;};
};
}
'''
HISTORY_HARNESS = r'''
#include <cassert>
#include <cmath>
#include <cstring>
#include <string>
#include <algorithm>
#include <helpers/IdentityStore.h>
#include <helpers/SensorManager.h>
#include <helpers/TelemetryHistory.h>
#include <helpers/ExternalVoltageHistory.h>
#include <helpers/ClientACLResponse.h>
#include <Wire.h>
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#define TELEM_CHANNEL_SELF 1
#define TELEM_PERM_ENVIRONMENT 4
#define PERM_ACL_ROLE_MASK 7
#define PERM_ACL_GUEST 0
#define PERM_ACL_READ_ONLY 1
#define PERM_ACL_READ_WRITE 2
#define PERM_ACL_ADMIN 3
#define REQ_TYPE_GET_TELEMETRY_DATA 3
#define MESH_DEBUG_PRINTLN(...) do {} while(0)
using std::isfinite;
using std::isnan;
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;
void optionalQuery(uint8_t channel,uint8_t,CayenneLPP& lpp) {
assert(channel==14);lpp.addVoltage(channel,5);
}
bool optionalVoltage(uint8_t,float& value) {value=5;return true;}
class EnvironmentSensorManager:public SensorManager {
struct ActiveSensor {
void(*query)(uint8_t,uint8_t,CayenneLPP&);
bool(*query_voltage)(uint8_t,float&);uint8_t sub_channel;
};
ActiveSensor _active_sensors[1]={{optionalQuery,optionalVoltage,0}};
int _active_sensor_count=ROOM_OPTIONAL_SENSOR_COUNT;
uint8_t next_available_channel=2;
public:
bool querySensors(uint8_t,CayenneLPP&);
uint8_t getVoltageSensorChannels(uint8_t*,uint8_t) const;
uint8_t queryVoltageSensors(VoltageSensorReading*,uint8_t);
};
@ENV_METHODS@
struct Clock {uint32_t epoch=0;uint32_t getCurrentTime() const {return epoch;}};
struct ClientInfo {
uint8_t permissions=PERM_ACL_ADMIN;
bool isAdmin() const {return (permissions&PERM_ACL_ROLE_MASK)==PERM_ACL_ADMIN;}
};
struct CommonCLI {
MainBoard& board;
MainBoard* getBoard() {return &board;}
void handleCommand(uint32_t sender_timestamp,char* command,char* reply) {
@COMMON_HOOK@
strcpy(reply,"unhandled");
}
};
class MyMesh {
public:
MainBoard board;
CommonCLI _cli{board};
Clock clock;
EnvironmentSensorManager sensors;
CayenneLPP telemetry{@ROOM_TELEMETRY_CAPACITY@};
uint8_t reply_data[184];
mesh::TelemetryHistory telemetry_history;
mesh::ExternalVoltageHistory external_voltage_history;
Clock* getRTCClock() {return &clock;}
void configureHistory() {
@HISTORY_INIT@
}
void sampleTelemetryHistory();
bool handleTelemetryHistoryCommand(const char*,char*);
int handleRequest(ClientInfo*,uint32_t,uint8_t*,size_t,size_t);
void handleCommand(uint32_t sender_timestamp,char* command,char* reply,
int gpio_client_index=-1,uint8_t gpio_path_hash_size=1) {
(void)gpio_path_hash_size;
@TIMESTAMP_GUARD@
@PREFIX@
_cli.handleCommand(sender_timestamp,command,reply);
}
struct Morse {void beginCommand(const uint8_t*) {} void endCommand() {}} morse;
void remoteCommand(ClientInfo* client,uint32_t sender_timestamp,const char* body,char* reply) {
uint8_t temp[192]={};char text[160];strncpy(text,body,sizeof(text)-1);text[sizeof(text)-1]=0;
const int i=0;
struct Packet {uint8_t getPathHashSize() const {return 1;}} real_packet;
Packet* packet=&real_packet;
@ADMIN_GATE@
strcpy(reply,reinterpret_cast<char*>(&temp[5]));
}
};
@ROOM_METHODS@
std::string cli(MyMesh& room,const char* command) {
char body[160]={},reply[160]={};strncpy(body,command,sizeof(body)-1);
room.handleCommand(0,body,reply);return reply;
}
void has(const std::string& text,const char* wanted) {assert(text.find(wanted)!=std::string::npos);}
std::vector<uint8_t> entries(const uint8_t* data,size_t n) {
assert(LPPData::boundedPrefix(data,n,n)==n);std::vector<uint8_t> channels;
for(size_t i=0;i<n;i+=2+LPPData::getDataSize(data[i+1]))channels.push_back(data[i]);
return channels;
}
bool present(const std::vector<uint8_t>& channels,uint8_t wanted) {
return std::find(channels.begin(),channels.end(),wanted)!=channels.end();
}
int main(int argc,char** argv) {
assert(argc==2);std::string scenario=argv[1];MockFS fs;MyMesh room;
beginSettings(room.board,&fs,true);
if(scenario=="history-late-detection") {
Wire.ina[0xfe]=0;room.configureHistory();assert(!Wire.calls);
assert(room.external_voltage_history.channelCount()==4+ROOM_OPTIONAL_SENSOR_COUNT);
assert(!room.external_voltage_history.populatedChannelCount());
room.sampleTelemetryHistory();assert(!Wire.calls);
assert(room.telemetry_history.voltageSampleCount()==1);
assert(!room.external_voltage_history.channelHasValue(2));
assert(room.external_voltage_history.channelHasValue(14)==bool(ROOM_OPTIONAL_SENSOR_COUNT));
now_ms=100;service();assert(!selection.nativeAllowed());
for(auto item:Wire.log)assert(item.address==0x40 && (item.reg==0xfe || item.reg==0xff));
Wire.ina[0xfe]=0x5449;Wire.charger[0x4a]=0;
now_ms=5100;service();assert(selection.nativeAllowed());
room.clock.epoch=1800;room.sampleTelemetryHistory();
assert(room.external_voltage_history.channelHasValue(2));
assert(!room.external_voltage_history.channelHasValue(3));
Wire.charger[0x4a]=1;room.clock.epoch=3600;room.sampleTelemetryHistory();
assert(room.external_voltage_history.channelHasValue(3));
const auto samples=room.external_voltage_history.sampleCount();
cli(room,"set solar.board basic");const auto calls=Wire.calls;
room.clock.epoch=5400;room.sampleTelemetryHistory();assert(Wire.calls==calls);
assert(room.external_voltage_history.channelHasValue(2));
assert(room.external_voltage_history.sampleCount()==samples+1);
uint8_t source[8]={},snapshot[184];
const size_t n=room.external_voltage_history.formatBinarySnapshot(source,2,0,snapshot,sizeof(snapshot));
assert(n>20 && snapshot[19]==4);
auto code=[&](unsigned item) {
uint16_t value=0;for(unsigned bit=0;bit<15;++bit) {
const unsigned at=item*15+bit;value=(value<<1)|((snapshot[20+at/8]>>(7-at%8))&1);
}return value;
};
assert(code(0)==0 && mesh::ExternalVoltageHistory::decodeMillivolts(code(1))==13200);
assert(mesh::ExternalVoltageHistory::decodeMillivolts(code(2))==13200 && code(3)==0);
cli(room,"set solar.board mppt");Wire.ina[2]=10720;
room.clock.epoch=7200;room.sampleTelemetryHistory();
assert(room.external_voltage_history.sampleCount()==samples+2);
assert(room.external_voltage_history.formatBinarySnapshot(source,2,0,snapshot,sizeof(snapshot))>20);
assert(snapshot[19]==5 && mesh::ExternalVoltageHistory::decodeMillivolts(code(4))==13400);
assert(room.external_voltage_history.channelHasValue(14)==bool(ROOM_OPTIONAL_SENSOR_COUNT));
char reply[160]={};assert(room.handleTelemetryHistoryCommand("get telemetry.volt.i2c",reply));
assert(strstr(reply,"2")!=nullptr);
} else if(scenario=="telemetry-permissions-bounds") {
ClientInfo client;uint8_t payload[]={REQ_TYPE_GET_TELEMETRY_DATA,0};
now_ms=100;service();assert(selection.nativeAllowed());
for(uint8_t role:{PERM_ACL_GUEST,PERM_ACL_READ_ONLY,PERM_ACL_READ_WRITE,PERM_ACL_ADMIN}) {
client.permissions=role;
for(uint8_t disabled:{uint8_t(0),uint8_t(TELEM_PERM_ENVIRONMENT)}) {
payload[1]=disabled;
for(size_t cap=4;cap<=184;++cap) {
memset(room.reply_data,0xa5,sizeof(room.reply_data));
const auto calls=Wire.calls;
const int n=room.handleRequest(&client,123,payload,sizeof(payload),cap);
assert(n>=4 && size_t(n)<=cap);
auto channels=entries(room.reply_data+4,n-4);
const bool env=role!=PERM_ACL_GUEST && !disabled;
if(!env)assert(Wire.calls==calls);
if(cap==184) {
assert(present(channels,1));assert(present(channels,2)==env);
assert(present(channels,13)==env);
assert(present(channels,14)==(env && ROOM_OPTIONAL_SENSOR_COUNT));
}
for(size_t i=n;i<sizeof(room.reply_data);++i)assert(room.reply_data[i]==0xa5);
}
}
}
assert(!room.handleRequest(&client,123,payload,1,184));
cli(room,"set solar.board basic");client.permissions=PERM_ACL_ADMIN;payload[1]=0;
const auto calls=Wire.calls;const int n=room.handleRequest(&client,123,payload,2,184);
auto channels=entries(room.reply_data+4,n-4);assert(present(channels,1) && !present(channels,2));
assert(Wire.calls==calls);
} else if(scenario=="admin-cli") {
uint8_t key[32]={};(void)key;
for(uint8_t role:{PERM_ACL_GUEST,PERM_ACL_READ_ONLY,PERM_ACL_READ_WRITE,PERM_ACL_ADMIN}) {
ClientInfo client;client.permissions=role;char reply[160]={};
const auto writes=fs.writes;
room.remoteCommand(&client,100,"set solar.board basic",reply);
if(role==PERM_ACL_ADMIN)assert(fs.writes>writes && selection.isBasic());
else {assert(fs.writes==writes);has(reply,"admin permission required");}
}
ClientInfo client;char reply[160]={};room.remoteCommand(&client,0,"mppt ship arm",reply);
assert(!Wire.writes);
cli(room,"set solar.board mppt");room.remoteCommand(&client,0,"mppt ship arm",reply);
has(reply,"requires local USB");assert(!Wire.writes);
std::vector<char> guard(176,'~');
char command[]="xy|get solar capabilities";room.handleCommand(0,command,guard.data()+8);
assert(!memcmp(guard.data()+8,"xy|",3));
for(unsigned i=0;i<8;++i)assert(guard[i]=='~' && guard[168+i]=='~');
} else assert(false);
}
'''
class SolarXiaoRoomTests(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 required")
cls.temp = tempfile.TemporaryDirectory(prefix="solarxiao-room-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_BOUNDARY, encoding="ascii")
(directory / "Wire.h").write_text(WIRE, encoding="ascii")
(directory / "Packet.h").write_text(
"#pragma once\n#include <cstdint>\n#include <cstddef>\n"
"#define MAX_PACKET_PAYLOAD 184\n#define CIPHER_MAC_SIZE 2\n#define CIPHER_BLOCK_SIZE 16\n"
"namespace mesh {class Packet {public: static bool isValidPathLen(uint8_t);};}\n",
encoding="ascii")
room = (ROOM / "MyMesh.cpp").read_text()
env = (SENSORS / "EnvironmentSensorManager.cpp").read_text()
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
begin = extract_braced(room, "void MyMesh::begin(")
history_start = begin.index("#if MESH_ENABLE_TELEMETRY_HISTORY\n uint8_t voltage_channels")
history_init = conditional_block(begin[history_start:], "#if MESH_ENABLE_TELEMETRY_HISTORY")
handler = extract_braced(room, "int MyMesh::handleRequest(")
prelude = handler[:handler.index(" if (payload[0] == mesh::ROOM_MAIL_REQUEST_SUBTYPE)")]
request = prelude + extract_braced(handler, "if (payload[0] == REQ_TYPE_GET_TELEMETRY_DATA)") + "\nreturn 0;\n}\n"
command = extract_braced(room, "void MyMesh::handleCommand(uint32_t sender_timestamp,")
timestamp_guard = re.search(r"if \(gpio_client_index >= 0 && sender_timestamp == 0\) sender_timestamp = 1;", command).group()
prefix = extract_braced(command, "if (strlen(command) > 4 && command[2] == '|')")
receive = extract_braced(room, "void MyMesh::onPeerDataRecv(")
gate = extract_braced(receive[receive.index("if (!handleClientPathCommand"):], "if (client->isAdmin())")
end = receive[receive.index(gate) + len(gate):]
otherwise = extract_braced(end, "else")
gate += " " + otherwise
gate = gate.replace("_cli.morseReply().beginCommand(client->id.pub_key);", "morse.beginCommand(nullptr);")
gate = gate.replace("_cli.morseReply().endCommand();", "morse.endCommand();")
# Only the unrelated Morse reply tracker is replaced. Keep the actual
# admin gate, remote timestamp and room command dispatch unchanged.
replacements = {
"@ADAPTER@": str(SENSORS / "SolarXiaoPower.cpp"),
"@ENV_METHODS@": "\n".join(extract_braced(env, signature) for signature in (
"bool EnvironmentSensorManager::querySensors(",
"uint8_t EnvironmentSensorManager::getVoltageSensorChannels(",
"uint8_t EnvironmentSensorManager::queryVoltageSensors(")),
"@ROOM_METHODS@": "\n".join((request,
extract_braced(room, "void MyMesh::sampleTelemetryHistory("),
extract_braced(room, "bool MyMesh::handleTelemetryHistoryCommand("))),
"@ROOM_TELEMETRY_CAPACITY@": re.search(r"\btelemetry\(([^)\n]+)\)", room).group(1),
"@HISTORY_INIT@": history_init,
"@COMMON_HOOK@": conditional_block(common, "#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER"),
"@TIMESTAMP_GUARD@": timestamp_guard,
"@PREFIX@": prefix,
"@ADMIN_GATE@": gate,
}
harness = HISTORY_HARNESS
for marker, value in replacements.items():
harness = harness.replace(marker, value)
cls.binaries = {}
for optional in (0, 1):
cpp = directory / ("room" + str(optional) + ".cpp")
cpp.write_text(harness, encoding="ascii")
binary = directory / ("room" + str(optional))
built = subprocess.run([cls.compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
"-DNRF52_PLATFORM", "-DMESH_SOLARXIAO_POWER=1", "-DMESH_ENABLE_TELEMETRY_HISTORY=1",
"-DMAX_PACKET_PAYLOAD=184", "-DHIGH=1", "-DPIN_BOARD_SDA=30", "-DPIN_BOARD_SCL=31",
"-DROOM_OPTIONAL_SENSOR_COUNT=" + str(optional),
"-I" + str(directory), "-I" + str(ROOT / "src"), "-I" + str(SENSORS), str(cpp),
str(SENSORS / "SolarXiaoCarrier.cpp"), str(SENSORS / "SolarXiaoPowerCore.cpp"),
"-o", str(binary)], text=True, capture_output=True, timeout=45)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.binaries[optional] = binary
def scenario(self, name):
for optional, binary in self.binaries.items():
with self.subTest(optional_sensor=bool(optional)):
result = subprocess.run([str(binary), name], text=True, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_actual_room_history_survives_late_detection_delayed_charger_and_mode_changes(self):
self.scenario("history-late-detection")
def test_actual_room_live_telemetry_respects_permissions_and_route_bounds(self):
self.scenario("telemetry-permissions-bounds")
def test_actual_room_admin_gate_remote_local_distinction_and_prefixed_reply_bounds(self):
self.scenario("admin-cli")
def test_actual_room_boot_and_loop_hooks_propagate_persistent_store_and_history(self):
main = (ROOM / "main.cpp").read_text()
setup = extract_braced(main, "void setup()")
# Conditional Ethernet branches each open the same logical block, so
# the raw loop has unbalanced braces before preprocessing.
loop = main[main.index("\nvoid loop()") :]
start = setup.index("FILESYSTEM* regulator_prefs_fs = fs;")
settings = setup[start:setup.index("#ifdef DISPLAY_CLASS", start)]
start = loop.index("mesh::power::serviceRegulatorSettings();")
periodic = loop[start:loop.index("board.loop();", start) + len("board.loop();")]
history = conditional_block((ROOM / "MyMesh.h").read_text(), "#ifndef MESH_ENABLE_TELEMETRY_HISTORY")
self.assertLess(setup.index("board.begin()"), setup.index("mesh::solar::beginSettings"))
self.assertLess(setup.index("mesh::solar::beginSettings"), setup.index("sensors.begin()"))
self.assertLess(setup.index("mesh::solar::beginSettings"), setup.index("radio_init()"))
harness = r'''
#include <cassert>
#include <vector>
#define MESH_DEBUG_PRINTLN(...) do {} while(0)
@HISTORY@
struct Store {};
#define FILESYSTEM Store
static unsigned solar_begin=0,solar_service=0,reg_begin=0,reg_service=0,board_loops=0;
static std::vector<int> order;
static Store* selected=nullptr;static bool actual_history=false;
struct Board {void loop() {++board_loops;order.push_back(3);}} board;
struct Radio {} radio_driver;
namespace mesh {
namespace leds {void beginSettings(Board&,Store*) {}}
namespace power {
bool beginRegulatorSettings(Board&,Radio&,Store*) {++reg_begin;return true;}
void serviceRegulatorSettings() {++reg_service;order.push_back(1);}
}
namespace solar {
void beginSettings(Board&,Store* fs,bool history) {++solar_begin;selected=fs;actual_history=history;}
void service() {++solar_service;order.push_back(2);}
}
}
void initialize(Store* fs,bool volatile_primary_fs) {
@SETTINGS@
}
void periodic() {@PERIODIC@}
int main() {
Store fs;
for(bool volatile_store:{false,true}) {
initialize(&fs,volatile_store);
assert(reg_begin==solar_begin || !MESH_SOLARXIAO_POWER);
if(MESH_SOLARXIAO_POWER) {
assert(selected==(volatile_store?nullptr:&fs));
assert(actual_history==bool(MESH_ENABLE_TELEMETRY_HISTORY));
}
order.clear();periodic();
assert(order==std::vector<int>(MESH_SOLARXIAO_POWER?std::initializer_list<int>{1,2,3}:std::initializer_list<int>{1,3}));
}
assert(reg_begin==2 && reg_service==2 && board_loops==2);
assert(solar_begin==2*MESH_SOLARXIAO_POWER && solar_service==2*MESH_SOLARXIAO_POWER);
}
'''.replace("@HISTORY@", history).replace("@SETTINGS@", settings).replace("@PERIODIC@", periodic)
directory = Path(self.temp.name)
cpp = directory / "hooks.cpp"
cpp.write_text(harness, encoding="ascii")
for enabled, history_enabled in ((0, None), (1, None), (1, 0)):
with self.subTest(carrier_feature=enabled, history=history_enabled):
binary = directory / "hooks"
flags = ["-DMESH_SOLARXIAO_POWER=" + str(enabled), "-DNRF52_PLATFORM"]
if history_enabled is not None:
flags.append("-DMESH_ENABLE_TELEMETRY_HISTORY=" + str(history_enabled))
built = subprocess.run([self.compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
*flags, str(cpp), "-o", str(binary)], text=True, capture_output=True, timeout=30)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
result = subprocess.run([str(binary)], text=True, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_feature_is_inherited_by_canonical_room_ota_targets_and_leaves_recovery_unchanged(self):
config = configparser.ConfigParser(interpolation=None, strict=False)
config.read([ROOT / "platformio.ini", *sorted((ROOT / "variants").glob("*/platformio.ini"))])
def option(section, key, stack=()):
pair = (section, key)
if pair in stack:
raise AssertionError("recursive PlatformIO option: " + repr(pair))
value = config.get(section, key, fallback=None)
if value is None:
parents = config.get(section, "extends", fallback="").replace(",", " ").split()
return "\n".join(option(parent, key, stack + (pair,)) for parent in parents)
def replace(match):
reference_section, reference_key = match.group(1).rsplit(".", 1)
return option(reference_section, reference_key, stack + (pair,))
return re.sub(r"\$\{([^}]+)\}", replace, value)
for radio in ("30S", "33S"):
for suffix, enabled in (("_room_server_lora_ota", True), ("_room_server", False),
("_companion_radio_ble", False)):
target = "env:solarxiao_" + radio + suffix
with self.subTest(target=target):
flags = option(target, "build_flags")
present = bool(re.search(r"-D\s*MESH_SOLARXIAO_POWER=1\b", flags))
self.assertEqual(present, enabled)
if enabled:
self.assertIn("+<helpers/sensors>", option(target, "build_src_filter"))
self.assertIn("+<../examples/simple_room_server/*.cpp>", option(target, "build_src_filter"))
self.assertIn("-D OTA_QSPI_STORE=1", flags)
if __name__ == "__main__":
unittest.main()
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Bind native SolarXiao history admission to its actual startup allocation."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import check_firmware_ram as ram
class SolarXiaoRoomMemoryTest(unittest.TestCase):
def test_budget_matches_real_capped_history_allocation(self):
compiler = shutil.which("g++")
if compiler is None:
self.skipTest("g++ is needed for the real history allocation check")
fixture = r'''
#include <cassert>
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <stdlib.h>
size_t allocation_bytes = 0;
unsigned allocation_count = 0;
void* history_malloc(size_t bytes) {
allocation_bytes = bytes;
++allocation_count;
return std::malloc(bytes);
}
#define malloc history_malloc
#include <helpers/ExternalVoltageHistory.h>
#undef malloc
int main() {
using History = mesh::ExternalVoltageHistory;
// Four native channels coexist with more optional monitors than fit.
// Production must enforce one shared ceiling rather than add four to it.
uint8_t channels[4 + History::MAX_CHANNELS];
for (unsigned i = 0; i < 4; ++i) channels[i] = 2 + i;
for (unsigned i = 0; i < History::MAX_CHANNELS; ++i) channels[4 + i] = 14 + i;
History history;
assert(history.configure(channels, sizeof(channels)));
assert(allocation_count == 1);
assert(history.channelCount() == History::MAX_CHANNELS);
assert(history.storageBytes() == allocation_bytes);
assert(allocation_bytes == History::MAX_CHANNELS * History::BYTES_PER_CHANNEL);
std::printf("%zu\n", allocation_bytes);
}
'''
with tempfile.TemporaryDirectory(prefix="solarxiao-room-memory-") as directory:
directory = Path(directory)
source, executable = directory / "history.cpp", directory / "history"
source.write_text(fixture)
result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-I" + str(ROOT / "src"), str(source), "-o", str(executable)],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
allocation = int(subprocess.check_output([str(executable)], text=True))
for radio in ("30S", "33S"):
for history_flag in ({}, {"MESH_ENABLE_TELEMETRY_HISTORY": 1}):
with self.subTest(radio=radio, history=history_flag):
target = f"solarxiao_{radio}_room_server_lora_ota"
policy = ram.requirements("NRF52_PLATFORM", {
"MESH_SOLARXIAO_POWER": 1, **history_flag}, target)
parts = policy["components"]
self.assertEqual(parts["solarxiao_voltage_history"], allocation + 32)
self.assertEqual(parts["core_usb_filesystems_sensors"], 12288)
self.assertEqual(policy["required_heap_bytes"], sum(parts.values()))
self.assertGreaterEqual(policy["required_contiguous_bytes"], allocation + 32)
def test_disabled_history_or_native_support_keeps_previous_budget(self):
target = "solarxiao_33S_room_server_lora_ota"
baseline = ram.requirements("NRF52_PLATFORM", {}, target)
for flags in ({"MESH_SOLARXIAO_POWER": 0},
{"MESH_ENABLE_TELEMETRY_HISTORY": 0},
{"MESH_SOLARXIAO_POWER": 1, "MESH_ENABLE_TELEMETRY_HISTORY": 0}):
with self.subTest(flags=flags):
self.assertEqual(ram.requirements("NRF52_PLATFORM", flags, target), baseline)
enabled = ram.requirements("NRF52_PLATFORM", {"MESH_SOLARXIAO_POWER": 1}, target)
parts = dict(enabled["components"])
reserve = parts.pop("solarxiao_voltage_history")
self.assertEqual(parts, baseline["components"])
self.assertEqual(enabled["required_heap_bytes"], baseline["required_heap_bytes"] + reserve)
def test_non_solarxiao_and_other_platform_budgets_are_unchanged(self):
for platform, target in (("NRF52_PLATFORM", "Xiao_nrf52_room_server_lora_ota"),
("ESP32_PLATFORM", "heltec_v4_room_server"),
("STM32_PLATFORM", "wio-e5_room_server"),
("RP2040_PLATFORM", "pico_room_server")):
baseline = ram.requirements(platform, {}, target)
if platform == "NRF52_PLATFORM":
# Native ownership is opt-in, not inferred from a board name.
self.assertNotIn("solarxiao_voltage_history", baseline["components"])
continue
with self.subTest(platform=platform):
self.assertEqual(ram.requirements(platform, {
"MESH_SOLARXIAO_POWER": 1, "MESH_ENABLE_TELEMETRY_HISTORY": 1}, target), baseline)
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -340,6 +340,7 @@ board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
board_upload.maximum_size = 757760
build_flags =
${env:solarxiao_30S_room_server.build_flags}
-D MESH_SOLARXIAO_POWER=1
${nrf52_qspi_ota.build_flags}
-D OTA_QSPI_BOOTLOADER_UPDATE=1
build_src_filter = ${env:solarxiao_30S_room_server.build_src_filter}
@@ -351,6 +352,7 @@ board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
board_upload.maximum_size = 757760
build_flags =
${env:solarxiao_33S_room_server.build_flags}
-D MESH_SOLARXIAO_POWER=1
${nrf52_qspi_ota.build_flags}
-D OTA_QSPI_BOOTLOADER_UPDATE=1
build_src_filter = ${env:solarxiao_33S_room_server.build_src_filter}