From 97ccfffccf4cebb4787ede77c675741fc558194f Mon Sep 17 00:00:00 2001 From: Cymaphore Date: Thu, 20 Jun 2024 09:05:31 +0200 Subject: [PATCH 1/9] autodiscovery.py: Change thermostat mode from 'heat' to 'auto' Change to cause the Thermostat to report mode 'auto'. This is done by setting auto as the only available mode, using the state topic as mode state topic and providing a static mode state topic enforcing state auto. This way the thermostat is properly visualised and not assumed to be always off. --- etrv2mqtt/autodiscovery.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/etrv2mqtt/autodiscovery.py b/etrv2mqtt/autodiscovery.py index ac483ed..6a9401b 100644 --- a/etrv2mqtt/autodiscovery.py +++ b/etrv2mqtt/autodiscovery.py @@ -25,7 +25,9 @@ class Autodiscovery(): "min_temp":"10", "max_temp":"40", "temp_step":"0.5", - "modes":["heat"], + "modes":["auto"], + "mode_state_topic":"etrv2mqtt/state", + "mode_state_template":"{{ 'auto' }}", "device": { "identifiers":"0000", "manufacturer": "Danfoss", @@ -139,6 +141,7 @@ def register_termostat(self, dev_name: str, dev_mac: str) -> AutodiscoveryResult autodiscovery_msg = self._autodiscovery_payload( self._termostat_template, dev_mac, dev_name, "Thermostat") autodiscovery_msg['~'] = self._config.mqtt.base_topic+'/'+dev_name + autodiscovery_msg['mode_state_topic'] = self._config.mqtt.base_topic + "/state" return AutodiscoveryResult(autodiscovery_topic, payload=json.dumps(autodiscovery_msg)) From 0a6f4ae5cb72f80dc52f6a4987398b3a8052fb01 Mon Sep 17 00:00:00 2001 From: Cymaphore Date: Thu, 20 Jun 2024 10:07:05 +0200 Subject: [PATCH 2/9] devices.py: Configurable polling, schedule for specified minute of every hour In order to get sensor readings at a more regular interval an option for polling at a certain minute of every hour was added. The polling strategy can be configured (option poll_schedule) and defaults to interval. Option poll_interval now only applies if poll_schedule=interval Option poll_hour_minute accepts a numeric minute For example, when configuring poll_schedule=hour_minute, poll_hour_minute=0 the thermostats will be polled every hour at minute 0. Further options can be added to poll_schedule in future to allow other scheduling strategies (like every two hours or at minute 0,30 or something) or even cron-like config. --- etrv2mqtt/config.py | 2 ++ etrv2mqtt/devices.py | 8 ++++++-- etrv2mqtt/schemas/config.schema.json | 17 +++++++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/etrv2mqtt/config.py b/etrv2mqtt/config.py index b6af599..5d4dc74 100644 --- a/etrv2mqtt/config.py +++ b/etrv2mqtt/config.py @@ -74,7 +74,9 @@ def __init__(self, filename: str): _config_json['mqtt']['hass_birth_payload'], ) self.retry_limit: int = _config_json['options']['retry_limit'] + self.poll_schedule: str = _config_json['options']['poll_schedule'] self.poll_interval: int = _config_json['options']['poll_interval'] + self.poll_hour_minute: int = _config_json['options']['poll_hour_minute'] self.stay_connected: bool = _config_json['options']['stay_connected'] self.report_room_temperature: bool = _config_json['options']['report_room_temperature'] self.setpoint_debounce_time: int = _config_json['options']['setpoint_debounce_time'] diff --git a/etrv2mqtt/devices.py b/etrv2mqtt/devices.py index 6db6b1e..daa272a 100644 --- a/etrv2mqtt/devices.py +++ b/etrv2mqtt/devices.py @@ -82,8 +82,12 @@ def _poll_devices(self): device.poll(self._mqtt) def poll_forever(self) -> NoReturn: - schedule.every(self._config.poll_interval).seconds.do( - self._poll_devices) + if self._config.poll_schedule == "hour_minute": + schedule.every().hour.at(":{0:02d}".format(self._config.poll_hour_minute)).do( + self._poll_devices) + else: # for poll_schedule=interval and as fallback + schedule.every(self._config.poll_interval).seconds.do( + self._poll_devices) mqtt_was_connected: bool = False while True: diff --git a/etrv2mqtt/schemas/config.schema.json b/etrv2mqtt/schemas/config.schema.json index 86cd086..ff3c5fe 100644 --- a/etrv2mqtt/schemas/config.schema.json +++ b/etrv2mqtt/schemas/config.schema.json @@ -71,12 +71,25 @@ "default": {}, "description": "Options common for all thermostats", "properties": { + "poll_schedule": { + "type": "string", + "description": "Polling schedule to use (regular interval, every hour at specified minute)", + "enum": [ "interval", "hour_minute" ], + "default": "interval" + }, "poll_interval": { "type":"integer", - "description": "Interval between thermostat data readouts in seconds", + "description": "Interval between thermostat data readouts in seconds (only used when poll_schedule is interval)", "minimum": 1, "default": 3600 }, + "poll_hour_minute": { + "type": "integer", + "description": "The minute of the hour polling starts (only used when poll_schedule is hour_minute", + "minimum": 0, + "maximum": 59, + "default": 0 + }, "retry_limit": { "type":"integer", "description": "Limit of BLE connect attempts", @@ -127,4 +140,4 @@ } } } -} \ No newline at end of file +} From 4970ada7522e16615979964d42c950121d0b0a01 Mon Sep 17 00:00:00 2001 From: Cymaphore Date: Fri, 21 Jun 2024 07:07:11 +0200 Subject: [PATCH 3/9] autodiscovery.py: Added state_class and entity_category Battery, Name and Last Updated report Entity Category 'diagnostic'. Battery and Temperature report State Class 'measurement' to make them available to the statistics system. --- etrv2mqtt/autodiscovery.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/etrv2mqtt/autodiscovery.py b/etrv2mqtt/autodiscovery.py index 6a9401b..f08cd62 100644 --- a/etrv2mqtt/autodiscovery.py +++ b/etrv2mqtt/autodiscovery.py @@ -41,12 +41,14 @@ class Autodiscovery(): _battery_template = json.loads(""" { + "entity_category": "diagnostic", "device_class": "battery", "name": "kitchen battery", "unique_id":"0000_battery", "state_topic": "etrv/kitchen/state", "value_template": "{{ value_json.battery }}", "unit_of_measurement": "%", + "state_class": "measurement", "device": { "identifiers":"0000", "manufacturer": "Danfoss", @@ -60,6 +62,7 @@ class Autodiscovery(): _reported_name_template = json.loads(""" { + "entity_category": "diagnostic", "name": "kitchen reported name", "unique_id":"0000_rep_name", "state_topic": "etrv/kitchen/state", @@ -83,6 +86,7 @@ class Autodiscovery(): "state_topic": "etrv/kitchen/state", "value_template": "{{ value_json.room_temp }}", "unit_of_measurement": "°C", + "state_class": "measurement", "device": { "identifiers":"0000", "manufacturer": "Danfoss", @@ -96,6 +100,7 @@ class Autodiscovery(): _last_update_template = json.loads(""" { + "entity_category": "diagnostic", "device_class": "timestamp", "name": "Kitchen Last Update", "unique_id":"0000_last_update", From 46f16f74734efa1f59c6e47b246a53aacaf666be Mon Sep 17 00:00:00 2001 From: Cymaphore Date: Fri, 21 Jun 2024 09:16:16 +0200 Subject: [PATCH 4/9] autodiscovery.py: Added suggested_display_precision Added suggested display precision to improve default display behaviour. temperature: 1 decimal (device resolution is 0.5K) battery: 0 decimals (not an accurate measurement, doesn't require decimals) thermostat: 1 decimal (same as temperature reading, res 0.5K) --- etrv2mqtt/autodiscovery.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/etrv2mqtt/autodiscovery.py b/etrv2mqtt/autodiscovery.py index f08cd62..53b715c 100644 --- a/etrv2mqtt/autodiscovery.py +++ b/etrv2mqtt/autodiscovery.py @@ -25,6 +25,7 @@ class Autodiscovery(): "min_temp":"10", "max_temp":"40", "temp_step":"0.5", + "suggested_display_precision": "1", "modes":["auto"], "mode_state_topic":"etrv2mqtt/state", "mode_state_template":"{{ 'auto' }}", @@ -48,6 +49,7 @@ class Autodiscovery(): "state_topic": "etrv/kitchen/state", "value_template": "{{ value_json.battery }}", "unit_of_measurement": "%", + "suggested_display_precision": "0", "state_class": "measurement", "device": { "identifiers":"0000", @@ -86,6 +88,7 @@ class Autodiscovery(): "state_topic": "etrv/kitchen/state", "value_template": "{{ value_json.room_temp }}", "unit_of_measurement": "°C", + "suggested_display_precision": "1", "state_class": "measurement", "device": { "identifiers":"0000", From 92c2f6ca194db90e7c89f45d6940ab4b451a7144 Mon Sep 17 00:00:00 2001 From: Cymaphore Date: Fri, 21 Jun 2024 22:40:54 +0200 Subject: [PATCH 5/9] bugfix: devices.py: Unhandled exception on weak connections kills scheduler Very weak connections can cause bluepy-helper to semi-crash and stop the scheduler. This commit catches the exception and tries to close the remaining connection (will fail gracefully in case it was completely broken). --- etrv2mqtt/devices.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/etrv2mqtt/devices.py b/etrv2mqtt/devices.py index daa272a..8a1eb27 100644 --- a/etrv2mqtt/devices.py +++ b/etrv2mqtt/devices.py @@ -47,8 +47,14 @@ def poll(self, mqtt: Mqtt): if self._stay_connected == False: self._device.disconnect() + except btle.BTLEInternalError as e: + logger.error(e) + if self._device.is_connected(): + self._device.disconnect() except btle.BTLEDisconnectError as e: logger.error(e) + if self._device.is_connected(): + self._device.disconnect() def set_temperature(self, mqtt: Mqtt, temperature: float): try: From 78912c3ded448aebd8fd52b0c68d5057fa70fb38 Mon Sep 17 00:00:00 2001 From: Cymaphore Date: Fri, 21 Jun 2024 23:33:19 +0200 Subject: [PATCH 6/9] devices.py: Option to retry polling for thermostats with weak connection Sometimes thermostats with weak connection can be sucessfully polled after a short while. This commit adds the option 'retry_rerun' for such an instance. In case polling retries fail at the first attempt, another attempt is started after finishing all other sensors. The option effectively doubles the number of retry attempts. The option defaults to 'False' for backward compatibility. --- etrv2mqtt/devices.py | 12 +++++++++++- etrv2mqtt/schemas/config.schema.json | 5 +++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/etrv2mqtt/devices.py b/etrv2mqtt/devices.py index 8a1eb27..0c1ecbf 100644 --- a/etrv2mqtt/devices.py +++ b/etrv2mqtt/devices.py @@ -47,6 +47,7 @@ def poll(self, mqtt: Mqtt): if self._stay_connected == False: self._device.disconnect() + return True except btle.BTLEInternalError as e: logger.error(e) if self._device.is_connected(): @@ -55,6 +56,7 @@ def poll(self, mqtt: Mqtt): logger.error(e) if self._device.is_connected(): self._device.disconnect() + return False def set_temperature(self, mqtt: Mqtt, temperature: float): try: @@ -84,8 +86,16 @@ def __init__(self, config: Config, deviceClass: Type[DeviceBase]): self._mqtt.hass_birth_callback = self._hass_birth_callback def _poll_devices(self): + rerun = [] for device in self._devices.values(): - device.poll(self._mqtt) + if not device.poll(self._mqtt): + if self._config.retry_rerun: + rerun.append(device) + if len(rerun)>0: + logger.warning("Attempting re-run of failed sensors in 5 seconds") + time.sleep(5) + for device in rerun: + device.poll(self._mqtt) def poll_forever(self) -> NoReturn: if self._config.poll_schedule == "hour_minute": diff --git a/etrv2mqtt/schemas/config.schema.json b/etrv2mqtt/schemas/config.schema.json index ff3c5fe..52124aa 100644 --- a/etrv2mqtt/schemas/config.schema.json +++ b/etrv2mqtt/schemas/config.schema.json @@ -96,6 +96,11 @@ "minimum": 0, "default": 5 }, + "retry_rerun": { + "type": "boolean", + "description": "Retry failed thermostats once again after the end of the queue", + "default": true + }, "stay_connected": { "type": "boolean", "description": "Set to true in order to leave BLE connection running after polling thermostat data or setting temperature. May drain battery.", From 8db806661ab6647c038883b2428b15c1b4408321 Mon Sep 17 00:00:00 2001 From: Cymaphore Date: Sat, 22 Jun 2024 12:03:23 +0200 Subject: [PATCH 7/9] config.py: Adding Option 'retry_rerun' Sorry, this file was missed from the previous commit 78912c3ded448aebd8fd52b0c68d5057fa70fb38 --- etrv2mqtt/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/etrv2mqtt/config.py b/etrv2mqtt/config.py index 5d4dc74..63f4923 100644 --- a/etrv2mqtt/config.py +++ b/etrv2mqtt/config.py @@ -74,6 +74,7 @@ def __init__(self, filename: str): _config_json['mqtt']['hass_birth_payload'], ) self.retry_limit: int = _config_json['options']['retry_limit'] + self.retry_rerun: bool = _config_json['options']['retry_rerun'] self.poll_schedule: str = _config_json['options']['poll_schedule'] self.poll_interval: int = _config_json['options']['poll_interval'] self.poll_hour_minute: int = _config_json['options']['poll_hour_minute'] From 45e3bfac86d71eaf49cc059022cbfbf7278208dd Mon Sep 17 00:00:00 2001 From: Cymaphore Date: Wed, 3 Jul 2024 10:38:52 +0200 Subject: [PATCH 8/9] devices.py: Disable Bluetooth when idle (avoid battery draining) Bad connections can cause bluetooth connections to stay open even when the polling run is finished. Seemingly the valves can be forced to timeout and disconnect by disabling BT alltogether. This commit provides a crude and simple approach by calling rfkill bind/unbind. Not the best way, but seems to work. Marked as experimental due to the lack of long term results (yet). New config option: idle_block_ble (default false) can be used to enable bluetooth disabling between polling cycles. --- etrv2mqtt/config.py | 1 + etrv2mqtt/devices.py | 25 ++++++++++++++++++++++++- etrv2mqtt/schemas/config.schema.json | 5 +++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/etrv2mqtt/config.py b/etrv2mqtt/config.py index 63f4923..244d8f1 100644 --- a/etrv2mqtt/config.py +++ b/etrv2mqtt/config.py @@ -75,6 +75,7 @@ def __init__(self, filename: str): ) self.retry_limit: int = _config_json['options']['retry_limit'] self.retry_rerun: bool = _config_json['options']['retry_rerun'] + self.idle_block_ble: bool = _config_json['options']['idle_block_ble'] self.poll_schedule: str = _config_json['options']['poll_schedule'] self.poll_interval: int = _config_json['options']['poll_interval'] self.poll_hour_minute: int = _config_json['options']['poll_hour_minute'] diff --git a/etrv2mqtt/devices.py b/etrv2mqtt/devices.py index 0c1ecbf..fb21b46 100644 --- a/etrv2mqtt/devices.py +++ b/etrv2mqtt/devices.py @@ -8,12 +8,14 @@ from etrv2mqtt.etrvutils import eTRVUtils from etrv2mqtt.mqtt import Mqtt from typing import Type, Dict, NoReturn +import os import schedule class DeviceBase(ABC): def __init__(self, thermostat_config: ThermostatConfig, config: Config): super().__init__() + self._config = config @abstractmethod def poll(self, mqtt: Mqtt): @@ -23,7 +25,6 @@ def poll(self, mqtt: Mqtt): def set_temperature(self, mqtt: Mqtt, temperature: float): pass - class TRVDevice(DeviceBase): def __init__(self, thermostat_config: ThermostatConfig, config: Config): super().__init__(thermostat_config, config) @@ -65,10 +66,18 @@ def set_temperature(self, mqtt: Mqtt, temperature: float): if not self._device.is_connected(): self._device.connect() eTRVUtils.set_temperature(self._device, temperature) + if self._stay_connected == False: + self._device.disconnect() # Home assistant needs to see updated temperature value to confirm change self.poll(mqtt) except btle.BTLEDisconnectError as e: logger.error(e) + if self._device.is_connected(): + self._device.disconnect() + except btle.BTLEInternalError as e: + logger.error(e) + if self._device.is_connected(): + self._device.disconnect() class DeviceManager(): @@ -86,6 +95,7 @@ def __init__(self, config: Config, deviceClass: Type[DeviceBase]): self._mqtt.hass_birth_callback = self._hass_birth_callback def _poll_devices(self): + self.ble_on() rerun = [] for device in self._devices.values(): if not device.poll(self._mqtt): @@ -96,6 +106,7 @@ def _poll_devices(self): time.sleep(5) for device in rerun: device.poll(self._mqtt) + self.ble_off() def poll_forever(self) -> NoReturn: if self._config.poll_schedule == "hour_minute": @@ -120,7 +131,9 @@ def poll_forever(self) -> NoReturn: time.sleep(2) def _set_temerature_task(self, device: DeviceBase, temperature: float): + self.ble_on() device.set_temperature(self._mqtt, temperature) + self.ble_off() # this will cause the task to be executed only once return schedule.CancelJob @@ -140,3 +153,13 @@ def _set_temperature_callback(self, mqtt: Mqtt, name: str, temperature: float): def _hass_birth_callback(self, mqtt: Mqtt): schedule.run_all(delay_seconds=1) + + def ble_off(self): + if self._config.idle_block_ble: + logger.info("Disable Bluetooth") + os.system("sudo rfkill block bluetooth") + + def ble_on(self): + if self._config.idle_block_ble: + logger.info("Enable Bluetooth") + os.system("sudo rfkill unblock bluetooth") diff --git a/etrv2mqtt/schemas/config.schema.json b/etrv2mqtt/schemas/config.schema.json index 52124aa..09515cb 100644 --- a/etrv2mqtt/schemas/config.schema.json +++ b/etrv2mqtt/schemas/config.schema.json @@ -106,6 +106,11 @@ "description": "Set to true in order to leave BLE connection running after polling thermostat data or setting temperature. May drain battery.", "default": false }, + "idle_block_ble": { + "type": "boolean", + "description": "Disable Bluetooth when idle (requires sudo permissions for rfkill) - EXPERIMENTAL, avoid battery draining", + "default": false + }, "report_room_temperature": { "type": "boolean", "description": "Set to false to disable reporting current room temperature as a separate Home Assistant sensor in MQTT auto discovery", From 67d3a607e52b3566c2e7aa428e958ebef515b0d8 Mon Sep 17 00:00:00 2001 From: Cymaphore Date: Thu, 5 Sep 2024 00:18:01 +0200 Subject: [PATCH 9/9] mqtt.py: Option to retain device data Adding the configuration option 'device_data_retain' to allow published device data messages to retain in the broker similar to autodiscovery messages. Defaults to false for backward compatibility. --- etrv2mqtt/config.py | 2 ++ etrv2mqtt/mqtt.py | 2 +- etrv2mqtt/schemas/config.schema.json | 5 +++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/etrv2mqtt/config.py b/etrv2mqtt/config.py index 244d8f1..eea8f5c 100644 --- a/etrv2mqtt/config.py +++ b/etrv2mqtt/config.py @@ -23,6 +23,7 @@ class _MQTTConfig: autodiscovery: bool autodiscovery_topic: str autodiscovery_retain: bool + device_data_retain: bool hass_birth_topic: str hass_birth_payload: str @@ -70,6 +71,7 @@ def __init__(self, filename: str): _config_json['mqtt']['autodiscovery'], _config_json['mqtt']['autodiscovery_topic'], _config_json['mqtt']['autodiscovery_retain'], + _config_json['mqtt']['device_data_retain'], _config_json['mqtt']['hass_birth_topic'], _config_json['mqtt']['hass_birth_payload'], ) diff --git a/etrv2mqtt/mqtt.py b/etrv2mqtt/mqtt.py index bff4373..8f62d1b 100644 --- a/etrv2mqtt/mqtt.py +++ b/etrv2mqtt/mqtt.py @@ -38,7 +38,7 @@ def __init__(self, config: Config): def publish_device_data(self, name: str, data: str): if self._client.is_connected(): self._client.publish( - self._config.mqtt.base_topic+'/'+name+'/state', payload=data) + self._config.mqtt.base_topic+'/'+name+'/state', payload=data, retain=self._config.mqtt.device_data_retain) def _publish_autodiscovery_result(self, result: AutodiscoveryResult, retain: bool = False): self._client.publish( diff --git a/etrv2mqtt/schemas/config.schema.json b/etrv2mqtt/schemas/config.schema.json index 09515cb..a9c417a 100644 --- a/etrv2mqtt/schemas/config.schema.json +++ b/etrv2mqtt/schemas/config.schema.json @@ -54,6 +54,11 @@ "default": true, "description": "Set retain bit on autodiscovery related messages" }, + "device_data_retain": { + "type":"boolean", + "default": false, + "description": "Set retain bit on device data messages" + }, "hass_birth_topic": { "type": "string", "default": "hass/status",