Skip to content

Commit 077df23

Browse files
committed
feat(em511): Add the driver with some functions and unit test
Signed-off-by: Bobo Bäck Engström <bobo@id8-engineering.io>
1 parent a46d5c9 commit 077df23

4 files changed

Lines changed: 384 additions & 11 deletions

File tree

src/em511/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
"""Hello package."""
1+
"""Top-level package for the EM511 driver.
22
3+
Provides the `Em511` class for reading and writing Modbus registers
4+
using Carlo Gavazzi EM511 energy meters.
5+
"""
36

4-
def hello() -> str:
5-
"""Hello docstring."""
6-
return "Hello from python-em511!"
7+
from .em511 import Em511
8+
9+
__all__ = ["Em511"]

src/em511/em511.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# ruff: noqa: N802
2+
"""Driver class for EM511."""
3+
4+
import time
5+
from decimal import Decimal, getcontext
6+
7+
from pymodbus.client import ModbusSerialClient
8+
from pymodbus.exceptions import ModbusException
9+
10+
11+
class Em511:
12+
"""Driver for Carlo Gavazzi EM511 series energy meters.
13+
14+
This class provides read and write access to Modbus registers
15+
via a connected `pymodbus.client.ModbusSerialClient` instance.
16+
17+
Attributes:
18+
device_address (int): Modbus address of the target device.
19+
client (ModbusSerialClient): Connected Modbus client.
20+
"""
21+
22+
INT16_REG_COUNT = 1
23+
INT32_REG_COUNT = 2
24+
25+
PASSWORD_MAX_VALUE = 9999
26+
INPUT_MAX_VALUE_32 = 0x7FFFFFFF
27+
INPUT_MAX_VALUE_16 = 0x7FFF
28+
29+
EM511_REGISTER_V = 0x0000
30+
EM511_REGISTER_A = 0x0002
31+
EM511_REGISTER_PASSWORD = 0x1000
32+
33+
SCALE_10 = 10
34+
SCALE_100 = 100
35+
SCALE_1000 = 1000
36+
37+
TIMEOUT = 0.5
38+
39+
def __init__(self, device_address: int, client: ModbusSerialClient) -> None:
40+
"""Initialize an Em511 driver instance with an existing Modbus client.
41+
42+
Args:
43+
device_address: Modbus address for the EM511 meter.
44+
client: An initialized ModbusSerialClient instance to use for communication.
45+
"""
46+
self.device_address = device_address
47+
self.client = client
48+
getcontext().prec = 3
49+
50+
def _read_input_registers(self, address: int, count: int) -> list[int]:
51+
"""Read input registers.
52+
53+
Internal helper to read Modbus registers safely.
54+
55+
Args:
56+
address: Register address to read from.
57+
count: Number of register to read from.
58+
59+
Returns:
60+
list of registers.
61+
62+
Raises:
63+
ModbusException: If read operation fails.
64+
"""
65+
result = self.client.read_input_registers(address=address, count=count, device_id=self.device_address)
66+
if result.isError():
67+
msg = (
68+
"Failed to read input register. "
69+
f"device_address={self.device_address} address={address} count={count} result={result} "
70+
)
71+
raise ModbusException(msg)
72+
return result.registers
73+
74+
def _write_register(self, address: int, value: int) -> None:
75+
"""Write to register.
76+
77+
Internal helper to write to single register.
78+
79+
Args:
80+
address: Register to write to.
81+
value: Value to write the given register with.
82+
83+
Raises:
84+
ModbusException: If write operation fails.
85+
"""
86+
result = self.client.write_register(address=address, value=value, device_id=self.device_address)
87+
88+
if result.isError():
89+
msg = (
90+
"Failed to write to single register."
91+
f"device_address={self.device_address} address={address} count={value}"
92+
)
93+
raise ModbusException(msg)
94+
95+
def _unpack(self, regs: list[int], address: int) -> int:
96+
"""Unpack registers.
97+
98+
Internal helper to unpack register list.
99+
100+
Args:
101+
regs: List of registers to unpack.
102+
address: address to the register.
103+
104+
Returns:
105+
Unpacked integer value.
106+
107+
Raises:
108+
ModbusException: If unexpected number of registers is given.
109+
"""
110+
if len(regs) == self.INT16_REG_COUNT:
111+
value = regs[0]
112+
if value == self.INPUT_MAX_VALUE_16:
113+
msg = f"Input overflow EEE for 16-bit register: device_address={self.device_address} address={address}"
114+
raise ValueError(msg)
115+
return value
116+
117+
if len(regs) == self.INT32_REG_COUNT:
118+
value = (regs[1] << 16) + regs[0]
119+
if value == self.INPUT_MAX_VALUE_32:
120+
msg = f"Input overflow EEE for 32-bit register: device_address={self.device_address} address={address}"
121+
raise ValueError(msg)
122+
return value
123+
124+
msg = f"Unexpected register count: {len(regs)}."
125+
raise ValueError(msg)
126+
127+
def _timeout_check(self, start_timer: float, address: int) -> None:
128+
"""Init timeout counter.
129+
130+
Timeout function to check timeout error.
131+
132+
Args:
133+
start_timer: Counter init.
134+
address: address to the register.
135+
136+
Raises:
137+
TimeoutError: If elapsed time is to long.
138+
"""
139+
elapsed = time.perf_counter() - start_timer
140+
if elapsed >= self.TIMEOUT:
141+
msg = f"Timeout error, response failed, device_address={self.device_address} address={address}"
142+
raise TimeoutError(msg)
143+
144+
@property
145+
def V(self) -> Decimal:
146+
"""Voltage (V).
147+
148+
Returns:
149+
Decimal: Current voltage value.
150+
151+
Raises:
152+
TimeoutError: If elapsed time is to long.
153+
ValueError: If input is at max value or above.
154+
ModbusException: If failed to read input register.
155+
"""
156+
start_timer = time.perf_counter()
157+
158+
regs = self._read_input_registers(self.EM511_REGISTER_V, self.INT32_REG_COUNT)
159+
self._timeout_check(start_timer, self.EM511_REGISTER_V)
160+
return Decimal(self._unpack(regs, self.EM511_REGISTER_V)) / self.SCALE_10
161+
162+
@property
163+
def A(self) -> Decimal:
164+
"""Current (A).
165+
166+
Returns:
167+
Decimal: Current ampere value.
168+
169+
Raises:
170+
TimeoutError: If elapsed time is to long.
171+
ValueError: If input is at max value or above.
172+
ModbusException: If failed to read input register.
173+
"""
174+
start_timer = time.perf_counter()
175+
regs = self._read_input_registers(self.EM511_REGISTER_A, self.INT32_REG_COUNT)
176+
self._timeout_check(start_timer, self.EM511_REGISTER_A)
177+
return Decimal(self._unpack(regs, self.EM511_REGISTER_A)) / self.SCALE_1000
178+
179+
@property
180+
def password(self) -> int:
181+
"""Password.
182+
183+
Returns:
184+
int: Current password value.
185+
186+
Raises:
187+
TimeoutError: If elapsed time is to long.
188+
ValueError: If input is at max value or above.
189+
ModbusException: If failed to read input register.
190+
"""
191+
start_timer = time.perf_counter()
192+
regs = self._read_input_registers(self.EM511_REGISTER_PASSWORD, self.INT16_REG_COUNT)
193+
self._timeout_check(start_timer, self.EM511_REGISTER_PASSWORD)
194+
return int(self._unpack(regs, self.EM511_REGISTER_PASSWORD))
195+
196+
@password.setter
197+
def password(self, value: int) -> None:
198+
"""Password.
199+
200+
Min value: 0 (no password).
201+
Max value: 9999.
202+
203+
Args:
204+
value (int): Set Password.
205+
206+
Raises:
207+
ModbusException: If failed to write to single register.
208+
"""
209+
if value not in range(self.PASSWORD_MAX_VALUE):
210+
msg = f"Invalid password value: {value}. Must be between 0 and 9999."
211+
raise ValueError(msg)
212+
self._write_register(self.EM511_REGISTER_PASSWORD, value)

src/em511/test_em511.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# ruff: noqa: S101,PLR2004, N802
2+
3+
"""Test file for driver."""
4+
5+
from contextlib import nullcontext
6+
from unittest.mock import MagicMock, patch
7+
8+
import pytest
9+
10+
from em511 import Em511
11+
12+
13+
def test_V() -> None:
14+
"""Test Get v."""
15+
client = MagicMock()
16+
mock_result = MagicMock()
17+
mock_result.isError.return_value = False
18+
meter = Em511(1, client)
19+
20+
"""Test 1: should pass"""
21+
mock_result.registers = [0x08FC, 0x0000]
22+
client.read_input_registers.return_value = mock_result
23+
value = meter.V
24+
assert value == 230
25+
26+
"""Test 2: Should pass."""
27+
mock_result.registers = [0x9A28, 0x0001]
28+
client.read_input_registers.return_value = mock_result
29+
value = meter.V
30+
assert value == 10500
31+
32+
"""Test 3: Should raise exception due to more registers in use than allowed."""
33+
mock_result.registers = [0x1860, 0x0023, 0x4244]
34+
client.read_input_registers.return_value = mock_result
35+
with pytest.raises(ValueError, match="Unexpected register count:"):
36+
_ = meter.V
37+
38+
"""Test 4: Should raise exception due to taking more then 500 ms to respond."""
39+
mock_result.registers = [0x1860, 0x0023]
40+
meter.TIMEOUT = 0.01
41+
client.read_input_registers.return_value = mock_result
42+
with (
43+
patch("em511.em511.time.perf_counter", side_effect=[0.0, 0.02]),
44+
pytest.raises(TimeoutError, match="Timeout error, response failed"),
45+
):
46+
_ = meter.V
47+
48+
"""Test 5: Should NOT raise exception when within timeout limit."""
49+
mock_result.registers = [0x1860, 0x0023]
50+
meter.TIMEOUT = 0.02
51+
client.read_input_registers.return_value = mock_result
52+
with patch("em511.em511.time.perf_counter", side_effect=[0.0, 0.01]), nullcontext():
53+
_ = meter.V
54+
55+
"""Test 6: Should raise exception if input value exceeds maximum value, display shows 'EEE', 32-bit register."""
56+
mock_result.registers = [0xFFFF, 0x7FFF]
57+
client.read_input_registers.return_value = mock_result
58+
with pytest.raises(ValueError, match="Input overflow EEE for 32-bit register: "):
59+
_ = meter.V
60+
61+
62+
def test_get_A() -> None:
63+
"""Test Get a."""
64+
client = MagicMock()
65+
mock_result = MagicMock()
66+
mock_result.isError.return_value = False
67+
meter = Em511(1, client)
68+
69+
"""Test 1: should pass"""
70+
mock_result.registers = [0x2904, 0x0000]
71+
client.read_input_registers.return_value = mock_result
72+
value = meter.A
73+
assert value == 10.5
74+
75+
"""Test 2: Should pass."""
76+
mock_result.registers = [0x1860, 0x0023]
77+
client.read_input_registers.return_value = mock_result
78+
value = meter.A
79+
assert value == 2300
80+
81+
"""Test 3: Should raise exception due to more registers in use than allowed."""
82+
mock_result.registers = [0x1860, 0x0023, 0x4244]
83+
client.read_input_registers.return_value = mock_result
84+
with pytest.raises(ValueError, match="Unexpected register count:"):
85+
_ = meter.A
86+
87+
"""Test 4: Should raise exception due to taking more then 500 ms to respond."""
88+
mock_result.registers = [0x1860, 0x0023]
89+
meter.TIMEOUT = 0.01
90+
client.read_input_registers.return_value = mock_result
91+
with (
92+
patch("em511.em511.time.perf_counter", side_effect=[0.0, 0.02]),
93+
pytest.raises(TimeoutError, match="Timeout error, response failed"),
94+
):
95+
_ = meter.A
96+
97+
"""Test 5: Should NOT raise exception when within timeout limit."""
98+
mock_result.registers = [0x1860, 0x0023]
99+
meter.TIMEOUT = 0.02
100+
client.read_input_registers.return_value = mock_result
101+
102+
with patch("em511.em511.time.perf_counter", side_effect=[0.0, 0.01]), nullcontext():
103+
_ = meter.A
104+
105+
"""Test 6: Should raise exception if input value exceeds maximum value, display shows 'EEE', 32-bit register."""
106+
mock_result.registers = [0xFFFF, 0x7FFF]
107+
client.read_input_registers.return_value = mock_result
108+
with pytest.raises(ValueError, match="Input overflow EEE for 32-bit register: "):
109+
_ = meter.A
110+
111+
112+
def test_get_password() -> None:
113+
"""Test Get password."""
114+
client = MagicMock()
115+
mock_result = MagicMock()
116+
mock_result.isError.return_value = False
117+
meter = Em511(1, client)
118+
119+
"""Test 1: should pass"""
120+
mock_result.registers = [1234]
121+
client.read_input_registers.return_value = mock_result
122+
value = meter.password
123+
assert value == 1234
124+
125+
"""Test 2: Should raise exception due to taking more then 500 ms to respond."""
126+
mock_result.registers = [0x1860, 0x0023]
127+
meter.TIMEOUT = 0.01
128+
client.read_input_registers.return_value = mock_result
129+
with (
130+
patch("em511.em511.time.perf_counter", side_effect=[0.0, 0.02]),
131+
pytest.raises(TimeoutError, match="Timeout error, response failed"),
132+
):
133+
_ = meter.password
134+
135+
"""Test 3: Should NOT raise exception when within timeout limit."""
136+
mock_result.registers = [0x1860, 0x0023]
137+
meter.TIMEOUT = 0.02
138+
client.read_input_registers.return_value = mock_result
139+
140+
with patch("em511.em511.time.perf_counter", side_effect=[0.0, 0.01]), nullcontext():
141+
_ = meter.password
142+
143+
"""Test 6: Should raise exception if input value exceeds maximum value, display shows 'EEE', 32-bit register."""
144+
mock_result.registers = [0xFFFF, 0x7FFF]
145+
client.read_input_registers.return_value = mock_result
146+
with pytest.raises(ValueError, match="Input overflow EEE for 32-bit register: "):
147+
_ = meter.password
148+
149+
150+
def test_set_password() -> None:
151+
"""Test Set Password."""
152+
client = MagicMock()
153+
mock_result = MagicMock()
154+
mock_result.isError.return_value = False
155+
meter = Em511(1, client)
156+
157+
"""Test 1: Set password"""
158+
mock_result.registers = [4096]
159+
client.write_register.return_value = mock_result
160+
meter.password = 1236
161+
client.write_register.assert_called_once_with(address=4096, value=1236, device_id=1)
162+
163+
"""Test 2: Try set password out of range."""
164+
with pytest.raises(ValueError, match="Invalid password value:"):
165+
meter.password = 12345

0 commit comments

Comments
 (0)