From 0d8b2fc2c560a21341e4374000084b86c66a31aa Mon Sep 17 00:00:00 2001 From: Guzz-T Date: Sun, 15 Mar 2026 15:06:44 +0100 Subject: [PATCH 1/8] Log a successor or outdated names only once per definition instance --- luxtronik/definitions/__init__.py | 30 +++++++++++++++++------------- tests/test_definitions.py | 1 - 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/luxtronik/definitions/__init__.py b/luxtronik/definitions/__init__.py index 1b21ec33..5d1c7ec6 100644 --- a/luxtronik/definitions/__init__.py +++ b/luxtronik/definitions/__init__.py @@ -69,6 +69,8 @@ def __init__(self, data_dict, type_name, offset, data_type=""): that have been checked for correctness using pytest. This eliminates the need for type tests here. """ + self.report_successor = True + self.report_outdated_name = True try: data_dict = self.DEFAULT_DATA | data_dict index = int(data_dict["index"]) @@ -194,11 +196,7 @@ def names(self): @property def successor(self): - # Clear the successor after first use, - # not to generate a lot of warnings - s = self._successor - self._successor = None - return s + return self._successor @property def name(self): @@ -301,12 +299,12 @@ def get(self, name_or_idx, default=None): if d is None: LOGGER.debug(f"Definition for '{name_or_idx}' not found") else: - # The successor is returned only once for each definition, - # not to generate a lot of warnings - successor = d.successor - if successor is not None: - LOGGER.warning(f"Definition for '{name_or_idx}' is outdated and will " \ - + f"be removed soon! Please use '{successor}' instead.") + # Report a successor only once per definition instance + # to avoid generating too many warnings + if d.successor is not None and d.report_successor: + d.report_successor = False + LOGGER.warning(f"'{d.type_name}' definition for '{name_or_idx}' is outdated and will " \ + + f"be removed soon! Please use '{d.successor}' instead.") return d if d is not None else default def _get(self, name_or_idx): @@ -362,8 +360,14 @@ def _get_definition_by_name(self, name): If multiple definitions added for the same name, the last added takes precedence. """ definition = self._name_dict.get(name.lower(), None) - if definition is not None and definition.valid and name.lower() != definition.name.lower(): - LOGGER.warning(f"'{name}' is outdated! Use '{definition.name}' instead.") + # Report an outdated name only once per definition instance + # to avoid generating too many warnings + if definition is not None and definition.valid \ + and name.lower() != definition.name.lower() \ + and definition.report_outdated_name: + definition.report_outdated_name = False + LOGGER.warning(f"'{definition.type_name}' name '{name}' is outdated! " \ + + f"Use '{definition.name}' instead.") return definition diff --git a/tests/test_definitions.py b/tests/test_definitions.py index fc0f4cc5..f493ab1c 100644 --- a/tests/test_definitions.py +++ b/tests/test_definitions.py @@ -38,7 +38,6 @@ def test_init(self): assert definition.names == names assert definition.name == names[0] assert definition.successor == 'abcd' - assert definition.successor is None assert definition.data_type == 'INT16' assert definition.valid assert definition From 388ca2cf5265ba89b01281447d64df4db9b0736a Mon Sep 17 00:00:00 2001 From: Guzz-T Date: Sun, 15 Mar 2026 15:13:08 +0100 Subject: [PATCH 2/8] Use FINAL for each constant value --- luxtronik/cfi/constants.py | 2 +- luxtronik/constants.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/luxtronik/cfi/constants.py b/luxtronik/cfi/constants.py index ade20989..81357030 100644 --- a/luxtronik/cfi/constants.py +++ b/luxtronik/cfi/constants.py @@ -26,7 +26,7 @@ # Wait time (in seconds) after writing parameters to give controller # some time to re-calculate values, etc. -WAIT_TIME_AFTER_PARAMETER_WRITE = 1 +WAIT_TIME_AFTER_PARAMETER_WRITE: Final = 1 # The data from the config interface are transmitted in 32-bit chunks. LUXTRONIK_CFI_REGISTER_BIT_SIZE: Final = 32 \ No newline at end of file diff --git a/luxtronik/constants.py b/luxtronik/constants.py index 23398081..fe297d3a 100644 --- a/luxtronik/constants.py +++ b/luxtronik/constants.py @@ -20,4 +20,4 @@ LUXTRONIK_32BIT_FUNCTION_NOT_AVAILABLE: Final = 0x7FFFFFFF # If True, preserve the last set field value on clear and assign `None` to raw -LUXTRONIK_PRESERVE_LAST_VALUE = True +LUXTRONIK_PRESERVE_LAST_VALUE: Final = True From ee89d1d08499942d9161a2efcdcf42e91e1ec657 Mon Sep 17 00:00:00 2001 From: Guzz-T Date: Sun, 15 Mar 2026 15:54:42 +0100 Subject: [PATCH 3/8] Provide similar logger.info messages for CFI and SHI --- luxtronik/cfi/interface.py | 7 +++++-- luxtronik/shi/interface.py | 19 +++++++++++++++++++ luxtronik/shi/modbus.py | 5 ++++- tests/fake/fake_modbus.py | 2 ++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/luxtronik/cfi/interface.py b/luxtronik/cfi/interface.py index 78dad769..e7ebf6e9 100644 --- a/luxtronik/cfi/interface.py +++ b/luxtronik/cfi/interface.py @@ -76,7 +76,7 @@ def _with_lock_and_connect(self, func, *args, **kwargs): ret_val = None with socket.create_connection((self._host, self._port)) as sock: self._socket = sock - LOGGER.info("Connected to Luxtronik heat pump %s:%s", self._host, self._port) + LOGGER.info("Connected to CFI of Luxtronik heat pump %s:%s", self._host, self._port) ret_val = func(*args, **kwargs) except socket.gaierror as e: LOGGER.error("Failed to connect to Luxtronik heat pump %s:%s. %s.", @@ -166,6 +166,7 @@ def _write(self, parameters): if not isinstance(parameters, Parameters): LOGGER.error("Only parameters are writable!") return + count = 0 for definition, field in parameters.items(): if field.write_pending: field.write_pending = False @@ -178,12 +179,14 @@ def _write(self, parameters): value, ) continue - LOGGER.info("%s: Parameter '%d' set to '%s'", self._host, definition.index, value) + LOGGER.debug("%s: Parameter '%d' set to '%s'", self._host, definition.index, value) self._send_ints(LUXTRONIK_PARAMETERS_WRITE, definition.index, value) cmd = self._read_int() LOGGER.debug("%s: Command %s", self._host, cmd) val = self._read_int() LOGGER.debug("%s: Value %s", self._host, val) + count += 1 + LOGGER.info("%s: Write %d parameters", self._host, count) # Give the heatpump a short time to handle the value changes/calculations: time.sleep(WAIT_TIME_AFTER_PARAMETER_WRITE) diff --git a/luxtronik/shi/interface.py b/luxtronik/shi/interface.py index b7469e16..4b35c787 100644 --- a/luxtronik/shi/interface.py +++ b/luxtronik/shi/interface.py @@ -506,6 +506,25 @@ def _send_and_integrate(self, blocks_list): # Send all telegrams. The retrieved data is returned within the telegrams telegrams = [data[1] for data in telegrams_data] success = self._interface.send(telegrams) + # Report send/received data + count = {'hr': 0, 'hw': 0, 'ir': 0, 'u': 0} + for t in telegrams: + if isinstance(t, LuxtronikSmartHomeReadHoldingsTelegram): + count['hr'] += t.count + elif isinstance(t, LuxtronikSmartHomeReadInputsTelegram): + count['ir'] += t.count + elif isinstance(t, LuxtronikSmartHomeWriteHoldingsTelegram): + count['hw'] += t.count + else: + count['u'] += t.count + if count['hr'] > 0: + LOGGER.info(f"{self._interface._host}: Read {count['hr']} holdings") + if count['ir'] > 0: + LOGGER.info(f"{self._interface._host}: Read {count['ir']} inputs") + if count['hw'] > 0: + LOGGER.info(f"{self._interface._host}: Write {count['hw']} holdings") + if count['u'] > 0: + LOGGER.info(f"{self._interface._host}: Write {count['u']} unknowns?") # Transfer the data from the telegrams into the fields success &= self._integrate_data(telegrams_data) return success diff --git a/luxtronik/shi/modbus.py b/luxtronik/shi/modbus.py index e897c704..a298c967 100644 --- a/luxtronik/shi/modbus.py +++ b/luxtronik/shi/modbus.py @@ -53,6 +53,8 @@ def __init__( self._lock = get_host_lock(host) # Create the Modbus client (connection is not opened/closed automatically) + self._host = host + self._port = port self._client = ModbusClient( host=host, port=port, @@ -86,6 +88,8 @@ def _connect(self): + f"{self._client.last_error_as_txt}") self._client.close() return False + else: + LOGGER.info(f"Connected to SHI of Luxtronik heat pump {self._host}:{self._port}") return True @@ -295,7 +299,6 @@ def send(self, telegrams): # Exit the function if no operation is necessary if total_count <= 0: - LOGGER.warning("No data requested/provided. Abort operation.") return False # Acquire lock, connect and read/write data. Disconnect afterwards. diff --git a/tests/fake/fake_modbus.py b/tests/fake/fake_modbus.py index 9734a29d..a456acc4 100644 --- a/tests/fake/fake_modbus.py +++ b/tests/fake/fake_modbus.py @@ -6,6 +6,8 @@ class FakeModbus: result = True def __init__(self, host="", port="", timeout=0): + self._host = host + self._port = port self._connected = False self._blocking = False From 73ee841860a6cc5b44097e262571a3eb99687d9a Mon Sep 17 00:00:00 2001 From: Guzz-T Date: Sun, 15 Mar 2026 22:06:17 +0100 Subject: [PATCH 4/8] Add new data fields discovered and already used by the HA luxtronik integration --- luxtronik/definitions/calculations.py | 6 ++++++ luxtronik/definitions/parameters.py | 28 +++++++++++++++++++++++---- tests/test_compatibility.py | 5 +++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/luxtronik/definitions/calculations.py b/luxtronik/definitions/calculations.py index a35b17ba..4b0d8140 100644 --- a/luxtronik/definitions/calculations.py +++ b/luxtronik/definitions/calculations.py @@ -2804,6 +2804,12 @@ "unit": '°C/10', "description": '', }, + { + "index": 268, + "names": ['Unknown_Calculation_268'], + "type": Unknown, + "successor": 'AC_Power_Input', + }, { "index": 268, "count": 1, diff --git a/luxtronik/definitions/parameters.py b/luxtronik/definitions/parameters.py index 65423395..2831763d 100644 --- a/luxtronik/definitions/parameters.py +++ b/luxtronik/definitions/parameters.py @@ -11892,23 +11892,43 @@ }, { "index": 1175, - "names": ['Unknown_Parameter_1175'], + "count": 1, + "names": ['THERMAL_POWER_LIMIT_SWITCH', 'Unknown_Parameter_1175'], "type": Unknown, + "writeable": False, + "datatype": 'UINT32', + "unit": '', + "description": '', }, { "index": 1176, - "names": ['Unknown_Parameter_1176'], + "count": 1, + "names": ['THERMAL_POWER_LIMIT_HEATING', 'Unknown_Parameter_1176'], "type": Unknown, + "writeable": False, + "datatype": 'UINT32', + "unit": '', + "description": '', }, { "index": 1177, - "names": ['Unknown_Parameter_1177'], + "count": 1, + "names": ['THERMAL_POWER_LIMIT_WATER', 'Unknown_Parameter_1177'], "type": Unknown, + "writeable": False, + "datatype": 'UINT32', + "unit": '', + "description": '', }, { "index": 1178, - "names": ['Unknown_Parameter_1178'], + "count": 1, + "names": ['THERMAL_POWER_LIMIT_COOLING', 'Unknown_Parameter_1178'], "type": Unknown, + "writeable": False, + "datatype": 'UINT32', + "unit": '', + "description": '', }, { "index": 1179, diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py index 100aaf5f..947a1ae7 100644 --- a/tests/test_compatibility.py +++ b/tests/test_compatibility.py @@ -1302,9 +1302,13 @@ def test_compatibilities(self): "Unknown_Parameter_1173": (1173, Unknown), "Unknown_Parameter_1174": (1174, Unknown), "Unknown_Parameter_1175": (1175, Unknown), + "THERMAL_POWER_LIMIT_SWITCH": (1175, Unknown), "Unknown_Parameter_1176": (1176, Unknown), + "THERMAL_POWER_LIMIT_HEATING": (1176, Unknown), "Unknown_Parameter_1177": (1177, Unknown), + "THERMAL_POWER_LIMIT_WATER": (1177, Unknown), "Unknown_Parameter_1178": (1178, Unknown), + "THERMAL_POWER_LIMIT_COOLING": (1178, Unknown), "Unknown_Parameter_1179": (1179, Unknown), "Unknown_Parameter_1180": (1180, Unknown), "Unknown_Parameter_1181": (1181, Unknown), @@ -1610,6 +1614,7 @@ def test_compatibilities(self): "Unknown_Calculation_265": (265, Unknown), "Unknown_Calculation_266": (266, Unknown), "Desired_Room_Temperature": (267, Celsius), + "Unknown_Calculation_268": (268, Unknown), "AC_Power_Input": (268, Power), "Unknown_Calculation_269": (269, Unknown), "Unknown_Calculation_270": (270, Unknown), From 94e1d03f75fca79f89822652207273da6d527025 Mon Sep 17 00:00:00 2001 From: Guzz-T Date: Tue, 17 Mar 2026 22:05:54 +0100 Subject: [PATCH 5/8] Note new data fields within the CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38187e45..3cb14a16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1328,6 +1328,10 @@ from `Unknown`to `Energy` - Add `POWER_LIMIT_SWITCH` (index `1158`) of type `Unknown` - Add `Unknown_Parameter_1159` (index `1159`) of type `Unknown` - Add `POWER_LIMIT_VALUE` (index `1159`) of type `Unknown` +- Add `THERMAL_POWER_LIMIT_SWITCH` (index `1175`) of type `Unknown` +- Add `THERMAL_POWER_LIMIT_HEATING` (index `1176`) of type `Unknown` +- Add `THERMAL_POWER_LIMIT_WATER` (index `1177`) of type `Unknown` +- Add `THERMAL_POWER_LIMIT_COOLING` (index `1178`) of type `Unknown` ### Calculations @@ -1372,6 +1376,7 @@ from `IPAddress`to `IPv4Address` - Add `Unknown_Calculation_265` (index `265`) of type `Unknown` - Add `Unknown_Calculation_266` (index `266`) of type `Unknown` - Add `Desired_Room_Temperature` (index `267`) of type `Celsius` +- Add `Unknown_Calculation_268` (index `268`) of type `Unknown` - Add `AC_Power_Input` (index `268`) of type `Power` ### Visibilities From f55bbbb43a2d41cbc6d5468cbfa23278f9e67f96 Mon Sep 17 00:00:00 2001 From: Guzz-T Date: Tue, 17 Mar 2026 18:56:06 +0100 Subject: [PATCH 6/8] Return True if there is no modbus data to write --- luxtronik/shi/modbus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/luxtronik/shi/modbus.py b/luxtronik/shi/modbus.py index a298c967..43bd153d 100644 --- a/luxtronik/shi/modbus.py +++ b/luxtronik/shi/modbus.py @@ -299,7 +299,7 @@ def send(self, telegrams): # Exit the function if no operation is necessary if total_count <= 0: - return False + return True # Acquire lock, connect and read/write data. Disconnect afterwards. success = False From c974753cc64a0c23281992020bf07e1904697cda Mon Sep 17 00:00:00 2001 From: Guzz-T Date: Tue, 17 Mar 2026 22:14:26 +0100 Subject: [PATCH 7/8] Adapt pytest to new modbus behavior --- tests/shi/test_shi_modbus.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/shi/test_shi_modbus.py b/tests/shi/test_shi_modbus.py index 1d0145ee..3f6b1442 100644 --- a/tests/shi/test_shi_modbus.py +++ b/tests/shi/test_shi_modbus.py @@ -157,7 +157,7 @@ def test_no_holdings_read_data(self): # Read zero holdings result = self.modbus_interface.send(data_list) - assert not result + assert result # no error when there is no data assert data_list[0].data == [] assert data_list[1].data == [] @@ -167,7 +167,7 @@ def test_no_holdings_write_data(self): # Write zero holdings result = self.modbus_interface.send(data_list) - assert not result + assert result # no error when there is no data def test_no_inputs_read_data(self): @@ -175,7 +175,7 @@ def test_no_inputs_read_data(self): # Read zero inputs result = self.modbus_interface.send(data_list) - assert not result + assert result # no error when there is no data assert data_list[0].data == [] assert data_list[1].data == [] @@ -185,7 +185,7 @@ def test_no_inputs_read_data(self): [ (1, 2, True, [1, 2]), (5, 3, True, [5, 6, 7]), - (0, 0, False, []), + (0, 0, True, []), # no error when there is no data (1000, 2, False, None), # client has read error (1001, 3, False, None), # client returns to less data (1002, 4, False, None), # client returns to much data @@ -213,7 +213,7 @@ def test_read_holdings(self, addr, count, valid, data): [ (1, 2, True, [1, 2]), (5, 3, True, [5, 6, 7]), - (0, 0, False, []), + (0, 0, True, []), # no error when there is no data (1000, 2, False, None), # client has read error (1001, 3, False, None), # client returns to less data (1002, 4, False, None), # client returns to much data @@ -241,7 +241,7 @@ def test_read_inputs(self, addr, count, valid, data): [ (1, [1, 2], True), (5, [5, 6, 7], True), - (0, [], False), + (0, [], True), # no error when there is no data (1000, [8, 9], False), # Write error (1001, [11], False), # Exception ] From bb8208a0860b188afdef66eddb505ab2499cee18 Mon Sep 17 00:00:00 2001 From: Guzz-T Date: Sun, 15 Mar 2026 19:14:55 +0100 Subject: [PATCH 8/8] Add preliminary calculations fields for 244 and 252 --- luxtronik/definitions/calculations.py | 22 ++++++++++++++++++++++ tests/test_compatibility.py | 3 +++ 2 files changed, 25 insertions(+) diff --git a/luxtronik/definitions/calculations.py b/luxtronik/definitions/calculations.py index 4b0d8140..0bab3be5 100644 --- a/luxtronik/definitions/calculations.py +++ b/luxtronik/definitions/calculations.py @@ -2558,6 +2558,17 @@ "unit": 'K/10', "description": '', }, + { + "index": 244, + "count": 1, + "names": ['flow_temp_limit'], + "type": Celsius, + "writeable": False, + "datatype": 'UINT32', + "unit": '°C/10', + "since": "3.88.0", + "description": 'PRELIMINARY: Max supply (flow) temperature setpoint', + }, { "index": 244, "count": 1, @@ -2638,6 +2649,17 @@ "unit": '', "description": '', }, + { + "index": 252, + "count": 1, + "names": ['hot_gas_temp_limit'], + "type": Celsius, + "writeable": False, + "datatype": 'UINT32', + "unit": '°C/10', + "since": "3.88.0", + "description": 'PRELIMINARY: Max hot-gas (discharge) cut-off setpoint', + }, { "index": 252, "count": 1, diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py index 947a1ae7..5925d85e 100644 --- a/tests/test_compatibility.py +++ b/tests/test_compatibility.py @@ -1622,6 +1622,9 @@ def test_compatibilities(self): "Unknown_Calculation_272": (272, Unknown), "Unknown_Calculation_273": (273, Unknown), "Unknown_Calculation_274": (274, Unknown), + # New in 'main' branch (after next release): + "flow_temp_limit": (244, Celsius), + "hot_gas_temp_limit": (252, Celsius), } visis = {