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"