From 6ab40fbb79462c9ace411c084bacffe6ccbbbf84 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 11 Mar 2026 19:35:54 +0100 Subject: [PATCH 1/2] Add real life simulator example. --- examples/heatpump.py | 210 ++++++++++++++++++++++++++++++++++++ examples/server_updating.py | 4 + pymodbus/__init__.py | 5 +- 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 examples/heatpump.py diff --git a/examples/heatpump.py b/examples/heatpump.py new file mode 100644 index 000000000..0c974edde --- /dev/null +++ b/examples/heatpump.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Controlling a Daikin Altherma 3 heatpump, and presenting data in Home Assistant. + +This app is used to control a Daikin heatpump, by reading the temperatures +of deposit for the underfloor heating. + +Based on the temperatures and the time of the day (electricity periods P1, P2 and P3) it +is determined to start/stop the heat pump (Daikin Altherma3) + +The data is made available in modbus and observed by a Home Assistant server, and presented +to the home users. + +Schematic of deposit: + + +--------+ + A) --> -+ | F) + | E) +--+- --> C) + | | | + B) <-- -+ | | + | +--+- <-- D) + +--------+ + + A) Intake from heat pump (hot) + B) Return to heat pump (cold) + C) Output to underfloor circulation pump (hot) + D) return from underfloor pipes (cold) + E) Output from tank (hot) + F) Thermostatic mixing valve (output is fixed at max 30 degrees) + +The 5 point are measured. + +The algorithm is quite simple: + + Difference between A) and heat pump setpoint shows the loss in the connection pipes. + Difference between D) and underfloor heating thermostats show if heat is required. + +Remark: the return from the underfloor heating is connected to a thermostatic valve, +on the output to the circulation pump. This allows to e.g. heat the tank to 50 degrees, +while still circulating 30 degrees. Using the thermostatic valve, dramatically reduces +the number of times the heat pump is started (when started it runs longer) and thus saving +electricity. + +The thermo meters are read via 1-wire protocol. + +usage:: + + heatpump.py [-h] + [--log {critical,error,warning,info,debug}] + [--port ] + [--test] + + -h, --help + show this help message and exit + -l, --log {critical,error,warning,info,debug} + set log level, default is info + -t, --time + Limit run time (used e.g. in automated test) + -p, --port PORT + set port to listen on +""" +import argparse +import asyncio +from contextlib import suppress +from time import time +from typing import cast + +from pymodbus import Log, pymodbus_apply_logging_config +from pymodbus.client import ModbusTcpClient as client +from pymodbus.constants import ExcCodes +from pymodbus.server import ModbusTcpServer +from pymodbus.simulator import DataType, SimData, SimDevice + + +DEVICE_ID = 1 +BITS_ADDR = 0 +THERMO_ADDR = (1, 3, 5, 7, 9) # Thermometros A-E +ALIVE_ADDR = (11, 12) # Call count, 1 minute counter + + +class OneWire: # pylint: disable=(too-few-public-methods + """Read 1wire thermometros.""" + + def read_all(self) -> list[float]: + """Read 1wire.""" + return [31.2, 32.3, 33.4, 34.5, 35.6] + + + +class Heatpump: + """Handle heatpump measurement.""" + + def __init__(self, cmdline: list[str] | None = None): + """Create instance.""" + parser = argparse.ArgumentParser(description="Daikin heatpump") + parser.add_argument( + "-l", + "--log", + choices=["critical", "error", "warning", "info", "debug"], + help="set log level, default is info", + dest="log", + default="info", + type=str, + ) + parser.add_argument( + "-p", + "--port", + help="set listen port, default is 5020", + dest="port", + default=5020, + type=str, + ) + parser.add_argument( + "-t", + "--time", + help="limit run time", + dest="test_time", + default=0, + type=int, + ) + args = parser.parse_args(cmdline) + self.test_time = args.test_time + pymodbus_apply_logging_config(args.log.upper()) + + device = SimDevice(DEVICE_ID, + simdata=[ + SimData(BITS_ADDR, DataType.BITS, readonly=True), + SimData(THERMO_ADDR[0], count=len(THERMO_ADDR), datatype=DataType.FLOAT32, readonly=True), + SimData(ALIVE_ADDR[0], count=len(ALIVE_ADDR), datatype=DataType.INT16, readonly=True), + ], + use_bit_addressing=True, + action=self.catch_requests + ) + self.server = ModbusTcpServer(device, address=("", args.port)) + self.serving: asyncio.Future = asyncio.Future() + self.last_keepalive = 0 + self.one_wire = OneWire() + self.server_task: asyncio.Task | None = None + + async def catch_requests( + self, + _function_code: int, + _start_address: int, + _address: int, + _count: int, + registers: list[int], + _set_values: list[int] | list[bool] | None + ) -> None | ExcCodes: + """Run action.""" + registers[ALIVE_ADDR[0]] = 0 if registers[ALIVE_ADDR[0]] > 32000 else registers[ALIVE_ADDR[0]] + 1 + return None + + async def serve_forever(self): + """Update thermometro reading, as well as a keepalive counter.""" + Log.debug("updating_task: started") + while True: + if self.serving.done() or self.serving.cancelled() or self.serving.exception(): + return + await asyncio.sleep(1) + + if (sec := int(time())) >= self.last_keepalive +60: + keepalive = cast(list[int], await self.server.async_getValues(DEVICE_ID, 3, ALIVE_ADDR[1], count=1)) + keepalive[0] = 0 if keepalive[0] > 32000 else keepalive[0] + 1 + await self.server.async_setValues(DEVICE_ID, 16, ALIVE_ADDR[1], keepalive) + self.last_keepalive = sec + + values = self.one_wire.read_all() + regs: list[int] = [] + for value in values: + regs.extend(client.convert_to_registers(value, data_type=client.DATATYPE.FLOAT32)) + await self.server.async_setValues(DEVICE_ID, 0x16, THERMO_ADDR[0], regs) + + async def shutdown(self, delayed): + """Close server.""" + if delayed: + await asyncio.sleep(delayed) + if not self.serving.done(): + self.serving.set_result(True) + await asyncio.sleep(1) + await self.server.shutdown() + if self.server_task: + if not self.server_task.cancelled(): # pragma: no cover + self.server_task.cancel() + with suppress(asyncio.CancelledError): + await self.server_task + + async def run_updating_server(self): + """Start updating_task concurrently with the current task.""" + self.server_task = asyncio.create_task(self.server.serve_forever()) + self.server_task.set_name("server task") + await asyncio.sleep(1) + shutdown_task: asyncio.Task | None = None + if self.test_time: + shutdown_task = asyncio.create_task(self.shutdown(self.test_time)) + await self.serve_forever() + if shutdown_task: + if not shutdown_task.cancelled(): # pragma: no cover + shutdown_task.cancel() + with suppress(asyncio.CancelledError): + await shutdown_task + + + +async def main(cmdline=None): + """Combine setup and run.""" + obj = Heatpump(cmdline) + await obj.run_updating_server() + + +if __name__ == "__main__": + asyncio.run(main(), debug=True) diff --git a/examples/server_updating.py b/examples/server_updating.py index 47ede724e..2d8ab34c9 100755 --- a/examples/server_updating.py +++ b/examples/server_updating.py @@ -4,6 +4,10 @@ An example of an asynchronous server and a task that runs continuously alongside the server and updates values. +A real world example controlling a heatpump can be found at + + examples/heatpump.py + usage:: server_updating.py [-h] diff --git a/pymodbus/__init__.py b/pymodbus/__init__.py index 8d74efd89..ff5086553 100644 --- a/pymodbus/__init__.py +++ b/pymodbus/__init__.py @@ -6,16 +6,17 @@ __all__ = [ "ExceptionResponse", "FramerType", + "Log", "ModbusDeviceIdentification", "ModbusException", "__version__", "__version_full__", - "pymodbus_apply_logging_config" + "pymodbus_apply_logging_config", ] from .exceptions import ModbusException from .framer import FramerType -from .logging import pymodbus_apply_logging_config +from .logging import Log, pymodbus_apply_logging_config from .pdu import ExceptionResponse from .pdu.device import ModbusDeviceIdentification From 2adf52f4ec7414e1cd6927112a1876a991e2274f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 24 Mar 2026 12:07:07 +0100 Subject: [PATCH 2/2] Final. --- examples/heatpump.py | 19 ++++++++++++------- test/examples/test_examples.py | 5 +++++ test/simulator/test_simdata.py | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) mode change 100644 => 100755 examples/heatpump.py diff --git a/examples/heatpump.py b/examples/heatpump.py old mode 100644 new mode 100755 index 0c974edde..43c5f90e9 --- a/examples/heatpump.py +++ b/examples/heatpump.py @@ -120,12 +120,13 @@ def __init__(self, cmdline: list[str] | None = None): args = parser.parse_args(cmdline) self.test_time = args.test_time pymodbus_apply_logging_config(args.log.upper()) + Log.info("Start heatpump monitor.") device = SimDevice(DEVICE_ID, simdata=[ - SimData(BITS_ADDR, DataType.BITS, readonly=True), - SimData(THERMO_ADDR[0], count=len(THERMO_ADDR), datatype=DataType.FLOAT32, readonly=True), - SimData(ALIVE_ADDR[0], count=len(ALIVE_ADDR), datatype=DataType.INT16, readonly=True), + SimData(BITS_ADDR, datatype=DataType.BITS), + SimData(THERMO_ADDR[0], values=0.0, count=len(THERMO_ADDR), datatype=DataType.FLOAT32), + SimData(ALIVE_ADDR[0], values=0, count=len(ALIVE_ADDR), datatype=DataType.INT16), ], use_bit_addressing=True, action=self.catch_requests @@ -153,9 +154,10 @@ async def serve_forever(self): """Update thermometro reading, as well as a keepalive counter.""" Log.debug("updating_task: started") while True: - if self.serving.done() or self.serving.cancelled() or self.serving.exception(): + if self.serving.done() or self.serving.cancelled(): return await asyncio.sleep(1) + Log.debug("Update values.") if (sec := int(time())) >= self.last_keepalive +60: keepalive = cast(list[int], await self.server.async_getValues(DEVICE_ID, 3, ALIVE_ADDR[1], count=1)) @@ -169,28 +171,31 @@ async def serve_forever(self): regs.extend(client.convert_to_registers(value, data_type=client.DATATYPE.FLOAT32)) await self.server.async_setValues(DEVICE_ID, 0x16, THERMO_ADDR[0], regs) - async def shutdown(self, delayed): + async def shutdown(self, delayed): # pragma: no cover """Close server.""" if delayed: await asyncio.sleep(delayed) + Log.debug("Shutdown initiated.") if not self.serving.done(): self.serving.set_result(True) await asyncio.sleep(1) await self.server.shutdown() if self.server_task: - if not self.server_task.cancelled(): # pragma: no cover + if not self.server_task.cancelled(): self.server_task.cancel() with suppress(asyncio.CancelledError): await self.server_task async def run_updating_server(self): """Start updating_task concurrently with the current task.""" + Log.info("Starting tasks.") self.server_task = asyncio.create_task(self.server.serve_forever()) self.server_task.set_name("server task") await asyncio.sleep(1) shutdown_task: asyncio.Task | None = None - if self.test_time: + if self.test_time: # pragma: no cover shutdown_task = asyncio.create_task(self.shutdown(self.test_time)) + Log.debug("Forever loop.") await self.serve_forever() if shutdown_task: if not shutdown_task.cancelled(): # pragma: no cover diff --git a/test/examples/test_examples.py b/test/examples/test_examples.py index 17fc24d7f..16ac176b3 100755 --- a/test/examples/test_examples.py +++ b/test/examples/test_examples.py @@ -17,6 +17,7 @@ from examples.client_calls import template_call from examples.custom_msg import main as main_custom_client from examples.datastore_simulator_share import main as main_datastore_simulator_share3 +from examples.heatpump import main as run_heatpump from examples.message_parser import main as main_parse_messages from examples.package_test_tool import run_test as run_package_tool from examples.server_async import setup_server @@ -103,6 +104,10 @@ async def test_package_tool(self): """Run package test tool.""" await run_package_tool() + async def test_heatpump(self, use_port): + """Test client with custom message.""" + await run_heatpump(cmdline=["-p", str(use_port), "-t", "5"]) + @pytest.mark.parametrize( ("use_comm", "use_framer"), diff --git a/test/simulator/test_simdata.py b/test/simulator/test_simdata.py index 6acabae8e..0012ef071 100644 --- a/test/simulator/test_simdata.py +++ b/test/simulator/test_simdata.py @@ -149,7 +149,7 @@ def test_simdata_build_string(self, value, code, expect): ]) def test_simdata_build_bit_block(self, value, regs): """Test simdata value.""" - sd = SimData(0, values=value, datatype=DataType.BITS) + sd = SimData(11, values=value, datatype=DataType.BITS) build_regs = sd.build_registers(True) assert build_regs == regs