diff --git a/etrv2mqtt/autodiscovery.py b/etrv2mqtt/autodiscovery.py index f78e5fc..3733346 100644 --- a/etrv2mqtt/autodiscovery.py +++ b/etrv2mqtt/autodiscovery.py @@ -12,12 +12,16 @@ class AutodiscoveryResult(): class Autodiscovery(): - _termostat_template = json.loads(""" + _thermostat_template = json.loads(""" { "~": "etrv/kitchen", "name":"Kitchen", "unique_id":"0000_thermostat", - "temp_cmd_t":"~/set", + "hold_command_topic":"~/set/preset_mode", + "hold_state_topic":"~/state", + "hold_state_template":"{{ value_json.preset_mode }}", + "hold_modes":["Manual","Scheduled","Vacation"], + "temp_cmd_t":"~/set/temperature", "temp_stat_t":"~/state", "temp_stat_tpl":"{{ value_json.set_point }}", "curr_temp_t":"~/state", @@ -133,12 +137,12 @@ def _autodiscovery_payload(self, template: dict, dev_mac: str, dev_name: str, se payload['availability_topic'] = self._config.mqtt.base_topic + "/state" return payload - def register_termostat(self, dev_name: str, dev_mac: str) -> AutodiscoveryResult: + def register_thermostat(self, dev_name: str, dev_mac: str) -> AutodiscoveryResult: autodiscovery_topic = self._autodiscovery_topic( dev_mac, 'climate', 'thermostat') autodiscovery_msg = self._autodiscovery_payload( - self._termostat_template, dev_mac, dev_name, "Thermostat") + self._thermostat_template, dev_mac, dev_name, "Thermostat") autodiscovery_msg['~'] = self._config.mqtt.base_topic+'/'+dev_name return AutodiscoveryResult(autodiscovery_topic, payload=json.dumps(autodiscovery_msg)) diff --git a/etrv2mqtt/devices.py b/etrv2mqtt/devices.py index 6db6b1e..ca27c1f 100644 --- a/etrv2mqtt/devices.py +++ b/etrv2mqtt/devices.py @@ -23,6 +23,10 @@ def poll(self, mqtt: Mqtt): def set_temperature(self, mqtt: Mqtt, temperature: float): pass + @abstractmethod + def set_mode(self, mqtt: Mqtt, mode: bytes): + pass + class TRVDevice(DeviceBase): def __init__(self, thermostat_config: ThermostatConfig, config: Config): @@ -62,6 +66,20 @@ def set_temperature(self, mqtt: Mqtt, temperature: float): except btle.BTLEDisconnectError as e: logger.error(e) + def set_mode(self, mqtt: Mqtt, mode: bytes): + try: + logger.info("Setting {} to {}", self._name, mode) + + if not self._device.is_connected(): + self._device.connect() + eTRVUtils.set_mode(self._device, mode) + # Home assistant needs to see updated settings value to confirm change + self.poll(mqtt) + except btle.BTLEDisconnectError as e: + logger.error(e) + except KeyError as e: + logger.warning("Invalid preset mode: {}", mode) + class DeviceManager(): def __init__(self, config: Config, deviceClass: Type[DeviceBase]): @@ -75,6 +93,7 @@ def __init__(self, config: Config, deviceClass: Type[DeviceBase]): self._mqtt = Mqtt(self._config) self._mqtt.set_temperature_callback = self._set_temperature_callback + self._mqtt.set_mode_callback = self._set_mode_callback self._mqtt.hass_birth_callback = self._hass_birth_callback def _poll_devices(self): @@ -99,7 +118,7 @@ def poll_forever(self) -> NoReturn: mqtt_was_connected = False time.sleep(2) - def _set_temerature_task(self, device: DeviceBase, temperature: float): + def _set_temperature_task(self, device: DeviceBase, temperature: float): device.set_temperature(self._mqtt, temperature) # this will cause the task to be executed only once return schedule.CancelJob @@ -111,12 +130,31 @@ def _set_temperature_callback(self, mqtt: Mqtt, name: str, temperature: float): return device = self._devices[name] - # cancel pending temeperature update for the same device + # cancel pending temperature update for the same device + schedule.clear(device) + + # schedule temperature update + schedule.every(self._config.setpoint_debounce_time).seconds.do( + self._set_temperature_task, device, temperature).tag(device) + + def _set_mode_task(self, device: DeviceBase, mode: bytes): + device.set_mode(self._mqtt, mode) + # this will cause the task to be executed only once + return schedule.CancelJob + + def _set_mode_callback(self, mqtt: Mqtt, name: str, mode: bytes): + if name not in self._devices.keys(): + logger.warning( + "Device {} not found", name) + return + device = self._devices[name] + + # cancel pending updates for the same device schedule.clear(device) - # schedule temeperature update + # schedule update schedule.every(self._config.setpoint_debounce_time).seconds.do( - self._set_temerature_task, device, temperature).tag(device) + self._set_mode_task, device, mode).tag(device) def _hass_birth_callback(self, mqtt: Mqtt): schedule.run_all(delay_seconds=1) diff --git a/etrv2mqtt/etrvutils.py b/etrv2mqtt/etrvutils.py index 9c612a7..c88bfef 100644 --- a/etrv2mqtt/etrvutils.py +++ b/etrv2mqtt/etrvutils.py @@ -1,10 +1,16 @@ import json +import enum from dataclasses import dataclass from libetrv.bluetooth import btle from libetrv.device import eTRVDevice +from libetrv.data_struct import ScheduleMode from datetime import datetime +class PresetModes(enum.Enum): + Manual = ScheduleMode.MANUAL + Scheduled = ScheduleMode.SCHEDULED + Vacation = ScheduleMode.VACATION @dataclass(repr=False) class eTRVData: @@ -12,6 +18,7 @@ class eTRVData: battery: int room_temp: float set_point: float + preset_mode: str last_update: datetime def _datetimeconverter(self, o): @@ -31,8 +38,24 @@ def create_device(address: str, key: bytes, retry_limit: int = 5) -> eTRVDevice: @staticmethod def read_device(device: eTRVDevice) -> eTRVData: - return eTRVData(device.name, device.battery, device.temperature.room_temperature, device.temperature.set_point_temperature, datetime.now()) + mode: str + try: + mode = PresetModes(device.settings.schedule_mode).name + except ValueError: + mode = "None" + + return eTRVData(device.name, + device.battery, + device.temperature.room_temperature, + device.temperature.set_point_temperature, + mode, + datetime.now()) @staticmethod def set_temperature(device: eTRVDevice, temperature: float): device.temperature.set_point_temperature = float(temperature) + + @staticmethod + def set_mode(device: eTRVDevice, mode: bytes): + device.settings.schedule_mode = PresetModes[mode.decode('utf-8')].value + device.settings.save() diff --git a/etrv2mqtt/mqtt.py b/etrv2mqtt/mqtt.py index fa3704a..cc05ed3 100644 --- a/etrv2mqtt/mqtt.py +++ b/etrv2mqtt/mqtt.py @@ -54,7 +54,7 @@ def _on_connect(self, client, userdata, flags, rc): if self._config.mqtt.autodiscovery: ad = Autodiscovery(self._config) for thermostat in self._config.thermostats.values(): - self._publish_autodiscovery_result(ad.register_termostat( + self._publish_autodiscovery_result(ad.register_thermostat( thermostat.topic, thermostat.address), self._config.mqtt.autodiscovery_retain) self._publish_autodiscovery_result(ad.register_battery( thermostat.topic, thermostat.address), self._config.mqtt.autodiscovery_retain) @@ -68,7 +68,7 @@ def _on_connect(self, client, userdata, flags, rc): # subscribe to set temperature topics self._client.subscribe( - self._config.mqtt.base_topic+'/+/set') + self._config.mqtt.base_topic+'/+/set/+') # subscribe to Home Assistant birth topic self._client.subscribe(self._config.mqtt.hass_birth_topic) @@ -80,7 +80,7 @@ def _on_disconnect(self, client, userdata, rc): self._is_connected = False def _on_message(self, client, userdata, msg): - # hass birth message + # Hass birth message if msg.topic == self._config.mqtt.hass_birth_topic: try: # MQTT payload can be random bytes @@ -90,9 +90,9 @@ def _on_message(self, client, userdata, msg): except UnicodeError: pass - # thermostat set temperature message - elif msg.topic.startswith(self._config.mqtt.base_topic) and msg.topic.endswith('/set'): - name = msg.topic.split('/')[-2] + # Thermostat set temperature message + elif msg.topic.startswith(self._config.mqtt.base_topic) and msg.topic.endswith('/set/temperature'): + name = msg.topic.split('/')[-3] try: if self._set_temperature_callback is not None: self._set_temperature_callback( @@ -101,6 +101,13 @@ def _on_message(self, client, userdata, msg): logger.warning("{}: {} is not a valid float", name, msg.payload) + # Thermostat set preset mode message + elif msg.topic.startswith(self._config.mqtt.base_topic) and msg.topic.endswith('/set/preset_mode'): + name = msg.topic.split('/')[-3] + if self._set_mode_callback is not None: + self._set_mode_callback( + self, name, msg.payload) + @property def set_temperature_callback(self) -> Callable[[Mqtt, str, float], None]: return self._set_temperature_callback @@ -109,6 +116,14 @@ def set_temperature_callback(self) -> Callable[[Mqtt, str, float], None]: def set_temperature_callback(self, callback: Callable[[Mqtt, str, float], None]): self._set_temperature_callback = callback + @property + def set_mode_callback(self) -> Callable[[Mqtt, str, str], None]: + return self._set_mode_callback + + @set_mode_callback.setter + def set_mode_callback(self, callback: Callable[[Mqtt, str, str], None]): + self._set_mode_callback = callback + @property def hass_birth_callback(self) -> Callable[[Mqtt], None]: return self._hass_birth_callback