diff --git a/README.md b/README.md index abf4c74..12ea14d 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,42 @@ It uses the https://pypi.org/project/pytechnicolor/ to retrieve devices # configuration Install as usual and then follow UI configuration to input your router IP address and credentials. + +## Multiple routers + +This integration supports configuring **more than one router**: + +- **UI**: Add the integration multiple times (one config entry per router). +- **YAML import**: You can configure a single router (dictionary) or multiple routers (list). On startup, each router will be imported as a separate config entry. + +### YAML examples + +Single router: + +```yaml +technicolor: + host: 192.168.0.1 + port: 80 + use_ssl: false + verify_ssl: true + username: admin + password: your_password +``` + +Multiple routers: + +```yaml +technicolor: + - host: 192.168.1.1 + port: 80 + use_ssl: false + verify_ssl: true + username: admin + password: your_password + - host: 192.168.2.1 + port: 80 + use_ssl: false + verify_ssl: true + username: admin + password: your_password +``` diff --git a/custom_components/technicolor/__init__.py b/custom_components/technicolor/__init__.py index 89c8971..f68d117 100644 --- a/custom_components/technicolor/__init__.py +++ b/custom_components/technicolor/__init__.py @@ -21,20 +21,22 @@ async def async_setup(hass, config): options = {} hass.data[DOMAIN] = {"yaml_options": options} - # check if already configured - domains_list = hass.config_entries.async_domains() - if DOMAIN in domains_list: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + # Support importing one or multiple routers from YAML. + conf_list = conf if isinstance(conf, list) else [conf] + for router_conf in conf_list: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=router_conf + ) ) - ) return True +async def _async_config_entry_update(hass: HomeAssistant, entry: ConfigEntry) -> None: + await hass.config_entries.async_reload(entry.entry_id) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up Technicolor platform.""" @@ -43,13 +45,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if not entry.options and yaml_options: hass.config_entries.async_update_entry(entry, options=yaml_options) + entry.async_on_unload(entry.add_update_listener(_async_config_entry_update)) + technicolor_router = TechnicolorRouter(hass, entry) await technicolor_router.setup() - hass.async_create_task(hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { DOMAIN: technicolor_router, } + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + router = hass.data[DOMAIN].pop(entry.entry_id, {}).get(DOMAIN) + if router: + router.async_unload() + return unload_ok diff --git a/custom_components/technicolor/config_flow.py b/custom_components/technicolor/config_flow.py index b92092e..fdaadfb 100644 --- a/custom_components/technicolor/config_flow.py +++ b/custom_components/technicolor/config_flow.py @@ -4,9 +4,10 @@ import voluptuous as vol from homeassistant import config_entries -from .const import DOMAIN +from .const import CONF_USE_SSL, CONF_VERIFY_SSL, DOMAIN from homeassistant.const import ( CONF_HOST, + CONF_PORT, CONF_PASSWORD, CONF_USERNAME, ) @@ -40,6 +41,11 @@ def _show_setup_form(self, user_input=None, errors=None): data_schema=vol.Schema( { vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + vol.Optional(CONF_PORT, default=user_input.get(CONF_PORT, 80)): int, + vol.Optional(CONF_USE_SSL, default=user_input.get(CONF_USE_SSL, False)): bool, + vol.Optional( + CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True) + ): bool, vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str, vol.Required(CONF_PASSWORD): str, } @@ -49,13 +55,12 @@ def _show_setup_form(self, user_input=None, errors=None): async def async_step_user(self, user_input=None): """Handle a flow initiated by the user.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - if user_input is None: return self._show_setup_form(user_input) self._host = user_input[CONF_HOST] + await self.async_set_unique_id(self._host) + self._abort_if_unique_id_configured() return self.async_create_entry( title=self._host, @@ -65,3 +70,39 @@ async def async_step_user(self, user_input=None): async def async_step_import(self, user_input=None): """Import a config entry.""" return await self.async_step_user(user_input) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> "TechnicolorOptionsFlowHandler": + return TechnicolorOptionsFlowHandler() + + +def _config_schema_defaults(entry: config_entries.ConfigEntry) -> dict: + merged = {**entry.data, **entry.options} + return { + CONF_PORT: merged.get(CONF_PORT, 80), + CONF_USE_SSL: merged.get(CONF_USE_SSL, False), + CONF_VERIFY_SSL: merged.get(CONF_VERIFY_SSL, True), + } + + +class TechnicolorOptionsFlowHandler(config_entries.OptionsFlow): + """Options flow to change port / SSL for an existing config entry.""" + + async def async_step_init(self, user_input: dict | None = None): + if user_input is not None: + return self.async_create_entry(data=user_input) + + defaults = _config_schema_defaults(self.config_entry) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional(CONF_PORT, default=defaults[CONF_PORT]): int, + vol.Optional(CONF_USE_SSL, default=defaults[CONF_USE_SSL]): bool, + vol.Optional(CONF_VERIFY_SSL, default=defaults[CONF_VERIFY_SSL]): bool, + } + ), + ) diff --git a/custom_components/technicolor/const.py b/custom_components/technicolor/const.py index c716ce6..eee289d 100644 --- a/custom_components/technicolor/const.py +++ b/custom_components/technicolor/const.py @@ -1,2 +1,4 @@ """Technicolor component constants.""" DOMAIN = "technicolor" +CONF_USE_SSL = "use_ssl" +CONF_VERIFY_SSL = "verify_ssl" diff --git a/custom_components/technicolor/device_tracker.py b/custom_components/technicolor/device_tracker.py index 3f83b54..9f4cad4 100644 --- a/custom_components/technicolor/device_tracker.py +++ b/custom_components/technicolor/device_tracker.py @@ -12,11 +12,12 @@ CONF_DEVICES, CONF_EXCLUDE, CONF_HOST, + CONF_PORT, CONF_PASSWORD, CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN +from .const import CONF_USE_SSL, CONF_VERIFY_SSL, DOMAIN from homeassistant.helpers.dispatcher import async_dispatcher_connect DEFAULT_DEVICE_NAME = "Unknown device" @@ -26,15 +27,45 @@ CONFIG_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]), - } - ), + DOMAIN: vol.Any( + vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=80): int, + vol.Optional(CONF_USE_SSL, default=False): bool, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_DEVICES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_EXCLUDE, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ), + vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=80): int, + vol.Optional(CONF_USE_SSL, default=False): bool, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_DEVICES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_EXCLUDE, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ) + ], + ), + ) }, extra=vol.ALLOW_EXTRA, ) @@ -63,7 +94,7 @@ def update_router(): @callback def add_entities(router, async_add_entities, tracked): """Add new tracker entities from the gateway.""" - _LOGGER.info(f"add_entities tracked ${tracked}") + _LOGGER.debug("add_entities tracked %s", tracked) new_tracked = [] for mac, device in router.devices.items(): @@ -72,7 +103,7 @@ def add_entities(router, async_add_entities, tracked): new_tracked.append(TechnicolorDeviceScanner(router, device)) tracked.add(mac) - _LOGGER.info(f"add_entities {mac}") + _LOGGER.debug("add_entities %s", mac) if new_tracked: async_add_entities(new_tracked, True) @@ -93,13 +124,15 @@ def async_update_state(self) -> None: """Update the Technicolor device.""" device = self._router.devices[self._mac] self._device['ip'] = device['ip'] - _LOGGER.info(f"updating state for ${self._mac} with ip ${self._device['ip']}") + _LOGGER.debug( + "updating state for %s with ip %s", self._mac, self._device["ip"] + ) self._active = self._device['ip'] is not None and self._device['ip'] != "" @property def unique_id(self) -> str: """Return a unique ID.""" - return self._device['mac'] + return f"{self._router.entry_id}_{self._device['mac']}" @property def name(self) -> str: @@ -149,13 +182,13 @@ def should_poll(self) -> bool: @callback def async_on_demand_update(self): """Update state.""" - _LOGGER.info("in async_on_demand_update") + _LOGGER.debug("in async_on_demand_update") self.async_update_state() self.async_write_ha_state() async def async_added_to_hass(self): """Register state update callback.""" - _LOGGER.info("in async_added_to_hass") + _LOGGER.debug("in async_added_to_hass") self.async_update_state() self.async_on_remove( async_dispatcher_connect( diff --git a/custom_components/technicolor/manifest.json b/custom_components/technicolor/manifest.json index a524815..29b0ee9 100644 --- a/custom_components/technicolor/manifest.json +++ b/custom_components/technicolor/manifest.json @@ -1,14 +1,15 @@ { "domain": "technicolor", "name": "Technicolor", - "documentation": "https://github.com/shaiu/technicolor", + "documentation": "https://github.com/arnonm/technicolor", "requirements": ["pytechnicolor==1.1.12"], "dependencies": [], "codeowners": [ - "@shaiu" + "@shaiu", + "@arnonm" ], "iot_class": "local_polling", "config_flow": true, - "version": "1.0.0", - "issue_tracker": "https://github.com/shaiu/technicolor/issues" + "version": "1.0.1", + "issue_tracker": "https://github.com/arnonm/technicolor/issues" } \ No newline at end of file diff --git a/custom_components/technicolor/router.py b/custom_components/technicolor/router.py index caed7ce..5e95d41 100644 --- a/custom_components/technicolor/router.py +++ b/custom_components/technicolor/router.py @@ -5,12 +5,12 @@ from technicolorgateway import TechnicolorGateway from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .const import DOMAIN +from .const import CONF_USE_SSL, CONF_VERIFY_SSL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -28,47 +28,74 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._host = entry.data[CONF_HOST] self._user = entry.data[CONF_USERNAME] self._pass = entry.data[CONF_PASSWORD] + self._port = int(self._get_entry_value(entry, CONF_PORT, 80)) + self._use_ssl = bool(self._get_entry_value(entry, CONF_USE_SSL, False)) + self._verify_ssl = bool(self._get_entry_value(entry, CONF_VERIFY_SSL, True)) self._api: TechnicolorGateway = None + self._unsub_update = None self.devices = {} self.listeners = [] + @property + def entry_id(self) -> str: + return self._entry.entry_id + + @property + def host(self) -> str: + return self._host + + @staticmethod + def _get_entry_value(entry: ConfigEntry, key: str, default): + if key in entry.options: + return entry.options[key] + return entry.data.get(key, default) + async def setup(self) -> None: self._api = TechnicolorGateway( - self._host, "80", self._user, self._pass + self._host, str(self._port), self._user, self._pass ) + scheme = "https" if self._use_ssl else "http" + self._api._uri = f"{scheme}://{self._host}:{self._port}" + if self._use_ssl and not self._verify_ssl: + self._api._br.session.verify = False try: await self.loop.run_in_executor(None, self._api.authenticate) - except Exception as e: - _LOGGER.exception("Failed to connect to Technicolor", e) - return ConfigEntryNotReady + except Exception: + _LOGGER.exception("Failed to connect to Technicolor") + raise ConfigEntryNotReady await self.update_all(None) - async_track_time_interval( + self._unsub_update = async_track_time_interval( self.hass, self.update_all, SCAN_INTERVAL ) + def async_unload(self) -> None: + if self._unsub_update is not None: + self._unsub_update() + self._unsub_update = None + async def update_all(self, now) -> None: """Update all Technicolor platforms.""" - _LOGGER.info("update_all") + _LOGGER.debug("update_all") await self.update_device_trackers() async def update_device_trackers(self) -> None: - _LOGGER.info("update_device_trackers") + _LOGGER.debug("update_device_trackers") new_device = None devices = await self.loop.run_in_executor(None, self._api.get_device_modal) - _LOGGER.info(f"update_device_trackers devices ${devices}") + _LOGGER.debug("update_device_trackers devices %s", devices) for device in devices: device_mac = device["mac"] - _LOGGER.info(f"device: {device_mac}") + _LOGGER.debug("device: %s", device_mac) if self.devices.get(device_mac) is None: new_device = True - _LOGGER.info("new") + _LOGGER.debug("new") self.devices[device_mac] = device @@ -80,9 +107,9 @@ async def update_device_trackers(self) -> None: @property def signal_device_update(self) -> str: """Event specific per Technicolor entry to signal updates in devices.""" - return f"{DOMAIN}-device-update" + return f"{DOMAIN}-{self.entry_id}-device-update" @property def signal_device_new(self) -> str: """Event specific per Technicolor entry to signal new device.""" - return f"{DOMAIN}-device-new" + return f"{DOMAIN}-{self.entry_id}-device-new" diff --git a/custom_components/technicolor/strings.json b/custom_components/technicolor/strings.json index 292893f..13ae8ec 100644 --- a/custom_components/technicolor/strings.json +++ b/custom_components/technicolor/strings.json @@ -5,10 +5,25 @@ "title": "Technicolor", "data": { "host": "[%key:common::config_flow::data::host%]", + "port": "Port", + "use_ssl": "Use SSL (HTTPS)", + "verify_ssl": "Verify SSL certificate", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } } + }, + "options": { + "step": { + "init": { + "title": "Connection", + "data": { + "port": "Port", + "use_ssl": "Use SSL (HTTPS)", + "verify_ssl": "Verify SSL certificate" + } + } + } } } } diff --git a/custom_components/technicolor/translations/en.json b/custom_components/technicolor/translations/en.json index 601d9a6..d155c27 100644 --- a/custom_components/technicolor/translations/en.json +++ b/custom_components/technicolor/translations/en.json @@ -4,11 +4,26 @@ "user": { "data": { "host": "Host/IP", + "port": "Port", + "use_ssl": "Use SSL (HTTPS)", + "verify_ssl": "Verify SSL certificate", "username": "Username", "password": "Password" }, "title": "Technicolor" } + }, + "options": { + "step": { + "init": { + "title": "Connection", + "data": { + "port": "Port", + "use_ssl": "Use SSL (HTTPS)", + "verify_ssl": "Verify SSL certificate" + } + } + } } } } \ No newline at end of file