Skip to content

Commit 9f72b7a

Browse files
committed
various
1 parent d97adf6 commit 9f72b7a

19 files changed

Lines changed: 571 additions & 376 deletions

‎.cursor/rules/sunsynk-addons.mdc‎

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,54 +11,57 @@ alwaysApply: true
1111

1212
## Editing policy (agents)
1313

14-
**Do not** create, edit, delete, or move files under **`hass-addon-sunsynk-multi/`** unless the user **explicitly** asks for
15-
stable-multi work (e.g. “sync multi”, “release prep”, “fix CI output in multi”, “cherry-pick to stable addon”). Normal
16-
tasks — config, schema, translations, Dockerfile, run scripts, CHANGELOG in the add-on folder — **must only be proposed
17-
under `hass-addon-sunsynk-edge/`**.
14+
**Do not** create, edit, delete, or move files under **`hass-addon-sunsynk-multi/`** unless the user
15+
**explicitly** asks for stable-multi work (e.g. “sync multi”, “release prep”, “fix CI output in
16+
multi”, “cherry-pick to stable addon”). Normal tasks — config, schema, translations, Dockerfile, run
17+
scripts, CHANGELOG in the add-on folder —
18+
**must only be proposed under `hass-addon-sunsynk-edge/`**.
1819

19-
If the user has **`hass-addon-sunsynk-multi/**`** open or asks for a change that would touch multi, **redirect**: apply the
20-
same change under **`hass-addon-sunsynk-edge/`** (or `src/` / docs as below) and state that multi is updated by **CI when
21-
a GitHub tag is pushed**.
20+
If the user has **`hass-addon-sunsynk-multi/**`** open or asks for a change that would touch multi,
21+
**redirect**: apply the same change under **`hass-addon-sunsynk-edge/`** (or `src/` / docs as below)
22+
and state that multi is updated by **CI when a GitHub tag is pushed**.
2223

2324
## Repository layout
2425

25-
- **`hass-addon-sunsynk-edge`** — **Development add-on.** All manual add-on packaging work happens here (`config.yaml`,
26-
`schema`, `translations/`, Docker/run scripts).
26+
- **`hass-addon-sunsynk-edge`** — **Development add-on.** All manual add-on packaging work happens
27+
here (`config.yaml`, `schema`, `translations/`, Docker/run scripts).
2728

28-
- **`hass-addon-sunsynk-multi`** — **Stable / release add-on.** **CI-maintained** for routine changes; do not hand-edit
29-
as part of feature work.
29+
- **`hass-addon-sunsynk-multi`** — **Stable / release add-on.** **CI-maintained** for routine
30+
changes; do not hand-edit as part of feature work.
3031

31-
- **`src/ha_addon_sunsynk_multi`** — **Add-on Python** used in the Docker build. Implement behaviour here; it ships with
32-
edge builds immediately and is what the multi image is built from on release (no second code path).
32+
- **`src/ha_addon_sunsynk_multi`** — **Add-on Python** used in the Docker build. Implement behaviour
33+
here; it ships with edge builds immediately and is what the multi image is built from on release
34+
(no second code path).
3335

3436
## Supervisor options (documentation workflow)
3537

36-
When you add or change a **Supervisor option**, keep these in sync — **using edge for packaging strings**:
38+
When you add or change a **Supervisor option**, keep these in sync —
39+
**using edge for packaging strings**:
3740

3841
### 1. Home Assistant UI (`translations/en.yaml`)
3942

40-
- Add or update **`hass-addon-sunsynk-edge/translations/en.yaml`** only (short `name` / `description` for the Supervisor
41-
UI).
42-
- **Do not** update `hass-addon-sunsynk-multi/translations/` unless the user explicitly requests stable-multi sync; CI /
43-
release flow is expected to align multi with edge.
43+
- Add or update **`hass-addon-sunsynk-edge/translations/en.yaml`** only (short `name` /
44+
`description` for the Supervisor UI).
45+
- **Do not** update `hass-addon-sunsynk-multi/translations/` unless the user explicitly requests
46+
stable-multi sync; CI / release flow is expected to align multi with edge.
4447

4548
### 2. Reference docs (`www/docs/reference/multi-options.md`)
4649

47-
- Document every user-facing option; this page is built into the **VitePress** site. Use the same `OPTION_KEY` as in
48-
`config.yaml` (uppercase with underscores).
50+
- Document every user-facing option; this page is built into the **VitePress** site. Use the same
51+
`OPTION_KEY` as in `config.yaml` (uppercase with underscores).
4952

50-
**Dev / edge only:** if the option exists **only** in `hass-addon-sunsynk-edge/config.yaml` (not yet in multi’s
51-
`config.yaml`), prefix the bullet in `multi-options.md` with:
53+
**Dev / edge only:** if the option exists **only** in `hass-addon-sunsynk-edge/config.yaml` (not yet
54+
in multi’s `config.yaml`), prefix the bullet in `multi-options.md` with:
5255

5356
```markdown
5457
- <i-mdi-dev-to class="vp-edge-option-icon" /> `YOUR_OPTION` – …
5558
```
5659

57-
**After release:** when CI adds the option to **`hass-addon-sunsynk-multi/config.yaml`**, remove that icon prefix and any
58-
“edge only” wording for that option (edits to **`multi-options.md`** are fine; still avoid hand-editing multi **addon**
59-
files unless the user asked).
60+
**After release:** when CI adds the option to **`hass-addon-sunsynk-multi/config.yaml`**, remove
61+
that icon prefix and any “edge only” wording for that option (edits to **`multi-options.md`** are
62+
fine; still avoid hand-editing multi **addon** files unless the user asked).
6063

6164
### 3. Python (`src/ha_addon_sunsynk_multi/options.py`)
6265

63-
- Add or update `Options` / `InverterOptions` fields so YAML/env loading matches the edge schema (uppercase keys map to
64-
lowercase attributes via `mqtt_entity`).
66+
- Add or update `Options` / `InverterOptions` fields so YAML/env loading matches the edge schema
67+
(uppercase keys map to lowercase attributes via `mqtt_entity`).
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
description: Inverter Modbus sensor definitions — profiles, inheritance, single vs three phase
3+
globs:
4+
- src/sunsynk/definitions/**
5+
alwaysApply: false
6+
---
7+
8+
# Sunsynk inverter definitions (`src/sunsynk/definitions/`)
9+
10+
## What lives here
11+
12+
All **Modbus register → sensor** mappings for supported inverters live under
13+
**`src/sunsynk/definitions/`**. Each **named definition profile** (loaded via `import_defs` in
14+
`__init__.py`) corresponds to a **distinct inverter family / register layout**, not a small tweak on
15+
the same map.
16+
17+
## Building on another profile
18+
19+
Some definitions **reuse another module’s `SENSORS`** and patch or append registers (e.g.
20+
`single_phase_16kw.py` starts from `single_phase` and overrides selected sensors). That pattern
21+
means a **large common register set** with **local overrides** — but only where the hardware
22+
actually matches.
23+
24+
## Single-phase vs three-phase
25+
26+
**Single-phase** (`single_phase.py`, …) and **three-phase** (`three_phase_common.py` plus
27+
`three_phase_lv.py` / `three_phase_hv.py`) are **not** two views of the same register file: they use
28+
**different definitions and different register semantics**. When adding or comparing sensors:
29+
30+
- **Do not assume** the same holding register number means the same physical quantity across
31+
profiles.
32+
- Prefer **reading the target profile’s file** end-to-end for that inverter before copying a sensor
33+
from another profile.
34+
35+
## Canonical profile names
36+
37+
The add-on / library config string for each profile is listed in **`ALL_DEFS`** in
38+
`src/sunsynk/definitions/__init__.py` (e.g. `single-phase`, `single-phase-16kw`, `three-phase`,
39+
`three-phase-hv`). New profiles must be wired there (and in docs / schema as needed).

‎.github/copilot-instructions.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Sunsynk — align with Cursor rules
2+
3+
This project keeps its agent and contributor policies in **`.cursor/rules/`** as
4+
Markdown-with-frontmatter files (**`*.mdc`**). **Follow those files as the source of truth** for how
5+
to work in this repository.
6+
7+
## What to do
8+
9+
1. **Before** suggesting or applying changes in an area covered by a rule’s `globs` (see the YAML
10+
frontmatter at the top of each `.mdc` file), **read the relevant `.cursor/rules/*.mdc` file** in
11+
the repo and obey its body (the markdown after the closing `---` of the frontmatter block).
12+
2. **Repository-wide / always-on rules:** if a rule sets `alwaysApply: true` in its frontmatter,
13+
treat it as binding for **all** work in this repo unless the user explicitly overrides it in the
14+
current conversation.
15+
3. **When new rules are added** under `.cursor/rules/`, use them the same way: prefer the rule text
16+
over assumptions from similar projects.
17+
18+
## Current rule files (index)
19+
20+
| Path | Typical scope | Topic |
21+
| ------ | ---------------- | -------- |
22+
| `.cursor/rules/sunsynk-addons.mdc` | Home Assistant add-ons, add-on Python (`src/ha_addon_sunsynk_multi/`), `www/docs/reference/multi-options.md` | Edit **`hass-addon-sunsynk-edge/`** for packaging; do not hand-edit **`hass-addon-sunsynk-multi/`** unless the user explicitly asks for stable-multi work; keep Supervisor options, docs, and `options.py` in sync as described in the rule. |
23+
| `.cursor/rules/sunsynk-definitions.mdc` | `src/sunsynk/definitions/**` | Inverter Modbus definitions: profiles, inheritance, single- vs three-phase; how to extend `ALL_DEFS` and avoid wrong cross-profile register assumptions. |
24+
25+
If this index drifts, **trust the `.mdc` files**; update this table when you add or rename rules.
26+
27+
## Conflicts
28+
29+
If another document in the repo disagrees with a **`.cursor/rules/*.mdc`** rule,
30+
**follow the `.mdc` rule** unless the user or maintainer explicitly says otherwise in the current
31+
thread.

‎.pre-commit-config.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ repos:
3333

3434
- id: codespell
3535
name: codespell
36-
entry: uv run codespell --toml pyproject.toml
36+
entry: uv run codespell --toml pyproject.toml src www/docs www/README.md README.md scripts .github .cursor/rules hass-addon-sunsynk-edge hass-addon-sunsynk-multi hass-addon-mbusd
3737
language: system
3838
pass_filenames: false
3939

‎pyproject.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ lint.pylint.max-args = 7
7676

7777
[tool.codespell]
7878
ignore-words-list = "HASS,hass,Hass,ist"
79-
skip = "data,*.map,package-lock.json,*.map,**/cache/*,**/node_modules/*"
79+
# Scan roots are the paths on the codespell pre-commit hook (not the whole repo).
80+
skip = "*.map"
8081

8182
[tool.pyrefly]
8283
project-includes = [
@@ -99,6 +100,7 @@ warn_unreachable = false
99100
warn_unused_ignores = true # Not yet supported in 0.6.0
100101

101102
[tool.rumdl]
103+
include = [ "**/*.md", "**/*.mdc" ]
102104
respect-gitignore = true
103105
extend-enable = [ "MD060" ] # table formatting
104106
MD013.severity = "info"

‎scripts/gen_sensors.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ha_addon_sunsynk_multi.sensor_options import SENSOR_GROUPS
88
from sunsynk import RWSensor, Sensor, SensorDefinitions
99
from sunsynk.definitions import import_all_defs
10+
from sunsynk.helpers import ensure_tuple
1011
from sunsynk.utils import pretty_table, table_data
1112

1213

@@ -34,13 +35,15 @@ def generate_all_sensors(
3435
sensors[key]["Group"] = "<br>".join(sorted(sen_groups.get(sen.id, [])))
3536

3637
def getname(row: list[Sensor | str | None]) -> str:
37-
"""Get the name of the row."""
38+
"""Get the name of the row (HTML: name, then aliases on separate lines)."""
3839
for sen in row:
3940
if not sen or isinstance(sen, str):
4041
continue
41-
if isinstance(sen, RWSensor):
42-
return f"{sen.name} (R/W)"
43-
return sen.name
42+
base = f"{sen.name} (R/W)" if isinstance(sen, RWSensor) else sen.name
43+
aliases = ensure_tuple(sen.alias)
44+
if aliases:
45+
return "<br>".join((base, *aliases))
46+
return base
4447
return "?"
4548

4649
def get_sensor_info(sensor: Sensor | str | None) -> str:

‎src/ha_addon_sunsynk_multi/a_inverter.py‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class AInverter:
5858
"""Where this inverter is in the add-on poll loop (see `AInverterLifecycle`)."""
5959

6060
_stale_quiet_until: float = 0
61-
_stale_enter_at: float = float("inf")
61+
_stale_enter_at: float = 0
6262

6363
write_queue: dict[Sensor, str | int | float | bool] = field(default_factory=dict)
6464
"""Write queue for RWSensors."""
@@ -119,8 +119,12 @@ async def set_lifecycle(self, lifecycle: AInverterLifecycle | None) -> None:
119119
except Exception as err:
120120
_LOG.debug("Lifecycle availability publish failed: %s", err)
121121

122-
async def lifecycle_enter_stale(self, reason: str | None = None) -> None:
122+
async def lifecycle_enter_stale(self, reason: str | None = None) -> bool:
123123
"""Start or extend stale quiet: refresh deadline, set lifecycle, and log."""
124+
past_stale = time.monotonic() >= self._stale_enter_at
125+
if self.lifecycle in ("running", "starting") and not past_stale:
126+
return False
127+
124128
self._stale_quiet_until = time.monotonic() + OPT.stale_inverter_skip_seconds
125129
await self.set_lifecycle("stale_quiet")
126130
if reason is None:
@@ -131,6 +135,7 @@ async def lifecycle_enter_stale(self, reason: str | None = None) -> None:
131135
OPT.stale_inverter_skip_seconds,
132136
reason,
133137
)
138+
return True
134139

135140
async def lifecycle_attempt_recovery(self) -> None:
136141
"""Handle stale quiet or run a serial probe when quiet has elapsed."""
@@ -173,18 +178,22 @@ def serial_matches_config(self) -> bool:
173178
async def read_sensors(self, *, sensors: Iterable[Sensor], msg: str = "") -> bool:
174179
"""Read from the Modbus interface."""
175180
async with self.lock_io() as inv:
181+
await asyncio.sleep(0.005)
176182
try:
177-
await asyncio.sleep(0.005)
178-
await inv.read_sensors(sensors)
183+
async with asyncio.timeout(OPT.timeout * 2):
184+
await inv.read_sensors(sensors)
179185
self.read_errors = 0
180186
self._stale_enter_at = (
181187
time.monotonic() + OPT.stale_inverter_after_seconds
182188
)
183189
return True
190+
except TimeoutError:
191+
self.read_errors += 1
192+
await self.lifecycle_enter_stale("read timeout")
193+
return False
184194
except ExceptionGroup as err:
185195
self.read_errors += 1
186-
if time.monotonic() > self._stale_enter_at:
187-
await self.lifecycle_enter_stale()
196+
if await self.lifecycle_enter_stale():
188197
return False
189198
if msg:
190199
arg0, *argn = err.args if err.args else ("",)
@@ -301,7 +310,7 @@ def hass_create_discovery_info(self) -> None:
301310

302311
if self.mqtt_dev.id == "":
303312
self.mqtt_dev = MQTTDevice(
304-
identifiers=[("serial", serial_nr)],
313+
identifiers=[serial_nr],
305314
# https://github.com/kellerza/sunsynk/issues/165
306315
# name=f"{OPT.manufacturer} AInverter {serial_nr}",
307316
name=self.opt.ha_prefix,

‎src/sunsynk/definitions/single_phase.py‎

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -68,37 +68,47 @@
6868
Sensor(192, "Load frequency", HZ, 0.01),
6969
)
7070

71-
#################
72-
# AUX / Generator
73-
#################
71+
######################
72+
# AUX / Generator Port
73+
######################
7474
SENSORS += (
75-
Sensor(166, "AUX power", WATT, -1),
76-
# Sensor(166, "Generator Power", WATT, -1),
77-
Sensor(181, "AUX voltage", VOLT, 0.1),
78-
Sensor(196, "AUX frequency", HZ, 0.1),
75+
SelectRWSensor(
76+
235,
77+
"AUX port usage",
78+
"",
79+
options={0: "Disable", 1: "Smartload", 2: "Generator"},
80+
alias="Generator input",
81+
),
82+
Sensor(166, "Gen power", WATT, -1, alias="AUX power"),
83+
Sensor(181, "Gen L1 voltage", VOLT, 0.1, alias="AUX L1 voltage"),
84+
Sensor(182, "Gen L2 voltage", VOLT, 0.1, alias="AUX L2 voltage"),
85+
Sensor(183, "Gen L3 voltage", VOLT, 0.1, alias="AUX L3 voltage"),
86+
Sensor(184, "Gen L1 current", AMPS, -0.01, alias="AUX L1 current"),
87+
Sensor(185, "Gen L2 current", AMPS, -0.01, alias="AUX L2 current"),
88+
Sensor(186, "Gen L3 current", AMPS, -0.01, alias="AUX L3 current"),
7989
)
8090

81-
#############
82-
# Solar PV 1
83-
#############
91+
#####################
92+
# MPPT 1 Solar Power
93+
#####################
8494
SENSORS += (
8595
Sensor(186, "PV1 power", WATT, -1),
8696
Sensor(109, "PV1 voltage", VOLT, 0.1),
8797
Sensor(110, "PV1 current", AMPS, 0.1),
8898
)
8999

90-
#############
91-
# Solar PV 2
92-
#############
100+
#####################
101+
# MPPT2 Solar Power
102+
#####################
93103
SENSORS += (
94104
Sensor(187, "PV2 power", WATT, -1),
95105
Sensor(111, "PV2 voltage", VOLT, 0.1),
96106
Sensor(112, "PV2 current", AMPS, 0.1),
97107
)
98108

99-
#############
100-
# Solar PV 3
101-
#############
109+
#####################
110+
# MPPT 3 Solar Power
111+
#####################
102112
SENSORS += (
103113
Sensor(188, "PV3 power", WATT, -1),
104114
Sensor(113, "PV3 voltage", VOLT, 0.1),
@@ -182,9 +192,6 @@
182192
SwitchRWSensor(43, "Inverter enabled", on=1), # 0=off, 1=on
183193
Sensor(200, "Control mode"),
184194
SwitchRWSensor(232, "Grid charge enabled", "", bitmask=1),
185-
SelectRWSensor(
186-
235, "Generator input", "", options={0: "Disable", 1: "Output", 2: "Input"}
187-
),
188195
Sensor(312, "Battery charging voltage", VOLT, 0.01),
189196
Sensor(603, "Bat1 SOC", "%"),
190197
Sensor(611, "Bat1 Cycle"),

0 commit comments

Comments
 (0)