|
| 1 | +# PV sensors via Component |
| 2 | + |
| 3 | +Status: **proposed** (alternative: [`pv_dynamic_total.md`](pv_dynamic_total.md)) |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +Introduce per-profile PV Components as the Modbus read path, keep existing sensor slugs as the |
| 8 | +config/HA surface, remove PVx from `power_flow_card`, and replace register-based `pv_power` with a |
| 9 | +sum of the user’s selected `pvN_power` sensors. |
| 10 | + |
| 11 | +## Context |
| 12 | + |
| 13 | +Today only [`Identity`](../src/sunsynk/identity.py) uses `modbus_connection.model.Component` |
| 14 | +(one-shot regs 0–7). All PV readings still go through `Sensor` + `group_sensors` + `InverterState`. |
| 15 | +Docs already describe the intended later step: |
| 16 | +**Component owns the register layout; `Sensor` stays the HA/mysensors view** |
| 17 | +([`modbus-connection.md`](modbus-connection.md) “If Components come later”). |
| 18 | + |
| 19 | +**Component package:** all Components live under |
| 20 | +[`src/sunsynk/components/`](../src/sunsynk/components/). Move |
| 21 | +[`identity.py`](../src/sunsynk/identity.py) → |
| 22 | +[`src/sunsynk/components/identity.py`](../src/sunsynk/components/identity.py). Re-export from |
| 23 | +`sunsynk.components` (and optionally keep `sunsynk.identity` as a thin shim for backwards compat). |
| 24 | +Update imports in `a_inverter.py`, tests, and docs. |
| 25 | + |
| 26 | +PV fits that spike better than Battery: small blocks, read-only, same slugs users already know |
| 27 | +(`pv1_power`, …). |
| 28 | + |
| 29 | +**Layout note (why not one Component per MPPT):** on 1PH, V/I live at 109–114 and power at 186–188 |
| 30 | +(gap ≫ `max_gap=16`), so a per-channel Component would still split reads. One Component per |
| 31 | +**register map family** is the natural shape; the planner issues 1–2 FC03 blocks automatically. |
| 32 | + |
| 33 | +**3PH LV + HV as one class:** `PVThreePhase` declares PV1–4 V/I, PV1–8 power (all register |
| 34 | +addresses). Differences between profiles are handled outside the Component class: |
| 35 | + |
| 36 | +| Concern | 3PH LV | 3PH HV | |
| 37 | +| ------------- | ----------------------------------------------- | ----------------------------- | |
| 38 | +| MPPT count | pv1–4 sensors in definitions | pv1–8 sensors in definitions | |
| 39 | +| PV5–8 fields | never tracked → `restrict_fields` excludes them | included when selected | |
| 40 | +| Power scaling | `ComponentSensor` factor `-1` (signed) | `ComponentSensor` factor `10` | |
| 41 | + |
| 42 | +Power fields on the Component read **raw register words** (`integer(addr, signed=False)`); the |
| 43 | +bridge applies each sensor’s existing `factor` (same signed/unsigned rules as today’s `Sensor`). V/I |
| 44 | +use `gauge(..., 0.1)` — identical on both profiles. No second HV class needed. |
| 45 | + |
| 46 | +## Selective reads and totals |
| 47 | + |
| 48 | +**A single PV Component still reads only what the user configured.** The class declares the full |
| 49 | +register layout; at runtime we narrow it with `Component.restrict_fields()`. |
| 50 | + |
| 51 | +When sensor selection is resolved (`init_sensors` / track): |
| 52 | + |
| 53 | +1. Collect the Component field names needed for **tracked** PV sensors (e.g. user enabled |
| 54 | + `pv1_power` + `pv1_voltage` → fields `pv1_power`, `pv1_voltage` only). |
| 55 | +2. If `pv_power` is enabled, also include every `pvN_power` field that contributes to the total (see |
| 56 | + below). |
| 57 | +3. Call `restrict_fields(...)` on the PV instance (recreate the instance if already restricted). |
| 58 | +4. Each poll calls `async_update()` on that narrowed component → Modbus reads **only** those |
| 59 | + registers. |
| 60 | + |
| 61 | +**Unconfigured sensors:** |
| 62 | + |
| 63 | +| Concern | Behaviour | |
| 64 | +| ---------------- | ---------------------------------------------------------- | |
| 65 | +| Modbus read | Not read — field excluded by `restrict_fields` | |
| 66 | +| HA entity | Not created — never tracked | |
| 67 | +| `pv_power` total | **Not included** — only selected `pvN_power` sensors count | |
| 68 | + |
| 69 | +**`pv_power` sum rules:** |
| 70 | + |
| 71 | +- User enables `pv1_power` + `pv2_power` + `pv_power` → total = pv1 + pv2 only. |
| 72 | +- User enables `pv_power` alone → auto-track all profile `pvN_power` as **hidden** deps (polled, no |
| 73 | + HA entity); total = sum of all MPPTs for that profile. |
| 74 | +- User enables `pv1_voltage` only → no `pv_power`, no power fields read. |
| 75 | + |
| 76 | +The Component class is **not** config-aware — it is the layout map. Selection + `restrict_fields` + |
| 77 | +`SumSensor.sources` keep reads and totals aligned with config. |
| 78 | + |
| 79 | +**Update cadence:** unchanged from today. Schedules/MQTT are not touched. Power (`W`) sensors read |
| 80 | +every 5s, report every 60s (or ≥80 W change); V/I read every 15s. |
| 81 | + |
| 82 | +```mermaid |
| 83 | +flowchart LR |
| 84 | + config["SENSORS slugs / groups"] --> sopt["SensorOptions"] |
| 85 | + sopt --> sched["SensorSchedule"] |
| 86 | + sched --> read["AInverter.read_sensors"] |
| 87 | + read --> part{"partition"} |
| 88 | + part -->|"classic Sensor"| classic["Sunsynk.read_sensors / group_sensors"] |
| 89 | + part -->|"ComponentSensor"| pv["PV.async_update"] |
| 90 | + pv --> inject["InverterState.apply_values"] |
| 91 | + classic --> state["InverterState"] |
| 92 | + inject --> state |
| 93 | + state --> sum["pv_power SumSensor"] |
| 94 | + state --> mqtt["MQTT / history"] |
| 95 | + sum --> mqtt |
| 96 | +``` |
| 97 | + |
| 98 | +## Design decisions |
| 99 | + |
| 100 | +1. **Config/HA unchanged in shape** — users still list slugs (`pv1_power`, `pv2_voltage`, …) and can |
| 101 | + still put those slugs in groups. |
| 102 | +2. **Read path changes for PV only** — selected PV sensors are satisfied by |
| 103 | + `Component.async_update()`, not by `group_sensors`. |
| 104 | +3. **Remove all `pv*` live power/V/I from `power_flow_card`** (including `pv_power`). Keep |
| 105 | + `day_pv_energy` / `total_pv_energy` where they already are. Users who need PV must list slugs |
| 106 | + explicitly (update edge defaults accordingly). |
| 107 | +4. **`pv_power` becomes a value-sum, not a register `MathSensor`:** sum of enabled `pvN_power` |
| 108 | + sensors. If the user enables `pv_power` alone, auto-track every profile `pvN_power` as **hidden** |
| 109 | + deps (polled, no HA entity) so the total still works. |
| 110 | +5. **No hand-edits under `hass-addon-sunsynk-multi/`** — edge + `src/` + docs only. |
| 111 | + |
| 112 | +## Implementation |
| 113 | + |
| 114 | +### A. Component package + PV layouts |
| 115 | + |
| 116 | +```text |
| 117 | +src/sunsynk/components/ |
| 118 | + __init__.py # re-export Identity, pv_component_for, … |
| 119 | + identity.py # moved from src/sunsynk/identity.py |
| 120 | + pv.py # PVSinglePhase, PVThreePhase |
| 121 | +``` |
| 122 | + |
| 123 | +- **`PVSinglePhase`** — pv1–3 power (186–188), V/I (109–114). Used by `single-phase` and |
| 124 | + `single-phase-16kw`. |
| 125 | +- **`PVThreePhase`** — pv1–4 V/I (676–683), pv1–8 power (672–675, 727–730). Used by both |
| 126 | + `three-phase` and `three-phase-hv`. |
| 127 | + |
| 128 | +Factory: `pv_component_for(defs_name) -> type[Component]` returns `PVSinglePhase` or `PVThreePhase`. |
| 129 | + |
| 130 | +```python |
| 131 | +# three_phase_common.py |
| 132 | +SENSORS += pv_sensors(PVThreePhase, mppts=4, power_factor=-1) |
| 133 | + |
| 134 | +# three_phase_hv.py — same Component, more MPPTs + HV scale |
| 135 | +SENSORS += pv_sensors(PVThreePhase, mppts=8, power_factor=10) |
| 136 | +``` |
| 137 | + |
| 138 | +Helper: `fields_for_sensors(tracked_pv_sensors) -> set[str]` — maps `ComponentSensor` instances to |
| 139 | +field names for `restrict_fields`. |
| 140 | + |
| 141 | +### B. Thin sensors from Component fields |
| 142 | + |
| 143 | +- `ComponentSensor` subclass tagged with `field="pv1_power"` etc. |
| 144 | +- `pv_sensors()` generates sensors + `SumSensor("PV power")` per profile. |
| 145 | +- `SumSensor`: no Modbus; value = sum of tracked `pvN_power` in `InverterState`. |
| 146 | + |
| 147 | +### C. Read bridge |
| 148 | + |
| 149 | +In `AInverter.read_sensors`: |
| 150 | + |
| 151 | +1. Partition `component_backed` vs `classic`. |
| 152 | +2. Classic → existing `inv.read_sensors`. |
| 153 | +3. PV → `restrict_fields` + `async_update` + `InverterState.apply_values`. |
| 154 | +4. Evaluate `SumSensor`s in the same tick. |
| 155 | + |
| 156 | +Instantiate `self.pv` at connect (alongside `read_identity`). |
| 157 | + |
| 158 | +### D. Groups + defaults + docs |
| 159 | + |
| 160 | +- Strip `pv_power` and all `pvN_*` from `power_flow_card` in edge `sensor_options.py`. |
| 161 | +- Edge `config.yaml`: explicit starter PV slugs after `power_flow_card`. |
| 162 | +- CHANGELOG: breaking — PV no longer implied by `power_flow_card`. |
| 163 | + |
| 164 | +### E. Tests |
| 165 | + |
| 166 | +- Component decode (1PH + 3PH, LV signed vs HV ×10). |
| 167 | +- Bridge read for selected sensors only. |
| 168 | +- `pv_power` sum selection rules. |
| 169 | + |
| 170 | +## Out of scope |
| 171 | + |
| 172 | +- Battery/Grid Component migration. |
| 173 | +- Rewrite of `group_sensors` / schedules / MQTT. |
| 174 | +- Edits under `hass-addon-sunsynk-multi/`. |
| 175 | +- Energy counters (`day_pv_energy`, `total_pv_energy`) — stay classic sensors. |
| 176 | + |
| 177 | +## Success criteria |
| 178 | + |
| 179 | +- `pv1_power` HA entity unchanged; value from `PV*.async_update()`. |
| 180 | +- `power_flow_card` alone does not enable any `pvN_*` / `pv_power`. |
| 181 | +- `pv_power` equals sum of user’s enabled `pvN_power` sensors. |
| 182 | +- Non-PV sensors and Identity path unchanged. |
| 183 | + |
| 184 | +## Tasks |
| 185 | + |
| 186 | +- [ ] Add `src/sunsynk/components/`; move `identity.py`; add `PVSinglePhase` + `PVThreePhase` |
| 187 | +- [ ] `ComponentSensor` + `pv_sensors()`; replace PV blocks in definitions; `SumSensor` |
| 188 | +- [ ] Read bridge in `AInverter`; `InverterState.apply_values` |
| 189 | +- [ ] Groups, edge defaults, docs, CHANGELOG |
| 190 | +- [ ] Tests |
0 commit comments