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.

640 lines
24 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/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 <text>"
#
# ------------------------------------------------------------------------------
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 thats 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()