Skip to content

Commit 2f7449d

Browse files
FomysEmantor
authored andcommitted
drivers: Add eth008 digital output
Eth008 is technically not a power supply controler, it is a simple relay board. To allow using it as a digital output, implement the proper digital output driver and resource. In addition, the HTTP implementation of eth008 power driver seems broken with older revision of board. The driver expect a reply like `<text> 0000000`, but with my board (older revision) the reply is only `<text> 0`. the io.cgi?relay is also not documented in both older and newer revision. I switched to the TCP API, tested with my own board. According to documentation the protocol is the same on the two eth008 revision. Signed-off-by: Louis Chauvet <louis.chauvet@bootlin.com> [rouven.czerwinski@linaro.org: fix ruff format error] Signed-off-by: Rouven Czerwinski <rouven.czerwinski@linaro.org>
1 parent df51556 commit 2f7449d

7 files changed

Lines changed: 342 additions & 1 deletion

File tree

doc/configuration.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,28 @@ Arguments:
606606
Used by:
607607
- `HttpDigitalOutputDriver`_
608608

609+
Eth008DigitalOutput
610+
++++++++++++++++++++
611+
An :any:`Eth008DigitalOutput` resource describes a digital output on a
612+
*Robot-Electronics eth008* relay board.
613+
614+
.. code-block:: yaml
615+
616+
Eth008DigitalOutput:
617+
host: '192.168.1.100'
618+
index: 1
619+
invert: false
620+
621+
The example describes relay ``1`` on the ETH008 device at ``192.168.1.100``.
622+
623+
Arguments:
624+
- host (str): hostname or IP address of the ETH008 device
625+
- index (int): number of the relay to use (1-8)
626+
- invert (bool, default=False): whether to invert the output (active-low)
627+
628+
Used by:
629+
- `Eth008DigitalOutputDriver`_
630+
609631
NetworkHIDRelay
610632
+++++++++++++++
611633
A :any:`NetworkHIDRelay` describes an `HIDRelay`_ resource available on a
@@ -3380,6 +3402,25 @@ Implements:
33803402
Arguments:
33813403
- None
33823404

3405+
Eth008DigitalOutputDriver
3406+
~~~~~~~~~~~~~~~~~~~~~~~~~
3407+
A :any:`Eth008DigitalOutputDriver` binds to an `Eth008DigitalOutput`_ to set
3408+
and get a digital output state on a Robot-Electronics eth008 relay board.
3409+
3410+
Binds to:
3411+
output:
3412+
- `Eth008DigitalOutput`_
3413+
3414+
.. code-block:: yaml
3415+
3416+
Eth008DigitalOutputDriver: {}
3417+
3418+
Implements:
3419+
- :any:`DigitalOutputProtocol`
3420+
3421+
Arguments:
3422+
- None
3423+
33833424
PyVISADriver
33843425
~~~~~~~~~~~~
33853426
The :any:`PyVISADriver` uses a `PyVISADevice`_ resource to control test

labgrid/driver/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@
4949
from .deditecrelaisdriver import DeditecRelaisDriver
5050
from .dediprogflashdriver import DediprogFlashDriver
5151
from .httpdigitaloutput import HttpDigitalOutputDriver
52+
from .eth008digitaloutput import Eth008DigitalOutputDriver
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""
2+
This driver implements a digital output driver for the robot electronics 8 relay
3+
outputs board (ETH008).
4+
5+
Driver has been tested with:
6+
* ETH008 - 8 relay outputs
7+
"""
8+
9+
import socket
10+
import attr
11+
12+
from ..factory import target_factory
13+
from ..protocol import DigitalOutputProtocol
14+
from ..step import step
15+
from ..util.proxy import proxymanager
16+
from .common import Driver
17+
from .exception import ExecutionError
18+
19+
PORT = 17494 # TCP port for ETH008 (0x4456)
20+
21+
22+
@target_factory.reg_driver
23+
@attr.s(eq=False)
24+
class Eth008DigitalOutputDriver(Driver, DigitalOutputProtocol):
25+
"""Eth008DigitalOutputDriver - Driver to control individual relays on ETH008 as digital outputs"""
26+
bindings = {"output": {"Eth008DigitalOutput"}, }
27+
28+
def __attrs_post_init__(self):
29+
super().__attrs_post_init__()
30+
self._host = None
31+
self._port = None
32+
33+
def on_activate(self):
34+
self._host, self._port = proxymanager.get_host_and_port(
35+
self.output, force_port=PORT
36+
)
37+
38+
def _send_tcp_command(self, command_bytes):
39+
"""Send a command over TCP and return the response."""
40+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
41+
s.settimeout(5.0)
42+
s.connect((self._host, self._port))
43+
s.sendall(command_bytes)
44+
response = s.recv(1)
45+
return response
46+
47+
@Driver.check_active
48+
@step(args=["status"])
49+
def set(self, status):
50+
index = int(self.output.index)
51+
assert 1 <= index <= 8
52+
53+
if self.output.invert:
54+
status = not status
55+
56+
# Use TCP command: 0x20 for active (on), 0x21 for inactive (off)
57+
command = 0x20 if status else 0x21
58+
# Permanent mode (time = 0)
59+
command_bytes = bytes([command, index, 0])
60+
61+
response = self._send_tcp_command(command_bytes)
62+
63+
# Check response: 0 for success, 1 for failure
64+
if response == b'\x01':
65+
raise ExecutionError(f"failed to set port {index} to status {status}")
66+
elif response != b'\x00':
67+
raise ExecutionError(f"unexpected response from device: {response}")
68+
69+
@Driver.check_active
70+
@step(result=["True"])
71+
def get(self):
72+
index = int(self.output.index)
73+
assert 1 <= index <= 8
74+
75+
# Use TCP command: 0x24 to get all relay states
76+
# The response is 1 byte where each bit represents a relay state
77+
command_bytes = bytes([0x24])
78+
79+
response = self._send_tcp_command(command_bytes)
80+
81+
# Parse the response byte
82+
# Each bit represents a relay: bit 0 = relay 1, bit 1 = relay 2, etc.
83+
state_byte = response[0]
84+
# Get the bit corresponding to the relay index (1-8)
85+
relay_bit = (state_byte >> (index - 1)) & 0x01
86+
state = bool(relay_bit)
87+
88+
if self.output.invert:
89+
state = not state
90+
91+
return state

labgrid/remote/client.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -979,7 +979,13 @@ def digital_io(self):
979979
action = self.args.action
980980
name = self.args.name
981981
target = self._get_target(place)
982-
from ..resource import ModbusTCPCoil, OneWirePIO, HttpDigitalOutput, WaveshareModbusTCPCoil
982+
from ..resource import (
983+
ModbusTCPCoil,
984+
OneWirePIO,
985+
HttpDigitalOutput,
986+
WaveshareModbusTCPCoil,
987+
Eth008DigitalOutput,
988+
)
983989
from ..resource.remote import NetworkDeditecRelais8, NetworkSysfsGPIO, NetworkLXAIOBusPIO, NetworkHIDRelay
984990

985991
drv = None
@@ -993,6 +999,8 @@ def digital_io(self):
993999
drv = self._get_driver_or_new(target, "WaveShareModbusCoilDriver", name=name)
9941000
elif isinstance(resource, ModbusTCPCoil):
9951001
drv = self._get_driver_or_new(target, "ModbusCoilDriver", name=name)
1002+
elif isinstance(resource, Eth008DigitalOutput):
1003+
drv = self._get_driver_or_new(target, "Eth008DigitalOutputDriver", name=name)
9961004
elif isinstance(resource, OneWirePIO):
9971005
drv = self._get_driver_or_new(target, "OneWirePIODriver", name=name)
9981006
elif isinstance(resource, HttpDigitalOutput):

labgrid/resource/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@
4848
from .httpdigitalout import HttpDigitalOutput
4949
from .sigrok import SigrokDevice
5050
from .fastboot import AndroidNetFastboot
51+
from .eth008 import Eth008DigitalOutput

labgrid/resource/eth008.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import attr
2+
3+
from ..factory import target_factory
4+
from .common import Resource
5+
6+
7+
@target_factory.reg_resource
8+
@attr.s(eq=False)
9+
class Eth008DigitalOutput(Resource):
10+
"""This resource describes a digital output on an ETH008 relay board.
11+
12+
Args:
13+
host (str): host to connect to
14+
index (str): index of the relay on the ETH008 board (1-8)
15+
invert (bool): whether to invert the output (default: False)
16+
"""
17+
host = attr.ib(validator=attr.validators.instance_of(str))
18+
index = attr.ib(validator=attr.validators.instance_of(str),
19+
converter=lambda x: str(int(x)))
20+
invert = attr.ib(default=False, validator=attr.validators.instance_of(bool))

tests/test_eth008digitaloutput.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import pytest
2+
import socket
3+
4+
from labgrid.driver import Eth008DigitalOutputDriver
5+
from labgrid.resource import Eth008DigitalOutput
6+
7+
8+
@pytest.fixture(scope="function")
9+
def mock_tcp_server(mocker):
10+
"""Mock the ETH008 TCP server responses."""
11+
state = 0x00 # Initial state: all relays OFF
12+
13+
# Track sent commands for verification
14+
sent_commands = []
15+
16+
def mock_sendall(data):
17+
"""Mock socket.sendall."""
18+
nonlocal state, sent_commands
19+
20+
sent_commands.append(data)
21+
22+
if len(data) == 1 and data[0] == 0x24:
23+
return
24+
elif len(data) == 3:
25+
command = data[0]
26+
relay_index = data[1]
27+
28+
if command == 0x20: # Digital Active (ON)
29+
state |= (1 << (relay_index - 1))
30+
elif command == 0x21: # Digital Inactive (OFF)
31+
state &= ~(1 << (relay_index - 1))
32+
33+
def mock_recv(bufsize):
34+
"""Mock socket.recv to return appropriate responses."""
35+
nonlocal state, sent_commands
36+
37+
last_sent = sent_commands[-1] if sent_commands else None
38+
39+
if last_sent and len(last_sent) == 1 and last_sent[0] == 0x24:
40+
return bytes([state])
41+
else:
42+
return b'\x00'
43+
44+
mock_socket = mocker.MagicMock()
45+
mock_socket.sendall.side_effect = mock_sendall
46+
mock_socket.recv.side_effect = mock_recv
47+
mock_socket.__enter__ = mocker.MagicMock(return_value=mock_socket)
48+
mock_socket.__exit__ = mocker.MagicMock(return_value=None)
49+
50+
def mock_socket_factory(*args, **kwargs):
51+
return mock_socket
52+
53+
mock_socket_class = mocker.patch("socket.socket")
54+
mock_socket_class.side_effect = mock_socket_factory
55+
mock_socket_class.return_value = mock_socket
56+
57+
return mock_socket
58+
59+
60+
def _make_eth008_driver(target, index, invert):
61+
"""Helper function to create an ETH008 digital output driver with resource."""
62+
eth008_res = Eth008DigitalOutput(
63+
target,
64+
name=None,
65+
host='192.168.1.100',
66+
index=str(index),
67+
invert=invert
68+
)
69+
70+
driver = Eth008DigitalOutputDriver(target, name=None)
71+
target.activate(driver)
72+
73+
return driver
74+
75+
76+
def test_eth008_instance(target, mocker):
77+
"""Test that Eth008DigitalOutputDriver can be instantiated and activated."""
78+
mocker.patch("socket.socket")
79+
driver = _make_eth008_driver(target, 1, False)
80+
assert isinstance(driver, Eth008DigitalOutputDriver)
81+
82+
83+
def test_eth008_set_true(target, mock_tcp_server):
84+
"""Test setting ETH008 relay to True (asserted)."""
85+
driver = _make_eth008_driver(target, 1, False)
86+
87+
driver.set(True)
88+
89+
# Verify the correct command was sent (0x20 = Digital Active, 0x01 = relay 1, 0x00 = permanent)
90+
mock_tcp_server.sendall.assert_any_call(bytes([0x20, 0x01, 0x00]))
91+
92+
93+
def test_eth008_set_false(target, mock_tcp_server):
94+
"""Test setting ETH008 relay to False (de-asserted)."""
95+
driver = _make_eth008_driver(target, 2, False)
96+
97+
driver.set(False)
98+
99+
# Verify the correct command was sent (0x21 = Digital Inactive, 0x02 = relay 2, 0x00 = permanent)
100+
mock_tcp_server.sendall.assert_any_call(bytes([0x21, 0x02, 0x00]))
101+
102+
103+
def test_eth008_get_true(target, mock_tcp_server):
104+
"""Test getting ETH008 relay state when True."""
105+
driver = _make_eth008_driver(target, 1, False)
106+
107+
# Set the relay to True first
108+
driver.set(True)
109+
110+
# Get the state
111+
result = driver.get()
112+
113+
assert result is True
114+
115+
116+
def test_eth008_get_false(target, mock_tcp_server):
117+
"""Test getting ETH008 relay state when False."""
118+
driver = _make_eth008_driver(target, 2, False)
119+
120+
# Set the relay to False first
121+
driver.set(False)
122+
123+
# Get the state
124+
result = driver.get()
125+
126+
assert result is False
127+
128+
129+
def test_eth008_invert_true(target, mock_tcp_server):
130+
"""Test ETH008 with inversion enabled."""
131+
driver = _make_eth008_driver(target, 1, True) # Invert the logic
132+
133+
# When invert=True, setting True should actually set the relay to False
134+
driver.set(True)
135+
136+
# Verify that 0x21 (Digital Inactive) was sent instead of 0x20 (Digital Active)
137+
mock_tcp_server.sendall.assert_any_call(bytes([0x21, 0x01, 0x00]))
138+
139+
140+
def test_eth008_invert_get(target, mock_tcp_server):
141+
"""Test getting ETH008 relay state with inversion enabled."""
142+
driver = _make_eth008_driver(target, 1, True) # Invert the logic
143+
144+
# Set the relay to True (which will actually set it to False due to inversion)
145+
driver.set(True)
146+
147+
# When invert=True, get() should return the opposite of the actual state
148+
result = driver.get()
149+
150+
# The actual relay state is False, but with inversion it should return True
151+
assert result is True
152+
153+
154+
def test_eth008_invalid_index(target, mocker):
155+
"""Test that invalid relay indices are handled correctly."""
156+
mocker.patch("socket.socket")
157+
158+
driver = _make_eth008_driver(target, 9, False) # Invalid index (must be 1-8)
159+
160+
# This should raise an AssertionError due to invalid index
161+
with pytest.raises(AssertionError):
162+
driver.set(True)
163+
164+
165+
def test_eth008_different_indices(target, mock_tcp_server):
166+
"""Test that different relay indices work correctly."""
167+
# Test first, middle, and last relays
168+
# Create separate targets for each test to avoid resource conflicts
169+
from labgrid.target import Target
170+
for index in [1, 4, 8]:
171+
test_target = Target(name=f'Test-{index}', env=None)
172+
173+
driver = _make_eth008_driver(test_target, index, False)
174+
175+
# Set and get should work for all indices
176+
driver.set(True)
177+
result = driver.get()
178+
179+
assert result is True

0 commit comments

Comments
 (0)