You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

891 lines
34 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# -----------------------
# 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 chunks 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))