# ----------------------- # File: components/uart/uart_logic.py # # Purpose # ------- # Unified UART logic that supports: # 1) Command send with a protected "capture window" (using the global coordinator) # 2) A continuous background logger (thread) that **buffers all bytes** into a # persistent ring and groups them into sessions (gap-based). No per-byte printing. # # Key Ideas # --------- # - We use a *global coordinator* (components.scheduler.coordinator) to mark an # exclusive "capture window" for the **command** UART path only. # - The *logger* runs in its own *daemon thread* and continuously reads **unthrottled**. # It appends to a persistent ByteRing and closes a session after an inactivity gap. # UI/decoding happens elsewhere (no hex lines printed here). # # Tuning / Knobs # -------------- # - self._logger_sleep_s: idle yield when no bytes are available (~20 Hz default). # - _gap_ns (150 ms): inactivity gap to auto-close the current session. # - ByteRing capacity (4 MiB default): bounded buffer with drop-oldest policy. # - capture_max_ms (send_command arg): only affects the command path. import serial import time import serial.tools.list_ports import threading from components.console.console_registry import log_main_console from components.scheduler.timebase import now_ns from components.uart.pgkomm import send_pgkomm2 as pg_send_pgkomm2 from components.data.db import get_uart_commands import config.config as config from components.data.db import ensure_telemetry_schema, insert_telemetry_rows from components.uart.packet_detector import PacketDetector from typing import Optional, Callable, List, Dict, Any from components.buffer.buffer import ( ByteRing, ) # persistent byte ring (shared by UART/I2C) # Global coordinator API: # - claim_uart_capture(deadline_ns): enters exclusive capture until deadline # - release_uart_capture(): leaves exclusive capture # - uart_capture_active(): True if someone is currently in capture from components.scheduler.coordinator import ( claim_uart_capture, release_uart_capture, uart_capture_active, ) from dataclasses import dataclass from components.i2c.i2c_logic import I2CLogic @dataclass class PacketMark: """ Index entry for a detected fixed-length packet in the ByteRing. - off_start/off_end: absolute byte offsets in the ByteRing [start, end) (14 bytes total for EF FE ... EE). - ts_end_ns: timestamp when the last byte (0xEE) was detected. - dropped: True if the bytes were already evicted from the ring when we tried to read them. """ id: int off_start: int off_end: int ts_end_ns: int dropped: bool = False @dataclass class SessionHeader: """ Metadata for a closed session (no bytes included here) - off_start/off_end are absolute offsets into the ByteRing - t_start_ns / t_end_ns are for diagnostics/UI only """ id: int t_start_ns: int t_end_ns: int off_start: int off_end: int dropped_bytes_during: int = 0 @dataclass class ClosedSession: """A ready-to-decode session (header + payload snapshot).""" header: SessionHeader payload: bytes class UART_logic: """ Single class for: - Connecting / disconnecting a UART port - Sending one-shot hex commands with an exclusive capture window - Running a continuous logger on the *same* port (optional) Threading model --------------- - The logger runs in a background daemon thread started by start_logger(). It loops, checks for available bytes, and logs them. - The command send path (send_command) runs in the UI/main thread, but it briefly "claims" exclusivity via the coordinator so: * Other sends are rejected, * The logger throttles its output during the capture window. """ def __init__(self): # pyserial handle and connection info self._run_session_id = now_ns() # stable for this program run self._logger_run_number = 0 # increments each start_logger() self._logger_run_tag = None # e.g., "log0001" self.serial = None self.port = None self.baudrate = None self._hex_echo = False # set True only when you need to see raw hex in console # --- Background logger configuration/state --- # The logger runs in a separate daemon thread and continuously reads. self._logger_thread = None # threading.Thread or None self._logger_running = False # set True to keep logger loop alive self._logger_hex = True # True: print hex; False: print utf-8 text self._logger_sleep_s = ( 0.05 # 50 ms idle sleep (~20 Hz). Tune for CPU vs responsiveness. ) # --- Detected packet marks (EF FE ... 14 ... EE) anchored to ring offsets --- self._packet_marks: list[PacketMark] = [] self._packet_next_id: int = 1 self._packet_count: int = 0 # visible counter for UI/metrics self._on_packet_cb: Optional[Callable[[int, bytes, int], None]] = None # Packet detector: we keep it simple and feed from the reader loop self._detector = PacketDetector(on_packet=self._on_packet_detected) # --- Persistent logging buffer + sessionizer (for the logger thread) --- # We keep a bounded byte ring so we can decode/save later without flooding the UI. self._ring = ByteRing(capacity_bytes=4 * 1024 * 1024) # 4 MiB (tune as needed) # Sessionizer (gap-based): we open a session at start_logger(), extend on bytes, # and auto-close after 150 ms inactivity or when forced. self._gap_ns = 150_000_000 # 150 ms inactivity closes a session self._next_session_id = 1 self._active_header = None # type: Optional[SessionHeader] self._last_rx_ns = 0 # last time we saw a byte (ns) self._closed_sessions = [] # queue of SessionHeader (payload copied on pop) self._sess_lock = threading.Lock() # --- I2C integration (silent, on-demand) --- self._i2c = None # type: Optional[object] # expect components.i2c.i2c_logic.I2CLogic self._i2c_cfg = None # type: Optional[dict] self._i2c_results: List[ Dict[str, Any] ] = [] # small bounded buffer for correlation/export self._i2c_results_max = 10000 self._i2c_inflight = False self._i2c_inflight_lock = threading.Lock() # --- NEW: per-chunk timing references for unique per-packet timestamps --- self._chunk_ref_off = 0 self._chunk_ref_t_ns = 0 ensure_telemetry_schema() self.set_db_writer(insert_telemetry_rows) # your UART_logic instance self.log = lambda level, msg: None # default: drop logs until UI injects one # ONLY for TEST self._i2c = I2CLogic() # Inject the console logger (must be a callable: (type, message) -> None) def set_logger(self, log_func): if callable(log_func): self.log = log_func def set_db_writer(self, writer_callable): """ Inject a DB writer callable. It will be called with rows like: { 'session_id': str, 'logger_session': str, 'logger_session_number': int, 'uart_raw': str | None, 'i2c_raw': str | None, } """ if callable(writer_callable): self._db_writer = writer_callable # --- Combobox helpers (static lists for UI) --- def get_baud_rates(self): return ["115200", "9600", "19200", "38400", "57600", "256000"] def get_data_bits(self): return ["8", "5", "6", "7"] def get_stop_bits(self): return ["1", "1.5", "2"] def get_parity(self): return ["Even", "None", "Odd"] def _on_packet_detected( self, _ts_ns: int, packet: bytes, abs_off_start: int ) -> None: """ Realtime packet hook (EF FE ... 14 ... EE). - Stores a PacketMark pinned to ByteRing offsets - Immediately performs a single I²C read (inline, no worker), silent - Saves the I²C result in _i2c_results for later correlation """ # --- derive a unique end timestamp per packet based on byte position --- off_start = int(abs_off_start) off_end = off_start + len(packet) # expected 14 # Compute per-packet end time from the current chunk’s base using UART bitrate. # Assume standard 8N1: 1 start + 8 data + 1 stop = 10 bits/byte. bits_per_byte = 10 baud = int(self.baudrate or 115200) byte_ns = int((bits_per_byte * 1_000_000_000) // baud) # bytes from the beginning of this chunk to the *end* of the packet: delta_bytes = max(0, off_end - int(getattr(self, "_chunk_ref_off", 0))) base_t = int(getattr(self, "_chunk_ref_t_ns", _ts_ns or now_ns())) end_ts = base_t + delta_bytes * byte_ns # Store the mark mark = PacketMark( id=self._packet_next_id, off_start=off_start, off_end=off_end, ts_end_ns=end_ts, ) self._packet_marks.append(mark) self._packet_next_id += 1 self._packet_count += 1 # Optional external packet callback (kept compatible) if self._on_packet_cb: try: self._on_packet_cb(end_ts, packet, abs_off_start) except Exception as e: self.log("warning", f"Packet handler error: {e}") # --- INLINE I²C READ (immediate-or-skip, no printing) --- if self._i2c and self._i2c_cfg: addr = self._i2c_cfg["addr"] reg = self._i2c_cfg["reg"] try: res = self._i2c.read_2_bytes(addr, reg) except Exception: res = {"status": "ERR", "addr": addr, "reg": reg} out = {"t_pkt_detected_ns": int(end_ts), "pkt_id": mark.id} if isinstance(res, dict): out.update(res) self._push_i2c_result(out) # Return a detailed list (not used by UI currently) @staticmethod def get_channels_with_product(self): ports_info = [] for port in serial.tools.list_ports.comports(): ports_info.append( { "device": port.device, "description": port.description, "product": port.product, # ATTRS{product} "manufacturer": port.manufacturer, # ATTRS{manufacturer} "serial_number": port.serial_number, } ) return ports_info # Return a simple list of user-facing labels for dropdowns def get_channels(self) -> list[str]: ports = serial.tools.list_ports.comports() if not ports: log_main_console("Error", "UART channels returned empty.") return [] labels = [] for p in ports: label = p.device if p.product: label += f" — {p.product}" # Append ATTRS{product} to help identify labels.append(label) log_main_console("success", "UART channels added with product info.") return labels # Load predefined commands (from DB) for your command table (if used elsewhere) def get_predefined_commands(self) -> list[dict]: commands = get_uart_commands() return [ { "id": id, "name": name, "description": description, "category": category, "hex_string": hex_string, } for id, name, description, category, hex_string in commands ] # --------------------------- # Connection lifecycle # --------------------------- def connect( self, port: str, baudrate: int = 115200, data_bits: int = 8, stop_bits: float = 1, parity: str = "N", ) -> bool: """ Open the UART port with the given settings. Note: `port` may be a label like "/dev/ttyUSB0 — RedBox 1017441". We split on whitespace and use the first token as the actual device path. """ if config.DEBUG_MODE: log_main_console("info", "DEBUG: fake connecting to UART") log_main_console( "success", f"🔗 Connected to {port} | Baud: {baudrate} | Data: {data_bits} Bits | Stop: {stop_bits} Bits | Parity: {parity}.", ) return True try: # Extract the pure path '/dev/tty*' even if label contains a ' — product' tail port_path = str(port).split()[0] self.serial = serial.Serial( port=port_path, baudrate=baudrate, bytesize=data_bits, stopbits=stop_bits, parity=parity, # If you pass 'N'/'E'/'O': ok. If "None"/"Even"/"Odd", consider a mapper. timeout=0.01, # Short timeout keeps reads responsive/non-blocking-ish ) if self.serial.is_open: self.port = port_path self.baudrate = baudrate log_main_console( "success", f"🔗 Connected to {port_path} | Baud: {baudrate} | Data: {data_bits} Bits | Stop: {stop_bits} Bits | Parity: {parity}.", ) return True except Exception as e: log_main_console("error", f"❌ Connection failed: {e}") return False def disconnect(self) -> bool: """ Stop logger if needed and close the port cleanly. Safe to call even if already disconnected. """ if config.DEBUG_MODE: self.log("info", "DEBUG: fake Disconnecting from UART") return True # NEW: stop the logger thread first to avoid read/close races try: self.stop_logger() except Exception: pass if not getattr(self, "serial", None): self.log("info", "🔌 Already disconnected (no serial).") return True try: if self.serial.is_open: # Flush + attempt to reset buffers while still open try: self.serial.flush() except Exception: pass try: self.serial.reset_input_buffer() self.serial.reset_output_buffer() except Exception: pass # Close the port try: self.serial.close() except Exception as e: self.log("error", f"❌ Close failed: {e}") else: self.log("info", "ℹ️ Port already closed.") except Exception as e: # Some drivers/platforms can throw on is_open/flush/close — keep it resilient self.log("warning", f"Disconnect encountered an issue: {e}") # Clear local state so future connects start fresh self.serial = None self.port = None self.baudrate = None self.log("info", "🔌 Disconnected") return True # --------------------------- # Helpers # --------------------------- def _fmt_hex_ascii(self, data: bytes) -> str: """Return 'HEX | ASCII' view for console.""" hex_part = data.hex(" ").upper() ascii_part = "".join(chr(b) if 32 <= b <= 126 else "." for b in data) return f"{hex_part} | '{ascii_part}'" # --------------------------- # Command send + capture (ASCII + HEX logging) # --------------------------- def send_command(self, hex_command: str, capture_max_ms: int = 300): """ Send a hex-encoded command and read until newline. Logs both HEX and printable CHARs. """ if not config.DEBUG_MODE: if not self.serial or not self.serial.is_open: self.log("error", "⚠️ Not connected.") return if uart_capture_active(): self.log("warning", "⛔ Busy: capture window active. Try again shortly.") return try: payload = bytes.fromhex(hex_command.strip()) except ValueError: self.log("error", "❌ Invalid hex string format.") return deadline_ns = now_ns() + int(capture_max_ms) * 1_000_000 if not claim_uart_capture(deadline_ns): self.log("warning", "⛔ Busy: could not acquire capture window.") return try: # Send if not config.DEBUG_MODE: self.serial.write(payload) self.log("success", f"➡️ {self._fmt_hex_ascii(payload)}") # Receive (newline-delimited) if config.DEBUG_MODE: time.sleep(min(0.2, capture_max_ms / 1000)) response = b"HP 01 02 03\n" else: response = self.serial.read_until(expected=b"\n") if not response: self.log("error", "❌ No response (timeout).") return self.log("info", f"⬅️ {self._fmt_hex_ascii(response)}") return response except Exception as e: self.log("error", f"❌ UART send error: {e}") finally: release_uart_capture() # --------------------------- # PGKomm2 send + framed receive # --------------------------- # --- PGKomm2 send that discards the PH echo and returns only HP --- def send_pgkomm2(self, hex_command: str, capture_max_ms: int = 100): """ Delegate to PGKomm2 module: sends TX, discards PH echo, returns first HP reply. Logs HEX+ASCII. """ return pg_send_pgkomm2(self.serial, hex_command, self.log, capture_max_ms) # --------------------------- # Continuous Logger (thread) # --------------------------- def start_logger(self, hex_output: bool = True) -> bool: """ Start the background logger thread. - If already running: no-op, returns True. - If not connected (and not in DEBUG): returns False. Logger behavior: - Continuously polls the serial port (non-blocking style) and **buffers** any available bytes into a persistent ByteRing. - Maintains a gap-based session: opens at start, extends on bytes, auto-closes after inactivity (default 150 ms). No per-byte console output here; decoding and UI happen outside the reader thread. """ if self._logger_running: self.log("info", "ℹ️ Logger already running.") return True if not config.DEBUG_MODE and (not self.serial or not self.serial.is_open): self.log("error", "⚠️ Cannot start logger: not connected.") return False # Configure output style (hex vs text) self._logger_hex = bool(hex_output) # --- NEW: explicitly open a session now (even before first byte arrives) --- with self._sess_lock: # We anchor the session start at the current ring end so we have a clean # byte interval even if the first bytes arrive a bit later. _, ring_end = self._ring.logical_window() t0 = now_ns() self._active_header = SessionHeader( id=self._next_session_id, t_start_ns=t0, t_end_ns=t0, # will be updated on first data and on close off_start=ring_end, off_end=ring_end, # extended as bytes arrive dropped_bytes_during=0, ) self._next_session_id += 1 self._last_rx_ns = 0 # remains 0 until we see first real byte # Reset packet tracking for a fresh run self._packet_marks.clear() self._packet_next_id = 1 self._packet_count = 0 # in start_logger(), before spinning up the thread try: if self._i2c.connect(1): self.configure_i2c( self._i2c, addr_7bit=0x40, angle_reg=0xFE, read_len=2 ) self.log("info", "I²C auto-initialized on /dev/i2c-1 for testing") else: self.log("warning", "I²C init failed (bus not available)") except Exception as e: self.log("error", f"I²C init exception: {e}") # Spin up the thread self._logger_running = True self._logger_thread = threading.Thread( target=self._logger_loop, name="UARTLogger", daemon=True ) self._logger_thread.start() self.log("success", "🟢 UART logger started (session opened).") self._logger_run_number += 1 self._logger_run_tag = f"log{self._logger_run_number:04d}" # fresh per-run buffers self._i2c_results.clear() # (Optional) if you want sessions view limited to this run: # self._closed_sessions.clear() self.log("info", f"Logger run tag: {self._logger_run_tag} ") return True def stop_logger(self) -> bool: """ Stop the background logger thread if running. Joins with a short timeout so shutdown remains responsive. Prints a summary + last 10 packets with UART + I²C correlation. """ if not self._logger_running: return True self._logger_running = False t = self._logger_thread self._logger_thread = None if t and t.is_alive(): t.join(timeout=1.0) self.log("info", "🔴 UART logger stopped.") # --- Summary counts (no historical byte counter; use ring window size) --- try: ring_start, ring_end = self._ring.logical_window() uart_buffered = max(0, int(ring_end - ring_start)) # current buffered bytes uart_pkts = len(self._packet_marks) i2c_results = list(getattr(self, "_i2c_results", [])) i2c_total = len(i2c_results) i2c_ok = sum(1 for r in i2c_results if r.get("status") == "OK") i2c_err = sum( 1 for r in i2c_results if r.get("status") in ("ERR", "ERR_NOT_CONNECTED") ) i2c_skipped = sum( 1 for r in i2c_results if r.get("status") == "SKIPPED_BUSY" ) self.log( "info", f"📊 Summary — UART buffered: {uart_buffered} B | UART packets: {uart_pkts} | " f"I²C samples: {i2c_total} (OK {i2c_ok}, ERR {i2c_err}, SKIPPED {i2c_skipped})", ) except Exception as e: self.log("warning", f"Summary failed: {e}") # stats = decode_raw_data.run(session_id=config.SESSION_NAME) ##self.log("success", f"Decoded session '{config.SESSION_NAME}': {stats}") return True def _logger_loop(self): """ Background reader thread (runs until stop_logger() or disconnect): Responsibilities ---------------- - Continuously poll the serial port for any available bytes. - Append all received bytes into the persistent ByteRing (no printing here). - Extend the currently active session with these bytes. - Detect inactivity gaps (default 150 ms) and *close* the session, enqueueing it for later decoding/saving. - On exit, finalize any open session so nothing is lost. Notes ----- - No time-based throttling here. The reader stays hot and unthrottled. - No capture-window checks (command UART is separate). - "Information logging" means: session closed messages + error/warning events. We do not log per-byte/hex lines in this loop. """ try: # --- Real hardware path --- while self._logger_running and self.serial and self.serial.is_open: try: # Non-blocking peek of pending bytes. to_read = self.serial.in_waiting or 0 if to_read == 0: # No bytes available: check inactivity-based session close. now_t = now_ns() with self._sess_lock: if self._active_header is not None and self._last_rx_ns: if (now_t - self._last_rx_ns) >= self._gap_ns: self._active_header.t_end_ns = self._last_rx_ns self._closed_sessions.append(self._active_header) self.log( "info", f"📦 Session {self._active_header.id} closed after gap", ) # Commit everything to the database try: written, failed = ( self.flush_logger_session_to_db() ) self.log( "success", f"💾 Telemetry written: {written} rows (failed {failed}) for {self._logger_run_tag}", ) # optional cleanup after preview self._packet_marks.clear() self._i2c_results.clear() except Exception as e: self.log("warning", f"DB flush failed: {e}") self._active_header = None # Yield CPU very briefly; keep it small for responsiveness. time.sleep(self._logger_sleep_s) continue # Bytes available: read them all in one go. data = self.serial.read(to_read) except Exception as e: # Transient serial errors (e.g., unplug). Stay calm and retry. self.log("warning", f"Logger read error: {e}") time.sleep(self._logger_sleep_s) continue if not data: # Rare edge case: read() returned empty despite in_waiting>0. time.sleep(self._logger_sleep_s) continue # Append to the persistent ring; returns absolute offsets. a, b, dropped = self._ring.write(data) t = now_ns() # --- set per-chunk timestamp/offset for unique packet timing --- self._chunk_ref_off = a self._chunk_ref_t_ns = t try: self._detector.feed(data, t, a) except Exception: pass # Extend (or open) the current session with these bytes. with self._sess_lock: if self._active_header is None: # If for any reason no session is open (should have been opened at start), # open one now so we never lose a contiguous interval. self._active_header = SessionHeader( id=self._next_session_id, t_start_ns=t, t_end_ns=t, off_start=a, off_end=b, dropped_bytes_during=0, ) self._next_session_id += 1 else: self._active_header.off_end = b self._active_header.t_end_ns = t if dropped: self._active_header.dropped_bytes_during += dropped self._last_rx_ns = t # (No printing here; UI/decoder will consume from pop_closed_session()) finally: # Finalize any open session so we don't lose the tail on stop/disconnect. with self._sess_lock: if self._active_header is not None: self._active_header.t_end_ns = self._last_rx_ns or now_ns() self._closed_sessions.append(self._active_header) self.log( "info", f"📦 Session {self._active_header.id} finalized on shutdown", ) self._active_header = None # Ensure the running flag is reset even if we broke out due to an error. self._logger_running = False def pop_closed_session(self) -> Optional[ClosedSession]: """ Pop the oldest closed session and return (header, payload snapshot). Use this from your decode/save worker. Safe to call while logger runs. """ with self._sess_lock: if not self._closed_sessions: return None header = self._closed_sessions.pop(0) # Copy outside the lock for minimal contention (ByteRing is internally locked) payload = self._ring.copy_range(header.off_start, header.off_end) return ClosedSession(header=header, payload=payload) def closed_count(self) -> int: with self._sess_lock: return len(self._closed_sessions) def stats_snapshot(self) -> dict: ring_start, ring_end = self._ring.logical_window() with self._sess_lock: active_id = self._active_header.id if self._active_header else None active_bytes = ( (self._active_header.off_end - self._active_header.off_start) if self._active_header else 0 ) last_rx_age_ms = ( None if not self._last_rx_ns else round((now_ns() - self._last_rx_ns) / 1_000_000) ) closed_ready = len(self._closed_sessions) ring_stats = self._ring.stats_snapshot() return { "port": self.port, "baudrate": self.baudrate, "ring": ring_stats, "window_start_off": ring_start, "window_end_off": ring_end, "active_session_id": active_id, "active_session_bytes": active_bytes, "last_rx_age_ms": last_rx_age_ms, "closed_ready": closed_ready, } # --------------------------- # I²C configuration + trigger (silent) # --------------------------- def configure_i2c( self, i2c_obj, *, bus_id: int = 1, addr_7bit: int = 0x00, angle_reg: int = 0x00, read_len: int = 2, ) -> None: """ Stash the I²C handle and constants for on-demand angle sampling. This does NOT connect/disconnect I²C. No logging. """ self._i2c = i2c_obj self._i2c_cfg = { "bus_id": int(bus_id), "addr": int(addr_7bit) & 0x7F, "reg": int(angle_reg) & 0xFF, "len": int(read_len), } def _push_i2c_result(self, item: Dict[str, Any]) -> None: buf = self._i2c_results buf.append(item) # bounded buffer (drop oldest) if len(buf) > self._i2c_results_max: del buf[: len(buf) - self._i2c_results_max] def i2c_get_angle( self, t_pkt_detected_ns: int, pkt_id: Optional[int] = None ) -> None: if not self._i2c or not self._i2c_cfg: return with self._i2c_inflight_lock: if self._i2c_inflight: return self._i2c_inflight = True addr = self._i2c_cfg["addr"] reg = self._i2c_cfg["reg"] def _worker(): try: try: res = self._i2c.read_2_bytes(addr, reg) except Exception: res = {"status": "ERR", "addr": addr, "reg": reg} out = {"t_pkt_detected_ns": int(t_pkt_detected_ns), "pkt_id": pkt_id} if isinstance(res, dict): out.update(res) self._push_i2c_result(out) finally: with self._i2c_inflight_lock: self._i2c_inflight = False threading.Thread(target=_worker, name="I2CProbeOnce", daemon=True).start() def flush_logger_session_to_db(self) -> tuple[int, int]: """ Persist all packets detected in the current logger session using one bulk insert. Returns (written_count, failed_count). """ if not getattr(self, "_db_writer", None): return (0, 0) # Map pkt_id -> latest i2c sample i2c_by_id = {} for res in self._i2c_results: pid = res.get("pkt_id") if pid is not None: i2c_by_id[pid] = res rows = [] for mark in self._packet_marks: # UART 14B payload snapshot (may be evicted) try: payload = self._ring.copy_range(mark.off_start, mark.off_end) uart_hex = payload.hex(" ").upper() except Exception: uart_hex = None # I2C 2B raw (only when OK) i2c_hex = None i2c_res = i2c_by_id.get(mark.id) if isinstance(i2c_res, dict) and i2c_res.get("status") == "OK": b = i2c_res.get("bytes") or [] if isinstance(b, (list, tuple)) and len(b) == 2: i2c_hex = f"{int(b[0]) & 0xFF:02X} {int(b[1]) & 0xFF:02X}" rows.append( { "session_id": config.SESSION_NAME, "logger_session": config.CURRENT_COMMAND, "logger_session_number": int(self._next_session_id - 1), "t_ns": int(mark.ts_end_ns), # NEW: packet timestamp (ns) "uart_raw": uart_hex, "i2c_raw": i2c_hex, } ) try: written = self._db_writer(rows) # bulk insert once failed = max(0, len(rows) - int(written or 0)) return (int(written or 0), failed) except Exception as e: self.log("warning", f"DB bulk write failed: {e}") return (0, len(rows))