Skip to content

Commit ace7914

Browse files
committed
pv_power
1 parent 41a1085 commit ace7914

17 files changed

Lines changed: 579 additions & 79 deletions

File tree

‎docs/pv_component.md‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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

‎docs/pv_dynamic_total.md‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# PV dynamic total (`pv_power`)
2+
3+
Status: **implemented** (alternative: [`pv_component.md`](pv_component.md))
4+
5+
## Overview
6+
7+
Smaller change than the Component migration: keep `pv1_power`, `pv2_power`, … as classic `Sensor`
8+
definitions. Replace the fixed-register `MathSensor` for `pv_power` with a new sensor class that
9+
**at runtime** builds its register list and factors from whichever `pvN_power` sensors are tracked.
10+
Add `pv_power` to single-phase (today 1PH has no aggregate).
11+
12+
Also remove PVx from `power_flow_card` so users enable MPPTs explicitly.
13+
14+
## Problem today
15+
16+
[`three_phase_common.py`](../src/sunsynk/definitions/three_phase_common.py) hard-codes all four MPPT
17+
power registers regardless of config:
18+
19+
```python
20+
MathSensor(
21+
(672, 673, 674, 675), "PV power", WATT, factors=(1, 1, 1, 1)
22+
), # pv1,pv2,pv3,pv4 power
23+
```
24+
25+
HV does the same with eight registers. Single-phase has **no** `pv_power` at all. Enabling
26+
`pv_power` always reads/sums every register in the tuple, even if the user only cares about PV1+PV2.
27+
28+
## Approach
29+
30+
Introduce **`PVDynamicTotalSensor`** (name TBD — `DynamicMathSensor` also fine) in
31+
[`sensors.py`](../src/sunsynk/sensors.py):
32+
33+
```python
34+
@dataclass(slots=True, eq=False)
35+
class PVDynamicTotalSensor(Sensor):
36+
"""PV total: sum registers from configured pvN_power sources."""
37+
38+
source_ids: tuple[str, ...] # ("pv1_power", "pv2_power", …) for this profile
39+
sources: tuple[Sensor, ...] = field(init=False, default=())
40+
factors: tuple[float, ...] = field(init=False, default=())
41+
```
42+
43+
### Resolution (config-aware, at `init_sensors`)
44+
45+
After the user’s sensor list is known, call `resolve(configured: set[Sensor])`:
46+
47+
1. **Intersection:** `configured_sources` = profile `source_ids` whose `Sensor` is already in
48+
`SensorOptions` (visible or hidden).
49+
2. **If any configured_sources** (user enabled some `pvN_power` ± `pv_power`):
50+
- `sources` = those sensors only.
51+
- `address` = flattened register tuple from each source (each `pvN_power` is one register).
52+
- `factors` = each source’s `factor` (preserves LV signed `-1` vs HV `×10`).
53+
3. **If none** (user enabled **`pv_power` only**):
54+
- `sources` = all profile `source_ids` from `DEFS.all`.
55+
- Auto-add them as **hidden** tracked sensors (same pattern as `RWSensor.dependencies` in
56+
[`sensor_options.py`](../src/ha_addon_sunsynk_multi/sensor_options.py)).
57+
4. If `pv_power` is not configured at all → sensor never tracked; no reads.
58+
59+
`resolve()` mutates `address` / `factors` / `sources` on the single definition instance (same
60+
`Sensor` object in `DEFS.all`, like overrides).
61+
62+
Hook: extend `SensorOptions.init_sensors()` — after building `sensors_all` / `sensors_1st`, run
63+
`resolve()` on every `PVDynamicTotalSensor` in the selection before `_add_sensor` / deps loop, or
64+
add a `dependencies` property that returns unresolved sources and a second pass that trims to
65+
configured subset.
66+
67+
Prefer **explicit `resolve()` pass** so intersection logic is clear.
68+
69+
### Decode
70+
71+
Do **not** reuse `MathSensor.reg_to_value` (it always `signed=True`). Decode each register with its
72+
**source sensor’s** rules:
73+
74+
```python
75+
def reg_to_value(self, regs: RegType) -> ValType:
76+
return int_round(
77+
sum(
78+
src.reg_to_value((reg,))
79+
for src, reg in zip(self.sources, regs, strict=True)
80+
)
81+
)
82+
```
83+
84+
This matches individual `pvN_power` scaling on HV and LV.
85+
86+
### Definitions
87+
88+
One shared sensor on [`COMMON`](../src/sunsynk/definitions/__init__.py):
89+
90+
```python
91+
COMMON += PVDynamicTotalSensor(0, "PV power", WATT)
92+
# source_ids default: pv1_power … pv8_power
93+
```
94+
95+
Every profile copies `COMMON`, so 1PH / 16kW / 3PH LV / HV all get the same slug. `resolve()` keeps
96+
only ids that exist in that profile’s `DEFS.all` — 1PH never sees pv4–8, LV never sees pv5–8.
97+
98+
Individual `Sensor(186, "PV1 power", …)` blocks stay unchanged.
99+
100+
### Individual `pvN_power` sensors — stay in definitions (explicit)
101+
102+
**Yes — each profile still declares its own `pvN_power` (and V/I) sensors.** The shared
103+
`PVDynamicTotalSensor` does not generate them; it looks them up by slug and **silently skips** any
104+
id the profile never defined.
105+
106+
| Profile | File | Individual sensors | Notes |
107+
| ------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------- |
108+
| `single-phase` | [`single_phase.py`](../src/sunsynk/definitions/single_phase.py) | `pv1`–`pv3` power + V/I | Unchanged blocks (~L94–116) |
109+
| `single-phase-16kw` | [`single_phase_16kw.py`](../src/sunsynk/definitions/single_phase_16kw.py) | Same as 1PH (copy) | Inherits PV sensors from `single_phase` |
110+
| `three-phase` | [`three_phase_common.py`](../src/sunsynk/definitions/three_phase_common.py) | `pv1`–`pv4` power + V/I | No local `pv_power` |
111+
| `three-phase-hv` | [`three_phase_hv.py`](../src/sunsynk/definitions/three_phase_hv.py) | Overrides `pv1`–`pv4` power (`×10`); adds `pv5`–`pv8` power | Same shared `pv_power`; extra MPPTs appear in defs so they join the total |
112+
113+
**What stays per file:** per-MPPT `Sensor(...)` entries — register addresses and factors differ by
114+
profile (1PH 186–188 vs 3PH 672–675, LV signed vs HV `×10`).
115+
116+
**Not in scope for this plan:** generating `pvN_power` from a shared table or Component — that is
117+
[`pv_component.md`](pv_component.md) (`pv_sensors()` from `PVThreePhase`).
118+
119+
```mermaid
120+
flowchart TD
121+
config["SENSORS config"] --> init["init_sensors"]
122+
init --> resolve["PVDynamicTotalSensor.resolve()"]
123+
resolve --> pick{"any pvN_power configured?"}
124+
pick -->|yes| subset["sources = intersection"]
125+
pick -->|no, pv_power only| all["sources = all profile pvN_power as hidden deps"]
126+
subset --> addr["set address + factors from sources"]
127+
all --> addr
128+
addr --> poll["group_sensors + read_sensors — unchanged path"]
129+
poll --> decode["reg_to_value per source factor"]
130+
```
131+
132+
## Shared with Component plan
133+
134+
- Remove `pv_power`, `pv1_power`, … from `power_flow_card` (edge `sensor_options.py`).
135+
- Edge `config.yaml`: list explicit PV slugs in defaults.
136+
- CHANGELOG breaking note.
137+
138+
## What this does **not** do
139+
140+
- No `modbus_connection` Component for PV reads.
141+
- No `ComponentSensor` / `restrict_fields` bridge.
142+
- No move of `identity.py` (can still happen separately).
143+
- `pv1_voltage` / `pvN_current` stay plain `Sensor` — only the **total** is dynamic.
144+
145+
## Comparison
146+
147+
| | `pv_dynamic_total` | `pv_component` |
148+
| ------------------------------- | ------------------------------------------ | ----------------------------------------------- |
149+
| Diff size | Small (~1 sensor class + init hook + defs) | Large (components package, bridge, SumSensor) |
150+
| PV read path | `group_sensors` (today) | `Component.async_update` |
151+
| Register layout | Still in definitions per `pvN_power` | Centralised in `PVThreePhase` / `PVSinglePhase` |
152+
| `pv_power` | Dynamic register `MathSensor` | Value-sum after component read |
153+
| Extends to PV V/I via Component | No | Yes |
154+
| 1PH `pv_power` | Yes | Yes |
155+
156+
**When to pick this:** you want configurable `pv_power` and explicit MPPT selection **now**, without
157+
committing to the Component migration. Component plan remains the path for deduplicating register
158+
maps and reading PV blocks through `modbus_connection`.
159+
160+
## Tests
161+
162+
- `resolve()`: pv1+pv2+pv_power → address `(186, 187)` on 1PH; factors `(-1, -1)`.
163+
- `resolve()`: pv_power only → all three 1PH sources added hidden; address `(186, 187, 188)`.
164+
- `reg_to_value`: HV sources with factor `10` decode like individual sensors.
165+
- `init_sensors` integration: hidden deps logged; `group_sensors` batch only includes resolved regs.
166+
167+
## Success criteria
168+
169+
- Enabling only `pv1_power` + `pv_power` on 3PH does not read regs 673–675.
170+
- `pv_power` alone sums all profile MPPTs (with hidden `pvN_power` polls).
171+
- Single-phase users can enable `pv_power` and get PV1+2+3 total.
172+
- `power_flow_card` no longer implies PV sensors.
173+
174+
## Tasks
175+
176+
- [x] Add `PVDynamicTotalSensor` + `resolve()` in `sensors.py`
177+
- [x] Hook `resolve()` in edge `SensorOptions.init_sensors()`
178+
- [x] One `pv_power` on `COMMON` (`pv1`–`pv8`); skip missing per profile
179+
- [x] Remove PVx from `power_flow_card`; edge defaults; CHANGELOG
180+
- [x] Tests for resolve, decode, and init_sensors deps

‎hass-addon-sunsynk-edge/CHANGELOG.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66

77
All items below are changes since add-on **1.0.0** (stable release on GitHub `main`).
88

9-
- Documentation update
9+
- **Breaking:** `power_flow_card` no longer includes PV live sensors (`pv1_power`, `pv_power`,
10+
etc.). Add the MPPT slugs you need explicitly under `SENSORS`.
11+
- `pv_power` is a dynamic total: it sums only the configured `pvN_power` sensors (or all profile
12+
MPPTs when `pv_power` is enabled alone). Added for single-phase profiles.
13+
- Serial `TIMEOUT` is ignored by tmodbus until
14+
[modbus-connection#213](https://github.com/home-assistant-libs/modbus-connection/pull/213) (pass
15+
timeout into the RTU/ASCII client). See [#672](https://github.com/kellerza/sunsynk/issues/672).
1016

1117
## Release 1.0.0
1218

‎hass-addon-sunsynk-edge/config.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ options:
2828
SENSORS:
2929
- energy_management
3030
- power_flow_card
31+
- pv1_power
3132
- pv2_power
33+
- pv_power
3234
SENSORS_FIRST_INVERTER:
3335
- settings
3436
MANUFACTURER: Sunsynk

0 commit comments

Comments
 (0)