1+ import asyncio
12import dataclasses
23import json
34import logging
45import re
56import time
7+ import traceback
68from collections import deque
79from collections .abc import Callable , Mapping , Sequence
810from dataclasses import dataclass
11+ from datetime import UTC , datetime
912from enum import Flag , auto
1013from functools import cached_property
1114from pathlib import Path
@@ -268,8 +271,10 @@ class DeviceDiagnostics:
268271 disconnect_times : list [float ]
269272 raw_data_connection : list [tuple [float , bytes ]]
270273 raw_data_messages : list [tuple [float , bytes ]]
274+ raw_data_send : list [tuple [float , bytes ]]
271275 iv : bytes
272276 session_key : bytes
277+ initial_session_key : bytes
273278
274279 def _encode_bytes (self , value : bytes , session : Session | None ) -> str :
275280 if session is not None :
@@ -289,8 +294,12 @@ def serialize(self, session: Session | None = None):
289294 raw_data_messages = [
290295 (k , self ._encode_bytes (v , session )) for (k , v ) in self .raw_data_messages
291296 ],
297+ raw_data_send = [
298+ (k , self ._encode_bytes (v , session )) for (k , v ) in self .raw_data_send
299+ ],
292300 iv = self ._encode_bytes (self .iv , session ),
293301 session_key = self ._encode_bytes (self .session_key , session ),
302+ initial_session_key = self ._encode_bytes (self .initial_session_key , session ),
294303 )
295304
296305 def as_dict (self ):
@@ -304,36 +313,66 @@ class DeviceDiagnosticsCollector:
304313 def __init__ (self , device : "DeviceBase" , buffer_size : int = 100 ):
305314 self ._device = device
306315 self ._enabled = False
316+ self ._save_on_exception = False
307317 self ._buffer_size = buffer_size
308318
309319 self ._last_packets : deque [tuple [float , bytes ]] = deque (maxlen = buffer_size )
310320 self ._last_errors : deque [tuple [float , str ]] = deque (maxlen = buffer_size )
311321 self ._connect_times : deque [float ] = deque (maxlen = buffer_size )
312322 self ._raw_data_connection : list [tuple [float , bytes ]] = []
313323 self ._raw_data_messages : deque [tuple [float , bytes ]] = deque (maxlen = 1000 )
324+ self ._raw_data_send : deque [tuple [float , bytes ]] = deque (maxlen = 1000 )
314325
315326 self ._disconnect_times : deque [float ] = deque (maxlen = buffer_size )
316327 self ._skip_first_messages : int = 8
317328 self ._unlisten_callbacks : list [Callable [[], None ]] = []
318329
319330 self ._start_time = time .time ()
320331
332+ self ._logger = logging .getLogger (__name__ )
333+
321334 def as_dict (self , session : Session | None = None ):
322335 """Get diagnostics data as dictionary"""
323336 return self .diagnostics .serialize (session ).as_dict ()
324337
338+ def build_diagnostics_dict (self , session : Session | None = None ) -> dict :
339+ device = self ._device
340+ result : dict = {
341+ "device" : device .device ,
342+ "name" : device .name ,
343+ "default_name" : device ._default_name ,
344+ "sn_prefix" : device ._sn [:4 ],
345+ "connection_state" : device .connection_state ,
346+ "connection_state_history" : list (device .connection_log .history ),
347+ "manufacturer_data" : (
348+ session .encrypt (device ._manufacturer_data ).hex ()
349+ if session is not None
350+ else device ._manufacturer_data .hex ()
351+ ),
352+ }
353+ if session is not None :
354+ result ["session" ] = session .header .hex ()
355+ if self .is_enabled :
356+ result |= self .as_dict (session )
357+ return result
358+
325359 @property
326360 def diagnostics (self ):
327361 """Get diagnostics data"""
362+ conn = self ._device ._conn
363+ encryption = conn ._encryption
364+
328365 return DeviceDiagnostics (
329366 last_packets = list (self ._last_packets ),
330367 last_errors = list (self ._last_errors ),
331368 connect_times = list (self ._connect_times ),
332369 disconnect_times = list (self ._disconnect_times ),
333370 raw_data_connection = self ._raw_data_connection ,
334371 raw_data_messages = list (self ._raw_data_messages ),
335- iv = self ._device ._conn ._encryption .iv ,
336- session_key = self ._device ._conn ._encryption .session_key ,
372+ raw_data_send = list (self ._raw_data_send ),
373+ iv = encryption .iv if encryption is not None else b"" ,
374+ session_key = encryption .session_key if encryption is not None else b"" ,
375+ initial_session_key = conn ._initial_session_key ,
337376 )
338377
339378 @property
@@ -368,6 +407,7 @@ def enabled(self, enabled: bool = True):
368407 self ._device .on_packet_received (self ._on_packet_received ),
369408 self ._device .on_packet_parsed (self ._on_packet_parsed ),
370409 self ._device .on_data_received (self ._on_data_received ),
410+ self ._device .on_data_send (self ._on_data_send ),
371411 ]
372412 )
373413 return self
@@ -448,11 +488,97 @@ def _on_data_received(self, data: bytes, state: "ConnectionState"):
448488
449489 buffer .append (self ._with_time (data ))
450490
491+ def _on_data_send (self , data : bytes ):
492+ self ._raw_data_send .append (self ._with_time (data ))
493+
494+ def with_save_on_exception (self , enabled : bool = True ):
495+ """
496+ Enable or disable automatic diagnostics save on connection error
497+
498+ When enabled, packet collection is force-enabled and a state change
499+ listener is registered. On any error state, collected diagnostics
500+ are saved to disk automatically.
501+ """
502+ if enabled == self ._save_on_exception :
503+ return self
504+
505+ self ._save_on_exception = enabled
506+
507+ if enabled :
508+ self .enabled (True )
509+ self ._unlisten_callbacks .append (
510+ self ._device .on_connection_state_change (self ._on_state_change )
511+ )
512+
513+ return self
514+
515+ def _on_state_change (self , state : "ConnectionState" ) -> None :
516+ if not self ._save_on_exception or not state .is_error :
517+ return
518+
519+ conn = self ._device ._conn
520+ exc = getattr (conn , "_last_exception" , None ) if conn is not None else None
521+ try :
522+ self ._save_to_disk (state , exc )
523+ except Exception :
524+ self ._device ._logger .exception (
525+ "Failed to save diagnostics-on-exception snapshot"
526+ )
527+
528+ def _save_to_disk (
529+ self ,
530+ state : "ConnectionState" ,
531+ exc : Exception | type [Exception ] | None = None ,
532+ ) -> None :
533+ session = Session ()
534+
535+ exc_message = None
536+ exc_traceback = None
537+ if exc is not None :
538+ exc_message = session .encrypt (str (exc ).encode ()).hex ()
539+ if isinstance (exc , BaseException ) and exc .__traceback__ is not None :
540+ tb = "" .join (traceback .format_tb (exc .__traceback__ ))
541+ exc_traceback = session .encrypt (tb .encode ()).hex ()
542+
543+ data = {
544+ "timestamp" : datetime .now (UTC ).isoformat (),
545+ "exception" : {
546+ "type" : type (exc ).__name__ if exc is not None else None ,
547+ "message" : exc_message ,
548+ "traceback" : exc_traceback ,
549+ "state_on_error" : state .name ,
550+ },
551+ "data" : self .build_diagnostics_dict (session ),
552+ }
553+
554+ sn_prefix = self ._device ._sn [:4 ]
555+ ts = datetime .now (UTC ).strftime ("%Y%m%d_%H%M%S" )
556+ cache_dir = Path (__file__ ).parent .parent / ".diagnostics"
557+ path = cache_dir / f"{ sn_prefix } _exception_{ ts } .json"
558+ content = json .dumps (data , default = str , indent = 2 )
559+
560+ def _write () -> None :
561+ cache_dir .mkdir (exist_ok = True )
562+ path .write_text (content )
563+
564+ task = asyncio .get_running_loop ().run_in_executor (None , _write )
565+
566+ def _log_result (future : asyncio .Future ) -> None :
567+ if (err := future .exception ()) is not None :
568+ self ._device ._logger .error (
569+ "Failed to save diagnostics to %s: %s" , path , err
570+ )
571+ else :
572+ self ._device ._logger .info ("Diagnostics saved to %s" , path )
573+
574+ task .add_done_callback (_log_result )
575+
451576 def _clear_buffers (self ):
452577 self ._last_packets .clear ()
453578 self ._last_errors .clear ()
454579 self ._connect_times .clear ()
455580 self ._disconnect_times .clear ()
581+ self ._raw_data_send .clear ()
456582
457583
458584class _LazyHex :
0 commit comments