Add native SolarXiao MPPT and battery telemetry and controls

This commit is contained in:
mikecarper committed 2026-10-21 15:25:53 -07:00
1 parent e5c8d28d5e
commit 2dcc1caccb
15 files changed
+1357 -2

No files matched your search

+3
View File
@@ -4,6 +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`).
See [two LoRa profiles](radio_profiles.md) for `radio2`, `tempradio2`, scheduling,
RX-only mode, cross-transmission policy, and optional radio preambles.
+2
View File
@@ -13,6 +13,8 @@ Choose firmware with the [firmware picker](firmware_picker.md). For help with a
## Documentation
- [SolarXiao MPPT 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)
- [CLI commands](cli_commands.md), [availability by build](cli_build_matrix.md), and [radio profiles](radio_profiles.md)
+150
View File
@@ -0,0 +1,150 @@
# SolarXiao MPPT and battery controls
SolarXiao firmware includes a native INA260 battery monitor and LTC4162 charger
interface in Full and Reduced Repeater, Room Server and Companion builds. Use
USB CLI or the role's authenticated administrator CLI over LoRa. Ship mode and
saved-policy reset require local USB. Commands do not change RF settings.
Start with:
```
get mppt
get mppt battery
get mppt power
get mppt alerts
get mppt saved
get mppt fields 0
```
`fields` returns three names per page with `rw`, `ro` or `locked`. Increment the
page to enumerate the register interface. `get mppt <field>` returns units and
the raw register. `get mppt raw 0x43` reads a documented register; arbitrary
register writes are not exposed. `nan` in a summary means unavailable, not zero.
## Identity and readings
The carrier bus uses SDA P0.09 and SCL P0.10. INA260 at `0x40` must match its TI
manufacturer and device IDs. Its signed current, voltage and power readings do
not require charger input power. The driver does not reset/configure INA260.
LTC4162 at `0x68` uses little-endian SMBus words. Access normally requires power
at SOLAR/VIN; USB alone can leave the charger unavailable.
`get mppt info` reports the chemistry/profile code, live cell count, last observed
nonzero cell count, MPPT, JEITA, charger state and telemetry validity. The actual
bench board identified as FST (chemistry code 6); this distinguishes it from
FAD, FFS, Li-ion and SLA variants. The ID does not uniquely identify the M suffix
or package/temperature grade. Cell count comes from hardware straps; zero means
unknown while the charger is inactive. Software cannot change chemistry/cells.
Readings cover battery/input/output voltage, signed battery/input current,
charger die and modeled NTC temperature, battery series resistance, charge/CV/
absorb timers, JEITA region, actual DAC settings, system/status bits and all three
alert banks. A retained ADC bank is not presented as live while telemetry is off.
Battery ADC voltage/resistance need a currently valid cell count. Questionable
resistance and an open/disconnected NTC return unavailable in the summaries.
## Settings on the 4S FST carrier
Use `set mppt <field> <value>`. Booleans accept `on` or `off` only.
| Fields | Behavior |
| --- | --- |
| `mppt.enabled` | Enable automatic solar maximum power tracking. |
| `charge.enabled` | Suspend/resume charging; does not disconnect the battery or system output. |
| `telemetry.force`, `telemetry.fast` | Force the charger ADC on, or use fast telemetry. These affect power consumption. |
| `input.limit_ma` | Input current limit; rounds down to the hardware step, never above the request; maximum request 3200 mA. |
| `input.minimum_mv` | Manual input-voltage floor; MPPT must be off. Rounds up in 140.625 mV steps, maximum request 35000 mV. |
| `jeita.enabled` | Select the fixed multi-region JEITA profile or basic temperature-qualified manual targets. |
| `charge.current_ma`, `charge.cell_mv` | Manual targets require JEITA off. Current rounds down in resistor-dependent steps, up to 3200 mA. This 4S board interface limits voltage requests to 3412.5..3600 mV/cell in 12.5 mV steps. |
| `thermal.start_c`, `thermal.end_c` | Die thermal regulation thresholds, -40..125 C; start must remain below end. |
| `termination.enabled`, `termination.current_ma` | C/x termination switch and nonnegative current threshold. |
| `termination.cv_seconds`, `termination.total_minutes` | 16-bit safety timers; zero disables the corresponding timer. |
| `alert.*` | Voltage/current/die/NTC/resistance thresholds and enabled alert masks; use field names/units from `fields`. |
FST locks the six JEITA thresholds, regional current/voltage values, recharge
threshold and absorb voltage/time. These remain readable. Manual target
registers are continuously overwritten by JEITA when JEITA is on; read actual
`dac.*` fields to see the active regulation targets. Basic temperature-qualified
charging still uses the fixed outer temperature limits when JEITA is off.
All configuration writes require a fresh FST identity and a live cell count of
4 or unknown. Other variants remain readable but cannot receive this board's
saved settings. Every setting uses a masked register update and verifies
readback before success. Unrelated bits are preserved; one-shot BSR is not
replayed by configuration writes. A failed write/save attempts to restore the
previous field and reports whether rollback was verified.
## Saving and actions
Successful settings save an explicit override in a versioned, CRC-protected
`/solar_power` record, using atomic file replacement plus public-file readback.
The board reapplies only saved overrides after restart or charger power loss,
with a five-second retry interval. It does not write default settings on first
boot. JEITA-owned targets are not repeatedly written while JEITA is enabled.
A failed/corrupt settings file blocks configuration saves and automatic replay.
- `mppt reload`: reload saved policy and retry applying it.
- `mppt forget`: remove saved register overrides; keep calibration and live chip settings.
- `mppt reset`: local USB only; reset saved policy/calibration to reference values,
including recovery from a corrupt record. Live charger registers are unchanged.
- `mppt bsr run`: request one battery-resistance measurement; not saved.
- `mppt ship arm` / `mppt ship disarm`: local USB only; not saved. Arming takes
effect when VIN drops below approximately 1 V and can remove system power.
- `mppt alerts clear limits|states|status <mask>`: clear selected latched alerts
using write-zero-to-clear semantics; unselected/new alerts are preserved.
These actions acknowledge the request, not completion of charging, resistance
measurement or entry into ship mode. Read the corresponding status afterward.
## Conversion defaults and carrier revisions
The supplied [Standard schematic](https://github.com/wehooper4/Meshtastic-Hardware/blob/main/XaioSeries/SolarXaio/MPPT/Standard/Schematic_SolarXaio_MPPT_2025-10-23.pdf)
shows R5/RSNSI and R14/RSNSB at 10 milliohms, a 10 kohm NTC bias and a 10 kohm
NTC. It shows an L42M single-cell charger, so it does not independently confirm
component values on this 12 V FSTM revision. Defaults use those resistor values
and the LTC4162 reference thermistor beta of 3490 K. Readings identify the model
as `reference` until the corresponding calibration values are explicitly set.
Calibration settings, saved without changing hardware target registers:
```
get mppt cal.rsnsb_mohm
get mppt cal.rsnsi_mohm
get mppt cal.ntc_bias_ohm
get mppt cal.ntc_r25_ohm
get mppt cal.ntc_beta
```
Set these with the same `set mppt` syntax after confirming actual components.
Allowed sense resistance is 0.1..1000 milliohms, NTC/bias resistance 100..1000000
ohms, beta 1000..10000 K. The simple beta temperature model assumes an unpadded
NTC divider. It does not model series/parallel thermistor dilution networks.
Changing calibration changes units/conversions, not already saved register codes.
INA260 uses its integrated shunt and does not use these sense-resistor values.
## Telemetry 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.
| Channel | Values |
| --- | --- |
| 2 | INA260 battery voltage, signed current, power |
| 3 | LTC4162 input voltage, signed current, computed nonnegative input power |
| 4 | Charger system output voltage |
| 5 | Charger battery ADC voltage and signed current |
| 6, 7 | Die temperature, modeled NTC temperature |
| 8, 9, 10 | Generic sensor integers: charger state, charge status, system bits |
| 11, 12, 13 | Generic sensor integers: limit, state and status alert masks |
| 14 onward | Optional environmental sensors, in discovery order |
Unavailable values are omitted. Voltage history reserves channels 2..5 and
marks unavailable samples invalid. The generic sensor scanner cannot claim
carrier addresses `0x40`/`0x68`; this prevents an INA219 driver from configuring
the INA260 and keeps carrier channels stable across profiles and power states.
No state-of-charge percentage or coulomb counting is inferred from voltage.
References: [SolarXaio hardware](https://github.com/wehooper4/Meshtastic-Hardware/tree/main/XaioSeries/SolarXaio),
[LTC4162-F Rev A](https://www.analog.com/media/en/technical-documentation/data-sheets/ltc4162-f.pdf),
[INA260](https://www.ti.com/lit/ds/symlink/ina260.pdf).
+4
View File
@@ -1,4 +1,5 @@
#include <helpers/RegulatorSettings.h>
#include <helpers/sensors/SolarXiaoPower.h>
#include <helpers/LedSettings.h>
#include <helpers/ui/DisplayPowerSettings.h>
#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS)
@@ -10771,6 +10772,9 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
}
if (mesh::power::handleRegulatorCommand(command, reply, reply_capacity)) return true;
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
if (mesh::solar::handleCommand(command, reply, reply_capacity, sender_timestamp == 0)) return true;
#endif
#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI)
if (handleCompactLedCommand(command, reply, reply_capacity)) return true;
#else
+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 <helpers/ui/StartupScreen.h>
@@ -3640,6 +3641,9 @@ void setup() {
#endif
#if !defined(WIO_E5_MINI_HEADLESS_COMPANION_UI)
mesh::leds::beginSettings(board, regulator_prefs_fs);
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
mesh::solar::beginSettings(regulator_prefs_fs);
#endif
#endif
if (!mesh::power::beginRegulatorSettings(board, radio_driver, regulator_prefs_fs)) {
MESH_DEBUG_PRINTLN("Regulator selection could not be applied");
@@ -4269,6 +4273,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();
#ifdef TBEAM_1W
+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>
@@ -213,6 +214,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(regulator_prefs_fs);
#endif
if (!mesh::power::beginRegulatorSettings(board, radio_driver, regulator_prefs_fs)) {
MESH_DEBUG_PRINTLN("Regulator selection could not be applied");
}
@@ -555,6 +559,9 @@ static void __attribute__((noinline)) serviceCommandInterfaces() {
}
mesh::power::serviceRegulatorSettings();
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
mesh::solar::service();
#endif
board.loop(); // let the board feed its watchdog, run periodic housekeeping
#ifdef ETHERNET_ENABLED
+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(regulator_prefs_fs);
#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
+4
View File
@@ -1,4 +1,5 @@
#include <helpers/RegulatorSettings.h>
#include <helpers/sensors/SolarXiaoPower.h>
#include <helpers/LedSettings.h>
#include <Arduino.h>
#include "CarrierTools.h"
@@ -3026,6 +3027,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
if (mesh::power::handleBatteryChargeCommand(*_board, command, reply, 160)) return;
#endif
if (mesh::power::handleRegulatorCommand(command, reply, 157)) return;
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
if (mesh::solar::handleCommand(command, reply, 157, sender_timestamp == 0)) return;
#endif
if (mesh::leds::handleCommand(command, reply, 157)) return;
if (mesh::handleUsbLoggingWatchdogCommand(command, reply, 160)) return;
if (mesh::powerdiag::handleCommand(command, reply, 157, millis())) return;
@@ -1,4 +1,5 @@
#include "EnvironmentSensorManager.h"
#include "SolarXiaoPower.h"
#include "I2CAddressClaimPolicy.h"
#include "EnvironmentI2CConfig.h"
#include "NmeaSentenceProbe.h"
@@ -1018,6 +1019,11 @@ bool EnvironmentSensorManager::begin() {
continue;
}
const uint8_t address = static_cast<uint8_t>(configured_address);
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
// Native carrier drivers own these addresses in Full and Reduced builds.
// In particular, never allow an INA219 initializer to configure the INA260.
if (TELEM_WIRE == &Wire && (address == 0x40 || address == 0x68)) continue;
#endif
if (!probed[address]) {
probed[address] = true;
detected[address] = i2c_probe(*TELEM_WIRE, address);
@@ -1029,6 +1035,9 @@ bool EnvironmentSensorManager::begin() {
const SensorDef& def = SENSOR_TABLE[i];
if (!mesh::isValidI2cPeripheralAddress(def.address)) continue;
const uint8_t address = static_cast<uint8_t>(def.address);
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
if (TELEM_WIRE == &Wire && (address == 0x40 || address == 0x68)) continue;
#endif
#ifdef RAK_WISBLOCK_GPS
if (mesh::shouldSkipSensorAtClaimedGpsAddress(
i2cGPSFlag, TELEM_WIRE == &Wire,
@@ -1074,6 +1083,10 @@ bool EnvironmentSensorManager::begin() {
bool EnvironmentSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) {
next_available_channel = TELEM_CHANNEL_SELF + 1;
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
next_available_channel = mesh::solar::FIRST_OPTIONAL_CHANNEL;
if (requester_permissions & TELEM_PERM_ENVIRONMENT) mesh::solar::addTelemetry(telemetry);
#endif
#if ENV_INCLUDE_GPS
queryGpsTelemetry(requester_permissions, telemetry);
@@ -1096,10 +1109,15 @@ bool EnvironmentSensorManager::querySensors(uint8_t requester_permissions, Cayen
uint8_t EnvironmentSensorManager::getVoltageSensorChannels(
uint8_t channels[], uint8_t capacity) const {
uint8_t count = 0;
uint8_t first_channel = TELEM_CHANNEL_SELF + 1;
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
count = mesh::solar::voltageChannels(channels, capacity);
first_channel = mesh::solar::FIRST_OPTIONAL_CHANNEL;
#endif
for (int i = 0; i < _active_sensor_count; i++) {
if (_active_sensors[i].query_voltage == NULL) continue;
if (count < capacity && channels != NULL) {
channels[count] = (uint8_t)(TELEM_CHANNEL_SELF + 1 + i);
channels[count] = (uint8_t)(first_channel + i);
}
count++;
}
@@ -1112,11 +1130,16 @@ uint8_t EnvironmentSensorManager::queryVoltageSensors(
if (!_wisblock_power.railEnabled()) return 0;
#endif
uint8_t count = 0;
uint8_t first_channel = TELEM_CHANNEL_SELF + 1;
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
count = mesh::solar::queryVoltages(readings, capacity);
first_channel = mesh::solar::FIRST_OPTIONAL_CHANNEL;
#endif
for (int i = 0; i < _active_sensor_count && count < capacity; i++) {
const ActiveSensor& sensor = _active_sensors[i];
if (sensor.query_voltage == NULL) continue;
VoltageSensorReading& reading = readings[count++];
reading.channel = (uint8_t)(TELEM_CHANNEL_SELF + 1 + i);
reading.channel = (uint8_t)(first_channel + i);
reading.voltage = 0.0f;
reading.valid = sensor.query_voltage(sensor.sub_channel,
reading.voltage)
+108
View File
@@ -0,0 +1,108 @@
#include "SolarXiaoPower.h"
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
#include "SolarXiaoPowerCore.h"
#include <helpers/CheckedSettingsFile.h>
#include <Wire.h>
#include <math.h>
namespace mesh { namespace solar {
extern const char meshcore_capability_solarxiao_power[] = "MESHCORE_CAPABILITY:solarxiao_power:v1";
namespace {
class CarrierBus : public Bus {
bool ready(uint8_t address) {
return (address==0x68 || address==0x40)
&& digitalRead(PIN_BOARD_SDA)==HIGH && digitalRead(PIN_BOARD_SCL)==HIGH;
}
public:
bool read(uint8_t address, uint8_t reg, uint16_t& value) override {
if (!ready(address)) return false;
Wire.beginTransmission(address);
Wire.write(reg);
if (Wire.endTransmission(false)!=0) return false;
if (Wire.requestFrom(address,uint8_t(2))!=2) {
while (Wire.available()) Wire.read();
return false;
}
const uint8_t first=Wire.read(), second=Wire.read();
// LTC4162 SMBus words are little endian; INA260 registers are big endian.
value=address==0x68 ? uint16_t(first)|(uint16_t(second)<<8)
: (uint16_t(first)<<8)|second;
return true;
}
bool write(uint8_t address,uint8_t reg,uint16_t value) override {
if (address!=0x68 || !ready(address)) return false;
Wire.beginTransmission(address);
Wire.write(reg); Wire.write(uint8_t(value)); Wire.write(uint8_t(value>>8));
return Wire.endTransmission()==0;
}
};
class CarrierStore : public Store {
public:
FILESYSTEM* fs=nullptr;
static constexpr const char* path="/solar_power";
bool available() const override { return fs!=nullptr; }
bool exists() const override { return fs && fs->exists(path); }
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 {
if (!fs || size!=Settings::IMAGE_SIZE) return false;
AtomicFileWriter writer(fs,path);
if (!writer || writer.write(data,size)!=size || !writer.commit()) return false;
uint8_t checked[Settings::IMAGE_SIZE];
return load(checked,size) && !memcmp(checked,data,size);
}
};
constexpr const char* CarrierStore::path;
CarrierBus transport;
CarrierStore persistence;
Controller controller(transport,persistence);
}
void beginSettings(FILESYSTEM* fs) {
const volatile char* marker=meshcore_capability_solarxiao_power;
(void)*marker;
persistence.fs=fs; controller.begin();
}
void service() { controller.service(millis()); }
bool handleCommand(const char* command,char* reply,size_t capacity,bool local) {
return controller.handle(command,reply,capacity,local);
}
void addTelemetry(CayenneLPP& lpp) {
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);
}
if (!s.charger) return;
if (isfinite(s.vin_v)) {
lpp.addVoltage(3,s.vin_v); lpp.addCurrent(3,s.iin_a);
lpp.addPower(3,fmaxf(0,s.vin_v*s.iin_a));
}
if (isfinite(s.vout_v)) lpp.addVoltage(4,s.vout_v);
if (isfinite(s.charger_vbat_v)) lpp.addVoltage(5,s.charger_vbat_v);
if (isfinite(s.ibat_a)) lpp.addCurrent(5,s.ibat_a);
if (isfinite(s.die_c)) lpp.addTemperature(6,s.die_c);
if (isfinite(s.ntc_c)) lpp.addTemperature(7,s.ntc_c);
lpp.addGenericSensor(8,s.state); lpp.addGenericSensor(9,s.status);
lpp.addGenericSensor(10,s.system);
for (unsigned i=0;i<3;++i) lpp.addGenericSensor(11+i,s.alerts[i]);
}
uint8_t voltageChannels(uint8_t* channels,uint8_t capacity) {
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;
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;
for (uint8_t i=0;i<count;++i) {
readings[i].channel=2+i;
readings[i].valid=(i==0?s.monitor:s.charger) && isfinite(volts[i]);
readings[i].voltage=readings[i].valid ? volts[i] : 0;
}
return count;
}
}} // namespace mesh::solar
#endif
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
#include <helpers/IdentityStore.h>
#include <helpers/SensorManager.h>
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 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);
uint8_t queryVoltages(SensorManager::VoltageSensorReading* readings, uint8_t capacity);
}}
#endif
+630
View File
@@ -0,0 +1,630 @@
#include "SolarXiaoPowerCore.h"
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
namespace mesh { namespace solar {
namespace {
constexpr uint8_t CHARGER = 0x68;
constexpr uint8_t MONITOR = 0x40;
enum Kind : uint8_t { Number, Signed, Boolean, Inverted, DieC, InputMa,
InputMv, BatteryMa, InputCurrentMa, ChargeMa, VinMv, VoutMv, BatteryMv, BsrOhm, CellMv };
enum Access : uint8_t { Read, Write, Locked };
struct Field {
const char* name;
uint8_t reg;
uint16_t mask;
uint8_t shift;
Kind kind;
Access access;
const char* unit;
};
// Registers and bitfields from LTC4162-F Rev A. Only FST-compatible settings
// are writable; identity and fixed-profile fields never reach Bus::write().
const Field fields[] = {
{"alert.battery_low_mv",1,0xffff,0,BatteryMv,Write,"mV"},
{"alert.battery_high_mv",2,0xffff,0,BatteryMv,Write,"mV"},
{"alert.input_low_mv",3,0xffff,0,VinMv,Write,"mV"},
{"alert.input_high_mv",4,0xffff,0,VinMv,Write,"mV"},
{"alert.output_low_mv",5,0xffff,0,VoutMv,Write,"mV"},
{"alert.output_high_mv",6,0xffff,0,VoutMv,Write,"mV"},
{"alert.input_high_raw",7,0xffff,0,Signed,Write,"ADC"},
{"alert.input_high_ma",7,0xffff,0,InputCurrentMa,Write,"mA"},
{"alert.battery_low_ma",8,0xffff,0,BatteryMa,Write,"mA"},
{"alert.die_high_c",9,0xffff,0,DieC,Write,"C"},
{"alert.resistance_raw",10,0xffff,0,Number,Write,"ADC"},
{"alert.ntc_high_raw",11,0xffff,0,Signed,Write,"ADC"},
{"alert.ntc_low_raw",12,0xffff,0,Signed,Write,"ADC"},
{"alert.limits_mask",13,0xcfff,0,Number,Write,"mask"},
{"alert.states_mask",14,0x1b7f,0,Number,Write,"mask"},
{"alert.status_mask",15,0x003f,0,Number,Write,"mask"},
{"thermal.start_c",16,0xffff,0,DieC,Write,"C"},
{"thermal.end_c",17,0xffff,0,DieC,Write,"C"},
{"charge.enabled",0x14,0x20,5,Inverted,Write,""},
{"telemetry.fast",0x14,8,3,Boolean,Write,""},
{"telemetry.force",0x14,4,2,Boolean,Write,""},
{"mppt.enabled",0x14,2,1,Boolean,Write,""},
{"input.limit_ma",0x15,63,0,InputMa,Write,"mA"},
{"input.minimum_mv",0x16,255,0,InputMv,Write,"mV"},
{"charge.current_code",0x1a,31,0,Number,Read,"code"},
{"charge.current_ma",0x1a,31,0,ChargeMa,Write,"mA"},
{"charge.cell_mv",0x1b,31,0,CellMv,Write,"mV/cell"},
{"termination.current_ma",0x1c,0xffff,0,BatteryMa,Write,"mA"},
{"termination.cv_seconds",0x1d,0xffff,0,Number,Write,"s"},
{"termination.total_minutes",0x1e,0xffff,0,Number,Write,"min"},
{"jeita.t1_raw",0x1f,0xffff,0,Signed,Locked,"ADC"},
{"jeita.t2_raw",0x20,0xffff,0,Signed,Locked,"ADC"},
{"jeita.t3_raw",0x21,0xffff,0,Signed,Locked,"ADC"},
{"jeita.t4_raw",0x22,0xffff,0,Signed,Locked,"ADC"},
{"jeita.t5_raw",0x23,0xffff,0,Signed,Locked,"ADC"},
{"jeita.t6_raw",0x24,0xffff,0,Signed,Locked,"ADC"},
{"jeita.voltages_65",0x25,0x03ff,0,Number,Locked,"packed"},
{"jeita.voltages_432",0x26,0x7fff,0,Number,Locked,"packed"},
{"jeita.currents_65",0x27,0x03ff,0,Number,Locked,"packed"},
{"jeita.currents_432",0x28,0x7fff,0,Number,Locked,"packed"},
{"termination.enabled",0x29,4,2,Boolean,Write,""},
{"jeita.enabled",0x29,1,0,Boolean,Write,""},
{"absorb.delta_code",0x2a,31,0,Number,Locked,"code"},
{"absorb.seconds",0x2b,0xffff,0,Number,Locked,"s"},
{"recharge.mv",0x2e,0xffff,0,BatteryMv,Locked,"mV"},
{"timer.charge_minutes",0x30,0xffff,0,Number,Read,"min"},
{"timer.cv_seconds",0x31,0xffff,0,Number,Read,"s"},
{"timer.absorb_seconds",0x32,0xffff,0,Number,Read,"s"},
{"state",0x34,0x1b7f,0,Number,Read,"mask"},
{"status",0x35,63,0,Number,Read,"mask"},
{"alerts.limits",0x36,0xcfff,0,Number,Read,"mask"},
{"alerts.states",0x37,0x1b7f,0,Number,Read,"mask"},
{"alerts.status",0x38,63,0,Number,Read,"mask"},
{"system",0x39,0x1ff,0,Number,Read,"mask"},
{"battery.mv",0x3a,0xffff,0,BatteryMv,Read,"mV"},
{"input.mv",0x3b,0xffff,0,VinMv,Read,"mV"},
{"output.mv",0x3c,0xffff,0,VoutMv,Read,"mV"},
{"battery.ma",0x3d,0xffff,0,BatteryMa,Read,"mA"},
{"input.current_raw",0x3e,0xffff,0,Signed,Read,"ADC"},
{"input.ma",0x3e,0xffff,0,InputCurrentMa,Read,"mA"},
{"die.c",0x3f,0xffff,0,DieC,Read,"C"},
{"ntc.raw",0x40,0xffff,0,Signed,Read,"ADC"},
{"battery.resistance_ohm",0x41,0xffff,0,BsrOhm,Read,"ohm"},
{"jeita.region",0x42,7,0,Number,Read,"region"},
{"chemistry",0x43,0x0f00,8,Number,Read,"code"},
{"cells",0x43,15,0,Number,Read,"cells"},
{"dac.charge_code",0x44,31,0,Number,Read,"code"},
{"dac.charge_ma",0x44,31,0,ChargeMa,Read,"mA"},
{"dac.cell_mv",0x45,31,0,CellMv,Read,"mV/cell"},
{"dac.input_ma",0x46,63,0,InputMa,Read,"mA"},
{"battery.filtered_mv",0x47,0xffff,0,BatteryMv,Read,"mV"},
{"resistance.current_ma",0x48,0xffff,0,BatteryMa,Read,"mA"},
{"telemetry.valid",0x4a,1,0,Boolean,Read,""},
{"resistance.questionable",0x4a,2,1,Boolean,Read,""},
{"dac.input_mv",0x4b,255,0,InputMv,Read,"mV"},
};
constexpr size_t FIELD_COUNT = sizeof(fields) / sizeof(fields[0]);
const Field* findField(const char* key) {
for (const auto& field : fields) if (strcmp(key, field.name) == 0) return &field;
return nullptr;
}
uint32_t crc(const uint8_t* data, size_t size) {
uint32_t value = 0xffffffff;
while (size--) {
value ^= *data++;
for (unsigned bit=0; bit<8; ++bit)
value = (value >> 1) ^ (0xedb88320UL & (0U - (value & 1)));
}
return ~value;
}
void put16(uint8_t* p, uint16_t n) { p[0]=n; p[1]=n>>8; }
uint16_t get16(const uint8_t* p) { return uint16_t(p[0]) | uint16_t(p[1])<<8; }
void put32(uint8_t* p, uint32_t n) { for (unsigned i=0;i<4;++i) p[i]=n>>(8*i); }
uint32_t get32(const uint8_t* p) {
uint32_t n=0; for (unsigned i=0;i<4;++i) n|=uint32_t(p[i])<<(8*i); return n;
}
bool number(const char* text, double& value) {
if (!text || !*text || *text==' ' || *text=='\t') return false;
char* end=nullptr; value=strtod(text,&end);
return end!=text && *end==0 && isfinite(value);
}
bool integer(const char* text, unsigned max, unsigned& value) {
double parsed; if (!number(text,parsed) || parsed<0 || parsed>max || floor(parsed)!=parsed) return false;
value=unsigned(parsed); return true;
}
bool signedKind(Kind kind) {
return kind==Signed || kind==DieC || kind==BatteryMa || kind==InputCurrentMa || kind==VinMv
|| kind==VoutMv || kind==BatteryMv;
}
double decode(const Field& f, uint16_t word, const Settings& settings, uint8_t cells) {
const uint16_t code=(word&f.mask)>>f.shift;
const double n=signedKind(f.kind) ? static_cast<int16_t>(code) : code;
switch (f.kind) {
case DieC:return n*0.0215-264.4;
case InputMa:return (n+1)*500.0/settings.rsnsi_mohm;
case InputMv:return (n+1)*140.625;
case BatteryMa:return n*1.466/settings.rsnsb_mohm;
case InputCurrentMa:return n*1.466/settings.rsnsi_mohm;
case ChargeMa:return (n+1)*1000.0/settings.rsnsb_mohm;
case VinMv:return n*1.649;
case VoutMv:return n*1.653;
case BatteryMv:return cells ? n*cells*0.1924 : NAN;
case BsrOhm:return cells ? n*cells*settings.rsnsb_mohm/500000.0 : NAN;
case CellMv:return 3412.5+n*12.5;
case Inverted:return !code;
default:return n;
}
}
bool encode(const Field& f, const char* text, const Settings& settings,
uint8_t cells, uint16_t& bits) {
double value;
if (f.kind==Boolean || f.kind==Inverted) {
if (!strcmp(text,"on")) value=1;
else if (!strcmp(text,"off")) value=0;
else return false;
if (f.kind==Inverted) value=!value;
} else {
if (!number(text,value)) return false;
switch (f.kind) {
case DieC:
if (value < -40 || value > 125) return false;
value=round((value+264.4)/0.0215); break;
case InputMa:
if (value<=0 || value>3200) return false;
value=floor(value*settings.rsnsi_mohm/500.0)-1; break;
case InputMv:
if (value<=0 || value>35000) return false;
value=ceil(value/140.625)-1; break;
case BatteryMa:value=round(value*settings.rsnsb_mohm/1.466); break;
case InputCurrentMa:value=round(value*settings.rsnsi_mohm/1.466); break;
case ChargeMa:
if (value<=0 || value>3200) return false;
value=floor(value*settings.rsnsb_mohm/1000.0)-1; break;
case CellMv:
if (value<3412.5 || value>3600) return false;
value=floor((value-3412.5)/12.5); break;
case VinMv:value=round(value/1.649); break;
case VoutMv:value=round(value/1.653); break;
case BatteryMv:
if (!cells) return false;
value=round(value/(cells*0.1924)); break;
default: if (floor(value)!=value) return false; break;
}
}
const double minimum=signedKind(f.kind) ? -32768 : 0;
const double maximum=signedKind(f.kind) ? 32767 : (f.mask>>f.shift);
if (!isfinite(value) || value<minimum || value>maximum) return false;
const uint16_t code=static_cast<uint16_t>(static_cast<int32_t>(value));
if ((static_cast<uint32_t>(code)<<f.shift)&~static_cast<uint32_t>(f.mask)) return false;
bits=static_cast<uint16_t>(code<<f.shift); return true;
}
} // namespace
uint16_t Controller::writableMask(uint8_t reg) {
uint16_t mask=0;
for (const auto& field:fields)
if (field.reg==reg && field.access==Write) mask|=field.mask;
return mask;
}
bool Controller::readableRegister(uint8_t reg) {
if (reg==0x19) return true;
for (const auto& field:fields) if (field.reg==reg) return true;
return false;
}
const char* Controller::chemistryName(uint8_t value) {
static const char* const names[]={"LAD","L42","L41","L40","FAD","FFS","FST","unknown","SST","SAD"};
return value<10 ? names[value] : "unknown";
}
const char* Controller::stateName(uint16_t state) {
switch(state) {
case 4096:return "battery_detection_failed";
case 2048:return "detecting_battery";
case 512:return "absorb";
case 256:return "suspended";
case 64:return "charging";
case 32:return "temperature_pause";
case 16:return "timer_terminated";
case 8:return "current_terminated";
case 4:return "charge_time_fault";
case 2:return "battery_missing";
case 1:return "battery_short";
default:return "unknown";
}
}
void Settings::defaults() {
memset(masks,0,sizeof(masks)); memset(values,0,sizeof(values));
rsnsb_mohm=rsnsi_mohm=10;
ntc_bias_ohm=ntc_r25_ohm=10000; ntc_beta=3490; calibration_user_mask=0;
}
bool Settings::valid() const {
const float calibration[]={rsnsb_mohm,rsnsi_mohm,ntc_bias_ohm,ntc_r25_ohm,ntc_beta};
const float minimum[]={0.1f,0.1f,100,100,1000};
const float maximum[]={1000,1000,1000000,1000000,10000};
for (unsigned i=0;i<5;++i)
if (!isfinite(calibration[i]) || calibration[i]<minimum[i] || calibration[i]>maximum[i]) return false;
if (calibration_user_mask&~31) return false;
for (unsigned reg=0;reg<REG_COUNT;++reg) {
if (masks[reg]&~Controller::writableMask(reg)) return false;
if (values[reg]&~masks[reg]) return false;
// Full-word targets must not contain a partial mask from a damaged record.
if (Controller::writableMask(reg)==0xffff && masks[reg] && masks[reg]!=0xffff) return false;
}
for (unsigned reg=16;reg<=17;++reg)
if (masks[reg] && (values[reg]<10437 || values[reg]>18112)) return false;
if (masks[0x1c] && static_cast<int16_t>(values[0x1c])<0) return false;
if (masks[0x1b] && values[0x1b]>15) return false;
if (masks[16] && masks[17] && static_cast<int16_t>(values[16])>=static_cast<int16_t>(values[17])) return false;
return true;
}
void Settings::encode(uint8_t image[IMAGE_SIZE]) const {
memset(image,0,IMAGE_SIZE); memcpy(image,"SXPW",4); image[4]=1; image[5]=6;
image[6]=calibration_user_mask;
size_t offset=8;
for (unsigned i=0;i<REG_COUNT;++i) {
put16(image+offset,masks[i]); put16(image+offset+2,values[i]); offset+=4;
}
const float calibration[]={rsnsb_mohm,rsnsi_mohm,ntc_bias_ohm,ntc_r25_ohm,ntc_beta};
for (float value:calibration) {
static_assert(sizeof(float)==4,"Settings require IEEE binary32");
uint32_t word; memcpy(&word,&value,4); put32(image+offset,word); offset+=4;
}
put32(image+IMAGE_SIZE-4,crc(image,IMAGE_SIZE-4));
}
bool Settings::decode(const uint8_t image[IMAGE_SIZE]) {
if (memcmp(image,"SXPW",4) || image[4]!=1 || image[5]!=6 || image[7]
|| get32(image+IMAGE_SIZE-4)!=crc(image,IMAGE_SIZE-4)) return false;
Settings candidate; candidate.defaults(); candidate.calibration_user_mask=image[6];
size_t offset=8;
for (unsigned i=0;i<REG_COUNT;++i) {
candidate.masks[i]=get16(image+offset); candidate.values[i]=get16(image+offset+2); offset+=4;
}
float* calibration[]={&candidate.rsnsb_mohm,&candidate.rsnsi_mohm,&candidate.ntc_bias_ohm,
&candidate.ntc_r25_ohm,&candidate.ntc_beta};
for (auto value:calibration) {uint32_t word=get32(image+offset); memcpy(value,&word,4); offset+=4;}
if (!candidate.valid()) return false;
*this=candidate; return true;
}
void Controller::begin() {
if (started) return;
started=true; serviced=false; settings.defaults(); storage_healthy=store.available();
if (store.exists()) {
uint8_t image[Settings::IMAGE_SIZE];
storage_healthy=store.load(image,sizeof(image)) && settings.decode(image);
}
}
bool Controller::identify(uint16_t& identity) {
if (!bus.read(CHARGER,0x43,identity)) return false;
const uint8_t chemistry=(identity>>8)&15;
if (!strcmp(chemistryName(chemistry),"unknown")) return false;
const uint8_t cells=identity&15;
if (cells>9) return false;
if (cells) last_cells=cells;
return true;
}
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;}
if (((identity>>8)&15)!=6 || ((identity&15)!=0 && (identity&15)!=4)) {
snprintf(reply,capacity,"Err - writes require LTC4162-FST on this 4S carrier; detected %s cells=%u",
chemistryName((identity>>8)&15),identity&15); return false;
}
return true;
}
bool Controller::update(uint8_t reg,uint16_t mask,uint16_t bits) {
if (!mask || (mask&~writableMask(reg)) || (bits&~mask)) return false;
uint16_t old;
if (!bus.read(CHARGER,reg,old)) return false;
uint16_t desired=(old&~mask)|bits;
if (reg==0x14) desired&=~0x10; // Never replay the one-shot run_bsr bit.
if ((old&mask)==bits) return true;
if (!bus.write(CHARGER,reg,desired)) return false;
uint16_t checked;
return bus.read(CHARGER,reg,checked) && (checked&mask)==bits;
}
bool Controller::save(const Settings& candidate) {
if (!candidate.valid() || !storage_healthy || !store.available()) return false;
uint8_t image[Settings::IMAGE_SIZE]; candidate.encode(image);
if (!store.save(image,sizeof(image))) {storage_healthy=false; return false;}
settings=candidate; return true;
}
void Controller::service(uint32_t now) {
if (!started || (serviced && static_cast<int32_t>(now-next_service)<0)) return;
serviced=true;
next_service=now+5000;
uint16_t identity;
if (!identify(identity)) {apply_ok=false; return;}
apply_ok=true;
if (!storage_healthy) return;
if (((identity>>8)&15)!=6 || ((identity&15) && (identity&15)!=4)) {apply_ok=false; return;}
// Restore an explicit suspend before other settings; explicit resume is last.
if ((settings.masks[0x14]&0x20) && (settings.values[0x14]&0x20)
&& !update(0x14,0x20,0x20)) {apply_ok=false; return;}
uint16_t start,end;
if (settings.masks[16] || settings.masks[17]) {
if (!bus.read(CHARGER,16,start) || !bus.read(CHARGER,17,end)) {apply_ok=false; return;}
const int16_t target_start=settings.masks[16] ? settings.values[16] : start;
const int16_t target_end=settings.masks[17] ? settings.values[17] : end;
if (target_start>=target_end) {apply_ok=false; return;}
const bool end_first=target_start>=static_cast<int16_t>(end);
const uint8_t order[2]={uint8_t(end_first?17:16),uint8_t(end_first?16:17)};
for (uint8_t reg:order) if (settings.masks[reg]
&& !update(reg,settings.masks[reg],settings.values[reg])) {apply_ok=false; return;}
}
// JEITA must be disabled before applying manual targets. When enabled, the
// chip continuously replaces those targets from its fixed JEITA table.
uint16_t config;
if (!bus.read(CHARGER,0x29,config)) {apply_ok=false; return;}
if (settings.masks[0x29] && !update(0x29,settings.masks[0x29],settings.values[0x29])) {apply_ok=false; return;}
const bool jeita=settings.masks[0x29]&1 ? settings.values[0x29]&1 : config&1;
for (unsigned reg=1;reg<Settings::REG_COUNT;++reg) {
if (reg==16 || reg==17) continue;
if (reg==0x29 || (jeita && (reg==0x1a || reg==0x1b))) continue;
uint16_t mask=settings.masks[reg];
if (reg==0x14) mask&=~0x20;
if (mask && !update(reg,mask,settings.values[reg]&mask)) {apply_ok=false; return;}
}
if (settings.masks[0x14]&0x20)
apply_ok=update(0x14,0x20,settings.values[0x14]&0x20);
}
bool Controller::readMonitor(float& volts,float& amps,float& watts) {
uint16_t manufacturer,device,v,i,p;
if (!bus.read(MONITOR,0xfe,manufacturer) || !bus.read(MONITOR,0xff,device)
|| manufacturer!=0x5449 || (device&0xfff0)!=0x2270
|| !bus.read(MONITOR,2,v) || !bus.read(MONITOR,1,i) || !bus.read(MONITOR,3,p)) return false;
volts=v*0.00125f; amps=static_cast<int16_t>(i)*0.00125f; watts=p*0.01f;
return true;
}
bool Controller::snapshot(Snapshot& result) {
result=Snapshot{};
result.vin_v=result.vout_v=result.charger_vbat_v=result.ibat_a=result.iin_a=NAN;
result.die_c=result.ntc_c=result.bsr_ohm=NAN;
result.monitor=readMonitor(result.battery_v,result.battery_a,result.battery_w);
uint16_t id;
if (!identify(id)) return result.monitor;
result.identity=id; result.chemistry=(id>>8)&15; result.cells=id&15;
const uint8_t regs[]={0x14,0x29,0x34,0x35,0x39,0x4a,0x36,0x37,0x38,
0x3a,0x3b,0x3c,0x3d,0x3e,0x3f,0x40,0x41};
uint16_t* values[]={&result.config,&result.charger_config,&result.state,&result.status,
&result.system,&result.telemetry,&result.alerts[0],&result.alerts[1],&result.alerts[2],
&result.raw_vbat,&result.raw_vin,&result.raw_vout,&result.raw_ibat,&result.raw_iin,
&result.raw_die,&result.raw_ntc,&result.raw_bsr};
for (unsigned i=0;i<sizeof(regs);++i) if (!bus.read(CHARGER,regs[i],*values[i])) return result.monitor;
result.charger=true;
// Do not label a retained ADC bank as a live reading while its ADC is off.
result.adc_active=(result.telemetry&1) && ((result.config&4) || (result.system&4));
if (!result.adc_active || result.chemistry<4 || result.chemistry>6) return true;
result.vin_v=static_cast<int16_t>(result.raw_vin)*0.001649f;
result.vout_v=static_cast<int16_t>(result.raw_vout)*0.001653f;
if (result.cells) {
result.charger_vbat_v=static_cast<int16_t>(result.raw_vbat)*result.cells*0.0001924f;
if (!(result.telemetry&2))
result.bsr_ohm=result.raw_bsr*result.cells*settings.rsnsb_mohm/500000.0f;
}
result.ibat_a=static_cast<int16_t>(result.raw_ibat)*0.001466f/settings.rsnsb_mohm;
result.iin_a=static_cast<int16_t>(result.raw_iin)*0.001466f/settings.rsnsi_mohm;
result.die_c=static_cast<int16_t>(result.raw_die)*0.0215f-264.4f;
const int raw=static_cast<int16_t>(result.raw_ntc);
if (raw>0 && raw<21684) {
const float resistance=settings.ntc_bias_ohm*raw/(21829.0f-raw);
result.ntc_c=1.0f/(1.0f/298.15f+logf(resistance/settings.ntc_r25_ohm)/settings.ntc_beta)-273.15f;
}
return true;
}
bool Controller::setField(const char* key,const char* value,char* reply,size_t capacity) {
const char* calibration[]={"cal.rsnsb_mohm","cal.rsnsi_mohm","cal.ntc_bias_ohm","cal.ntc_r25_ohm","cal.ntc_beta"};
for (unsigned i=0;i<5;++i) if (!strcmp(key,calibration[i])) {
double parsed;
Settings candidate=settings;
float* targets[]={&candidate.rsnsb_mohm,&candidate.rsnsi_mohm,&candidate.ntc_bias_ohm,
&candidate.ntc_r25_ohm,&candidate.ntc_beta};
if (!number(value,parsed)) {snprintf(reply,capacity,"Err - finite numeric calibration required"); return true;}
*targets[i]=parsed; candidate.calibration_user_mask|=1<<i;
if (!candidate.valid()) snprintf(reply,capacity,"Err - calibration out of range");
else if (!save(candidate)) snprintf(reply,capacity,"Err - settings save unconfirmed; use mppt reload");
else snprintf(reply,capacity,"OK - %s=%.6g saved; register targets unchanged",key,parsed);
return true;
}
const Field* field=findField(key);
if (!field) {snprintf(reply,capacity,"Err - unknown setting; get mppt fields <page>"); return true;}
if (field->access!=Write) {
snprintf(reply,capacity,"Err - %s is %s",key,field->access==Locked ? "locked by FST charging profile" : "read-only (hardware/state)"); return true;
}
if (!storage_healthy || !store.available()) {snprintf(reply,capacity,"Err - settings storage unavailable; use mppt reload"); return true;}
if (!verifyWritableIdentity(reply,capacity)) return true;
uint16_t bits;
// A valid cell count is needed for per-cell-scaled alert thresholds. Do not
// substitute the last known count while the hardware reports unknown.
uint16_t identity;
if (!identify(identity) || !encode(*field,value,settings,identity&15,bits)) {
snprintf(reply,capacity,"Err - invalid value/range or cell count unknown; %s (%s)",key,field->unit); return true;
}
uint16_t other;
if (field->reg==0x16 && (!bus.read(CHARGER,0x14,other) || (other&2))) {
snprintf(reply,capacity,"Err - disable mppt.enabled before setting manual input.minimum_mv"); return true;
}
if ((field->reg==0x1a || field->reg==0x1b)
&& (!bus.read(CHARGER,0x29,other) || (other&1))) {
snprintf(reply,capacity,"Err - disable jeita.enabled before setting manual charge targets"); return true;
}
if (field->reg==16 || field->reg==17) {
if (!bus.read(CHARGER,field->reg==16?17:16,other)
|| (field->reg==16 ? static_cast<int16_t>(bits)>=static_cast<int16_t>(other)
: static_cast<int16_t>(bits)<=static_cast<int16_t>(other))) {
snprintf(reply,capacity,"Err - thermal.start_c must be below thermal.end_c"); return true;
}
}
if (field->reg==0x1c && static_cast<int16_t>(bits)<0) {
snprintf(reply,capacity,"Err - termination current must be nonnegative"); return true;
}
uint16_t before;
if (!bus.read(CHARGER,field->reg,before)) {snprintf(reply,capacity,"Err - charger read failed"); return true;}
Settings candidate=settings;
candidate.masks[field->reg]|=field->mask;
candidate.values[field->reg]=(candidate.values[field->reg]&~field->mask)|bits;
if (!candidate.valid()) {snprintf(reply,capacity,"Err - setting conflicts with saved policy; use mppt forget"); return true;}
if (!update(field->reg,field->mask,bits)) {
const bool restored=update(field->reg,field->mask,before&field->mask);
snprintf(reply,capacity,"Err - write/readback failed; rollback=%s",restored?"verified":"unconfirmed"); return true;
}
if (!save(candidate)) {
const bool restored=update(field->reg,field->mask,before&field->mask);
snprintf(reply,capacity,"Err - save unconfirmed; live rollback=%s; use mppt reload",restored?"verified":"unconfirmed"); return true;
}
apply_ok=true;
snprintf(reply,capacity,"OK - %s=%.6g %s saved, readback verified",key,
decode(*field,bits,settings,identity&15),field->unit);
return true;
}
bool Controller::getField(const char* key,char* reply,size_t capacity) {
const char* calibration[]={"cal.rsnsb_mohm","cal.rsnsi_mohm","cal.ntc_bias_ohm","cal.ntc_r25_ohm","cal.ntc_beta"};
const float values[]={settings.rsnsb_mohm,settings.rsnsi_mohm,settings.ntc_bias_ohm,settings.ntc_r25_ohm,settings.ntc_beta};
for (unsigned i=0;i<5;++i) if (!strcmp(key,calibration[i])) {
snprintf(reply,capacity,"> %s=%.6g source=%s",key,double(values[i]),settings.calibration_user_mask&(1<<i)?"user":"reference"); return true;
}
const Field* field=findField(key);
if (!field) return false;
uint16_t identity,word;
if (!identify(identity) || !bus.read(CHARGER,field->reg,word)) {
snprintf(reply,capacity,"Err - charger unavailable; power SOLAR/VIN"); return true;
}
if ((field->kind==CellMv || field->kind==BatteryMv || field->kind==BsrOhm)
&& (((identity>>8)&15)<4 || ((identity>>8)&15)>6)) {
snprintf(reply,capacity,"> %s=unsupported chemistry; raw=%04X",key,word); return true;
}
if (field->access==Read && ((field->reg>=0x3a && field->reg<=0x41) || field->reg==0x47 || field->reg==0x48)) {
uint16_t cfg,system,valid;
if (!bus.read(CHARGER,0x14,cfg) || !bus.read(CHARGER,0x39,system)
|| !bus.read(CHARGER,0x4a,valid) || !(valid&1) || (!(cfg&4) && !(system&4))) {
snprintf(reply,capacity,"> %s=unavailable ADC inactive; raw=%04X",key,word); return true;
}
}
const double value=decode(*field,word,settings,identity&15);
if (!isfinite(value)) snprintf(reply,capacity,"> %s=unknown cells; raw=%04X",key,word);
else if (field->kind==Boolean || field->kind==Inverted)
snprintf(reply,capacity,"> %s=%s raw=%04X",key,value?"on":"off",word);
else snprintf(reply,capacity,"> %s=%.7g %s raw=%04X%s",key,value,field->unit,word,
field->access==Locked?" locked":"");
return true;
}
bool Controller::action(const char* args,bool local,char* reply,size_t capacity) {
if (!strcmp(args,"reset")) {
if (!local) {snprintf(reply,capacity,"Err - reset requires local USB"); return true;}
Settings candidate; candidate.defaults();
storage_healthy=store.available();
if (!save(candidate)) snprintf(reply,capacity,"Err - default policy save unconfirmed");
else snprintf(reply,capacity,"OK - saved policy/calibration reset; live charger unchanged");
return true;
}
if (!strcmp(args,"reload")) {
started=false; begin(); next_service=0;
snprintf(reply,capacity,storage_healthy?"OK - saved settings loaded; apply on service":"Err - saved settings invalid/unavailable"); return true;
}
if (!strcmp(args,"forget")) {
Settings candidate=settings;
memset(candidate.masks,0,sizeof(candidate.masks)); memset(candidate.values,0,sizeof(candidate.values));
if (!save(candidate)) snprintf(reply,capacity,"Err - override removal save unconfirmed");
else snprintf(reply,capacity,"OK - saved register overrides removed; live charger unchanged");
return true;
}
if (!strcmp(args,"bsr run") || !strcmp(args,"ship arm") || !strcmp(args,"ship disarm")) {
const bool ship=!strncmp(args,"ship ",5);
if (ship && !local) {snprintf(reply,capacity,"Err - ship mode requires local USB"); return true;}
if (!verifyWritableIdentity(reply,capacity)) return true;
bool ok;
if (ship) ok=bus.write(CHARGER,0x19,!strcmp(args,"ship arm")?21325:0);
else {
uint16_t cfg; ok=bus.read(CHARGER,0x14,cfg) && bus.write(CHARGER,0x14,cfg|0x10);
}
snprintf(reply,capacity,ok?"OK - action requested; not saved":"Err - action write failed"); return true;
}
const char* groups[]={"limits","states","status"};
for (unsigned i=0;i<3;++i) {
char prefix[32]; snprintf(prefix,sizeof(prefix),"alerts clear %s ",groups[i]);
if (strncmp(args,prefix,strlen(prefix))) continue;
unsigned mask;
const uint16_t allowed[]={0xcfff,0x1b7f,0x3f};
if (!integer(args+strlen(prefix),65535,mask) || !mask || (mask&~allowed[i])) {
snprintf(reply,capacity,"Err - invalid alert mask"); return true;
}
if (!verifyWritableIdentity(reply,capacity)) return true;
// Alerts are W0C: ones preserve all unselected (including newly arrived) alerts.
const bool ok=bus.write(CHARGER,0x36+i,static_cast<uint16_t>(~mask));
snprintf(reply,capacity,ok?"OK - selected alerts cleared":"Err - alert clear failed"); return true;
}
return false;
}
bool Controller::handle(const char* command,char* reply,size_t capacity,bool local) {
if (!command || !reply || capacity<2) return false;
const bool get=!strncmp(command,"get mppt",8) && (!command[8] || command[8]==' ');
const bool set=!strncmp(command,"set mppt ",9);
const bool act=!strncmp(command,"mppt ",5);
if (!get && !set && !act) return false;
if (set) {
char key[48],value[48],tail;
if (sscanf(command+9,"%47s %47s %c",key,value,&tail)!=2)
snprintf(reply,capacity,"Err - use set mppt <field> <value>; get mppt fields <page>");
else setField(key,value,reply,capacity);
return true;
}
if (act) {
if (!action(command+5,local,reply,capacity)) snprintf(reply,capacity,"Err - mppt reload|forget|reset|bsr run|ship arm/disarm|alerts clear <group> <mask>");
return true;
}
const char* key=command+8; if (*key==' ') ++key;
if (!strncmp(key,"fields",6) && (!key[6] || key[6]==' ')) {
unsigned page=0;
if (key[6] && !integer(key+7,100,page)) {snprintf(reply,capacity,"Err - fields page must be an integer"); return true;}
const size_t start=page*3;
if (start>=FIELD_COUNT) {snprintf(reply,capacity,"> end; fields=%u",unsigned(FIELD_COUNT)); return true;}
size_t used=snprintf(reply,capacity,"> page=%u ",page);
for (size_t i=start;i<FIELD_COUNT && i<start+3 && used<capacity;++i)
used+=snprintf(reply+used,capacity-used,"%s[%s]%s",fields[i].name,
fields[i].access==Write?"rw":fields[i].access==Locked?"locked":"ro",i+1<start+3?" ":"");
return true;
}
if (!strncmp(key,"raw ",4)) {
unsigned reg;
if (!integer(key+4,255,reg) || !readableRegister(reg)) snprintf(reply,capacity,"Err - unknown/read-protected register");
else {uint16_t id,value; if (!identify(id) || !bus.read(CHARGER,reg,value)) snprintf(reply,capacity,"Err - charger read failed");
else snprintf(reply,capacity,"> addr=68 reg=%02X raw=%04X",reg,value);}
return true;
}
if (!strcmp(key,"saved")) {
unsigned count=0; for (uint16_t mask:settings.masks) if (mask) ++count;
snprintf(reply,capacity,"> storage=%s overrides=%u apply=%s rsnsb=%.3gmOhm rsnsi=%.3gmOhm cal_user=%02X",
storage_healthy?"ok":"unavailable",count,apply_ok?"verified":"pending",double(settings.rsnsb_mohm),double(settings.rsnsi_mohm),settings.calibration_user_mask); return true;
}
if (*key && getField(key,reply,capacity)) return true;
Snapshot s; snapshot(s);
if (!*key || !strcmp(key,"info")) {
if (!s.charger) snprintf(reply,capacity,"> LTC4162=unavailable INA260=%s; power SOLAR/VIN for charger access",s.monitor?"online":"unavailable");
else snprintf(reply,capacity,"> LTC4162-%s chem=%u cells=%u last=%u MPPT=%s JEITA=%s state=%s ADC=%s saved=%s",
chemistryName(s.chemistry),s.chemistry,s.cells,last_cells,s.config&2?"on":"off",
s.charger_config&1?"on":"off",stateName(s.state),s.adc_active?"on":"off",storage_healthy?"ok":"bad");
} else if (!strcmp(key,"battery")) {
if (!s.monitor) snprintf(reply,capacity,"Err - INA260 unavailable");
else snprintf(reply,capacity,"> INA260 %.4fV %+.5fA %.3fW; charger=%.4fV %+.5fA NTC=%.2fC model=%s",
double(s.battery_v),double(s.battery_a),double(s.battery_w),double(s.charger_vbat_v),double(s.ibat_a),double(s.ntc_c),
(settings.calibration_user_mask&28)==28?"user":"reference");
} else if (!strcmp(key,"power")) {
if (!s.charger) snprintf(reply,capacity,"Err - charger unavailable; power SOLAR/VIN");
else snprintf(reply,capacity,"> ADC=%s VIN=%.4fV IIN=%+.5fA VOUT=%.4fV die=%.2fC NTC=%.2fC BSR=%.5fohm",
s.adc_active?"on":"off",double(s.vin_v),double(s.iin_a),double(s.vout_v),double(s.die_c),double(s.ntc_c),double(s.bsr_ohm));
} else if (!strcmp(key,"alerts")) {
if (!s.charger) snprintf(reply,capacity,"Err - charger unavailable");
else snprintf(reply,capacity,"> limits=%04X states=%04X status=%04X state=%04X/%s charge_status=%04X system=%04X telemetry=%04X",
s.alerts[0],s.alerts[1],s.alerts[2],s.state,stateName(s.state),s.status,s.system,s.telemetry);
} else snprintf(reply,capacity,"Err - use get mppt info|battery|power|alerts|saved|fields <page>|raw <reg>|<field>");
return true;
}
}} // namespace mesh::solar
#endif
+87
View File
@@ -0,0 +1,87 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
// Portable charger logic. The board adapter owns Wire and filesystem access.
namespace mesh { namespace solar {
class Bus {
public:
virtual ~Bus() {}
virtual bool read(uint8_t address, uint8_t reg, uint16_t& value) = 0;
virtual bool write(uint8_t address, uint8_t reg, uint16_t value) = 0;
};
class Store {
public:
virtual ~Store() {}
virtual bool available() const = 0;
virtual bool exists() const = 0;
virtual bool load(uint8_t* data, size_t size) = 0;
virtual bool save(const uint8_t* data, size_t size) = 0;
};
struct Settings {
static constexpr size_t REG_COUNT = 0x30;
static constexpr size_t IMAGE_SIZE = 224;
uint16_t masks[REG_COUNT];
uint16_t values[REG_COUNT];
// Reference schematic values, configurable for other carrier revisions.
float rsnsb_mohm, rsnsi_mohm, ntc_bias_ohm, ntc_r25_ohm, ntc_beta;
uint8_t calibration_user_mask;
void defaults();
bool valid() const;
void encode(uint8_t image[IMAGE_SIZE]) const;
bool decode(const uint8_t image[IMAGE_SIZE]);
};
struct Snapshot {
bool charger, monitor, adc_active;
uint8_t chemistry, cells;
uint16_t identity, config, charger_config, state, status, system, telemetry;
uint16_t alerts[3];
uint16_t raw_vbat, raw_vin, raw_vout, raw_ibat, raw_iin, raw_die, raw_ntc, raw_bsr;
float battery_v, battery_a, battery_w;
float vin_v, vout_v, charger_vbat_v, ibat_a, iin_a, die_c, ntc_c, bsr_ohm;
};
class Controller {
Bus& bus;
Store& store;
Settings settings;
bool storage_healthy = true;
bool apply_ok = true;
uint8_t last_cells = 0;
uint32_t next_service = 0;
bool serviced = false;
bool started = false;
bool identify(uint16_t& identity);
bool verifyWritableIdentity(char* reply, size_t capacity);
bool update(uint8_t reg, uint16_t mask, uint16_t bits);
bool save(const Settings& candidate);
bool setField(const char* key, const char* value, char* reply, size_t capacity);
bool getField(const char* key, char* reply, size_t capacity);
bool action(const char* arguments, bool local, char* reply, size_t capacity);
public:
Controller(Bus& transport, Store& persistence) : bus(transport), store(persistence) {
settings.defaults();
}
void begin();
void service(uint32_t now);
bool snapshot(Snapshot& result);
bool readMonitor(float& volts, float& amps, float& watts);
bool handle(const char* command, char* reply, size_t capacity, bool local);
const Settings& configuration() const { return settings; }
bool settingsHealthy() const { return storage_healthy; }
bool settingsApplied() const { return apply_ok; }
uint8_t lastCellCount() const { return last_cells; }
static uint16_t writableMask(uint8_t reg);
static bool readableRegister(uint8_t reg);
static const char* chemistryName(uint8_t chemistry);
static const char* stateName(uint16_t state);
};
}} // namespace mesh::solar
+305
View File
@@ -0,0 +1,305 @@
"""Exercise the shipping charger core with fault-injected I2C and persistence."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include "SolarXiaoPowerCore.h"
#include <cassert>
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
using namespace mesh::solar;
struct FakeBus : Bus {
uint16_t r[256]={}, ina[256]={};
bool online=true, fail_write=false, ignore_write=false;
int read_fail=-1;
struct Write {unsigned reg, value;};
std::vector<Write> writes;
FakeBus() {
r[0x43]=0x46b4; r[0x14]=2; r[0x29]=1;
r[16]=16019; r[17]=17414; r[0x1a]=31; r[0x1b]=15;
r[0x4a]=1; r[0x39]=7; r[0x34]=256;
r[0x3a]=17152; r[0x3b]=8000; r[0x3c]=7985;
r[0x3d]=uint16_t(-100); r[0x3e]=200; r[0x3f]=14000;
r[0x40]=10914; r[0x41]=10;
ina[0xfe]=0x5449; ina[0xff]=0x2270;
ina[2]=10560; ina[1]=uint16_t(-8); ina[3]=13;
}
bool read(uint8_t addr,uint8_t reg,uint16_t& word) override {
assert(addr==0x68 || addr==0x40);
if (!online || int(reg)==read_fail) return false;
word=(addr==0x68?r:ina)[reg]; return true;
}
bool write(uint8_t addr,uint8_t reg,uint16_t word) override {
assert(addr==0x68); writes.push_back({reg,word});
if (fail_write) return false;
if (ignore_write) return true;
if(reg>=0x36 && reg<=0x38) r[reg]&=word;
else r[reg]=word;
return true;
}
};
struct FakeStore : Store {
std::vector<uint8_t> bytes;
bool writable=true, failed=false;
int saves=0;
bool available() const override {return writable;}
bool exists() const override {return !bytes.empty();}
bool load(uint8_t* data,size_t n) override {
if(bytes.size()!=n) return false;
memcpy(data,bytes.data(),n);return true;
}
bool save(const uint8_t* data,size_t n) override {
++saves; if(failed)return false;
bytes.assign(data,data+n);return true;
}
};
std::string cli(Controller& c,const char* cmd,bool local=true) {
struct Guard {char a[3]; char reply[157]; char z[3];} g;
memset(&g,0x5a,sizeof(g));
assert(c.handle(cmd,g.reply,sizeof(g.reply),local));
assert(g.a[0]==0x5a && g.a[2]==0x5a && g.z[0]==0x5a && g.z[2]==0x5a);
assert(memchr(g.reply,0,sizeof(g.reply)));
return g.reply;
}
void has(const std::string& s,const char* part) {assert(s.find(part)!=std::string::npos);}
void close(double got,double expected) {assert(fabs(got-expected)<0.0002);}
int main(int argc,char** argv) {
assert(argc==2); std::string test=argv[1];
FakeBus bus;FakeStore store;Controller c(bus,store);c.begin();
if(test=="readings") {
Snapshot s;assert(c.snapshot(s)); assert(s.charger && s.monitor && s.adc_active);
close(s.battery_v,13.2);close(s.battery_a,-0.01);close(s.battery_w,0.13);
close(s.vin_v,13.192);close(s.charger_vbat_v,13.2001792);
close(s.ibat_a,-0.01466);close(s.iin_a,0.02932);close(s.die_c,36.6);
assert(fabs(s.ntc_c-25)<0.02);close(s.bsr_ohm,0.0008);
bus.r[0x4a]=3;c.snapshot(s);assert(std::isnan(s.bsr_ohm));
bus.r[0x43]=0x06b0;c.snapshot(s);assert(s.cells==0 && c.lastCellCount()==4);
assert(std::isnan(s.charger_vbat_v));has(cli(c,"get mppt battery.mv"),"unknown cells");
bus.r[0x39]=0;c.snapshot(s);assert(!s.adc_active && std::isnan(s.vin_v));
has(cli(c,"get mppt input.mv"),"ADC inactive");
bus.r[0x14]|=4;c.snapshot(s);assert(s.adc_active);
bus.r[0x40]=21684;c.snapshot(s);assert(std::isnan(s.ntc_c));
bus.ina[0xff]=0x227f;c.snapshot(s);assert(s.monitor);
bus.ina[0xff]=0x2260;c.snapshot(s);assert(!s.monitor);
assert(bus.writes.empty() && store.saves==0);
} else if(test=="defaults-identity") {
c.service(0);c.service(5000);assert(bus.writes.empty());
for(unsigned id: {0x0101u,0x0404u,0x0603u,0x0704u,0x0f04u}) {
bus.r[0x43]=id;has(cli(c,"set mppt telemetry.fast on"),"Err -");
c.service(10000+id);assert(bus.writes.empty());
}
bus.r[0x43]=0x06b0;has(cli(c,"set mppt telemetry.fast on"),"OK -");
assert(bus.r[0x14]==10);
bus.online=false;has(cli(c,"set mppt telemetry.fast off"),"unavailable");
} else if(test=="locks-ranges") {
for(const char* cmd: {"set mppt cells 3","set mppt chemistry 4",
"set mppt jeita.t1_raw 123","set mppt jeita.voltages_432 123",
"set mppt absorb.seconds 500","set mppt recharge.mv 13000",
"set mppt input.limit_ma nan","set mppt input.limit_ma inf",
"set mppt input.limit_ma -1","set mppt input.limit_ma 10",
"set mppt input.limit_ma 3201","set mppt charge.cell_mv 3650",
"set mppt charge.current_ma 2000","set mppt charge.cell_mv 3550",
"set mppt input.minimum_mv 14000","set mppt telemetry.fast 1",
"set mppt thermal.start_c 126","set mppt thermal.start_c 120",
"set mppt thermal.end_c 70","set mppt termination.current_ma -1",
"set mppt alert.limits_mask 0x1000","set mppt state 0",
"set mppt telemetry.fast on extra"}) {
has(cli(c,cmd),"Err -");assert(bus.writes.empty());
}
bus.r[0x43]=0x0600;
has(cli(c,"set mppt alert.battery_low_mv 12000"),"unknown");assert(bus.writes.empty());
assert(store.saves==0);
} else if(test=="rounding-rmw") {
bus.r[0x14]=0x8052;
has(cli(c,"set mppt telemetry.fast on"),"readback verified");
assert(bus.r[0x14]==0x804a); // preserve other bits; never replay run_bsr.
has(cli(c,"set mppt input.limit_ma 1234"),"1200");assert(bus.r[0x15]==23);
has(cli(c,"set mppt mppt.enabled off"),"OK -");
has(cli(c,"set mppt input.minimum_mv 13000"),"13078.1");assert(bus.r[0x16]==92);
has(cli(c,"set mppt jeita.enabled off"),"OK -");
has(cli(c,"set mppt charge.current_ma 1540"),"1500");assert(bus.r[0x1a]==14);
has(cli(c,"set mppt charge.cell_mv 3558"),"3550");assert(bus.r[0x1b]==11);
has(cli(c,"set mppt thermal.start_c 80"),"OK -");
has(cli(c,"set mppt thermal.end_c 115"),"OK -");
has(cli(c,"set mppt termination.current_ma 200"),"OK -");
} else if(test=="persistence-order") {
has(cli(c,"set mppt telemetry.force on"),"OK -");
has(cli(c,"set mppt jeita.enabled off"),"OK -");
has(cli(c,"set mppt charge.current_ma 1000"),"OK -");
has(cli(c,"set mppt thermal.end_c 125"),"OK -");
has(cli(c,"set mppt thermal.start_c 115"),"OK -");
has(cli(c,"set mppt charge.enabled off"),"OK -");
bus.r[0x14]=2;bus.r[0x29]=1;bus.r[0x1a]=31;bus.r[16]=16019;bus.r[17]=17414;
bus.writes.clear();Controller fresh(bus,store);fresh.begin();fresh.service(0xfffffff0);
assert(bus.writes[0].reg==0x14 && (bus.writes[0].value&0x20));
assert(bus.writes[1].reg==17 && bus.writes[2].reg==16);
assert(bus.r[0x29]==0 && bus.r[0x1a]==9 && bus.r[0x14]==38);
assert(fresh.settingsApplied());size_t count=bus.writes.size();fresh.service(20);
assert(bus.writes.size()==count);fresh.service(5000);assert(bus.writes.size()==count);
has(cli(fresh,"set mppt jeita.enabled on"),"OK -");
bus.r[0x1a]=31;count=bus.writes.size();fresh.service(10000);assert(bus.writes.size()==count);
has(cli(fresh,"mppt forget"),"live charger unchanged");
bus.r[0x14]=2;fresh.service(15000);assert(bus.r[0x14]==2);
} else if(test=="faults") {
bus.ignore_write=true;has(cli(c,"set mppt telemetry.fast on"),"write/readback failed");
assert(store.saves==0 && bus.r[0x14]==2);
bus.ignore_write=false;store.failed=true;
has(cli(c,"set mppt telemetry.fast on"),"rollback=verified");
assert(bus.r[0x14]==2 && !c.settingsHealthy());
size_t count=bus.writes.size();c.service(0);assert(bus.writes.size()==count);
has(cli(c,"set mppt telemetry.fast on"),"storage unavailable");
store.failed=false;has(cli(c,"mppt reload"),"OK -");
has(cli(c,"set mppt telemetry.fast on"),"OK -");
bus.read_fail=0x14;has(cli(c,"set mppt telemetry.fast off"),"read failed");
bus.read_fail=-1;bus.fail_write=true;
has(cli(c,"set mppt telemetry.fast off"),"rollback=verified");
} else if(test=="record-validation") {
Settings s;s.defaults();uint8_t image[Settings::IMAGE_SIZE];s.encode(image);
Settings got;assert(got.decode(image));image[23]^=1;assert(!got.decode(image));
for(int type=0;type<6;++type) {
s.defaults();
if(type==0){s.masks[0x43%Settings::REG_COUNT]=1;} // reserved/non-writable
if(type==1){s.masks[0x14]=16;s.values[0x14]=16;}
if(type==2){s.masks[16]=0xffff;s.values[16]=1;}
if(type==3){s.rsnsb_mohm=NAN;}
if(type==4){s.masks[0x1c]=0xffff;s.values[0x1c]=0xffff;}
if(type==5){s.masks[0x1b]=31;s.values[0x1b]=31;}
s.encode(image);assert(!got.decode(image));
}
store.bytes.assign(10,0);Controller bad(bus,store);bad.begin();bad.service(0);
assert(!bad.settingsHealthy() && bus.writes.empty());
has(cli(bad,"set mppt telemetry.force on"),"storage unavailable");
has(cli(bad,"mppt reset",false),"local USB");
has(cli(bad,"mppt reset"),"OK -");assert(bad.settingsHealthy());
has(cli(bad,"set mppt cal.rsnsb_mohm 20"),"saved");
has(cli(bad,"get mppt cal.rsnsb_mohm"),"source=user");
Controller after(bus,store);after.begin();close(after.configuration().rsnsb_mohm,20);
} else if(test=="actions-parser") {
assert(!c.handle("get mpptish",nullptr,0,true));
for(int i=0;i<40;++i) {std::string cmd="get mppt fields "+std::to_string(i);cli(c,cmd.c_str());}
has(cli(c,"get mppt fields -1"),"Err -");
has(cli(c,"get mppt raw 0x43"),"46B4");has(cli(c,"get mppt raw 0x12"),"Err -");
has(cli(c,"mppt ship arm",false),"local USB");assert(bus.writes.empty());
has(cli(c,"mppt bsr run"),"not saved");assert(bus.writes.back().value==18);
has(cli(c,"mppt ship arm"),"not saved");assert(bus.writes.back().value==21325);
has(cli(c,"mppt ship disarm"),"not saved");assert(bus.writes.back().value==0);
bus.r[0x36]=0xcfff;has(cli(c,"mppt alerts clear limits 0x20"),"OK -");
assert(bus.writes.back().value==0xffdf && bus.r[0x36]==0xcfdf);
has(cli(c,"mppt alerts clear limits 0x1000"),"Err -");assert(store.saves==0);
} else return 2;
}
'''
class SolarXiaoPowerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp = tempfile.TemporaryDirectory(prefix="solarxiao-power-test-")
cls.addClassCleanup(cls.temp.cleanup)
directory = Path(cls.temp.name)
cpp = directory / "test.cpp"
cpp.write_text(HARNESS, encoding="ascii")
cls.binary = directory / "test"
compiler = os.environ.get("CXX") or shutil.which("g++")
assert compiler, "host C++11 compiler required"
subprocess.run([compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
"-DMESH_SOLARXIAO_POWER=1", "-I" + str(ROOT / "src/helpers/sensors"),
str(cpp), str(ROOT / "src/helpers/sensors/SolarXiaoPowerCore.cpp"),
"-o", str(cls.binary)], check=True, capture_output=True, text=True, timeout=30)
def test_shipping_core(self):
for scenario in ("readings", "defaults-identity", "locks-ranges", "rounding-rmw",
"persistence-order", "faults", "record-validation", "actions-parser"):
with self.subTest(scenario=scenario):
result = subprocess.run([str(self.binary), scenario], capture_output=True,
text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class SolarXiaoTelemetryIntegrationTests(unittest.TestCase):
def test_permissions_and_stable_voltage_channels(self):
source = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text()
methods = "\n".join(extract_braced(source, prefix) for prefix in (
"bool EnvironmentSensorManager::querySensors(",
"uint8_t EnvironmentSensorManager::getVoltageSensorChannels(",
"uint8_t EnvironmentSensorManager::queryVoltageSensors("))
harness = r'''#include <cassert>
#include <cstdint>
#include <cmath>
using std::isfinite;
#define TELEM_CHANNEL_SELF 1
#define TELEM_PERM_ENVIRONMENT 4
struct CayenneLPP {};
struct SensorManager { struct VoltageSensorReading {uint8_t channel;float voltage;bool valid;}; };
int native_queries=0, optional_queries=0;
namespace mesh { namespace solar {
constexpr uint8_t FIRST_OPTIONAL_CHANNEL=14;
void addTelemetry(CayenneLPP&) {++native_queries;}
uint8_t voltageChannels(uint8_t* ch,uint8_t cap) {
const uint8_t n=cap<4?cap:4;for(uint8_t i=0;ch && i<n;++i)ch[i]=2+i;return n;
}
uint8_t queryVoltages(SensorManager::VoltageSensorReading* r,uint8_t cap) {
const uint8_t n=cap<4?cap:4;
for(uint8_t i=0;i<n;++i)r[i]={uint8_t(2+i),13.2f,i==0};return n;
}
}}
void optional(uint8_t ch,uint8_t,CayenneLPP&) {
++optional_queries;assert(ch==(MESH_SOLARXIAO_POWER?14:2));
}
bool optional_voltage(uint8_t,float& voltage) {voltage=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]={{optional,optional_voltage,0}};
int _active_sensor_count=1;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);
};
@METHODS@
int main() {
EnvironmentSensorManager env;CayenneLPP lpp;
env.querySensors(0,lpp);assert(!native_queries && !optional_queries);
env.querySensors(4,lpp);assert(native_queries==MESH_SOLARXIAO_POWER && optional_queries==1);
const int all=MESH_SOLARXIAO_POWER?5:1;
for(int cap=0;cap<7;++cap) {
uint8_t ch[8];for(auto& c:ch)c=255;
SensorManager::VoltageSensorReading r[8];for(auto& item:r)item.channel=255;
int n=cap<all?cap:all;
assert(env.getVoltageSensorChannels(ch,cap)==n);
assert(env.queryVoltageSensors(r,cap)==n);
for(int i=0;i<n;++i) {
int expected=MESH_SOLARXIAO_POWER && i<4?2+i:MESH_SOLARXIAO_POWER?14:2;
assert(ch[i]==expected && r[i].channel==expected);
assert(r[i].valid==(MESH_SOLARXIAO_POWER?i==0 || i==4:true));
}
assert(ch[n]==255 && r[n].channel==255);
}
}
'''.replace("@METHODS@", methods)
with tempfile.TemporaryDirectory(prefix="solarxiao-telemetry-test-") as temp:
cpp = Path(temp) / "test.cpp"
binary = Path(temp) / "test"
cpp.write_text(harness, encoding="ascii")
for enabled in (0, 1):
build = subprocess.run([shutil.which("g++"), "-std=c++11",
"-DMESH_SOLARXIAO_POWER=" + str(enabled), "-fsanitize=address,undefined",
"-fno-pie", "-no-pie", str(cpp), "-o", str(binary)],
capture_output=True, text=True, timeout=30)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -180,6 +180,7 @@ build_flags = ${Xiao_nrf52.build_flags}
-D PIN_BOARD_SDA=PIN_NFC1
-D PIN_BOARD_SCL=PIN_NFC2
-D TELEM_INA260_ADDRESS=0x40
-D MESH_SOLARXIAO_POWER=1
-D MANUFACTURER_STRING='"SolarXiao 30S"'
[env:solarxiao_30S_companion_radio_ble]