352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""Minimal Linux GPIO v2 UAPI wrapper for output lines and input edge events."""
|
|
|
|
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_INPUT = 1 << 2
|
|
GPIO_V2_LINE_FLAG_OUTPUT = 1 << 3
|
|
GPIO_V2_LINE_FLAG_EDGE_RISING = 1 << 4
|
|
GPIO_V2_LINE_FLAG_EDGE_FALLING = 1 << 5
|
|
GPIO_V2_LINE_FLAG_BIAS_PULL_UP = 1 << 8
|
|
GPIO_V2_LINE_FLAG_BIAS_PULL_DOWN = 1 << 9
|
|
GPIO_V2_LINE_FLAG_BIAS_DISABLED = 1 << 10
|
|
|
|
GPIO_V2_LINE_ATTR_ID_DEBOUNCE = 3
|
|
|
|
GPIO_V2_LINE_EVENT_RISING_EDGE = 1
|
|
GPIO_V2_LINE_EVENT_FALLING_EDGE = 2
|
|
|
|
_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),
|
|
]
|
|
|
|
|
|
class GpioV2LineEvent(ctypes.Structure):
|
|
"""ctypes mapping of `gpio_v2_line_event`."""
|
|
|
|
_fields_ = [
|
|
("timestamp_ns", ctypes.c_uint64),
|
|
("id", ctypes.c_uint32),
|
|
("offset", ctypes.c_uint32),
|
|
("seqno", ctypes.c_uint32),
|
|
("line_seqno", ctypes.c_uint32),
|
|
("padding", ctypes.c_uint32 * 6),
|
|
]
|
|
|
|
|
|
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 (best-effort, idempotent)."""
|
|
try:
|
|
self._close_line_fd()
|
|
finally:
|
|
# Ensure the chip fd is always closed even if closing the line fd raised.
|
|
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 (best-effort, idempotent)."""
|
|
if self._line_fd >= 0:
|
|
try:
|
|
os.close(self._line_fd)
|
|
finally:
|
|
# Always clear the fd so close() stays idempotent even if os.close raised.
|
|
self._line_fd = -1
|
|
|
|
def _close_chip_fd(self) -> None:
|
|
"""Close chip file descriptor if currently open (best-effort, idempotent)."""
|
|
if self._chip_fd >= 0:
|
|
try:
|
|
os.close(self._chip_fd)
|
|
finally:
|
|
# Always clear the fd so close() stays idempotent even if os.close raised.
|
|
self._chip_fd = -1
|
|
|
|
|
|
class GpioLineEventWatcher:
|
|
"""Watch a single GPIO input line for edge events via Linux GPIO v2 UAPI.
|
|
|
|
The line file descriptor returned by the kernel becomes readable whenever a
|
|
requested edge occurs; each read yields exactly one ``gpio_v2_line_event``.
|
|
Callers drive the wait loop themselves (e.g. with :func:`select.select`)
|
|
using :meth:`fileno`, which keeps this wrapper free of any threading or
|
|
polling policy.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
chip: str,
|
|
offset: int,
|
|
*,
|
|
edge_flags: int,
|
|
bias_flags: int = 0,
|
|
debounce_us: int = 0,
|
|
consumer: str = "radar_input",
|
|
) -> None:
|
|
"""Build an input line request descriptor for edge detection."""
|
|
if not chip:
|
|
raise ValueError("gpio chip path must not be empty")
|
|
if offset < 0:
|
|
raise ValueError("GPIO offset must be non-negative")
|
|
if not edge_flags:
|
|
raise ValueError("at least one edge flag is required")
|
|
|
|
self._chip = chip
|
|
self._offset = int(offset)
|
|
self._flags = GPIO_V2_LINE_FLAG_INPUT | int(edge_flags) | int(bias_flags)
|
|
self._debounce_us = max(0, int(debounce_us))
|
|
self._consumer = (consumer or "radar_input").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 the configured input line with edge events."""
|
|
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()
|
|
request.offsets[0] = ctypes.c_uint32(self._offset).value
|
|
request.num_lines = ctypes.c_uint32(1).value
|
|
request.config.flags = ctypes.c_uint64(self._flags).value
|
|
request.consumer = self._consumer
|
|
|
|
if self._debounce_us > 0:
|
|
config_attr = request.config.attrs[0]
|
|
config_attr.attr.id = ctypes.c_uint32(GPIO_V2_LINE_ATTR_ID_DEBOUNCE).value
|
|
config_attr.attr.value = ctypes.c_uint64(self._debounce_us).value
|
|
config_attr.mask = ctypes.c_uint64(1).value # applies to line index 0
|
|
request.config.num_attrs = ctypes.c_uint32(1).value
|
|
|
|
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 line 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 fileno(self) -> int:
|
|
"""Return the line file descriptor for use with poll/select."""
|
|
if self._line_fd < 0:
|
|
raise RuntimeError("GPIO line request is not open")
|
|
return self._line_fd
|
|
|
|
def read_event(self) -> int:
|
|
"""Read one queued edge event and return its event id (rising/falling)."""
|
|
if self._line_fd < 0:
|
|
raise RuntimeError("GPIO line request is not open")
|
|
|
|
size = ctypes.sizeof(GpioV2LineEvent)
|
|
data = os.read(self._line_fd, size)
|
|
if len(data) < size:
|
|
raise RuntimeError(f"Short GPIO event read: expected {size} bytes, got {len(data)}")
|
|
|
|
event = GpioV2LineEvent.from_buffer_copy(data)
|
|
return int(event.id)
|
|
|
|
def close(self) -> None:
|
|
"""Close line request and chip file descriptors (best-effort, idempotent)."""
|
|
try:
|
|
self._close_line_fd()
|
|
finally:
|
|
# Ensure the chip fd is always closed even if closing the line fd raised.
|
|
self._close_chip_fd()
|
|
|
|
def _close_line_fd(self) -> None:
|
|
"""Close line file descriptor if currently open (best-effort, idempotent)."""
|
|
if self._line_fd >= 0:
|
|
try:
|
|
os.close(self._line_fd)
|
|
finally:
|
|
# Always clear the fd so close() stays idempotent even if os.close raised.
|
|
self._line_fd = -1
|
|
|
|
def _close_chip_fd(self) -> None:
|
|
"""Close chip file descriptor if currently open (best-effort, idempotent)."""
|
|
if self._chip_fd >= 0:
|
|
try:
|
|
os.close(self._chip_fd)
|
|
finally:
|
|
# Always clear the fd so close() stays idempotent even if os.close raised.
|
|
self._chip_fd = -1
|