Skip to content

Commit 308251b

Browse files
author
Maksym Shtembuliak
committed
fix: retain typed SMG no-match diagnostics
1 parent b8cc742 commit 308251b

9 files changed

Lines changed: 804 additions & 24 deletions

File tree

‎custom_components/eybond_local/drivers/base.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from abc import ABC, abstractmethod
6+
from dataclasses import dataclass
67
from typing import Any
78

89
from ..link_transport import PayloadLinkTransport
@@ -20,9 +21,29 @@
2021
from .support_probe import SupportProbeRequest
2122
from .read_result import DriverReadResult
2223
from .local_register_evidence import LocalRegisterSnapshot
24+
from .probe_evidence import DriverProbeEvidence
2325
from .write_error import EMPTY_WRITE_ERROR_CLASSIFICATION, WriteErrorClassification
2426

2527

28+
@dataclass(frozen=True, slots=True)
29+
class DriverProbeAttempt:
30+
"""One driver probe result plus optional typed no-match evidence."""
31+
32+
inverter: DetectedInverter | None
33+
no_match_evidence: DriverProbeEvidence | None = None
34+
35+
def __post_init__(self) -> None:
36+
if self.inverter is not None and type(self.inverter) is not DetectedInverter:
37+
raise TypeError("invalid_detected_inverter")
38+
if (
39+
self.no_match_evidence is not None
40+
and type(self.no_match_evidence) is not DriverProbeEvidence
41+
):
42+
raise TypeError("invalid_probe_no_match_evidence")
43+
if self.inverter is not None and self.no_match_evidence is not None:
44+
raise ValueError("matched_probe_cannot_have_no_match_evidence")
45+
46+
2647
class InverterDriver(ABC):
2748
"""A probeable inverter payload driver."""
2849

@@ -179,6 +200,17 @@ async def async_probe(
179200
) -> DetectedInverter | None:
180201
"""Try to identify a matching inverter behind the collector."""
181202

203+
async def async_probe_with_evidence(
204+
self,
205+
transport: PayloadLinkTransport,
206+
target: ProbeTarget,
207+
) -> DriverProbeAttempt:
208+
"""Probe with optional typed diagnostics; default preserves old drivers."""
209+
210+
return DriverProbeAttempt(
211+
inverter=await self.async_probe(transport, target),
212+
)
213+
182214
@abstractmethod
183215
async def async_read_values(
184216
self,

‎custom_components/eybond_local/drivers/catalog_identity.py‎

Lines changed: 139 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
build_descriptor_decision_report_from_catalog_identity_probe,
2929
)
3030
from .catalog_probe import async_walk_detection_dag
31+
from .probe_evidence import DriverProbeEvidence, ProbeActionFailure
3132

3233
logger = logging.getLogger(__name__)
3334

@@ -113,16 +114,46 @@ def as_details(self) -> dict[str, Any]:
113114
return payload
114115

115116

117+
@dataclass(frozen=True, slots=True)
118+
class CatalogIdentityAttempt:
119+
"""One identity attempt plus safe evidence when it could not resolve."""
120+
121+
probe: CatalogIdentityProbe | None
122+
no_match_evidence: DriverProbeEvidence | None = None
123+
124+
def __post_init__(self) -> None:
125+
if self.probe is not None and type(self.probe) is not CatalogIdentityProbe:
126+
raise TypeError("invalid_catalog_identity_probe")
127+
if (
128+
self.no_match_evidence is not None
129+
and type(self.no_match_evidence) is not DriverProbeEvidence
130+
):
131+
raise TypeError("invalid_catalog_identity_evidence")
132+
133+
116134
async def async_probe_catalog_identity(
117135
session: Any,
118136
*,
119137
transport_key: str = DEFAULT_TRANSPORT_KEY,
120138
) -> CatalogIdentityProbe | None:
139+
"""Return the legacy probe projection for callers that need no diagnostics."""
140+
141+
attempt = await async_probe_catalog_identity_attempt(
142+
session,
143+
transport_key=transport_key,
144+
)
145+
return attempt.probe
146+
147+
148+
async def async_probe_catalog_identity_attempt(
149+
session: Any,
150+
*,
151+
transport_key: str = DEFAULT_TRANSPORT_KEY,
152+
) -> CatalogIdentityAttempt:
121153
"""Read the identity window and match it against the device catalog.
122154
123-
Returns ``None`` when no identity block could be read at all (a non-modbus
124-
device, or transport failure) — callers must treat that as "no opinion",
125-
NOT as link-down.
155+
The attempt retains only typed identity facts and typed action failures.
156+
It never records raw payloads, endpoint details, or arbitrary exceptions.
126157
"""
127158

128159
compiled_catalog = load_compiled_detection_catalog()
@@ -135,11 +166,12 @@ async def async_probe_catalog_identity(
135166
None,
136167
)
137168
if protocol is None:
138-
return None
169+
return CatalogIdentityAttempt(probe=None)
139170

140171
words: dict[int, int] = {}
141172
raw_fields: dict[str, object] = {}
142173
evidence: dict[str, object] = {}
174+
action_failures: list[ProbeActionFailure] = []
143175
tree = compiled_catalog.decision_trees[protocol.key]
144176

145177
async def _execute(action) -> str:
@@ -159,6 +191,9 @@ async def _execute(action) -> str:
159191
except Exception as exc: # pylint: disable=broad-except
160192
last_error = exc
161193
if values is None:
194+
action_failures.append(
195+
_probe_action_failure(action.key, last_error)
196+
)
162197
logger.debug(
163198
"Catalog identity action failed action=%s error=%s",
164199
action.key,
@@ -187,19 +222,41 @@ async def _execute(action) -> str:
187222
failed_actions = list(walk.failed_actions)
188223
failed_evidence = set(walk.failed_evidence) | set(walk.unsupported_evidence)
189224
if not executed_actions:
190-
return None
225+
return CatalogIdentityAttempt(
226+
probe=None,
227+
no_match_evidence=_catalog_no_match_evidence(
228+
protocol_key=protocol.key,
229+
status="read_failed",
230+
layout_code=None,
231+
model_code=None,
232+
executed_actions=executed_actions,
233+
failed_actions=failed_actions,
234+
action_failures=action_failures,
235+
),
236+
)
191237

192238
layout_code = _optional_int(raw_fields.get("layout_code"))
193239
model_code = _optional_int(raw_fields.get("model_code"))
194240
if layout_code is None or model_code is None:
195241
# The fingerprint registers themselves were unreadable: that is "no
196242
# opinion" (e.g. a layout that rejects this block read), NOT link-down.
197243
# Link-down is specifically a SUCCESSFUL read returning zeros.
198-
return None
244+
return CatalogIdentityAttempt(
245+
probe=None,
246+
no_match_evidence=_catalog_no_match_evidence(
247+
protocol_key=protocol.key,
248+
status="partial_identity",
249+
layout_code=layout_code,
250+
model_code=model_code,
251+
executed_actions=executed_actions,
252+
failed_actions=failed_actions,
253+
action_failures=action_failures,
254+
),
255+
)
199256
rated_power = _optional_int(raw_fields.get("rated_power"))
200257
serial_ascii = str(raw_fields.get("serial_ascii") or "")
201258
if layout_code == 0 and model_code == 0:
202-
return CatalogIdentityProbe(
259+
return CatalogIdentityAttempt(probe=CatalogIdentityProbe(
203260
layout_code=layout_code,
204261
model_code=model_code,
205262
rated_power=rated_power,
@@ -216,7 +273,7 @@ async def _execute(action) -> str:
216273
),
217274
probe_action_keys=tuple(executed_actions),
218275
failed_probe_action_keys=tuple(failed_actions),
219-
)
276+
))
220277
if serial_ascii:
221278
evidence["structural.serial_ascii_plausible"] = serial_ascii_plausible(
222279
serial_ascii
@@ -277,7 +334,7 @@ async def _execute(action) -> str:
277334
rated_power=rated_power,
278335
serial_ascii=serial_ascii,
279336
)
280-
return CatalogIdentityProbe(
337+
probe = CatalogIdentityProbe(
281338
layout_code=layout_code,
282339
model_code=model_code,
283340
rated_power=rated_power,
@@ -287,6 +344,27 @@ async def _execute(action) -> str:
287344
probe_action_keys=tuple(executed_actions),
288345
failed_probe_action_keys=tuple(failed_actions),
289346
)
347+
no_match_evidence = None
348+
if not compiled_resolution.resolved:
349+
no_match_evidence = _catalog_no_match_evidence(
350+
protocol_key=protocol.key,
351+
status="unresolved_identity",
352+
layout_code=layout_code,
353+
model_code=model_code,
354+
resolution=compiled_resolution.resolution,
355+
candidate_keys=(
356+
compiled_resolution.candidate_keys
357+
if 0 < len(compiled_resolution.candidate_keys) <= 4
358+
else ()
359+
),
360+
executed_actions=executed_actions,
361+
failed_actions=failed_actions,
362+
action_failures=action_failures,
363+
)
364+
return CatalogIdentityAttempt(
365+
probe=probe,
366+
no_match_evidence=no_match_evidence,
367+
)
290368

291369

292370
def catalog_match_from_resolution(
@@ -413,3 +491,55 @@ def _optional_int(value: object) -> int | None:
413491
if value is None:
414492
return None
415493
return int(value)
494+
495+
496+
def _probe_action_failure(
497+
action_key: str,
498+
error: BaseException | None,
499+
) -> ProbeActionFailure:
500+
"""Classify one failed identity read without retaining exception text."""
501+
502+
message = str(error or "")
503+
if message.startswith("exception_code:"):
504+
raw_code = message.removeprefix("exception_code:")
505+
try:
506+
exception_code = int(raw_code, 10)
507+
except ValueError:
508+
exception_code = 0
509+
if 1 <= exception_code <= 0xFF:
510+
return ProbeActionFailure(
511+
action_key=action_key,
512+
reason="modbus_exception",
513+
exception_code=exception_code,
514+
)
515+
if isinstance(error, asyncio.TimeoutError) or message == "request_timeout":
516+
return ProbeActionFailure(action_key=action_key, reason="timeout")
517+
return ProbeActionFailure(action_key=action_key, reason="read_error")
518+
519+
520+
def _catalog_no_match_evidence(
521+
*,
522+
protocol_key: str,
523+
status: str,
524+
layout_code: int | None,
525+
model_code: int | None,
526+
resolution: str = "",
527+
candidate_keys: tuple[str, ...] = (),
528+
executed_actions: list[str],
529+
failed_actions: list[str],
530+
action_failures: list[ProbeActionFailure],
531+
) -> DriverProbeEvidence:
532+
"""Build the typed public evidence for one unresolved identity attempt."""
533+
534+
return DriverProbeEvidence(
535+
kind="catalog_identity",
536+
status=status,
537+
protocol_key=protocol_key,
538+
layout_code=layout_code,
539+
model_code=model_code,
540+
resolution=resolution,
541+
candidate_keys=tuple(candidate_keys),
542+
executed_actions=tuple(executed_actions),
543+
failed_actions=tuple(failed_actions),
544+
action_failures=tuple(action_failures),
545+
)

0 commit comments

Comments
 (0)