-
Notifications
You must be signed in to change notification settings - Fork 0
feat(driver): add base driver #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| """Init file.""" | ||
|
|
||
| from .dcm230 import Dcm230 | ||
|
|
||
| __all__ = ["Dcm230"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| 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] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]: ... | ||
|
Dexter9532 marked this conversation as resolved.
|
||
| def _read_registers(self, address: int, count: int, reg_type: int) -> list[int]: ... | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
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 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.