diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 939f8469d..ad42c04af 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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. diff --git a/docs/index.md b/docs/index.md index 907cc58e8..efb77b80b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) diff --git a/docs/solarxiao_power.md b/docs/solarxiao_power.md new file mode 100644 index 000000000..df1ca7fcb --- /dev/null +++ b/docs/solarxiao_power.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 ` 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 `. 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 `: 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). diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 803cf8197..2dbae74f2 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #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 diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index eaa85937a..7b2e457e7 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include // needed for PlatformIO #include @@ -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 diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index f78bf5864..42581b2c5 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include // needed for PlatformIO #include @@ -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 diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index d7393bed6..3f3657f88 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include // needed for PlatformIO #include @@ -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 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 948a6bd4e..29413614b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #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; diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index dd93d9254..390bf91eb 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -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(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(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) diff --git a/src/helpers/sensors/SolarXiaoPower.cpp b/src/helpers/sensors/SolarXiaoPower.cpp new file mode 100644 index 000000000..4b2c834be --- /dev/null +++ b/src/helpers/sensors/SolarXiaoPower.cpp @@ -0,0 +1,108 @@ +#include "SolarXiaoPower.h" +#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER +#include "SolarXiaoPowerCore.h" +#include +#include +#include + +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 +#include + +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 diff --git a/src/helpers/sensors/SolarXiaoPowerCore.cpp b/src/helpers/sensors/SolarXiaoPowerCore.cpp new file mode 100644 index 000000000..9f256aba3 --- /dev/null +++ b/src/helpers/sensors/SolarXiaoPowerCore.cpp @@ -0,0 +1,630 @@ +#include "SolarXiaoPowerCore.h" +#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER + +#include +#include +#include +#include + +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(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) || valuemaximum) return false; + const uint16_t code=static_cast(static_cast(value)); + if ((static_cast(code)<(f.mask)) return false; + bits=static_cast(code<maximum[i]) return false; + if (calibration_user_mask&~31) return false; + for (unsigned reg=0;reg18112)) return false; + if (masks[0x1c] && static_cast(values[0x1c])<0) return false; + if (masks[0x1b] && values[0x1b]>15) return false; + if (masks[16] && masks[17] && static_cast(values[16])>=static_cast(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>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(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(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(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;i6) return true; + result.vin_v=static_cast(result.raw_vin)*0.001649f; + result.vout_v=static_cast(result.raw_vout)*0.001653f; + if (result.cells) { + result.charger_vbat_v=static_cast(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(result.raw_ibat)*0.001466f/settings.rsnsb_mohm; + result.iin_a=static_cast(result.raw_iin)*0.001466f/settings.rsnsi_mohm; + result.die_c=static_cast(result.raw_die)*0.0215f-264.4f; + const int raw=static_cast(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<"); 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(bits)>=static_cast(other) + : static_cast(bits)<=static_cast(other))) { + snprintf(reply,capacity,"Err - thermal.start_c must be below thermal.end_c"); return true; + } + } + if (field->reg==0x1c && static_cast(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<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(~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 ; get mppt fields "); + 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 "); + 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 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 |raw |"); + return true; +} + +}} // namespace mesh::solar +#endif diff --git a/src/helpers/sensors/SolarXiaoPowerCore.h b/src/helpers/sensors/SolarXiaoPowerCore.h new file mode 100644 index 000000000..2d42068af --- /dev/null +++ b/src/helpers/sensors/SolarXiaoPowerCore.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include + +// 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 diff --git a/test/test_solarxiao_power.py b/test/test_solarxiao_power.py new file mode 100644 index 000000000..053666031 --- /dev/null +++ b/test/test_solarxiao_power.py @@ -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 +#include +#include +#include +#include +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 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 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 +#include +#include +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