Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 44 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
184 changes: 184 additions & 0 deletions src/denkovi_cli/bitbang.py
Original file line number Diff line number Diff line change
@@ -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()}"
Loading
Loading