Skip to content

Commit 22c5fa1

Browse files
authored
Merge pull request #9 from id8-engineering/add_functions
feat: add more functions
2 parents f245a63 + d692749 commit 22c5fa1

3 files changed

Lines changed: 268 additions & 24 deletions

File tree

src/dcm230/dcm230.py

Lines changed: 110 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import struct
1313
from dataclasses import dataclass
1414
from decimal import Decimal
15-
from typing import Final, TypeVar
15+
from typing import ClassVar, Final, TypeVar
1616

1717
from pymodbus.client import ModbusSerialClient
1818
from pymodbus.exceptions import ModbusException
@@ -54,7 +54,7 @@ def register_properties(cls: T) -> T:
5454
a corresponding @property getter, and optionally a setter if `writable=True`.
5555
5656
The generated getter automatically calls `_read_register(register_name)`
57-
and the setter calls `_write_register(address, value)` with range validation
57+
and the setter calls `_write_registers(address, value)` with range validation
5858
if enabled in the `RegisterSpec`.
5959
6060
Args:
@@ -97,7 +97,7 @@ def setter(self: "Dcm230", value: int, _name: str = name, _spec: "RegisterSpec"
9797
if _spec.range and not (_spec.min <= value <= _spec.max):
9898
msg = f"Invalid value for '{_name}': {value}. Must be between {_spec.min} and {_spec.max}."
9999
raise ValueError(msg)
100-
self._write_register(_spec.address, int(value))
100+
self._write_registers(_spec.address, int(value))
101101

102102
prop = property(getter, setter) if spec.writable else property(getter)
103103

@@ -121,11 +121,56 @@ class Dcm230:
121121

122122
SINGLE_REGISTER = 1
123123
MAX_REGS = 2
124+
124125
INPUT_REGISTER = 0x03
125126
HOLDING_REGISTER = 0x04
126127

128+
BACKLIT_OPTIONS: ClassVar[list[int]] = [0, 5, 10, 20, 30, 60]
129+
DCM230_REGISTER_BACKLIT_TIME = 0x003C
130+
DCM230_REGISTER_RESET_MAX_DMD_AND_PARTIAL_ENERGY = 0xF010
131+
RESET_MAX_DMD = 0x0000
132+
RESET_PARTIAL_ENERGY = 0x0003
133+
127134
_register_specs: Final[dict[str, RegisterSpec]] = {
128-
"V": RegisterSpec(address=0x0000, count=2, decimals=1, reg_type=0x03),
135+
"V": RegisterSpec(address=0x0000, count=2, decimals=1, reg_type=INPUT_REGISTER),
136+
"A": RegisterSpec(address=0x0006, count=2, decimals=1, reg_type=INPUT_REGISTER),
137+
"W": RegisterSpec(address=0x000C, count=2, decimals=1, reg_type=INPUT_REGISTER),
138+
"kwh": RegisterSpec(address=0x0048, count=2, decimals=1, reg_type=INPUT_REGISTER),
139+
"W_dmd": RegisterSpec(address=0x0054, count=2, decimals=1, reg_type=INPUT_REGISTER),
140+
"W_dmd_peak": RegisterSpec(address=0x0056, count=2, decimals=1, reg_type=INPUT_REGISTER),
141+
"kwh_tot": RegisterSpec(address=0x0156, count=2, decimals=1, reg_type=INPUT_REGISTER),
142+
"kwh_partial": RegisterSpec(address=0x0180, count=2, decimals=1, reg_type=INPUT_REGISTER),
143+
"dmd_period": RegisterSpec(
144+
address=0x0002,
145+
count=2,
146+
range=True,
147+
min=0,
148+
max=60,
149+
writable=True,
150+
reg_type=HOLDING_REGISTER,
151+
return_type=int,
152+
),
153+
"network_info": RegisterSpec(
154+
address=0x0012, count=2, range=True, min=0, max=3, writable=True, reg_type=HOLDING_REGISTER, return_type=int
155+
),
156+
"device_id": RegisterSpec(
157+
address=0x0014,
158+
count=2,
159+
range=True,
160+
min=1,
161+
max=247,
162+
writable=True,
163+
reg_type=HOLDING_REGISTER,
164+
return_type=int,
165+
),
166+
"password": RegisterSpec(address=0x0018, count=2, reg_type=HOLDING_REGISTER, return_type=int),
167+
"baud_rate": RegisterSpec(
168+
address=0x001C, count=2, range=True, min=0, max=5, writable=True, reg_type=HOLDING_REGISTER, return_type=int
169+
),
170+
"energy_measurement_tool": RegisterSpec(
171+
address=0xF920, count=2, range=True, min=0, max=3, writable=True, reg_type=HOLDING_REGISTER, return_type=int
172+
),
173+
"serial_number": RegisterSpec(address=0xFC00, count=2, reg_type=HOLDING_REGISTER, return_type=int),
129174
}
130175

131176
def __init__(self, device_address: int, client: ModbusSerialClient) -> None:
@@ -190,8 +235,8 @@ def _read_register(self, register_name: str) -> Decimal | int:
190235
return round(value, spec.decimals)
191236
return self._unpack(regs, spec.address)
192237

193-
def _write_register(self, address: int, value: int) -> None:
194-
"""Write a single Modbus register.
238+
def _write_registers(self, address: int, value: int) -> None:
239+
"""Write to Modbus registers.
195240
196241
Args:
197242
address: Register address to write.
@@ -200,12 +245,9 @@ def _write_register(self, address: int, value: int) -> None:
200245
Raises:
201246
ModbusException: If the write operation fails.
202247
"""
203-
result = self.client.write_register(address=address, value=value, device_id=self.device_address)
248+
result = self.client.write_registers(address=address, values=[value], device_id=self.device_address)
204249
if result.isError():
205-
msg = (
206-
"Failed to write to single register. "
207-
f"device_address={self.device_address} address={address} value={value}"
208-
)
250+
msg = f"Failed to write to registers. device_address={self.device_address} address={address} value={value}"
209251
raise ModbusException(msg)
210252

211253
def _unpack(self, regs: list[int], address: int) -> int:
@@ -232,3 +274,60 @@ def _unpack(self, regs: list[int], address: int) -> int:
232274
raise ValueError(msg)
233275

234276
return struct.unpack(">f", struct.pack(">HH", regs[0], regs[1]))[0]
277+
278+
def reset_max_dmd(self) -> None:
279+
"""Reset max demand.
280+
281+
Raises:
282+
ModbusException: If failed to write to registers.
283+
"""
284+
self._write_registers(self.DCM230_REGISTER_RESET_MAX_DMD_AND_PARTIAL_ENERGY, self.RESET_MAX_DMD)
285+
286+
def reset_partial_energy(self) -> None:
287+
"""Reset partial energy.
288+
289+
Raises:
290+
ModbusException: If failed to write to registers.
291+
"""
292+
self._write_registers(self.DCM230_REGISTER_RESET_MAX_DMD_AND_PARTIAL_ENERGY, self.RESET_PARTIAL_ENERGY)
293+
294+
@property
295+
def backlit_time(self) -> int:
296+
"""Backlit time.
297+
298+
Options:
299+
0, 5, 10, 20, 30, 60 minutes.
300+
301+
Returns:
302+
int: Current backlit time value.
303+
304+
Raises:
305+
ValueError: If not a specified value.
306+
ModbusException: If failed to read holding registers.
307+
"""
308+
regs = self._read_registers(self.DCM230_REGISTER_BACKLIT_TIME, self.MAX_REGS, self.HOLDING_REGISTER)
309+
value = round(self._unpack(regs, self.DCM230_REGISTER_BACKLIT_TIME))
310+
311+
if value not in self.BACKLIT_OPTIONS:
312+
msg = f"Invalid backlit option: {value}. Must be one of: {self.BACKLIT_OPTIONS}"
313+
raise ValueError(msg)
314+
return value
315+
316+
@backlit_time.setter
317+
def backlit_time(self, value: int) -> None:
318+
"""Backlite time.
319+
320+
Options:
321+
0, 5, 10, 20, 30, 60 minutes.
322+
323+
Args:
324+
value (int): Set backlit time.
325+
326+
Raises:
327+
ModbusException: If failed to write to registers.
328+
ValueError: If not specified value.
329+
"""
330+
if value not in self.BACKLIT_OPTIONS:
331+
msg = f"Invalid backlit option: {value}. Must be one of: {self.BACKLIT_OPTIONS}"
332+
raise ValueError(msg)
333+
self._write_registers(self.DCM230_REGISTER_BACKLIT_TIME, value)

src/dcm230/dcm230.pyi

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,50 @@ from decimal import Decimal
22

33
from pymodbus.client import ModbusSerialClient
44

5+
class RegisterSpec:
6+
address: int
7+
count: int
8+
reg_type: int
9+
decimals: int = 0
10+
range: bool = False
11+
min: int = 0
12+
max: int = 0x7FFFFFFF
13+
writable: bool = False
14+
return_type: type[int | Decimal] = ...
15+
16+
def register_properties(self) -> None: ...
17+
518
class Dcm230:
19+
RESET_MAX_DMD: int
20+
RESET_PARTIAL_ENERGY: int
21+
DCM230_REGISTER_RESET_MAX_DMD_AND_PARTIAL_ENERGY: int
22+
DCM230_REGISTER_BACKLIT_TIME: int
23+
INPUT_REGISTER: int
24+
HOLDING_REGISTER: int
25+
_register_specs: RegisterSpec
26+
627
def __init__(self, device_address: int, client: ModbusSerialClient) -> None: ...
728
V: Decimal
29+
A: Decimal
30+
W: Decimal
31+
kwh: Decimal
32+
W_dmd: Decimal
33+
W_dmd_peak: Decimal
34+
kwh_tot: Decimal
35+
kwh_partial: Decimal
36+
dmd_period: Decimal
37+
backlit_time: int
38+
network_info: int
39+
device_id: int
40+
password: int
41+
baud_rate: int
42+
energy_measurement_tool: int
43+
serial_number: int
844

945
def _unpack(self, registers: list[int], address: int) -> int: ...
10-
def _write_register(self, address: int, value: int) -> None: ...
46+
def _write_registers(self, address: int, value: int) -> None: ...
1147
def _read_register(self, register_name: str) -> Decimal | int: ...
1248
def _read_input_registers(self, address: int, count: int) -> list[int]: ...
1349
def _read_registers(self, address: int, count: int, reg_type: int) -> list[int]: ...
50+
def reset_max_dmd(self) -> None: ...
51+
def reset_partial_energy(self) -> None: ...

src/dcm230/test_dcm230.py

Lines changed: 119 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# ruff: noqa: S101,PLR2004, N802, SLF001
1+
# ruff: noqa: S101, PLR2004, SLF001
22

33
"""Test file for driver."""
44

@@ -42,21 +42,128 @@ def test_read_register() -> None:
4242
_ = meter._read_registers(address, count, reg_type)
4343

4444

45-
def test_V() -> None:
46-
"""Test get v."""
45+
def test_range_validation() -> None:
46+
"""Test range validation."""
47+
client = MagicMock()
48+
meter = Dcm230(1, client)
49+
50+
"""Test 1: Should not pass due to out of range."""
51+
for name, spec in Dcm230._register_specs.items(): # type: ignore[attr-defined]
52+
if not spec.range:
53+
continue
54+
55+
client.write_registers.return_value.isError.return_value = False
56+
with nullcontext():
57+
_ = setattr(meter, name, spec.min)
58+
59+
invalid_value = spec.max + 1
60+
with pytest.raises(ValueError, match="Invalid value for"):
61+
setattr(meter, name, invalid_value)
62+
63+
invalid_value = spec.min - 1
64+
with pytest.raises(ValueError, match="Invalid value for"):
65+
setattr(meter, name, invalid_value)
66+
67+
68+
def read_input_registers() -> None:
69+
"""Test all input registers."""
4770
client = MagicMock()
4871
mock_result = MagicMock()
4972
mock_result.isError.return_value = False
5073
meter = Dcm230(1, client)
5174

52-
"""Test 1: should pass"""
53-
mock_result.registers = [0x4366, 0x0000]
75+
mock_result.registers = [0x3F80, 0x0000]
5476
client.read_input_registers.return_value = mock_result
55-
value = meter.V
56-
assert value == 230
5777

58-
"""Test 2: Should pass."""
59-
mock_result.registers = [0x4624, 0x1000]
60-
client.read_input_registers.return_value = mock_result
61-
value = meter.V
62-
assert value == 10500
78+
"""Test 1: Should pass."""
79+
for name, spec in Dcm230._register_specs.items(): # type: ignore[attr-defined]
80+
if spec.reg_type != meter.INPUT_REGISTER:
81+
continue
82+
83+
value = getattr(meter, name)
84+
assert value == 1
85+
86+
87+
def read_holding_registers() -> None:
88+
"""Test all holding registers."""
89+
client = MagicMock()
90+
mock_result = MagicMock()
91+
mock_result.isError.return_value = False
92+
meter = Dcm230(1, client)
93+
94+
mock_result.registers = [0x3F80, 0x0000]
95+
client.read_holding_registers.return_value = mock_result
96+
97+
"""Test 1: Should pass."""
98+
for name, spec in Dcm230._register_specs.items(): # type: ignore[attr-defined]
99+
if spec.reg_type != meter.HOLDING_REGISTER:
100+
continue
101+
102+
value = getattr(meter, name)
103+
assert value == 1
104+
105+
106+
def test_set_all_register() -> None:
107+
"""Test set all registers."""
108+
client = MagicMock()
109+
mock_result = MagicMock()
110+
mock_result.isError.return_value = False
111+
client.write_registers.return_value = mock_result
112+
113+
meter = Dcm230(1, client)
114+
115+
for name, spec in Dcm230._register_specs.items(): # type: ignore[attr-defined]
116+
if not spec.writable:
117+
continue
118+
119+
value = 1
120+
setattr(meter, name, value)
121+
client.write_registers.assert_called_once_with(address=spec.address, values=[1], device_id=1)
122+
client.write_registers.reset_mock()
123+
124+
125+
def test_backlit_time() -> None:
126+
"""Test get and set backlit time."""
127+
client = MagicMock()
128+
mock_result = MagicMock()
129+
mock_result.isError.return_value = False
130+
meter = Dcm230(1, client)
131+
132+
"""Test 1: should pass"""
133+
mock_result.registers = [0x4270, 0x0000]
134+
client.read_holding_registers.return_value = mock_result
135+
value = meter.backlit_time
136+
assert value == 60
137+
138+
"""Test 2: Should raise exception due to invalid backlit option."""
139+
with pytest.raises(ValueError, match="Invalid backlit option:"):
140+
meter.backlit_time = 100
141+
142+
"""Test 3: should pass when set."""
143+
client.write_registers.return_value = mock_result
144+
meter.backlit_time = 60
145+
client.write_registers.assert_called_once_with(address=meter.DCM230_REGISTER_BACKLIT_TIME, values=[60], device_id=1)
146+
147+
148+
def test_reset_functions() -> None:
149+
"""Test all reset functions."""
150+
client = MagicMock()
151+
mock_result = MagicMock()
152+
mock_result.isError.return_value = False
153+
client.write_registers.return_value = mock_result
154+
155+
meter = Dcm230(1, client)
156+
157+
"""Test 1: reset_max_dmd."""
158+
meter.reset_max_dmd()
159+
client.write_registers.assert_called_once_with(
160+
address=meter.DCM230_REGISTER_RESET_MAX_DMD_AND_PARTIAL_ENERGY, values=[meter.RESET_MAX_DMD], device_id=1
161+
)
162+
163+
client.write_registers.reset_mock()
164+
165+
"""Test 2: reset_partial_energy."""
166+
meter.reset_partial_energy()
167+
client.write_registers.assert_called_once_with(
168+
address=meter.DCM230_REGISTER_RESET_MAX_DMD_AND_PARTIAL_ENERGY, values=[meter.RESET_PARTIAL_ENERGY], device_id=1
169+
)

0 commit comments

Comments
 (0)