Skip to content

Commit 8d043ac

Browse files
committed
alias
1 parent 58193d5 commit 8d043ac

8 files changed

Lines changed: 66 additions & 31 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
do not. Other I/O errors still flush.
1717
- Add a 50ms `connect_delay` for serial
1818
- Remove Gen L1/L2/L3 current sensors & check for duplicates. #676
19+
- Sensor **aliases** are registered as copies whose primary ``name`` is the alias (HA entity name
20+
follows the id you put in ``SENSORS``).
1921

2022
## Release 1.1.1
2123

‎scripts/gen_sensors.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from sunsynk import RWSensor, Sensor, SensorDefinitions
1010
from sunsynk.definitions import import_all_defs
1111
from sunsynk.helpers import ensure_tuple
12+
from sunsynk.sensors import ensure_slugs
1213
from sunsynk.utils import pretty_table, table_data
1314

1415

@@ -48,9 +49,10 @@ def generate_all_sensors(
4849
"""Generate groups/all.html."""
4950
sensors = defaultdict[str, dict[str, Sensor | str]](dict[str, Sensor | str])
5051
for name, defs in all_defs.items():
52+
alias_ids = {a for s in defs.all.values() for a in ensure_slugs(s.alias)}
5153
for key, sen in defs.all.items():
52-
if key != sen.id:
53-
continue # alias slug → same Sensor; Name column already lists aliases
54+
if key in alias_ids:
55+
continue # alias copy; Name column lists aliases on the canonical row
5456
sensors[key][name] = sen
5557
sensors[key]["Group"] = "<br>".join(sorted(sen_groups.get(sen.id, [])))
5658

‎src/ha_addon_sunsynk_multi/sensor_options.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
from sunsynk.definitions import import_defs
99
from sunsynk.helpers import slug
1010
from sunsynk.rwsensors import RWSensor
11-
from sunsynk.sensors import Constant, PVDynamicTotalSensor, Sensor, SensorDefinitions
11+
from sunsynk.sensors import (
12+
Constant,
13+
PVDynamicTotalSensor,
14+
Sensor,
15+
SensorDefinitions,
16+
ensure_slugs,
17+
)
1218

1319
from .helpers import import_mysensors
1420
from .options import OPT
@@ -370,5 +376,9 @@ def get_sensors(
370376

371377
# Add groups at the end
372378
for name in groups:
373-
names = list(DEFS.all) if name == "all" else SENSOR_GROUPS[name]
379+
if name == "all":
380+
alias_ids = {a for s in DEFS.all.values() for a in ensure_slugs(s.alias)}
381+
names = [k for k in DEFS.all if k not in alias_ids]
382+
else:
383+
names = SENSOR_GROUPS[name]
374384
yield from get_sensors(target=target, names=names, warn=False)

‎src/sunsynk/sensors.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class Sensor:
4040
"""Whether to trace changes for this sensor."""
4141

4242
alias: str | tuple[str, ...] | None = None
43-
"""Alternate name(s); each is registered in ``SensorDefinitions.all`` under ``slug(name)``."""
43+
"""Alternate name(s). Each is registered as a copy whose ``name`` is the alias."""
4444

4545
def __post_init__(self, address0: RegType | int) -> None:
4646
"""Post init."""
@@ -190,8 +190,9 @@ def __add__(self, item: Sensor | tuple[Sensor, ...] | list[Sensor]) -> Self:
190190
sensors = item if isinstance(item, (tuple, list)) else [item]
191191
for sen in sensors:
192192
self.all[sen.id] = sen
193-
for aid in ensure_slugs(sen.alias):
194-
self.all[aid] = sen
193+
for aname in ensure_tuple(sen.alias):
194+
aliased = replace(sen, address0=sen.address, name=aname, alias=None)
195+
self.all[aliased.id] = aliased
195196
return self
196197

197198
def copy(self) -> "SensorDefinitions":
@@ -206,9 +207,10 @@ def register_bit_conflicts(self) -> list[tuple[int, Sensor, Sensor, int]]:
206207
``MathSensor`` / ``PVDynamicTotalSensor`` intentionally reuse other
207208
sensors' registers and are skipped.
208209
"""
210+
alias_ids = {a for s in self.all.values() for a in ensure_slugs(s.alias)}
209211
by_reg: dict[int, list[Sensor]] = {}
210212
for key, sen in self.all.items():
211-
if key != sen.id or not sen.address:
213+
if key in alias_ids or key != sen.id or not sen.address:
212214
continue
213215
# Forward refs: MathSensor / PVDynamicTotalSensor defined below.
214216
if isinstance(sen, (MathSensor, PVDynamicTotalSensor)):

‎src/sunsynk/sunsynk.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
from dataclasses import dataclass, field
1010
from typing import Protocol, cast, runtime_checkable
1111

12-
from modbus_connection import ModbusSerialParams, ModbusTimeoutError
12+
from modbus_connection import (
13+
ModbusProtocolError,
14+
ModbusSerialParams,
15+
ModbusTimeoutError,
16+
)
1317

1418
from sunsynk.connection import ModbusConnection, open_connection
1519
from sunsynk.helpers import hex_str, patch_bitmask
@@ -120,7 +124,7 @@ async def write_register(self, *, address: int, value: int) -> bool:
120124
try:
121125
await self.unit.write_registers(address, [value])
122126
return True
123-
except TimeoutError:
127+
except ModbusTimeoutError:
124128
_LOG.warning("timeout writing register %s=%s", address, value)
125129
self.timeouts += 1
126130
except Exception as err:
@@ -164,11 +168,11 @@ async def read_holding_registers(self, start: int, length: int) -> Sequence[int]
164168
for attempt in range(self.read_attempts):
165169
try:
166170
return await self.unit.read_holding_registers(start, length)
167-
except TimeoutError as err:
171+
except ModbusTimeoutError as err:
168172
self.timeouts += 1
169173
errs.append(err)
170174
_LOG.error(
171-
"Read register %s (count %s): %s [attempt %s/%s]",
175+
"Read register %s x%s: %s [attempt %s/%s]",
172176
start,
173177
length,
174178
_exc_label(err),
@@ -179,10 +183,18 @@ async def read_holding_registers(self, start: int, length: int) -> Sequence[int]
179183
self.connection._params, ModbusSerialParams
180184
):
181185
await self._flush_modbus_connection(reason=type(err).__name__)
186+
except ModbusProtocolError:
187+
_LOG.error(
188+
"Read register %s x%s: protocol error [attempt %s/%s]",
189+
start,
190+
length,
191+
attempt + 1,
192+
self.read_attempts,
193+
)
182194
except Exception as err:
183195
errs.append(err)
184196
_LOG.error(
185-
"Read register %s (count %s): %s [attempt %s/%s]",
197+
"Read register %s x%s: %s [attempt %s/%s]",
186198
start,
187199
length,
188200
_exc_label(err),

‎src/tests/sunsynk/test_connection.py‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
GatewayTargetError,
99
ModbusSerialParams,
1010
ModbusTcpParams,
11+
ModbusTimeoutError,
1112
ModbusUdpParams,
1213
)
1314
from modbus_connection.mock import MockModbusConnection
@@ -133,7 +134,7 @@ async def test_from_url_mock_unit(state: InverterState) -> None:
133134
async def test_write_register_timeout(state: InverterState) -> None:
134135
"""Timeouts on write increment the counter and return False after retries."""
135136
unit = MagicMock()
136-
unit.write_registers = AsyncMock(side_effect=TimeoutError)
137+
unit.write_registers = AsyncMock(side_effect=ModbusTimeoutError)
137138
conn = MagicMock()
138139
conn.connected = True
139140
conn.disconnect = AsyncMock()
@@ -147,7 +148,7 @@ async def test_write_register_timeout(state: InverterState) -> None:
147148
async def test_read_holding_registers_timeout(state: InverterState) -> None:
148149
"""Serial timeouts increment the counter, flush, retry, then raise ExceptionGroup."""
149150
unit = MagicMock()
150-
unit.read_holding_registers = AsyncMock(side_effect=TimeoutError)
151+
unit.read_holding_registers = AsyncMock(side_effect=ModbusTimeoutError)
151152
conn = MagicMock()
152153
conn.connected = True
153154
conn.disconnect = AsyncMock()
@@ -165,7 +166,7 @@ async def test_read_holding_registers_timeout_tcp_does_not_flush(
165166
) -> None:
166167
"""TCP timeouts still retry but leave the socket up."""
167168
unit = MagicMock()
168-
unit.read_holding_registers = AsyncMock(side_effect=TimeoutError)
169+
unit.read_holding_registers = AsyncMock(side_effect=ModbusTimeoutError)
169170
conn = MagicMock()
170171
conn.connected = True
171172
conn.disconnect = AsyncMock()
@@ -182,7 +183,7 @@ async def test_read_holding_registers_timeout_without_connection(
182183
) -> None:
183184
"""Solarman-style units have no ModbusConnection to flush."""
184185
unit = MagicMock()
185-
unit.read_holding_registers = AsyncMock(side_effect=TimeoutError)
186+
unit.read_holding_registers = AsyncMock(side_effect=ModbusTimeoutError)
186187
ss = Sunsynk(unit=unit, state=state)
187188
with pytest.raises(ExceptionGroup, match="Failed to read"):
188189
await ss.read_holding_registers(1, 1)
@@ -194,7 +195,7 @@ async def test_read_holding_registers_timeout_then_success(
194195
) -> None:
195196
"""A later attempt can still succeed after a timeout flush."""
196197
unit = MagicMock()
197-
unit.read_holding_registers = AsyncMock(side_effect=[TimeoutError, [9, 8]])
198+
unit.read_holding_registers = AsyncMock(side_effect=[ModbusTimeoutError, [9, 8]])
198199
conn = MagicMock()
199200
conn.connected = True
200201
conn.disconnect = AsyncMock()
@@ -208,7 +209,7 @@ async def test_read_holding_registers_timeout_then_success(
208209
async def test_read_holding_registers_one_attempt(state: InverterState) -> None:
209210
"""read_attempts=1 raises after a single timeout."""
210211
unit = MagicMock()
211-
unit.read_holding_registers = AsyncMock(side_effect=TimeoutError)
212+
unit.read_holding_registers = AsyncMock(side_effect=ModbusTimeoutError)
212213
ss = Sunsynk(unit=unit, state=state, read_attempts=1)
213214
with pytest.raises(ExceptionGroup, match="Failed to read"):
214215
await ss.read_holding_registers(1, 1)
@@ -220,14 +221,14 @@ async def test_read_holding_registers_logs_empty_timeout(
220221
state: InverterState,
221222
caplog: pytest.LogCaptureFixture,
222223
) -> None:
223-
"""Empty TimeoutError strings must still log the exception type."""
224+
"""Empty ModbusTimeoutError strings must still log the exception type."""
224225
unit = MagicMock()
225-
unit.read_holding_registers = AsyncMock(side_effect=TimeoutError())
226+
unit.read_holding_registers = AsyncMock(side_effect=ModbusTimeoutError())
226227
ss = Sunsynk(unit=unit, state=state)
227228
with caplog.at_level(logging.ERROR):
228229
with pytest.raises(ExceptionGroup):
229230
await ss.read_holding_registers(0, 8)
230-
assert "Read register 0 (count 8): TimeoutError" in caplog.text
231+
assert "Read register 0 x8: ModbusTimeoutError" in caplog.text
231232
assert "(retry" not in caplog.text
232233

233234

‎src/tests/sunsynk/test_sensors.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,23 +281,29 @@ def test_decode_fault() -> None:
281281

282282

283283
def test_sensor_alias_registered_as_slug() -> None:
284-
"""String ``alias`` is one alternate name; registry keys match ``slug()`` (config / get_sensors)."""
284+
"""String ``alias`` is one alternate name; that copy uses the alias as ``name``."""
285285
sen = SensorDefinitions()
286286
s = Sensor(1, "Gen power", WATT, -1, alias="AUX power")
287287
sen += s
288288
assert sen.all["gen_power"] is s
289-
assert sen.all["aux_power"] is s
289+
aux = sen.all["aux_power"]
290+
assert aux is not s
291+
assert aux.name == "AUX power"
292+
assert aux.id == "aux_power"
293+
assert aux.address == s.address
294+
assert aux.alias is None
290295
assert set(sen.all) == {"gen_power", "aux_power"}
291296

292297

293298
def test_sensor_alias_tuple() -> None:
294-
"""Multiple alternate names from a tuple each get a slug key."""
299+
"""Each alias is a copy whose primary name is that alias."""
295300
sen = SensorDefinitions()
296301
s = Sensor(1, "X", WATT, -1, alias=("Alt one", "Alt two"))
297302
sen += s
298303
assert sen.all["x"] is s
299-
assert sen.all["alt_one"] is s
300-
assert sen.all["alt_two"] is s
304+
assert sen.all["alt_one"].name == "Alt one"
305+
assert sen.all["alt_two"].name == "Alt two"
306+
assert sen.all["alt_one"].address == s.address
301307

302308

303309
def test_source() -> None:

‎www/docs/reference/groups/all.html‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,15 +200,15 @@
200200
<td>[235]</td>
201201
<td>[133]</td>
202202
<td>[133]</td>
203-
<td>generator</td>
203+
<td></td>
204204
</tr>
205205
<tr>
206206
<td>Gen power<br>AUX power</td>
207207
<td>[166] S</td>
208208
<td>[166] S</td>
209209
<td>[667,671] S</td>
210210
<td>[667,671] S</td>
211-
<td>power_flow_card</td>
211+
<td></td>
212212
</tr>
213213
<tr>
214214
<td>PV1 power</td>
@@ -544,7 +544,7 @@
544544
<td>[194]</td>
545545
<td>[552] & 0x04</td>
546546
<td>[552] & 0x04</td>
547-
<td>diagnostics<br>power_flow_card</td>
547+
<td>power_flow_card</td>
548548
</tr>
549549
<tr>
550550
<td>Date time (R/W)</td>
@@ -1752,7 +1752,7 @@
17521752
<td></td>
17531753
<td>[146] & 0x01</td>
17541754
<td>[146] & 0x01</td>
1755-
<td>power_flow_card<br>settings</td>
1755+
<td></td>
17561756
</tr>
17571757
<tr>
17581758
<td>Prog Monday Enabled (R/W)</td>

0 commit comments

Comments
 (0)