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