From c12d903f80b0ab17cb5d2412cf3fd447629a3f5c Mon Sep 17 00:00:00 2001 From: Bernhard Trinnes Date: Wed, 9 Sep 2026 16:41:53 +0200 Subject: [PATCH] Drive the bit-banged boards on macOS The 4 and 8 relay boards hang their relays off the data lines of the board's FTDI chip, and the relay library reaches those lines through a backend it picks by platform. It has one for Windows and one for Linux, so on macOS it ends up with no backend at all and those boards could only be refused. Add a backend of the same shape written against pylibftdi, the library the Linux one uses, which works just as well on macOS. It is put in place of the library's own on everything but Windows, and differs from it in two ways: it finds libftdi where Homebrew and MacPorts put it, which is outside the paths ctypes searches, and it opens a board by its whole serial number rather than by a prefix, so the board that --serial picked is the board that gets driven. Pass that serial number to the library instead of the port name. A bit-banged board is not reached through its serial port at all, so the port it was opened with was never going to find it. Stop probing from writing to a board that must not be written to. A bit-banged board is a FIFO wearing a serial port's clothes: 'ask//' left the relays holding a '/', and the stream of zeroes that came back read as a valid two byte reply, so the board was reported as a type16 with sixteen relays that were not there. It gives itself away by handing over bytes with nothing asked of it, which a board that answers a protocol never does, so the port is now listened to first and only a port that stays quiet is spoken to. Tested against an 8 relay board (FT245) on macOS 26. --- README.md | 56 +++++-- pyproject.toml | 3 + src/denkovi_cli/bitbang.py | 184 ++++++++++++++++++++++ src/denkovi_cli/board.py | 67 ++++++-- tests/test_bitbang.py | 307 +++++++++++++++++++++++++++++++++++++ uv.lock | 11 ++ 6 files changed, 603 insertions(+), 25 deletions(-) create mode 100644 src/denkovi_cli/bitbang.py create mode 100644 tests/test_bitbang.py diff --git a/README.md b/README.md index 21caec4..aa90bda 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,11 @@ uv sync # or: pip install . ### Without installing anything -To run from a source checkout with only the two runtime dependencies present: +To run from a source checkout with only the runtime dependencies present: ```sh pip install pyserial dae-RelayBoard +pip install pylibftdi # macOS and Linux, for the 4 and 8 relay boards PYTHONPATH=src python -m denkovi_cli.cli status ``` @@ -140,26 +141,56 @@ with `DAE`. If a board's chip was reflashed with a serial number that does not, find it with `denkovi list --all` and address it by `--port`. Board type is probed by asking the board for its state: only the 16 relay board -answers. The 4 and 8 relay boards are silent and indistinguishable from each other, -so they have to be named with `--board`. +answers. The 4 and 8 relay boards cannot be told apart from each other, so they have +to be named with `--board`: + +```console +$ denkovi --board type8 on 1,3 +type8 DAE00745 on /dev/cu.usbserial-DAE00745, 8 relays +1 ● ON 2 ○ off 3 ● ON 4 ○ off +5 ○ off 6 ○ off 7 ○ off 8 ○ off +on: 1, 3 [0x05] +``` + +Probing never writes to a bit-banged board. Its FTDI chip is a FIFO rather than a +UART, so every byte written to it lands straight on the relays — asking it for its +state would leave them holding a `/`. It gives itself away by handing over bytes with +nothing asked of it, which a board that answers a protocol never does, so the port is +listened to before it is spoken to, and relays are left where they were. ## Board support | Board | Driver | Works on | | --- | --- | --- | | `type16` | virtual COM port, ASCII protocol | macOS, Linux, Windows | -| `type8`, `type4` | FTDI D2XX bit-banging | Linux, Windows | +| `type8`, `type4` | FTDI chip bit-banged | macOS, Linux, Windows | + +The 4 and 8 relay boards speak no protocol at all: their relays hang off the data +lines of the board's FTDI chip — an FT245 on the 8 relay board, an FT232 on the 4 — +which is driven in bit-bang mode. Because nothing answers back, these boards cannot +be probed and have to be named with `--board type8` or `--board type4`. + +Windows reaches the chip through FTDI's D2XX driver and needs nothing extra: +`FTD2XX.dll` arrives with the board's own driver. macOS and Linux go through +`libftdi`, which is a C library and so does not come from pip: + +```sh +brew install libftdi # macOS +sudo apt install libftdi1-2 # Debian, Ubuntu +``` -The 4 and 8 relay boards are driven by bit-banging the FT232R through the D2XX -driver, which the underlying library only implements for Windows and Linux; on -Linux they additionally need `pylibftdi`. Asking for one on macOS fails with an -explanation rather than a traceback. +Its Python binding, `pylibftdi`, is installed with denkovi-cli. Nothing has to be +unloaded or disabled on macOS: the board can be bit-banged while the system's FTDI +serial driver still offers it as `/dev/cu.usbserial-*`. ## Notes - Only one program can drive a board at a time. Two processes on the same serial port interleave their commands and corrupt each other's replies, which shows up - as a communication error. + as a communication error. A bit-banged board is claimed outright, and the second + command reports that it could not open the board. +- Bit-banged boards keep their relays where they were left: the state lives in the + FTDI chip's output latch, and closing the board does not disturb it. - The type16 protocol needs a delay between commands. The library's default of 50ms is used; the documented 5ms was found to corrupt replies. `--delay` can raise it if a board proves flaky. Commands that drive the whole board the same @@ -191,9 +222,10 @@ against Python 3.12 to 3.14. The board communication is done by **[dae-py-relay-controller][lib]** by [Peter Bingham][author], taken from PyPI as [`dae_RelayBoard`][pypi]. It implements -both the ASCII serial protocol of the 16 relay boards and the D2XX bit-banging of the -4 and 8 relay boards; this project only adds discovery, argument parsing and output -on top. The library is distributed under the MIT licence. +both the ASCII serial protocol of the 16 relay boards and the bit-banging of the 4 +and 8 relay boards; this project adds discovery, argument parsing and output on top, +plus the `pylibftdi` backend that carries the bit-banged boards on macOS. The library +is distributed under the MIT licence. Relay boards and their documentation are made by [Denkovi Assembly Electronics][denkovi], who are not affiliated with this project. diff --git a/pyproject.toml b/pyproject.toml index d449d91..5cd88cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ classifiers = [ dependencies = [ "pyserial>=3.5", "dae-RelayBoard>=1.5.2", + # Bit-bangs the 4 and 8 relay boards. Windows drives them through the D2XX + # DLL instead, so it needs no Python package for them. + "pylibftdi>=0.24; sys_platform == 'darwin' or sys_platform == 'linux'", ] [project.urls] diff --git a/src/denkovi_cli/bitbang.py b/src/denkovi_cli/bitbang.py new file mode 100644 index 0000000..876ff10 --- /dev/null +++ b/src/denkovi_cli/bitbang.py @@ -0,0 +1,184 @@ +# denkovi-cli - command line control of Denkovi USB relay boards. +# Copyright (C) 2026 Bernhard Trinnes +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License version 2, as published by the +# Free Software Foundation. This program is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY. See the LICENSE file for the full text. + +"""Bit-banged access to the 4 and 8 relay boards, on macOS as well as Linux. + +Those boards carry no serial protocol: their relays hang off the eight data +lines of the FTDI chip, which is driven in asynchronous bit-bang mode. The +relay library reaches those lines through a backend it picks by platform, and +it has one for Windows and one for Linux only, so on macOS it ends up with no +backend at all. + +This module is a backend of the same shape, written against ``pylibftdi`` -- +the library the Linux one uses, which works just as well on macOS. It is put +in place of the library's own; see `board.open_board`. Two things it does +differently: + +* it finds ``libftdi`` where Homebrew and MacPorts put it, which is outside + the paths ctypes searches; +* it opens a board by its whole serial number rather than by a prefix, so the + board that ``--serial`` picked is the board that gets driven. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any + +from .board import DenkoviError + +#: Where Homebrew and MacPorts keep their libraries. ``HOMEBREW_PREFIX`` comes +#: first so a non-standard Homebrew is honoured. +MACOS_LIBRARY_DIRS = ( + os.environ.get("HOMEBREW_PREFIX", "") + "/lib", + "/opt/homebrew/lib", # Homebrew on Apple silicon + "/usr/local/lib", # Homebrew on Intel + "/opt/local/lib", # MacPorts +) + +#: File name of each library pylibftdi loads, as installed on macOS. +MACOS_LIBRARY_NAMES = {"libftdi": "libftdi1.dylib", "libusb": "libusb-1.0.dylib"} + + +def is_supported() -> bool: + """Whether this platform can bit-bang a board through pylibftdi.""" + return sys.platform == "darwin" or "linux" in sys.platform + + +def macos_library_paths(name: str) -> list[str]: + """Return the paths to try for ``name`` before ctypes' own library search. + + Only paths that exist are returned: a path that does not resolve sends + pylibftdi on to ``find_library``, which on a Mac that has both Homebrew + prefixes can turn up a library built for the other architecture. + """ + file_name = MACOS_LIBRARY_NAMES[name] + paths = [str(Path(directory) / file_name) for directory in MACOS_LIBRARY_DIRS if directory] + return [path for path in dict.fromkeys(paths) if Path(path).is_file()] + + +class BitBangBackend: + """An FTDI chip driven in bit-bang mode, one byte in and one byte out. + + Implements the four methods the relay library calls on its backend: + ``initialise``, ``close``, ``writeByte`` and ``readByte``. The library + keeps the relay-to-bit mapping and the read-modify-write of the state + byte to itself. + """ + + def __init__(self, serial_number: str | None = None) -> None: + self.serial_number = serial_number + self._device: Any = None + + def initialise(self, device_id: str, baud_rate: int, mask: int, bit_mode: int) -> None: + """Open the board. Called by the library with its own defaults.""" + # `device_id` is the serial number prefix the library searches with, so + # it would open whichever DAE board came first. The serial number of + # the board that was actually resolved is better, and is used when + # there is one; a board addressed by --port alone may not have one, and + # then the first FTDI device on the bus is taken. + wanted = self.serial_number or None + bit_bang_device, driver, ftdi_error = _pylibftdi() + + self.close() + try: + device = bit_bang_device( + wanted, + direction=mask, + bitbang_mode=bit_mode, + driver=_driver(driver), + # On macOS there is no kernel driver to hand back, and asking + # for one pulls in libusb for nothing. + auto_detach=sys.platform != "darwin", + ) + device.baudrate = baud_rate + # pylibftdi raises FtdiError for anything it recognises; a library that + # cannot be loaded at all surfaces as the ctypes error instead. + except (ftdi_error, OSError, AttributeError) as error: + raise DenkoviError(_open_failed(error, wanted)) from error + self._device = device + + def close(self) -> None: + device, self._device = self._device, None + if device is not None: + device.close() + + def writeByte(self, byte: int) -> None: # camelCase: named by the library + self._connected().port = byte + + def readByte(self) -> int: # camelCase: named by the library + return int(self._connected().port) + + def _connected(self) -> Any: + if self._device is None: + raise DenkoviError("the board is not open.") + return self._device + + +def _pylibftdi() -> tuple[Any, Any, type[BaseException]]: + """Return the pylibftdi names used here, or say how to install it.""" + try: + from pylibftdi import BitBangDevice, Driver, FtdiError + except ImportError as error: + raise DenkoviError( + "the 4 and 8 relay boards are driven through pylibftdi, which is not " + "installed. Install it with 'pip install pylibftdi' (it also needs the " + "libftdi C library: 'brew install libftdi' on macOS, or the distribution's " + "libftdi1 package on Linux)." + ) from error + return BitBangDevice, Driver, FtdiError + + +def _driver(driver_class: Any) -> Any: + """Return a pylibftdi driver that can find its libraries on this platform. + + Everywhere but macOS the stock search works. On macOS the Homebrew and + MacPorts paths have to be added: ctypes does not look there, and neither + ``libftdi`` nor ``libusb`` ships with the system. They go in front of the + plain library names rather than after, so the library that was actually + installed wins over anything ``find_library`` digs up. The search list is + set on the instance because the constructor argument covers only + ``libftdi``, and setting it writes through to state shared by every driver. + """ + driver = driver_class() + if sys.platform == "darwin": + driver._lib_search = { + name: [*macos_library_paths(name), *search] + for name, search in driver_class._lib_search.items() + } + return driver + + +def _open_failed(error: BaseException, serial_number: str | None) -> str: + """Turn a pylibftdi failure into something worth reading.""" + text = str(error) + which = f"board {serial_number}" if serial_number else "board" + + if "libftdi library not found" in text or isinstance(error, OSError | AttributeError): + return ( + "could not load the libftdi library, which the 4 and 8 relay boards are " + "driven through. Install it with 'brew install libftdi' on macOS, or the " + "distribution's libftdi1 package on Linux." + ) + if "unable to claim" in text.lower() or "(-5)" in text: + return ( + f"could not claim the {which}: another program is driving it. Close any " + "other denkovi command, and on Linux unload the ftdi_sio driver if it is " + "holding the board." + ) + if "(-3)" in text: + # libftdi walks past a board it cannot open and ends up reporting that it + # found nothing, so a busy board and an absent one look the same here. + return ( + f"could not open the {which} on the USB bus: either it is not plugged in, " + "or another program is already driving it, which only one can at a time " + "('denkovi list' shows the boards that were found)." + ) + return f"could not open the {which}: {text.strip()}" diff --git a/src/denkovi_cli/board.py b/src/denkovi_cli/board.py index 758c50a..f758fb1 100644 --- a/src/denkovi_cli/board.py +++ b/src/denkovi_cli/board.py @@ -25,7 +25,8 @@ from dae_RelayBoard import dae_RelayBoard_Common from serial.tools import list_ports -#: FTDI's USB vendor id. Every Denkovi USB board is built around an FT232R. +#: FTDI's USB vendor id. Every Denkovi USB board is built around an FTDI chip: +#: an FT232R on the 4 and 16 relay boards, an FT245R on the 8 relay board. FTDI_VENDOR_ID = 0x0403 #: Denkovi programs its boards with a serial number starting with this. @@ -41,8 +42,8 @@ #: Boards driven over a virtual COM port with the ASCII "//" protocol. VCP_BOARD_TYPES = (dae_RelayBoard.DAE_RELAYBOARD_TYPE_16,) -#: Boards driven by bit-banging the FT232R through the D2XX driver. -D2XX_BOARD_TYPES = ( +#: Boards driven by bit-banging the data lines of their FTDI chip. +BITBANG_BOARD_TYPES = ( dae_RelayBoard.DAE_RELAYBOARD_TYPE_4, dae_RelayBoard.DAE_RELAYBOARD_TYPE_8, ) @@ -50,6 +51,10 @@ #: Default inter-command delay of the VCP protocol, in seconds. DEFAULT_DELAY = 0.05 +#: How long a board is listened to before it is probed, in seconds. Only long +#: enough to catch a board that is already talking; it is dead time otherwise. +LISTEN_TIMEOUT = 0.1 + class DenkoviError(Exception): """An error worth reporting to the user without a traceback.""" @@ -183,14 +188,24 @@ def probe_board_type(port: str, *, timeout: float = 1.0) -> str | None: Only the VCP boards can be identified over the wire: they answer the ``ask`` command with one status byte per eight relays. The bit-banged 4 and - 8 relay boards are silent and indistinguishable from each other, so they - have to be named explicitly. + 8 relay boards cannot be told apart from each other, so they have to be + named explicitly. + + Nothing is written to a board that must not be written to. A bit-banged + board is a FIFO wearing a serial port's clothes: every byte written to it + lands on the relays, and 'ask//' would leave them holding a '/'. It gives + itself away by handing over bytes with nothing asked of it, which a board + that answers a protocol never does, so the port is listened to first and + only a port that stays quiet is spoken to. """ try: - with serial.Serial(port=port, baudrate=9600, timeout=timeout) as connection: + with serial.Serial(port=port, baudrate=9600, timeout=LISTEN_TIMEOUT) as connection: time.sleep(DEFAULT_DELAY) connection.reset_input_buffer() connection.reset_output_buffer() + if connection.read(1): + return None + connection.timeout = timeout connection.write(b"ask//") time.sleep(DEFAULT_DELAY) reply = connection.read(2) @@ -303,18 +318,27 @@ def open_board( ) -> Iterator[Board]: """Connect to a board, and disconnect again however the block exits.""" port = device.port - if board_type in D2XX_BOARD_TYPES and not _has_d2xx_support(): + bit_banged = board_type in BITBANG_BOARD_TYPES + if bit_banged and not _has_bitbang_support(): raise DenkoviError( - f"the {board_type} board is driven through the FTDI D2XX driver, which the " - f"relay library only supports on Windows and Linux (this is {sys.platform}). " + f"the {board_type} board is driven by bit-banging its FTDI chip, which is " + f"implemented for Windows, macOS and Linux only (this is {sys.platform}). " "Only type16 boards can be used here." ) try: - # Only the VCP boards take a command delay; the D2XX ones take no args. + # Only the VCP boards take a command delay; the bit-banged ones take no args. args = (delay,) if board_type in VCP_BOARD_TYPES else () handle = dae_RelayBoard.DAE_RelayBoard(board_type, *args) - handle.initialise(port) + if bit_banged and sys.platform != "win32": + # The library has no backend for macOS, and the one it has for Linux + # can only find a board by a prefix of its serial number, so both are + # served by ours instead. Imported here to keep pylibftdi off the + # path of anyone who only ever touches a type16 board. + from .bitbang import BitBangBackend + + handle.relayHandler.FTD2XX = BitBangBackend(device.serial_number) + handle.initialise(_initialise_argument(device, board_type)) except dae_RelayBoard_Common.Denkovi_Exception as error: raise DenkoviError(f"could not connect to the board on {port}: {error}") from error @@ -331,5 +355,22 @@ def open_board( handle.disconnect() -def _has_d2xx_support() -> bool: - return sys.platform == "win32" or "linux" in sys.platform +def _initialise_argument(device: Device, board_type: str) -> str: + """Return what the library's ``initialise`` wants for this board type. + + The VCP boards are opened on their serial port. The bit-banged ones are not + reached through a serial port at all: they are looked up on the USB bus by + their FTDI serial number, and a board that only ``--port`` named may not + have one to give, in which case the Denkovi prefix picks the first board. + """ + if board_type in VCP_BOARD_TYPES: + return device.port + return device.serial_number or DENKOVI_SERIAL_PREFIX + + +def _has_bitbang_support() -> bool: + if sys.platform == "win32": + return True + from .bitbang import is_supported + + return is_supported() diff --git a/tests/test_bitbang.py b/tests/test_bitbang.py new file mode 100644 index 0000000..d68c0ec --- /dev/null +++ b/tests/test_bitbang.py @@ -0,0 +1,307 @@ +"""Tests for driving the bit-banged boards, with the FTDI chip stubbed out.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Self + +import pytest + +from denkovi_cli import bitbang +from denkovi_cli import board as board_module +from denkovi_cli.board import DenkoviError, Device, open_board + +TYPE8 = "type8" +TYPE16 = "type16" + + +def _device(serial_number: str | None = "DAE00745") -> Device: + return Device( + port="/dev/cu.usbserial-DAE00745", + serial_number=serial_number, + description="relay board", + manufacturer="Denkovi", + vendor_id=0x0403, + product_id=0x6001, + ) + + +class FakeBitBangDevice: + """Stands in for pylibftdi's BitBangDevice: one byte of latched state.""" + + def __init__(self, device_id: str | None, **kwargs: object) -> None: + self.device_id = device_id + self.kwargs = kwargs + self.port = 0 + self.baudrate = 0 + self.closed = False + + def close(self) -> None: + self.closed = True + + +@pytest.fixture +def fake_ftdi(monkeypatch: pytest.MonkeyPatch) -> list[FakeBitBangDevice]: + """Open every board onto a fake chip, and record the ones that were opened.""" + opened: list[FakeBitBangDevice] = [] + + def bit_bang_device(device_id: str | None, **kwargs: object) -> FakeBitBangDevice: + device = FakeBitBangDevice(device_id, **kwargs) + opened.append(device) + return device + + class FtdiError(Exception): + pass + + monkeypatch.setattr(bitbang, "_pylibftdi", lambda: (bit_bang_device, object, FtdiError)) + monkeypatch.setattr(bitbang, "_driver", lambda driver_class: None) + return opened + + +class TestBackend: + def test_opens_the_board_by_its_whole_serial_number( + self, fake_ftdi: list[FakeBitBangDevice] + ) -> None: + backend = bitbang.BitBangBackend("DAE00745") + # The library passes the prefix it would have searched with; the exact + # serial number of the resolved board wins over it. + backend.initialise("DAE", 921600, 0xFF, 1) + + assert fake_ftdi[0].device_id == "DAE00745" + assert fake_ftdi[0].kwargs["direction"] == 0xFF + assert fake_ftdi[0].baudrate == 921600 + + def test_without_a_serial_number_the_first_board_is_taken( + self, fake_ftdi: list[FakeBitBangDevice] + ) -> None: + bitbang.BitBangBackend(None).initialise("DAE", 921600, 0xFF, 1) + assert fake_ftdi[0].device_id is None + + def test_reads_and_writes_the_state_byte(self, fake_ftdi: list[FakeBitBangDevice]) -> None: + backend = bitbang.BitBangBackend("DAE00745") + backend.initialise("DAE", 921600, 0xFF, 1) + + backend.writeByte(0b10100101) + assert fake_ftdi[0].port == 0b10100101 + assert backend.readByte() == 0b10100101 + + def test_reopening_closes_the_previous_board(self, fake_ftdi: list[FakeBitBangDevice]) -> None: + backend = bitbang.BitBangBackend("DAE00745") + backend.initialise("DAE", 921600, 0xFF, 1) + backend.initialise("DAE", 921600, 0xFF, 1) + + assert fake_ftdi[0].closed + assert not fake_ftdi[1].closed + + def test_closing_twice_is_harmless(self, fake_ftdi: list[FakeBitBangDevice]) -> None: + backend = bitbang.BitBangBackend("DAE00745") + backend.initialise("DAE", 921600, 0xFF, 1) + backend.close() + backend.close() + + def test_using_a_closed_board_is_a_clean_error( + self, fake_ftdi: list[FakeBitBangDevice] + ) -> None: + with pytest.raises(DenkoviError, match="not open"): + bitbang.BitBangBackend("DAE00745").readByte() + + +class TestLibrarySearch: + """macOS keeps libftdi where ctypes does not look, so paths are given.""" + + def test_only_libraries_that_exist_are_offered( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + (tmp_path / "libftdi1.dylib").touch() + monkeypatch.setattr( + bitbang, "MACOS_LIBRARY_DIRS", (str(tmp_path), "/nowhere/lib", "", str(tmp_path)) + ) + assert bitbang.macos_library_paths("libftdi") == [str(tmp_path / "libftdi1.dylib")] + assert bitbang.macos_library_paths("libusb") == [] + + +class TestErrorMessages: + """A failure to open should say what to do about it, not print a traceback.""" + + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("libftdi library not found (search: ['ftdi1'])", "brew install libftdi"), + ("b'device not found' (-3)", "already driving it"), + ("b'unable to claim usb device' (-5)", "another program is driving it"), + ], + ) + def test_known_failures_are_explained(self, text: str, expected: str) -> None: + assert expected in bitbang._open_failed(Exception(text), "DAE00745") + + def test_an_unknown_failure_still_names_the_board(self) -> None: + message = bitbang._open_failed(Exception("something else"), "DAE00745") + assert "DAE00745" in message and "something else" in message + + def test_a_library_that_will_not_load_reads_as_a_missing_library(self) -> None: + # ctypes raises rather than pylibftdi when the path resolves to a + # library built for the wrong architecture. + error = OSError("incompatible architecture") + assert "brew install libftdi" in bitbang._open_failed(error, None) + + +class FakeRelayHandler: + NUMRELAYS = 8 + + def __init__(self) -> None: + self.states = dict.fromkeys(range(1, 9), False) + + +class FakeLibraryBoard: + """Stands in for the relay library's board, remembering how it was opened.""" + + last: FakeLibraryBoard + + def __init__(self, board_type: str, *args: object) -> None: + self.board_type = board_type + self.args = args + self.relayHandler = FakeRelayHandler() + self.initialised_with: object = None + FakeLibraryBoard.last = self + + def initialise(self, *args: object) -> None: + self.initialised_with = args[0] if args else None + + def disconnect(self) -> None: + pass + + def getNumRelays(self) -> int: + return self.relayHandler.NUMRELAYS + + +@pytest.fixture +def fake_library(monkeypatch: pytest.MonkeyPatch) -> type[FakeLibraryBoard]: + monkeypatch.setattr( + board_module.dae_RelayBoard, "DAE_RelayBoard", FakeLibraryBoard, raising=True + ) + return FakeLibraryBoard + + +class TestOpenBoard: + def test_a_bit_banged_board_gets_our_backend_and_its_serial_number( + self, fake_library: type[FakeLibraryBoard], monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(board_module.sys, "platform", "darwin") + with open_board(_device(), TYPE8) as board: + assert board.num_relays == 8 + + opened = fake_library.last + # Looked up on the USB bus by serial number, not opened as a serial port. + assert opened.initialised_with == "DAE00745" + assert isinstance(opened.relayHandler.FTD2XX, bitbang.BitBangBackend) + + def test_a_board_without_a_serial_number_falls_back_to_the_denkovi_prefix( + self, fake_library: type[FakeLibraryBoard], monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(board_module.sys, "platform", "darwin") + with open_board(_device(serial_number=None), TYPE8): + pass + assert fake_library.last.initialised_with == board_module.DENKOVI_SERIAL_PREFIX + + def test_windows_keeps_the_librarys_own_backend( + self, fake_library: type[FakeLibraryBoard], monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(board_module.sys, "platform", "win32") + with open_board(_device(), TYPE8): + pass + assert not hasattr(fake_library.last.relayHandler, "FTD2XX") + + def test_a_vcp_board_is_still_opened_on_its_port( + self, fake_library: type[FakeLibraryBoard] + ) -> None: + device = _device() + with open_board(device, TYPE16, delay=0.25): + pass + assert fake_library.last.initialised_with == device.port + assert fake_library.last.args == (0.25,) # the command delay, VCP only + + def test_an_unsupported_platform_says_so_rather_than_failing_obscurely( + self, fake_library: type[FakeLibraryBoard], monkeypatch: pytest.MonkeyPatch + ) -> None: + # `board` and `bitbang` both read the one `sys.platform`. + monkeypatch.setattr(board_module.sys, "platform", "sunos5") + with ( + pytest.raises(DenkoviError, match="Windows, macOS and Linux only"), + open_board(_device(), TYPE8), + ): + pass + + +class FakePort: + """A serial port that streams ``stream``, and answers ``reply`` once written to. + + A bit-banged board is the streaming case: it hands over bytes unprompted. + A type16 board is the answering case: quiet until asked. + """ + + def __init__(self, stream: bytes = b"", reply: bytes = b"") -> None: + self.stream = stream + self.reply = reply + self.written = b"" + self.timeout: float | None = None + + def __call__(self, *, port: str, baudrate: int, timeout: float) -> Self: + self.timeout = timeout + return self + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc_info: object) -> bool: + return False + + def reset_input_buffer(self) -> None: + pass + + def reset_output_buffer(self) -> None: + pass + + def write(self, data: bytes) -> int: + self.written += data + self.stream += self.reply + return len(data) + + def read(self, size: int) -> bytes: + data, self.stream = self.stream[:size], self.stream[size:] + return data + + +class TestProbe: + """Probing must never write to a board whose relays are the data lines.""" + + @pytest.fixture + def port(self, monkeypatch: pytest.MonkeyPatch) -> type[FakePort]: + def install(fake: FakePort) -> FakePort: + monkeypatch.setattr(board_module.serial, "Serial", fake) + return fake + + return install # type: ignore[return-value] + + def test_a_talkative_port_is_left_alone(self, port) -> None: + # An FT245 hands over bytes with nothing asked of it, and anything + # written to it would land on the relays. + fake = port(FakePort(stream=b"\x00" * 8)) + + assert board_module.probe_board_type("/dev/fake") is None + assert fake.written == b"" + + def test_a_board_that_answers_is_a_type16(self, port) -> None: + fake = port(FakePort(reply=b"\x00\x00")) + + assert board_module.probe_board_type("/dev/fake") == TYPE16 + assert fake.written == b"ask//" + + def test_a_board_that_says_nothing_at_all_is_unknown(self, port) -> None: + port(FakePort()) + assert board_module.probe_board_type("/dev/fake") is None + + def test_an_unidentified_board_asks_to_be_named(self, port) -> None: + port(FakePort(stream=b"\x00" * 8)) + + with pytest.raises(DenkoviError, match="--board type8"): + board_module.resolve_board_type("/dev/fake", None) diff --git a/uv.lock b/uv.lock index f4822ee..a817148 100644 --- a/uv.lock +++ b/uv.lock @@ -26,6 +26,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "dae-relayboard" }, + { name = "pylibftdi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "pyserial" }, ] @@ -38,6 +39,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "dae-relayboard", specifier = ">=1.5.2" }, + { name = "pylibftdi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'", specifier = ">=0.24" }, { name = "pyserial", specifier = ">=3.5" }, ] @@ -83,6 +85,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pylibftdi" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/5f/f0e03128161fff27d4f92d57b7fab20fa64e9bcd53627442d2ef43b8882c/pylibftdi-0.24.0.tar.gz", hash = "sha256:e2229bc10d077f018cbe7b2b490227b2b538643fa6f7bff132cde15549ca129b", size = 32255, upload-time = "2026-04-04T10:59:00.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/09/9891a561c1aedf31c546624f715e626cbb16b179be93514f7cb0abf07083/pylibftdi-0.24.0-py3-none-any.whl", hash = "sha256:8b3ba2a2106bc50a8bb551772433ebc7eb52d22a03c75fff6552de5ada6c4127", size = 33176, upload-time = "2026-04-04T10:58:58.997Z" }, +] + [[package]] name = "pyserial" version = "3.5"