init commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Switch driver implementations for native GPIO and mock operation."""
|
||||
|
||||
from python_app.hardware_full.switch_drivers.h7992_driver import H7992Driver
|
||||
from python_app.hardware_full.switch_drivers.hmc349a_driver import HMC349ADriver
|
||||
from python_app.hardware_full.switch_drivers.interface import SwitchDriverProtocol
|
||||
from python_app.hardware_full.switch_drivers.mock_driver import MockSwitchDriver
|
||||
|
||||
__all__ = [
|
||||
"H7992Driver",
|
||||
"HMC349ADriver",
|
||||
"MockSwitchDriver",
|
||||
"SwitchDriverProtocol",
|
||||
]
|
||||
@@ -0,0 +1,200 @@
|
||||
"""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
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Native GPIO driver for H7992 switch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from python_app.hardware_full.switch_drivers.gpio_uapi import GpioOutputLines
|
||||
|
||||
|
||||
_POSITION_TO_AB = (
|
||||
(0, 0),
|
||||
(0, 1),
|
||||
(1, 0),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class H7992Driver:
|
||||
"""Drive H7992 using two GPIO lines (A/B)."""
|
||||
|
||||
name: str
|
||||
positions: int = 4
|
||||
default_position: int = 0
|
||||
gpio_chip: str = "/dev/gpiochip0"
|
||||
pin_a: int = 17
|
||||
pin_b: int = 27
|
||||
_lines: GpioOutputLines | None = field(init=False, default=None, repr=False)
|
||||
_is_open: bool = field(init=False, default=False, repr=False)
|
||||
_current_position: int = field(init=False, default=0, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Initialize runtime state."""
|
||||
self._lines: GpioOutputLines | None = None
|
||||
self._is_open = False
|
||||
self._current_position = 0
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open GPIO lines and switch to default position."""
|
||||
if self._is_open:
|
||||
return
|
||||
|
||||
self._validate()
|
||||
self._lines = GpioOutputLines(
|
||||
chip=self.gpio_chip,
|
||||
offsets=(self.pin_a, self.pin_b),
|
||||
consumer=f"radar_{self.name}",
|
||||
)
|
||||
self._lines.open()
|
||||
self._is_open = True
|
||||
self.switch_to(self.default_position)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close GPIO lines."""
|
||||
if self._lines is not None:
|
||||
self._lines.close()
|
||||
self._lines = None
|
||||
self._is_open = False
|
||||
|
||||
def position_count(self) -> int:
|
||||
"""Return number of supported positions."""
|
||||
return self.positions
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
"""Switch hardware to selected position."""
|
||||
if not self._is_open or self._lines is None:
|
||||
raise RuntimeError(f"Switch driver is not open for {self.name}")
|
||||
if position < 0 or position >= self.positions:
|
||||
raise ValueError(f"Position out of range for {self.name}: {position}")
|
||||
|
||||
self._lines.set_values(_POSITION_TO_AB[position])
|
||||
self._current_position = int(position)
|
||||
|
||||
@property
|
||||
def current_position(self) -> int:
|
||||
"""Return current position."""
|
||||
return self._current_position
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Validate H7992 driver parameters."""
|
||||
if self.positions <= 0 or self.positions > 4:
|
||||
raise ValueError(f"H7992 positions must be in range [1,4] for {self.name}")
|
||||
if self.default_position < 0 or self.default_position >= self.positions:
|
||||
raise ValueError(f"default_position out of range for {self.name}")
|
||||
if self.pin_a < 0 or self.pin_b < 0 or self.pin_a == self.pin_b:
|
||||
raise ValueError(f"pin_a/pin_b are invalid for {self.name}")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Native GPIO driver for HMC349A switch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from python_app.hardware_full.switch_drivers.gpio_uapi import GpioOutputLines
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HMC349ADriver:
|
||||
"""Drive HMC349A using one control GPIO line."""
|
||||
|
||||
name: str
|
||||
positions: int = 2
|
||||
default_position: int = 0
|
||||
gpio_chip: str = "/dev/gpiochip0"
|
||||
pin_a: int = 17
|
||||
invert_logic: bool = False
|
||||
_lines: GpioOutputLines | None = field(init=False, default=None, repr=False)
|
||||
_is_open: bool = field(init=False, default=False, repr=False)
|
||||
_current_position: int = field(init=False, default=0, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Initialize runtime state."""
|
||||
self._lines: GpioOutputLines | None = None
|
||||
self._is_open = False
|
||||
self._current_position = 0
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open GPIO lines and switch to default position."""
|
||||
if self._is_open:
|
||||
return
|
||||
|
||||
self._validate()
|
||||
|
||||
offsets = [self.pin_a]
|
||||
self._lines = GpioOutputLines(
|
||||
chip=self.gpio_chip,
|
||||
offsets=offsets,
|
||||
consumer=f"radar_{self.name}",
|
||||
)
|
||||
self._lines.open()
|
||||
self._is_open = True
|
||||
self.switch_to(self.default_position)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close GPIO lines."""
|
||||
if self._lines is not None:
|
||||
self._lines.close()
|
||||
self._lines = None
|
||||
self._is_open = False
|
||||
|
||||
def position_count(self) -> int:
|
||||
"""Return number of supported positions."""
|
||||
return self.positions
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
"""Switch hardware to selected position."""
|
||||
if not self._is_open or self._lines is None:
|
||||
raise RuntimeError(f"Switch driver is not open for {self.name}")
|
||||
if position < 0 or position >= self.positions:
|
||||
raise ValueError(f"Position out of range for {self.name}: {position}")
|
||||
|
||||
control = int(position & 0x01)
|
||||
if self.invert_logic:
|
||||
control ^= 0x01
|
||||
|
||||
self._lines.set_values([control])
|
||||
self._current_position = int(position)
|
||||
|
||||
@property
|
||||
def current_position(self) -> int:
|
||||
"""Return current position."""
|
||||
return self._current_position
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Validate HMC349A driver parameters."""
|
||||
if self.positions <= 0 or self.positions > 2:
|
||||
raise ValueError(f"HMC349A positions must be in range [1,2] for {self.name}")
|
||||
if self.default_position < 0 or self.default_position >= self.positions:
|
||||
raise ValueError(f"default_position out of range for {self.name}")
|
||||
if self.pin_a < 0:
|
||||
raise ValueError(f"pin_a is invalid for {self.name}")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Protocols for switch backend implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class SwitchDriverProtocol(Protocol):
|
||||
"""Required contract for all switch backends."""
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open hardware or mock resources."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close hardware or mock resources."""
|
||||
|
||||
def position_count(self) -> int:
|
||||
"""Return total number of supported positions."""
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
"""Switch to requested zero-based position."""
|
||||
|
||||
@property
|
||||
def current_position(self) -> int:
|
||||
"""Return currently active position."""
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Mock switch driver used in development and simulation modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MockSwitchDriver:
|
||||
"""In-memory switch driver that validates and tracks current position."""
|
||||
|
||||
name: str
|
||||
positions: int
|
||||
default_position: int = 0
|
||||
_is_open: bool = field(init=False, default=False, repr=False)
|
||||
_current_position: int = field(init=False, default=0, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Initialize closed driver state."""
|
||||
self._is_open = False
|
||||
self._current_position = 0
|
||||
|
||||
def open(self) -> None:
|
||||
"""Validate config and open driver."""
|
||||
self._validate()
|
||||
self._is_open = True
|
||||
self.switch_to(self.default_position)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close mock driver."""
|
||||
self._is_open = False
|
||||
|
||||
def position_count(self) -> int:
|
||||
"""Return number of supported positions."""
|
||||
return self.positions
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
"""Switch to requested position."""
|
||||
if not self._is_open:
|
||||
raise RuntimeError(f"Switch driver is not open for {self.name}")
|
||||
if position < 0 or position >= self.positions:
|
||||
raise ValueError(f"Position out of range for {self.name}: {position}")
|
||||
self._current_position = int(position)
|
||||
|
||||
@property
|
||||
def current_position(self) -> int:
|
||||
"""Return current position."""
|
||||
return self._current_position
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Validate mock driver parameters."""
|
||||
if self.positions <= 0:
|
||||
raise ValueError(f"Switch positions must be > 0 for {self.name}")
|
||||
if self.default_position < 0 or self.default_position >= self.positions:
|
||||
raise ValueError(f"Switch default_position out of range for {self.name}")
|
||||
Reference in New Issue
Block a user