#!/usr/bin/env python3 """ I2C Core - vzug-e-hinge ======================= Generic I2C bus management with reading/writing and continuous logging. Features: - Bus scanning (detect available /dev/i2c-*) - Device scanning (like i2cdetect) - Read/write operations (byte or block) - Continuous logger thread (background polling) - Circular buffer for logger data - Timestamp synchronization (inject clock) - Thread-safe operations - Sensor-agnostic (generic register access) Author: Kynsight Version: 1.0.0 """ from __future__ import annotations import os import threading import time from dataclasses import dataclass, field from enum import Enum from typing import Optional, Callable, Tuple, List try: import smbus2 except ImportError: smbus2 = None from buffer_kit.circular_buffer import ( Status as BufferStatus, CircularBufferHandle, cb_init, cb_write, cb_capacity, cb_fill_bytes, cb_fill_pct, cb_overflows, ) # ============================================================================= # Status & Configuration # ============================================================================= class Status(Enum): """Operation status codes.""" OK = 0 ERROR = 1 BUS_NOT_FOUND = 2 DEVICE_NOT_FOUND = 3 ALREADY_OPEN = 4 NOT_OPEN = 5 LOGGER_RUNNING = 6 NACK = 7 # Device did not acknowledge @dataclass(frozen=True) class I2CConfig: """ I2C bus configuration. bus_id: Bus number (e.g., 1 for /dev/i2c-1) buffer_size: Logger circular buffer capacity timestamp_source: Optional external time function (for sync with UART) """ bus_id: int buffer_size: int = 40 * 1024 * 1024 # 40MB default buffer timestamp_source: Optional[Callable[[], float]] = None @dataclass class I2CReading: """ Single I2C read/write operation record. reading_id: Sequential ID (per session) timestamp: When operation occurred address: Device address (7-bit) register: Register address data: Raw bytes read/written operation: "READ" or "WRITE" """ reading_id: int timestamp: float address: int register: int data: bytes operation: str # "READ" or "WRITE" @dataclass class BusInfo: """ Information about I2C bus status. is_open: Bus is opened is_logger_running: Logger thread active buffer_capacity: Buffer size buffer_fill_bytes: Current buffer usage buffer_fill_percent: Fill percentage buffer_overflows: Overflow count total_reads: Total read operations total_writes: Total write operations total_errors: Total errors encountered """ is_open: bool is_logger_running: bool buffer_capacity: int buffer_fill_bytes: int buffer_fill_percent: int buffer_overflows: int total_reads: int total_writes: int total_errors: int # ============================================================================= # I2C Handle # ============================================================================= @dataclass class I2CHandle: """ I2C bus handle (internal state - use API functions). Do not access fields directly - use i2c_* functions. """ config: I2CConfig # SMBus _bus: Optional[smbus2.SMBus] = None _is_open: bool = False # Circular buffer for logger _buffer: Optional[CircularBufferHandle] = None # Logger thread _logger_thread: Optional[threading.Thread] = None _logger_running: bool = False _stop_logger_event: threading.Event = field(default_factory=threading.Event) # Logger configuration _logger_addr: int = 0 _logger_reg: int = 0 _logger_len: int = 1 _logger_interval_ms: int = 100 # Thread safety _lock: threading.RLock = field(default_factory=threading.RLock) # Statistics _total_reads: int = 0 _total_writes: int = 0 _total_errors: int = 0 _reading_counter: int = 0 # Timestamp function _get_timestamp: Callable[[], float] = field(default=time.perf_counter) def __post_init__(self): """Initialize timestamp source.""" if self.config.timestamp_source: self._get_timestamp = self.config.timestamp_source # ============================================================================= # Bus Discovery # ============================================================================= def i2c_scan_buses() -> List[int]: """ Scan for available I2C buses. Returns: List of bus IDs (e.g., [0, 1, 2] for /dev/i2c-0, /dev/i2c-1, etc.) """ buses = [] try: for entry in os.listdir("/dev"): if entry.startswith("i2c-"): try: bus_id = int(entry.split("-")[1]) buses.append(bus_id) except ValueError: continue except Exception: pass return sorted(buses) # ============================================================================= # Bus Lifecycle # ============================================================================= def i2c_create(config: I2CConfig) -> Tuple[Status, Optional[I2CHandle]]: """ Create I2C handle. Args: config: I2C configuration Returns: (Status.OK, handle) on success (Status.ERROR, None) on failure """ if not smbus2: return (Status.ERROR, None) if config.bus_id < 0: return (Status.ERROR, None) handle = I2CHandle(config=config) return (Status.OK, handle) def i2c_open(handle: I2CHandle) -> Status: """ Open I2C bus. Args: handle: I2C handle Returns: Status.OK on success Status.ALREADY_OPEN if already open Status.BUS_NOT_FOUND if bus not available """ if handle._is_open: return Status.ALREADY_OPEN try: # Open SMBus handle._bus = smbus2.SMBus(handle.config.bus_id) # Create buffer for logger status, buffer = cb_init(handle.config.buffer_size) if status != BufferStatus.OK: handle._bus.close() return Status.ERROR handle._buffer = buffer handle._is_open = True # Reset statistics with handle._lock: handle._total_reads = 0 handle._total_writes = 0 handle._total_errors = 0 handle._reading_counter = 0 return Status.OK except FileNotFoundError: return Status.BUS_NOT_FOUND except Exception: return Status.ERROR def i2c_close(handle: I2CHandle) -> Status: """ Close I2C bus and cleanup. Stops logger if running. Args: handle: I2C handle Returns: Status.OK """ if not handle._is_open: return Status.OK # Stop logger first if handle._logger_running: i2c_stop_logger(handle) # Close bus with handle._lock: if handle._bus: try: handle._bus.close() except: pass handle._bus = None handle._buffer = None handle._is_open = False return Status.OK # ============================================================================= # Device Scanning # ============================================================================= def i2c_scan_devices(handle: I2CHandle) -> Tuple[Status, List[int]]: """ Scan bus for devices (like i2cdetect). Probes addresses 0x03 to 0x77 (valid 7-bit range). Args: handle: I2C handle (must be open) Returns: (Status.OK, [addresses]) on success (Status.NOT_OPEN, []) if bus not open """ if not handle._is_open or not handle._bus: return (Status.NOT_OPEN, []) devices = [] with handle._lock: for addr in range(0x03, 0x78): # Valid 7-bit address range try: # Quick write to probe device handle._bus.write_quick(addr) devices.append(addr) except OSError: # Device not present (NACK) continue except Exception: # Other error - skip continue return (Status.OK, devices) # ============================================================================= # Read Operations # ============================================================================= def i2c_read_byte(handle: I2CHandle, addr: int, reg: int) -> Tuple[Status, int]: """ Read single byte from register. Args: handle: I2C handle addr: Device address (7-bit) reg: Register address Returns: (Status.OK, value) on success (Status.NOT_OPEN, 0) if bus not open (Status.NACK, 0) if device did not respond (Status.ERROR, 0) on other error """ if not handle._is_open or not handle._bus: return (Status.NOT_OPEN, 0) try: with handle._lock: value = handle._bus.read_byte_data(addr, reg) handle._total_reads += 1 return (Status.OK, value) except OSError as e: with handle._lock: handle._total_errors += 1 # errno 121 = Remote I/O error (NACK) if e.errno == 121: return (Status.NACK, 0) return (Status.ERROR, 0) except Exception: with handle._lock: handle._total_errors += 1 return (Status.ERROR, 0) def i2c_read_block(handle: I2CHandle, addr: int, reg: int, length: int) -> Tuple[Status, bytes]: """ Read block of bytes from register. Args: handle: I2C handle addr: Device address (7-bit) reg: Register address length: Number of bytes to read (1-32) Returns: (Status.OK, data) on success (Status.NOT_OPEN, b"") if bus not open (Status.NACK, b"") if device did not respond (Status.ERROR, b"") on other error """ if not handle._is_open or not handle._bus: return (Status.NOT_OPEN, b"") if length < 1 or length > 32: return (Status.ERROR, b"") try: with handle._lock: data = handle._bus.read_i2c_block_data(addr, reg, length) handle._total_reads += 1 return (Status.OK, bytes(data)) except OSError as e: with handle._lock: handle._total_errors += 1 if e.errno == 121: return (Status.NACK, b"") return (Status.ERROR, b"") except Exception: with handle._lock: handle._total_errors += 1 return (Status.ERROR, b"") # ============================================================================= # Write Operations # ============================================================================= def i2c_write_byte(handle: I2CHandle, addr: int, reg: int, value: int) -> Status: """ Write single byte to register. Args: handle: I2C handle addr: Device address (7-bit) reg: Register address value: Byte value (0-255) Returns: Status.OK on success Status.NOT_OPEN if bus not open Status.NACK if device did not respond Status.ERROR on other error """ if not handle._is_open or not handle._bus: return Status.NOT_OPEN if value < 0 or value > 255: return Status.ERROR try: with handle._lock: handle._bus.write_byte_data(addr, reg, value) handle._total_writes += 1 return Status.OK except OSError as e: with handle._lock: handle._total_errors += 1 if e.errno == 121: return Status.NACK return Status.ERROR except Exception: with handle._lock: handle._total_errors += 1 return Status.ERROR def i2c_write_block(handle: I2CHandle, addr: int, reg: int, data: bytes) -> Status: """ Write block of bytes to register. Args: handle: I2C handle addr: Device address (7-bit) reg: Register address data: Bytes to write (1-32 bytes) Returns: Status.OK on success Status.NOT_OPEN if bus not open Status.NACK if device did not respond Status.ERROR on other error """ if not handle._is_open or not handle._bus: return Status.NOT_OPEN if len(data) < 1 or len(data) > 32: return Status.ERROR try: with handle._lock: handle._bus.write_i2c_block_data(addr, reg, list(data)) handle._total_writes += 1 return Status.OK except OSError as e: with handle._lock: handle._total_errors += 1 if e.errno == 121: return Status.NACK return Status.ERROR except Exception: with handle._lock: handle._total_errors += 1 return Status.ERROR # ============================================================================= # Logger (Background Polling) # ============================================================================= def i2c_start_logger(handle: I2CHandle, addr: int, reg: int, length: int, interval_ms: int) -> Status: """ Start background logger thread. Continuously polls register at specified rate and stores in buffer. Args: handle: I2C handle addr: Device address (7-bit) reg: Register address length: Bytes to read (1-32) interval_ms: Polling interval in milliseconds Returns: Status.OK on success Status.NOT_OPEN if bus not open Status.LOGGER_RUNNING if already running """ if not handle._is_open: return Status.NOT_OPEN if handle._logger_running: return Status.LOGGER_RUNNING if length < 1 or length > 32 or interval_ms < 1: return Status.ERROR # Configure logger handle._logger_addr = addr handle._logger_reg = reg handle._logger_len = length handle._logger_interval_ms = interval_ms # Start thread handle._stop_logger_event.clear() handle._logger_running = True handle._logger_thread = threading.Thread( target=_logger_thread_func, args=(handle,), daemon=True, name=f"I2C-Logger-{handle.config.bus_id}" ) handle._logger_thread.start() return Status.OK def i2c_stop_logger(handle: I2CHandle) -> Status: """ Stop background logger thread. Blocks until thread exits (with timeout). Args: handle: I2C handle Returns: Status.OK """ if not handle._logger_running: return Status.OK handle._stop_logger_event.set() if handle._logger_thread: handle._logger_thread.join(timeout=2.0) handle._logger_thread = None handle._logger_running = False return Status.OK def _logger_thread_func(handle: I2CHandle): """ Logger thread implementation. Polls register at configured interval and stores readings in buffer. """ addr = handle._logger_addr reg = handle._logger_reg length = handle._logger_len interval_s = handle._logger_interval_ms / 1000.0 while not handle._stop_logger_event.is_set(): try: # Read from device with handle._lock: if not handle._bus: break data = handle._bus.read_i2c_block_data(addr, reg, length) timestamp = handle._get_timestamp() handle._total_reads += 1 handle._reading_counter += 1 reading_id = handle._reading_counter # Create reading record reading = I2CReading( reading_id=reading_id, timestamp=timestamp, address=addr, register=reg, data=bytes(data), operation="READ" ) # Store in buffer (serialize reading to bytes) # Format: [timestamp(8)][addr(1)][reg(1)][len(1)][data(N)] buf_data = ( int(timestamp * 1_000_000).to_bytes(8, 'little') + addr.to_bytes(1, 'little') + reg.to_bytes(1, 'little') + len(data).to_bytes(1, 'little') + bytes(data) ) cb_write(handle._buffer, buf_data) except Exception: with handle._lock: handle._total_errors += 1 # Sleep for interval (check stop event periodically) handle._stop_logger_event.wait(interval_s) # ============================================================================= # Status Query # ============================================================================= def i2c_get_status(handle: I2CHandle) -> BusInfo: """ Get current bus status. Args: handle: I2C handle Returns: BusInfo with current metrics """ if not handle._buffer: return BusInfo( is_open=handle._is_open, is_logger_running=handle._logger_running, buffer_capacity=0, buffer_fill_bytes=0, buffer_fill_percent=0, buffer_overflows=0, total_reads=0, total_writes=0, total_errors=0 ) with handle._lock: total_reads = handle._total_reads total_writes = handle._total_writes total_errors = handle._total_errors return BusInfo( is_open=handle._is_open, is_logger_running=handle._logger_running, buffer_capacity=cb_capacity(handle._buffer), buffer_fill_bytes=cb_fill_bytes(handle._buffer), buffer_fill_percent=cb_fill_pct(handle._buffer), buffer_overflows=cb_overflows(handle._buffer), total_reads=total_reads, total_writes=total_writes, total_errors=total_errors )