Add native SolarXiao MPPT and battery telemetry and controls
This commit is contained in:
1 parent
e5c8d28d5e
commit
2dcc1caccb
15 files changed
+1357
-2
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user