Skip to content
Merged
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
59 changes: 54 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,64 @@
[project]
name = "python-dcm230"
version = "0.1.0"
description = "Driver for Eastron DCM230 Series Single Phase Energy Meters using Modbus RTU"
version = "0.0.1"
description = "Driver for Eastron DCM230 DC energy Meters using Modbus RTU"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

license = "MIT"
license-files = ["LICENSE"]

authors = [
{ name = "Mirza Krak", email = "mirza@mkrak.org" },
{ name = "Bobo Bäck Engström", email = "bobo@id8-engineering.io"},
{ name = "Milad Makdesi", email = "milad@id8-engineering.io"},
]
maintainers = [
{ name = "Mirza Krak", email = "mirza@mkrak.org" },
{ name = "Bobo Bäck Engström", email = "bobo@id8-engineering.io"},
{ name = "Milad Makdesi", email = "milad@id8-engineering.io"},
]

requires-python = ">=3.10"
dependencies = [
"pymodbus[serial]>=3.11"
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Embedded Systems",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/dcm230"]

[dependency-groups]
dev = [
"pre-commit>=4.3.0",
"ruff>=0.14.3",
"pyright>=1.1.406",
"pytest>=8.4.2",
"ruff>=0.14.0",
]

[tool.ruff]
line-length = 120

[tool.ruff.lint]
select = ["ALL"]

ignore = [
"COM812", # in conflict with formatter
]

[tool.ruff.lint.pydocstyle]
convention = "google"
5 changes: 5 additions & 0 deletions src/dcm230/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Init file."""

from .dcm230 import Dcm230

__all__ = ["Dcm230"]
236 changes: 232 additions & 4 deletions src/dcm230/dcm230.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,234 @@
def main():
print("Hello from python-dcm230!")
"""Driver class for Eastron DCM230 Modbus energy meters.

This module provides a generic, configurable driver for the Eastron Dcm230
series of energy meters, using pymodbus for serial communication.

if __name__ == "__main__":
main()
The design uses dataclasses to define register specifications and a class
decorator to automatically generate @property accessors for all defined
registers. This minimizes boilerplate and ensures consistency across multiple
registers.
"""

import struct
from dataclasses import dataclass
from decimal import Decimal
from typing import Final, TypeVar

from pymodbus.client import ModbusSerialClient
from pymodbus.exceptions import ModbusException

T = TypeVar("T", bound=type)


@dataclass(frozen=True)
class RegisterSpec:
"""Specification for a Modbus register mapping.

Defines the register address, scaling, precision, and read/write behavior.

Attributes:
address (int): Modbus register address.
count (int): Number of consecutive registers to read.
decimals (int): Number of decimal places to round the scaled value to.
writable (bool): Whether this register can be written to.
range (bool): Whether range validation should be performed.
min (int): Minimum allowed value for range validation.
max (int): Maximum allowed value for range validation.
"""

address: int
count: int
reg_type: int
decimals: int = 0
range: bool = False
min: int = 0
max: int = 0x7FFFFFFF
writable: bool = False
return_type: type[int] | type[Decimal] = Decimal


def register_properties(cls: T) -> T:
"""Class decorator that auto-generates @property accessors for Modbus registers.

For each entry in `cls._register_specs`, this decorator dynamically creates
a corresponding @property getter, and optionally a setter if `writable=True`.

The generated getter automatically calls `_read_register(register_name)`
and the setter calls `_write_register(address, value)` with range validation
if enabled in the `RegisterSpec`.

Args:
cls: The target class to which properties will be added.

Returns:
The same class with dynamically added properties.
"""
for name, spec in cls._register_specs.items():

def getter(self: "Dcm230", _name: str = name, _spec: "RegisterSpec" = spec) -> Decimal | int:
"""Auto-generated register reader.

Returns the current value of the register. If range validation is
enabled, ensures that the returned value is within the expected range.

Raises:
ValueError: If the register value is outside its defined range.
"""
_value = self._read_register(_name)
if _spec.range and not (_spec.min <= _value <= _spec.max):
msg = f"Invalid value for '{_name}': {_value}. Must be between {_spec.min} and {_spec.max}."
raise ValueError(msg)
return _value

def setter(self: "Dcm230", value: int, _name: str = name, _spec: "RegisterSpec" = spec) -> None:
"""Auto-generated register writer.

Writes a new value to the register and performs range validation
if defined in the corresponding `RegisterSpec`.

Raises:
AttributeError: If the register is read-only.
ValueError: If the written value is outside its defined range.
"""
if not _spec.writable:
msg = f"Register '{_name}' is read-only."
raise AttributeError(msg)

if _spec.range and not (_spec.min <= value <= _spec.max):
msg = f"Invalid value for '{_name}': {value}. Must be between {_spec.min} and {_spec.max}."
raise ValueError(msg)
self._write_register(_spec.address, int(value))

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

prop.__doc__ = f"{name} ({'read/write' if spec.writable else 'read-only'})" + (
f" range=[{spec.min}, {spec.max}]" if spec.range else ""
)

setattr(cls, name, prop)

return cls


@register_properties
class Dcm230:
"""Driver for Eastron Dcm230 series energy meters.
Comment thread
mirzak marked this conversation as resolved.

Provides read and write access to Modbus registers via an existing
`pymodbus.client.ModbusSerialClient` instance. Register definitions are
dynamically mapped to @property accessors based on `_register_specs`.
"""

SINGLE_REGISTER = 1
MAX_REGS = 2
INPUT_REGISTER = 0x03
HOLDING_REGISTER = 0x04

_register_specs: Final[dict[str, RegisterSpec]] = {
"V": RegisterSpec(address=0x0000, count=2, decimals=1, reg_type=0x03),
}

def __init__(self, device_address: int, client: ModbusSerialClient) -> None:
"""Initialize an Dcm230 driver instance.

Args:
device_address: Modbus address for the Dcm230 meter.
client: A connected `ModbusSerialClient` instance.
"""
self.device_address = device_address
self.client = client

def _read_registers(self, address: int, count: int, reg_type: int) -> list[int]:
"""Safely read input or holding registers from the Modbus device.

Args:
address: Starting register address to read.
count: Number of registers to read.
reg_type: Input or holding register.

Returns:
A list of integer register values.

Raises:
ModbusException: If the read operation fails or returns an error.
ValueError: If register type is incorrect.
"""
if reg_type == self.INPUT_REGISTER:
result = self.client.read_input_registers(address=address, count=count, device_id=self.device_address)
elif reg_type == self.HOLDING_REGISTER:
result = self.client.read_holding_registers(address=address, count=count, device_id=self.device_address)
else:
msg = f"Unsupported reg_type: {reg_type}"
raise ValueError(msg)

if result.isError():
msg = (
"Failed to read input register. "
f"device_address={self.device_address} address={address} count={count} result={result}"
)
raise ModbusException(msg)
return result.registers

def _read_register(self, register_name: str) -> Decimal | int:
"""Read and scale the specified register.

Args:
register_name: Name of the register as defined in `_register_specs`.

Returns:
A Decimal value representing the scaled register reading.

Raises:
ValueError: If register unpacking fails or returns overflow values.
ModbusException: If Modbus read operation fails.
"""
spec = self._register_specs[register_name]
regs = self._read_registers(spec.address, spec.count, spec.reg_type)

if spec.return_type is Decimal:
value = Decimal(str(self._unpack(regs, spec.address)))
return round(value, spec.decimals)
return self._unpack(regs, spec.address)

def _write_register(self, address: int, value: int) -> None:
"""Write a single Modbus register.

Args:
address: Register address to write.
value: Integer value to write to the register.

Raises:
ModbusException: If the write operation fails.
"""
result = self.client.write_register(address=address, value=value, device_id=self.device_address)
if result.isError():
msg = (
"Failed to write to single register. "
f"device_address={self.device_address} address={address} value={value}"
)
raise ModbusException(msg)

def _unpack(self, regs: list[int], address: int) -> int:
"""Unpack raw Modbus register data into an integer value.

Args:
regs: The list of register values to unpack.
address: The base register address (used for error reporting).

Returns:
The unpacked integer representation of the registers.

Raises:
ValueError: If an invalid number of registers is provided or an
overflow marker is detected.
"""
# Some devices return only a single register; pad with zero to make
# it a full 32-bit value so struct.unpack(...) works correctly.
if len(regs) == self.SINGLE_REGISTER:
regs.append(0) # Padd with zero

if len(regs) != self.MAX_REGS:
msg = f"Unexpected register count: {len(regs)} for address={address}"
raise ValueError(msg)

return struct.unpack(">f", struct.pack(">HH", regs[0], regs[1]))[0]
13 changes: 13 additions & 0 deletions src/dcm230/dcm230.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from decimal import Decimal

from pymodbus.client import ModbusSerialClient

class Dcm230:
def __init__(self, device_address: int, client: ModbusSerialClient) -> None: ...
V: Decimal

def _unpack(self, registers: list[int], address: int) -> int: ...
def _write_register(self, address: int, value: int) -> None: ...
def _read_register(self, register_name: str) -> Decimal | int: ...
def _read_input_registers(self, address: int, count: int) -> list[int]: ...
Comment thread
Dexter9532 marked this conversation as resolved.
def _read_registers(self, address: int, count: int, reg_type: int) -> list[int]: ...
64 changes: 62 additions & 2 deletions src/dcm230/test_dcm230.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,62 @@
def test() -> None:
pass
# ruff: noqa: S101,PLR2004, N802, SLF001

"""Test file for driver."""

from contextlib import nullcontext
from unittest.mock import MagicMock

import pytest

from dcm230 import Dcm230


def test_unpack() -> None:
"""Test unpack."""
client = MagicMock()
meter = Dcm230(1, client)

"""Test 1: Should raise exception due to more registers in use than allowed."""
registers = [0x1860, 0x0023, 0x4244]
with pytest.raises(ValueError, match="Unexpected register count:"):
_ = meter._unpack(registers, 0x0001)


def test_read_register() -> None:
"""Test read_register."""
client = MagicMock()
meter = Dcm230(1, client)

"""Test 1: Should raise exception due to incorrect register type"""
address = 1
count = 2
reg_type = 0x05
with pytest.raises(ValueError, match="Unsupported reg_type:"):
_ = meter._read_registers(address, count, reg_type)
Comment thread
Dexter9532 marked this conversation as resolved.

"""Test 2: Should NOT raise when using correct register type"""
reg_type = 0x03
client.read_input_registers.return_value.isError.return_value = False
client.read_input_registers.return_value.registers = [100, 200]

with nullcontext():
_ = meter._read_registers(address, count, reg_type)


def test_V() -> None:
"""Test get v."""
client = MagicMock()
mock_result = MagicMock()
mock_result.isError.return_value = False
meter = Dcm230(1, client)

"""Test 1: should pass"""
mock_result.registers = [0x4366, 0x0000]
client.read_input_registers.return_value = mock_result
value = meter.V
assert value == 230

"""Test 2: Should pass."""
mock_result.registers = [0x4624, 0x1000]
client.read_input_registers.return_value = mock_result
value = meter.V
assert value == 10500
Loading