Skip to content

Commit a29cb36

Browse files
author
Maksym Shtembuliak
committed
fix: complete late collector identity and expose PI30 charge currents
1 parent 6e6ecd4 commit a29cb36

13 files changed

Lines changed: 600 additions & 16 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ the GitHub release body should be rendered from the matching version section her
99

1010
### Added
1111

12+
- PI30 devices without model-specific charge-current controls can expose
13+
**Max Total Charge Current** and **Max Utility Charge Current** in Full
14+
Control, using only the selectable values read from that inverter (#58).
15+
These writes remain untested. Two bounded read-only queries enrich detection;
16+
normal polling and existing model-specific controls are unchanged.
17+
1218
- Anenji GES48120M250-500P auto-binds through exact Sumry/GES identity anchors
1319
(model 45, product class 10, protocol raw 220) and exposes the existing
1420
nine-field read-only 0x7530 subset (#49, #55). Broader telemetry, derived
@@ -81,6 +87,14 @@ the GitHub release body should be rendered from the matching version section her
8187

8288
### Fixed
8389

90+
- An initially silent collector that reveals a short framed heartbeat can
91+
confirm its full PN through FC2 on the same socket instead of being closed
92+
and retried with another dialect (#23). A short PN alone still cannot claim
93+
a device; foreign identities, deadlines and cancellation remain guarded.
94+
- Enum controls discovered after HA platform setup are added without an entry
95+
reload. Updated device-specific choices replace stale options, and controls
96+
become unavailable when their live capability is withdrawn.
97+
8498
- Framed collector connections accept AT metadata replies preceded by CR/LF
8599
without treating them as oversized binary frames and disconnecting. Binary
86100
transaction IDs and payloads remain intact; this does not change inverter

‎custom_components/eybond_local/collector/session_identity_negotiator.py‎

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
normal callback attempt may try the alternate dialect. This prevents an AT
1010
query from following a framed query (or vice versa) on one stream while keeping
1111
the established one-``set>server``-per-attempt contract intact.
12+
A short heartbeat learned during FC1 permits a bounded FC2 identity upgrade on
13+
that now-known framed socket; it does not permit claiming the short identity.
1214
"""
1315

1416
from __future__ import annotations
@@ -25,6 +27,7 @@
2527
from .identity_probe import (
2628
PROBE_AT_DTUPN,
2729
PROBE_FRAMED_FC1,
30+
PROBE_FRAMED_FC2,
2831
silent_probe_kind_for_protocol,
2932
)
3033
from .silent_session_probe import SessionObservation
@@ -281,6 +284,48 @@ async def async_negotiate(
281284
session_protocol=protocol,
282285
identity_probe_kind=probe_kind,
283286
)
287+
post = next(
288+
(observation for observation in channel.snapshot_session_observations()
289+
if observation.session_id == selected.session_id),
290+
selected,
291+
)
292+
if (
293+
not identity_source_is_strong(post.identity_source)
294+
and unknown_wire
295+
and protocol == "eybond_framed"
296+
and post.identity_source == "framed_heartbeat"
297+
and validated_collector_pn(post.collector_pn)
298+
and post.state not in _TERMINAL_STATES
299+
and not post.state.startswith("closed")
300+
):
301+
# FC1 can reveal only a short PN on an initially silent socket.
302+
# This proves the framed dialect, NOT ownership. Upgrade that
303+
# exact stream with FC2, rather than discarding the observation
304+
# and trying AT on the next connection. Never mix dialects or
305+
# extend the caller's deadline; a failed upgrade stays weak.
306+
unknown_wire = False
307+
if not pn_is_same_identity(durable_pn, post.collector_pn):
308+
return ExactSessionIdentityResult(
309+
NEGOTIATION_FOREIGN_IDENTITY,
310+
session_id=selected.session_id,
311+
collector_pn=post.collector_pn,
312+
session_protocol=protocol,
313+
identity_source=post.identity_source,
314+
)
315+
remaining = deadline - loop.time()
316+
if remaining > 0:
317+
probe_kind = PROBE_FRAMED_FC2
318+
try:
319+
identified_pn = await asyncio.wait_for(
320+
channel.async_identify_exact_session(
321+
selected.session_id,
322+
session_protocol=protocol,
323+
identity_probe_kind=probe_kind,
324+
),
325+
timeout=remaining,
326+
)
327+
except asyncio.TimeoutError:
328+
identified_pn = ""
284329
except asyncio.CancelledError as original_cancel:
285330
if unknown_wire:
286331
try:
@@ -292,7 +337,17 @@ async def async_negotiate(
292337
pass
293338
raise original_cancel
294339

295-
strong_pn = validated_collector_pn(identified_pn)
340+
post = next(
341+
(observation for observation in channel.snapshot_session_observations()
342+
if observation.session_id == selected.session_id),
343+
selected,
344+
)
345+
strong_pn = (
346+
validated_collector_pn(post.collector_pn)
347+
if identity_source_is_strong(post.identity_source)
348+
and pn_is_same_identity(identified_pn, post.collector_pn)
349+
else ""
350+
)
296351
if strong_pn:
297352
if not pn_is_same_identity(durable_pn, strong_pn):
298353
return ExactSessionIdentityResult(
@@ -302,14 +357,6 @@ async def async_negotiate(
302357
session_protocol=protocol,
303358
probe_kind=probe_kind,
304359
)
305-
post = next(
306-
(
307-
observation
308-
for observation in channel.snapshot_session_observations()
309-
if observation.session_id == selected.session_id
310-
),
311-
selected,
312-
)
313360
self._reset_unknown_candidate()
314361
return ExactSessionIdentityResult(
315362
NEGOTIATION_IDENTIFIED,
@@ -337,7 +384,7 @@ async def async_negotiate(
337384
NEGOTIATION_PROBE_FAILED,
338385
session_id=selected.session_id,
339386
session_protocol=protocol,
340-
identity_source=selected.identity_source,
387+
identity_source=post.identity_source,
341388
probe_kind=probe_kind,
342389
)
343390

‎custom_components/eybond_local/drivers/pi30.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
unsupported_commands as _unsupported_commands,
6767
)
6868
from .read_result import DriverReadMode, DriverReadResult
69+
from .pi30_controls import async_enrich_charge_current_controls
6970
from .support_probe import SupportProbeRequest
7071

7172

@@ -270,6 +271,7 @@ async def async_probe_signature(self, transport, target: ProbeTarget) -> bool:
270271
)
271272

272273
async def async_probe(self, transport, target: ProbeTarget) -> DetectedInverter | None:
274+
started = time.monotonic()
273275
session = self._session(transport, target)
274276
try:
275277
probe = await async_probe_ascii_catalog(
@@ -315,6 +317,12 @@ async def async_probe(self, transport, target: ProbeTarget) -> DetectedInverter
315317
config_values.update(serial_identity.as_details())
316318

317319
capabilities = _build_pi30_capabilities(config_values, profile.capabilities)
320+
# Enrichment must not extend the existing driver probe window or make
321+
# unsupported optional queries a reason to lose positive PI30 identity.
322+
capabilities = await async_enrich_charge_current_controls(
323+
session, config_values, capabilities,
324+
timeout=min(3.0, max(0.0, self.probe_timeout - (time.monotonic() - started) - 0.1)),
325+
)
318326
_translate_capability_enum_values(config_values, capabilities)
319327

320328
return DetectedInverter(
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Device-advertised PI30 charge-current controls, not a retail model guess.
2+
3+
PI30 Communication Protocol 2015-09-24, sections 2.16/2.17 and 3.3/3.5,
4+
defines selectable-value queries and the corresponding three-digit writes.
5+
Only missing controls are enriched after positive protocol identification.
6+
Existing model-specific controls (including their tested status) are preserved.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
import re
13+
14+
from ..models import CapabilityChoice, WriteCapability
15+
from ..payload.pi30 import Pi30Error
16+
17+
18+
_CONTROLS = (
19+
("max_charging_current", "QMCHGCR", "MCHGC", "Max Total Charge Current"),
20+
("max_ac_charging_current", "QMUCHGCR", "MUCHGC", "Max Utility Charge Current"),
21+
)
22+
23+
24+
def parse_charge_current_choices(payload: str) -> tuple[int, ...]:
25+
"""Accept a bounded list of distinct three-digit positive ampere values."""
26+
27+
if not isinstance(payload, str) or not re.fullmatch(r"[0-9]{3}(?: [0-9]{3}){0,63}", payload):
28+
raise Pi30Error("invalid_charge_current_choices")
29+
values = tuple(int(field) for field in payload.split(" "))
30+
if 0 in values or len(set(values)) != len(values):
31+
raise Pi30Error("invalid_charge_current_choices")
32+
return values
33+
34+
35+
async def async_enrich_charge_current_controls(
36+
session, values: dict, capabilities: tuple[WriteCapability, ...], *, timeout: float,
37+
) -> tuple[WriteCapability, ...]:
38+
"""Read at most two missing lists within a shared optional time budget.
39+
40+
A timeout/NAK/malformed list never invalidates an already detected inverter.
41+
This is not part of the signature scan or regular polling. Writes remain
42+
untested/Full-Control-only and accept only the device's advertised values.
43+
"""
44+
45+
result = list(capabilities)
46+
existing = {cap.key for cap in capabilities}
47+
loop = asyncio.get_running_loop()
48+
deadline = loop.time() + max(0.0, timeout)
49+
for index, (key, query, command, title) in enumerate(_CONTROLS):
50+
current = values.get(key)
51+
if key in existing or type(current) is not int or current <= 0:
52+
continue
53+
remaining = deadline - loop.time()
54+
if remaining <= 0:
55+
break
56+
try:
57+
payload = await asyncio.wait_for(session.request(query), timeout=min(1.5, remaining))
58+
choices = parse_charge_current_choices(payload)
59+
except Exception: # Optional query/parser failure; cancellation is BaseException.
60+
continue
61+
if current not in choices:
62+
continue
63+
result.append(WriteCapability(
64+
key=key, register=0, value_kind="enum", command=command, command_width=3,
65+
note=f"PI30 {query} advertised these values on this device; local writes are untested.",
66+
tested=False, provenance="doc_backed", support_tier="conditional",
67+
title=title, group="battery", order=260 + 10 * index, unit="A",
68+
enabled_default=True, requires_confirm=True,
69+
change_summary="Limits battery charging current to a value advertised by this inverter.",
70+
choices=tuple(CapabilityChoice(value=value, label=f"{value} A", order=order)
71+
for order, value in enumerate(choices)),
72+
))
73+
return tuple(result)

‎custom_components/eybond_local/select.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,28 @@ async def async_setup_entry(
122122
],
123123
]
124124
)
125+
# Some protocol controls are qualified from device-advertised choices only
126+
# after the startup snapshot has built the platforms. Add newly confirmed
127+
# enum surfaces without reloading the entry (which would lose that live
128+
# evidence and recreate the same startup gap). Stable IDs remain unchanged.
129+
added_keys = {capability.key for capability in exposable_capabilities}
130+
131+
def add_live_capabilities() -> None:
132+
inverter = coordinator.identified_inverter
133+
if inverter is None:
134+
return
135+
additions = [
136+
capability for capability in inverter.capabilities
137+
if capability.key not in added_keys
138+
and capability.value_kind == "enum" and capability.enum_value_map
139+
and coordinator.can_expose_capability(capability)
140+
]
141+
if additions:
142+
added_keys.update(capability.key for capability in additions)
143+
async_add_entities([EybondCapabilitySelect(coordinator, cap) for cap in additions])
144+
145+
entry.async_on_unload(coordinator.async_add_listener(add_live_capabilities))
146+
add_live_capabilities()
125147

126148

127149
class EybondRuntimeSettingSelect(CoordinatorEntity[EybondLocalCoordinator], SelectEntity):
@@ -203,6 +225,15 @@ def __init__(
203225
def device_info(self):
204226
return self.coordinator.inverter_device_info()
205227

228+
def _handle_coordinator_update(self) -> None:
229+
inverter = self.coordinator.data.inverter
230+
if inverter is not None:
231+
current = next((cap for cap in inverter.capabilities if cap.key == self._capability.key), None)
232+
if current is not None and current.value_kind == "enum":
233+
self._capability = current
234+
self._attr_options = current.enum_options
235+
super()._handle_coordinator_update()
236+
206237
@property
207238
def available(self) -> bool:
208239
snapshot = self.coordinator.data
@@ -211,6 +242,8 @@ def available(self) -> bool:
211242
return False
212243
if not any(cap.key == self._capability.key for cap in inverter.capabilities):
213244
return False
245+
if not self.coordinator.can_expose_capability(self._capability):
246+
return False
214247
return self._capability.runtime_state(snapshot.runtime_values()).visible
215248

216249
@property

‎docs/architecture/CONNECTION_ARCHITECTURE.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ wire from the *observed* session, not from a persisted `collector_session_protoc
200200
hint. Untrusted states (`route_identity_mismatch`, `waiting_for_route_identity`,
201201
`parked_*`, `closed_*`) can never override a claimed, routed session.
202202

203+
If a first-contact FC1 probe on an initially silent socket reveals only a
204+
short framed heartbeat, the exact-session negotiator may send one FC2 PN query
205+
on that same now-observed framed stream, within the caller's remaining deadline.
206+
This is a same-dialect identity upgrade, not an AT fallback or permission to
207+
claim the weak PN. Failure preserves the known framed candidate for a later
208+
normal attempt; only a still-unknown dialect is retired before trying another.
209+
203210
The framed runtime reader is fail-closed at the physical socket boundary. An
204211
idle socket may remain silent indefinitely, but once the first byte of a frame
205212
arrives, header and payload completion are bounded and the decoded local-runtime

‎docs/user/RUNTIME_AND_INVERTER.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,21 @@ Persistent failures still need investigation: create a Support Archive while
9999
the problem is present. It includes failed command names and the age of the
100100
last confirmed basic readings. Retained values are not new measurements.
101101

102+
### PI30: charge-current settings
103+
104+
In **Full Control**, compatible PI30 inverters can show **Max Total Charge
105+
Current** and **Max Utility Charge Current**. The first limits total battery
106+
charging current; the second limits charging from the grid. They are dropdowns,
107+
not unrestricted number inputs: available values come from the inverter itself.
108+
109+
For devices without an existing model-specific control map, these controls are
110+
marked **untested** until local writes are confirmed. Enabling Full Control does
111+
not change any inverter setting. If the inverter cannot supply a valid list of
112+
choices, the corresponding new control is not offered. Existing model-specific
113+
controls are unchanged. A readable buzzer/backlight state does not guarantee
114+
that the inverter accepts its enable/disable command; a `NAK` is a rejection,
115+
not a successful change.
116+
102117
## MUST PV/PH18 power and energy corrections
103118

104119
The unreleased test build corrects several readings in the MUST PV/PH18 map:

0 commit comments

Comments
 (0)