From 66a22265891a785cee277ab87f2c31c66a61a170 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Mon, 6 Apr 2026 21:48:32 +0200 Subject: [PATCH 01/11] docs: add three-phase inverter support design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-06-three-phase-support-design.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-06-three-phase-support-design.md diff --git a/docs/superpowers/specs/2026-04-06-three-phase-support-design.md b/docs/superpowers/specs/2026-04-06-three-phase-support-design.md new file mode 100644 index 0000000..561a0b6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-three-phase-support-design.md @@ -0,0 +1,105 @@ +# Three-Phase Inverter Support Design + +**Date:** 2026-04-06 +**Branch:** extend-functions +**Status:** Approved + +## Overview + +Extend the `sunspec` ESPHome component to support 3-phase inverters alongside the existing single-phase support. The phase count is selected at YAML configuration time via a `phases` key. Existing single-phase configs remain unchanged. + +## YAML Schema + +A new optional `phases` key (default `1`, valid values `1` or `3`) selects the inverter model. + +**Single-phase (existing, unchanged):** +```yaml +sunspec: + phases: 1 # or omit — default + ac_voltage: id(sensor_v) + ac_current: id(sensor_i) # optional + ac_power: id(sensor_p) + ac_frequency: id(sensor_f) + temperature: id(sensor_t) +``` + +**Three-phase:** +```yaml +sunspec: + phases: 3 + ac_voltage_a: id(sensor_va) + ac_voltage_b: id(sensor_vb) + ac_voltage_c: id(sensor_vc) + ac_current_a: id(sensor_ia) # optional + ac_current_b: id(sensor_ib) # optional + ac_current_c: id(sensor_ic) # optional + ac_power: id(sensor_p) + ac_frequency: id(sensor_f) + temperature: id(sensor_t) +``` + +Shared keys (`ac_power`, `ac_frequency`, `temperature`, `energy_total`, all power limit options) work identically in both modes. + +**Python validation rules:** +- `ac_voltage_a/b/c` are rejected when `phases: 1` +- `ac_voltage` is rejected when `phases: 3` +- All three `ac_voltage_a/b/c` must appear together when `phases: 3` +- `ac_current_a/b/c` must be all-or-none when `phases: 3` + +## Register Layout + +3-phase uses **SunSpec Model 103** instead of Model 101. The block starts at the same address (40070) with the same length (50 registers), so the subsequent Model 120, Model 123, and end marker blocks are unaffected. + +| Address | Model 101 (1-phase) | Model 103 (3-phase) | +|---------|--------------------------|--------------------------| +| 40070 | Model ID = 101 | Model ID = 103 | +| 40071 | Length = 50 | Length = 50 | +| 40072 | Total current | Total current | +| 40073 | Phase A current | Phase A current | +| 40074 | 0xFFFF | Phase B current | +| 40075 | 0xFFFF | Phase C current | +| 40076 | Current SF = -2 | Current SF = -2 | +| 40077–79 | Line-to-line (0xFFFF) | Line-to-line (0xFFFF) | +| 40080 | Voltage AN | Voltage AN (phase A) | +| 40081 | 0xFFFF | Voltage BN (phase B) | +| 40082 | 0xFFFF | Voltage CN (phase C) | +| 40083–121 | Same as Model 101 | Same as Model 103 | + +**Current derivation (when no current sensors configured):** +- Per-phase current = `ac_power / 3 / voltage_x` (per phase) +- Total current = sum of derived phase currents + +## C++ Class Changes + +### `sunspec.h` additions + +New flag and per-phase sensor pointers: +```cpp +bool three_phase_{false}; +sensor::Sensor *ac_voltage_b_{nullptr}; +sensor::Sensor *ac_voltage_c_{nullptr}; +sensor::Sensor *ac_current_a_{nullptr}; +sensor::Sensor *ac_current_b_{nullptr}; +sensor::Sensor *ac_current_c_{nullptr}; +``` + +The existing `ac_voltage_` serves as phase A voltage in both modes. The existing `ac_current_` remains the single-phase current sensor. + +New setters: `set_three_phase()`, `set_ac_voltage_b()`, `set_ac_voltage_c()`, `set_ac_current_a()`, `set_ac_current_b()`, `set_ac_current_c()`. + +### `sunspec.cpp` changes + +- **`init_static_registers_()`:** writes `101` or `103` at 40070 based on `three_phase_` flag. +- **`setup()` validation:** when `three_phase_`, asserts `ac_voltage_b_` and `ac_voltage_c_` are non-null. +- **`refresh_sensors_()`:** branches on `three_phase_`: + - `false`: current behavior unchanged + - `true`: fills 40073–40075 (per-phase currents) and 40080–40082 (per-phase voltages), derives missing values as above +- **`dump_config()`:** logs phase count. + +No changes to TCP server, frame handling, or power limit logic. + +## Out of Scope + +- Split-phase (2-phase) support +- Per-phase energy metering +- Per-phase power reporting From 5fe63ee7f9d81f965d941dcd1ce96fda0e2e25c7 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Mon, 6 Apr 2026 21:55:55 +0200 Subject: [PATCH 02/11] docs: add three-phase support implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-04-06-three-phase-support.md | 714 ++++++++++++++++++ 1 file changed, 714 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-three-phase-support.md diff --git a/docs/superpowers/plans/2026-04-06-three-phase-support.md b/docs/superpowers/plans/2026-04-06-three-phase-support.md new file mode 100644 index 0000000..0154dde --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-three-phase-support.md @@ -0,0 +1,714 @@ +# Three-Phase Inverter Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `phases: 1|3` config key to the `sunspec` ESPHome component so 3-phase inverters are served as SunSpec Model 103 with per-phase voltages and currents. + +**Architecture:** A single `phases` key (default `1`) selects between Model 101 (single-phase) and Model 103 (three-phase). New per-phase sensor pointers are added to the C++ class; `refresh_sensors_()` branches on a `three_phase_` flag. Existing single-phase configs require zero changes. + +**Tech Stack:** ESPHome external component (Python `__init__.py` + C++ `.h`/`.cpp`), pytest, pymodbus (integration tests only). + +--- + +## File Map + +| File | Change | +|------|--------| +| `components/sunspec/__init__.py` | Add `phases`, `ac_voltage_a/b/c`, `ac_current_a/b/c` keys; add `_validate_phases()` | +| `components/sunspec/sunspec.h` | Add `three_phase_` flag, 5 new sensor pointers, 6 new setters | +| `components/sunspec/sunspec.cpp` | Update `init_static_registers_`, `setup`, `refresh_sensors_`, `dump_config` | +| `tests/test_schema.py` | Add `_validate_phases` unit tests | +| `tests/test_sunspec.py` | Add 3-phase integration tests (hardware, skipped without `--three-phase`) | + +--- + +## Task 1: Schema validation tests + +**Files:** +- Modify: `tests/test_schema.py` + +- [ ] **Step 1: Add the failing tests** + +Append to `tests/test_schema.py`: + +```python +# ---- _validate_phases tests ---- + +import sys as _sys +import os as _os +_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) + + +def _make_base_config(): + """Minimal valid shared keys for _validate_phases tests (no sensor IDs needed).""" + return { + "id": "sunspec", + "manufacturer": "Test", + "model": "T1", + "serial_number": "123", + "version": "1.0", + "rated_power": 5000, + "ac_power": "sensor_p", + "ac_frequency": "sensor_f", + "temperature": "sensor_t", + } + + +def test_validate_phases_1_requires_ac_voltage(): + """phases:1 without ac_voltage must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import _validate_phases, CONF_PHASES + + config = _make_base_config() + config[CONF_PHASES] = 1 + with pytest.raises(cv.Invalid, match="ac_voltage"): + _validate_phases(config) + + +def test_validate_phases_1_valid(): + """phases:1 with ac_voltage must pass.""" + import esphome.config_validation as cv + from components.sunspec import _validate_phases, CONF_PHASES, CONF_AC_VOLTAGE + + config = _make_base_config() + config[CONF_PHASES] = 1 + config[CONF_AC_VOLTAGE] = "sensor_v" + result = _validate_phases(config) + assert result[CONF_AC_VOLTAGE] == "sensor_v" + + +def test_validate_phases_1_rejects_ac_voltage_a(): + """phases:1 with ac_voltage_a must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import _validate_phases, CONF_PHASES, CONF_AC_VOLTAGE, CONF_AC_VOLTAGE_A + + config = _make_base_config() + config[CONF_PHASES] = 1 + config[CONF_AC_VOLTAGE] = "sensor_v" + config[CONF_AC_VOLTAGE_A] = "sensor_va" + with pytest.raises(cv.Invalid, match="ac_voltage_a"): + _validate_phases(config) + + +def test_validate_phases_3_valid_no_current(): + """phases:3 with all three voltages and no currents must pass.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_B] = "sensor_vb" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + result = _validate_phases(config) + assert result[CONF_AC_VOLTAGE_A] == "sensor_va" + + +def test_validate_phases_3_valid_with_currents(): + """phases:3 with all three voltages and all three currents must pass.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + CONF_AC_CURRENT_A, CONF_AC_CURRENT_B, CONF_AC_CURRENT_C, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_B] = "sensor_vb" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + config[CONF_AC_CURRENT_A] = "sensor_ia" + config[CONF_AC_CURRENT_B] = "sensor_ib" + config[CONF_AC_CURRENT_C] = "sensor_ic" + result = _validate_phases(config) + assert result[CONF_AC_CURRENT_A] == "sensor_ia" + + +def test_validate_phases_3_missing_voltage_b(): + """phases:3 missing ac_voltage_b must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_C, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + with pytest.raises(cv.Invalid, match="ac_voltage_b"): + _validate_phases(config) + + +def test_validate_phases_3_rejects_ac_voltage(): + """phases:3 with ac_voltage must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, CONF_AC_VOLTAGE, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE] = "sensor_v" + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_B] = "sensor_vb" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + with pytest.raises(cv.Invalid, match="ac_voltage"): + _validate_phases(config) + + +def test_validate_phases_3_partial_currents_rejected(): + """phases:3 with only ac_current_a (partial set) must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + CONF_AC_CURRENT_A, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_B] = "sensor_vb" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + config[CONF_AC_CURRENT_A] = "sensor_ia" + with pytest.raises(cv.Invalid, match="ac_current"): + _validate_phases(config) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd /Users/remco/git/bigdisplat/sunspec-esphome +python -m pytest tests/test_schema.py -v -k "validate_phases" +``` + +Expected: all 8 new tests FAIL with `ImportError` or `ImportError: cannot import name '_validate_phases'`. + +--- + +## Task 2: Python schema implementation + +**Files:** +- Modify: `components/sunspec/__init__.py` + +- [ ] **Step 1: Add new CONF constants** + +After the existing `CONF_POWER_LIMIT_NUMBER_ID = "power_limit_number_id"` line, add: + +```python +CONF_PHASES = "phases" +CONF_AC_VOLTAGE_A = "ac_voltage_a" +CONF_AC_VOLTAGE_B = "ac_voltage_b" +CONF_AC_VOLTAGE_C = "ac_voltage_c" +CONF_AC_CURRENT_A = "ac_current_a" +CONF_AC_CURRENT_B = "ac_current_b" +CONF_AC_CURRENT_C = "ac_current_c" +``` + +- [ ] **Step 2: Add `_validate_phases` function** + +After the `_validate_power_limit` function, add: + +```python +def _validate_phases(config): + phases = config.get(CONF_PHASES, 1) + if phases == 1: + if CONF_AC_VOLTAGE not in config: + raise cv.Invalid( + f"'{CONF_AC_VOLTAGE}' is required when phases: 1" + ) + for key in (CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + CONF_AC_CURRENT_A, CONF_AC_CURRENT_B, CONF_AC_CURRENT_C): + if key in config: + raise cv.Invalid( + f"'{key}' is not allowed when phases: 1" + ) + else: # phases == 3 + if CONF_AC_VOLTAGE in config: + raise cv.Invalid( + f"'{CONF_AC_VOLTAGE}' is not allowed when phases: 3; use ac_voltage_a" + ) + if CONF_AC_CURRENT in config: + raise cv.Invalid( + f"'{CONF_AC_CURRENT}' is not allowed when phases: 3; use ac_current_a/b/c" + ) + for key in (CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C): + if key not in config: + raise cv.Invalid( + f"'{key}' is required when phases: 3" + ) + present = [k for k in (CONF_AC_CURRENT_A, CONF_AC_CURRENT_B, CONF_AC_CURRENT_C) + if k in config] + if present and len(present) != 3: + raise cv.Invalid( + "ac_current_a, ac_current_b, and ac_current_c must all be configured or none" + ) + return config +``` + +- [ ] **Step 3: Update `CONFIG_SCHEMA`** + +Replace the existing `CONFIG_SCHEMA` block with: + +```python +CONFIG_SCHEMA = cv.All( + cv.only_with_esp_idf, + cv.Schema( + { + cv.GenerateID(): cv.declare_id(SunspecComponent), + cv.Required(CONF_MANUFACTURER): cv.All(cv.string, cv.Length(max=32)), + cv.Required(CONF_MODEL_NAME): cv.All(cv.string, cv.Length(max=32)), + cv.Required(CONF_SERIAL_NUMBER): cv.All(cv.string, cv.Length(max=32)), + cv.Required(CONF_VERSION): cv.All(cv.string, cv.Length(max=16)), + cv.Required(CONF_RATED_POWER): cv.All(cv.positive_int, cv.Range(max=32767)), + cv.Optional(CONF_PHASES, default=1): cv.one_of(1, 3, int=True), + # 1-phase sensors + cv.Optional(CONF_AC_VOLTAGE): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_CURRENT): cv.use_id(sensor.Sensor), + # 3-phase sensors + cv.Optional(CONF_AC_VOLTAGE_A): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_VOLTAGE_B): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_VOLTAGE_C): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_CURRENT_A): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_CURRENT_B): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_CURRENT_C): cv.use_id(sensor.Sensor), + # shared sensors + cv.Required(CONF_AC_POWER): cv.use_id(sensor.Sensor), + cv.Required(CONF_AC_FREQUENCY): cv.use_id(sensor.Sensor), + cv.Required(CONF_TEMPERATURE): cv.use_id(sensor.Sensor), + cv.Optional(CONF_ENERGY_TOTAL): cv.use_id(sensor.Sensor), + cv.Optional(CONF_MODBUS_CONTROLLER_ID): cv.use_id( + modbus_controller.ModbusController + ), + cv.Optional(CONF_POWER_LIMIT_REGISTER): cv.positive_int, + cv.Optional(CONF_POWER_LIMIT_NUMBER_ID): cv.use_id(number.Number), + } + ).extend(cv.COMPONENT_SCHEMA), + _validate_power_limit, + _validate_phases, +) +``` + +- [ ] **Step 4: Update `to_code`** + +Replace the existing `to_code` function with: + +```python +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_manufacturer(config[CONF_MANUFACTURER])) + cg.add(var.set_model(config[CONF_MODEL_NAME])) + cg.add(var.set_serial_number(config[CONF_SERIAL_NUMBER])) + cg.add(var.set_version(config[CONF_VERSION])) + cg.add(var.set_rated_power(config[CONF_RATED_POWER])) + + three_phase = config[CONF_PHASES] == 3 + cg.add(var.set_three_phase(three_phase)) + + if three_phase: + for conf_key, setter in [ + (CONF_AC_VOLTAGE_A, "set_ac_voltage"), + (CONF_AC_VOLTAGE_B, "set_ac_voltage_b"), + (CONF_AC_VOLTAGE_C, "set_ac_voltage_c"), + ]: + sens = await cg.get_variable(config[conf_key]) + cg.add(getattr(var, setter)(sens)) + if CONF_AC_CURRENT_A in config: + for conf_key, setter in [ + (CONF_AC_CURRENT_A, "set_ac_current_a"), + (CONF_AC_CURRENT_B, "set_ac_current_b"), + (CONF_AC_CURRENT_C, "set_ac_current_c"), + ]: + sens = await cg.get_variable(config[conf_key]) + cg.add(getattr(var, setter)(sens)) + else: + sens = await cg.get_variable(config[CONF_AC_VOLTAGE]) + cg.add(var.set_ac_voltage(sens)) + if CONF_AC_CURRENT in config: + sens = await cg.get_variable(config[CONF_AC_CURRENT]) + cg.add(var.set_ac_current(sens)) + + for conf_key, setter in [ + (CONF_AC_POWER, "set_ac_power"), + (CONF_AC_FREQUENCY, "set_ac_frequency"), + (CONF_TEMPERATURE, "set_temperature"), + ]: + sens = await cg.get_variable(config[conf_key]) + cg.add(getattr(var, setter)(sens)) + + if CONF_ENERGY_TOTAL in config: + sens = await cg.get_variable(config[CONF_ENERGY_TOTAL]) + cg.add(var.set_energy_total(sens)) + + if CONF_MODBUS_CONTROLLER_ID in config: + ctrl = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) + cg.add(var.set_modbus_controller(ctrl)) + + if CONF_POWER_LIMIT_REGISTER in config: + cg.add(var.set_power_limit_register(config[CONF_POWER_LIMIT_REGISTER])) + + if CONF_POWER_LIMIT_NUMBER_ID in config: + num = await cg.get_variable(config[CONF_POWER_LIMIT_NUMBER_ID]) + cg.add(var.set_power_limit_number(num)) +``` + +- [ ] **Step 5: Run schema tests to verify they pass** + +```bash +python -m pytest tests/test_schema.py -v +``` + +Expected: all tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add components/sunspec/__init__.py tests/test_schema.py +git commit -m "feat: add phases config key and 3-phase schema validation" +``` + +--- + +## Task 3: C++ header additions + +**Files:** +- Modify: `components/sunspec/sunspec.h` + +- [ ] **Step 1: Add new setters** + +In `sunspec.h`, after the `set_energy_total` setter line, add: + +```cpp + void set_three_phase(bool v) { this->three_phase_ = v; } + void set_ac_voltage_b(sensor::Sensor *s) { this->ac_voltage_b_ = s; } + void set_ac_voltage_c(sensor::Sensor *s) { this->ac_voltage_c_ = s; } + void set_ac_current_a(sensor::Sensor *s) { this->ac_current_a_ = s; } + void set_ac_current_b(sensor::Sensor *s) { this->ac_current_b_ = s; } + void set_ac_current_c(sensor::Sensor *s) { this->ac_current_c_ = s; } +``` + +- [ ] **Step 2: Add new protected members** + +In `sunspec.h`, after the `energy_total_` member line, add: + +```cpp + bool three_phase_{false}; + sensor::Sensor *ac_voltage_b_{nullptr}; + sensor::Sensor *ac_voltage_c_{nullptr}; + sensor::Sensor *ac_current_a_{nullptr}; + sensor::Sensor *ac_current_b_{nullptr}; + sensor::Sensor *ac_current_c_{nullptr}; +``` + +- [ ] **Step 3: Commit** + +```bash +git add components/sunspec/sunspec.h +git commit -m "feat: add 3-phase sensor pointers and setters to SunspecComponent" +``` + +--- + +## Task 4: C++ static register init, setup validation, and dump_config + +**Files:** +- Modify: `components/sunspec/sunspec.cpp` + +- [ ] **Step 1: Update `init_static_registers_` model ID** + +In `sunspec.cpp`, find this line inside `init_static_registers_()`: + +```cpp + this->set_reg(40070, 101); // Model ID +``` + +Replace with: + +```cpp + this->set_reg(40070, this->three_phase_ ? 103 : 101); // Model ID: 101=single-phase, 103=three-phase +``` + +- [ ] **Step 2: Add 3-phase sensor validation in `setup()`** + +In `sunspec.cpp`, inside `setup()`, after the existing sensor null-check block: + +```cpp + if (!this->ac_power_ || !this->ac_voltage_ || !this->ac_frequency_ || !this->temperature_) { + ESP_LOGE(TAG, "One or more required sensors are not configured"); + this->mark_failed(); + return; + } +``` + +Add immediately after: + +```cpp + if (this->three_phase_ && (!this->ac_voltage_b_ || !this->ac_voltage_c_)) { + ESP_LOGE(TAG, "ac_voltage_b and ac_voltage_c are required when phases: 3"); + this->mark_failed(); + return; + } +``` + +- [ ] **Step 3: Log phase count in `dump_config()`** + +In `dump_config()`, after the `Rated Power` log line, add: + +```cpp + ESP_LOGCONFIG(TAG, " Phases: %d", this->three_phase_ ? 3 : 1); +``` + +- [ ] **Step 4: Write integration tests for 3-phase model ID (hardware test)** + +In `tests/test_sunspec.py`, add a `three_phase` fixture and a model-ID test at the end of the file: + +```python +@pytest.fixture(scope="module") +def three_phase(request): + """Skip unless --device-ip and --three-phase are both provided.""" + ip = request.config.getoption("--device-ip") + flag = request.config.getoption("--three-phase", default=False) + if ip is None or not flag: + pytest.skip("--device-ip and --three-phase required") + c = ModbusTcpClient(ip, port=502) + c.connect() + yield c + c.close() + + +def pytest_addoption_three_phase(parser): + parser.addoption("--three-phase", action="store_true", default=False, + help="Run 3-phase inverter tests") + + +def test_model103_id(three_phase): + """3-phase: inverter block model ID must be 103.""" + rr = three_phase.read_holding_registers(40070, 1, slave=1) + assert not rr.isError() + assert rr.registers[0] == 103, f"Expected 103, got {rr.registers[0]}" +``` + +Also add `--three-phase` option to the existing `pytest_addoption` function in `test_sunspec.py`: + +```python +def pytest_addoption(parser): + parser.addoption("--device-ip", default=None, help="ESP32 device IP address") + parser.addoption("--three-phase", action="store_true", default=False, + help="Run 3-phase inverter tests") +``` + +(Remove the standalone `pytest_addoption_three_phase` function — it was only shown above for clarity; the option must be in the single `pytest_addoption`.) + +- [ ] **Step 5: Run schema tests to make sure nothing is broken** + +```bash +python -m pytest tests/test_schema.py -v +``` + +Expected: all tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add components/sunspec/sunspec.cpp tests/test_sunspec.py +git commit -m "feat: Model 103 register init, setup validation, dump_config for 3-phase" +``` + +--- + +## Task 5: C++ `refresh_sensors_` 3-phase branch + +**Files:** +- Modify: `components/sunspec/sunspec.cpp` + +- [ ] **Step 1: Replace `refresh_sensors_` with the branching version** + +In `sunspec.cpp`, replace the entire `refresh_sensors_()` function body with: + +```cpp +void SunspecComponent::refresh_sensors_() { + if (this->three_phase_) { + // ---- Three-phase (Model 103) ---- + + // Phase voltages (SF=-1): VAN, VBN, VCN at 40080-40082 + this->set_reg(40080, (uint16_t) to_sf(this->ac_voltage_->get_state(), -1)); + this->set_reg(40081, (uint16_t) to_sf(this->ac_voltage_b_->get_state(), -1)); + this->set_reg(40082, (uint16_t) to_sf(this->ac_voltage_c_->get_state(), -1)); + + if (this->ac_current_a_) { + // Sensor-provided per-phase currents (SF=-2) + float ia = this->ac_current_a_->get_state(); + float ib = this->ac_current_b_->get_state(); + float ic = this->ac_current_c_->get_state(); + float total = (!std::isnan(ia) ? ia : 0.0f) + + (!std::isnan(ib) ? ib : 0.0f) + + (!std::isnan(ic) ? ic : 0.0f); + this->set_reg(40072, (uint16_t) to_sf(total, -2)); + this->set_reg(40073, (uint16_t) to_sf(ia, -2)); + this->set_reg(40074, (uint16_t) to_sf(ib, -2)); + this->set_reg(40075, (uint16_t) to_sf(ic, -2)); + } else { + // Derive per-phase current: P/3 / V_phase + float pwr = this->ac_power_->get_state(); + float va = this->ac_voltage_->get_state(); + float vb = this->ac_voltage_b_->get_state(); + float vc = this->ac_voltage_c_->get_state(); + float phase_pwr = !std::isnan(pwr) ? pwr / 3.0f : NAN; + + auto derive_curr = [&](float v) -> int16_t { + if (!std::isnan(phase_pwr) && !std::isnan(v) && v > 1.0f) + return to_sf(phase_pwr / v, -2); + return (int16_t) 0x8000; + }; + + int16_t ia = derive_curr(va); + int16_t ib = derive_curr(vb); + int16_t ic = derive_curr(vc); + + bool voltages_valid = !std::isnan(va) && !std::isnan(vb) && !std::isnan(vc) + && (va + vb + vc) > 3.0f; + uint16_t total_reg; + if (!std::isnan(pwr) && voltages_valid) { + float avg_v = (va + vb + vc) / 3.0f; + total_reg = (uint16_t) to_sf(pwr / avg_v, -2); + } else { + total_reg = 0x8000; + } + + this->set_reg(40072, total_reg); + this->set_reg(40073, (uint16_t) ia); + this->set_reg(40074, (uint16_t) ib); + this->set_reg(40075, (uint16_t) ic); + } + + } else { + // ---- Single-phase (Model 101) ---- + + // AC current (SF=-2): derive from power/voltage if no current sensor + if (this->ac_current_) { + int16_t curr = to_sf(this->ac_current_->get_state(), -2); + this->set_reg(40072, (uint16_t) curr); + this->set_reg(40073, (uint16_t) curr); + } else { + float pwr = this->ac_power_->get_state(); + float volt = this->ac_voltage_->get_state(); + if (!std::isnan(pwr) && !std::isnan(volt) && volt > 1.0f) { + int16_t curr = to_sf(pwr / volt, -2); + this->set_reg(40072, (uint16_t) curr); + this->set_reg(40073, (uint16_t) curr); + } else { + this->set_reg(40072, 0x8000); + this->set_reg(40073, 0x8000); + } + } + + // AC voltage (SF=-1) + this->set_reg(40080, (uint16_t) to_sf(this->ac_voltage_->get_state(), -1)); + } + + // ---- Shared registers (both modes) ---- + + // AC power (SF=0) + this->set_reg(40084, (uint16_t) to_sf(this->ac_power_->get_state(), 0)); + + // AC frequency (SF=-2) + this->set_reg(40086, (uint16_t) to_sf(this->ac_frequency_->get_state(), -2)); + + // Energy (uint32, SF=0) + if (this->energy_total_) { + float e = this->energy_total_->get_state(); + if (!std::isnan(e) && e >= 0) { + uint32_t wh = (uint32_t)(e * 1000.0f); + this->set_reg(40094, (uint16_t)(wh >> 16)); + this->set_reg(40095, (uint16_t)(wh & 0xFFFF)); + } else { + this->set_reg(40094, 0); + this->set_reg(40095, 0); + } + } + + // Temperature (SF=-1) + this->set_reg(40103, (uint16_t) to_sf(this->temperature_->get_state(), -1)); + + // Inverter state: MPPT (4) if producing, else Off (1) + float pwr = this->ac_power_->get_state(); + this->set_reg(40108, (!std::isnan(pwr) && pwr > 5.0f) ? 4 : 1); +} +``` + +- [ ] **Step 2: Add 3-phase integration tests for per-phase registers** + +Append to `tests/test_sunspec.py`: + +```python +def test_model103_phase_voltages_not_ffff(three_phase): + """3-phase: VAN/VBN/VCN (40080-40082) must not be 0xFFFF when sensors connected.""" + rr = three_phase.read_holding_registers(40080, 3, slave=1) + assert not rr.isError() + for i, reg in enumerate(rr.registers): + assert reg != 0xFFFF, f"Voltage register 4008{i} is 0xFFFF (not implemented)" + + +def test_model103_phase_currents_not_ffff(three_phase): + """3-phase: phase A/B/C currents (40073-40075) must not be 0xFFFF when inverter active.""" + rr = three_phase.read_holding_registers(40073, 3, slave=1) + assert not rr.isError() + for i, reg in enumerate(rr.registers): + assert reg != 0xFFFF, f"Current register 4007{3+i} is 0xFFFF (not implemented)" + + +def test_model103_total_current_matches_sum(three_phase): + """3-phase: total current (40072) should be consistent with phase sum.""" + rr = three_phase.read_holding_registers(40072, 4, slave=1) + assert not rr.isError() + total, ia, ib, ic = rr.registers + # All values must be set (not NaN sentinel 0x8000) + assert total != 0x8000 + assert ia != 0x8000 + assert ib != 0x8000 + assert ic != 0x8000 +``` + +- [ ] **Step 3: Run schema tests to make sure nothing is broken** + +```bash +python -m pytest tests/test_schema.py -v +``` + +Expected: all tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add components/sunspec/sunspec.cpp tests/test_sunspec.py +git commit -m "feat: 3-phase refresh_sensors branch with per-phase voltage and current" +``` + +--- + +## Self-Review Checklist + +- **Spec coverage:** + - `phases: 1|3` YAML key → Task 2 ✓ + - `ac_voltage_a/b/c` required for 3-phase → Tasks 1+2 ✓ + - `ac_current_a/b/c` optional all-or-none → Tasks 1+2 ✓ + - `ac_voltage`/`ac_current` rejected for 3-phase → Tasks 1+2 ✓ + - Backward-compatible (existing 1-phase configs unchanged) → Task 2 (`phases` defaults to `1`) ✓ + - Model 103 written to register 40070 → Task 4 ✓ + - Per-phase voltages at 40080-40082 → Task 5 ✓ + - Per-phase currents at 40073-40075 → Task 5 ✓ + - Derived current when no sensors → Task 5 ✓ + - `dump_config` logs phase count → Task 4 ✓ + - `setup()` validates 3-phase sensor pointers → Task 4 ✓ + +- **No placeholders:** all steps contain complete code. + +- **Type consistency:** `set_ac_voltage` (phase A) is used in both `to_code` (Task 2) and C++ (Task 3 — existing setter, unchanged). New setters `set_ac_voltage_b/c`, `set_ac_current_a/b/c` defined in Task 3 and called in Task 2 `to_code`. From e4142c65f60d0889ab6600f0684b4f9bd9fe4884 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Mon, 6 Apr 2026 21:59:22 +0200 Subject: [PATCH 03/11] test: add schema validation tests for _validate_phases Add 8 comprehensive test cases for the _validate_phases validator function that will be implemented in Task 2. Tests cover: - Single-phase (phases:1) validation: - Requires ac_voltage - Rejects ac_voltage_a, ac_voltage_b, ac_voltage_c - Three-phase (phases:3) validation: - Requires all three ac_voltage_a, ac_voltage_b, ac_voltage_c - Rejects single-phase ac_voltage - Optional currents (all three or none) - Rejects partial current sets Tests use a helper function _make_base_config() to build minimal valid configs, reducing boilerplate. Tests are expected to fail with ImportError until the constants and _validate_phases function are defined. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_schema.py | 149 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/tests/test_schema.py b/tests/test_schema.py index b5c7f0f..0cf0279 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -25,3 +25,152 @@ def test_manufacturer_is_string(): assert cv.string("Solis") == "Solis" with pytest.raises(cv.Invalid): cv.string(123) + + +# ---- _validate_phases tests ---- + +import sys as _sys +import os as _os +_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) + + +def _make_base_config(): + """Minimal valid shared keys for _validate_phases tests (no sensor IDs needed).""" + return { + "id": "sunspec", + "manufacturer": "Test", + "model": "T1", + "serial_number": "123", + "version": "1.0", + "rated_power": 5000, + "ac_power": "sensor_p", + "ac_frequency": "sensor_f", + "temperature": "sensor_t", + } + + +def test_validate_phases_1_requires_ac_voltage(): + """phases:1 without ac_voltage must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import _validate_phases, CONF_PHASES + + config = _make_base_config() + config[CONF_PHASES] = 1 + with pytest.raises(cv.Invalid, match="ac_voltage"): + _validate_phases(config) + + +def test_validate_phases_1_valid(): + """phases:1 with ac_voltage must pass.""" + import esphome.config_validation as cv + from components.sunspec import _validate_phases, CONF_PHASES, CONF_AC_VOLTAGE + + config = _make_base_config() + config[CONF_PHASES] = 1 + config[CONF_AC_VOLTAGE] = "sensor_v" + result = _validate_phases(config) + assert result[CONF_AC_VOLTAGE] == "sensor_v" + + +def test_validate_phases_1_rejects_ac_voltage_a(): + """phases:1 with ac_voltage_a must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import _validate_phases, CONF_PHASES, CONF_AC_VOLTAGE, CONF_AC_VOLTAGE_A + + config = _make_base_config() + config[CONF_PHASES] = 1 + config[CONF_AC_VOLTAGE] = "sensor_v" + config[CONF_AC_VOLTAGE_A] = "sensor_va" + with pytest.raises(cv.Invalid, match="ac_voltage_a"): + _validate_phases(config) + + +def test_validate_phases_3_valid_no_current(): + """phases:3 with all three voltages and no currents must pass.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_B] = "sensor_vb" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + result = _validate_phases(config) + assert result[CONF_AC_VOLTAGE_A] == "sensor_va" + + +def test_validate_phases_3_valid_with_currents(): + """phases:3 with all three voltages and all three currents must pass.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + CONF_AC_CURRENT_A, CONF_AC_CURRENT_B, CONF_AC_CURRENT_C, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_B] = "sensor_vb" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + config[CONF_AC_CURRENT_A] = "sensor_ia" + config[CONF_AC_CURRENT_B] = "sensor_ib" + config[CONF_AC_CURRENT_C] = "sensor_ic" + result = _validate_phases(config) + assert result[CONF_AC_CURRENT_A] == "sensor_ia" + + +def test_validate_phases_3_missing_voltage_b(): + """phases:3 missing ac_voltage_b must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_C, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + with pytest.raises(cv.Invalid, match="ac_voltage_b"): + _validate_phases(config) + + +def test_validate_phases_3_rejects_ac_voltage(): + """phases:3 with ac_voltage must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, CONF_AC_VOLTAGE, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE] = "sensor_v" + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_B] = "sensor_vb" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + with pytest.raises(cv.Invalid, match="ac_voltage"): + _validate_phases(config) + + +def test_validate_phases_3_partial_currents_rejected(): + """phases:3 with only ac_current_a (partial set) must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + CONF_AC_CURRENT_A, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_B] = "sensor_vb" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + config[CONF_AC_CURRENT_A] = "sensor_ia" + with pytest.raises(cv.Invalid, match="ac_current"): + _validate_phases(config) From 8b876535085e615296127dfe70f975e2c36dfda2 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Mon, 6 Apr 2026 22:02:56 +0200 Subject: [PATCH 04/11] fix: address code review findings in test_schema.py - Move sys.path.insert block to top of file, immediately after imports - Add missing test for phases:3 rejecting single-phase ac_current sensor - Fixes issue where sys.path manipulation was mid-file instead of adjacent to imports - Adds test coverage for _validate_phases rejecting ac_current in 3-phase config Co-Authored-By: Claude Sonnet 4.6 --- tests/test_schema.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/test_schema.py b/tests/test_schema.py index 0cf0279..b3e3313 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,5 +1,8 @@ """Schema validation tests — run without hardware.""" import pytest +import sys as _sys +import os as _os +_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) def test_rated_power_max(): @@ -29,10 +32,6 @@ def test_manufacturer_is_string(): # ---- _validate_phases tests ---- -import sys as _sys -import os as _os -_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) - def _make_base_config(): """Minimal valid shared keys for _validate_phases tests (no sensor IDs needed).""" @@ -157,6 +156,24 @@ def test_validate_phases_3_rejects_ac_voltage(): _validate_phases(config) +def test_validate_phases_3_rejects_ac_current(): + """phases:3 with ac_current (single-phase key) must raise Invalid.""" + import esphome.config_validation as cv + from components.sunspec import ( + _validate_phases, CONF_PHASES, CONF_AC_CURRENT, + CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + ) + + config = _make_base_config() + config[CONF_PHASES] = 3 + config[CONF_AC_VOLTAGE_A] = "sensor_va" + config[CONF_AC_VOLTAGE_B] = "sensor_vb" + config[CONF_AC_VOLTAGE_C] = "sensor_vc" + config[CONF_AC_CURRENT] = "sensor_i" + with pytest.raises(cv.Invalid, match="ac_current"): + _validate_phases(config) + + def test_validate_phases_3_partial_currents_rejected(): """phases:3 with only ac_current_a (partial set) must raise Invalid.""" import esphome.config_validation as cv From af1f4ec51117b323a9239e0e699dc4b5b60645a8 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Apr 2026 20:22:59 +0200 Subject: [PATCH 05/11] feat: add phases config key and 3-phase schema validation --- components/sunspec/__init__.py | 89 +++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/components/sunspec/__init__.py b/components/sunspec/__init__.py index 61a4805..a3421ad 100644 --- a/components/sunspec/__init__.py +++ b/components/sunspec/__init__.py @@ -21,6 +21,13 @@ CONF_MODBUS_CONTROLLER_ID = "modbus_controller_id" CONF_POWER_LIMIT_REGISTER = "power_limit_register" CONF_POWER_LIMIT_NUMBER_ID = "power_limit_number_id" +CONF_PHASES = "phases" +CONF_AC_VOLTAGE_A = "ac_voltage_a" +CONF_AC_VOLTAGE_B = "ac_voltage_b" +CONF_AC_VOLTAGE_C = "ac_voltage_c" +CONF_AC_CURRENT_A = "ac_current_a" +CONF_AC_CURRENT_B = "ac_current_b" +CONF_AC_CURRENT_C = "ac_current_c" def _validate_power_limit(config): @@ -37,6 +44,42 @@ def _validate_power_limit(config): return config +def _validate_phases(config): + phases = config.get(CONF_PHASES, 1) + if phases == 1: + if CONF_AC_VOLTAGE not in config: + raise cv.Invalid( + f"'{CONF_AC_VOLTAGE}' is required when phases: 1" + ) + for key in (CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C, + CONF_AC_CURRENT_A, CONF_AC_CURRENT_B, CONF_AC_CURRENT_C): + if key in config: + raise cv.Invalid( + f"'{key}' is not allowed when phases: 1" + ) + else: # phases == 3 + if CONF_AC_VOLTAGE in config: + raise cv.Invalid( + f"'{CONF_AC_VOLTAGE}' is not allowed when phases: 3; use ac_voltage_a" + ) + if CONF_AC_CURRENT in config: + raise cv.Invalid( + f"'{CONF_AC_CURRENT}' is not allowed when phases: 3; use ac_current_a/b/c" + ) + for key in (CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C): + if key not in config: + raise cv.Invalid( + f"'{key}' is required when phases: 3" + ) + present = [k for k in (CONF_AC_CURRENT_A, CONF_AC_CURRENT_B, CONF_AC_CURRENT_C) + if k in config] + if present and len(present) != 3: + raise cv.Invalid( + "ac_current_a, ac_current_b, and ac_current_c must all be configured or none" + ) + return config + + CONFIG_SCHEMA = cv.All( cv.only_on_esp32, cv.Schema( @@ -48,9 +91,19 @@ def _validate_power_limit(config): cv.Required(CONF_SERIAL_NUMBER): cv.All(cv.string, cv.Length(max=32)), cv.Required(CONF_VERSION): cv.All(cv.string, cv.Length(max=16)), cv.Required(CONF_RATED_POWER): cv.All(cv.positive_int, cv.Range(max=32767)), - cv.Required(CONF_AC_POWER): cv.use_id(sensor.Sensor), - cv.Required(CONF_AC_VOLTAGE): cv.use_id(sensor.Sensor), + cv.Optional(CONF_PHASES, default=1): cv.one_of(1, 3, int=True), + # 1-phase sensors + cv.Optional(CONF_AC_VOLTAGE): cv.use_id(sensor.Sensor), cv.Optional(CONF_AC_CURRENT): cv.use_id(sensor.Sensor), + # 3-phase sensors + cv.Optional(CONF_AC_VOLTAGE_A): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_VOLTAGE_B): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_VOLTAGE_C): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_CURRENT_A): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_CURRENT_B): cv.use_id(sensor.Sensor), + cv.Optional(CONF_AC_CURRENT_C): cv.use_id(sensor.Sensor), + # shared sensors + cv.Required(CONF_AC_POWER): cv.use_id(sensor.Sensor), cv.Required(CONF_AC_FREQUENCY): cv.use_id(sensor.Sensor), cv.Required(CONF_TEMPERATURE): cv.use_id(sensor.Sensor), cv.Optional(CONF_ENERGY_TOTAL): cv.use_id(sensor.Sensor), @@ -62,6 +115,7 @@ def _validate_power_limit(config): } ).extend(cv.COMPONENT_SCHEMA), _validate_power_limit, + _validate_phases, ) @@ -75,19 +129,40 @@ async def to_code(config): cg.add(var.set_version(config[CONF_VERSION])) cg.add(var.set_rated_power(config[CONF_RATED_POWER])) + three_phase = config[CONF_PHASES] == 3 + cg.add(var.set_three_phase(three_phase)) + + if three_phase: + for conf_key, setter in [ + (CONF_AC_VOLTAGE_A, "set_ac_voltage"), + (CONF_AC_VOLTAGE_B, "set_ac_voltage_b"), + (CONF_AC_VOLTAGE_C, "set_ac_voltage_c"), + ]: + sens = await cg.get_variable(config[conf_key]) + cg.add(getattr(var, setter)(sens)) + if CONF_AC_CURRENT_A in config: + for conf_key, setter in [ + (CONF_AC_CURRENT_A, "set_ac_current_a"), + (CONF_AC_CURRENT_B, "set_ac_current_b"), + (CONF_AC_CURRENT_C, "set_ac_current_c"), + ]: + sens = await cg.get_variable(config[conf_key]) + cg.add(getattr(var, setter)(sens)) + else: + sens = await cg.get_variable(config[CONF_AC_VOLTAGE]) + cg.add(var.set_ac_voltage(sens)) + if CONF_AC_CURRENT in config: + sens = await cg.get_variable(config[CONF_AC_CURRENT]) + cg.add(var.set_ac_current(sens)) + for conf_key, setter in [ (CONF_AC_POWER, "set_ac_power"), - (CONF_AC_VOLTAGE, "set_ac_voltage"), (CONF_AC_FREQUENCY, "set_ac_frequency"), (CONF_TEMPERATURE, "set_temperature"), ]: sens = await cg.get_variable(config[conf_key]) cg.add(getattr(var, setter)(sens)) - if CONF_AC_CURRENT in config: - sens = await cg.get_variable(config[CONF_AC_CURRENT]) - cg.add(var.set_ac_current(sens)) - if CONF_ENERGY_TOTAL in config: sens = await cg.get_variable(config[CONF_ENERGY_TOTAL]) cg.add(var.set_energy_total(sens)) From 19f633f0f8cf1d8cfafa4ed81690076597e8287a Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Apr 2026 20:23:17 +0200 Subject: [PATCH 06/11] feat: add 3-phase sensor pointers and setters to SunspecComponent --- components/sunspec/sunspec.h | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/components/sunspec/sunspec.h b/components/sunspec/sunspec.h index 5649ced..4f3efc7 100644 --- a/components/sunspec/sunspec.h +++ b/components/sunspec/sunspec.h @@ -48,6 +48,13 @@ class SunspecComponent : public Component { void set_temperature(sensor::Sensor *s) { this->temperature_ = s; } void set_energy_total(sensor::Sensor *s) { this->energy_total_ = s; } + void set_three_phase(bool v) { this->three_phase_ = v; } + void set_ac_voltage_b(sensor::Sensor *s) { this->ac_voltage_b_ = s; } + void set_ac_voltage_c(sensor::Sensor *s) { this->ac_voltage_c_ = s; } + void set_ac_current_a(sensor::Sensor *s) { this->ac_current_a_ = s; } + void set_ac_current_b(sensor::Sensor *s) { this->ac_current_b_ = s; } + void set_ac_current_c(sensor::Sensor *s) { this->ac_current_c_ = s; } + void set_modbus_controller(modbus_controller::ModbusController *ctrl) { this->controller_ = ctrl; } void set_power_limit_register(uint16_t reg) { this->power_limit_register_ = reg; } void set_power_limit_number(number::Number *n) { this->power_limit_number_ = n; } @@ -68,6 +75,13 @@ class SunspecComponent : public Component { sensor::Sensor *temperature_{nullptr}; sensor::Sensor *energy_total_{nullptr}; + bool three_phase_{false}; + sensor::Sensor *ac_voltage_b_{nullptr}; + sensor::Sensor *ac_voltage_c_{nullptr}; + sensor::Sensor *ac_current_a_{nullptr}; + sensor::Sensor *ac_current_b_{nullptr}; + sensor::Sensor *ac_current_c_{nullptr}; + // Modbus write-back modbus_controller::ModbusController *controller_{nullptr}; uint16_t power_limit_register_{0}; From f64462bfc1181af8d953f7c1c5eedfd43a6db821 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Apr 2026 20:24:12 +0200 Subject: [PATCH 07/11] feat: Model 103 register init, setup validation, dump_config for 3-phase --- components/sunspec/sunspec.cpp | 9 ++++++++- tests/test_sunspec.py | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/components/sunspec/sunspec.cpp b/components/sunspec/sunspec.cpp index 878e465..33aa802 100644 --- a/components/sunspec/sunspec.cpp +++ b/components/sunspec/sunspec.cpp @@ -59,7 +59,7 @@ void SunspecComponent::init_static_registers_() { this->set_reg(40069, 0xFFFF); // Padding // --- Inverter Block (Model 101) --- - this->set_reg(40070, 101); // Model ID + this->set_reg(40070, this->three_phase_ ? 103 : 101); // Model ID: 101=single-phase, 103=three-phase this->set_reg(40071, 50); // Length // 40072-40075: current -- updated in refresh_sensors_() this->set_reg(40076, (uint16_t)(int16_t)(-2)); // Current SF = -2 @@ -121,6 +121,12 @@ void SunspecComponent::setup() { return; } + if (this->three_phase_ && (!this->ac_voltage_b_ || !this->ac_voltage_c_)) { + ESP_LOGE(TAG, "ac_voltage_b and ac_voltage_c are required when phases: 3"); + this->mark_failed(); + return; + } + // 2. Initialise register bank this->init_static_registers_(); @@ -176,6 +182,7 @@ void SunspecComponent::dump_config() { ESP_LOGCONFIG(TAG, " Serial: %s", this->serial_number_.c_str()); ESP_LOGCONFIG(TAG, " Version: %s", this->version_.c_str()); ESP_LOGCONFIG(TAG, " Rated Power: %u W", this->rated_power_); + ESP_LOGCONFIG(TAG, " Phases: %d", this->three_phase_ ? 3 : 1); ESP_LOGCONFIG(TAG, " Port: 502"); if (this->is_failed()) { ESP_LOGE(TAG, " Setup failed!"); diff --git a/tests/test_sunspec.py b/tests/test_sunspec.py index a12e130..b596c37 100644 --- a/tests/test_sunspec.py +++ b/tests/test_sunspec.py @@ -8,6 +8,8 @@ def pytest_addoption(parser): parser.addoption("--device-ip", default=None, help="ESP32 device IP address") + parser.addoption("--three-phase", action="store_true", default=False, + help="Run 3-phase inverter tests") @pytest.fixture(scope="module") @@ -191,3 +193,23 @@ def test_power_limit_disable_restores_full_power(client): rr = client.read_holding_registers(40155, 1, slave=1) assert not rr.isError() assert rr.registers[0] == 100 + + +@pytest.fixture(scope="module") +def three_phase(request): + """Skip unless --device-ip and --three-phase are both provided.""" + ip = request.config.getoption("--device-ip") + flag = request.config.getoption("--three-phase", default=False) + if ip is None or not flag: + pytest.skip("--device-ip and --three-phase required") + c = ModbusTcpClient(ip, port=502) + c.connect() + yield c + c.close() + + +def test_model103_id(three_phase): + """3-phase: inverter block model ID must be 103.""" + rr = three_phase.read_holding_registers(40070, 1, slave=1) + assert not rr.isError() + assert rr.registers[0] == 103, f"Expected 103, got {rr.registers[0]}" From 8450f742d42a8d58083cedc5585f27ecb07b42a3 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Apr 2026 20:24:56 +0200 Subject: [PATCH 08/11] feat: 3-phase refresh_sensors branch with per-phase voltage and current --- components/sunspec/sunspec.cpp | 93 +++++++++++++++++++++++++++------- tests/test_sunspec.py | 28 ++++++++++ 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/components/sunspec/sunspec.cpp b/components/sunspec/sunspec.cpp index 33aa802..19fedb0 100644 --- a/components/sunspec/sunspec.cpp +++ b/components/sunspec/sunspec.cpp @@ -240,32 +240,91 @@ void SunspecComponent::close_client_(Client &c) { // ---------- sensor refresh ---------- void SunspecComponent::refresh_sensors_() { - // AC current (SF=-2): value x 100, same on total and phase A. - // If no current sensor, derive from power / voltage (assuming unity power factor). - if (this->ac_current_) { - int16_t curr = to_sf(this->ac_current_->get_state(), -2); - this->set_reg(40072, (uint16_t) curr); // AC total current - this->set_reg(40073, (uint16_t) curr); // Phase A current + if (this->three_phase_) { + // ---- Three-phase (Model 103) ---- + + // Phase voltages (SF=-1): VAN, VBN, VCN at 40080-40082 + this->set_reg(40080, (uint16_t) to_sf(this->ac_voltage_->get_state(), -1)); + this->set_reg(40081, (uint16_t) to_sf(this->ac_voltage_b_->get_state(), -1)); + this->set_reg(40082, (uint16_t) to_sf(this->ac_voltage_c_->get_state(), -1)); + + if (this->ac_current_a_) { + // Sensor-provided per-phase currents (SF=-2) + float ia = this->ac_current_a_->get_state(); + float ib = this->ac_current_b_->get_state(); + float ic = this->ac_current_c_->get_state(); + float total = (!std::isnan(ia) ? ia : 0.0f) + + (!std::isnan(ib) ? ib : 0.0f) + + (!std::isnan(ic) ? ic : 0.0f); + this->set_reg(40072, (uint16_t) to_sf(total, -2)); + this->set_reg(40073, (uint16_t) to_sf(ia, -2)); + this->set_reg(40074, (uint16_t) to_sf(ib, -2)); + this->set_reg(40075, (uint16_t) to_sf(ic, -2)); + } else { + // Derive per-phase current: P/3 / V_phase + float pwr = this->ac_power_->get_state(); + float va = this->ac_voltage_->get_state(); + float vb = this->ac_voltage_b_->get_state(); + float vc = this->ac_voltage_c_->get_state(); + float phase_pwr = !std::isnan(pwr) ? pwr / 3.0f : NAN; + + auto derive_curr = [&](float v) -> int16_t { + if (!std::isnan(phase_pwr) && !std::isnan(v) && v > 1.0f) + return to_sf(phase_pwr / v, -2); + return (int16_t) 0x8000; + }; + + int16_t ia = derive_curr(va); + int16_t ib = derive_curr(vb); + int16_t ic = derive_curr(vc); + + bool voltages_valid = !std::isnan(va) && !std::isnan(vb) && !std::isnan(vc) + && (va + vb + vc) > 3.0f; + uint16_t total_reg; + if (!std::isnan(pwr) && voltages_valid) { + float avg_v = (va + vb + vc) / 3.0f; + total_reg = (uint16_t) to_sf(pwr / avg_v, -2); + } else { + total_reg = 0x8000; + } + + this->set_reg(40072, total_reg); + this->set_reg(40073, (uint16_t) ia); + this->set_reg(40074, (uint16_t) ib); + this->set_reg(40075, (uint16_t) ic); + } + } else { - float pwr = this->ac_power_->get_state(); - float volt = this->ac_voltage_->get_state(); - if (!std::isnan(pwr) && !std::isnan(volt) && volt > 1.0f) { - int16_t curr = to_sf(pwr / volt, -2); + // ---- Single-phase (Model 101) ---- + + // AC current (SF=-2): derive from power/voltage if no current sensor + if (this->ac_current_) { + int16_t curr = to_sf(this->ac_current_->get_state(), -2); this->set_reg(40072, (uint16_t) curr); this->set_reg(40073, (uint16_t) curr); } else { - this->set_reg(40072, 0x8000); - this->set_reg(40073, 0x8000); + float pwr = this->ac_power_->get_state(); + float volt = this->ac_voltage_->get_state(); + if (!std::isnan(pwr) && !std::isnan(volt) && volt > 1.0f) { + int16_t curr = to_sf(pwr / volt, -2); + this->set_reg(40072, (uint16_t) curr); + this->set_reg(40073, (uint16_t) curr); + } else { + this->set_reg(40072, 0x8000); + this->set_reg(40073, 0x8000); + } } + + // AC voltage (SF=-1) + this->set_reg(40080, (uint16_t) to_sf(this->ac_voltage_->get_state(), -1)); } - // AC voltage (SF=-1): value x 10 - this->set_reg(40080, (uint16_t) to_sf(this->ac_voltage_->get_state(), -1)); + // ---- Shared registers (both modes) ---- - // AC power (SF=0): direct watts + // AC power (SF=0) this->set_reg(40084, (uint16_t) to_sf(this->ac_power_->get_state(), 0)); - // AC frequency (SF=-2): value x 100 + // AC frequency (SF=-2) this->set_reg(40086, (uint16_t) to_sf(this->ac_frequency_->get_state(), -2)); // Energy (uint32, SF=0) @@ -281,7 +340,7 @@ void SunspecComponent::refresh_sensors_() { } } - // Temperature (SF=-1): value x 10 + // Temperature (SF=-1) this->set_reg(40103, (uint16_t) to_sf(this->temperature_->get_state(), -1)); // Inverter state: MPPT (4) if producing, else Off (1) diff --git a/tests/test_sunspec.py b/tests/test_sunspec.py index b596c37..152c4c1 100644 --- a/tests/test_sunspec.py +++ b/tests/test_sunspec.py @@ -213,3 +213,31 @@ def test_model103_id(three_phase): rr = three_phase.read_holding_registers(40070, 1, slave=1) assert not rr.isError() assert rr.registers[0] == 103, f"Expected 103, got {rr.registers[0]}" + + +def test_model103_phase_voltages_not_ffff(three_phase): + """3-phase: VAN/VBN/VCN (40080-40082) must not be 0xFFFF when sensors connected.""" + rr = three_phase.read_holding_registers(40080, 3, slave=1) + assert not rr.isError() + for i, reg in enumerate(rr.registers): + assert reg != 0xFFFF, f"Voltage register 4008{i} is 0xFFFF (not implemented)" + + +def test_model103_phase_currents_not_ffff(three_phase): + """3-phase: phase A/B/C currents (40073-40075) must not be 0xFFFF when inverter active.""" + rr = three_phase.read_holding_registers(40073, 3, slave=1) + assert not rr.isError() + for i, reg in enumerate(rr.registers): + assert reg != 0xFFFF, f"Current register 4007{3+i} is 0xFFFF (not implemented)" + + +def test_model103_total_current_matches_sum(three_phase): + """3-phase: total current (40072) should be consistent with phase sum.""" + rr = three_phase.read_holding_registers(40072, 4, slave=1) + assert not rr.isError() + total, ia, ib, ic = rr.registers + # All values must be set (not NaN sentinel 0x8000) + assert total != 0x8000 + assert ia != 0x8000 + assert ib != 0x8000 + assert ic != 0x8000 From 6480262038befbc68c8319c65f7ca5a76fbf56ca Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Apr 2026 20:28:01 +0200 Subject: [PATCH 09/11] fix: three_phase as constructor param, fix stale comment for 3-phase voltage registers --- components/sunspec/__init__.py | 6 ++---- components/sunspec/sunspec.cpp | 2 +- components/sunspec/sunspec.h | 5 +++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/components/sunspec/__init__.py b/components/sunspec/__init__.py index a3421ad..70c265e 100644 --- a/components/sunspec/__init__.py +++ b/components/sunspec/__init__.py @@ -120,7 +120,8 @@ def _validate_phases(config): async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + three_phase = config[CONF_PHASES] == 3 + var = cg.new_Pvariable(config[CONF_ID], three_phase) await cg.register_component(var, config) cg.add(var.set_manufacturer(config[CONF_MANUFACTURER])) @@ -129,9 +130,6 @@ async def to_code(config): cg.add(var.set_version(config[CONF_VERSION])) cg.add(var.set_rated_power(config[CONF_RATED_POWER])) - three_phase = config[CONF_PHASES] == 3 - cg.add(var.set_three_phase(three_phase)) - if three_phase: for conf_key, setter in [ (CONF_AC_VOLTAGE_A, "set_ac_voltage"), diff --git a/components/sunspec/sunspec.cpp b/components/sunspec/sunspec.cpp index 19fedb0..5b21561 100644 --- a/components/sunspec/sunspec.cpp +++ b/components/sunspec/sunspec.cpp @@ -65,7 +65,7 @@ void SunspecComponent::init_static_registers_() { this->set_reg(40076, (uint16_t)(int16_t)(-2)); // Current SF = -2 // 40077-40079: phase voltage AB/BC/CA -- stay 0xFFFF // 40080: Volts AN -- updated in refresh_sensors_() - // 40081-40082: BN/CN -- stay 0xFFFF + // 40081-40082: BN/CN -- updated in refresh_sensors_() for 3-phase, else 0xFFFF this->set_reg(40083, (uint16_t)(int16_t)(-1)); // Voltage SF = -1 // 40084: AC power -- updated in refresh_sensors_() this->set_reg(40085, 0); // Power SF = 0 diff --git a/components/sunspec/sunspec.h b/components/sunspec/sunspec.h index 4f3efc7..792d366 100644 --- a/components/sunspec/sunspec.h +++ b/components/sunspec/sunspec.h @@ -29,6 +29,8 @@ struct Client { class SunspecComponent : public Component { public: + explicit SunspecComponent(bool three_phase) : three_phase_(three_phase) {} + void setup() override; void loop() override; void dump_config() override; @@ -48,7 +50,6 @@ class SunspecComponent : public Component { void set_temperature(sensor::Sensor *s) { this->temperature_ = s; } void set_energy_total(sensor::Sensor *s) { this->energy_total_ = s; } - void set_three_phase(bool v) { this->three_phase_ = v; } void set_ac_voltage_b(sensor::Sensor *s) { this->ac_voltage_b_ = s; } void set_ac_voltage_c(sensor::Sensor *s) { this->ac_voltage_c_ = s; } void set_ac_current_a(sensor::Sensor *s) { this->ac_current_a_ = s; } @@ -61,6 +62,7 @@ class SunspecComponent : public Component { protected: // Config + const bool three_phase_; std::string manufacturer_; std::string model_; std::string serial_number_; @@ -75,7 +77,6 @@ class SunspecComponent : public Component { sensor::Sensor *temperature_{nullptr}; sensor::Sensor *energy_total_{nullptr}; - bool three_phase_{false}; sensor::Sensor *ac_voltage_b_{nullptr}; sensor::Sensor *ac_voltage_c_{nullptr}; sensor::Sensor *ac_current_a_{nullptr}; From af08c4ae58cbfddd8038cef5ff015de6477869ea Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Apr 2026 20:31:14 +0200 Subject: [PATCH 10/11] docs: update README for three-phase (Model 103) support --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index bfd3936..77c5ffe 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,17 @@ Custom ESPHome external component that runs a **SunSpec Modbus TCP server** (port 502) directly on an ESP32. It reads live inverter data from existing ESPHome sensors and serves it to any SunSpec-compatible energy manager (Victron, SolarEdge, Fronius, etc.). It also accepts power limit commands from SunSpec clients and relays them back to the inverter over RS485 Modbus. -Built for a **Solis single-phase inverter** bridged via an ESP32 (m5stack-atom, ESP-IDF), but the sensor IDs and power limit register are configurable via YAML. +Built for a **Solis inverter** bridged via an ESP32 (m5stack-atom, ESP-IDF), but the sensor IDs and power limit register are configurable via YAML. Supports both single-phase and three-phase inverters. ## Features -- SunSpec Models 1 (Common), 101 (Single-phase inverter), 120 (Nameplate), 123 (Controls) +- SunSpec Models 1 (Common), 101/103 (Single/three-phase inverter), 120 (Nameplate), 123 (Controls) +- Single-phase (Model 101) and three-phase (Model 103) support via `phases: 1|3` - FC03 read holding registers - FC06 / FC16 write — power limit (WMaxLimPct) and enable (WMaxLim_Ena) - Power limit write-back to inverter over RS485 via `modbus_controller` - Up to 2 simultaneous Modbus TCP clients -- ESP-IDF target (not Arduino) +- ESP-IDF and Arduino frameworks on ESP32 ## Installation @@ -28,6 +29,8 @@ external_components: ## Configuration +**Single-phase (default):** + ```yaml sunspec: # Device identification (shown to SunSpec clients) @@ -37,7 +40,7 @@ sunspec: version: "1.0.0" rated_power: 3000 # watts, max 32767 - # Sensor references (ESPHome sensor IDs) + # phases defaults to 1 — serves SunSpec Model 101 ac_power: ac_power # required ac_voltage: ac_voltage # required ac_frequency: ac_frequency # required @@ -53,6 +56,29 @@ sunspec: # power_limit_register: 3051 # Solis RS485 register for power limit ``` +**Three-phase (Model 103):** + +```yaml +sunspec: + manufacturer: "Solis" + model: "S6-GR3P8K-M" + serial_number: "12345678" + version: "1.0.0" + rated_power: 8000 + + phases: 3 # serves SunSpec Model 103 + ac_voltage_a: ac_voltage_a # required — phase A voltage (V) + ac_voltage_b: ac_voltage_b # required — phase B voltage (V) + ac_voltage_c: ac_voltage_c # required — phase C voltage (V) + ac_current_a: ac_current_a # optional — if omitted, current is derived from power/voltage + ac_current_b: ac_current_b # optional — must configure all three or none + ac_current_c: ac_current_c # optional + ac_power: ac_power # required + ac_frequency: ac_frequency # required + temperature: inverter_temp # required + energy_total: energy_total_wh # optional +``` + The `power_limit_number_id` approach routes the limit through an existing `modbus_controller` number entity. Define it alongside your other number entities: ```yaml @@ -78,16 +104,23 @@ number: | Key | Required | Description | |-----|----------|-------------| -| `manufacturer` | yes | Manufacturer string (max 16 chars) | -| `model` | yes | Model string (max 16 chars) | -| `serial_number` | yes | Serial number string (max 16 chars) | -| `version` | yes | Firmware version string (max 8 chars) | +| `manufacturer` | yes | Manufacturer string (max 32 chars) | +| `model` | yes | Model string (max 32 chars) | +| `serial_number` | yes | Serial number string (max 32 chars) | +| `version` | yes | Firmware version string (max 16 chars) | | `rated_power` | yes | Inverter rated power in watts (int, max 32767) | +| `phases` | no | `1` (default) or `3` — selects Model 101 or 103 | | `ac_power` | yes | ESPHome sensor ID for AC power (W) | -| `ac_voltage` | yes | ESPHome sensor ID for AC voltage (V) | | `ac_frequency` | yes | ESPHome sensor ID for AC frequency (Hz) | | `temperature` | yes | ESPHome sensor ID for inverter temperature (°C) | -| `ac_current` | no | ESPHome sensor ID for AC current (A) | +| `ac_voltage` | phases=1 | ESPHome sensor ID for AC voltage (V) | +| `ac_current` | no | ESPHome sensor ID for AC current (A) — single-phase only | +| `ac_voltage_a` | phases=3 | Phase A voltage sensor (V) | +| `ac_voltage_b` | phases=3 | Phase B voltage sensor (V) | +| `ac_voltage_c` | phases=3 | Phase C voltage sensor (V) | +| `ac_current_a` | no | Phase A current sensor (A) — all three or none | +| `ac_current_b` | no | Phase B current sensor (A) — all three or none | +| `ac_current_c` | no | Phase C current sensor (A) — all three or none | | `energy_total` | no | ESPHome sensor ID for lifetime energy (Wh) | | `power_limit_number_id` | no | ESPHome `number` entity ID to route power limit through (recommended) | | `modbus_controller_id` | no | ID of your `modbus_controller` component (required when using `power_limit_register`) | @@ -98,11 +131,21 @@ number: | Range | Model | Content | |-------|-------|---------| | 40000–40069 | 1 | Common block (manufacturer, model, serial, version) | -| 40070–40121 | 101 | Single-phase inverter (power, voltage, current, frequency, temperature, energy, state) | +| 40070–40121 | 101 or 103 | Inverter block — Model 101 (single-phase) or 103 (three-phase) | | 40122–40149 | 120 | Nameplate (DER type, rated power) | | 40150–40175 | 123 | Controls (WMaxLimPct @ 40155, WMaxLim_Ena @ 40159) | | 40176–40179 | — | End marker | +**Three-phase register assignments (Model 103):** + +| Register | Content | +|----------|---------| +| 40072 | Total AC current (SF=-2) | +| 40073–40075 | Phase A/B/C current (SF=-2) | +| 40080–40082 | Phase A/B/C voltage VAN/VBN/VCN (SF=-1) | + +If `ac_current_a/b/c` sensors are not configured, per-phase currents are derived from `ac_power / 3 / V_phase`. + ## Power Limiting Write `WMaxLimPct` (register 40155, range 0–100) and `WMaxLim_Ena` (register 40159, `1` = enabled): @@ -121,8 +164,7 @@ On ESP32 reboot, `WMaxLim_Ena` defaults to `0` and `WMaxLimPct` defaults to `100 ## Scope / Limitations -- Single-phase only (Model 101) - One inverter instance per ESP32 - No persistent energy counter across reboots - No WMaxLimPct auto-revert timer (field present but always 0) -- No three-phase support (Model 103) +- Three-phase derived currents assume unity power factor per phase From 14929fc70f31727cf96e74f305d17a762be7ef72 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Apr 2026 20:32:17 +0200 Subject: [PATCH 11/11] docs: mark three-phase support as experimental/not hardware-tested --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 77c5ffe..6ddc3c9 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ sunspec: # power_limit_register: 3051 # Solis RS485 register for power limit ``` -**Three-phase (Model 103):** +**Three-phase (Model 103) — experimental, not hardware-tested:** ```yaml sunspec: @@ -167,4 +167,5 @@ On ESP32 reboot, `WMaxLim_Ena` defaults to `0` and `WMaxLimPct` defaults to `100 - One inverter instance per ESP32 - No persistent energy counter across reboots - No WMaxLimPct auto-revert timer (field present but always 0) +- Three-phase support (Model 103) is **not hardware-tested** — use with caution and report issues - Three-phase derived currents assume unity power factor per phase