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.

685 lines
18 KiB

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