"""Minimal Linux GPIO v2 UAPI wrapper for output-only line control.""" from __future__ import annotations import ctypes import errno import fcntl import os from typing import Sequence GPIO_MAX_NAME_SIZE = 32 GPIO_V2_LINES_MAX = 64 GPIO_V2_LINE_NUM_ATTRS_MAX = 10 GPIO_V2_LINE_FLAG_OUTPUT = 1 << 3 _IOC_NRBITS = 8 _IOC_TYPEBITS = 8 _IOC_SIZEBITS = 14 _IOC_DIRBITS = 2 _IOC_NRSHIFT = 0 _IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS _IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS _IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS _IOC_WRITE = 1 _IOC_READ = 2 def _ioc(direction: int, ioc_type: int, number: int, size: int) -> int: """Build raw ioctl command number.""" return ( (direction << _IOC_DIRSHIFT) | (ioc_type << _IOC_TYPESHIFT) | (number << _IOC_NRSHIFT) | (size << _IOC_SIZESHIFT) ) def _iowr(ioc_type: int, number: int, struct_type: type[ctypes.Structure]) -> int: """Build read-write ioctl number for provided structure.""" return _ioc(_IOC_READ | _IOC_WRITE, ioc_type, number, ctypes.sizeof(struct_type)) class GpioV2LineAttribute(ctypes.Structure): """ctypes mapping of `gpio_v2_line_attribute`.""" _fields_ = [ ("id", ctypes.c_uint32), ("padding", ctypes.c_uint32), ("value", ctypes.c_uint64), ] class GpioV2LineConfigAttribute(ctypes.Structure): """ctypes mapping of `gpio_v2_line_config_attribute`.""" _fields_ = [ ("attr", GpioV2LineAttribute), ("mask", ctypes.c_uint64), ] class GpioV2LineConfig(ctypes.Structure): """ctypes mapping of `gpio_v2_line_config`.""" _fields_ = [ ("flags", ctypes.c_uint64), ("num_attrs", ctypes.c_uint32), ("padding", ctypes.c_uint32 * 5), ("attrs", GpioV2LineConfigAttribute * GPIO_V2_LINE_NUM_ATTRS_MAX), ] class GpioV2LineRequest(ctypes.Structure): """ctypes mapping of `gpio_v2_line_request`.""" _fields_ = [ ("offsets", ctypes.c_uint32 * GPIO_V2_LINES_MAX), ("consumer", ctypes.c_char * GPIO_MAX_NAME_SIZE), ("config", GpioV2LineConfig), ("num_lines", ctypes.c_uint32), ("event_buffer_size", ctypes.c_uint32), ("padding", ctypes.c_uint32 * 5), ("fd", ctypes.c_int32), ] class GpioV2LineValues(ctypes.Structure): """ctypes mapping of `gpio_v2_line_values`.""" _fields_ = [ ("bits", ctypes.c_uint64), ("mask", ctypes.c_uint64), ] GPIO_V2_GET_LINE_IOCTL = _iowr(0xB4, 0x07, GpioV2LineRequest) GPIO_V2_LINE_SET_VALUES_IOCTL = _iowr(0xB4, 0x0F, GpioV2LineValues) class GpioOutputLines: """Open and control one or more GPIO output lines as a single request.""" def __init__(self, chip: str, offsets: Sequence[int], consumer: str) -> None: """Build GPIO line request descriptor.""" if not chip: raise ValueError("gpio chip path must not be empty") if not offsets: raise ValueError("at least one GPIO line offset is required") if len(offsets) > GPIO_V2_LINES_MAX: raise ValueError(f"too many GPIO offsets requested: {len(offsets)}") normalized_offsets = [int(offset) for offset in offsets] if any(offset < 0 for offset in normalized_offsets): raise ValueError("GPIO offsets must be non-negative") if len(set(normalized_offsets)) != len(normalized_offsets): raise ValueError("GPIO offsets must be unique") self._chip = chip self._offsets = normalized_offsets self._consumer = (consumer or "radar_switch").encode("ascii", errors="ignore")[: GPIO_MAX_NAME_SIZE - 1] self._chip_fd = -1 self._line_fd = -1 def open(self) -> None: """Open GPIO chip and request configured output lines.""" if self._line_fd >= 0: return try: self._chip_fd = os.open(self._chip, os.O_RDONLY | os.O_CLOEXEC) except OSError as exc: raise RuntimeError(f"Failed to open GPIO chip '{self._chip}': {exc}") from exc request = GpioV2LineRequest() for index, offset in enumerate(self._offsets): request.offsets[index] = ctypes.c_uint32(offset).value request.num_lines = ctypes.c_uint32(len(self._offsets)).value request.config.flags = ctypes.c_uint64(GPIO_V2_LINE_FLAG_OUTPUT).value request.consumer = self._consumer try: fcntl.ioctl(self._chip_fd, GPIO_V2_GET_LINE_IOCTL, request) except OSError as exc: self._close_chip_fd() raise RuntimeError(f"Failed to request GPIO lines on '{self._chip}': {exc}") from exc if request.fd < 0: self._close_chip_fd() raise RuntimeError(f"GPIO line request returned invalid fd for '{self._chip}'") self._line_fd = int(request.fd) def close(self) -> None: """Close line request and chip file descriptors.""" self._close_line_fd() self._close_chip_fd() def set_values(self, values: Sequence[int]) -> None: """Apply output values for all requested lines.""" if self._line_fd < 0: raise RuntimeError("GPIO line request is not open") if len(values) != len(self._offsets): raise ValueError( f"GPIO values length mismatch: expected {len(self._offsets)}, got {len(values)}" ) bits = 0 for index, value in enumerate(values): normalized = int(value) if normalized not in (0, 1): raise ValueError(f"GPIO output value must be 0 or 1, got {value}") if normalized == 1: bits |= (1 << index) mask = (1 << len(self._offsets)) - 1 line_values = GpioV2LineValues(bits=ctypes.c_uint64(bits).value, mask=ctypes.c_uint64(mask).value) try: fcntl.ioctl(self._line_fd, GPIO_V2_LINE_SET_VALUES_IOCTL, line_values) except OSError as exc: if exc.errno == errno.ENODEV: raise RuntimeError("GPIO device disconnected") from exc raise RuntimeError(f"Failed to set GPIO output values: {exc}") from exc def _close_line_fd(self) -> None: """Close line file descriptor if currently open.""" if self._line_fd >= 0: os.close(self._line_fd) self._line_fd = -1 def _close_chip_fd(self) -> None: """Close chip file descriptor if currently open.""" if self._chip_fd >= 0: os.close(self._chip_fd) self._chip_fd = -1