# ----------------------- # File: components/i2c/i2c_logic.py # # Purpose # ------- # Unified I²C logic that supports: # 1) One-shot command send under an exclusive "capture window" # 2) A continuous background logger (thread) that politely backs off during capture # # Key Ideas # --------- # - We reuse the global coordinator (components.scheduler.coordinator) to mark an # exclusive "capture window" while a one-shot I²C command is in flight. # During this window, other sends are rejected, and the logger throttles # output to keep the console readable and the UI snappy — same as UART. # - The logger runs in its own daemon thread and performs periodic READs from a # chosen register/length at a configurable interval. # - All SMBus interactions are serialized by a single RLock so disconnect/close # cannot race against in-flight reads/writes (prevents SIGSEGV). # # Tuning / Knobs # -------------- # - self._logger_sleep_s: how often the logger loop yields when idle/throttled # (default ~50 Hz). Increase for less CPU, decrease for more responsiveness. # - _throttle_ns (inside _logger_loop): during capture, the logger emits at most # once every 50 ms. Increase to further reduce noise. # - The default capture window in send_command() is ~300 ms; adjust if needed. # # Logging Format # -------------- # - TX (read): "➡️ | addr 0xAA | reg 0xRR | action READ | len N" # - RX (n bytes): "⬅️ | addr 0xAA | reg 0xRR | data [0xV0, 0xV1, ...]" # - TX (write1): "➡️ | addr 0xAA | reg 0xRR | action WRITE | value 0xVV" # - TX (writeN): "➡️ | addr 0xAA | reg 0xRR | action WRITE | bytes [0x.., ...]" # - OK (write): "✅ | addr 0xAA | reg 0xRR | status OK" # - Errors: "❌ | addr 0xAA | reg 0xRR | error " # # ------------------------------------------------------------------------------ from __future__ import annotations import os import time import threading from enum import Enum, auto from typing import List, Optional, Union import statistics import smbus2 import config.config as config from components.console.console_registry import log_main_console from components.data.db import get_i2c_commands # Shared coordinator + timebase (UART-style exclusivity/throttling) from components.scheduler.coordinator import ( claim_i2c_capture, release_i2c_capture, i2c_capture_active, ) from components.scheduler.timebase import now_ns # --------------------------- # Helpers (parsing and formatting) # --------------------------- def _coerce_int(val: Union[str, int], base: int = 10) -> int: """Turn '1', '0x40', '64' or 1 into int. Raises ValueError on failure.""" if isinstance(val, int): return val s = str(val).strip().lower() if s.startswith("0x"): return int(s, 16) return int(s, base) def _parse_hex_bytes_list(s: str) -> List[int]: """ Parse strings like '0x01,0x02,0x03' or '01 02 03' or '01, 02, 03' into a list of ints. """ if not s: return [] raw = [tok.strip() for tok in s.replace(",", " ").split()] out = [] for t in raw: if not t: continue # accept '0x..' or plain hex/dec out.append(int(t, 0)) return out # --------------------------- # State Machine # --------------------------- class I2CState(Enum): DISCONNECTED = auto() CONNECTED_IDLE = auto() LOGGING = auto() CAPTURE_ACTIVE = auto() # --------------------------- # Main logic # --------------------------- class I2CLogic: """ Single class for: - Connecting / disconnecting an I²C bus - Sending one-shot register read/write commands under an exclusive capture window - Running a continuous logger on the same bus (polls reg/length at an interval) Threading model --------------- - The logger runs in a background daemon thread started by start_logger(). - All SMBus calls (read/write/close) are serialized by self._bus_lock (RLock). - The command send path (send_command) runs in the UI/main thread, but it briefly "claims" exclusivity via the shared coordinator so: * Other sends are rejected during capture, * The logger throttles its output during the capture window. """ def __init__(self): # Connection self.bus: Optional[smbus2.SMBus] = None self.bus_id: int = 1 # Raspberry Pi default /dev/i2c-1 # Threading & safety self._bus_lock = threading.RLock() # serialize ALL SMBus I/O self._logger_thread: Optional[threading.Thread] = None self._logger_running: bool = False self._logger_sleep_s: float = 0.05 # ~20 Hz idle yield self._throttle_ns: int = 50_000_000 # 50 ms between emissions during capture # Logger configuration self._log_addr: Optional[int] = None self._log_reg: int = 0 self._log_len: int = 1 self._log_interval_s: float = 0.20 # default 200 ms between polls self._shutting_down = False # prevent new work during disconnects self._2_bytes_read_counter = ( 0 # to cinfurm how many times we were in the read 2 bytes function ) # State self._state = I2CState.DISCONNECTED # Logger injection (UI provides console logger) self.log = lambda level, msg: None # default: drop logs until UI injects one # Inject the console logger (must be a callable: (type, message) -> None) # components/i2c/i2c_logic.py def set_logger(self, log_func): if callable(log_func): self.log = log_func # ----- convenience formatters ----- def _fmt_hex(self, v: int, width: int = 2) -> str: return f"0x{v:0{width}X}" def _fmt_bytes(self, data) -> str: if data is None: return "[]" return "[" + ", ".join(f"0x{b:02X}" for b in data) + "]" def _to_int_any(self, v, default=None): try: return int(str(v).strip(), 0) # accepts "0xFE" or "254" except Exception: return default def _parse_hex_list(self, s: str) -> List[int]: return _parse_hex_bytes_list(s) # --------------------------- # State & Channels # --------------------------- def state(self) -> I2CState: return self._state def get_channels(self) -> List[str]: """List available /dev/i2c-* buses as strings of bus ids.""" i2c_devices = [] try: for entry in os.listdir("/dev"): if entry.startswith("i2c-"): try: bus_id = int(entry.split("-")[1]) i2c_devices.append(str(bus_id)) except ValueError: continue except Exception as e: self.log("warn", f"Could not list /dev for i2c buses: {e}") return sorted(i2c_devices) def scan_bus(self) -> List[str]: """ Scan the currently opened bus for devices. Returns like ['0x40', '0x41']. """ found: List[str] = [] with self._bus_lock: if self.bus is None: return found try: for address in range(0x03, 0x78): # valid 7-bit range try: # write_quick is the classic probe; some devices NACK writes. self.bus.write_quick(address) found.append(f"0x{address:02X}") except OSError: continue except Exception as e: self.log("warn", f"Unexpected at 0x{address:02X}: {e}") continue self.log("info", f"Scan complete. Found devices: {found}") except Exception as e: self.log("error", f"Scan failed: {e}") return found # --------------------------- # Connection lifecycle # --------------------------- def connect(self, port: Union[int, str]) -> bool: """ Open the I²C bus. 'port' can be an int or string ('1' -> /dev/i2c-1). """ if config.DEBUG_MODE: self._state = I2CState.CONNECTED_IDLE self.log("info", "DEBUG: simulate I²C connect") self.log("success", f"🔗 Connected to I²C bus {port} (DEBUG)") return True try: self.bus_id = _coerce_int(port) with self._bus_lock: self.bus = smbus2.SMBus(self.bus_id) self._state = I2CState.CONNECTED_IDLE self.log("success", f"🔗 Connected to I²C bus {self.bus_id}") return True except FileNotFoundError: self.log("error", f"I²C bus {port} not found (no /dev/i2c-{port}).") except Exception as e: self.log("error", f"❌ Connection failed: {e}") self._state = I2CState.DISCONNECTED return False def disconnect(self) -> bool: """ Stop logger (join) and close the bus cleanly. Safe to call even if already disconnected. """ if config.DEBUG_MODE: self._state = I2CState.DISCONNECTED self.log("info", "DEBUG: simulate I²C disconnect") return True try: # 1) Stop logger first so no thread touches the bus. self.stop_logger() # 2) Close the bus under the same lock used for I/O. with self._bus_lock: if self.bus: try: self.bus.close() except Exception: pass self.bus = None self._state = I2CState.DISCONNECTED self.log("info", f"🔌 Disconnected from I²C bus {self.bus_id}") return True except Exception as e: self.log("error", f"❌ Disconnection error: {e}") return False # --------------------------- # One-shot command + capture window (UART-style) # --------------------------- def send_command(self, command, device_address=0x40): """ Execute a single I²C action under an exclusive capture window. command: either - str (raw): "01,02,03" (block write to reg 0x00) - dict: { 'action'/'operation': 'read'|'write', 'reg'/'register': int|str, 'length': int (for read), 'value': int (single-byte write), 'bytes': '01,02,03' (block write string) } device_address: int or str ('0x40' accepted). If None, attempt to read from command['address'] if present. """ if self._shutting_down: self.log("warning", "⛔ I²C is shutting down; command ignored.") return None # must be connected in real mode if not config.DEBUG_MODE and not self.bus: self.log("error", "❌ | bus not connected") return None # Claim capture (exclusive) — reject if already in use deadline_ns = now_ns() + 300_000_000 # ~300 ms default window if not claim_i2c_capture(deadline_ns, owner="I2C_CMD"): self.log("warning", "⛔ Busy: capture window active. Try again shortly.") return None prev_state = self._state self._state = I2CState.CAPTURE_ACTIVE try: # Normalize device address if device_address is not None: addr = self._to_int_any(device_address, default=0x40) elif isinstance(command, dict) and "address" in command: addr = self._to_int_any(command["address"], default=0x40) else: self.log("error", "❌ | no device address provided") return None # RAW string mode (block write to reg 0x00) if isinstance(command, str): data = self._parse_hex_list(command) self.log( "info", f"➡️ | addr {self._fmt_hex(addr)} | reg 0x00 | action WRITE | bytes {self._fmt_bytes(data)}", ) with self._bus_lock: if not config.DEBUG_MODE: self.bus.write_i2c_block_data(addr, 0x00, data) self.log( "success", f"✅ | addr {self._fmt_hex(addr)} | reg 0x00 | status OK" ) return True # Dict mode action = ( command.get("action") or command.get("operation") or "read" ).upper() reg = self._to_int_any( command.get("reg", command.get("register", 0)), default=0 ) if action == "READ": length = int(command.get("length", 1)) self.log( "info", f"➡️ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | action READ | len {length}", ) with self._bus_lock: if not config.DEBUG_MODE: if length <= 1: data = [self.bus.read_byte_data(addr, reg)] else: data = self.bus.read_i2c_block_data(addr, reg, length) else: # synthetic data in DEBUG data = [0xBE] if length <= 1 else list(range(length)) self.log( "success", f"⬅️ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | data {self._fmt_bytes(data)}", ) return data if length > 1 else data[0] elif action == "WRITE": if "bytes" in command and command["bytes"]: data = self._parse_hex_list(command["bytes"]) self.log( "info", f"➡️ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | action WRITE | bytes {self._fmt_bytes(data)}", ) with self._bus_lock: if not config.DEBUG_MODE: self.bus.write_i2c_block_data(addr, reg, data) self.log( "success", f"✅ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | status OK", ) return True else: value = self._to_int_any(command.get("value", 0)) self.log( "info", f"➡️ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | action WRITE | value {self._fmt_hex(value)}", ) with self._bus_lock: if not config.DEBUG_MODE: self.bus.write_byte_data(addr, reg, value) self.log( "success", f"✅ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | status OK", ) return True else: self.log( "warning", f"⚠️ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | unknown action '{action}'", ) return None except Exception as e: self.log("error", f"❌ | error: {e}") return None finally: release_i2c_capture() # restore state (back to LOGGING if logger is on) self._state = ( I2CState.LOGGING if self._logger_running else I2CState.CONNECTED_IDLE ) # --------------------------- # Hot-path primitive (no logging, no waits) # --------------------------- def read_2_bytes(self, addr_7bit: int, reg: int) -> dict: """ Fast, on-demand I²C read of exactly 2 bytes from 'reg' at 7-bit address. - Immediate-or-skip capture; no waits, no prints, no retries. """ # must be connected in real (non-debug) mode if not config.DEBUG_MODE and self.bus is None: return {"status": "ERR_NOT_CONNECTED", "addr": addr_7bit, "reg": reg} # Give the coordinator a tiny future window so claim is valid deadline_ns = now_ns() + 5_000 if not claim_i2c_capture(deadline_ns, owner="I2C_PKT"): return {"status": "SKIPPED_BUSY", "addr": addr_7bit, "reg": reg} try: with self._bus_lock: data = self.bus.read_i2c_block_data(addr_7bit, reg, 2) if not isinstance(data, list) or len(data) != 2: return {"status": "ERR", "addr": addr_7bit, "reg": reg} return { "status": "OK", "bytes": [data[0], data[1]], } except Exception: return {"status": "ERR", "addr": addr_7bit, "reg": reg} finally: release_i2c_capture() def measure_zero_raw14(self, duration_ms: int = 200, interval_ms: int = 10): """ Measure zero for the I²C angle sensor. Saves the result bytes into config.SESSION_ZERO = [MSB, LSB] (big-endian). Returns the median raw14 (int) or None. """ samples = [] deadline = time.time() + (duration_ms / 1000.0) try: if self.connect(1): self.log("info", "I²C auto-initialized on /dev/i2c-1 to set zero") while time.time() < deadline: rep = self.read_2_bytes( 0x40, 0xFE ) # dict: {"status":"OK","bytes":[b0,b1]} if isinstance(rep, dict) and rep.get("status") == "OK": by = rep.get("bytes") if isinstance(by, list) and len(by) == 2: raw16 = ((by[0] & 0xFF) << 8) | (by[1] & 0xFF) # big-endian raw14 = raw16 & 0x3FFF samples.append(raw14) time.sleep(interval_ms / 1000.0) if not samples: self.log("warn", "I²C zero measurement collected no valid samples") return None med = int(statistics.median(samples)) msb = (med >> 8) & 0xFF lsb = med & 0xFF # Store exactly the two bytes config.SESSION_ZERO = [msb, lsb] self.log( "info", f"I²C zero raw14={med} -> SESSION_ZERO={msb:02X} {lsb:02X}" ) return med except Exception as e: self.log("error", f"I²C zero measurement exception: {e}") return None # --------------------------- # Continuous Logger (thread) — UART-style # --------------------------- def start_logger(self, device_address, reg, length, interval_ms: int = 200) -> bool: """ Start the background I²C logger thread. - If already running: no-op, returns True. - If not connected (and not in DEBUG): returns False. Behavior: - At each interval, performs a READ of 'length' bytes at 'reg' from 'device_address'. - During a capture window, emissions are throttled to at most once every _throttle_ns. """ if self._logger_running: self.log("info", "ℹ️ I²C logger already running.") return True if not config.DEBUG_MODE and not self.bus: self.log("error", "⚠️ Cannot start I²C logger: not connected.") return False addr = self._to_int_any(device_address) r = self._to_int_any(reg) ln = self._to_int_any(length, default=1) if addr is None or r is None or ln is None or ln <= 0: self.log("warning", "⚠️ Invalid logger params (addr/reg/length).") return False self._log_addr = addr self._log_reg = r self._log_len = ln self._log_interval_s = max(0.001, (interval_ms or 200) / 1000.0) self._logger_running = True self._logger_thread = threading.Thread( target=self._logger_loop, name="I2CLogger", daemon=True ) self._logger_thread.start() self._state = I2CState.LOGGING self.log( "success", f"🟢 I²C logger started | addr 0x{addr:02X} | reg 0x{r:02X} | len {ln} | interval {interval_ms}ms", ) return True def stop_logger(self) -> bool: """ Stop the background I²C logger thread if running. Joins with a short timeout so shutdown remains responsive. """ if not self._logger_running: return True self._logger_running = False t = self._logger_thread self._logger_thread = None # Wait up to ~2s for a clean exit (covers a read that’s mid-call + pacing sleep) if t and t.is_alive(): t.join(timeout=2.0) # If still alive, wait a tiny bit more — but don't hang the UI if t.is_alive(): time.sleep(0.05) if self.bus: self._state = I2CState.CONNECTED_IDLE self.log("info", "🔴 I²C logger stopped.") return True def _logger_loop(self): """ Background poller (daemon thread): - DEBUG_MODE: emits synthetic lines periodically so you can test the UI without hardware. - Real bus: performs read(s) at ~_log_interval_s cadence. - During capture: throttle emissions (drop extras) to one per _throttle_ns. """ addr = self._log_addr r = self._log_reg ln = self._log_len pace = self._log_interval_s last_emit_ns = now_ns() try: while self._logger_running: try: # Respect capture throttle (like UART) if self._shutting_down: break # exit ASAP on shutdown if i2c_capture_active(): now = now_ns() if now - last_emit_ns < self._throttle_ns: time.sleep(self._logger_sleep_s) continue last_emit_ns = now # TX line self.log( "info", f"➡️ | addr 0x{addr:02X} | reg 0x{r:02X} | action READ | len {ln}", ) # Perform read under the same bus lock as send_command with self._bus_lock: if not self.bus and not config.DEBUG_MODE: break if not self.bus and not config.DEBUG_MODE: break if config.DEBUG_MODE: data = [0xBE] if ln <= 1 else list(range(ln)) else: if ln <= 1: data = [self.bus.read_byte_data(addr, r)] else: data = self.bus.read_i2c_block_data(addr, r, ln) # RX line self.log( "success", f"⬅️ | addr 0x{addr:02X} | reg 0x{r:02X} | data {self._fmt_bytes(data)}", ) except Exception as e: self.log( "error", f"❌ | addr 0x{addr:02X} | reg 0x{r:02X} | error {e}" ) # pacing time.sleep(pace or self._logger_sleep_s) finally: self._logger_running = False # --------------------------- # Commands catalog passthrough # --------------------------- def get_predefined_commands(self): """ Load predefined I²C commands for your command table. The DB returns rows mapped by components.data.db.get_i2c_commands(). """ return get_i2c_commands()