diff --git a/custom_components/myhome/__init__.py b/custom_components/myhome/__init__.py index 0c0f03f..eefb4ad 100644 --- a/custom_components/myhome/__init__.py +++ b/custom_components/myhome/__init__.py @@ -1,226 +1,285 @@ -""" MyHOME integration. """ -import aiofiles -import yaml - -from OWNd.message import OWNCommand, OWNGatewayCommand - -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.const import CONF_MAC - -from .const import ( - ATTR_GATEWAY, - ATTR_MESSAGE, - CONF_PLATFORMS, - CONF_ENTITY, - CONF_ENTITIES, - CONF_GATEWAY, - CONF_WORKER_COUNT, - CONF_FILE_PATH, - CONF_GENERATE_EVENTS, - DOMAIN, - LOGGER, -) -from .validate import config_schema, format_mac -from .gateway import MyHOMEGatewayHandler - -PLATFORMS = ["light", "switch", "cover", "climate", "binary_sensor", "sensor"] - - -async def async_setup(hass, config): - """Set up the MyHOME component.""" - hass.data[DOMAIN] = {} - - if DOMAIN not in config: - return True - - LOGGER.error("configuration.yaml not supported for this component!") - - return False - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): - if entry.data[CONF_MAC] not in hass.data[DOMAIN]: - hass.data[DOMAIN][entry.data[CONF_MAC]] = {} - - _config_file_path = str(entry.options[CONF_FILE_PATH]) if CONF_FILE_PATH in entry.options else "/config/myhome.yaml" - _generate_events = entry.options[CONF_GENERATE_EVENTS] if CONF_GENERATE_EVENTS in entry.options else False - - try: - async with aiofiles.open(_config_file_path, mode="r") as yaml_file: - _validated_config = config_schema(yaml.safe_load(await yaml_file.read())) - except FileNotFoundError: - LOGGER.error(f"Configartion file '{_config_file_path}' is not present!") - return False - - if entry.data[CONF_MAC] in _validated_config: - hass.data[DOMAIN][entry.data[CONF_MAC]] = _validated_config[entry.data[CONF_MAC]] - else: - return False - - # Migrating the config entry's unique_id if it was not formated to the recommended hass standard - if entry.unique_id != dr.format_mac(entry.unique_id): - hass.config_entries.async_update_entry(entry, unique_id=dr.format_mac(entry.unique_id)) - LOGGER.warning("Migrating config entry unique_id to %s", entry.unique_id) - - hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY] = MyHOMEGatewayHandler(hass=hass, config_entry=entry, generate_events=_generate_events) - - try: - tests_results = await hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY].test() - except OSError as ose: - _gateway_handler = hass.data[DOMAIN].pop(CONF_GATEWAY) - _host = _gateway_handler.gateway.host - raise ConfigEntryNotReady(f"Gateway cannot be reached at {_host}, make sure its address is correct.") from ose - - if not tests_results["Success"]: - if tests_results["Message"] == "password_error" or tests_results["Message"] == "password_required": - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH}, - data=entry.data, - ) - ) - del hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY] - return False - - _command_worker_count = int(entry.options[CONF_WORKER_COUNT]) if CONF_WORKER_COUNT in entry.options else 1 - - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - - gateway_entry = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, entry.data[CONF_MAC])}, - identifiers={(DOMAIN, hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY].unique_id)}, - manufacturer=hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY].manufacturer, - name=hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY].name, - model=hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY].model, - sw_version=hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY].firmware, - ) - - await hass.config_entries.async_forward_entry_setups(entry, hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_PLATFORMS].keys()) - - hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY].listening_worker = hass.loop.create_task(hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY].listening_loop()) - for i in range(_command_worker_count): - hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY].sending_workers.append(hass.loop.create_task(hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_ENTITY].sending_loop(i))) - - # Pruning lose entities and devices from the registry - entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - - entities_to_be_removed = [] - devices_to_be_removed = [device_entry.id for device_entry in device_registry.devices.values() if entry.entry_id in device_entry.config_entries] - - if gateway_entry.id in devices_to_be_removed: - devices_to_be_removed.remove(gateway_entry.id) - - configured_entities = [] - - for _platform in hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_PLATFORMS].keys(): - for _device in hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_PLATFORMS][_platform].keys(): - for _entity_name in hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_PLATFORMS][_platform][_device][CONF_ENTITIES]: - if _entity_name != _platform: - configured_entities.append( - f"{entry.data[CONF_MAC]}-{_device}-{_entity_name}" - ) # extrapolating _attr_unique_id out of the entity's place in the config data structure - else: - configured_entities.append(f"{entry.data[CONF_MAC]}-{_device}") # extrapolating _attr_unique_id out of the entity's place in the config data structure - - for entity_entry in entity_entries: - if entity_entry.unique_id in configured_entities: - if entity_entry.device_id in devices_to_be_removed: - devices_to_be_removed.remove(entity_entry.device_id) - continue - entities_to_be_removed.append(entity_entry.entity_id) - - for enity_id in entities_to_be_removed: - entity_registry.async_remove(enity_id) - - for device_id in devices_to_be_removed: - if len(er.async_entries_for_device(entity_registry, device_id, include_disabled_entities=True)) == 0: - device_registry.async_remove_device(device_id) - - # Defining the services - async def handle_sync_time(call): - gateway = call.data.get(ATTR_GATEWAY, None) - if gateway is None: - gateway = list(hass.data[DOMAIN].keys())[0] - else: - mac = format_mac(gateway) - if mac is None: - LOGGER.error( - "Invalid gateway mac `%s`, could not send time synchronisation message.", - gateway, - ) - return False - else: - gateway = mac - timezone = hass.config.as_dict()["time_zone"] - if gateway in hass.data[DOMAIN]: - await hass.data[DOMAIN][gateway][CONF_ENTITY].send(OWNGatewayCommand.set_datetime_to_now(timezone)) - else: - LOGGER.error( - "Gateway `%s` not found, could not send time synchronisation message.", - gateway, - ) - return False - - hass.services.async_register(DOMAIN, "sync_time", handle_sync_time) - - async def handle_send_message(call): - gateway = call.data.get(ATTR_GATEWAY, None) - message = call.data.get(ATTR_MESSAGE, None) - if gateway is None: - gateway = list(hass.data[DOMAIN].keys())[0] - else: - mac = format_mac(gateway) - if mac is None: - LOGGER.error( - "Invalid gateway mac `%s`, could not send message `%s`.", - gateway, - message, - ) - return False - else: - gateway = mac - LOGGER.debug("Handling message `%s` to be sent to `%s`", message, gateway) - if gateway in hass.data[DOMAIN]: - if message is not None: - own_message = OWNCommand.parse(message) - if own_message is not None: - if own_message.is_valid: - LOGGER.debug( - "%s Sending valid OpenWebNet Message: `%s`", - hass.data[DOMAIN][gateway][CONF_ENTITY].log_id, - own_message, - ) - await hass.data[DOMAIN][gateway][CONF_ENTITY].send(own_message) - else: - LOGGER.error("Could not parse message `%s`, not sending it.", message) - return False - else: - LOGGER.error("Gateway `%s` not found, could not send message `%s`.", gateway, message) - return False - - hass.services.async_register(DOMAIN, "send_message", handle_send_message) - - return True - - -async def async_unload_entry(hass, entry): - """Unload a config entry.""" - - LOGGER.info("Unloading MyHome entry.") - - for platform in hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_PLATFORMS].keys(): - await hass.config_entries.async_forward_entry_unload(entry, platform) - - hass.services.async_remove(DOMAIN, "sync_time") - hass.services.async_remove(DOMAIN, "send_message") - - gateway_handler = hass.data[DOMAIN][entry.data[CONF_MAC]].pop(CONF_ENTITY) - del hass.data[DOMAIN][entry.data[CONF_MAC]] - - return await gateway_handler.close_listener() +""" MyHOME integration. """ +import aiofiles +import yaml + +from OWNd.message import OWNCommand, OWNGatewayCommand + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.const import CONF_MAC + +from .const import ( + ATTR_GATEWAY, + ATTR_MESSAGE, + CONF_PLATFORMS, + CONF_ENTITY, + CONF_ENTITIES, + CONF_GATEWAY, + CONF_WORKER_COUNT, + CONF_FILE_PATH, + CONF_GENERATE_EVENTS, + DOMAIN, + LOGGER, +) +from .validate import config_schema, format_mac +from .gateway import MyHOMEGatewayHandler + +PLATFORMS = ["light", "switch", "cover", "climate", "binary_sensor", "sensor"] + + +async def async_setup(hass, config): + """Set up the MyHOME component.""" + hass.data[DOMAIN] = {} + + if DOMAIN not in config: + return True + + LOGGER.error("configuration.yaml not supported for this component!") + + return False + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + + mac = entry.data[CONF_MAC] + if mac not in hass.data[DOMAIN]: + hass.data[DOMAIN][mac] = {} + + _config_file_path = ( + str(entry.options[CONF_FILE_PATH]) + if CONF_FILE_PATH in entry.options + else "/config/myhome.yaml" + ) + _generate_events = ( + entry.options[CONF_GENERATE_EVENTS] + if CONF_GENERATE_EVENTS in entry.options + else False + ) + + try: + async with aiofiles.open(_config_file_path, mode="r") as yaml_file: + _validated_config = config_schema(yaml.safe_load(await yaml_file.read())) + except FileNotFoundError as e: + LOGGER.error(f"Configuration file '{_config_file_path}' is not present: %s", e) + return False + + if mac in _validated_config: + hass.data[DOMAIN][mac] = _validated_config[mac] + else: + LOGGER.error("Configuration file '%s' does not contain any configuration for the gateway with MAC address '%s'. Failing the configuration.", + _config_file_path, mac) + return False + + # Migrating the config entry's unique_id if it was not formatted to the recommended hass standard + if entry.unique_id != dr.format_mac(entry.unique_id): + hass.config_entries.async_update_entry( + entry, unique_id=dr.format_mac(entry.unique_id) + ) + LOGGER.warning("Migrating config entry unique_id to %s", entry.unique_id) + + hass.data[DOMAIN][mac][CONF_ENTITY] = MyHOMEGatewayHandler( + hass=hass, config_entry=entry, generate_events=_generate_events + ) + + try: + tests_results = await hass.data[DOMAIN][mac][CONF_ENTITY].test() + except OSError as ose: + _gateway_handler = hass.data[DOMAIN].pop(CONF_GATEWAY) + _host = _gateway_handler.gateway.host + raise ConfigEntryNotReady( + f"Gateway cannot be reached at {_host}, make sure its address is correct." + ) from ose + + if not tests_results["Success"]: + if ( + tests_results["Message"] == "password_error" + or tests_results["Message"] == "password_required" + ): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=entry.data, + ) + ) + del hass.data[DOMAIN][mac][CONF_ENTITY] + LOGGER.error("Failed configuration of gateway with MAC address '%s': %s", mac, tests_results["Message"]) + return False + + _command_worker_count = ( + int(entry.options[CONF_WORKER_COUNT]) + if CONF_WORKER_COUNT in entry.options + else 1 + ) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + LOGGER.info( + "Registering gateway with name '%s' and MAC address '%s'", + hass.data[DOMAIN][mac][CONF_ENTITY].name, mac + ) + gateway_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={ + (DOMAIN, hass.data[DOMAIN][mac][CONF_ENTITY].unique_id) + }, + manufacturer=hass.data[DOMAIN][mac][CONF_ENTITY].manufacturer, + name=hass.data[DOMAIN][mac][CONF_ENTITY].name, + model=hass.data[DOMAIN][mac][CONF_ENTITY].model, + sw_version=hass.data[DOMAIN][mac][CONF_ENTITY].firmware, + ) + + await hass.config_entries.async_forward_entry_setups(entry, hass.data[DOMAIN][mac][CONF_PLATFORMS].keys()) + + hass.data[DOMAIN][mac][CONF_ENTITY].listening_worker = hass.loop.create_task( + hass.data[DOMAIN][mac][CONF_ENTITY].listening_loop() + ) + for i in range(_command_worker_count): + hass.data[DOMAIN][mac][CONF_ENTITY].sending_workers.append( + hass.loop.create_task( + hass.data[DOMAIN][mac][CONF_ENTITY].sending_loop(i) + ) + ) + + # Pruning lost entities and devices from the registry + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + entities_to_be_removed = [] + devices_to_be_removed = [ + device_entry.id + for device_entry in device_registry.devices.values() + if entry.entry_id in device_entry.config_entries + ] + + if gateway_entry.id in devices_to_be_removed: + devices_to_be_removed.remove(gateway_entry.id) + + configured_entities = [] + + for _platform in hass.data[DOMAIN][mac][CONF_PLATFORMS].keys(): + for _device in hass.data[DOMAIN][mac][CONF_PLATFORMS][_platform].keys(): + for _entity_name in hass.data[DOMAIN][mac][CONF_PLATFORMS][_platform][_device][CONF_ENTITIES]: + LOGGER.info("Registering entity %s", _entity_name) + + if _entity_name != _platform: + configured_entities.append( + f"{mac}-{_device}-{_entity_name}" + ) # extrapolating _attr_unique_id out of the entity's place in the config data structure + else: + configured_entities.append( + f"{mac}-{_device}" + ) # extrapolating _attr_unique_id out of the entity's place in the config data structure + + for entity_entry in entity_entries: + if entity_entry.unique_id in configured_entities: + if entity_entry.device_id in devices_to_be_removed: + devices_to_be_removed.remove(entity_entry.device_id) + continue + entities_to_be_removed.append(entity_entry.entity_id) + + for enity_id in entities_to_be_removed: + entity_registry.async_remove(enity_id) + + for device_id in devices_to_be_removed: + if ( + len( + er.async_entries_for_device( + entity_registry, device_id, include_disabled_entities=True + ) + ) + == 0 + ): + device_registry.async_remove_device(device_id) + + # Defining the services + async def handle_sync_time(call): + gateway = call.data.get(ATTR_GATEWAY, None) + if gateway is None: + gateway = list(hass.data[DOMAIN].keys())[0] + else: + mac = format_mac(gateway) + if mac is None: + LOGGER.error( + "Invalid gateway mac `%s`, could not send time synchronisation message.", + gateway, + ) + return False + else: + gateway = mac + timezone = hass.config.as_dict()["time_zone"] + if gateway in hass.data[DOMAIN]: + await hass.data[DOMAIN][gateway][CONF_ENTITY].send( + OWNGatewayCommand.set_datetime_to_now(timezone) + ) + else: + LOGGER.error( + "Gateway `%s` not found, could not send time synchronisation message.", + gateway, + ) + return False + + hass.services.async_register(DOMAIN, "sync_time", handle_sync_time) + + async def handle_send_message(call): + gateway = call.data.get(ATTR_GATEWAY, None) + message = call.data.get(ATTR_MESSAGE, None) + if gateway is None: + gateway = list(hass.data[DOMAIN].keys())[0] + else: + mac = format_mac(gateway) + if mac is None: + LOGGER.error( + "Invalid gateway mac `%s`, could not send message `%s`.", + gateway, + message, + ) + return False + else: + gateway = mac + LOGGER.debug("Handling message `%s` to be sent to `%s`", message, gateway) + if gateway in hass.data[DOMAIN]: + if message is not None: + own_message = OWNCommand.parse(message) + if own_message is not None: + if own_message.is_valid: + LOGGER.debug( + "%s Sending valid OpenWebNet Message: `%s`", + hass.data[DOMAIN][gateway][CONF_ENTITY].log_id, + own_message, + ) + await hass.data[DOMAIN][gateway][CONF_ENTITY].send(own_message) + else: + LOGGER.error( + "Could not parse message `%s`, not sending it.", message + ) + return False + else: + LOGGER.error( + "Gateway `%s` not found, could not send message `%s`.", gateway, message + ) + return False + + hass.services.async_register(DOMAIN, "send_message", handle_send_message) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + + LOGGER.info("Unloading MyHome entry.") + + for platform in hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_PLATFORMS].keys(): + await hass.config_entries.async_forward_entry_unload(entry, platform) + + hass.services.async_remove(DOMAIN, "sync_time") + hass.services.async_remove(DOMAIN, "send_message") + + gateway_handler = hass.data[DOMAIN][entry.data[CONF_MAC]].pop(CONF_ENTITY) + del hass.data[DOMAIN][entry.data[CONF_MAC]] + + return await gateway_handler.close_listener() diff --git a/custom_components/myhome/config_flow.py b/custom_components/myhome/config_flow.py index a7ec7d1..98e9dd2 100644 --- a/custom_components/myhome/config_flow.py +++ b/custom_components/myhome/config_flow.py @@ -1,449 +1,450 @@ -"""Config flow to configure MyHome.""" -import asyncio -import ipaddress -import re -import os -from typing import Dict, Optional - -import async_timeout -from voluptuous import ( - Schema, - Required, - Coerce, - All, - In, - Range, - IsFile, -) -from homeassistant.config_entries import ( - CONN_CLASS_LOCAL_PUSH, - ConfigEntry, - ConfigFlow, - OptionsFlow, -) -from homeassistant.const import ( - CONF_FRIENDLY_NAME, - CONF_HOST, - CONF_ID, - CONF_MAC, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, -) -from homeassistant.core import callback -from homeassistant.helpers import device_registry as dr -from OWNd.connection import OWNGateway, OWNSession -from OWNd.discovery import find_gateways - -from .const import ( - CONF_ADDRESS, - CONF_DEVICE_TYPE, - CONF_FIRMWARE, - CONF_MANUFACTURER, - CONF_MANUFACTURER_URL, - CONF_OWN_PASSWORD, - CONF_SSDP_LOCATION, - CONF_SSDP_ST, - CONF_UDN, - CONF_WORKER_COUNT, - CONF_FILE_PATH, - CONF_GENERATE_EVENTS, - DOMAIN, - LOGGER, -) -from .gateway import MyHOMEGatewayHandler - - -class MACAddress: - def __init__(self, mac: str): - mac = re.sub("[.:-]", "", mac).upper() - mac = "".join(mac.split()) - if len(mac) != 12 or not mac.isalnum() or re.search("[G-Z]", mac) is not None: - raise ValueError("Invalid MAC address") - self.mac = mac - - def __repr__(self) -> str: - return ":".join(["%s" % (self.mac[i : i + 2]) for i in range(0, 12, 2)]) - - def __str__(self) -> str: - return ":".join(["%s" % (self.mac[i : i + 2]) for i in range(0, 12, 2)]) - - -class MyhomeFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a MyHome config flow.""" - - VERSION = 1 - CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return MyhomeOptionsFlowHandler(config_entry) - - def __init__(self): - """Initialize the MyHome flow.""" - self.gateway_handler: Optional[OWNGateway] = None - self.discovered_gateways: Optional[Dict[str, OWNGateway]] = None - self._existing_entry: ConfigEntry = None - - async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - - # Check if user chooses manual entry - if user_input is not None and user_input["serial"] == "00:00:00:00:00:00": - return await self.async_step_custom() - - if user_input is not None and self.discovered_gateways is not None and user_input["serial"] in self.discovered_gateways: - self.gateway_handler = await OWNGateway.build_from_discovery_info(self.discovered_gateways[user_input["serial"]]) - await self.async_set_unique_id( - dr.format_mac(self.gateway_handler.serial), - raise_on_progress=False, - ) - # We pass user input to link so it will attempt to link right away - return await self.async_step_test_connection() - - try: - with async_timeout.timeout(5): - local_gateways = await find_gateways() - except asyncio.TimeoutError: - return self.async_abort(reason="discovery_timeout") - - # Find already configured hosts - already_configured = self._async_current_ids(False) - if user_input is not None: - local_gateways = [gateway for gateway in local_gateways if dr.format_mac(f'{MACAddress(user_input["serialNumber"])}') not in already_configured] - - # if not local_gateways: - # return self.async_abort(reason="all_configured") - - self.discovered_gateways = {gateway["serialNumber"]: gateway for gateway in local_gateways} - - return self.async_show_form( - step_id="user", - data_schema=Schema( - { - Required("serial"): In( - { - **{gateway["serialNumber"]: f"{gateway['modelName']} Gateway ({gateway['address']})" for gateway in local_gateways}, - "00:00:00:00:00:00": "Custom", - } - ) - } - ), - ) - - async def async_step_custom(self, user_input=None, errors={}): # pylint: disable=dangerous-default-value - """Handle manual gateway setup.""" - - if user_input is not None: - try: - user_input["address"] = str(ipaddress.IPv4Address(user_input["address"])) - except ipaddress.AddressValueError: - errors["address"] = "invalid_ip" - - try: - user_input["serialNumber"] = dr.format_mac(f'{MACAddress(user_input["serialNumber"])}') - except ValueError: - errors["serialNumber"] = "invalid_mac" - - if not errors: - user_input["ssdp_location"] = (None,) - user_input["ssdp_st"] = (None,) - user_input["deviceType"] = (None,) - user_input["friendlyName"] = (None,) - user_input["manufacturer"] = ("BTicino S.p.A.",) - user_input["manufacturerURL"] = ("http://www.bticino.it",) - user_input["modelNumber"] = (None,) - user_input["UDN"] = (None,) - self.gateway_handler = OWNGateway(user_input) - await self.async_set_unique_id(user_input["serialNumber"], raise_on_progress=False) - return await self.async_step_test_connection() - - address_suggestion = user_input["address"] if user_input is not None and user_input["address"] is not None else "192.168.1.135" - port_suggestion = user_input["port"] if user_input is not None and user_input["port"] is not None else 20000 - serial_number_suggestion = user_input["serialNumber"] if user_input is not None and user_input["serialNumber"] is not None else "00:03:50:00:00:00" - model_name_suggestion = user_input["modelName"] if user_input is not None and user_input["modelName"] is not None else "F454" - - return self.async_show_form( - step_id="custom", - data_schema=Schema( - { - Required("address", description={"suggested_value": address_suggestion}): str, - Required("port", description={"suggested_value": port_suggestion}): int, - Required( - "serialNumber", - description={"suggested_value": serial_number_suggestion}, - ): str, - Required( - "modelName", - description={"suggested_value": model_name_suggestion}, - ): str, - } - ), - errors=errors, - ) - - async def async_step_reauth(self, config: dict = None): - """Perform reauth upon an authentication error.""" - - self._existing_entry = await self.async_set_unique_id(config[CONF_MAC]) - - self.gateway_handler = MyHOMEGatewayHandler(hass=self.hass, config_entry=self._existing_entry).gateway - - self.context.update( - { - CONF_HOST: self.gateway_handler.host, - CONF_NAME: self.gateway_handler.model, - CONF_MAC: self.gateway_handler.serial, - "title_placeholders": { - CONF_HOST: self.gateway_handler.host, - CONF_NAME: self.gateway_handler.model, - CONF_MAC: self.gateway_handler.serial, - }, - } - ) - - return await self.async_step_password(errors={CONF_OWN_PASSWORD: "password_error"}) - - async def async_step_test_connection(self, user_input=None, errors={}): # pylint: disable=unused-argument,dangerous-default-value - """Testing connection to the OWN Gateway. - - Given a configured gateway, will attempt to connect and negociate a - dummy event session to validate all parameters. - """ - gateway = self.gateway_handler - assert gateway is not None - - self.context.update( - { - CONF_HOST: gateway.host, - CONF_NAME: gateway.model_name, - CONF_MAC: gateway.serial, - "title_placeholders": { - CONF_HOST: gateway.host, - CONF_NAME: gateway.model_name, - CONF_MAC: gateway.serial, - }, - } - ) - - test_session = OWNSession(gateway=gateway, logger=LOGGER) - test_result = await test_session.test_connection() - - if test_result["Success"]: - _new_entry_data = { - CONF_ID: dr.format_mac(gateway.serial), - CONF_HOST: gateway.address, - CONF_PORT: gateway.port, - CONF_PASSWORD: gateway.password, - CONF_SSDP_LOCATION: gateway.ssdp_location, - CONF_SSDP_ST: gateway.ssdp_st, - CONF_DEVICE_TYPE: gateway.device_type, - CONF_FRIENDLY_NAME: gateway.friendly_name, - CONF_MANUFACTURER: gateway.manufacturer, - CONF_MANUFACTURER_URL: gateway.manufacturer_url, - CONF_NAME: gateway.model_name, - CONF_FIRMWARE: gateway.model_number, - CONF_MAC: dr.format_mac(gateway.serial), - CONF_UDN: gateway.udn, - } - _new_entry_options = { - CONF_WORKER_COUNT: self._existing_entry.options[CONF_WORKER_COUNT] if self._existing_entry and CONF_WORKER_COUNT in self._existing_entry.options else 1, - } - - if self._existing_entry: - self.hass.config_entries.async_update_entry( - self._existing_entry, - data=_new_entry_data, - options=_new_entry_options, - ) - await self.hass.config_entries.async_reload(self._existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - else: - return self.async_create_entry( - title=f"{gateway.model_name} Gateway", - data=_new_entry_data, - options=_new_entry_options, - ) - else: - if test_result["Message"] == "password_required": - return await self.async_step_password() - elif test_result["Message"] == "password_error" or test_result["Message"] == "password_retry": - errors["password"] = test_result["Message"] - return await self.async_step_password(errors=errors) - else: - return self.async_abort(reason=test_result["Message"]) - - async def async_step_port(self, user_input=None, errors={}): # pylint: disable=dangerous-default-value - """Port information for the gateway is missing. - - Asking user to provide the port on which the gateway is listening. - """ - if user_input is not None: - # Validate user input - if 1 <= int(user_input[CONF_PORT]) <= 65535: - self.gateway_handler.port = int(user_input[CONF_PORT]) - return await self.async_step_test_connection() - errors["port"] = "invalid_port" - - return self.async_show_form( - step_id="port", - data_schema=Schema( - { - Required(CONF_PORT, description={"suggested_value": 20000}): int, - } - ), - description_placeholders={ - CONF_HOST: self.context[CONF_HOST], - CONF_NAME: self.context[CONF_NAME], - CONF_MAC: self.context[CONF_MAC], - }, - errors=errors, - ) - - async def async_step_password(self, user_input=None, errors={}): # pylint: disable=dangerous-default-value - """Password is required to connect the gateway. - - Asking user to provide the gateway's password. - """ - if user_input is not None: - # Validate user input - self.gateway_handler.password = str(user_input[CONF_OWN_PASSWORD]) - return await self.async_step_test_connection() - else: - if self.gateway_handler.password is not None: - _suggested_password = self.gateway_handler.password - else: - _suggested_password = 12345 - - return self.async_show_form( - step_id="password", - data_schema=Schema( - { - Required( - CONF_OWN_PASSWORD, - description={"suggested_value": _suggested_password}, - ): Coerce(str), - } - ), - description_placeholders={ - CONF_HOST: self.context[CONF_HOST], - CONF_NAME: self.context[CONF_NAME], - CONF_MAC: self.context[CONF_MAC], - }, - errors=errors, - ) - - async def async_step_ssdp(self, discovery_info): - """Handle a discovered OpenWebNet gateway. - - This flow is triggered by the SSDP component. It will check if the - gateway is already configured and if not, it will ask for the connection port - if it has not been discovered on its own, and test the connection. - """ - - _discovery_info = discovery_info.upnp - _discovery_info["ssdp_st"] = discovery_info.ssdp_st - _discovery_info["ssdp_location"] = discovery_info.ssdp_location - _discovery_info["address"] = discovery_info.ssdp_headers["_host"] - _discovery_info["port"] = 20000 - - gateway = await OWNGateway.build_from_discovery_info(_discovery_info) - await self.async_set_unique_id(dr.format_mac(gateway.unique_id)) - LOGGER.info("Found gateway: %s", gateway.address) - updatable = { - CONF_HOST: gateway.address, - CONF_NAME: gateway.model_name, - CONF_FRIENDLY_NAME: gateway.friendly_name, - CONF_UDN: gateway.udn, - CONF_FIRMWARE: gateway.firmware, - } - if gateway.port is not None: - updatable[CONF_PORT] = gateway.port - - self._abort_if_unique_id_configured(updates=updatable) - - self.gateway_handler = gateway - - if self.gateway_handler.port is None: - return await self.async_step_port() - return await self.async_step_test_connection() - - -class MyhomeOptionsFlowHandler(OptionsFlow): - """Handle MyHome options.""" - - def __init__(self, config_entry): - """Initialize MyHome options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - self.data = dict(config_entry.data) - if CONF_WORKER_COUNT not in self.options: - self.options[CONF_WORKER_COUNT] = 1 - if CONF_FILE_PATH not in self.options: - self.options[CONF_FILE_PATH] = "/config/myhome.yaml" - if CONF_GENERATE_EVENTS not in self.options: - self.options[CONF_GENERATE_EVENTS] = False - - async def async_step_init(self, user_input=None): # pylint: disable=unused-argument - """Manage the MyHome options.""" - return await self.async_step_user() - - async def async_step_user(self, user_input=None, errors={}): # pylint: disable=dangerous-default-value - """Manage the MyHome devices options.""" - - errors = {} - - if user_input is not None: - if not os.path.isfile(user_input[CONF_FILE_PATH]): - errors[CONF_FILE_PATH] = "invalid_config_path" - - self.options.update({CONF_WORKER_COUNT: user_input[CONF_WORKER_COUNT]}) - self.options.update({CONF_FILE_PATH: user_input[CONF_FILE_PATH]}) - self.options.update({CONF_GENERATE_EVENTS: user_input[CONF_GENERATE_EVENTS]}) - - _data_update = not (self.data[CONF_HOST] == user_input[CONF_ADDRESS] and self.data[CONF_OWN_PASSWORD] == user_input[CONF_OWN_PASSWORD]) - self.data.update({CONF_HOST: user_input[CONF_ADDRESS]}) - self.data.update({CONF_OWN_PASSWORD: user_input[CONF_OWN_PASSWORD]}) - - try: - self.data[CONF_HOST] = str(ipaddress.IPv4Address(self.data[CONF_HOST])) - except ipaddress.AddressValueError: - errors[CONF_ADDRESS] = "invalid_ip" - - if not errors: - if _data_update: - self.hass.config_entries.async_update_entry(self.config_entry, data=self.data) - await self.hass.config_entries.async_reload(self.config_entry.entry_id) - - return self.async_create_entry(title="", data=self.options) - - return self.async_show_form( - step_id="user", - data_schema=Schema( - { - Required( - CONF_ADDRESS, - description={"suggested_value": self.data[CONF_HOST]}, - ): str, - Required( - CONF_OWN_PASSWORD, - description={"suggested_value": self.data[CONF_PASSWORD]}, - ): str, - Required( - CONF_FILE_PATH, - description={"suggested_value": self.options[CONF_FILE_PATH]}, - ): Coerce(str), - Required( - CONF_WORKER_COUNT, - description={"suggested_value": self.options[CONF_WORKER_COUNT]}, - ): All(Coerce(int), Range(min=1, max=10)), - Required( - CONF_GENERATE_EVENTS, - description={"suggested_value": self.options[CONF_GENERATE_EVENTS]}, - ): bool, - } - ), - errors=errors, - ) +"""Config flow to configure MyHome.""" +import asyncio +import ipaddress +import re +import os +from typing import Dict, Optional + +import async_timeout +from voluptuous import ( + Schema, + Required, + Coerce, + All, + In, + Range, + IsFile, +) +from homeassistant.config_entries import ( + CONN_CLASS_LOCAL_PUSH, + ConfigEntry, + ConfigFlow, + OptionsFlow, +) +from homeassistant.const import ( + CONF_FRIENDLY_NAME, + CONF_HOST, + CONF_ID, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, +) +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from OWNd.connection import OWNGateway, OWNSession +from OWNd.discovery import find_gateways + +from .const import ( + CONF_ADDRESS, + CONF_DEVICE_TYPE, + CONF_FIRMWARE, + CONF_MANUFACTURER, + CONF_MANUFACTURER_URL, + CONF_OWN_PASSWORD, + CONF_SSDP_LOCATION, + CONF_SSDP_ST, + CONF_UDN, + CONF_WORKER_COUNT, + CONF_FILE_PATH, + CONF_GENERATE_EVENTS, + DOMAIN, + LOGGER, +) +from .gateway import MyHOMEGatewayHandler + + +class MACAddress: + def __init__(self, mac: str): + mac = re.sub("[.:-]", "", mac).upper() + mac = "".join(mac.split()) + if len(mac) != 12 or not mac.isalnum() or re.search("[G-Z]", mac) is not None: + raise ValueError("Invalid MAC address") + self.mac = mac + + def __repr__(self) -> str: + return ":".join(["%s" % (self.mac[i : i + 2]) for i in range(0, 12, 2)]) + + def __str__(self) -> str: + return ":".join(["%s" % (self.mac[i : i + 2]) for i in range(0, 12, 2)]) + + +class MyhomeFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a MyHome config flow.""" + + VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return MyhomeOptionsFlowHandler(config_entry) + + def __init__(self): + """Initialize the MyHome flow.""" + self.gateway_handler: Optional[OWNGateway] = None + self.discovered_gateways: Optional[Dict[str, OWNGateway]] = None + self._existing_entry: ConfigEntry = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + + # Check if user chooses manual entry + if user_input is not None and user_input["serial"] == "00:00:00:00:00:00": + return await self.async_step_custom() + + if user_input is not None and self.discovered_gateways is not None and user_input["serial"] in self.discovered_gateways: + self.gateway_handler = await OWNGateway.build_from_discovery_info(self.discovered_gateways[user_input["serial"]]) + await self.async_set_unique_id( + dr.format_mac(self.gateway_handler.serial), + raise_on_progress=False, + ) + # We pass user input to link so it will attempt to link right away + return await self.async_step_test_connection() + + try: + LOGGER.info("Launching gateway discovery with 5 sec timeout") + with async_timeout.timeout(5): + local_gateways = await find_gateways() + except asyncio.TimeoutError: + return self.async_abort(reason="discovery_timeout") + + # Find already configured hosts + already_configured = self._async_current_ids(False) + if user_input is not None: + local_gateways = [gateway for gateway in local_gateways if dr.format_mac(f'{MACAddress(user_input["serialNumber"])}') not in already_configured] + + # if not local_gateways: + # return self.async_abort(reason="all_configured") + LOGGER.info("Local gateways are: %s", local_gateways) + self.discovered_gateways = {gateway["serialNumber"]: gateway for gateway in local_gateways} + + return self.async_show_form( + step_id="user", + data_schema=Schema( + { + Required("serial"): In( + { + **{gateway["serialNumber"]: f"{gateway['modelName']} Gateway ({gateway['address']})" for gateway in local_gateways}, + "00:00:00:00:00:00": "Custom", + } + ) + } + ), + ) + + async def async_step_custom(self, user_input=None, errors={}): # pylint: disable=dangerous-default-value + """Handle manual gateway setup.""" + + if user_input is not None: + try: + user_input["address"] = str(ipaddress.IPv4Address(user_input["address"])) + except ipaddress.AddressValueError: + errors["address"] = "invalid_ip" + + try: + user_input["serialNumber"] = dr.format_mac(f'{MACAddress(user_input["serialNumber"])}') + except ValueError: + errors["serialNumber"] = "invalid_mac" + + if not errors: + user_input["ssdp_location"] = (None,) + user_input["ssdp_st"] = (None,) + user_input["deviceType"] = (None,) + user_input["friendlyName"] = (None,) + user_input["manufacturer"] = ("BTicino S.p.A.",) + user_input["manufacturerURL"] = ("http://www.bticino.it",) + user_input["modelNumber"] = (None,) + user_input["UDN"] = (None,) + self.gateway_handler = OWNGateway(user_input) + await self.async_set_unique_id(user_input["serialNumber"], raise_on_progress=False) + return await self.async_step_test_connection() + + address_suggestion = user_input["address"] if user_input is not None and user_input["address"] is not None else "192.168.1.6" + port_suggestion = user_input["port"] if user_input is not None and user_input["port"] is not None else 20000 + serial_number_suggestion = user_input["serialNumber"] if user_input is not None and user_input["serialNumber"] is not None else "00:03:50:CA:37:A1" + model_name_suggestion = user_input["modelName"] if user_input is not None and user_input["modelName"] is not None else "MyHomeServer1" + + return self.async_show_form( + step_id="custom", + data_schema=Schema( + { + Required("address", description={"suggested_value": address_suggestion}): str, + Required("port", description={"suggested_value": port_suggestion}): int, + Required( + "serialNumber", + description={"suggested_value": serial_number_suggestion}, + ): str, + Required( + "modelName", + description={"suggested_value": model_name_suggestion}, + ): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, config: dict = None): + """Perform reauth upon an authentication error.""" + + self._existing_entry = await self.async_set_unique_id(config[CONF_MAC]) + + self.gateway_handler = MyHOMEGatewayHandler(hass=self.hass, config_entry=self._existing_entry).gateway + + self.context.update( + { + CONF_HOST: self.gateway_handler.host, + CONF_NAME: self.gateway_handler.model, + CONF_MAC: self.gateway_handler.serial, + "title_placeholders": { + CONF_HOST: self.gateway_handler.host, + CONF_NAME: self.gateway_handler.model, + CONF_MAC: self.gateway_handler.serial, + }, + } + ) + + return await self.async_step_password(errors={CONF_OWN_PASSWORD: "password_error"}) + + async def async_step_test_connection(self, user_input=None, errors={}): # pylint: disable=unused-argument,dangerous-default-value + """Testing connection to the OWN Gateway. + + Given a configured gateway, will attempt to connect and negociate a + dummy event session to validate all parameters. + """ + gateway = self.gateway_handler + assert gateway is not None + + self.context.update( + { + CONF_HOST: gateway.host, + CONF_NAME: gateway.model_name, + CONF_MAC: gateway.serial, + "title_placeholders": { + CONF_HOST: gateway.host, + CONF_NAME: gateway.model_name, + CONF_MAC: gateway.serial, + }, + } + ) + + test_session = OWNSession(gateway=gateway, logger=LOGGER) + test_result = await test_session.test_connection() + + if test_result["Success"]: + _new_entry_data = { + CONF_ID: dr.format_mac(gateway.serial), + CONF_HOST: gateway.address, + CONF_PORT: gateway.port, + CONF_PASSWORD: gateway.password, + CONF_SSDP_LOCATION: gateway.ssdp_location, + CONF_SSDP_ST: gateway.ssdp_st, + CONF_DEVICE_TYPE: gateway.device_type, + CONF_FRIENDLY_NAME: gateway.friendly_name, + CONF_MANUFACTURER: gateway.manufacturer, + CONF_MANUFACTURER_URL: gateway.manufacturer_url, + CONF_NAME: gateway.model_name, + CONF_FIRMWARE: gateway.model_number, + CONF_MAC: dr.format_mac(gateway.serial), + CONF_UDN: gateway.udn, + } + _new_entry_options = { + CONF_WORKER_COUNT: self._existing_entry.options[CONF_WORKER_COUNT] if self._existing_entry and CONF_WORKER_COUNT in self._existing_entry.options else 1, + } + + if self._existing_entry: + self.hass.config_entries.async_update_entry( + self._existing_entry, + data=_new_entry_data, + options=_new_entry_options, + ) + await self.hass.config_entries.async_reload(self._existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + else: + return self.async_create_entry( + title=f"{gateway.model_name} Gateway", + data=_new_entry_data, + options=_new_entry_options, + ) + else: + if test_result["Message"] == "password_required": + return await self.async_step_password() + elif test_result["Message"] == "password_error" or test_result["Message"] == "password_retry": + errors["password"] = test_result["Message"] + return await self.async_step_password(errors=errors) + else: + return self.async_abort(reason=test_result["Message"]) + + async def async_step_port(self, user_input=None, errors={}): # pylint: disable=dangerous-default-value + """Port information for the gateway is missing. + + Asking user to provide the port on which the gateway is listening. + """ + if user_input is not None: + # Validate user input + if 1 <= int(user_input[CONF_PORT]) <= 65535: + self.gateway_handler.port = int(user_input[CONF_PORT]) + return await self.async_step_test_connection() + errors["port"] = "invalid_port" + + return self.async_show_form( + step_id="port", + data_schema=Schema( + { + Required(CONF_PORT, description={"suggested_value": 20000}): int, + } + ), + description_placeholders={ + CONF_HOST: self.context[CONF_HOST], + CONF_NAME: self.context[CONF_NAME], + CONF_MAC: self.context[CONF_MAC], + }, + errors=errors, + ) + + async def async_step_password(self, user_input=None, errors={}): # pylint: disable=dangerous-default-value + """Password is required to connect the gateway. + + Asking user to provide the gateway's password. + """ + if user_input is not None: + # Validate user input + self.gateway_handler.password = str(user_input[CONF_OWN_PASSWORD]) + return await self.async_step_test_connection() + else: + if self.gateway_handler.password is not None: + _suggested_password = self.gateway_handler.password + else: + _suggested_password = "willy111" + + return self.async_show_form( + step_id="password", + data_schema=Schema( + { + Required( + CONF_OWN_PASSWORD, + description={"suggested_value": _suggested_password}, + ): Coerce(str), + } + ), + description_placeholders={ + CONF_HOST: self.context[CONF_HOST], + CONF_NAME: self.context[CONF_NAME], + CONF_MAC: self.context[CONF_MAC], + }, + errors=errors, + ) + + async def async_step_ssdp(self, discovery_info): + """Handle a discovered OpenWebNet gateway. + + This flow is triggered by the SSDP component. It will check if the + gateway is already configured and if not, it will ask for the connection port + if it has not been discovered on its own, and test the connection. + """ + + _discovery_info = discovery_info.upnp + _discovery_info["ssdp_st"] = discovery_info.ssdp_st + _discovery_info["ssdp_location"] = discovery_info.ssdp_location + _discovery_info["address"] = discovery_info.ssdp_headers["_host"] + _discovery_info["port"] = 20000 + + gateway = await OWNGateway.build_from_discovery_info(_discovery_info) + await self.async_set_unique_id(dr.format_mac(gateway.unique_id)) + LOGGER.info("Found gateway: %s", gateway.address) + updatable = { + CONF_HOST: gateway.address, + CONF_NAME: gateway.model_name, + CONF_FRIENDLY_NAME: gateway.friendly_name, + CONF_UDN: gateway.udn, + CONF_FIRMWARE: gateway.firmware, + } + if gateway.port is not None: + updatable[CONF_PORT] = gateway.port + + self._abort_if_unique_id_configured(updates=updatable) + + self.gateway_handler = gateway + + if self.gateway_handler.port is None: + return await self.async_step_port() + return await self.async_step_test_connection() + + +class MyhomeOptionsFlowHandler(OptionsFlow): + """Handle MyHome options.""" + + def __init__(self, config_entry): + """Initialize MyHome options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + self.data = dict(config_entry.data) + if CONF_WORKER_COUNT not in self.options: + self.options[CONF_WORKER_COUNT] = 1 + if CONF_FILE_PATH not in self.options: + self.options[CONF_FILE_PATH] = "/config/myhome.yaml" + if CONF_GENERATE_EVENTS not in self.options: + self.options[CONF_GENERATE_EVENTS] = False + + async def async_step_init(self, user_input=None): # pylint: disable=unused-argument + """Manage the MyHome options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input=None, errors={}): # pylint: disable=dangerous-default-value + """Manage the MyHome devices options.""" + + errors = {} + + if user_input is not None: + if not os.path.isfile(user_input[CONF_FILE_PATH]): + errors[CONF_FILE_PATH] = "invalid_config_path" + + self.options.update({CONF_WORKER_COUNT: user_input[CONF_WORKER_COUNT]}) + self.options.update({CONF_FILE_PATH: user_input[CONF_FILE_PATH]}) + self.options.update({CONF_GENERATE_EVENTS: user_input[CONF_GENERATE_EVENTS]}) + + _data_update = not (self.data[CONF_HOST] == user_input[CONF_ADDRESS] and self.data[CONF_OWN_PASSWORD] == user_input[CONF_OWN_PASSWORD]) + self.data.update({CONF_HOST: user_input[CONF_ADDRESS]}) + self.data.update({CONF_OWN_PASSWORD: user_input[CONF_OWN_PASSWORD]}) + + try: + self.data[CONF_HOST] = str(ipaddress.IPv4Address(self.data[CONF_HOST])) + except ipaddress.AddressValueError: + errors[CONF_ADDRESS] = "invalid_ip" + + if not errors: + if _data_update: + self.hass.config_entries.async_update_entry(self.config_entry, data=self.data) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + + return self.async_create_entry(title="", data=self.options) + + return self.async_show_form( + step_id="user", + data_schema=Schema( + { + Required( + CONF_ADDRESS, + description={"suggested_value": self.data[CONF_HOST]}, + ): str, + Required( + CONF_OWN_PASSWORD, + description={"suggested_value": self.data[CONF_PASSWORD]}, + ): str, + Required( + CONF_FILE_PATH, + description={"suggested_value": self.options[CONF_FILE_PATH]}, + ): Coerce(str), + Required( + CONF_WORKER_COUNT, + description={"suggested_value": self.options[CONF_WORKER_COUNT]}, + ): All(Coerce(int), Range(min=1, max=10)), + Required( + CONF_GENERATE_EVENTS, + description={"suggested_value": self.options[CONF_GENERATE_EVENTS]}, + ): bool, + } + ), + errors=errors, + ) diff --git a/custom_components/myhome/const.py b/custom_components/myhome/const.py index 9c560b6..d14f052 100644 --- a/custom_components/myhome/const.py +++ b/custom_components/myhome/const.py @@ -1,48 +1,48 @@ -"""Constants for the MyHome component.""" -import logging - -LOGGER = logging.getLogger(__package__) -DOMAIN = "myhome" - -ATTR_GATEWAY = "gateway" -ATTR_MESSAGE = "message" - -CONF = "config" -CONF_ENTITY = "entity" -CONF_ENTITIES = "entities" -CONF_ENTITY_NAME = "entity_name" -CONF_ICON = "icon" -CONF_ICON_ON = "icon_on" -CONF_PLATFORMS = "platforms" -CONF_ADDRESS = "address" -CONF_OWN_PASSWORD = "password" -CONF_FIRMWARE = "firmware" -CONF_SSDP_LOCATION = "ssdp_location" -CONF_SSDP_ST = "ssdp_st" -CONF_DEVICE_TYPE = "deviceType" -CONF_DEVICE_MODEL = "model" -CONF_MANUFACTURER = "manufacturer" -CONF_MANUFACTURER_URL = "manufacturerURL" -CONF_UDN = "UDN" -CONF_WORKER_COUNT = "command_worker_count" -CONF_FILE_PATH = "config_file_path" -CONF_GENERATE_EVENTS = "generate_events" -CONF_PARENT_ID = "parent_id" -CONF_WHO = "who" -CONF_WHERE = "where" -CONF_BUS_INTERFACE = "interface" -CONF_ZONE = "zone" -CONF_DIMMABLE = "dimmable" -CONF_GATEWAY = "gateway" -CONF_DEVICE_CLASS = "class" -CONF_INVERTED = "inverted" -CONF_ADVANCED_SHUTTER = "advanced" -CONF_HEATING_SUPPORT = "heat" -CONF_COOLING_SUPPORT = "cool" -CONF_FAN_SUPPORT = "fan" -CONF_STANDALONE = "standalone" -CONF_CENTRAL = "central" -CONF_SHORT_PRESS = "pushbutton_short_press" -CONF_SHORT_RELEASE = "pushbutton_short_release" -CONF_LONG_PRESS = "pushbutton_long_press" -CONF_LONG_RELEASE = "pushbutton_long_release" +"""Constants for the MyHome component.""" +import logging + +LOGGER = logging.getLogger(__package__) +DOMAIN = "myhome" + +ATTR_GATEWAY = "gateway" +ATTR_MESSAGE = "message" + +CONF = "config" +CONF_ENTITY = "entity" +CONF_ENTITIES = "entities" +CONF_ENTITY_NAME = "entity_name" +CONF_ICON = "icon" +CONF_ICON_ON = "icon_on" +CONF_PLATFORMS = "platforms" +CONF_ADDRESS = "address" +CONF_OWN_PASSWORD = "password" +CONF_FIRMWARE = "firmware" +CONF_SSDP_LOCATION = "ssdp_location" +CONF_SSDP_ST = "ssdp_st" +CONF_DEVICE_TYPE = "deviceType" +CONF_DEVICE_MODEL = "model" +CONF_MANUFACTURER = "manufacturer" +CONF_MANUFACTURER_URL = "manufacturerURL" +CONF_UDN = "UDN" +CONF_WORKER_COUNT = "command_worker_count" +CONF_FILE_PATH = "config_file_path" +CONF_GENERATE_EVENTS = "generate_events" +CONF_PARENT_ID = "parent_id" +CONF_WHO = "who" +CONF_WHERE = "where" +CONF_BUS_INTERFACE = "interface" +CONF_ZONE = "zone" +CONF_DIMMABLE = "dimmable" +CONF_GATEWAY = "gateway" +CONF_DEVICE_CLASS = "class" +CONF_INVERTED = "inverted" +CONF_ADVANCED_SHUTTER = "advanced" +CONF_HEATING_SUPPORT = "heat" +CONF_COOLING_SUPPORT = "cool" +CONF_FAN_SUPPORT = "fan" +CONF_STANDALONE = "standalone" +CONF_CENTRAL = "central" +CONF_SHORT_PRESS = "pushbutton_short_press" +CONF_SHORT_RELEASE = "pushbutton_short_release" +CONF_LONG_PRESS = "pushbutton_long_press" +CONF_LONG_RELEASE = "pushbutton_long_release" diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 13c42e0..dfee344 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -36,18 +36,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if PLATFORM not in hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS]: + LOGGER.info("TODO: write message as to why we skip setup here") return True _covers = [] - _configured_covers = hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS][PLATFORM] + _configured_covers = hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS][ + PLATFORM + ] + LOGGER.info("Found %d configured covers", len(_configured_covers)) for _cover in _configured_covers.keys(): + LOGGER.info("Registering cover '%s'", _configured_covers[_cover][CONF_NAME]) _cover = MyHOMECover( hass=hass, device_id=_cover, who=_configured_covers[_cover][CONF_WHO], where=_configured_covers[_cover][CONF_WHERE], - interface=_configured_covers[_cover][CONF_BUS_INTERFACE] if CONF_BUS_INTERFACE in _configured_covers[_cover] else None, + interface=_configured_covers[_cover][CONF_BUS_INTERFACE] + if CONF_BUS_INTERFACE in _configured_covers[_cover] + else None, name=_configured_covers[_cover][CONF_NAME], entity_name=_configured_covers[_cover][CONF_ENTITY_NAME], advanced=_configured_covers[_cover][CONF_ADVANCED_SHUTTER], @@ -64,10 +71,14 @@ async def async_unload_entry(hass, config_entry): # pylint: disable=unused-argu if PLATFORM not in hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS]: return True - _configured_covers = hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS][PLATFORM] + _configured_covers = hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS][ + PLATFORM + ] for _cover in _configured_covers.keys(): - del hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS][PLATFORM][_cover] + del hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS][PLATFORM][ + _cover + ] class MyHOMECover(MyHOMEEntity, CoverEntity): @@ -102,9 +113,15 @@ def __init__( self._attr_name = entity_name self._interface = interface - self._full_where = f"{self._where}#4#{self._interface}" if self._interface is not None else self._where + self._full_where = ( + f"{self._where}#4#{self._interface}" + if self._interface is not None + else self._where + ) - self._attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) if advanced: self._attr_supported_features |= CoverEntityFeature.SET_POSITION self._gateway_handler = gateway @@ -126,25 +143,35 @@ async def async_update(self): Only used by the generic entity update service. """ - await self._gateway_handler.send_status_request(OWNAutomationCommand.status(self._full_where)) + await self._gateway_handler.send_status_request( + OWNAutomationCommand.status(self._full_where) + ) async def async_open_cover(self, **kwargs): # pylint: disable=unused-argument """Open the cover.""" - await self._gateway_handler.send(OWNAutomationCommand.raise_shutter(self._full_where)) + await self._gateway_handler.send( + OWNAutomationCommand.raise_shutter(self._full_where) + ) async def async_close_cover(self, **kwargs): # pylint: disable=unused-argument """Close cover.""" - await self._gateway_handler.send(OWNAutomationCommand.lower_shutter(self._full_where)) + await self._gateway_handler.send( + OWNAutomationCommand.lower_shutter(self._full_where) + ) async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] - await self._gateway_handler.send(OWNAutomationCommand.set_shutter_level(self._full_where, position)) + await self._gateway_handler.send( + OWNAutomationCommand.set_shutter_level(self._full_where, position) + ) async def async_stop_cover(self, **kwargs): # pylint: disable=unused-argument """Stop the cover.""" - await self._gateway_handler.send(OWNAutomationCommand.stop_shutter(self._full_where)) + await self._gateway_handler.send( + OWNAutomationCommand.stop_shutter(self._full_where) + ) def handle_event(self, message: OWNAutomationEvent): """Handle an event message.""" diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 821a20d..b5f30df 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -149,7 +149,10 @@ async def listening_loop(self): _event_content.update(message.event_content) self.hass.bus.async_fire("myhome_message_event", _event_content) else: - self.hass.bus.async_fire("myhome_message_event", {"gateway": str(self.gateway.host), "message": str(message)}) + self.hass.bus.async_fire( + "myhome_message_event", + {"gateway": str(self.gateway.host), "message": str(message)}, + ) if not isinstance(message, OWNMessage): LOGGER.warning( @@ -158,13 +161,23 @@ async def listening_loop(self): message, ) elif isinstance(message, OWNEnergyEvent): - if SENSOR in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS] and message.entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR]: - for _entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][message.entity][CONF_ENTITIES]: + if ( + SENSOR in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS] + and message.entity + in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR] + ): + for _entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][ + SENSOR + ][message.entity][CONF_ENTITIES]: if isinstance( - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][message.entity][CONF_ENTITIES][_entity], + self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][ + message.entity + ][CONF_ENTITIES][_entity], MyHOMEEntity, ): - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][message.entity][CONF_ENTITIES][_entity].handle_event(message) + self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][ + message.entity + ][CONF_ENTITIES][_entity].handle_event(message) else: continue elif ( @@ -185,7 +198,9 @@ async def listening_loop(self): {"message": str(message), "event": event}, ) await asyncio.sleep(0.1) - await self.send_status_request(OWNLightingCommand.status("0")) + await self.send_status_request( + OWNLightingCommand.status("0") + ) elif message.is_area: is_event = True event = "on" if message.is_on else "off" @@ -198,7 +213,9 @@ async def listening_loop(self): }, ) await asyncio.sleep(0.1) - await self.send_status_request(OWNLightingCommand.status(message.area)) + await self.send_status_request( + OWNLightingCommand.status(message.area) + ) elif message.is_group: is_event = True event = "on" if message.is_on else "off" @@ -256,31 +273,64 @@ async def listening_loop(self): }, ) if not is_event: - if isinstance(message, OWNLightingEvent) and message.brightness_preset: + if ( + isinstance(message, OWNLightingEvent) + and message.brightness_preset + ): if isinstance( - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][LIGHT][message.entity][CONF_ENTITIES][LIGHT], + self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][LIGHT][ + message.entity + ][CONF_ENTITIES][LIGHT], MyHOMEEntity, ): - await self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][LIGHT][message.entity][CONF_ENTITIES][LIGHT].async_update() + await self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][ + LIGHT + ][message.entity][CONF_ENTITIES][LIGHT].async_update() else: - for _platform in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS]: - if _platform != BUTTON and message.entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform]: - for _entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES]: + for _platform in self.hass.data[DOMAIN][self.mac][ + CONF_PLATFORMS + ]: + if ( + _platform != BUTTON + and message.entity + in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][ + _platform + ] + ): + for _entity in self.hass.data[DOMAIN][self.mac][ + CONF_PLATFORMS + ][_platform][message.entity][CONF_ENTITIES]: if ( isinstance( - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity], + self.hass.data[DOMAIN][self.mac][ + CONF_PLATFORMS + ][_platform][message.entity][ + CONF_ENTITIES + ][_entity], MyHOMEEntity, ) and not isinstance( - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity], + self.hass.data[DOMAIN][self.mac][ + CONF_PLATFORMS + ][_platform][message.entity][ + CONF_ENTITIES + ][_entity], DisableCommandButtonEntity, ) and not isinstance( - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity], + self.hass.data[DOMAIN][self.mac][ + CONF_PLATFORMS + ][_platform][message.entity][ + CONF_ENTITIES + ][_entity], EnableCommandButtonEntity, ) ): - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity].handle_event(message) + self.hass.data[DOMAIN][self.mac][ + CONF_PLATFORMS + ][_platform][message.entity][CONF_ENTITIES][ + _entity + ].handle_event(message) else: LOGGER.debug( @@ -288,8 +338,16 @@ async def listening_loop(self): self.log_id, message, ) - elif isinstance(message, OWNHeatingCommand) and message.dimension is not None and message.dimension == 14: - where = message.where[1:] if message.where.startswith("#") else message.where + elif ( + isinstance(message, OWNHeatingCommand) + and message.dimension is not None + and message.dimension == 14 + ): + where = ( + message.where[1:] + if message.where.startswith("#") + else message.where + ) LOGGER.debug( "%s Received heating command, sending query to zone %s", self.log_id, @@ -344,7 +402,9 @@ async def listening_loop(self): self.log_id, message.human_readable_log, ) - elif isinstance(message, OWNGatewayEvent) or isinstance(message, OWNGatewayCommand): + elif isinstance(message, OWNGatewayEvent) or isinstance( + message, OWNGatewayCommand + ): LOGGER.info( "%s %s", self.log_id, @@ -378,13 +438,15 @@ async def sending_loop(self, worker_id: int): while not self._terminate_sender: task = await self.send_buffer.get() LOGGER.debug( - "%s Message `%s` was successfully unqueued by worker %s.", + "%s Message `%s` was successfully dequeued by worker %s/%d.", self.name, self.gateway.host, task["message"], worker_id, ) - await _command_session.send(message=task["message"], is_status_request=task["is_status_request"]) + await _command_session.send( + message=task["message"], is_status_request=task["is_status_request"] + ) self.send_buffer.task_done() await _command_session.close() diff --git a/custom_components/myhome/manifest.json b/custom_components/myhome/manifest.json index b32a3e7..0c414db 100644 --- a/custom_components/myhome/manifest.json +++ b/custom_components/myhome/manifest.json @@ -1,72 +1,72 @@ -{ - "domain": "myhome", - "integration_type": "hub", - "name": "MyHOME", - "version": "0.9.3", - "config_flow": true, - "documentation": "https://github.com/anotherjulien/MyHOME", - "issue_tracker": "https://github.com/anotherjulien/MyHOME/issues", - "requirements": [ - "OWNd==0.7.48" - ], - "ssdp": [ - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "HL4684" - }, - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "AM4890" - }, - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "MyHomeServer1" - }, - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "F455" - }, - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "F454" - }, - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "F453AV" - }, - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "F452" - }, - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "MH200N" - }, - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "MH200" - }, - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "MH202" - }, - { - "st": "upnp:rootdevice", - "manufacturer": "BTicino S.p.A.", - "modelName": "MH201" - } - ], - "codeowners": [ - "@anotherjulien" - ] +{ + "domain": "myhome", + "integration_type": "hub", + "name": "MyHOME", + "version": "0.9.3", + "config_flow": true, + "documentation": "https://github.com/anotherjulien/MyHOME", + "issue_tracker": "https://github.com/anotherjulien/MyHOME/issues", + "requirements": [ + "OWNd==0.7.48" + ], + "ssdp": [ + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "HL4684" + }, + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "AM4890" + }, + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "MyHomeServer1" + }, + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "F455" + }, + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "F454" + }, + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "F453AV" + }, + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "F452" + }, + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "MH200N" + }, + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "MH200" + }, + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "MH202" + }, + { + "st": "upnp:rootdevice", + "manufacturer": "BTicino S.p.A.", + "modelName": "MH201" + } + ], + "codeowners": [ + "@anotherjulien" + ] } \ No newline at end of file