2828 build_descriptor_decision_report_from_catalog_identity_probe ,
2929)
3030from .catalog_probe import async_walk_detection_dag
31+ from .probe_evidence import DriverProbeEvidence , ProbeActionFailure
3132
3233logger = 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+
116134async 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
292370def 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