From 7dcea5e8d2e36cba3127c754a22a984c582fd6c4 Mon Sep 17 00:00:00 2001 From: zigpy-review-bot <286747149+zigpy-review-bot@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:50:34 +0200 Subject: [PATCH 1/3] Replace gpiozero with a built-in GPIO character device helper gpiozero pulls in colorzero, which declares setuptools as a runtime dependency. The PiZiGate only needs to drive two output pins, so do that directly through /dev/gpiochipN with the GPIO v2 uAPI and drop the dependency. --- README.md | 1 + pyproject.toml | 1 - tests/test_gpio.py | 248 +++++++++++++++++++++++++++++++++++++++++ tests/test_uart.py | 27 ++++- zigpy_zigate/common.py | 40 ++----- zigpy_zigate/gpio.py | 191 +++++++++++++++++++++++++++++++ 6 files changed, 470 insertions(+), 38 deletions(-) create mode 100644 tests/test_gpio.py create mode 100644 zigpy_zigate/gpio.py diff --git a/README.md b/README.md index c4a7501..2caf3f0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Note! ZiGate open source ZigBee USB and GPIO adapter hardware requires ZiGate 3. - To configure __usb__ ZiGate (USB TTL or DIN) port, just specify the port, example : `/dev/ttyUSB0` - Alternatively you could manually set port to `auto` to enable automatic usb port discovery - To configure __pizigate__ port, specify the port, example : `/dev/serial0` or `/dev/ttyAMA0` + - The PiZiGate's GPIO pins are switched through the Linux GPIO character device (`/dev/gpiochip*`, Linux 5.10 or later), so the user running zigpy-zigate needs read/write access to it (e.g. the `gpio` group on Raspberry Pi OS; in a container, pass the `/dev/gpiochip*` devices through) - To configure __wifi__ ZiGate, manually specify IP address and port, example : `socket://192.168.1.10:9999` __pizigate__ does require some additional adjustements on Raspberry Pi 3/Zero, and 4: diff --git a/pyproject.toml b/pyproject.toml index c7fd9af..0df0e0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ dependencies = [ "voluptuous", "zigpy>=1.3.0", "pyusb>=1.1.0", - "gpiozero", ] [tool.setuptools.packages.find] diff --git a/tests/test_gpio.py b/tests/test_gpio.py new file mode 100644 index 0000000..341ad0b --- /dev/null +++ b/tests/test_gpio.py @@ -0,0 +1,248 @@ +import errno +import os +import struct + +import pytest + +from zigpy_zigate import gpio + + +class FakeChip: + def __init__(self, label, line_names): + self.label = label + self.line_names = line_names + + +class FakeKernel: + """Just enough of the GPIO character device uAPI v2 to drive `gpio`.""" + + def __init__(self, chips): + self.chips = chips # path -> FakeChip + self.fds = {} # open fd -> chip path or ("line", request) + self.requests = [] + self.set_values = [] + self.closed = [] + self._next_fd = 100 + + def _new_fd(self, target): + fd = self._next_fd + self._next_fd += 1 + self.fds[fd] = target + return fd + + def glob(self, pattern): + assert pattern == "/dev/gpiochip[0-9]*" + return list(self.chips) + + def open(self, path, flags): + assert flags == os.O_RDWR | os.O_CLOEXEC + if path not in self.chips: + raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path) + return self._new_fd(path) + + def close(self, fd): + self.closed.append(fd) + del self.fds[fd] + + def ioctl(self, fd, request, arg, mutate_flag=True): + target = self.fds[fd] + + if request == gpio.GPIO_GET_CHIPINFO_IOCTL: + chip = self.chips[target] + arg[:] = gpio.CHIP_INFO.pack( + os.path.basename(target).encode(), + chip.label.encode(), + len(chip.line_names), + ) + elif request == gpio.GPIO_V2_GET_LINEINFO_IOCTL: + offset = gpio.LINE_INFO.unpack(arg)[2] + name = self.chips[target].line_names[offset] + arg[:] = gpio.LINE_INFO.pack(name.encode(), b"", offset, 0, 0) + elif request == gpio.GPIO_V2_GET_LINE_IOCTL: + fields = gpio.LINE_REQUEST.unpack(arg) + self.requests.append((target, fields)) + line_fd = self._new_fd(("line", len(self.requests) - 1)) + arg[:] = gpio.LINE_REQUEST.pack(*fields[:-1], line_fd) + elif request == gpio.GPIO_V2_LINE_SET_VALUES_IOCTL: + self.set_values.append((target[1], gpio.LINE_VALUES.unpack(arg))) + else: + raise OSError(errno.ENOTTY, os.strerror(errno.ENOTTY)) + + return 0 + + +def pi_line_names(count): + return [f"GPIO{n}" for n in range(count)] + + +@pytest.fixture +def kernel(monkeypatch): + def install(chips): + k = FakeKernel(chips) + monkeypatch.setattr(gpio.glob, "glob", k.glob) + monkeypatch.setattr(gpio.os, "open", k.open) + monkeypatch.setattr(gpio.os, "close", k.close) + monkeypatch.setattr(gpio.fcntl, "ioctl", k.ioctl) + return k + + monkeypatch.setattr(gpio, "_requests", {}) + return install + + +def test_uapi_layout(): + # Sizes and ioctl numbers of , printed by a C program on x86-64. + # The v2 uAPI structs are padded to have the same layout on every architecture. + assert gpio.CHIP_INFO.size == 68 + assert gpio.LINE_INFO.size == 256 + assert gpio.LINE_REQUEST.size == 592 + assert gpio.LINE_VALUES.size == 16 + assert gpio.GPIO_GET_CHIPINFO_IOCTL == 0x8044B401 + assert gpio.GPIO_V2_GET_LINEINFO_IOCTL == 0xC100B405 + assert gpio.GPIO_V2_GET_LINE_IOCTL == 0xC250B407 + assert gpio.GPIO_V2_LINE_SET_VALUES_IOCTL == 0xC010B40F + + +def test_line_request_layout(): + raw = gpio.LINE_REQUEST.pack(17, b"zigpy-zigate", 8, 1, 2, 1, 1, 1, 0, 0) + + # offsets[0] at 0, consumer at 256, config at 288, num_lines at 560, fd at 588 + assert struct.unpack_from("=I", raw, 0) == (17,) + assert raw[256:288].rstrip(b"\0") == b"zigpy-zigate" + # config: flags, num_attrs, then attrs[0] = {id, padding, values, mask} at +32 + assert struct.unpack_from("=QI", raw, 288) == (8, 1) + assert struct.unpack_from("=I4xQQ", raw, 288 + 32) == (2, 1, 1) + assert struct.unpack_from("=I", raw, 560) == (1,) + assert struct.unpack_from("=i", raw, 588) == (0,) + # everything else (the other offsets and attributes, padding) is zero + assert raw.count(0) == len(raw) - len(b"zigpy-zigate") - 7 + + +def test_set_value_requests_line_once(kernel): + k = kernel( + { + "/dev/gpiochip0": FakeChip("pinctrl-bcm2711", pi_line_names(58)), + "/dev/gpiochip1": FakeChip("raspberrypi-exp-gpio", ["BT_ON"] * 8), + } + ) + + gpio.set_value(27, True) + gpio.set_value(17, False) + gpio.set_value(17, True) + gpio.set_value(27, False) + + # each line is requested once, as an output, with the first value as its + # initial output value + assert [(path, f[0], f[1].rstrip(b"\0")) for path, f in k.requests] == [ + ("/dev/gpiochip0", 27, gpio.CONSUMER), + ("/dev/gpiochip0", 17, gpio.CONSUMER), + ] + for _, fields in k.requests: + assert fields[2] == gpio.GPIO_V2_LINE_FLAG_OUTPUT + assert fields[3:5] == (1, gpio.GPIO_V2_LINE_ATTR_ID_OUTPUT_VALUES) + assert fields[6:9] == (1, 1, 0) # mask, num_lines, event_buffer_size + assert [f[5] for _, f in k.requests] == [1, 0] + + # later writes go to the held requests + assert k.set_values == [(1, (1, 1)), (0, (0, 1))] + + # chip fds are closed, the line requests are kept open + assert sorted(k.fds) == sorted(gpio._requests.values()) + assert all(target[0] == "line" for target in k.fds.values()) + + +def test_find_line_by_name_on_any_chip(kernel): + # Pi 5 on older kernels: the header is on gpiochip4, after the SoC's own chips + k = kernel( + { + "/dev/gpiochip0": FakeChip("gpio-brcmstb@107d508500", ["-"] * 32), + "/dev/gpiochip10": FakeChip("other", ["GPIO17"]), + "/dev/gpiochip4": FakeChip("pinctrl-rp1", pi_line_names(54)), + } + ) + + assert gpio._find_line(17) == ("/dev/gpiochip4", 17) + assert gpio._find_line(27) == ("/dev/gpiochip4", 27) + assert not k.fds + + +def test_find_line_by_chip_label(kernel): + kernel( + { + "/dev/gpiochip0": FakeChip("other", ["-"] * 64), + "/dev/gpiochip1": FakeChip("pinctrl-bcm2835", [""] * 54), + } + ) + + assert gpio._find_line(17) == ("/dev/gpiochip1", 17) + + +def test_find_line_no_chips(kernel): + kernel({}) + + with pytest.raises(gpio.GPIOError, match="No GPIO character devices"): + gpio.set_value(17, True) + + +def test_find_line_not_found(kernel, monkeypatch): + k = kernel( + { + "/dev/gpiochip0": FakeChip("other", ["-"] * 64), + "/dev/gpiochip1": FakeChip("other", ["-"] * 8), + } + ) + + def open_(path, flags): + if path == "/dev/gpiochip1": + raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), path) + return FakeKernel.open(k, path, flags) + + monkeypatch.setattr(gpio.os, "open", open_) + + with pytest.raises(gpio.GPIOError) as exc_info: + gpio.set_value(17, True) + + assert str(exc_info.value) == ( + "No GPIO line named GPIO17 found on /dev/gpiochip0, /dev/gpiochip1" + " (could not read /dev/gpiochip1: Permission denied)" + ) + assert not gpio._requests + + +def test_request_fails(kernel, monkeypatch): + k = kernel({"/dev/gpiochip0": FakeChip("pinctrl-bcm2711", pi_line_names(58))}) + ioctl = k.ioctl + + def busy_ioctl(fd, request, arg, mutate_flag=True): + if request == gpio.GPIO_V2_GET_LINE_IOCTL: + raise OSError(errno.EBUSY, os.strerror(errno.EBUSY)) + return ioctl(fd, request, arg, mutate_flag) + + monkeypatch.setattr(gpio.fcntl, "ioctl", busy_ioctl) + + with pytest.raises(gpio.GPIOError) as exc_info: + gpio.set_value(17, True) + + assert str(exc_info.value) == ( + "Could not request GPIO17 (/dev/gpiochip0 line 17) as an output:" + " Device or resource busy" + ) + assert not gpio._requests + assert not k.fds + + # a later call tries again + monkeypatch.setattr(gpio.fcntl, "ioctl", ioctl) + gpio.set_value(17, True) + assert list(gpio._requests) == [17] + + +def test_set_value_fails(kernel, monkeypatch): + kernel({"/dev/gpiochip0": FakeChip("pinctrl-bcm2711", pi_line_names(58))}) + gpio.set_value(17, True) + + def failing_ioctl(fd, request, arg, mutate_flag=True): + raise OSError(errno.EIO, os.strerror(errno.EIO)) + + monkeypatch.setattr(gpio.fcntl, "ioctl", failing_ioctl) + + with pytest.raises(gpio.GPIOError, match="Could not set GPIO17: Input/output"): + gpio.set_value(17, False) diff --git a/tests/test_uart.py b/tests/test_uart.py index 4f9740b..b4be594 100644 --- a/tests/test_uart.py +++ b/tests/test_uart.py @@ -1,6 +1,5 @@ from unittest.mock import AsyncMock, MagicMock, call -import gpiozero import pytest from serialx import SerialPortInfo import zigpy.config @@ -21,8 +20,6 @@ def gw(): ("/dev/null", "pizigate:/dev/ttyAMA0"), ) async def test_connect(port, monkeypatch): - monkeypatch.setattr(gpiozero.Device, "_default_pin_factory", MagicMock()) - api = MagicMock() async def mock_conn(loop, protocol_factory, url, **kwargs): @@ -32,7 +29,7 @@ async def mock_conn(loop, protocol_factory, url, **kwargs): return None, protocol monkeypatch.setattr(uart, "create_serial_connection", mock_conn) - monkeypatch.setattr(common, "set_pizigate_running_mode", AsyncMock()) + monkeypatch.setattr(common, "async_set_pizigate_running_mode", AsyncMock()) DEVICE_CONFIG = zigpy.config.SCHEMA_DEVICE({zigpy.config.CONF_DEVICE_PATH: port}) await uart.connect(DEVICE_CONFIG, api) @@ -187,7 +184,27 @@ def test_is_not_zigate_wifi(): def test_startup_gpio_toggling(monkeypatch): - monkeypatch.setattr(gpiozero.Device, "_default_pin_factory", MagicMock()) + manager = MagicMock() + monkeypatch.setattr(common.gpio, "set_value", manager.set_value) + monkeypatch.setattr(common.time, "sleep", manager.sleep) common.set_pizigate_running_mode() + assert manager.mock_calls == [ + call.set_value(27, True), + call.sleep(0.5), + call.set_value(17, False), + call.sleep(0.5), + call.set_value(17, True), + call.sleep(0.5), + ] + + manager.reset_mock() common.set_pizigate_flashing_mode() + assert manager.mock_calls == [ + call.set_value(27, False), + call.sleep(0.5), + call.set_value(17, False), + call.sleep(0.5), + call.set_value(17, True), + call.sleep(0.5), + ] diff --git a/zigpy_zigate/common.py b/zigpy_zigate/common.py index 3469c95..95a4a25 100644 --- a/zigpy_zigate/common.py +++ b/zigpy_zigate/common.py @@ -4,34 +4,16 @@ import re import time -from gpiozero import OutputDevice from serialx import SerialException, list_serial_ports +from . import gpio + LOGGER = logging.getLogger(__name__) GPIO_PIN0 = 17 GPIO_PIN2 = 27 -class UnclosableOutputDevice(OutputDevice): - """ - `OutputDevice` that never closes its pins. Allows for the last-written pin state to - be retained even after the `OutputDevice` is garbage collected. - """ - - def __init__( - self, pin=None, *, active_high=True, initial_value=False, pin_factory=None - ): - super().__init__( - pin, - active_high=active_high, - initial_value=initial_value, - pin_factory=pin_factory, - ) - self._pin.close = lambda *args, **kwargs: None - self.pin_factory.close = lambda *args, **kwargs: None - - def discover_port(): """discover zigate port""" ports = list_serial_ports() @@ -78,32 +60,26 @@ def is_zigate_wifi(port): def set_pizigate_running_mode(): LOGGER.info("Put PiZiGate in running mode") - gpio0 = UnclosableOutputDevice(pin=GPIO_PIN0, initial_value=None) - gpio2 = UnclosableOutputDevice(pin=GPIO_PIN2, initial_value=None) - - gpio2.on() + gpio.set_value(GPIO_PIN2, True) time.sleep(0.5) - gpio0.off() + gpio.set_value(GPIO_PIN0, False) time.sleep(0.5) - gpio0.on() + gpio.set_value(GPIO_PIN0, True) time.sleep(0.5) def set_pizigate_flashing_mode(): LOGGER.info("Put PiZiGate in flashing mode") - gpio0 = UnclosableOutputDevice(pin=GPIO_PIN0, initial_value=None) - gpio2 = UnclosableOutputDevice(pin=GPIO_PIN2, initial_value=None) - - gpio2.off() + gpio.set_value(GPIO_PIN2, False) time.sleep(0.5) - gpio0.off() + gpio.set_value(GPIO_PIN0, False) time.sleep(0.5) - gpio0.on() + gpio.set_value(GPIO_PIN0, True) time.sleep(0.5) diff --git a/zigpy_zigate/gpio.py b/zigpy_zigate/gpio.py new file mode 100644 index 0000000..bb8e110 --- /dev/null +++ b/zigpy_zigate/gpio.py @@ -0,0 +1,191 @@ +"""Minimal GPIO output support using the Linux GPIO character device. + +This only implements what the PiZiGate needs: drive a header GPIO pin as an output and +keep driving it. It talks to `/dev/gpiochipN` with the GPIO v2 uAPI (Linux 5.10+) and +has no third-party dependencies. + +Line requests are kept open for the lifetime of the process: closing one hands the line +back to the kernel, which may stop driving the pin. +""" + +from __future__ import annotations + +import fcntl +import glob +import logging +import os +import re +import struct +import threading + +LOGGER = logging.getLogger(__name__) + +CONSUMER = b"zigpy-zigate" + +# GPIO chips that expose the Raspberry Pi header with line offset == BCM GPIO number. +# Only used if no line carries the expected "GPIO" name. +PI_HEADER_CHIP_LABELS = frozenset( + { + "pinctrl-bcm2835", # Pi 1-3, Zero + "pinctrl-bcm2711", # Pi 4 + "pinctrl-rp1", # Pi 5 + } +) + +GPIO_V2_LINE_FLAG_OUTPUT = 1 << 3 +GPIO_V2_LINE_ATTR_ID_OUTPUT_VALUES = 2 + + +def _ioc(direction: int, nr: int, size: int) -> int: + """`_IOC()` from `asm-generic/ioctl.h`, used by ARM and x86.""" + return (direction << 30) | (size << 16) | (0xB4 << 8) | nr + + +_IOC_WRITE = 1 +_IOC_READ = 2 + +# struct gpiochip_info: name[32], label[32], lines +CHIP_INFO = struct.Struct("=32s32sI") +# struct gpio_v2_line_info: name[32], consumer[32], offset, num_attrs, flags, +# attrs[10] (16 bytes each), padding[4] +LINE_INFO = struct.Struct("=32s32sIIQ160x16x") +# struct gpio_v2_line_request for a single line with a single config attribute: +# offsets[64], consumer[32], +# config { flags, num_attrs, padding[5], attrs[10] { id, padding, value, mask } }, +# num_lines, event_buffer_size, padding[5], fd +LINE_REQUEST = struct.Struct("=I252x32sQI20xI4xQQ216xII20xi") +# struct gpio_v2_line_values: bits, mask +LINE_VALUES = struct.Struct("=QQ") + +GPIO_GET_CHIPINFO_IOCTL = _ioc(_IOC_READ, 0x01, CHIP_INFO.size) +GPIO_V2_GET_LINEINFO_IOCTL = _ioc(_IOC_READ | _IOC_WRITE, 0x05, LINE_INFO.size) +GPIO_V2_GET_LINE_IOCTL = _ioc(_IOC_READ | _IOC_WRITE, 0x07, LINE_REQUEST.size) +GPIO_V2_LINE_SET_VALUES_IOCTL = _ioc(_IOC_READ | _IOC_WRITE, 0x0F, LINE_VALUES.size) + + +class GPIOError(RuntimeError): + """A GPIO line could not be found or driven.""" + + +# BCM GPIO number -> file descriptor of the line request, held until the process exits +_requests: dict[int, int] = {} +_lock = threading.Lock() + + +def _cstr(raw: bytes) -> str: + return raw.split(b"\0", 1)[0].decode("utf-8", "replace") + + +def _chip_paths() -> list[str]: + def chip_number(path: str) -> int: + return int(re.sub(r"\D", "", os.path.basename(path)) or 0) + + return sorted(glob.glob("/dev/gpiochip[0-9]*"), key=chip_number) + + +def _find_line(pin: int) -> tuple[str, int]: + """Find the chip and line offset of header GPIO `pin` (BCM numbering).""" + name = f"GPIO{pin}" + chips = _chip_paths() + if not chips: + raise GPIOError( + "No GPIO character devices (/dev/gpiochip*) found, cannot control the" + " PiZiGate GPIO pins" + ) + + fallback = None + errors = [] + + for path in chips: + try: + fd = os.open(path, os.O_RDWR | os.O_CLOEXEC) + except OSError as exc: + errors.append(f"{path}: {exc.strerror}") + continue + + try: + buf = bytearray(CHIP_INFO.size) + fcntl.ioctl(fd, GPIO_GET_CHIPINFO_IOCTL, buf, True) + _, label, num_lines = CHIP_INFO.unpack(buf) + + for offset in range(num_lines): + buf = bytearray(LINE_INFO.pack(b"", b"", offset, 0, 0)) + fcntl.ioctl(fd, GPIO_V2_GET_LINEINFO_IOCTL, buf, True) + if _cstr(LINE_INFO.unpack(buf)[0]) == name: + return path, offset + + if fallback is None and _cstr(label) in PI_HEADER_CHIP_LABELS: + if pin < num_lines: + fallback = (path, pin) + except OSError as exc: + errors.append(f"{path}: {exc.strerror}") + finally: + os.close(fd) + + if fallback is not None: + LOGGER.debug("No GPIO line named %s, using %s line %d", name, *fallback) + return fallback + + msg = f"No GPIO line named {name} found on {', '.join(chips)}" + if errors: + msg += f" (could not read {'; '.join(errors)})" + raise GPIOError(msg) + + +def _request_output(path: str, offset: int, value: bool) -> int: + """Request a line as an output, driving `value` right away.""" + request = bytearray( + LINE_REQUEST.pack( + offset, + CONSUMER, + GPIO_V2_LINE_FLAG_OUTPUT, # config.flags + 1, # config.num_attrs + GPIO_V2_LINE_ATTR_ID_OUTPUT_VALUES, # config.attrs[0].attr.id + int(value), # config.attrs[0].attr.values + 1, # config.attrs[0].mask + 1, # num_lines + 0, # event_buffer_size + 0, # fd + ) + ) + + fd = os.open(path, os.O_RDWR | os.O_CLOEXEC) + try: + fcntl.ioctl(fd, GPIO_V2_GET_LINE_IOCTL, request, True) + finally: + # the line request has its own file descriptor and outlives the chip's + os.close(fd) + + return LINE_REQUEST.unpack(request)[-1] + + +def set_value(pin: int, value: bool) -> None: + """Drive header GPIO `pin` (BCM numbering) to `value` and keep driving it. + + The line is requested on first use, with `value` as its initial output value, so + no other level is ever driven onto the pin. + """ + with _lock: + fd = _requests.get(pin) + + if fd is None: + path, offset = _find_line(pin) + try: + _requests[pin] = _request_output(path, offset, value) + except OSError as exc: + raise GPIOError( + f"Could not request GPIO{pin} ({path} line {offset}) as an" + f" output: {exc.strerror}" + ) from exc + + LOGGER.debug( + "Requested GPIO%d (%s line %d) as an output", pin, path, offset + ) + return + + try: + fcntl.ioctl( + fd, GPIO_V2_LINE_SET_VALUES_IOCTL, LINE_VALUES.pack(int(value), 1) + ) + except OSError as exc: + raise GPIOError(f"Could not set GPIO{pin}: {exc.strerror}") from exc From ba1e181ac4f15ebc13980b488e1413fbfeb3142d Mon Sep 17 00:00:00 2001 From: zigpy-review-bot <286747149+zigpy-review-bot@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:18:58 +0200 Subject: [PATCH 2/3] Import the GPIO helper lazily, cover the Pi 5 6.1 kernel layout - gpio.py needs fcntl (Linux only); import it only when switching PiZiGate modes - point at the kernel version when the GPIO v2 uAPI is missing - the Pi 5 launch kernel names the RP1 header lines PINnn, test the label fallback for it - the fake kernel checks the padding/limits the real one validates --- tests/test_gpio.py | 62 +++++++++++++++++++++++++++++++++++++++--- tests/test_uart.py | 4 +-- zigpy_zigate/common.py | 6 ++-- zigpy_zigate/gpio.py | 6 +++- 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/tests/test_gpio.py b/tests/test_gpio.py index 341ad0b..410592c 100644 --- a/tests/test_gpio.py +++ b/tests/test_gpio.py @@ -55,11 +55,18 @@ def ioctl(self, fd, request, arg, mutate_flag=True): len(chip.line_names), ) elif request == gpio.GPIO_V2_GET_LINEINFO_IOCTL: + # the kernel rejects non-zero padding and invalid offsets with EINVAL + assert not any(arg[240:256]) offset = gpio.LINE_INFO.unpack(arg)[2] name = self.chips[target].line_names[offset] arg[:] = gpio.LINE_INFO.pack(name.encode(), b"", offset, 0, 0) elif request == gpio.GPIO_V2_GET_LINE_IOCTL: fields = gpio.LINE_REQUEST.unpack(arg) + # config.padding and the request's padding must be zero + assert not any(arg[300:320]) and not any(arg[568:588]) + assert 1 <= fields[7] <= 64 # num_lines + assert fields[3] <= 10 # config.num_attrs + assert fields[0] < len(self.chips[target].line_names) self.requests.append((target, fields)) line_fd = self._new_fd(("line", len(self.requests) - 1)) arg[:] = gpio.LINE_REQUEST.pack(*fields[:-1], line_fd) @@ -151,18 +158,49 @@ def test_set_value_requests_line_once(kernel): def test_find_line_by_name_on_any_chip(kernel): - # Pi 5 on older kernels: the header is on gpiochip4, after the SoC's own chips + # Pi 5 on Raspberry Pi kernels 6.6+: the RP1 header chip is gpiochip0, the SoC's + # own chips follow and carry no GPIO names k = kernel( { - "/dev/gpiochip0": FakeChip("gpio-brcmstb@107d508500", ["-"] * 32), + "/dev/gpiochip10": FakeChip("gpio-brcmstb@107d508500", ["-"] * 32), + "/dev/gpiochip0": FakeChip("pinctrl-rp1", pi_line_names(54)), + "/dev/gpiochip2": FakeChip("other", ["GPIO17"]), + } + ) + + assert gpio._find_line(17) == ("/dev/gpiochip0", 17) + assert gpio._find_line(27) == ("/dev/gpiochip0", 27) + assert not k.fds + + +def test_find_line_sorts_chips_numerically(kernel): + kernel( + { "/dev/gpiochip10": FakeChip("other", ["GPIO17"]), - "/dev/gpiochip4": FakeChip("pinctrl-rp1", pi_line_names(54)), + "/dev/gpiochip4": FakeChip("other", ["-", "GPIO17"]), + } + ) + + assert gpio._find_line(17) == ("/dev/gpiochip4", 1) + + +def test_find_line_pi5_kernel_6_1(kernel): + # Pi 5 on its launch kernel (6.1): the RP1 header chip is gpiochip4, after the + # SoC's own chips, and its lines are named after the header pins ("PIN11" is + # GPIO17), so only the chip label finds it + rp1_names = [f"PIN{n}" for n in range(54)] + kernel( + { + **{ + f"/dev/gpiochip{n}": FakeChip("gpio-brcmstb@107d508500", ["-"] * 32) + for n in range(4) + }, + "/dev/gpiochip4": FakeChip("pinctrl-rp1", rp1_names), } ) assert gpio._find_line(17) == ("/dev/gpiochip4", 17) assert gpio._find_line(27) == ("/dev/gpiochip4", 27) - assert not k.fds def test_find_line_by_chip_label(kernel): @@ -246,3 +284,19 @@ def failing_ioctl(fd, request, arg, mutate_flag=True): with pytest.raises(gpio.GPIOError, match="Could not set GPIO17: Input/output"): gpio.set_value(17, False) + + +def test_find_line_old_kernel(kernel, monkeypatch): + k = kernel({"/dev/gpiochip0": FakeChip("pinctrl-bcm2711", pi_line_names(58))}) + ioctl = k.ioctl + + def v1_only_ioctl(fd, request, arg, mutate_flag=True): + if request == gpio.GPIO_V2_GET_LINEINFO_IOCTL: + raise OSError(errno.ENOTTY, os.strerror(errno.ENOTTY)) + return ioctl(fd, request, arg, mutate_flag) + + monkeypatch.setattr(gpio.fcntl, "ioctl", v1_only_ioctl) + + with pytest.raises(gpio.GPIOError, match=r"needs Linux 5\.10\+"): + gpio.set_value(17, True) + assert not k.fds diff --git a/tests/test_uart.py b/tests/test_uart.py index b4be594..f269a67 100644 --- a/tests/test_uart.py +++ b/tests/test_uart.py @@ -4,7 +4,7 @@ from serialx import SerialPortInfo import zigpy.config -from zigpy_zigate import common, uart +from zigpy_zigate import common, gpio, uart @pytest.fixture @@ -185,7 +185,7 @@ def test_is_not_zigate_wifi(): def test_startup_gpio_toggling(monkeypatch): manager = MagicMock() - monkeypatch.setattr(common.gpio, "set_value", manager.set_value) + monkeypatch.setattr(gpio, "set_value", manager.set_value) monkeypatch.setattr(common.time, "sleep", manager.sleep) common.set_pizigate_running_mode() diff --git a/zigpy_zigate/common.py b/zigpy_zigate/common.py index 95a4a25..3b171f0 100644 --- a/zigpy_zigate/common.py +++ b/zigpy_zigate/common.py @@ -6,8 +6,6 @@ from serialx import SerialException, list_serial_ports -from . import gpio - LOGGER = logging.getLogger(__name__) GPIO_PIN0 = 17 @@ -58,6 +56,8 @@ def is_zigate_wifi(port): def set_pizigate_running_mode(): + from . import gpio # Linux only, so imported only when it is needed + LOGGER.info("Put PiZiGate in running mode") gpio.set_value(GPIO_PIN2, True) @@ -71,6 +71,8 @@ def set_pizigate_running_mode(): def set_pizigate_flashing_mode(): + from . import gpio # Linux only, so imported only when it is needed + LOGGER.info("Put PiZiGate in flashing mode") gpio.set_value(GPIO_PIN2, False) diff --git a/zigpy_zigate/gpio.py b/zigpy_zigate/gpio.py index bb8e110..a23fb02 100644 --- a/zigpy_zigate/gpio.py +++ b/zigpy_zigate/gpio.py @@ -10,6 +10,7 @@ from __future__ import annotations +import errno import fcntl import glob import logging @@ -118,7 +119,10 @@ def _find_line(pin: int) -> tuple[str, int]: if pin < num_lines: fallback = (path, pin) except OSError as exc: - errors.append(f"{path}: {exc.strerror}") + if exc.errno == errno.ENOTTY: + errors.append(f"{path}: GPIO v2 uAPI not supported (needs Linux 5.10+)") + else: + errors.append(f"{path}: {exc.strerror}") finally: os.close(fd) From b90a3698e06ab14d11f6e3bbb2134a50e4ff319a Mon Sep 17 00:00:00 2001 From: zigpy-review-bot <286747149+zigpy-review-bot@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:23:35 +0200 Subject: [PATCH 3/3] Recognise the EINVAL a pre-5.10 kernel returns for the v2 ioctls --- tests/test_gpio.py | 2 +- zigpy_zigate/gpio.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_gpio.py b/tests/test_gpio.py index 410592c..ee79f8b 100644 --- a/tests/test_gpio.py +++ b/tests/test_gpio.py @@ -292,7 +292,7 @@ def test_find_line_old_kernel(kernel, monkeypatch): def v1_only_ioctl(fd, request, arg, mutate_flag=True): if request == gpio.GPIO_V2_GET_LINEINFO_IOCTL: - raise OSError(errno.ENOTTY, os.strerror(errno.ENOTTY)) + raise OSError(errno.EINVAL, os.strerror(errno.EINVAL)) return ioctl(fd, request, arg, mutate_flag) monkeypatch.setattr(gpio.fcntl, "ioctl", v1_only_ioctl) diff --git a/zigpy_zigate/gpio.py b/zigpy_zigate/gpio.py index a23fb02..ef59693 100644 --- a/zigpy_zigate/gpio.py +++ b/zigpy_zigate/gpio.py @@ -119,7 +119,8 @@ def _find_line(pin: int) -> tuple[str, int]: if pin < num_lines: fallback = (path, pin) except OSError as exc: - if exc.errno == errno.ENOTTY: + # kernels without the v2 uAPI reject its ioctls with EINVAL + if exc.errno in (errno.EINVAL, errno.ENOTTY): errors.append(f"{path}: GPIO v2 uAPI not supported (needs Linux 5.10+)") else: errors.append(f"{path}: {exc.strerror}")