From 95c15949bd003b3d0a8e356361b77117fd5afa1c Mon Sep 17 00:00:00 2001 From: Louis King Date: Thu, 2 Nov 2023 23:49:18 +0000 Subject: [PATCH] Started moving HASS entity row tiles to using MQTT state stream --- pyproject.toml | 1 + wideboy/homeassistant/mqtt.py | 7 +- wideboy/scenes/default/__init__.py | 128 +++++++++++--------- wideboy/sprites/homeassistant/entity_row.py | 77 +++++++++--- 4 files changed, 133 insertions(+), 80 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1caa132..90c5a13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ packages = [] [tool.poetry.dependencies] python = "^3.9" +jinja2 = "^3.1.2" numpy = "^1.24.2" python-dotenv = "^0.21.1" pillow = "^9.4.0" diff --git a/wideboy/homeassistant/mqtt.py b/wideboy/homeassistant/mqtt.py index 0c1cae5..8088105 100644 --- a/wideboy/homeassistant/mqtt.py +++ b/wideboy/homeassistant/mqtt.py @@ -60,14 +60,15 @@ def _on_connect(self, client: mqtt.Client, userdata, flags, rc): logger.info( f"mqtt:connected connected={client.is_connected()} userdata={userdata} flags={flags} rc={str(rc)}" ) + self.subscribe(f"homeassistant/#", 0) # DISABLED, could be too slow # self.subscribe(f"{settings.mqtt.topic_prefix}/{self.device_id}/#", 0) def _on_message(self, client: mqtt.Client, userdata, msg): topic, payload = str(msg.topic), msg.payload.decode("utf-8") - logger.debug( - f"mqtt:message_received topic={topic} payload={payload} userdata={userdata}" - ) + # logger.debug( + # f"mqtt:message_received topic={topic} payload={payload} userdata={userdata}" + # ) pygame.event.post( Event(EVENT_MQTT_MESSAGE_RECEIVED, dict(topic=topic, payload=payload)) ) diff --git a/wideboy/scenes/default/__init__.py b/wideboy/scenes/default/__init__.py index e4463f7..75bcb72 100644 --- a/wideboy/scenes/default/__init__.py +++ b/wideboy/scenes/default/__init__.py @@ -115,88 +115,104 @@ def setup(self): hass_row_main_entities = [ dict( - entity_id="sensor.steps_louis", icon=MaterialIcons.MDI_DIRECTIONS_WALK, icon_color=Color(255, 0, 255, 255), - template="{{ states('sensor.steps_louis') }}", + template="{{ states['sensor.steps_louis'] }}", + watch_entities=["sensor.steps_louis"], ), # active when public IP is home IP dict( - entity_id="sensor.privacy_ip_info", icon=MaterialIcons.MDI_LOCK, icon_color=Color(255, 0, 0, 255), - template="VPN DOWN ({{ states('sensor.privacy_ip_info') }})", - cb_active=lambda state: state.state == settings.secrets.home_ip, + template="VPN DOWN ({{ states['sensor.privacy_ip_info'] }})", + cb_active=lambda states: states["sensor.privacy_ip_info"] + == settings.secrets.home_ip, + watch_entities=["sensor.privacy_ip_info"], ), dict( - entity_id="sensor.transmission_down_speed", icon=MaterialIcons.MDI_VPN_LOCK, icon_color=Color(255, 255, 255, 255), - template="{{ states('sensor.transmission_down_speed') | int }}Mbps", - cb_active=lambda state: float(state.state) > 0, + template="{{ states['sensor.transmission_down_speed'] }}Mbps", + cb_active=lambda states: float(states["sensor.transmission_down_speed"]) + > 0, + watch_entities=["sensor.transmission_down_speed"], ), dict( - entity_id="sensor.ds920plus_volume_used", icon=MaterialIcons.MDI_DNS, icon_color=Color(255, 255, 0, 255), - template="{{ states('sensor.ds920plus_volume_used') }}%", - cb_active=lambda state: float(state.state) > 66.66, + template="{{ states['sensor.ds920plus_volume_used'] }}%", + cb_active=lambda states: float(states["sensor.ds920plus_volume_used"]) + > 66.66, + watch_entities=["sensor.ds920plus_volume_used"], ), dict( - entity_id="sensor.speedtest_download_average", icon=MaterialIcons.MDI_DOWNLOAD, icon_color=Color(0, 255, 0, 255), - template="{{ states('sensor.speedtest_download_average') | int }}Mbps", - cb_active=lambda state: float(state.state) < 600, + template="{{ states['sensor.speedtest_download_average'] }}Mbps", + cb_active=lambda states: float( + states["sensor.speedtest_download_average"] + ) + < 600, + watch_entities=["sensor.speedtest_download_average"], ), dict( - entity_id="sensor.speedtest_upload_average", icon=MaterialIcons.MDI_UPLOAD, icon_color=Color(255, 0, 0, 255), - template="{{ states('sensor.speedtest_upload_average') | int }}Mbps", - cb_active=lambda state: float(state.state) < 600, + template="{{ states['sensor.speedtest_upload_average'] }}Mbps", + cb_active=lambda states: float( + states["sensor.speedtest_upload_average"] + ) + < 600, + watch_entities=["sensor.speedtest_upload_average"], ), dict( - entity_id="sensor.speedtest_ping_average", icon=MaterialIcons.MDI_WIFI, icon_color=Color(0, 0, 255, 255), - template="{{ states('sensor.speedtest_ping_average') | int }}ms", - cb_active=lambda state: float(state.state) > 10, - ), - dict( - entity_id="sensor.bin_collection_days", - icon=MaterialIcons.MDI_DELETE, - icon_color=Color(192, 192, 192, 255), - template="{{ state_attr('calendar.bin_collection', 'message') }}", - cb_active=lambda state: float(state.state) <= 1, + template="{{ states['sensor.speedtest_ping_average' ] }}ms", + cb_active=lambda states: float(states["sensor.speedtest_ping_average"]) + > 10, + watch_entities=["sensor.speedtest_ping_average"], ), + # dict( + # icon=MaterialIcons.MDI_DELETE, + # icon_color=Color(192, 192, 192, 255), + # template="{{ state_attr('calendar.bin_collection', 'message') }}", + # cb_active=lambda state: float(state.state) <= 1, + # watch_entities=["sensor.bin_collection_days"], + # ), dict( - entity_id="binary_sensor.back_door_contact_sensor_contact", icon=MaterialIcons.MDI_DOOR, icon_color=Color(255, 64, 64, 255), template="Back", - cb_active=lambda state: state.state == "on", + cb_active=lambda states: states[ + "binary_sensor.back_door_contact_sensor_contact" + ] + == "on", + watch_entities=["binary_sensor.back_door_contact_sensor_contact"], ), dict( - entity_id="binary_sensor.front_door_contact_sensor_contact", icon=MaterialIcons.MDI_DOOR, icon_color=Color(255, 64, 64, 255), template="Front", - cb_active=lambda state: state.state == "on", + cb_active=lambda states: states[ + "binary_sensor.front_door_contact_sensor_contact" + ] + == "on", + watch_entities=["binary_sensor.front_door_contact_sensor_contact"], ), dict( - entity_id="input_boolean.house_manual", icon=MaterialIcons.MDI_TOGGLE_ON, icon_color=Color(255, 0, 0, 255), template="MANUAL", - cb_active=lambda state: state.state == "on", + cb_active=lambda states: states["input_boolean.house_manual"] == "on", + watch_entities=["input_boolean.house_manual"], ), dict( - entity_id="switch.lounge_fans", icon=MaterialIcons.MDI_AC_UNIT, icon_color=Color(196, 196, 255, 255), template="ON", - cb_active=lambda state: state.state == "on", + cb_active=lambda states: states["switch.lounge_fans"] == "on", + watch_entities=["switch.lounge_fans"], ), ] @@ -208,32 +224,36 @@ def setup(self): ) self.group.add(self.hass_row_main) - hass_row_power_entities = [ dict( - entity_id="sensor.octopus_energy_electricity_current_demand", icon=MaterialIcons.MDI_BOLT, icon_color=Color(192, 192, 192, 255), - template="{{ states('sensor.octopus_energy_electricity_current_demand') | int }}w", + template="{{ states['sensor.octopus_energy_electricity_current_demand'] }}w", + watch_entities=["sensor.octopus_energy_electricity_current_demand"], ), dict( - entity_id="sensor.octopus_energy_electricity_current_rate", icon=MaterialIcons.MDI_SYMBOL_AT, icon_color=Color(192, 192, 192, 255), - template="£{{ '{:.2f}'.format(states('sensor.octopus_energy_electricity_current_rate') | float) }}", + template="£{{ '{:.2f}'.format(states['sensor.octopus_energy_electricity_current_rate'] | float) }}", + watch_entities=["sensor.octopus_energy_electricity_current_rate"], ), dict( - entity_id="sensor.octopus_energy_electricity_current_demand", icon=MaterialIcons.MDI_CURRENCY_DOLLAR, icon_color=Color(255, 64, 64, 255), - template="£{{ '{:.2f}'.format(int(states('sensor.octopus_energy_electricity_current_demand')) / 1000 * float(states('sensor.octopus_energy_electricity_current_rate'))) }}", + template="£{{ (states['sensor.octopus_energy_electricity_current_demand'] | int / 1000) * (states['sensor.octopus_energy_electricity_current_rate'] | float) }}", + watch_entities=[ + "sensor.octopus_energy_electricity_current_demand", + "sensor.octopus_energy_electricity_current_rate", + ], ), dict( - entity_id="sensor.octopus_energy_electricity_current_accumulative_cost", icon=MaterialIcons.MDI_SCHEDULE, icon_color=Color(255, 64, 64, 255), - template="£{{ '{:.2f}'.format(states('sensor.octopus_energy_electricity_current_accumulative_cost') | float) }}", - ) + template="£{{ '{:.2f}'.format(states['sensor.octopus_energy_electricity_current_accumulative_cost'] | float) }}", + watch_entities=[ + "sensor.octopus_energy_electricity_current_accumulative_cost" + ], + ), ] self.hass_row_power = HomeAssistantEntityRowSprite( @@ -246,41 +266,35 @@ def setup(self): hass_row_battery_entities = [ dict( - entity_id="sensor.delta_2_max_downstairs_battery_level", icon=MaterialIcons.MDI_BATTERY, icon_color=Color(192, 192, 192, 255), - template="{{ states('sensor.delta_2_max_downstairs_battery_level') | int }}%", + template="{{ states['sensor.delta_2_max_downstairs_battery_level'] | int }}%", ), dict( - entity_id="sensor.delta_2_max_downstairs_cycles", icon=MaterialIcons.MDI_LOOP, icon_color=Color(192, 192, 192, 255), - template="{{ states('sensor.delta_2_max_downstairs_cycles') | int }}" + template="{{ states['sensor.delta_2_max_downstairs_cycles'] | int }}", ), dict( - entity_id="sensor.delta_2_max_downstairs_discharge_remaining_time", icon=MaterialIcons.MDI_HOURGLASS, icon_color=Color(255, 64, 64, 255), - template="{{ (states('sensor.delta_2_max_downstairs_discharge_remaining_time') | int) // 60 }}h{{ '{:2d}'.format((states('sensor.delta_2_max_downstairs_discharge_remaining_time') | int) % 60) }}m", + template="{{ states['sensor.delta_2_max_downstairs_discharge_remaining_time'] | int }}m", cb_active=lambda state: float(state.state) > 0, ), dict( - entity_id="sensor.delta_2_max_downstairs_charge_remaining_time", icon=MaterialIcons.MDI_HOURGLASS, icon_color=Color(64, 255, 64, 255), - template="{{ (states('sensor.delta_2_max_downstairs_charge_remaining_time') | int) // 60 }}h{{ '{:2d}'.format((states('sensor.delta_2_max_downstairs_charge_remaining_time') | int) % 60) }}m", + template="{{ ( states['sensor.delta_2_max_downstairs_discharge_remaining_time'] | int ) }}m", cb_active=lambda state: float(state.state) > 0, ), dict( - entity_id="sensor.delta_2_max_downstairs_ac_in_power", icon=MaterialIcons.MDI_POWER, icon_color=Color(255, 64, 64, 255), - template="{{ states('sensor.delta_2_max_downstairs_ac_in_power') | int }}w", + template="{{ states['sensor.delta_2_max_downstairs_ac_in_power'] | int }}w", cb_active=lambda state: float(state.state) > 0, - ) + ), ] - self.hass_row_battery = HomeAssistantEntityRowSprite( self, Rect(512, 48, 128, 16), diff --git a/wideboy/sprites/homeassistant/entity_row.py b/wideboy/sprites/homeassistant/entity_row.py index b363077..fd84d7c 100644 --- a/wideboy/sprites/homeassistant/entity_row.py +++ b/wideboy/sprites/homeassistant/entity_row.py @@ -1,7 +1,12 @@ import logging from pygame import Clock, Color, Event, Rect, Surface, SRCALPHA -from typing import Optional, List, Dict -from wideboy.constants import EVENT_EPOCH_MINUTE, EVENT_EPOCH_SECOND +from jinja2 import Template +from typing import Optional, List, Set, Dict, Any +from wideboy.constants import ( + EVENT_EPOCH_MINUTE, + EVENT_EPOCH_SECOND, + EVENT_MQTT_MESSAGE_RECEIVED, +) # from wideboy.mqtt.homeassistant import HASS from wideboy.scenes.base import BaseScene @@ -30,6 +35,8 @@ def __init__( ) -> None: super().__init__(scene, rect) self.entities = entities + self.entity_states: Dict[str, Any] = dict() + self.entity_watches: Set[str] = set() self.font_name = font_name self.font_size = font_size self.color_fg = color_fg @@ -37,6 +44,7 @@ def __init__( self.color_outline = color_outline self.padding_right = padding_right self.show_all = show_all + self.setup_watches() self.render() def update( @@ -48,9 +56,38 @@ def update( ) -> None: super().update(frame, clock, delta, events) for event in events: + if event.type == EVENT_MQTT_MESSAGE_RECEIVED: + changed = self.parse_state_message(event.topic, event.payload) + if changed: + self.render() if event.type == EVENT_EPOCH_MINUTE: self.render() + def setup_watches(self) -> None: + for entity in self.entities: + self.entity_watches.update(entity.get("watch_entities", [])) + for watched_entities in self.entity_watches: + self.entity_states[watched_entities] = "" + + def parse_state_message(self, topic, payload) -> bool | None: + topic_exploded = topic.split("/") + if len(topic_exploded) < 3: + return None + device_class, device_id = topic_exploded[1:3] + entity_id = f"{device_class}.{device_id}" + changed = False + if entity_id in self.entity_watches: + if entity_id in self.entity_states: + changed = self.entity_states[entity_id] != payload + if changed: + logger.debug( + f"hass:entity_row watch_entity_change entity={entity_id} state={self.entity_states[entity_id]}" + ) + else: + changed = True + self.entity_states[entity_id] = payload + return changed + def render(self) -> None: w, h = 1, 2 surfaces = [] @@ -58,23 +95,26 @@ def render(self) -> None: callback = entity.get("cb_active", lambda e: True) template = entity.get("template", None) try: - with self.scene.engine.hass.client as hass: - hass_state = hass.get_state(entity_id=entity["entity_id"]) - # logger.debug( - # f"hass:entity entity_id={entity['entity_id']} state={hass_state.dict()}" - # ) - active = self.show_all or callback(hass_state) - label = None - if not active: - continue + try: + active = self.show_all or callback(self.entity_states) + except Exception as e: + logger.warn(f"hass:entity_row callback error={e}") + active = False + if template: - logger.debug(f"hass:entity_row template={template}") try: - with self.scene.engine.hass.client as hass: - label = hass.get_rendered_template(template) + jinja_template = Template(template) + label = jinja_template.render(states=self.entity_states) + logger.debug( + f"hass:entity_row template={template} label={label}" + ) except Exception as e: - logger.warn(f"failed to render template {template}", exc_info=e) - label = "ERR" + logger.warn(f"hass:entity_row template={template}", exc_info=e) + label = "?" + + if not active: + continue + entity_surface = render_hass_tile( icon_codepoint=entity.get("icon", None), icon_color=entity.get("icon_color", None), @@ -86,10 +126,7 @@ def render(self) -> None: h = max(h, entity_surface.get_rect().height) surfaces.append(entity_surface) except Exception as ex: - logger.warn( - f"failed to render entity {entity['entity_id']}", - exc_info=ex - ) + logger.warn(f"failed to render entity", exc_info=ex) self.image = Surface((w, h - 1), SRCALPHA) self.image.fill(self.color_bg) x = 0