Skip to content

Commit c3b2c23

Browse files
author
Maksym Shtembuliak
committed
fix: negotiate fully silent callback sessions
1 parent af510bb commit c3b2c23

17 files changed

Lines changed: 1271 additions & 176 deletions

‎CHANGELOG.md‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ the GitHub release body should be rendered from the matching version section her
1515
- Added a typed support-acquisition boundary so read-only cloud evidence can be
1616
collected for an identified collector before an inverter driver is known.
1717
- Added 118 opt-in, document-backed settings for the exact Kevolt
18-
PD0080G-TPM-EU / Deye-compatible 80 kW fingerprint. They remain untested,
18+
PD0080G-TPM-EU / Deye-compatible 8 kW fingerprint. They remain untested,
1919
hidden in Auto mode, and disabled by default; six Time of Use schedule fields
2020
use native Home Assistant time entities.
2121

@@ -33,6 +33,26 @@ the GitHub release body should be rendered from the matching version section her
3333

3434
### Fixed
3535

36+
- Fixed fully silent callback collectors that open a TCP session without an
37+
initial payload. Home Assistant now performs one bounded FC=1 identity
38+
challenge on the exact observed session, accepts only a correlated full-PN
39+
response, and never falls back to peer-IP routing. Unknown wire dialects are
40+
tried only on separate callback attempts and separate physical sockets.
41+
- Correct the Anenji HHS-11kW non-parallel Grid Power source for the exact
42+
layout-3/model-29440 fingerprint: hardware-correlated cloud traffic reads
43+
holding register 340, not the compatible-family register 204.
44+
45+
- Corrected the Kevolt PD0080G-TPM-EU rated-power scale from 80 kW to its
46+
hardware-confirmed 8 kW rating without changing the stable internal
47+
fingerprint key or its already-correct battery-value sign.
48+
- Treat a catalog-declared previous model name as the same runtime inverter
49+
identity after a display-name correction. Unknown and ambiguous aliases still
50+
fail closed instead of replacing a durable binding.
51+
- Fixed the admission failure menu so switching a failed selected-route attempt
52+
to manual setup starts a fresh callback transaction instead of raising an
53+
internal lifecycle error into the Home Assistant UI.
54+
- Preserve the exact collector-management error in the normal Home Assistant
55+
log when restart confirmation fails during recovery verification.
3656
- Renamed the four optional PI30 Q1 temperature channels to neutral numbered
3757
names. Clone manufacturers can wire the same protocol fields to different
3858
physical components, so the integration no longer presents an inferred
@@ -213,7 +233,7 @@ the GitHub release body should be rendered from the matching version section her
213233
hardware evidence:
214234
- **Gootu GT-H2436M14P5** — exact G-ASCII fingerprint, telemetry, and only the
215235
charging-priority writes confirmed by the captured device.
216-
- **Kevolt PD0080G-TPM-EU 80 kW** — exact Deye-compatible high-register
236+
- **Kevolt PD0080G-TPM-EU 8 kW** — exact Deye-compatible high-register
217237
fingerprint and read-only three-phase, PV, battery, generator, and energy
218238
telemetry.
219239
- **Anenji ANJ-11KW-48V-WIFI-P** — documented Secondary Output Priority
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Typed collector-identity probes for one exact TCP session.
2+
3+
This module owns the wire shape of identity challenges, but no socket selection,
4+
callback routing, ownership, or retry policy. A caller must already hold the
5+
authority to write to one exact session. No probe changes the collector's
6+
endpoint, UART settings, or durable configuration; FC=1 carries the protocol's
7+
ordinary UTC/heartbeat-interval handshake fields.
8+
9+
The framed probes are deliberately distinct:
10+
11+
* ``framed_fc2`` upgrades a socket that has already volunteered a framed
12+
heartbeat by reading collector parameter 2;
13+
* ``framed_fc1`` bootstraps a completely silent framed collector with the same
14+
FC=1 request used by the original EyeBond server protocol;
15+
* ``at_dtupn`` reads the PN from a known or candidate AT-text session.
16+
17+
Only a response correlated to the request's transaction/function code becomes
18+
the strong ``fc1_identity_challenge`` evidence. An unsolicited FC=1 heartbeat
19+
continues to be weak ``framed_heartbeat`` evidence elsewhere in the transport.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from dataclasses import dataclass
25+
import re
26+
27+
from ..collector_identity import validated_collector_pn
28+
from .at import build_at_query
29+
from .protocol import (
30+
FC_HEARTBEAT,
31+
FC_QUERY_COLLECTOR,
32+
HEADER_SIZE,
33+
build_collector_request,
34+
build_heartbeat_request,
35+
decode_header,
36+
)
37+
38+
PROBE_FRAMED_FC1 = "framed_fc1"
39+
PROBE_FRAMED_FC2 = "framed_fc2"
40+
PROBE_AT_DTUPN = "at_dtupn"
41+
42+
_PROBE_KINDS = frozenset(
43+
{PROBE_FRAMED_FC1, PROBE_FRAMED_FC2, PROBE_AT_DTUPN}
44+
)
45+
_AT_DTUPN_RE = re.compile(
46+
rb"AT\+DTUPN\s*[:=]\s*([A-Za-z0-9][A-Za-z0-9._-]{5,63})(?![A-Za-z0-9._-])"
47+
)
48+
_IDENTITY_TID = 1
49+
_IDENTITY_HEARTBEAT_INTERVAL_SECONDS = 60
50+
51+
52+
@dataclass(frozen=True, slots=True)
53+
class IdentityProbeRequest:
54+
"""One typed identity request and its expected response dialect."""
55+
56+
session_protocol: str
57+
probe_kind: str
58+
payload: bytes
59+
transaction_id: int = 0
60+
function_code: int = 0
61+
62+
def __post_init__(self) -> None:
63+
if type(self.session_protocol) is not str or self.session_protocol not in {
64+
"eybond_framed",
65+
"at_text",
66+
}:
67+
raise ValueError("identity_probe_session_protocol_invalid")
68+
if type(self.probe_kind) is not str or self.probe_kind not in _PROBE_KINDS:
69+
raise ValueError("identity_probe_kind_invalid")
70+
if type(self.payload) is not bytes or not self.payload:
71+
raise ValueError("identity_probe_payload_invalid")
72+
if type(self.transaction_id) is not int or self.transaction_id < 0:
73+
raise ValueError("identity_probe_transaction_id_invalid")
74+
if type(self.function_code) is not int or self.function_code < 0:
75+
raise ValueError("identity_probe_function_code_invalid")
76+
77+
78+
def default_probe_kind_for_protocol(session_protocol: object) -> str:
79+
"""Return the established known-wire identity query, or ``""``."""
80+
81+
if session_protocol == "eybond_framed":
82+
return PROBE_FRAMED_FC2
83+
if session_protocol == "at_text":
84+
return PROBE_AT_DTUPN
85+
return ""
86+
87+
88+
def silent_probe_kind_for_protocol(session_protocol: object) -> str:
89+
"""Return the first-contact query for a fully silent candidate wire."""
90+
91+
if session_protocol == "eybond_framed":
92+
return PROBE_FRAMED_FC1
93+
if session_protocol == "at_text":
94+
return PROBE_AT_DTUPN
95+
return ""
96+
97+
98+
def build_identity_probe_request(
99+
session_protocol: object,
100+
*,
101+
probe_kind: object = "",
102+
) -> IdentityProbeRequest | None:
103+
"""Build one strict probe request without coercing protocol authority."""
104+
105+
if type(session_protocol) is not str:
106+
return None
107+
protocol = session_protocol
108+
if protocol not in {"eybond_framed", "at_text"}:
109+
return None
110+
if type(probe_kind) is str and probe_kind == "":
111+
kind = default_probe_kind_for_protocol(protocol)
112+
elif type(probe_kind) is str and probe_kind in _PROBE_KINDS:
113+
kind = probe_kind
114+
else:
115+
return None
116+
117+
if kind == PROBE_AT_DTUPN and protocol == "at_text":
118+
return IdentityProbeRequest(
119+
session_protocol=protocol,
120+
probe_kind=kind,
121+
payload=build_at_query("DTUPN"),
122+
)
123+
if kind == PROBE_FRAMED_FC2 and protocol == "eybond_framed":
124+
return IdentityProbeRequest(
125+
session_protocol=protocol,
126+
probe_kind=kind,
127+
payload=build_collector_request(
128+
_IDENTITY_TID,
129+
b"\x02",
130+
devcode=1,
131+
collector_addr=1,
132+
fcode=FC_QUERY_COLLECTOR,
133+
),
134+
transaction_id=_IDENTITY_TID,
135+
function_code=FC_QUERY_COLLECTOR,
136+
)
137+
if kind == PROBE_FRAMED_FC1 and protocol == "eybond_framed":
138+
return IdentityProbeRequest(
139+
session_protocol=protocol,
140+
probe_kind=kind,
141+
payload=build_heartbeat_request(
142+
_IDENTITY_TID,
143+
_IDENTITY_HEARTBEAT_INTERVAL_SECONDS,
144+
),
145+
transaction_id=_IDENTITY_TID,
146+
function_code=FC_HEARTBEAT,
147+
)
148+
return None
149+
150+
151+
def parse_identity_probe_response(
152+
request: IdentityProbeRequest,
153+
response: bytes,
154+
) -> tuple[str, str]:
155+
"""Return ``(full_pn, strong_source)`` for one correlated response."""
156+
157+
if type(request) is not IdentityProbeRequest or type(response) is not bytes:
158+
return "", ""
159+
160+
if request.probe_kind == PROBE_AT_DTUPN:
161+
match = _AT_DTUPN_RE.search(response)
162+
if not match:
163+
return "", ""
164+
pn = validated_collector_pn(match.group(1).decode("ascii"))
165+
return (pn, "at_dtupn") if pn else ("", "")
166+
167+
if len(response) < HEADER_SIZE:
168+
return "", ""
169+
try:
170+
header = decode_header(response[:HEADER_SIZE])
171+
except Exception:
172+
return "", ""
173+
if (
174+
header.tid != request.transaction_id
175+
or header.fcode != request.function_code
176+
or header.total_len != len(response)
177+
or header.payload_len <= 0
178+
):
179+
return "", ""
180+
payload = response[HEADER_SIZE:]
181+
182+
if request.probe_kind == PROBE_FRAMED_FC2:
183+
if len(payload) < 3 or payload[1] != 2:
184+
return "", ""
185+
raw_pn = payload[2:].rstrip(b"\x00")
186+
source = "fc2_parameter_2"
187+
elif request.probe_kind == PROBE_FRAMED_FC1:
188+
raw_pn = payload.rstrip(b"\x00")
189+
source = "fc1_identity_challenge"
190+
else:
191+
return "", ""
192+
193+
try:
194+
pn = validated_collector_pn(raw_pn.decode("ascii"))
195+
except UnicodeDecodeError:
196+
return "", ""
197+
return (pn, source) if pn else ("", "")
198+
199+
200+
__all__ = [
201+
"IdentityProbeRequest",
202+
"PROBE_AT_DTUPN",
203+
"PROBE_FRAMED_FC1",
204+
"PROBE_FRAMED_FC2",
205+
"build_identity_probe_request",
206+
"default_probe_kind_for_protocol",
207+
"parse_identity_probe_response",
208+
"silent_probe_kind_for_protocol",
209+
]

0 commit comments

Comments
 (0)