diff --git a/RELEASE.md b/RELEASE.md index f777d03..cea11fb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,10 +1,43 @@ -# 0.6.6 +# 0.7.7 +* Fix for firmware version number has more than three digits. by @fvanroie + +# 0.7.6 +* Fix: MQTTt not being ready at startup. by @xNUTx in https://github.com/HASwitchPlate/openHASP-custom-component/pull/149 + +# 0.7.5 +* Support for HA 2024.9 by @dgomes +* fix: Reuse of the same image with different sized displays causes a random resize. by @xNUTx in https://github.com/HASwitchPlate/openHASP-custom-component/pull/143 +* fix: Dynamic reloading broken on recently added displays. by @xNUTx in https://github.com/HASwitchPlate/openHASP-custom-component/pull/140 +* feat: Added optional setting http_proxy to push_image by @xNUTx in https://github.com/HASwitchPlate/openHASP-custom-component/pull/144 + +# 0.7.4 +* Fixes 'str' object has no attribute 'read' by @illuzn in https://github.com/HASwitchPlate/openHASP-custom-component/pull/132 +* Replace deprecated async_forward_entry_setup call by @TNTLarsn in https://github.com/HASwitchPlate/openHASP-custom-component/pull/137 +* fix: properly split jsonl upload at lineends by @akloeckner in https://github.com/HASwitchPlate/openHASP-custom-component/pull/138 + +# 0.7.3 +- Support for 2024.6.0 +- Fixed height & width were being transposed when fitscreen=true by @FreeBear-nc in https://github.com/HASwitchPlate/openHASP-custom-component/pull/121 +- Move file open() to executor job by @dgomes in https://github.com/HASwitchPlate/openHASP-custom-component/pull/123 +- feat: allow full script syntax in event section by @akloeckner in https://github.com/HASwitchPlate/openHASP-custom-component/pull/112 +# 0.7.2 +- Support discovery through mDNS (0.7.0-rc11 or higher) +- Support for HA 2024.1 +- Replace version popup for legacy 0.6.x plates with log warning + +# 0.7.1 +- Fix error as `ANTIALIAS` was removed in Pillow 10.0.0. Now using `LANCZOS` instead. +- Updated Manifest.json + +# 0.7.0 +- Better handling of discovery for 0.7.0-dev firmware + +# 0.6.6 - Support for 2022.7.0 - Code improvements # 0.6.5 - - Support for 2022.4.0 - Adds page number entity - Adds restart button diff --git a/custom_components/openhasp/__init__.py b/custom_components/openhasp/__init__.py index ff2ec14..f79d816 100644 --- a/custom_components/openhasp/__init__.py +++ b/custom_components/openhasp/__init__.py @@ -5,15 +5,20 @@ import os import pathlib import re -import jsonschema -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + format_mac, +) +from homeassistant.components.mqtt import async_subscribe, async_publish +import homeassistant.components.mqtt as mqtt from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import callback +from homeassistant.core import callback, Context from homeassistant.exceptions import TemplateError from homeassistant.helpers import device_registry as dr, entity_registry import homeassistant.helpers.config_validation as cv @@ -22,22 +27,25 @@ from homeassistant.helpers.network import get_url from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.service import async_call_from_config +from homeassistant.helpers.script import Script from homeassistant.util import slugify +import jsonschema import voluptuous as vol from .common import HASP_IDLE_SCHEMA from .const import ( + ATTR_COMMAND_KEYWORD, + ATTR_COMMAND_PARAMETERS, + ATTR_CONFIG_PARAMETERS, ATTR_CONFIG_SUBMODULE, + ATTR_FORCE_FITSCREEN, ATTR_HEIGHT, ATTR_IDLE, + ATTR_PROXY, ATTR_IMAGE, ATTR_OBJECT, ATTR_PAGE, ATTR_PATH, - ATTR_COMMAND_KEYWORD, - ATTR_COMMAND_PARAMETERS, - ATTR_CONFIG_PARAMETERS, ATTR_WIDTH, CONF_COMPONENT, CONF_EVENT, @@ -50,6 +58,7 @@ CONF_PROPERTIES, CONF_TOPIC, CONF_TRACK, + CONF_SUBTOPIC, DATA_IMAGES, DATA_LISTENER, DISCOVERED_MANUFACTURER, @@ -71,14 +80,14 @@ MAJOR, MINOR, SERVICE_CLEAR_PAGE, + SERVICE_COMMAND, + SERVICE_CONFIG, SERVICE_LOAD_PAGE, SERVICE_PAGE_CHANGE, SERVICE_PAGE_NEXT, SERVICE_PAGE_PREV, SERVICE_PUSH_IMAGE, SERVICE_WAKEUP, - SERVICE_COMMAND, - SERVICE_CONFIG, ) from .image import ImageServeView, image_to_rgb565 @@ -101,7 +110,7 @@ def hasp_object(value): # Configuration YAML schemas -EVENT_SCHEMA = cv.schema_with_slug_keys([cv.SERVICE_SCHEMA]) +EVENT_SCHEMA = cv.schema_with_slug_keys(cv.SCRIPT_SCHEMA) PROPERTY_SCHEMA = cv.schema_with_slug_keys(cv.template) @@ -111,6 +120,7 @@ def hasp_object(value): vol.Optional(CONF_TRACK, default=None): vol.Any(cv.entity_id, None), vol.Optional(CONF_PROPERTIES, default={}): PROPERTY_SCHEMA, vol.Optional(CONF_EVENT, default={}): EVENT_SCHEMA, + vol.Optional(CONF_SUBTOPIC): cv.string, } ) @@ -147,18 +157,23 @@ def hasp_object(value): HASP_PAGE_SCHEMA = vol.Schema(vol.All(vol.Coerce(int), vol.Range(min=0, max=12))) -PUSH_IMAGE_SCHEMA = vol.Schema( +PUSH_IMAGE_SCHEMA = cv.make_entity_service_schema( { vol.Required(ATTR_IMAGE): vol.Any(cv.url, cv.isfile), vol.Required(ATTR_OBJECT): hasp_object, + vol.Optional(ATTR_PROXY): cv.url, vol.Optional(ATTR_WIDTH): cv.positive_int, vol.Optional(ATTR_HEIGHT): cv.positive_int, + vol.Optional(ATTR_FORCE_FITSCREEN): cv.boolean, }, extra=vol.ALLOW_EXTRA, ) async def async_setup(hass, config): + """Wait for MQTT to become available before starting.""" + await mqtt.async_wait_for_mqtt_client(hass) + """Set up the MQTT async example component.""" conf = config.get(DOMAIN) @@ -171,46 +186,59 @@ async def async_setup(hass, config): hass.data[DOMAIN] = {CONF_PLATE: {}} - component = hass.data[DOMAIN][CONF_COMPONENT] = EntityComponent( - _LOGGER, DOMAIN, hass - ) + component = hass.data[DOMAIN][CONF_COMPONENT] = EntityComponent(_LOGGER, DOMAIN, hass) - component.async_register_entity_service(SERVICE_WAKEUP, {}, "async_wakeup") + # Use cv.make_entity_service_schema for all services for consistency + component.async_register_entity_service( + SERVICE_WAKEUP, + cv.make_entity_service_schema({}), + "async_wakeup", + ) component.async_register_entity_service( - SERVICE_PAGE_NEXT, {}, "async_change_page_next" + SERVICE_PAGE_NEXT, + cv.make_entity_service_schema({}), + "async_change_page_next", ) component.async_register_entity_service( - SERVICE_PAGE_PREV, {}, "async_change_page_prev" + SERVICE_PAGE_PREV, + cv.make_entity_service_schema({}), + "async_change_page_prev", ) component.async_register_entity_service( - SERVICE_PAGE_CHANGE, {vol.Required(ATTR_PAGE): int}, "async_change_page" + SERVICE_PAGE_CHANGE, + cv.make_entity_service_schema({vol.Required(ATTR_PAGE): int}), + "async_change_page", ) component.async_register_entity_service( - SERVICE_LOAD_PAGE, {vol.Required(ATTR_PATH): cv.isfile}, "async_load_page" + SERVICE_LOAD_PAGE, + cv.make_entity_service_schema({vol.Required(ATTR_PATH): cv.isfile}), + "async_load_page", ) component.async_register_entity_service( - SERVICE_CLEAR_PAGE, {vol.Optional(ATTR_PAGE): int}, "async_clearpage" + SERVICE_CLEAR_PAGE, + cv.make_entity_service_schema({vol.Optional(ATTR_PAGE): int}), + "async_clearpage", ) component.async_register_entity_service( SERVICE_COMMAND, - { + cv.make_entity_service_schema({ vol.Required(ATTR_COMMAND_KEYWORD): cv.string, vol.Optional(ATTR_COMMAND_PARAMETERS, default=""): cv.string, - }, + }), "async_command_service", ) - component.async_register_entity_service( SERVICE_CONFIG, - { + cv.make_entity_service_schema({ vol.Required(ATTR_CONFIG_SUBMODULE): cv.string, vol.Required(ATTR_CONFIG_PARAMETERS): cv.string, - }, + }), "async_config_service", ) - component.async_register_entity_service( - SERVICE_PUSH_IMAGE, PUSH_IMAGE_SCHEMA, "async_push_image" + SERVICE_PUSH_IMAGE, + PUSH_IMAGE_SCHEMA, + "async_push_image", ) hass.data[DOMAIN][DATA_IMAGES] = dict() @@ -253,6 +281,7 @@ async def async_setup_entry(hass, entry) -> bool: sw_version=entry.data[DISCOVERED_VERSION], configuration_url=entry.data.get(DISCOVERED_URL), name=plate, + connections={(CONNECTION_NETWORK_MAC, format_mac(entry.data[CONF_HWID]))}, ) # Add entity to component @@ -261,10 +290,7 @@ async def async_setup_entry(hass, entry) -> bool: await component.async_add_entities([plate_entity]) hass.data[DOMAIN][CONF_PLATE][plate] = plate_entity - for domain in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, domain) - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) listener = entry.add_update_listener(async_update_options) entry.async_on_unload(listener) @@ -289,6 +315,7 @@ async def async_unload_entry(hass, entry): return True + async def async_remove_entry(hass, entry): plate = entry.data[CONF_NAME] @@ -312,9 +339,10 @@ async def async_remove_entry(hass, entry): device_registry.async_remove_device(dev.id) # Component does not remove entity from entity_registry, so we must do it - registry = await entity_registry.async_get_registry(hass) + registry = entity_registry.async_get(hass) registry.async_remove(hass.data[DOMAIN][CONF_PLATE][plate].entity_id) + # pylint: disable=R0902 class SwitchPlate(RestoreEntity): """Representation of an openHASP Plate.""" @@ -339,15 +367,15 @@ def __init__(self, hass, config, entry): self._subscriptions = [] - with open( - pathlib.Path(__file__).parent.joinpath("pages_schema.json"), "r" - ) as schema_file: - self.json_schema = json.load(schema_file) - self._attr_unique_id = entry.data[CONF_HWID] self._attr_name = entry.data[CONF_NAME] self._attr_icon = "mdi:gesture-tap-box" + def _read_file(self, path): + """Executor helper to read file.""" + with open(path, "r") as src_file: + return src_file.read() + @property def state(self): """Return the state of the component.""" @@ -372,6 +400,11 @@ async def async_added_to_hass(self): """Run when entity about to be added.""" await super().async_added_to_hass() + schema_file_contents = await self.hass.async_add_executor_job( + self._read_file, pathlib.Path(__file__).parent.joinpath("pages_schema.json") + ) + self.json_schema = json.loads(schema_file_contents) + state = await self.async_get_last_state() if state and state.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN, None]: self._page = int(state.state) @@ -387,8 +420,8 @@ async def page_update_received(msg): _LOGGER.error("%s in %s", err, msg.payload) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - f"{self._topic}/state/page", page_update_received + await async_subscribe( + self.hass, f"{self._topic}/state/page", page_update_received ) ) @@ -399,17 +432,9 @@ async def statusupdate_message_received(msg): try: message = HASP_STATUSUPDATE_SCHEMA(json.loads(msg.payload)) - major, minor, _ = message["version"].split(".") + major, minor, patch = message["version"].split(".")[:3] if (major, minor) != (MAJOR, MINOR): - self.hass.components.persistent_notification.create( - f"You require firmware version {MAJOR}.{MINOR}.x \ - in plate {self._entry.data[CONF_NAME]} \ - for this component to work properly.\ -
Some features will simply not work!", - title="openHASP Firmware mismatch", - notification_id="openhasp_firmware_notification", - ) - _LOGGER.error( + _LOGGER.warning( "%s firmware mismatch %s <> %s", self._entry.data[CONF_NAME], (major, minor), @@ -437,11 +462,13 @@ async def statusupdate_message_received(msg): _LOGGER.error("While processing status update: %s", err) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - f"{self._topic}/state/statusupdate", statusupdate_message_received + await async_subscribe( + self.hass, + f"{self._topic}/state/statusupdate", + statusupdate_message_received, ) ) - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command", "statusupdate", qos=0, retain=False ) @@ -455,8 +482,8 @@ async def idle_message_received(msg): _LOGGER.error("While processing idle message: %s", err) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - f"{self._topic}/state/idle", idle_message_received + await async_subscribe( + self.hass, f"{self._topic}/state/idle", idle_message_received ) ) @@ -495,9 +522,7 @@ async def lwt_message_received(msg): _LOGGER.error("While processing LWT: %s", err) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - f"{self._topic}/LWT", lwt_message_received - ) + await async_subscribe(self.hass, f"{self._topic}/LWT", lwt_message_received) ) @property @@ -519,9 +544,7 @@ async def async_wakeup(self): """Wake up the display.""" cmd_topic = f"{self._topic}/command" _LOGGER.warning("Wakeup will be deprecated in 0.8.0") # remove in version 0.8.0 - await self.hass.components.mqtt.async_publish( - self.hass, cmd_topic, "wakeup", qos=0, retain=False - ) + await async_publish(self.hass, cmd_topic, "wakeup", qos=0, retain=False) async def async_change_page_next(self): """Change page to next one.""" @@ -530,9 +553,7 @@ async def async_change_page_next(self): "page next service will be deprecated in 0.8.0" ) # remove in version 0.8.0 - await self.hass.components.mqtt.async_publish( - self.hass, cmd_topic, "page next", qos=0, retain=False - ) + await async_publish(self.hass, cmd_topic, "page next", qos=0, retain=False) async def async_change_page_prev(self): """Change page to previous one.""" @@ -541,22 +562,18 @@ async def async_change_page_prev(self): "page prev service will be deprecated in 0.8.0" ) # remove in version 0.8.0 - await self.hass.components.mqtt.async_publish( - self.hass, cmd_topic, "page prev", qos=0, retain=False - ) + await async_publish(self.hass, cmd_topic, "page prev", qos=0, retain=False) async def async_clearpage(self, page="all"): """Clear page.""" cmd_topic = f"{self._topic}/command" - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, cmd_topic, f"clearpage {page}", qos=0, retain=False ) if page == "all": - await self.hass.components.mqtt.async_publish( - self.hass, cmd_topic, "page 1", qos=0, retain=False - ) + await async_publish(self.hass, cmd_topic, "page 1", qos=0, retain=False) async def async_change_page(self, page): """Change page to number.""" @@ -565,7 +582,11 @@ async def async_change_page(self, page): if self._statusupdate: num_pages = self._statusupdate[HASP_NUM_PAGES] - if page <= 0 or page > num_pages: + if ( + isinstance(page, int) + and isinstance(num_pages, int) + and (page <= 0 or page > num_pages) + ): _LOGGER.error( "Can't change to %s, available pages are 1 to %s", page, num_pages ) @@ -574,14 +595,12 @@ async def async_change_page(self, page): self._page = page _LOGGER.debug("Change page %s", self._page) - await self.hass.components.mqtt.async_publish( - self.hass, cmd_topic, self._page, qos=0, retain=False - ) + await async_publish(self.hass, cmd_topic, self._page, qos=0, retain=False) self.async_write_ha_state() async def async_command_service(self, keyword, parameters): """Send commands directly to the plate entity.""" - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command", f"{keyword} {parameters}".strip(), @@ -591,7 +610,7 @@ async def async_command_service(self, keyword, parameters): async def async_config_service(self, submodule, parameters): """Send configuration commands to plate entity.""" - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/config/{submodule}", f"{parameters}".strip(), @@ -599,28 +618,31 @@ async def async_config_service(self, submodule, parameters): retain=False, ) - async def async_push_image(self, image, obj, width=None, height=None): + async def async_push_image( + self, image, obj, http_proxy=None, width=None, height=None, fitscreen=False + ): """Update object image.""" - image_id = hashlib.md5(image.encode("utf-8")).hexdigest() + image_id = hashlib.md5( + image.encode("utf-8") + self._entry.data[CONF_NAME].encode("utf-8") + ).hexdigest() rgb_image = await self.hass.async_add_executor_job( - image_to_rgb565, image, (width, height) + image_to_rgb565, image, (width, height), fitscreen ) self.hass.data[DOMAIN][DATA_IMAGES][image_id] = rgb_image cmd_topic = f"{self._topic}/command/{obj}.src" - rgb_image_url = ( - f"{get_url(self.hass, allow_external=False)}/api/openhasp/serve/{image_id}" - ) - + if http_proxy: + rgb_image_url = f"{http_proxy}/api/openhasp/serve/{image_id}" + else: + rgb_image_url = f"{get_url(self.hass, allow_external=False)}/api/openhasp/serve/{image_id}" + # self._entry.data _LOGGER.debug("Push %s with %s", cmd_topic, rgb_image_url) - await self.hass.components.mqtt.async_publish( - self.hass, cmd_topic, rgb_image_url, qos=0, retain=False - ) + await async_publish(self.hass, cmd_topic, rgb_image_url, qos=0, retain=False) async def refresh(self): """Refresh objects in the SwitchPlate.""" @@ -644,7 +666,7 @@ async def send_lines(lines): mqtt_payload_buffer = "" for line in lines: if len(mqtt_payload_buffer) + len(line) > 1000: - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{cmd_topic}/jsonl", mqtt_payload_buffer, @@ -654,7 +676,7 @@ async def send_lines(lines): mqtt_payload_buffer = line else: mqtt_payload_buffer = mqtt_payload_buffer + line - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{cmd_topic}/jsonl", mqtt_payload_buffer, @@ -663,17 +685,17 @@ async def send_lines(lines): ) try: - with open(path, "r") as pages_file: - if path.endswith(".json"): - json_data = json.load(pages_file) - jsonschema.validate(instance=json_data, schema=self.json_schema) - lines = [] - for item in json_data: - if isinstance(item, dict): - lines.append(json.dumps(item) + "\n") - await send_lines(lines) - else: - await send_lines(pages_file) + pages_file = await self.hass.async_add_executor_job(self._read_file, path) + if path.endswith(".json"): + json_data = json.loads(pages_file) + jsonschema.validate(instance=json_data, schema=self.json_schema) + lines = [] + for item in json_data: + if isinstance(item, dict): + lines.append(json.dumps(item) + "\n") + await send_lines(lines) + else: + await send_lines(pages_file.splitlines(keepends=True)) await self.refresh() except (IndexError, FileNotFoundError, IsADirectoryError, UnboundLocalError): @@ -705,12 +727,19 @@ def __init__(self, hass, plate_topic, config): self.hass = hass self.obj_id = config[CONF_OBJID] - self.command_topic = f"{plate_topic}/command/{self.obj_id}." + subtopic = config.get("subtopic") + if subtopic: + self.command_topic = f"{plate_topic}/command/{subtopic}/{self.obj_id}." + else: + self.command_topic = f"{plate_topic}/command/{self.obj_id}." self.state_topic = f"{plate_topic}/state/{self.obj_id}" self.cached_properties = {} self.properties = config.get(CONF_PROPERTIES) - self.event_services = config.get(CONF_EVENT) + self.event_services = { + event: Script(hass, script, plate_topic, DOMAIN) + for (event, script) in config[CONF_EVENT].items() + } self._tracked_property_templates = [] self._freeze_properties = [] self._subscriptions = [] @@ -772,9 +801,7 @@ async def _async_template_result_changed(event, updates): result, ) - await self.hass.components.mqtt.async_publish( - self.hass, self.command_topic + _property, result - ) + await async_publish(self.hass, self.command_topic + _property, result) property_template = async_track_template_result( self.hass, @@ -789,9 +816,7 @@ async def refresh(self): """Refresh based on cached values.""" for _property, result in self.cached_properties.items(): _LOGGER.debug("Refresh object %s.%s = %s", self.obj_id, _property, result) - await self.hass.components.mqtt.async_publish( - self.hass, self.command_topic + _property, result - ) + await async_publish(self.hass, self.command_topic + _property, result) async def async_listen_hasp_events(self): """Listen to messages on MQTT for HASP events.""" @@ -808,7 +833,7 @@ async def message_received(msg): elif message[HASP_EVENT] in [HASP_EVENT_UP, HASP_EVENT_RELEASE]: self._freeze_properties = [] - for event in self.event_services: + for event, script in self.event_services.items(): if event in message[HASP_EVENT]: _LOGGER.debug( "Service call for '%s' triggered by '%s' on '%s' with variables %s", @@ -817,13 +842,10 @@ async def message_received(msg): msg.topic, message, ) - for service in self.event_services[event]: - await async_call_from_config( - self.hass, - service, - validate_config=False, - variables=message, - ) + await script.async_run( + run_variables=message, + context=Context(), + ) except vol.error.Invalid: _LOGGER.debug( "Could not handle openHASP event: '%s' on '%s'", @@ -836,6 +858,4 @@ async def message_received(msg): ) _LOGGER.debug("Subscribe to '%s' events on '%s'", self.obj_id, self.state_topic) - return await self.hass.components.mqtt.async_subscribe( - self.state_topic, message_received - ) + return await async_subscribe(self.hass, self.state_topic, message_received) diff --git a/custom_components/openhasp/binary_sensor.py b/custom_components/openhasp/binary_sensor.py index 828c188..951b221 100644 --- a/custom_components/openhasp/binary_sensor.py +++ b/custom_components/openhasp/binary_sensor.py @@ -3,6 +3,7 @@ import logging from typing import Callable +from homeassistant.components.mqtt import async_publish, async_subscribe from homeassistant.components.binary_sensor import BinarySensorEntity # pylint: disable=R0801 @@ -69,7 +70,7 @@ def device_class(self): async def refresh(self): """Force sync of plate state back to binary sensor.""" - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/input{self._gpio}", "", @@ -97,12 +98,14 @@ async def state_message_received(msg): _LOGGER.error(err) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - f"{self._topic}/state/input{self._gpio}", state_message_received + await async_subscribe( + self.hass, + f"{self._topic}/state/input{self._gpio}", + state_message_received, ) ) - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/input{self._gpio}", "", diff --git a/custom_components/openhasp/button.py b/custom_components/openhasp/button.py index 3397a54..5203215 100644 --- a/custom_components/openhasp/button.py +++ b/custom_components/openhasp/button.py @@ -1,16 +1,13 @@ """Support for current page numbers.""" import logging -from homeassistant.components.button import ( - ButtonDeviceClass, - ButtonEntity, -) +from homeassistant.components.mqtt import async_publish +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory - from .common import HASPEntity from .const import CONF_HWID, CONF_TOPIC @@ -49,7 +46,7 @@ def __init__(self, name, hwid, topic) -> None: async def async_press(self) -> None: """Handle the button press.""" - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/restart", "", diff --git a/custom_components/openhasp/common.py b/custom_components/openhasp/common.py index 50f8962..0630314 100644 --- a/custom_components/openhasp/common.py +++ b/custom_components/openhasp/common.py @@ -57,8 +57,12 @@ async def online(event): if self._state: await self.refresh() else: - self.async_write_ha_state() # Just to update availability - _LOGGER.debug("%s is available, %s", self.entity_id, "refresh" if self._state else "stale") + self.async_write_ha_state() # Just to update availability + _LOGGER.debug( + "%s is available, %s", + self.entity_id, + "refresh" if self._state else "stale", + ) self._subscriptions.append( self.hass.bus.async_listen(EVENT_HASP_PLATE_ONLINE, online) @@ -84,7 +88,7 @@ async def async_will_remove_from_hass(self): class HASPToggleEntity(HASPEntity, ToggleEntity): """Representation of HASP ToggleEntity.""" - + def __init__(self, name, hwid, topic, gpio): """Initialize the relay.""" super().__init__(name, hwid, topic, gpio) diff --git a/custom_components/openhasp/config_flow.py b/custom_components/openhasp/config_flow.py index b5d2587..5811bd6 100644 --- a/custom_components/openhasp/config_flow.py +++ b/custom_components/openhasp/config_flow.py @@ -3,6 +3,7 @@ import logging import os +from homeassistant.components.mqtt import async_publish from homeassistant import config_entries, data_entry_flow, exceptions from homeassistant.components.mqtt import valid_subscribe_topic from homeassistant.const import CONF_NAME @@ -22,6 +23,7 @@ CONF_RELAYS, CONF_TOPIC, DEFAULT_IDLE_BRIGHNESS, + DEFAULT_TOPIC, DISCOVERED_DIM, DISCOVERED_HWID, DISCOVERED_INPUT, @@ -29,6 +31,7 @@ DISCOVERED_MANUFACTURER, DISCOVERED_MODEL, DISCOVERED_NODE, + DISCOVERED_NODE_T, DISCOVERED_PAGES, DISCOVERED_POWER, DISCOVERED_URL, @@ -54,8 +57,7 @@ def validate_jsonl(path): return file_in -@config_entries.HANDLERS.register(DOMAIN) -class OpenHASPFlowHandler(config_entries.ConfigFlow): +class OpenHASPFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for OpenHASP component.""" VERSION = 1 @@ -74,7 +76,7 @@ async def async_step_user(self, user_input=None): """Handle a flow initialized by User.""" _LOGGER.info("Discovery Only") - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, "hasp/broadcast/command/discovery", "discovery", @@ -84,12 +86,44 @@ async def async_step_user(self, user_input=None): return self.async_abort(reason="discovery_only") + async def async_step_zeroconf(self, discovery_info=None): + _discovered = discovery_info.properties + _LOGGER.debug("Discovered ZeroConf: %s", _discovered) + + _discovered[CONF_TOPIC] = _discovered[DISCOVERED_NODE_T][:-1] + + for key in [ + DISCOVERED_PAGES, + DISCOVERED_POWER, + DISCOVERED_LIGHT, + DISCOVERED_DIM, + DISCOVERED_INPUT, + ]: + _LOGGER.debug( + "[%s] Discovered %s = %s", + _discovered[CONF_TOPIC], + key, + _discovered.get(key), + ) + _discovered[key] = json.loads(_discovered.get(key)) + + return await self._process_discovery(_discovered) + async def async_step_mqtt(self, discovery_info=None): """Handle a flow initialized by MQTT discovery.""" _discovered = json.loads(discovery_info.payload) - _LOGGER.debug("Discovered: %s", _discovered) + _LOGGER.debug("Discovered MQTT: %s", _discovered) + + _discovered[ + CONF_TOPIC + ] = f"{discovery_info.topic.split('/')[0]}/{_discovered[DISCOVERED_NODE]}" + + return await self._process_discovery(_discovered) - await self.async_set_unique_id(_discovered[DISCOVERED_HWID], raise_on_progress=False) + async def _process_discovery(self, _discovered): + await self.async_set_unique_id( + _discovered[DISCOVERED_HWID], raise_on_progress=False + ) self._abort_if_unique_id_configured() version = _discovered.get(DISCOVERED_VERSION) @@ -103,13 +137,11 @@ async def async_step_mqtt(self, discovery_info=None): self.config_data[DISCOVERED_VERSION] = version - self.config_data[CONF_HWID] = _discovered[DISCOVERED_HWID] + self.config_data[CONF_HWID] = _discovered[DISCOVERED_HWID] self.config_data[CONF_NODE] = self.config_data[CONF_NAME] = _discovered[ DISCOVERED_NODE ] - self.config_data[ - CONF_TOPIC - ] = f"{discovery_info.topic.split('/')[0]}/{self.config_data[CONF_NODE]}" + self.config_data[CONF_TOPIC] = _discovered[CONF_TOPIC] self.config_data[DISCOVERED_URL] = _discovered.get(DISCOVERED_URL) self.config_data[DISCOVERED_MANUFACTURER] = _discovered.get( @@ -122,6 +154,10 @@ async def async_step_mqtt(self, discovery_info=None): self.config_data[CONF_DIMLIGHTS] = _discovered.get(DISCOVERED_DIM) self.config_data[CONF_INPUT] = _discovered.get(DISCOVERED_INPUT) + self.context.update( + {"title_placeholders": {"name": self.config_data[CONF_NODE]}} + ) + return await self.async_step_personalize() async def async_step_personalize(self, user_input=None): @@ -165,7 +201,8 @@ async def async_step_personalize(self, user_input=None): data_schema=vol.Schema( { vol.Required( - CONF_TOPIC, default=self.config_data.get(CONF_TOPIC, "hasp") + CONF_TOPIC, + default=self.config_data.get(CONF_TOPIC, DEFAULT_TOPIC), ): str, vol.Required( CONF_NAME, default=self.config_data.get(CONF_NAME) @@ -183,16 +220,12 @@ async def async_step_personalize(self, user_input=None): @callback def async_get_options_flow(config_entry): """Set the OptionsFlowHandler.""" - return OpenHASPOptionsFlowHandler(config_entry) + return OpenHASPOptionsFlowHandler() class OpenHASPOptionsFlowHandler(config_entries.OptionsFlow): """ConfigOptions flow for openHASP.""" - def __init__(self, config_entry): - """Initialize openHASP options flow.""" - self.config_entry = config_entry - async def async_step_init(self, user_input=None): """Manage the options.""" if user_input is not None: diff --git a/custom_components/openhasp/const.py b/custom_components/openhasp/const.py index 3d5b930..83d3e42 100644 --- a/custom_components/openhasp/const.py +++ b/custom_components/openhasp/const.py @@ -2,7 +2,7 @@ # Version MAJOR = "0" -MINOR = "6" +MINOR = "7" DOMAIN = "openhasp" @@ -28,6 +28,8 @@ CONF_NODE = "node" CONF_HWID = "hwid" CONF_INPUT = "input" +CONF_SUBTOPIC = "subtopic" + DATA_LISTENER = "listener" DATA_IMAGES = "images" @@ -37,6 +39,7 @@ DEFAULT_IDLE_BRIGHNESS = 25 DISCOVERED_NODE = "node" +DISCOVERED_NODE_T = "node_t" DISCOVERED_MODEL = "mdl" DISCOVERED_MANUFACTURER = "mf" DISCOVERED_HWID = "hwid" @@ -80,6 +83,7 @@ HASP_OFFLINE = "offline" HASP_LWT = (HASP_ONLINE, HASP_OFFLINE) +ATTR_FORCE_FITSCREEN = "fit_screen" ATTR_PAGE = "page" ATTR_CURRENT_DIM = "dim" ATTR_IDLE = "idle" @@ -90,6 +94,7 @@ ATTR_COMMAND_PARAMETERS = "parameters" ATTR_CONFIG_SUBMODULE = "submodule" ATTR_CONFIG_PARAMETERS = "parameters" +ATTR_PROXY = "http_proxy" ATTR_IMAGE = "image" ATTR_OBJECT = "obj" ATTR_WIDTH = "width" diff --git a/custom_components/openhasp/image.py b/custom_components/openhasp/image.py index 96ba7fe..18a2d84 100644 --- a/custom_components/openhasp/image.py +++ b/custom_components/openhasp/image.py @@ -4,7 +4,7 @@ import struct import tempfile -from PIL import Image +from PIL import Image, ImageOps from aiohttp import hdrs, web from homeassistant.components.http.static import CACHE_HEADERS from homeassistant.components.http.view import HomeAssistantView @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -def image_to_rgb565(in_image, size): +def image_to_rgb565(in_image, size, fitscreen): """Transform image to rgb565 format according to LVGL requirements.""" try: if in_image.startswith("http"): @@ -29,10 +29,15 @@ def image_to_rgb565(in_image, size): original_width, original_height = im.size width, height = size - width = min(w for w in [width, original_width] if w is not None and w > 0) - height = min(h for h in [height, original_height] if h is not None and h > 0) + if not fitscreen: + width = min(w for w in [width, original_width] if w is not None and w > 0) + height = min(h for h in [height, original_height] if h is not None and h > 0) + im.thumbnail((width, height), Image.LANCZOS) + else: + im = ImageOps.fit( + im, (width, height), method=3, bleed=0.0, centering=(0.5, 0.5) + ) - im.thumbnail((height, width), Image.ANTIALIAS) width, height = im.size # actual size after resize out_image = tempfile.NamedTemporaryFile(mode="w+b") @@ -47,7 +52,12 @@ def image_to_rgb565(in_image, size): b = (pix[2] >> 3) & 0x1F out_image.write(struct.pack("H", (r << 11) | (g << 5) | b)) - _LOGGER.debug("image_to_rgb565 out_image: %s", out_image.name) + _LOGGER.debug( + "image_to_rgb565 out_image: %s - %s > %s", + out_image.name, + (original_width, original_height), + im.size, + ) out_image.flush() diff --git a/custom_components/openhasp/light.py b/custom_components/openhasp/light.py index 596f825..5f6db2c 100644 --- a/custom_components/openhasp/light.py +++ b/custom_components/openhasp/light.py @@ -6,10 +6,10 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, + ColorMode, LightEntity, ) +from homeassistant.components.mqtt import async_publish, async_subscribe from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback @@ -106,6 +106,9 @@ async def async_setup_entry( class HASPLight(HASPToggleEntity, LightEntity): """Representation of openHASP Light.""" + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + def __init__(self, name, hwid, topic, gpio): """Initialize the light.""" super().__init__(name, hwid, topic, gpio) @@ -113,7 +116,7 @@ def __init__(self, name, hwid, topic, gpio): async def refresh(self): """Sync local state back to plate.""" - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/output{self._gpio}", json.dumps(HASP_LIGHT_SCHEMA({"state": int(self._state)})), @@ -142,13 +145,15 @@ async def light_state_message_received(msg): _LOGGER.error(err) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - f"{self._topic}/state/output{self._gpio}", light_state_message_received + await async_subscribe( + self.hass, + f"{self._topic}/state/output{self._gpio}", + light_state_message_received, ) ) # Force immediatable state update from plate - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/output{self._gpio}", "", @@ -160,11 +165,13 @@ async def light_state_message_received(msg): class HASPDimmableLight(HASPToggleEntity, LightEntity): """Representation of openHASP Light.""" + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + def __init__(self, name, hwid, topic, gpio): """Initialize the dimmable light.""" super().__init__(name, hwid, topic, gpio) self._brightness = None - self._attr_supported_features = SUPPORT_BRIGHTNESS self._gpio = gpio self._attr_name = f"{name} dimmable light {self._gpio}" @@ -182,7 +189,7 @@ async def refresh(self): self._brightness, ) - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/output{self._gpio}", json.dumps( @@ -216,14 +223,15 @@ async def dimmable_light_message_received(msg): _LOGGER.error(err) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( + await async_subscribe( + self.hass, f"{self._topic}/state/output{self._gpio}", dimmable_light_message_received, ) ) # Force immediatable state update from plate - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/output{self._gpio}", "", @@ -242,13 +250,15 @@ async def async_turn_on(self, **kwargs): class HASPBackLight(HASPToggleEntity, LightEntity, RestoreEntity): """Representation of HASP LVGL Backlight.""" + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + def __init__(self, name, hwid, topic, brightness): """Initialize the light.""" super().__init__(name, hwid, topic, "backlight") self._awake_brightness = 255 self._brightness = None self._idle_brightness = brightness - self._attr_supported_features = SUPPORT_BRIGHTNESS self._attr_name = f"{name} backlight" @property @@ -311,14 +321,10 @@ async def backlight_message_received(msg): ) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - state_topic, backlight_message_received - ) + await async_subscribe(self.hass, state_topic, backlight_message_received) ) - await self.hass.components.mqtt.async_publish( - self.hass, cmd_topic, "backlight", qos=0, retain=False - ) + await async_publish(self.hass, cmd_topic, "backlight", qos=0, retain=False) async def async_listen_idleness(self): """Listen to messages on MQTT for HASP idleness.""" @@ -330,13 +336,13 @@ async def idle_message_received(msg): if message == HASP_IDLE_OFF: brightness = self._awake_brightness - backlight = 1 + backlight = "on" elif message == HASP_IDLE_SHORT: brightness = self._idle_brightness - backlight = 1 + backlight = "on" elif message == HASP_IDLE_LONG: brightness = self._awake_brightness - backlight = 0 + backlight = "off" else: return @@ -350,7 +356,7 @@ async def idle_message_received(msg): new_state = {"state": backlight, "brightness": brightness} - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command", f"backlight {json.dumps(new_state)}", @@ -360,8 +366,8 @@ async def idle_message_received(msg): self.async_write_ha_state() self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - f"{self._topic}/state/idle", idle_message_received + await async_subscribe( + self.hass, f"{self._topic}/state/idle", idle_message_received ) ) @@ -369,11 +375,14 @@ async def refresh(self): """Sync local state back to plate.""" cmd_topic = f"{self._topic}/command" - new_state = {"state": self._state, "brightness": self._brightness} + new_state = { + "state": "on" if self._state else "off", + "brightness": self._brightness, + } _LOGGER.debug("refresh(%s) backlight - %s", self.name, new_state) - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, cmd_topic, f"backlight {json.dumps(new_state)}", @@ -396,12 +405,14 @@ async def async_turn_on(self, **kwargs): class HASPMoodLight(HASPToggleEntity, LightEntity, RestoreEntity): """Representation of HASP LVGL Moodlight.""" + _attr_color_mode = ColorMode.HS + _attr_supported_color_modes = {ColorMode.HS} + def __init__(self, name, hwid, topic): """Initialize the light.""" super().__init__(name, hwid, topic, "moodlight") self._hs = None self._brightness = None - self._attr_supported_features = SUPPORT_COLOR | SUPPORT_BRIGHTNESS self._attr_name = f"{name} moodlight" @property @@ -450,12 +461,12 @@ async def moodlight_message_received(msg): _LOGGER.error("While proccessing moodlight: %s", err) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - f"{self._topic}/state/moodlight", moodlight_message_received + await async_subscribe( + self.hass, f"{self._topic}/state/moodlight", moodlight_message_received ) ) - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command", "moodlight", qos=0, retain=False ) @@ -471,7 +482,7 @@ async def refresh(self): new_state["brightness"] = self._brightness _LOGGER.debug("refresh(%s) moodlight - %s", self.name, new_state) - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, cmd_topic, f"moodlight {json.dumps(new_state)}", diff --git a/custom_components/openhasp/manifest.json b/custom_components/openhasp/manifest.json index b85e9b4..da5dabf 100644 --- a/custom_components/openhasp/manifest.json +++ b/custom_components/openhasp/manifest.json @@ -1,13 +1,14 @@ { "domain": "openhasp", "name": "openHASP", + "codeowners": ["@dgomes"], + "config_flow": true, + "dependencies": ["mqtt", "http"], "documentation": "https://www.openhasp.com/", + "iot_class": "local_push", "issue_tracker": "https://github.com/HASwitchPlate/openHASP-custom-component/issues", - "dependencies": ["mqtt", "http"], + "mqtt": ["hasp/discovery/#"], "requirements": ["jsonschema>=3.2.0"], - "version": "0.7.0", - "config_flow": true, - "iot_class": "local_push", - "codeowners": ["@dgomes"], - "mqtt": ["hasp/discovery/#"] + "version": "0.7.9", + "zeroconf": ["_openhasp._tcp.local."] } diff --git a/custom_components/openhasp/number.py b/custom_components/openhasp/number.py index d17f20b..8f5a5f1 100644 --- a/custom_components/openhasp/number.py +++ b/custom_components/openhasp/number.py @@ -1,11 +1,12 @@ """Support for current page numbers.""" -import logging from dataclasses import dataclass +import logging +from homeassistant.components.mqtt import async_publish, async_subscribe from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.restore_state import RestoreEntity @@ -22,6 +23,7 @@ class HASPNumberDescriptionMixin: command_topic: str state_topic: str + @dataclass class HASPNumberDescription(NumberEntityDescription, HASPNumberDescriptionMixin): """Class to describe an HASP Number Entity.""" @@ -73,7 +75,7 @@ def __init__(self, name, hwid, topic, description) -> None: async def refresh(self): """Sync local state back to plate.""" - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}{self.entity_description.command_topic}", "" if self._number is None else self._number, @@ -98,7 +100,8 @@ async def page_state_message_received(msg): self.async_write_ha_state() self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( + await async_subscribe( + self.hass, f"{self._topic}{self.entity_description.state_topic}", page_state_message_received, ) diff --git a/custom_components/openhasp/services.yaml b/custom_components/openhasp/services.yaml index b1fb8e9..518056e 100644 --- a/custom_components/openhasp/services.yaml +++ b/custom_components/openhasp/services.yaml @@ -106,6 +106,13 @@ push_image: example: "https://people.sc.fsu.edu/~jburkardt/data/jpg/lena.jpg" selector: text: + http_proxy: + name: HTTP (Reverse) Proxy + description: Proxy address to use. This can be used to allow HTTP access to an otherwise SSL secured HA instance. Offering the proxy functionality is out of the scope of this integration. + required: false + example: "http://people.sc.fsu.edu:port" + selector: + text: obj: name: Object description: Object ID in the format p#b## @@ -133,3 +140,10 @@ push_image: min: 0 max: 1024 mode: box + fitscreen: + name: Fit Screen + description: If this is set to true, then image is resized to the previously defined width and height, regardless of screen dimensions and/or aspect ratio + required: false + example: false + selector: + boolean: diff --git a/custom_components/openhasp/switch.py b/custom_components/openhasp/switch.py index 05a4918..49d93fc 100644 --- a/custom_components/openhasp/switch.py +++ b/custom_components/openhasp/switch.py @@ -4,11 +4,12 @@ from typing import Callable # pylint: disable=R0801 +from homeassistant.components.mqtt import async_publish, async_subscribe from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, STATE_ON, STATE_OFF -from homeassistant.helpers.entity import EntityCategory +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityCategory import voluptuous as vol from .common import HASPToggleEntity @@ -66,7 +67,7 @@ async def refresh(self): # Don't do anything before we know the state return - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/output{self._gpio}", json.dumps(HASP_RELAY_SCHEMA({"state": int(self._state)})), @@ -95,12 +96,14 @@ async def relay_state_message_received(msg): _LOGGER.error(err) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - f"{self._topic}/state/output{self._gpio}", relay_state_message_received + await async_subscribe( + self.hass, + f"{self._topic}/state/output{self._gpio}", + relay_state_message_received, ) ) - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/output{self._gpio}", "", @@ -122,7 +125,7 @@ def __init__(self, name, hwid, topic): async def refresh(self): """Sync local state back to plate.""" - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/antiburn", int(self._state), @@ -152,15 +155,17 @@ async def antiburn_state_message_received(msg): _LOGGER.error(err) self._subscriptions.append( - await self.hass.components.mqtt.async_subscribe( - f"{self._topic}/state/antiburn", antiburn_state_message_received + await async_subscribe( + self.hass, + f"{self._topic}/state/antiburn", + antiburn_state_message_received, ) ) self._state = False self._available = True - await self.hass.components.mqtt.async_publish( + await async_publish( self.hass, f"{self._topic}/command/antiburn", int(self._state),