From 10ce9f99ecadbd9da34d5bcd2f25fe706b7e88bc Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 26 Feb 2026 14:45:18 -0500 Subject: [PATCH 1/4] Add Noyito Serial/HID Relay Power Control --- .../package-apis/drivers/noyito-relay.md | 1 + .../.gitignore | 3 + .../jumpstarter-driver-noyito-relay/README.md | 178 ++++++++++ .../examples/exporter.yaml | 48 +++ .../__init__.py | 0 .../jumpstarter_driver_noyito_relay/client.py | 18 + .../conftest.py | 20 ++ .../jumpstarter_driver_noyito_relay/driver.py | 163 +++++++++ .../driver_test.py | 312 ++++++++++++++++++ .../pyproject.toml | 49 +++ python/uv.lock | 54 +++ 11 files changed, 846 insertions(+) create mode 120000 python/docs/source/reference/package-apis/drivers/noyito-relay.md create mode 100644 python/packages/jumpstarter-driver-noyito-relay/.gitignore create mode 100644 python/packages/jumpstarter-driver-noyito-relay/README.md create mode 100644 python/packages/jumpstarter-driver-noyito-relay/examples/exporter.yaml create mode 100644 python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/__init__.py create mode 100644 python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/client.py create mode 100644 python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/conftest.py create mode 100644 python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py create mode 100644 python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py create mode 100644 python/packages/jumpstarter-driver-noyito-relay/pyproject.toml diff --git a/python/docs/source/reference/package-apis/drivers/noyito-relay.md b/python/docs/source/reference/package-apis/drivers/noyito-relay.md new file mode 120000 index 000000000..498f3d268 --- /dev/null +++ b/python/docs/source/reference/package-apis/drivers/noyito-relay.md @@ -0,0 +1 @@ +../../../../../packages/jumpstarter-driver-noyito-relay/README.md \ No newline at end of file diff --git a/python/packages/jumpstarter-driver-noyito-relay/.gitignore b/python/packages/jumpstarter-driver-noyito-relay/.gitignore new file mode 100644 index 000000000..cbc5d672b --- /dev/null +++ b/python/packages/jumpstarter-driver-noyito-relay/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +.coverage +coverage.xml diff --git a/python/packages/jumpstarter-driver-noyito-relay/README.md b/python/packages/jumpstarter-driver-noyito-relay/README.md new file mode 100644 index 000000000..0cdd395c4 --- /dev/null +++ b/python/packages/jumpstarter-driver-noyito-relay/README.md @@ -0,0 +1,178 @@ +# NoyitoPowerSerial / NoyitoPowerHID Driver + +`jumpstarter-driver-noyito-relay` provides Jumpstarter power drivers for NOYITO +USB relay boards in 1, 2, 4, and 8-channel variants. + +Two hardware series are supported: + +- **`NoyitoPowerSerial`** — 1/2-channel boards using a CH340 USB-to-serial chip + (serial port, supports status query) +- **`NoyitoPowerHID`** — 4/8-channel "HID Drive-free" boards presenting as a + USB HID device (no serial port, status query not available) + +Both use the same 4-byte binary command protocol (`A0` + channel + state + +checksum). + +## Installation + +```shell +pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-driver-noyito-relay +``` + +If you are using `NoyitoPowerHID`, the `hid` Python package requires the native +`hidapi` shared library. Install it for your OS before use: + +| OS | Command | +|----|---------| +| macOS | `brew install hidapi` | +| Debian/Ubuntu | `sudo apt-get install libhidapi-hidraw0` | +| Fedora/RHEL | `sudo dnf install hidapi` | + +## Board Detection + +To determine which driver to use, check whether the board appears as a serial +port or a HID device: + +- **Serial port** (`/dev/ttyUSB*`, `/dev/tty.usbserial-*`): Use `NoyitoPowerSerial` + (1/2-channel CH340 board) +- **No serial port / HID only**: Use `NoyitoPowerHID` (4/8-channel HID + Drive-free board). Confirm with `lsusb` — the NOYITO HID module appears with + VID `0x1409` / PID `0x07D7` (decimal: 5131 / 2007). + +## `NoyitoPowerSerial` (1/2-Channel Serial) + +### Hardware Notes + +- **Purchase**: [NOYITO 2-Channel USB Relay Module (Amazon)](https://www.amazon.com/NOYITO-2-Channel-Module-Control-Intelligent/dp/B081RM7PMY/) +- **Chip**: CH340 USB-to-serial +- **Baud rate**: 9600 +- **Default port**: `/dev/ttyUSB0` (Linux) — may appear as `/dev/tty.usbserial-*` on macOS +- **Channels**: 1 or 2 independent relay channels on one USB port +- **Supply voltage**: 5 V via USB + +### Configuration + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `port` | `str` | *(required)* | Serial port path, e.g. `/dev/ttyUSB0` | +| `channel` | `int` | `1` | Relay channel to control (`1` or `2`) | +| `dual` | `bool` | `false` | Switch both channels simultaneously | + +Example configuration controlling both channels independently: + +```yaml +export: + relay1: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerSerial + config: + port: "/dev/ttyUSB0" + channel: 1 + relay2: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerSerial + config: + port: "/dev/ttyUSB0" + channel: 2 +``` + +### API Reference + +Implements `PowerInterface` (provides `on`, `off`, `read`, and `cycle` via +`PowerClient`). + +| Method | Description | +|--------|-------------| +| `on()` | Energise the configured relay channel | +| `off()` | De-energise the configured relay channel | +| `read()` | Yields a single `PowerReading(voltage=0.0, current=0.0)` | +| `status()` | Returns the channel state string, e.g. `"on"`, `"off"`, or `"partial"` | + +### CLI Usage + +Inside a `jmp exporter shell`: + +```shell +# Power on relay 1 +j relay1 on + +# Query state of relay 1 +j relay1 status +# on + +# Power cycle relay 2 with a 3-second wait +j relay2 cycle --wait 3 + +# Power off relay 1 +j relay1 off +``` + +## `NoyitoPowerHID` (4/8-Channel HID Drive-free) + +### Hardware Notes + +- **Purchase (4-channel)**: [NOYITO 4-Channel HID Drive-free USB Relay (Amazon)](https://www.amazon.com/NOYITO-Drive-Free-Computer-2-Channel-Micro-USB/dp/B0B538N95Q) +- **Purchase (8-channel)**: [NOYITO 8-Channel HID Drive-free USB Relay (Amazon)](https://www.amazon.com/NOYITO-Drive-Free-Computer-2-Channel-Micro-USB/dp/B0B536M5MH) +- **Interface**: USB HID (no serial port) +- **Default VID/PID**: `5131` / `2007` (0x1409 / 0x07D7) +- **Channels**: 4 or 8 independent relay channels +- **Supply voltage**: 5 V via USB +- **Status query**: Not supported by this hardware series + +### Configuration + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `num_channels` | `int` | `4` | Number of relay channels on the board (`4` or `8`) | +| `channel` | `int` | `1` | Relay channel to control (`1`..`num_channels`) | +| `all_channels` | `bool` | `false` | Fire every channel simultaneously | +| `vendor_id` | `int` | `5131` | USB vendor ID (override if needed) | +| `product_id` | `int` | `2007` | USB product ID (override if needed) | + +Example configuration for a 4-channel board (channel 1) and an 8-channel board +(all channels simultaneously): + +```yaml +export: + relay_4ch_ch1: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerHID + config: + num_channels: 4 + channel: 1 + relay_8ch_all: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerHID + config: + num_channels: 8 + channel: 1 + all_channels: true +``` + +### API Reference + +Implements `PowerInterface` (provides `on`, `off`, `read`, and `cycle` via +`PowerClient`). + +| Method | Description | +|--------|-------------| +| `on()` | Energise the configured relay channel(s) | +| `off()` | De-energise the configured relay channel(s) | +| `read()` | Yields a single `PowerReading(voltage=0.0, current=0.0)` | + +> **Note**: `status()` is not available for HID boards. The hardware does not +> support a status query command. + +### CLI Usage + +Inside a `jmp exporter shell`: + +```shell +# Power on relay channel 1 of the 4-ch board +j relay_4ch_ch1 on + +# Power cycle with a 1-second wait +j relay_4ch_ch1 cycle --wait 1 + +# Power off +j relay_4ch_ch1 off + +# Power on all 8 channels simultaneously +j relay_8ch_all on +``` diff --git a/python/packages/jumpstarter-driver-noyito-relay/examples/exporter.yaml b/python/packages/jumpstarter-driver-noyito-relay/examples/exporter.yaml new file mode 100644 index 000000000..a4864ecbb --- /dev/null +++ b/python/packages/jumpstarter-driver-noyito-relay/examples/exporter.yaml @@ -0,0 +1,48 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + namespace: default + name: noyito-relay-demo +endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082 +token: "" +export: + relay1: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerSerial + config: + port: "/dev/cu.usbserial-9120" + channel: 1 + relay2: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerSerial + config: + port: "/dev/cu.usbserial-9120" + channel: 2 + # dual=true fires both serial channels simultaneously (high-current wiring) + relay_serial_dual: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerSerial + config: + port: "/dev/cu.usbserial-9120" + dual: true + # 4-channel HID board — individual channel + relay_4ch_ch1: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerHID + config: + num_channels: 4 + channel: 1 + # 4-channel HID board — all_channels=true fires all 4 channels simultaneously + relay_4ch_all: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerHID + config: + num_channels: 4 + all_channels: true + # 8-channel HID board — individual channel + relay_8ch_ch1: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerHID + config: + num_channels: 8 + channel: 1 + # 8-channel HID board — all_channels=true fires all 8 channels simultaneously + relay_8ch_all: + type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerHID + config: + num_channels: 8 + all_channels: true diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/__init__.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/client.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/client.py new file mode 100644 index 000000000..0843c4b79 --- /dev/null +++ b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/client.py @@ -0,0 +1,18 @@ +import click +from jumpstarter_driver_power.client import PowerClient + + +class NoyitoPowerClient(PowerClient): + def status(self) -> str: + """Query the configured relay channel state.""" + return self.call("status") + + def cli(self): + base = super().cli() + + @base.command() + def status(): + """Query relay channel state""" + click.echo(self.status()) + + return base diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/conftest.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/conftest.py new file mode 100644 index 000000000..7c95d4f48 --- /dev/null +++ b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/conftest.py @@ -0,0 +1,20 @@ +import sys +from unittest.mock import MagicMock + +# Stub the hid module so tests run without the native hidapi shared library. +# NoyitoPowerHID._send_command defers `import hid` to call time; this stub +# ensures that deferred import returns a mock rather than attempting to load +# the native library. Tests that verify HID commands patch hid.Device +# explicitly on top of this stub. +if "hid" not in sys.modules: + sys.modules["hid"] = MagicMock() + +import pytest +import serial + + +def pytest_runtest_call(item): + try: + item.runtest() + except serial.SerialException: + pytest.skip("Serial device not available") # ty: ignore[call-non-callable] diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py new file mode 100644 index 000000000..c52374080 --- /dev/null +++ b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py @@ -0,0 +1,163 @@ +import os +import sys +from collections.abc import Generator +from dataclasses import dataclass + +import serial +from jumpstarter_driver_power.driver import PowerInterface, PowerReading + +from jumpstarter.driver import Driver, export + +# Protocol constants +_CMD_STATUS = bytes([0xFF]) +_BAUD_RATE = 9600 +_SERIAL_TIMEOUT = 2 + + +def _build_command(channel: int, state: int) -> bytes: + """Build 4-byte relay command. Checksum = (0xA0 + channel + state) & 0xFF.""" + checksum = (0xA0 + channel + state) & 0xFF + return bytes([0xA0, channel, state, checksum]) + + +@dataclass(kw_only=True) +class NoyitoPowerSerial(PowerInterface, Driver): + """Driver for the NOYITO 5V 2-Channel USB Relay Module. + + Controls one relay channel on the NOYITO USB relay board via the CH340 + USB-to-serial chip at 9600 baud with a 4-byte binary protocol. + + Set ``dual=True`` to switch both channels simultaneously for high-current + applications where the two relay contacts are wired in parallel. + """ + + port: str + channel: int = 1 + dual: bool = False + + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_noyito_relay.client.NoyitoPowerClient" + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + if not self.dual and self.channel not in (1, 2): + raise ValueError(f"channel must be 1 or 2, got {self.channel!r}") + + def _send_command(self, cmd: bytes) -> None: + with serial.Serial(self.port, baudrate=_BAUD_RATE, timeout=_SERIAL_TIMEOUT) as ser: + ser.write(cmd) + + def _query_status(self) -> dict[str, str]: + with serial.Serial(self.port, baudrate=_BAUD_RATE, timeout=_SERIAL_TIMEOUT) as ser: + ser.write(_CMD_STATUS) + raw = ser.read(32) + text = raw.decode("ascii", errors="replace").strip() + result: dict[str, str] = {} + for part in text.replace("\r", "").split("\n"): + part = part.strip() + if ":" in part: + key, _, val = part.partition(":") + result[key.strip()] = val.strip() + if not result: + raise ValueError(f"Unexpected status response: {raw!r}") + return result + + def _channels(self) -> list[int]: + return [1, 2] if self.dual else [self.channel] + + @export + def on(self) -> None: + for ch in self._channels(): + self.logger.info("Relay channel %d ON", ch) + self._send_command(_build_command(ch, 1)) + + @export + def off(self) -> None: + for ch in self._channels(): + self.logger.info("Relay channel %d OFF", ch) + self._send_command(_build_command(ch, 0)) + + @export + def read(self) -> Generator[PowerReading, None, None]: + yield PowerReading(voltage=0.0, current=0.0) + + @export + def status(self) -> str: + all_channels = self._query_status() + states = set() + for ch in self._channels(): + key = f"CH{ch}" + if key not in all_channels: + raise ValueError(f"Channel {key} not found in status response: {all_channels!r}") + states.add(all_channels[key].lower()) + if len(states) == 1: + return states.pop() + return "partial" + + +@dataclass(kw_only=True) +class NoyitoPowerHID(PowerInterface, Driver): + """Driver for the NOYITO 4/8-Channel HID Drive-free USB Relay Module. + + Uses USB HID (hid library) instead of serial. Status query is not + supported by this hardware series. + + vendor_id / product_id default to the NOYITO HID module values (5131 / 2007). + Set num_channels to 4 or 8 to match the physical board. + Set all_channels=True to fire every channel simultaneously for high-current use. + """ + + vendor_id: int = 5131 + product_id: int = 2007 + num_channels: int = 4 + channel: int = 1 + all_channels: bool = False + + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_noyito_relay.client.NoyitoPowerClient" + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + if self.num_channels not in (4, 8): + raise ValueError(f"num_channels must be 4 or 8, got {self.num_channels!r}") + if not self.all_channels and self.channel not in range(1, self.num_channels + 1): + raise ValueError( + f"channel must be 1..{self.num_channels}, got {self.channel!r}" + ) + + def _channels(self) -> list[int]: + return list(range(1, self.num_channels + 1)) if self.all_channels else [self.channel] + + def _send_command(self, cmd: bytes) -> None: + # On Apple Silicon Macs, Homebrew installs hidapi to /opt/homebrew/lib + # which is not in ctypes's default search path. Extend + # DYLD_FALLBACK_LIBRARY_PATH before the first import so dlopen finds it. + if sys.platform == "darwin": + _brew_lib = os.path.join(os.environ.get("HOMEBREW_PREFIX", "/opt/homebrew"), "lib") + if os.path.isdir(_brew_lib): + _fallback = os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "") + if _brew_lib not in _fallback.split(":"): + os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = _brew_lib + (":" + _fallback if _fallback else "") + import hid # noqa: PLC0415 + with hid.Device(self.vendor_id, self.product_id) as device: + device.write(b"\x00" + cmd) # 0x00 = HID report ID + + @export + def on(self) -> None: + for ch in self._channels(): + self.logger.info("HID Relay channel %d ON", ch) + self._send_command(_build_command(ch, 1)) + + @export + def off(self) -> None: + for ch in self._channels(): + self.logger.info("HID Relay channel %d OFF", ch) + self._send_command(_build_command(ch, 0)) + + @export + def read(self) -> Generator[PowerReading, None, None]: + yield PowerReading(voltage=0.0, current=0.0) diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py new file mode 100644 index 000000000..0f4d0d5bf --- /dev/null +++ b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py @@ -0,0 +1,312 @@ +from unittest.mock import MagicMock, call, patch + +import pytest + +from .driver import NoyitoPowerHID, NoyitoPowerSerial, _build_command +from jumpstarter.common.utils import serve + +# --------------------------------------------------------------------------- +# Protocol unit tests (no mocking needed) +# --------------------------------------------------------------------------- + + +def test_build_command_ch1_on(): + assert _build_command(1, 1) == bytes([0xA0, 0x01, 0x01, 0xA2]) + + +def test_build_command_ch1_off(): + assert _build_command(1, 0) == bytes([0xA0, 0x01, 0x00, 0xA1]) + + +def test_build_command_ch2_on(): + assert _build_command(2, 1) == bytes([0xA0, 0x02, 0x01, 0xA3]) + + +def test_build_command_ch2_off(): + assert _build_command(2, 0) == bytes([0xA0, 0x02, 0x00, 0xA2]) + + +# --------------------------------------------------------------------------- +# Validation tests +# --------------------------------------------------------------------------- + + +def test_channel_too_high(): + with pytest.raises(ValueError): + NoyitoPowerSerial(port="/dev/ttyUSB0", channel=3) + + +def test_channel_too_low(): + with pytest.raises(ValueError): + NoyitoPowerSerial(port="/dev/ttyUSB0", channel=0) + + +# --------------------------------------------------------------------------- +# Integration tests via serve() with serial.Serial mocked +# --------------------------------------------------------------------------- + + +def _make_serial_mock(): + mock_serial = MagicMock() + mock_serial.__enter__ = MagicMock(return_value=mock_serial) + mock_serial.__exit__ = MagicMock(return_value=False) + return mock_serial + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_on_ch1(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=1)) as client: + client.on() + + mock_ser.write.assert_called_once_with(bytes([0xA0, 0x01, 0x01, 0xA2])) + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_off_ch1(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=1)) as client: + client.off() + + mock_ser.write.assert_called_once_with(bytes([0xA0, 0x01, 0x00, 0xA1])) + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_on_ch2(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=2)) as client: + client.on() + + mock_ser.write.assert_called_once_with(bytes([0xA0, 0x02, 0x01, 0xA3])) + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_off_ch2(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=2)) as client: + client.off() + + mock_ser.write.assert_called_once_with(bytes([0xA0, 0x02, 0x00, 0xA2])) + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_read(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=1)) as client: + readings = list(client.read()) + + assert len(readings) == 1 + assert readings[0].voltage == 0.0 + assert readings[0].current == 0.0 + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_status_ch1(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_ser.read.return_value = b"CH1:ON \r\nCH2:OFF \r\n" + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=1)) as client: + assert client.status() == "on" + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_status_ch2(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_ser.read.return_value = b"CH1:ON \r\nCH2:OFF \r\n" + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=2)) as client: + assert client.status() == "off" + + +# --------------------------------------------------------------------------- +# Dual-channel (high-current) mode tests +# --------------------------------------------------------------------------- + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_dual_on(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", dual=True)) as client: + client.on() + + write_calls = mock_ser.write.call_args_list + assert write_calls[0] == call(bytes([0xA0, 0x01, 0x01, 0xA2])) + assert write_calls[1] == call(bytes([0xA0, 0x02, 0x01, 0xA3])) + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_dual_off(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", dual=True)) as client: + client.off() + + write_calls = mock_ser.write.call_args_list + assert write_calls[0] == call(bytes([0xA0, 0x01, 0x00, 0xA1])) + assert write_calls[1] == call(bytes([0xA0, 0x02, 0x00, 0xA2])) + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_dual_status_both_on(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_ser.read.return_value = b"CH1:ON \r\nCH2:ON \r\n" + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", dual=True)) as client: + assert client.status() == "on" + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_dual_status_partial(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_ser.read.return_value = b"CH1:ON \r\nCH2:OFF \r\n" + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", dual=True)) as client: + assert client.status() == "partial" + + +@patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") +def test_cycle(mock_serial_cls): + mock_ser = _make_serial_mock() + mock_serial_cls.return_value = mock_ser + + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=1)) as client: + client.cycle(wait=0) + + write_calls = mock_ser.write.call_args_list + assert write_calls[0] == call(bytes([0xA0, 0x01, 0x00, 0xA1])) + assert write_calls[1] == call(bytes([0xA0, 0x01, 0x01, 0xA2])) + + +# --------------------------------------------------------------------------- +# NoyitoPowerHID validation tests +# --------------------------------------------------------------------------- + + +def test_hid_invalid_num_channels(): + with pytest.raises(ValueError): + NoyitoPowerHID(num_channels=3) + + +def test_hid_channel_too_high_4ch(): + with pytest.raises(ValueError): + NoyitoPowerHID(num_channels=4, channel=5) + + +def test_hid_channel_too_high_8ch(): + with pytest.raises(ValueError): + NoyitoPowerHID(num_channels=8, channel=9) + + +# --------------------------------------------------------------------------- +# NoyitoPowerHID integration tests via serve() with hid.Device mocked +# --------------------------------------------------------------------------- + + +def _make_hid_mock(): + m = MagicMock() + m.__enter__ = MagicMock(return_value=m) + m.__exit__ = MagicMock(return_value=False) + return m + + +@patch("hid.Device") +def test_hid_on_ch3_4ch(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=4, channel=3)) as client: + client.on() + + mock_dev.write.assert_called_once_with(b"\x00" + bytes([0xA0, 0x03, 0x01, 0xA4])) + + +@patch("hid.Device") +def test_hid_off_ch3_4ch(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=4, channel=3)) as client: + client.off() + + mock_dev.write.assert_called_once_with(b"\x00" + bytes([0xA0, 0x03, 0x00, 0xA3])) + + +@patch("hid.Device") +def test_hid_on_ch8_8ch(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=8, channel=8)) as client: + client.on() + + mock_dev.write.assert_called_once_with(b"\x00" + bytes([0xA0, 0x08, 0x01, 0xA9])) + + +@patch("hid.Device") +def test_hid_off_ch8_8ch(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=8, channel=8)) as client: + client.off() + + mock_dev.write.assert_called_once_with(b"\x00" + bytes([0xA0, 0x08, 0x00, 0xA8])) + + +@patch("hid.Device") +def test_hid_all_channels_on_4ch(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=4, all_channels=True)) as client: + client.on() + + assert mock_dev.write.call_count == 4 + write_calls = mock_dev.write.call_args_list + assert write_calls[0] == call(b"\x00" + bytes([0xA0, 0x01, 0x01, 0xA2])) + assert write_calls[1] == call(b"\x00" + bytes([0xA0, 0x02, 0x01, 0xA3])) + assert write_calls[2] == call(b"\x00" + bytes([0xA0, 0x03, 0x01, 0xA4])) + assert write_calls[3] == call(b"\x00" + bytes([0xA0, 0x04, 0x01, 0xA5])) + + +@patch("hid.Device") +def test_hid_read(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=4, channel=1)) as client: + readings = list(client.read()) + + assert len(readings) == 1 + assert readings[0].voltage == 0.0 + assert readings[0].current == 0.0 + + +@patch("hid.Device") +def test_hid_cycle(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=4, channel=1)) as client: + client.cycle(wait=0) + + write_calls = mock_dev.write.call_args_list + assert write_calls[0] == call(b"\x00" + bytes([0xA0, 0x01, 0x00, 0xA1])) + assert write_calls[1] == call(b"\x00" + bytes([0xA0, 0x01, 0x01, 0xA2])) diff --git a/python/packages/jumpstarter-driver-noyito-relay/pyproject.toml b/python/packages/jumpstarter-driver-noyito-relay/pyproject.toml new file mode 100644 index 000000000..13ceaf5d8 --- /dev/null +++ b/python/packages/jumpstarter-driver-noyito-relay/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "jumpstarter-driver-noyito-relay" +dynamic = ["version", "urls"] +description = "Jumpstarter driver for the NOYITO 5V 2-Channel USB Relay Module" +readme = "README.md" +license = "Apache-2.0" +authors = [ + { name = "Kirk Brauer", email = "kbrauer@hatci.com" } +] +requires-python = ">=3.11" +dependencies = [ + "pyserial>=3.5", + "hid>=1.0.4", + "jumpstarter", + "jumpstarter-driver-power", +] + +[project.entry-points."jumpstarter.drivers"] +NoyitoPowerSerial = "jumpstarter_driver_noyito_relay.driver:NoyitoPowerSerial" +NoyitoPowerHID = "jumpstarter_driver_noyito_relay.driver:NoyitoPowerHID" + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../../'} + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.pytest.ini_options] +addopts = "--cov --cov-report=html --cov-report=xml" +log_cli = true +log_cli_level = "INFO" +testpaths = ["jumpstarter_driver_noyito_relay"] +asyncio_mode = "auto" + +[build-system] +requires = ["hatchling", "hatch-vcs", "hatch-pin-jumpstarter"] +build-backend = "hatchling.build" + +[tool.hatch.build.hooks.pin_jumpstarter] +name = "pin_jumpstarter" + +[dependency-groups] +dev = [ + "pytest-cov>=6.0.0", + "pytest>=8.3.3", + "pytest-mock>=3.14.0", +] diff --git a/python/uv.lock b/python/uv.lock index 0e3b6d16e..11c77ba1e 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -23,6 +23,7 @@ members = [ "jumpstarter-driver-http-power", "jumpstarter-driver-iscsi", "jumpstarter-driver-network", + "jumpstarter-driver-noyito-relay", "jumpstarter-driver-opendal", "jumpstarter-driver-power", "jumpstarter-driver-probe-rs", @@ -1034,6 +1035,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl", hash = "sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b", size = 75794, upload-time = "2024-12-15T17:08:10.364Z" }, ] +[[package]] +name = "hid" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/f8/0357a8aa8874a243e96d08a8568efaf7478293e1a3441ddca18039b690c1/hid-1.0.9.tar.gz", hash = "sha256:f4471f11f0e176d1b0cb1b243e55498cc90347a3aede735655304395694ac182", size = 4973, upload-time = "2026-02-05T15:35:20.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/c7/f0e1ad95179f44a6fc7a9140be025812cc7a62cf7390442b685a57ee1417/hid-1.0.9-py3-none-any.whl", hash = "sha256:6b9289e00bbc1e1589bec0c7f376a63fe03a4a4a1875575d0ad60e3e11a349f4", size = 4959, upload-time = "2026-02-05T15:35:19.269Z" }, +] + [[package]] name = "identify" version = "2.6.12" @@ -1820,6 +1830,38 @@ dev = [ { name = "websocket-client", specifier = ">=1.8.0" }, ] +[[package]] +name = "jumpstarter-driver-noyito-relay" +source = { editable = "packages/jumpstarter-driver-noyito-relay" } +dependencies = [ + { name = "hid" }, + { name = "jumpstarter" }, + { name = "jumpstarter-driver-power" }, + { name = "pyserial" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "hid", specifier = ">=1.0.4" }, + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }, + { name = "pyserial", specifier = ">=3.5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, +] + [[package]] name = "jumpstarter-driver-opendal" source = { editable = "packages/jumpstarter-driver-opendal" } @@ -3541,6 +3583,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/d2/dfc2f25f3905921c2743c300a48d9494d29032f1389fc142e718d6978fb2/pytest_httpserver-1.1.3-py3-none-any.whl", hash = "sha256:5f84757810233e19e2bb5287f3826a71c97a3740abe3a363af9155c0f82fdbb9", size = 21000, upload-time = "2025-04-10T08:17:13.906Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "pytest-mqtt" version = "0.5.0" From 8b3e139e92ff0207726c28110d279a825026df12 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Tue, 3 Mar 2026 10:58:02 -0500 Subject: [PATCH 2/4] Add HID device status reports and all_channels API for setting all channels simultaneously --- .../reference/package-apis/drivers/index.md | 3 + .../jumpstarter-driver-noyito-relay/README.md | 5 +- .../examples/exporter.yaml | 6 +- .../jumpstarter_driver_noyito_relay/driver.py | 54 +++++++++++++--- .../driver_test.py | 62 +++++++++++++++++-- 5 files changed, 112 insertions(+), 18 deletions(-) diff --git a/python/docs/source/reference/package-apis/drivers/index.md b/python/docs/source/reference/package-apis/drivers/index.md index 2a5e173cc..6b42176d5 100644 --- a/python/docs/source/reference/package-apis/drivers/index.md +++ b/python/docs/source/reference/package-apis/drivers/index.md @@ -25,6 +25,8 @@ Drivers that control the power state and basic operation of devices: * **[Tasmota](tasmota.md)** (`jumpstarter-driver-tasmota`) - Tasmota hardware control * **[HTTP Power](http-power.md)** (`jumpstarter-driver-http-power`) - HTTP-based power control, useful for smart sockets, like the Shelly Smart Plug or similar +* **[Noyito Relay](noyito-relay.md)** (`jumpstarter-driver-noyito-relay`) - NOYITO USB relay + board control (1/2-channel serial and 4/8-channel HID variants) ### Communication Drivers @@ -96,6 +98,7 @@ http.md http-power.md iscsi.md network.md +noyito-relay.md opendal.md power.md probe-rs.md diff --git a/python/packages/jumpstarter-driver-noyito-relay/README.md b/python/packages/jumpstarter-driver-noyito-relay/README.md index 0cdd395c4..a91b7ea00 100644 --- a/python/packages/jumpstarter-driver-noyito-relay/README.md +++ b/python/packages/jumpstarter-driver-noyito-relay/README.md @@ -8,7 +8,7 @@ Two hardware series are supported: - **`NoyitoPowerSerial`** — 1/2-channel boards using a CH340 USB-to-serial chip (serial port, supports status query) - **`NoyitoPowerHID`** — 4/8-channel "HID Drive-free" boards presenting as a - USB HID device (no serial port, status query not available) + USB HID device (no serial port, supports all-channels status query) Both use the same 4-byte binary command protocol (`A0` + channel + state + checksum). @@ -56,7 +56,7 @@ port or a HID device: |-----------|------|---------|-------------| | `port` | `str` | *(required)* | Serial port path, e.g. `/dev/ttyUSB0` | | `channel` | `int` | `1` | Relay channel to control (`1` or `2`) | -| `dual` | `bool` | `false` | Switch both channels simultaneously | +| `all_channels` | `bool` | `false` | Switch both channels simultaneously | Example configuration controlling both channels independently: @@ -115,7 +115,6 @@ j relay1 off - **Default VID/PID**: `5131` / `2007` (0x1409 / 0x07D7) - **Channels**: 4 or 8 independent relay channels - **Supply voltage**: 5 V via USB -- **Status query**: Not supported by this hardware series ### Configuration diff --git a/python/packages/jumpstarter-driver-noyito-relay/examples/exporter.yaml b/python/packages/jumpstarter-driver-noyito-relay/examples/exporter.yaml index a4864ecbb..12bc659f6 100644 --- a/python/packages/jumpstarter-driver-noyito-relay/examples/exporter.yaml +++ b/python/packages/jumpstarter-driver-noyito-relay/examples/exporter.yaml @@ -16,12 +16,12 @@ export: config: port: "/dev/cu.usbserial-9120" channel: 2 - # dual=true fires both serial channels simultaneously (high-current wiring) - relay_serial_dual: + # all_channels=true switches both channels simultaneously + relay_serial_all: type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerSerial config: port: "/dev/cu.usbserial-9120" - dual: true + all_channels: true # 4-channel HID board — individual channel relay_4ch_ch1: type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerHID diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py index c52374080..c1c6b5e4b 100644 --- a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py +++ b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py @@ -27,13 +27,13 @@ class NoyitoPowerSerial(PowerInterface, Driver): Controls one relay channel on the NOYITO USB relay board via the CH340 USB-to-serial chip at 9600 baud with a 4-byte binary protocol. - Set ``dual=True`` to switch both channels simultaneously for high-current - applications where the two relay contacts are wired in parallel. + Set ``all_channels=True`` in the exporter config to switch both channels + simultaneously (e.g. for high-current applications). """ port: str channel: int = 1 - dual: bool = False + all_channels: bool = False @classmethod def client(cls) -> str: @@ -42,7 +42,7 @@ def client(cls) -> str: def __post_init__(self): if hasattr(super(), "__post_init__"): super().__post_init__() - if not self.dual and self.channel not in (1, 2): + if not self.all_channels and self.channel not in (1, 2): raise ValueError(f"channel must be 1 or 2, got {self.channel!r}") def _send_command(self, cmd: bytes) -> None: @@ -65,7 +65,7 @@ def _query_status(self) -> dict[str, str]: return result def _channels(self) -> list[int]: - return [1, 2] if self.dual else [self.channel] + return [1, 2] if self.all_channels else [self.channel] @export def on(self) -> None: @@ -81,7 +81,8 @@ def off(self) -> None: @export def read(self) -> Generator[PowerReading, None, None]: - yield PowerReading(voltage=0.0, current=0.0) + # Power reading not supported + raise NotImplementedError @export def status(self) -> str: @@ -101,8 +102,7 @@ def status(self) -> str: class NoyitoPowerHID(PowerInterface, Driver): """Driver for the NOYITO 4/8-Channel HID Drive-free USB Relay Module. - Uses USB HID (hid library) instead of serial. Status query is not - supported by this hardware series. + Uses USB HID (hid library) instead of serial. vendor_id / product_id default to the NOYITO HID module values (5131 / 2007). Set num_channels to 4 or 8 to match the physical board. @@ -158,6 +158,44 @@ def off(self) -> None: self.logger.info("HID Relay channel %d OFF", ch) self._send_command(_build_command(ch, 0)) + def _query_status(self) -> dict[str, str]: + if sys.platform == "darwin": + _brew_lib = os.path.join(os.environ.get("HOMEBREW_PREFIX", "/opt/homebrew"), "lib") + if os.path.isdir(_brew_lib): + _fallback = os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "") + if _brew_lib not in _fallback.split(":"): + os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = _brew_lib + (":" + _fallback if _fallback else "") + import hid # noqa: PLC0415 + + cmd = _build_command(0x0F, 0x02) # 0x0F = all-channels status query pseudo-channel + with hid.Device(self.vendor_id, self.product_id) as device: + device.write(b"\x00" + cmd) + raw = device.read(32, timeout_ms=2000) + + text = bytes(raw).decode("ascii", errors="replace") + result: dict[str, str] = {} + for line in text.split("\n"): + line = line.strip("\r").strip() + if ":" in line: + key, _, value = line.partition(":") + result[key.strip()] = value.strip() + if not result: + raise ValueError(f"Unexpected status response: {text!r}") + return result + @export def read(self) -> Generator[PowerReading, None, None]: yield PowerReading(voltage=0.0, current=0.0) + + @export + def status(self) -> str: + states = self._query_status() + channel_states = [] + for ch in self._channels(): + key = f"CH{ch}" + if key not in states: + raise ValueError(f"Channel {ch} not found in status response: {states!r}") + channel_states.append(states[key].lower()) + if all(s == channel_states[0] for s in channel_states): + return channel_states[0] + return "partial" diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py index 0f4d0d5bf..0dfa5ffb5 100644 --- a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py +++ b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py @@ -140,7 +140,7 @@ def test_dual_on(mock_serial_cls): mock_ser = _make_serial_mock() mock_serial_cls.return_value = mock_ser - with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", dual=True)) as client: + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", all_channels=True)) as client: client.on() write_calls = mock_ser.write.call_args_list @@ -153,7 +153,7 @@ def test_dual_off(mock_serial_cls): mock_ser = _make_serial_mock() mock_serial_cls.return_value = mock_ser - with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", dual=True)) as client: + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", all_channels=True)) as client: client.off() write_calls = mock_ser.write.call_args_list @@ -167,7 +167,7 @@ def test_dual_status_both_on(mock_serial_cls): mock_ser.read.return_value = b"CH1:ON \r\nCH2:ON \r\n" mock_serial_cls.return_value = mock_ser - with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", dual=True)) as client: + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", all_channels=True)) as client: assert client.status() == "on" @@ -177,7 +177,7 @@ def test_dual_status_partial(mock_serial_cls): mock_ser.read.return_value = b"CH1:ON \r\nCH2:OFF \r\n" mock_serial_cls.return_value = mock_ser - with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", dual=True)) as client: + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", all_channels=True)) as client: assert client.status() == "partial" @@ -310,3 +310,57 @@ def test_hid_cycle(mock_hid_cls): write_calls = mock_dev.write.call_args_list assert write_calls[0] == call(b"\x00" + bytes([0xA0, 0x01, 0x00, 0xA1])) assert write_calls[1] == call(b"\x00" + bytes([0xA0, 0x01, 0x01, 0xA2])) + + +def _encode_status(text: str) -> list[int]: + """Encode an ASCII status string into a 32-byte HID read buffer.""" + raw = list(text.encode("ascii")) + return raw + [0] * (32 - len(raw)) + + +@patch("hid.Device") +def test_hid_status_ch1_on(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_dev.read.return_value = _encode_status("CH1:ON\r\nCH2:OFF\r\n") + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=4, channel=1)) as client: + assert client.status() == "on" + + mock_dev.write.assert_called_once_with(b"\x00" + bytes([0xA0, 0x0F, 0x02, 0xB1])) + + +@patch("hid.Device") +def test_hid_status_ch2_off(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_dev.read.return_value = _encode_status("CH1:ON\r\nCH2:OFF\r\n") + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=4, channel=2)) as client: + assert client.status() == "off" + + mock_dev.write.assert_called_once_with(b"\x00" + bytes([0xA0, 0x0F, 0x02, 0xB1])) + + +@patch("hid.Device") +def test_hid_status_all_on_4ch(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_dev.read.return_value = _encode_status("CH1:ON\r\nCH2:ON\r\nCH3:ON\r\nCH4:ON\r\n") + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=4, all_channels=True)) as client: + assert client.status() == "on" + + mock_dev.write.assert_called_once_with(b"\x00" + bytes([0xA0, 0x0F, 0x02, 0xB1])) + + +@patch("hid.Device") +def test_hid_status_partial_4ch(mock_hid_cls): + mock_dev = _make_hid_mock() + mock_dev.read.return_value = _encode_status("CH1:ON\r\nCH2:OFF\r\nCH3:ON\r\nCH4:OFF\r\n") + mock_hid_cls.return_value = mock_dev + + with serve(NoyitoPowerHID(num_channels=4, all_channels=True)) as client: + assert client.status() == "partial" + + mock_dev.write.assert_called_once_with(b"\x00" + bytes([0xA0, 0x0F, 0x02, 0xB1])) From 0fb97ffc444399e13f027ae6ce7206ae1374e1ce Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Tue, 3 Mar 2026 11:10:14 -0500 Subject: [PATCH 3/4] Update README --- python/packages/jumpstarter-driver-noyito-relay/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/packages/jumpstarter-driver-noyito-relay/README.md b/python/packages/jumpstarter-driver-noyito-relay/README.md index a91b7ea00..045717da8 100644 --- a/python/packages/jumpstarter-driver-noyito-relay/README.md +++ b/python/packages/jumpstarter-driver-noyito-relay/README.md @@ -154,9 +154,7 @@ Implements `PowerInterface` (provides `on`, `off`, `read`, and `cycle` via | `on()` | Energise the configured relay channel(s) | | `off()` | De-energise the configured relay channel(s) | | `read()` | Yields a single `PowerReading(voltage=0.0, current=0.0)` | - -> **Note**: `status()` is not available for HID boards. The hardware does not -> support a status query command. +| `status()` | Returns the channel state string, e.g. `"on"`, `"off"`, or `"partial"` | ### CLI Usage From 6d2993a9a183f425048492a99b17026970c7dbb1 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Tue, 3 Mar 2026 16:26:30 -0500 Subject: [PATCH 4/4] Fix broken tests due to NotImplementedError --- .../jumpstarter_driver_noyito_relay/driver.py | 2 +- .../jumpstarter_driver_noyito_relay/driver_test.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py index c1c6b5e4b..1dd769ccb 100644 --- a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py +++ b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py @@ -81,8 +81,8 @@ def off(self) -> None: @export def read(self) -> Generator[PowerReading, None, None]: - # Power reading not supported raise NotImplementedError + yield # makes this a generator function @export def status(self) -> str: diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py index 0dfa5ffb5..08e51fd9a 100644 --- a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py +++ b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py @@ -103,11 +103,8 @@ def test_read(mock_serial_cls): mock_serial_cls.return_value = mock_ser with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=1)) as client: - readings = list(client.read()) - - assert len(readings) == 1 - assert readings[0].voltage == 0.0 - assert readings[0].current == 0.0 + with pytest.raises(Exception): + list(client.read()) @patch("jumpstarter_driver_noyito_relay.driver.serial.Serial")