Skip to content
Open
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
3 changes: 3 additions & 0 deletions python/docs/source/reference/package-apis/drivers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -96,6 +98,7 @@ http.md
http-power.md
iscsi.md
network.md
noyito-relay.md
opendal.md
power.md
probe-rs.md
Expand Down
3 changes: 3 additions & 0 deletions python/packages/jumpstarter-driver-noyito-relay/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
.coverage
coverage.xml
175 changes: 175 additions & 0 deletions python/packages/jumpstarter-driver-noyito-relay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# 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, supports all-channels status query)

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`) |
| `all_channels` | `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

### 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)` |
| `status()` | Returns the channel state string, e.g. `"on"`, `"off"`, or `"partial"` |

### 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
```
Original file line number Diff line number Diff line change
@@ -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: "<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
# all_channels=true switches both channels simultaneously
relay_serial_all:
type: jumpstarter_driver_noyito_relay.driver.NoyitoPowerSerial
config:
port: "/dev/cu.usbserial-9120"
all_channels: 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import click
from jumpstarter_driver_power.client import PowerClient


class NoyitoPowerClient(PowerClient):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would go ahead and add status and cli to all PowerClients, defaulting to "not implemented", then we can add it to others as well. WDYT?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Saying because we have been talking about that sometimes. ... anyway, we can do that later TBH, not really this PR, please ignore :D

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mangelajo yeah, I think this makes sense, let's just throw NotImplemented for this driver for now.

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
Original file line number Diff line number Diff line change
@@ -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]
Comment on lines +16 to +20
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the actual conftest.py file
cat -n python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/conftest.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 871


🏁 Script executed:

# Run the verification script from the review comment
#!/bin/bash
set -euo pipefail

echo "Custom pytest_runtest_call hooks:"
rg -n --type=py 'def pytest_runtest_call\('

echo
echo "Direct calls to item.runtest() (should be absent in custom hooks):"
rg -n --type=py 'item\.runtest\s*\('

echo
echo "All pytest hook declarations:"
rg -n --type=py 'def pytest_' python/packages/jumpstarter-driver-noyito-relay/

Repository: jumpstarter-dev/jumpstarter

Length of output: 1013


🌐 Web query:

pytest_runtest_call hook semantics documentation

💡 Result:

pytest_runtest_call(item) is a runtest-phase hook that pytest calls to execute the “call” phase of a single test item (i.e., the actual test body, after setup and before teardown). Its default implementation is essentially to run the test via item.runtest(). [1]

Where it fits in the per-item runtest protocol:

  • pytest_runtest_logstart(...) happens before setup/call/teardown. [2]
  • pytest_runtest_setup(item) runs before pytest_runtest_call(item). [2]
  • pytest_runtest_call(item) runs the test body (call phase). [1]
  • pytest_runtest_teardown(item, nextitem) runs teardown after the call phase. [1]
  • pytest_runtest_logfinish(...) happens after setup/call/teardown. [2]

Implementation/usage notes:

  • You can implement this hook in conftest.py (subject to the usual conftest visibility rules). [1]
  • Unlike most non-pytest_runtest_* hooks, runtest hooks are allowed to raise exceptions as part of reporting test failures/errors. [3]

Docs: see the pytest “API Reference” entry for pytest_runtest_call. [1]

Sources:
[1] pytest docs – API Reference (pytest_runtest_call) (docs.pytest.org)
[2] pytest docs – runtest hook order (logstart, setup, call, teardown, logfinish) (docs.pytest.org)
[3] pytest docs – hook-writing notes (exception rules; runtest hooks special) (docs.pytest.org)

Citations:


Implement pytest_runtest_call using the hookwrapper pattern to properly integrate with pytest's test execution protocol.

The current implementation directly calls item.runtest() without using @pytest.hookimpl(hookwrapper=True) and yield. This violates pytest's runtest hook semantics, which expects hooks to yield control back to pytest's runner rather than manually executing the test. Improper exception handling also prevents integration with pytest's error reporting and other plugins.

💡 Proposed fix
+@pytest.hookimpl(hookwrapper=True)
 def pytest_runtest_call(item):
-    try:
-        item.runtest()
-    except serial.SerialException:
-        pytest.skip("Serial device not available")  # ty: ignore[call-non-callable]
+    outcome = yield
+    if outcome.excinfo and issubclass(outcome.excinfo[0], serial.SerialException):
+        pytest.skip("Serial device not available")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/conftest.py`
around lines 16 - 20, Replace the direct call to item.runtest() with a pytest
hookwrapper: add `@pytest.hookimpl`(hookwrapper=True) to pytest_runtest_call,
yield to let pytest run the test, capture the outcome via outcome = yield, then
call outcome.get_result() inside a try/except that catches
serial.SerialException and calls pytest.skip("Serial device not available");
ensure serial is imported and remove the manual item.runtest() invocation so
pytest's runner and plugins handle execution and reporting correctly.

Loading
Loading