From 27144751d6fa380940601017821fa127353ce51a Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Sat, 11 Oct 2025 18:49:34 +0100 Subject: [PATCH] refactor - rearchitect code --- custom_components/view_assist/__init__.py | 409 +------ .../view_assist/assets/__init__.py | 57 +- custom_components/view_assist/assets/base.py | 8 +- .../view_assist/assets/blueprints.py | 2 +- .../view_assist/assets/dashboard.py | 50 +- .../{ => assets}/utils/dictdiff/__init__.py | 0 .../{ => assets}/utils/dictdiff/utils.py | 0 custom_components/view_assist/config_flow.py | 108 +- custom_components/view_assist/const.py | 35 +- .../view_assist/core/__init__.py | 120 ++ .../view_assist/{ => core}/alarm_repeater.py | 26 +- custom_components/view_assist/core/decoder.py | 500 ++++++++ .../view_assist/{http_url.py => core/http.py} | 16 +- .../__init__.py => core/javascript.py} | 20 +- .../view_assist/core/services.py | 62 + .../view_assist/core/templates.py | 133 +++ .../view_assist/{ => core}/timers.py | 1045 +++++++---------- .../view_assist/core/translator/__init__.py | 70 ++ .../view_assist/core/translator/decoder.py | 98 ++ .../view_assist/core/translator/normaliser.py | 383 ++++++ .../view_assist/core/translator/translator.py | 390 ++++++ .../core/translator/wordstonumbers.py | 73 ++ .../view_assist/core/websocket.py | 416 +++++++ custom_components/view_assist/data.py | 162 +++ .../view_assist/devices/__init__.py | 190 +++ .../view_assist/devices/background.py | 337 ++++++ .../view_assist/devices/entity_listeners.py | 645 ++++++++++ custom_components/view_assist/devices/menu.py | 404 +++++++ .../view_assist/devices/navigation.py | 234 ++++ .../view_assist/docs/custom_elements.md | 37 - .../view_assist/docs/templates.md | 55 - custom_components/view_assist/docs/timers.md | 224 ---- .../view_assist/entity_listeners.py | 927 --------------- custom_components/view_assist/helpers.py | 140 +-- .../view_assist/js_modules/timers.js | 95 ++ .../view_assist/js_modules/view_assist.js | 397 ++++--- custom_components/view_assist/manifest.json | 4 +- custom_components/view_assist/menu_manager.py | 646 ---------- custom_components/view_assist/migration.py | 146 +++ custom_components/view_assist/sensor.py | 250 ++-- custom_components/view_assist/services.py | 225 ---- custom_components/view_assist/services.yaml | 12 + custom_components/view_assist/templates.py | 117 -- .../view_assist/translations/en.json | 18 +- .../view_assist/translations/timers/de.json | 127 ++ .../view_assist/translations/timers/en.json | 71 ++ .../view_assist/translations/timers/es.json | 131 +++ .../view_assist/translations/timers/fr.json | 121 ++ .../translations/timers/normaliser.json | 38 + .../view_assist/translations/timers/ua.json | 98 ++ custom_components/view_assist/typed.py | 34 +- custom_components/view_assist/update.py | 15 +- custom_components/view_assist/websocket.py | 322 ----- 53 files changed, 6195 insertions(+), 4048 deletions(-) rename custom_components/view_assist/{ => assets}/utils/dictdiff/__init__.py (100%) rename custom_components/view_assist/{ => assets}/utils/dictdiff/utils.py (100%) create mode 100644 custom_components/view_assist/core/__init__.py rename custom_components/view_assist/{ => core}/alarm_repeater.py (95%) create mode 100644 custom_components/view_assist/core/decoder.py rename custom_components/view_assist/{http_url.py => core/http.py} (75%) rename custom_components/view_assist/{js_modules/__init__.py => core/javascript.py} (89%) create mode 100644 custom_components/view_assist/core/services.py create mode 100644 custom_components/view_assist/core/templates.py rename custom_components/view_assist/{ => core}/timers.py (61%) create mode 100644 custom_components/view_assist/core/translator/__init__.py create mode 100644 custom_components/view_assist/core/translator/decoder.py create mode 100644 custom_components/view_assist/core/translator/normaliser.py create mode 100644 custom_components/view_assist/core/translator/translator.py create mode 100644 custom_components/view_assist/core/translator/wordstonumbers.py create mode 100644 custom_components/view_assist/core/websocket.py create mode 100644 custom_components/view_assist/data.py create mode 100644 custom_components/view_assist/devices/__init__.py create mode 100644 custom_components/view_assist/devices/background.py create mode 100644 custom_components/view_assist/devices/entity_listeners.py create mode 100644 custom_components/view_assist/devices/menu.py create mode 100644 custom_components/view_assist/devices/navigation.py delete mode 100644 custom_components/view_assist/docs/custom_elements.md delete mode 100644 custom_components/view_assist/docs/templates.md delete mode 100644 custom_components/view_assist/docs/timers.md delete mode 100644 custom_components/view_assist/entity_listeners.py create mode 100644 custom_components/view_assist/js_modules/timers.js delete mode 100644 custom_components/view_assist/menu_manager.py create mode 100644 custom_components/view_assist/migration.py delete mode 100644 custom_components/view_assist/services.py delete mode 100644 custom_components/view_assist/templates.py create mode 100644 custom_components/view_assist/translations/timers/de.json create mode 100644 custom_components/view_assist/translations/timers/en.json create mode 100644 custom_components/view_assist/translations/timers/es.json create mode 100644 custom_components/view_assist/translations/timers/fr.json create mode 100644 custom_components/view_assist/translations/timers/normaliser.json create mode 100644 custom_components/view_assist/translations/timers/ua.json delete mode 100644 custom_components/view_assist/websocket.py diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 818e76a..8651eb6 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -3,75 +3,20 @@ import logging from homeassistant import config_entries -from homeassistant.const import CONF_TYPE, Platform +from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery_flow -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.start import async_at_started -from .alarm_repeater import ALARMS, VAAlarmRepeater -from .assets import ASSETS_MANAGER, AssetsManager -from .const import ( - CONF_ASSIST_PROMPT, - CONF_BACKGROUND, - CONF_BACKGROUND_MODE, - CONF_BACKGROUND_SETTINGS, - CONF_DEV_MIMIC, - CONF_DISPLAY_SETTINGS, - CONF_DO_NOT_DISTURB, - CONF_FONT_STYLE, - CONF_HIDE_HEADER, - CONF_HIDE_SIDEBAR, - CONF_MIC_TYPE, - CONF_MIC_UNMUTE, - CONF_ROTATE_BACKGROUND, - CONF_ROTATE_BACKGROUND_INTERVAL, - CONF_ROTATE_BACKGROUND_LINKED_ENTITY, - CONF_ROTATE_BACKGROUND_PATH, - CONF_ROTATE_BACKGROUND_SOURCE, - CONF_SCREEN_MODE, - CONF_STATUS_ICON_SIZE, - CONF_STATUS_ICONS, - CONF_TIME_FORMAT, - CONF_USE_24H_TIME, - CONF_USE_ANNOUNCE, - DEFAULT_VALUES, - DOMAIN, - OPTION_KEY_MIGRATIONS, -) -from .entity_listeners import EntityListeners -from .helpers import ( - ensure_list, - get_device_name_from_id, - get_integration_entries, - get_key, - get_master_config_entry, - is_first_instance, -) -from .http_url import HTTPManager -from .js_modules import JSModuleRegistration -from .menu_manager import MenuManager -from .services import VAServices -from .templates import setup_va_templates -from .timers import TIMERS, VATimers -from .typed import ( - DeviceCoreConfig, - DeviceRuntimeData, - MasterConfigRuntimeData, - VABackgroundMode, - VAConfigEntry, - VAEvent, - VAMenuConfig, - VAScreenMode, - VATimeFormat, - VAType, -) -from .websocket import async_register_websockets +from .const import DOMAIN +from .core import CoreManager +from .data import set_runtime_data_for_config +from .devices import DeviceManager +from .helpers import get_integration_entries, get_master_config_entry, is_first_instance +from .migration import async_migrate_view_assist_config_entry +from .typed import VAConfigEntry, VAType _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR] - def migrate_to_section(entry: VAConfigEntry, params: list[str]): """Build a section for the config entry.""" @@ -87,110 +32,7 @@ async def async_migrate_entry( entry: VAConfigEntry, ) -> bool: """Migrate config entry if needed.""" - # No migration needed - _LOGGER.debug( - "Config Migration from v%s.%s - %s", - entry.version, - entry.minor_version, - entry.options, - ) - new_options = {**entry.options} - if entry.minor_version < 2 and entry.options: - # Migrate options keys - for key, value in new_options.items(): - if isinstance(value, str) and value in OPTION_KEY_MIGRATIONS: - new_options[key] = OPTION_KEY_MIGRATIONS.get(value) - - if entry.minor_version < 3 and entry.options: - # Remove mic_type key - if "mic_type" in entry.options: - new_options.pop(CONF_MIC_TYPE) - - if entry.minor_version < 4: - # Migrate to master config model - - # Remove mimic device key as moved into master config - new_options.pop(CONF_DEV_MIMIC, None) - - # Dashboard options - # Background has both moved into a section and also changed parameters - # Add section and migrate values - if CONF_BACKGROUND_SETTINGS not in new_options: - new_options[CONF_BACKGROUND_SETTINGS] = {} - - for param in ( - CONF_ROTATE_BACKGROUND, - CONF_BACKGROUND, - CONF_ROTATE_BACKGROUND_PATH, - CONF_ROTATE_BACKGROUND_INTERVAL, - CONF_ROTATE_BACKGROUND_LINKED_ENTITY, - ): - if param in new_options: - if param == CONF_ROTATE_BACKGROUND: - new_options[CONF_BACKGROUND_SETTINGS][CONF_BACKGROUND_MODE] = ( - VABackgroundMode.DEFAULT_BACKGROUND - if new_options[param] is False - else new_options[CONF_ROTATE_BACKGROUND_SOURCE] - ) - new_options.pop(param, None) - new_options.pop(CONF_ROTATE_BACKGROUND_SOURCE, None) - else: - new_options[CONF_BACKGROUND_SETTINGS][param] = new_options.pop( - param, None - ) - - # Display options - # Display options has both moved into a section and also changed parameters - if CONF_DISPLAY_SETTINGS not in new_options: - new_options[CONF_DISPLAY_SETTINGS] = {} - - for param in [ - CONF_ASSIST_PROMPT, - CONF_STATUS_ICON_SIZE, - CONF_FONT_STYLE, - CONF_STATUS_ICONS, - CONF_USE_24H_TIME, - CONF_HIDE_HEADER, - ]: - if param in new_options: - if param == CONF_USE_24H_TIME: - new_options[CONF_DISPLAY_SETTINGS][CONF_TIME_FORMAT] = ( - VATimeFormat.HOUR_24 - if entry.options[param] - else VATimeFormat.HOUR_12 - ) - new_options.pop(param) - elif param == CONF_HIDE_HEADER: - mode = 0 - if new_options.pop(CONF_HIDE_HEADER, None): - mode += 1 - if new_options.pop(CONF_HIDE_SIDEBAR, None): - mode += 2 - new_options[CONF_DISPLAY_SETTINGS][CONF_SCREEN_MODE] = list( - VAScreenMode - )[mode].value - else: - new_options[CONF_DISPLAY_SETTINGS][param] = new_options.pop(param) - - if entry.minor_version < 5: - # Fix for none migration of default options for dnd, announce and unmute mic - for key in [CONF_DO_NOT_DISTURB, CONF_USE_ANNOUNCE, CONF_MIC_UNMUTE]: - if new_options.get(key) is not None: - new_options[CONF_DO_NOT_DISTURB] = ( - "on" if new_options.get(key) else "off" - ) - - if new_options != entry.options: - hass.config_entries.async_update_entry( - entry, options=new_options, minor_version=5, version=1 - ) - - _LOGGER.debug( - "Migration to configuration version %s.%s successful", - entry.version, - entry.minor_version, - ) - return True + return await async_migrate_view_assist_config_entry(hass, entry) async def async_setup_entry(hass: HomeAssistant, entry: VAConfigEntry): @@ -200,238 +42,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: VAConfigEntry): has_master_entry = get_master_config_entry(hass) is_master_entry = has_master_entry and entry.data[CONF_TYPE] == VAType.MASTER_CONFIG - # Add runtime data to config entry to have place to store data and - # make accessible throughout integration - set_runtime_data_for_config(hass, entry, is_master_entry) - - # Add config change listener - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - if not has_master_entry: # Start a config flow to add a master entry if no master entry - _LOGGER.debug("No master entry found, starting config flow") if is_first_instance(hass, entry): + _LOGGER.debug("No master entry found, starting config flow") discovery_flow.async_create_flow( hass, DOMAIN, {"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, {"name": VAType.MASTER_CONFIG}, ) + return True + return False - # Run first instance only functions - if is_first_instance(hass, entry, display_instance_only=False): - await load_common_functions(hass, entry) - - # Run first display instance only functions - if is_first_instance(hass, entry, display_instance_only=True): - await load_common_display_functions(hass, entry) + # Set runtime data + set_runtime_data_for_config(hass, entry, is_master_entry) if is_master_entry: - await load_common_functions(hass, entry) - await load_common_display_functions(hass, entry) - + # Load asset manager + await CoreManager(hass, entry).async_start() else: - # Add config change listener - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - - # Set entity listeners - EntityListeners(hass, entry) + # Load device manager + await DeviceManager(hass, entry).async_setup() - # Request platform setup - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - # Fire config update event - # Does nothing on HA reload but sends update to device if config reloaded from config update - async_dispatcher_send( - hass, f"{DOMAIN}_{entry.entry_id}_event", VAEvent("config_update") - ) - - # Fire display device registration to setup display if first time config - if entry.data[CONF_TYPE] == VAType.VIEW_AUDIO: - async_dispatcher_send( - hass, - f"{DOMAIN}_{get_device_name_from_id(hass, entry.runtime_data.core.display_device)}_registered", - ) + # Add config change listener + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True -async def load_common_functions(hass: HomeAssistant, entry: VAConfigEntry): - """Things to run only for first instance of integration.""" - - # Inisitialise service - services = VAServices(hass, entry) - await services.async_setup_services() - - # Setup Timers - timers = VATimers(hass, entry) - hass.data[DOMAIN][TIMERS] = timers - await timers.load() - - # Setup Menu Manager - menu_manager = MenuManager(hass, entry) - hass.data[DOMAIN]["menu_manager"] = menu_manager - - # Load javascript modules - jsloader = JSModuleRegistration(hass) - await jsloader.async_register() - - hass.data[DOMAIN][ALARMS] = VAAlarmRepeater(hass, entry) - - setup_va_templates(hass) - - # Load asset manager - am = AssetsManager(hass, entry) - hass.data[DOMAIN][ASSETS_MANAGER] = am - await am.async_setup() - - # Request update platform setup - if entry.runtime_data.integration.enable_updates: - _LOGGER.debug("Update notifications are enabled") - await hass.config_entries.async_forward_entry_setups(entry, [Platform.UPDATE]) - - -async def load_common_display_functions(hass: HomeAssistant, entry: VAConfigEntry): - """Things to run only one when multiple instances exist.""" - - # Run dashboard and view setup - async def setup_frontend(*args): - # Initiate var to hold VA browser ids. Do not reset if exists - # as this is used to track browser ids across reloads - if not hass.data[DOMAIN].get("va_browser_ids"): - hass.data[DOMAIN]["va_browser_ids"] = {} - # Load websockets - await async_register_websockets(hass) - - http = HTTPManager(hass, entry) - await http.create_url_paths() - - async_at_started(hass, setup_frontend) - - -def set_runtime_data_for_config( - hass: HomeAssistant, config_entry: VAConfigEntry, is_master: bool = False -): - """Set config.runtime_data attributes from matching config values.""" - - def get_config_value( - attr: str, is_master: bool = False - ) -> str | float | list | None: - value = get_key(attr, dict(config_entry.options)) - if not is_master and ( - value is None - or (isinstance(value, dict) and not value) - or (isinstance(value, list) and not value) - ): - value = get_key(attr, dict(master_config_options)) - if value is None or (isinstance(value, dict) and not value): - value = get_key(attr, DEFAULT_VALUES) - - # This is a fix for config lists being a string - if isinstance(attr, list): - value = ensure_list(value) - return value - - master_config_options = ( - get_master_config_entry(hass).options if get_master_config_entry(hass) else {} - ) - - if is_master: - r = config_entry.runtime_data = MasterConfigRuntimeData() - # Dashboard options - handles sections - for attr in r.dashboard.__dict__: - if value := get_config_value(attr, is_master=True): - try: - if attr in (CONF_BACKGROUND_SETTINGS, CONF_DISPLAY_SETTINGS): - values = {} - for sub_attr in getattr(r.dashboard, attr).__dict__: - if sub_value := get_config_value( - f"{attr}.{sub_attr}", is_master=True - ): - values[sub_attr] = sub_value - value = type(getattr(r.dashboard, attr))(**values) - setattr(r.dashboard, attr, value) - except Exception as ex: # noqa: BLE001 - _LOGGER.error( - "Error setting runtime data for %s - %s: %s", - attr, - type(getattr(r.dashboard, attr)), - str(ex), - ) - - # Integration options - for attr in r.integration.__dict__: - value = get_config_value(attr, is_master=True) - if value is not None: - setattr(r.integration, attr, value) - - # Developer options - for attr in r.developer_settings.__dict__: - if value := get_config_value(attr, is_master=True): - setattr(r.developer_settings, attr, value) - else: - r = config_entry.runtime_data = DeviceRuntimeData() - r.core = DeviceCoreConfig(**config_entry.data) - master_config_options = ( - get_master_config_entry(hass).options - if get_master_config_entry(hass) - else {} - ) - # Dashboard options - handles sections - for attr in r.dashboard.__dict__: - if value := get_config_value(attr): - try: - if isinstance(value, dict): - values = {} - for sub_attr in getattr(r.dashboard, attr).__dict__: - if sub_value := get_config_value(f"{attr}.{sub_attr}"): - values[sub_attr] = sub_value - value = type(getattr(r.dashboard, attr))(**values) - setattr(r.dashboard, attr, value) - except Exception as ex: # noqa: BLE001 - _LOGGER.error( - "Error setting runtime data for %s - %s: %s", - attr, - type(getattr(r.dashboard, attr)), - str(ex), - ) - - # Dashboard options - handles sections - master and non master - for attr in r.dashboard.__dict__: - if value := get_config_value(attr, is_master=is_master): - try: - if attr in (CONF_BACKGROUND_SETTINGS, CONF_DISPLAY_SETTINGS): - values = {} - for sub_attr in getattr(r.dashboard, attr).__dict__: - if sub_value := get_config_value( - f"{attr}.{sub_attr}", is_master=is_master - ): - values[sub_attr] = sub_value - value = type(getattr(r.dashboard, attr))(**values) - setattr(r.dashboard, attr, value) - except Exception as ex: # noqa: BLE001 - _LOGGER.error( - "Error setting runtime data for %s - %s: %s", - attr, - type(getattr(r.dashboard, attr)), - str(ex), - ) - - # Default options - doesn't yet handle sections - for attr in r.default.__dict__: - if value := get_config_value(attr, is_master=is_master): - setattr(r.default, attr, value) - - async def _async_update_listener(hass: HomeAssistant, config_entry: VAConfigEntry): """Handle config options update.""" # Reload the integration when the options change. - is_master = config_entry.data[CONF_TYPE] == VAType.MASTER_CONFIG - if is_master: - if entries := get_integration_entries(hass): - for entry in entries: - await hass.config_entries.async_reload(entry.entry_id) - await hass.config_entries.async_reload(config_entry.entry_id) + hass.config_entries.async_schedule_reload(config_entry.entry_id) async def async_unload_entry(hass: HomeAssistant, entry: VAConfigEntry): @@ -439,11 +82,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: VAConfigEntry): # Unload js resources if entry.data[CONF_TYPE] == VAType.MASTER_CONFIG: - # Unload lovelace module resource if only instance - _LOGGER.debug("Removing javascript modules cards") - jsloader = JSModuleRegistration(hass) - await jsloader.async_unregister() - await hass.config_entries.async_unload_platforms(entry, [Platform.UPDATE]) - return True - - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await CoreManager.async_unload(hass, entry) + return await DeviceManager.async_unload(hass, entry) diff --git a/custom_components/view_assist/assets/__init__.py b/custom_components/view_assist/assets/__init__.py index 2009b97..fde75a1 100644 --- a/custom_components/view_assist/assets/__init__.py +++ b/custom_components/view_assist/assets/__init__.py @@ -34,8 +34,6 @@ _LOGGER = logging.getLogger(__name__) -ASSETS_MANAGER = "assets_manager" - class AssetClass(StrEnum): """Asset class.""" @@ -136,6 +134,14 @@ async def update_last_commit(self, asset_class: str, last_commit: str): class AssetsManager: """Class to manage VA asset installs/updates/deletes etc.""" + @classmethod + def get(cls, hass: HomeAssistant) -> "AssetsManager": + """Get the asset manager.""" + try: + return hass.data[DOMAIN][cls.__name__] + except KeyError: + return None + def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: """Initialise.""" self.hass = hass @@ -148,7 +154,6 @@ def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: async def async_setup(self) -> None: """Set up the AssetManager.""" try: - _LOGGER.debug("Setting up AssetsManager") self.data = await self.store.load() # Setup managers @@ -196,6 +201,7 @@ async def async_setup(self) -> None: timedelta(minutes=VERSION_CHECK_INTERVAL), ) ) + except Exception as ex: _LOGGER.error("Error setting up AssetsManager. Error is %s", ex) raise HomeAssistantError(f"Error setting up AssetsManager: {ex}") from ex @@ -291,18 +297,17 @@ async def async_update_version_info( # Reduces download by only getting version from repo if the last commit date is greater than # we have stored update_from_repo = True - if self.data.get("last_commit"): - if repo_last_commit := await manager.async_get_last_commit(): - stored_last_commit = self.data.get("last_commit").get(asset_class) - if repo_last_commit == stored_last_commit: - _LOGGER.debug( - "No new updates in repo for %s", - asset_class, - ) - update_from_repo = False + repo_last_commit = await manager.async_get_last_commit() + if repo_last_commit and self.data.get("last_commit"): + stored_last_commit = self.data.get("last_commit").get(asset_class) + if repo_last_commit == stored_last_commit: + _LOGGER.debug( + "No new updates in repo for %s", + asset_class, + ) + update_from_repo = False if update_from_repo: - repo_last_commit = await manager.async_get_last_commit() _LOGGER.debug("New updates in repo for %s", asset_class) self.data.setdefault("last_commit", {}) self.data["last_commit"][asset_class] = repo_last_commit @@ -311,13 +316,14 @@ async def async_update_version_info( if version_info := await manager.async_get_version_info(update_from_repo): for name, versions in version_info.items(): self.data[asset_class][name] = versions - # Fire update entity update event - if versions["installed"]: - self._fire_updates_update( - asset_class, - name, - AwesomeVersion(versions["installed"]) >= versions["latest"], - ) + # Fire update entity update event if asset is installed or new asset + self._fire_updates_update( + asset_class, + name, + AwesomeVersion(versions["installed"]) >= versions["latest"] + if versions["installed"] + else False, + ) await self.store.update(asset_class, None, self.data[asset_class]) _LOGGER.debug("Latest versions updated") @@ -357,11 +363,18 @@ async def async_install_or_update( await self.store.update(asset_class, name, self.data[asset_class][name]) def _fire_updates_update( - self, asset_class: AssetClass, name: str, remove: bool + self, + asset_class: AssetClass, + name: str, + remove: bool, ) -> None: """Fire update entity update event.""" async_dispatcher_send( self.hass, VA_ADD_UPDATE_ENTITY_EVENT, - {"asset_class": asset_class, "name": name, "remove": remove}, + { + "asset_class": asset_class, + "name": name, + "remove": remove, + }, ) diff --git a/custom_components/view_assist/assets/base.py b/custom_components/view_assist/assets/base.py index 6fe2fc0..c5d371a 100644 --- a/custom_components/view_assist/assets/base.py +++ b/custom_components/view_assist/assets/base.py @@ -3,11 +3,11 @@ from dataclasses import dataclass from typing import Any +from config.custom_components.view_assist.const import VA_ASSET_UPDATE_PROGRESS from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send -from ..const import VA_ASSET_UPDATE_PROGRESS # noqa: TID252 -from ..typed import VAConfigEntry # noqa: TID252 +from . import VAConfigEntry from .download_manager import DownloadManager @@ -58,7 +58,9 @@ async def async_get_latest_version(self, name: str) -> dict[str, Any]: """Get latest version of asset from repo.""" raise NotImplementedError - async def async_get_version_info(self) -> dict[str, Any]: + async def async_get_version_info( + self, update_from_repo: bool = True + ) -> dict[str, Any]: """Update versions from repo.""" raise NotImplementedError diff --git a/custom_components/view_assist/assets/blueprints.py b/custom_components/view_assist/assets/blueprints.py index 96b8116..6be52b0 100644 --- a/custom_components/view_assist/assets/blueprints.py +++ b/custom_components/view_assist/assets/blueprints.py @@ -266,7 +266,7 @@ def _copy_file_to_dir(self, source_file: Path, dest_file: Path) -> None: """Copy a file to a directory.""" try: dest_file.parent.mkdir(parents=True, exist_ok=True) - with Path.open(dest_file, "wb") as f: + with Path.open(dest_file, "wb", encoding="utf-8") as f: f.write(source_file.read_bytes()) except OSError as ex: raise AssetManagerException( diff --git a/custom_components/view_assist/assets/dashboard.py b/custom_components/view_assist/assets/dashboard.py index 3f2ecf2..87fd44e 100644 --- a/custom_components/view_assist/assets/dashboard.py +++ b/custom_components/view_assist/assets/dashboard.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass import logging import operator from pathlib import Path @@ -29,14 +30,57 @@ GITHUB_DEV_BRANCH, ) from ..helpers import differ_to_json, get_key, json_to_dictdiffer # noqa: TID252 -from ..typed import VAConfigEntry # noqa: TID252 -from ..utils import dictdiff # noqa: TID252 -from ..websocket import MockWSConnection # noqa: TID252 +from . import VAConfigEntry from .base import AssetManagerException, BaseAssetManager, InstallStatus +from .utils import dictdiff _LOGGER = logging.getLogger(__name__) +class MockWSConnection: + """Mock a websocket connection to be able to call websocket handler functions. + + This is here for creating the View Assist dashboard + """ + + @dataclass + class MockAdminUser: + """Mock admin user for use in MockWSConnection.""" + + is_admin = True + + def __init__(self, hass: HomeAssistant) -> None: + """Initilise.""" + self.hass = hass + self.user = self.MockAdminUser() + + self.failed_request: bool = False + + def send_result(self, id, item): + """Receive result.""" + self.failed_request = False + + def send_error(self, id, code, msg): + """Receive error.""" + self.failed_request = True + + def execute_ws_func(self, ws_type: str, msg: dict[str, Any]) -> bool: + """Execute ws function.""" + if self.hass.data["websocket_api"].get(ws_type): + try: + handler, schema = self.hass.data["websocket_api"][ws_type] + if schema is False: + handler(self.hass, self, msg) + else: + handler(self.hass, self, schema(msg)) + except Exception as ex: # noqa: BLE001 + _LOGGER.error("Error calling %s. Error is %s", ws_type, ex) + return False + else: + return True + return False + + class DashboardManager(BaseAssetManager): """Class to manage dashboard assets.""" diff --git a/custom_components/view_assist/utils/dictdiff/__init__.py b/custom_components/view_assist/assets/utils/dictdiff/__init__.py similarity index 100% rename from custom_components/view_assist/utils/dictdiff/__init__.py rename to custom_components/view_assist/assets/utils/dictdiff/__init__.py diff --git a/custom_components/view_assist/utils/dictdiff/utils.py b/custom_components/view_assist/assets/utils/dictdiff/utils.py similarity index 100% rename from custom_components/view_assist/utils/dictdiff/utils.py rename to custom_components/view_assist/assets/utils/dictdiff/utils.py diff --git a/custom_components/view_assist/config_flow.py b/custom_components/view_assist/config_flow.py index 682e84d..678ffdf 100644 --- a/custom_components/view_assist/config_flow.py +++ b/custom_components/view_assist/config_flow.py @@ -11,11 +11,16 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigFlow, OptionsFlow -from homeassistant.const import CONF_MODE, CONF_NAME, CONF_TYPE, Platform +from homeassistant.const import CONF_DEVICE, CONF_MODE, CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import SectionConfig, section +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.selector import ( BooleanSelector, + ConversationAgentSelector, + ConversationAgentSelectorConfig, + DeviceSelector, + DeviceSelectorConfig, EntityFilterSelectorConfig, EntitySelector, EntitySelectorConfig, @@ -27,9 +32,8 @@ SelectSelectorMode, ) -from .assets import ASSETS_MANAGER, AssetClass +from .assets import AssetClass, AssetsManager from .const import ( - BROWSERMOD_DOMAIN, CONF_ASSIST_PROMPT, CONF_BACKGROUND, CONF_BACKGROUND_MODE, @@ -62,6 +66,7 @@ CONF_STATUS_ICON_SIZE, CONF_STATUS_ICONS, CONF_TIME_FORMAT, + CONF_TRANSLATION_ENGINE, CONF_USE_ANNOUNCE, CONF_VIEW_TIMEOUT, CONF_WEATHER_ENTITY, @@ -70,16 +75,12 @@ DEFAULT_VALUES, DOMAIN, MIN_DASHBOARD_FOR_OVERLAYS, - REMOTE_ASSIST_DISPLAY_DOMAIN, VACA_DOMAIN, VAIconSizes, ) -from .helpers import ( - get_available_overlays, - get_devices_for_domain, - get_master_config_entry, -) +from .helpers import get_available_overlays, get_master_config_entry from .typed import ( + DISPLAY_DEVICE_TYPES, VAAssistPrompt, VABackgroundMode, VAConfigEntry, @@ -134,29 +135,39 @@ ) +def get_vaca_config(hass: HomeAssistant, device_id: str) -> dict[str, Any]: + """Get the config for a VACA device.""" + + def get_entity( + domain: str, suffix: str, entities: list[er.RegistryEntry] + ) -> str | None: + for entity in entities: + if entity.entity_id.startswith(f"{domain}."): + if (not suffix) or (suffix and entity.entity_id.endswith(f"_{suffix}")): + return entity.entity_id + return None + + def get_display_id(device_id: str) -> str | None: + device_registry = dr.async_get(hass) + if device := device_registry.async_get(device_id): + return f"va-{device.name.split(' ')[1]}" + return None + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_device(entity_registry, device_id) + return { + CONF_MIC_DEVICE: get_entity("assist_satellite", "", entities) or "", + CONF_MEDIAPLAYER_DEVICE: get_entity("media_player", "", entities) or "", + CONF_MUSICPLAYER_DEVICE: get_entity("media_player", "", entities) or "", + CONF_INTENT_DEVICE: get_entity("sensor", "intent", entities) or "", + CONF_DISPLAY_DEVICE: get_display_id(device_id) or "", + } + + def get_display_devices( hass: HomeAssistant, config: VAConfigEntry | None = None ) -> dict[str, Any]: """Get display device options.""" - domain_filters = [BROWSERMOD_DOMAIN, REMOTE_ASSIST_DISPLAY_DOMAIN] - - hass_data = hass.data.setdefault(DOMAIN, {}) - display_devices: dict[str, Any] = hass_data.get("va_browser_ids", {}) - - # Add suported domain devices - for domain in domain_filters: - domain_devices = get_devices_for_domain(hass, domain) - if domain_devices: - for device in domain_devices: - display_devices[device.id] = device.name - - # Add current setting if not already in list - if config is not None: - attrs = [CONF_DISPLAY_DEVICE, CONF_DEVELOPER_DEVICE] - for attr in attrs: - if d := config.data.get(attr): - if d not in display_devices: - display_devices[d] = d # Make into options dict return [ @@ -164,7 +175,7 @@ def get_display_devices( "value": key, "label": value, } - for key, value in display_devices.items() + for key, value in hass.data[DOMAIN].get("browser_ids", {}).items() ] @@ -198,14 +209,13 @@ async def get_dashboard_options_schema( } # Get the overlay options - installed_dashboard = await hass.data[DOMAIN][ASSETS_MANAGER].get_installed_version( + installed_dashboard = await AssetsManager.get(hass).get_installed_version( AssetClass.DASHBOARD, "dashboard" ) if AwesomeVersion(installed_dashboard) >= MIN_DASHBOARD_FOR_OVERLAYS: available_overlays = await hass.async_add_executor_job( get_available_overlays, hass ) - _LOGGER.debug("Overlay options: %s", available_overlays) overlay_options = [ {"value": key, "label": value} for key, value in available_overlays.items() ] @@ -345,7 +355,10 @@ async def get_dashboard_options_schema( ) INTEGRATION_OPTIONS_SCHEMA = vol.Schema( - {vol.Optional(CONF_ENABLE_UPDATES): BooleanSelector()} + { + vol.Optional(CONF_ENABLE_UPDATES): BooleanSelector(), + vol.Optional(CONF_TRANSLATION_ENGINE): ConversationAgentSelector(), + } ) @@ -445,14 +458,32 @@ async def async_step_integration_discovery(self, discovery_info=None): async def async_step_options(self, user_input=None): """Handle the options step.""" if user_input is not None: + # Auto populate config parameters if VACA device + if self.type == VAType.VACA: + device_id = user_input.pop(CONF_DEVICE) + user_input = user_input | get_vaca_config(self.hass, device_id) + # Include the type in the data to save in the config entry user_input[CONF_TYPE] = self.type + return self.async_create_entry( title=user_input.get(CONF_NAME, DEFAULT_NAME), data=user_input ) # Define the schema based on the selected type - if self.type == VAType.VIEW_AUDIO: + if self.type == VAType.VACA: + # Special simple VACA setup + data_schema = vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_DEVICE): DeviceSelector( + DeviceSelectorConfig( + integration=VACA_DOMAIN, + ) + ), + } + ) + elif self.type == VAType.VIEW_AUDIO: data_schema = BASE_DEVICE_SCHEMA.extend( { vol.Required(CONF_DISPLAY_DEVICE): SelectSelector( @@ -497,7 +528,7 @@ async def async_step_init(self, user_input=None): # Also need to be in strings.json and translation files. self.va_type = self.config_entry.data[CONF_TYPE] # pylint: disable=attribute-defined-outside-init - if self.va_type == VAType.VIEW_AUDIO: + if self.va_type in DISPLAY_DEVICE_TYPES: return self.async_show_menu( step_id="init", menu_options=["main_config", "dashboard_options", "default_options"], @@ -526,7 +557,7 @@ async def async_step_main_config(self, user_input=None): ) return self.async_create_entry(data=None) - if self.va_type == VAType.VIEW_AUDIO: + if self.va_type in DISPLAY_DEVICE_TYPES: data_schema = BASE_DEVICE_SCHEMA.extend( { vol.Required(CONF_DISPLAY_DEVICE): SelectSelector( @@ -611,7 +642,12 @@ async def async_step_integration_options(self, user_input=None): data_schema = self.add_suggested_values_to_schema( INTEGRATION_OPTIONS_SCHEMA, - get_suggested_option_values(self.config_entry), + { + CONF_ENABLE_UPDATES: self.config_entry.options.get(CONF_ENABLE_UPDATES), + CONF_TRANSLATION_ENGINE: self.config_entry.options.get( + CONF_TRANSLATION_ENGINE + ), + }, ) if user_input is not None: diff --git a/custom_components/view_assist/const.py b/custom_components/view_assist/const.py index 8bc504c..62bbccd 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -2,7 +2,7 @@ from enum import StrEnum -from homeassistant.const import CONF_MODE +from homeassistant.const import CONF_MODE, Platform from .typed import ( VABackgroundMode, @@ -12,6 +12,8 @@ VATimeFormat, ) +PLATFORMS: list[Platform] = [Platform.SENSOR] + DOMAIN = "view_assist" GITHUB_REPO = "dinki/View-Assist" GITHUB_BRANCH = "main" @@ -27,6 +29,18 @@ DEFAULT_VIEW = "clock" CYCLE_VIEWS = ["music", "info", "weather", "clock"] +DASHBOARD_ICONS = [ + "mic", + "hold", + "cycle", + "dnd", + "weather", + "home", + "camera", + "wake", + "menu", + "timer", +] BROWSERMOD_DOMAIN = "browser_mod" REMOTE_ASSIST_DISPLAY_DOMAIN = "remote_assist_display" @@ -44,12 +58,11 @@ { "name": "View Assist Helper", "filename": "view_assist.js", - "version": "1.0.17", + "version": "1.0.19", }, ] -VERSION_CHECK_INTERVAL = ( - 120 # mins between checks for updated versions of dashboard and views -) +# mins between checks for updated versions of dashboard and views +VERSION_CHECK_INTERVAL = 120 class VAMode(StrEnum): @@ -73,6 +86,10 @@ class VAMode(StrEnum): # Config keys +DATA = "data" +MASTER_CONFIG = "master_config" +DEVICES = "devices" +CONF_VA_BROWSER_IDS = "va_browser_ids" CONF_MIC_DEVICE = "mic_device" CONF_MEDIAPLAYER_DEVICE = "mediaplayer_device" CONF_MUSICPLAYER_DEVICE = "musicplayer_device" @@ -110,6 +127,7 @@ class VAMode(StrEnum): CONF_DUCKING_VOLUME = "ducking_volume" CONF_ENABLE_UPDATES = "enable_updates" +CONF_TRANSLATION_ENGINE = "translation_engine" CONF_DEVELOPER_DEVICE = "developer_device" CONF_DEVELOPER_MIMIC_DEVICE = "developer_mimic_device" @@ -166,17 +184,19 @@ class VAMode(StrEnum): # Config default values DEFAULT_NAME = "View Assist" -DEFAULT_TYPE = "view_audio" +DEFAULT_TYPE = "vaca" DEFAULT_VIEW_INFO = "info" # Service attributes ATTR_EVENT_NAME = "event_name" ATTR_EVENT_DATA = "event_data" -ATTR_PATH = "path" + ATTR_DEVICE = "device" ATTR_EXTRA = "extra" ATTR_TYPE = "type" + +ATTR_LANGUAGE = "language" ATTR_TIMER_ID = "timer_id" ATTR_REMOVE_ALL = "remove_all" ATTR_INCLUDE_EXPIRED = "include_expired" @@ -189,6 +209,7 @@ class VAMode(StrEnum): ATTR_DOWNLOAD_FROM_DEV_BRANCH = "download_from_dev_branch" ATTR_DISCARD_DASHBOARD_USER_CHANGES = "discard_dashboard_user_changes" + VA_ATTRIBUTE_UPDATE_EVENT = "va_attr_update_event_{}" VA_BACKGROUND_UPDATE_EVENT = "va_background_update_{}" VA_ASSET_UPDATE_PROGRESS = "va_asset_update_progress" diff --git a/custom_components/view_assist/core/__init__.py b/custom_components/view_assist/core/__init__.py new file mode 100644 index 0000000..97661b6 --- /dev/null +++ b/custom_components/view_assist/core/__init__.py @@ -0,0 +1,120 @@ +"""Loads core modules for View Assist. + +This code should not be edited unless you know what you are doing. +To add a new module, create a new file in the core folder and add the module class +to the LOAD_MODULES list. +""" + +import asyncio +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from ..assets import AssetsManager # noqa: TID252 +from ..const import DOMAIN # noqa: TID252 +from ..helpers import get_integration_entries # noqa: TID252 +from ..typed import VAConfigEntry # noqa: TID252 +from .alarm_repeater import AlarmRepeater +from .http import HTTPManager +from .javascript import JSModuleRegistration +from .services import Services +from .templates import TemplatesManager +from .timers import TimerManager +from .translator import Translator +from .websocket import WebsocketManager + +_LOGGER = logging.getLogger(__name__) + +LOAD_MODULES = [ + HTTPManager, + JSModuleRegistration, + TemplatesManager, + AssetsManager, + Translator, + Services, + TimerManager, + AlarmRepeater, + WebsocketManager, +] + + +class CoreManager: + """Class to manage core functions.""" + + def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: + """Initialise.""" + self.hass = hass + self.config = config + + async def async_start(self, *args) -> bool: + """Set up the Core Functions.""" + _LOGGER.debug("Loading core functions") + + loader_tasks = set() + for module in LOAD_MODULES: + loader_tasks.add(asyncio.create_task(self._async_load_module(module))) + + setup_result = all(await asyncio.gather(*loader_tasks)) + + # Load update platform + if self.config.runtime_data.integration.enable_updates: + _LOGGER.debug("Loading %s platform", Platform.UPDATE) + await self.hass.config_entries.async_forward_entry_setups( + self.config, [Platform.UPDATE] + ) + + # Reload any running device config entries to pick up core changes + if entries := get_integration_entries(self.hass): + for entry in entries: + if entry.state == ConfigEntryState.LOADED: + _LOGGER.debug("Reloading config entry %s", entry.title) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + + return setup_result + + async def _async_load_module(self, module) -> bool: + """Load a module.""" + instance = module(self.hass, self.config) + _LOGGER.debug("Loading %s", module.__name__) + if hasattr(instance, "async_setup"): + result = await instance.async_setup() + self.hass.data[DOMAIN][module.__name__] = instance + return result + return False + + @staticmethod + async def async_unload(hass: HomeAssistant, config: VAConfigEntry) -> None: + """Stop the Core Functions.""" + _LOGGER.debug("Unloading core functions") + + # Unload update platform + if config.runtime_data.integration.enable_updates: + _LOGGER.debug("Unloading update notifications") + await hass.config_entries.async_unload_platforms(config, [Platform.UPDATE]) + + unloader_tasks = set() + for module in LOAD_MODULES: + if hasattr(module, "async_unload"): + unloader_tasks.add( + asyncio.create_task( + CoreManager._async_unload_module(hass, config, module) + ) + ) + + return all(await asyncio.gather(*unloader_tasks)) + + @staticmethod + async def _async_unload_module( + hass: HomeAssistant, config: VAConfigEntry, module: Any + ) -> None: + """Unload a module.""" + _LOGGER.debug("Unloading %s", module.__name__) + instance = hass.data[DOMAIN].get(module.__name__) + if instance and hasattr(instance, "async_unload"): + result = await instance.async_unload() + hass.data[DOMAIN].pop(module.__name__, None) + return result + return False diff --git a/custom_components/view_assist/alarm_repeater.py b/custom_components/view_assist/core/alarm_repeater.py similarity index 95% rename from custom_components/view_assist/alarm_repeater.py rename to custom_components/view_assist/core/alarm_repeater.py index 235c47a..58d725c 100644 --- a/custom_components/view_assist/alarm_repeater.py +++ b/custom_components/view_assist/core/alarm_repeater.py @@ -1,16 +1,17 @@ """Handlers alarm repeater.""" +from __future__ import annotations + import asyncio from dataclasses import dataclass import io import logging -import math import time from typing import Any -import voluptuous as vol import mutagen import requests +import voluptuous as vol from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -25,7 +26,7 @@ from homeassistant.helpers.entity_component import DATA_INSTANCES, EntityComponent from homeassistant.helpers.network import get_url -from .const import ( +from ..const import ( # noqa: TID252 ATTR_MAX_REPEATS, ATTR_MEDIA_FILE, ATTR_RESUME_MEDIA, @@ -69,9 +70,14 @@ class PlayingMedia: queue: Any | None = None -class VAAlarmRepeater: +class AlarmRepeater: """Class to handle announcing on media player with resume.""" + @classmethod + def get(cls, hass: HomeAssistant) -> AlarmRepeater | None: + """Get the alarm repeater instance.""" + return hass.data[DOMAIN][cls.__name__] + def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: """Initialise.""" self.hass = hass @@ -81,6 +87,9 @@ def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: self.announcement_in_progress: bool = False + async def async_setup(self) -> bool: + """Start the alarm repeater.""" + self.hass.services.async_register( DOMAIN, "sound_alarm", @@ -95,6 +104,15 @@ def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: schema=STOP_ALARM_SOUND_SERVICE_SCHEMA, ) + return True + + async def async_unload(self) -> bool: + """Stop the alarm repeater.""" + await self.cancel_alarm_sound() + self.hass.services.async_remove(DOMAIN, "sound_alarm") + self.hass.services.async_remove(DOMAIN, "cancel_sound_alarm") + return True + async def _async_handle_alarm_sound(self, call: ServiceCall) -> ServiceResponse: """Handle alarm sound.""" entity_id = call.data.get(ATTR_ENTITY_ID) diff --git a/custom_components/view_assist/core/decoder.py b/custom_components/view_assist/core/decoder.py new file mode 100644 index 0000000..c8349e7 --- /dev/null +++ b/custom_components/view_assist/core/decoder.py @@ -0,0 +1,500 @@ +"""Decoder for time-related phrases.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import StrEnum +import json +import logging +from pathlib import Path +from typing import Any + +import wordtodigits + +from homeassistant.core import HomeAssistant + +from ..const import DOMAIN # noqa: TID252 + +_LOGGER = logging.getLogger(__name__) + + +class Days(StrEnum): + """Days of the week enum.""" + + SUNDAY = "sunday" + MONDAY = "monday" + TUESDAY = "tuesday" + WEDNESDAY = "wednesday" + THURSDAY = "thursday" + FRIDAY = "friday" + SATURDAY = "saturday" + TODAY = "today" + TOMORROW = "tomorrow" + + +class Durations(StrEnum): + """Duration types enum.""" + + DAY = "day" + HOUR = "hour" + MINUTE = "minute" + SECOND = "second" + + +class HourPrefixes(StrEnum): + """Hour prefixes for time expressions.""" + + TO = "to" + PAST = "past" + + +class Meridiem(StrEnum): + """Meridiem indicators.""" + + AM = "am" + PM = "pm" + + +class SpecialMinutes(StrEnum): + """Special minute indicators.""" + + OCLOCK = "oclock" + QUARTER = "quarter" + QUARTERPAST = "quarterpast" + QUARTERTO = "quarterto" + HALF = "half" + HALFPAST = "halfpast" + THREEQUARTER = "threequarter" + + +class SpecialMinuteConversion(StrEnum): + """Special minute conversions for calculating time.""" + + OCLOCK = "" + QUARTER = "15" + HALF = "30" + THREEQUARTER = "45" + DAY_QUARTER = "6" + DAY_HALF = "12" + DAY_THREEQUARTER = "18" + + +@dataclass +class TimerInterval: + """Data class for timer intervals.""" + + sentence: str | None = None + translated: str | None = None + day: int = 0 + hour: int = 0 + minute: int = 0 + second: int = 0 + + +@dataclass +class TimerTime: + """Data class for specific timer times.""" + + sentence: str | None = None + translated: str | None = None + day: str | None = None + meridiem: str | None = None + time: str | None = None + + +class LangPackKeys(StrEnum): + """Language pack keys.""" + + NUMBERS = "numbers" + DAYS = "days" + DURATIONS = "durations" + HOUR_PREFIXES = "hour_prefixes" + MERIDIEM = "meridiem" + SPECIAL_MINUTES = "special_minutes" + REMOVE_WORDS = "remove_words" + REPLACE_TEXT = "replace_text" + + +REMOVE_CHARS = [",", ";", "!", "?", "'", '"'] + + +class SentenceDecoder: + """Class to decode time and interval sentences.""" + + @classmethod + def get(cls, hass: HomeAssistant) -> SentenceDecoder | None: + """Get the websocket manager for a config entry.""" + try: + return hass.data[DOMAIN][cls.__name__] + except KeyError: + return None + + def __init__(self, hass: HomeAssistant, lang: str = "en") -> None: + """Initialise.""" + self.hass = hass + self.lang = lang + self.translator: TimeSentenceTranslator | None = None + + self.day: str | None = None + self.minutes_adjustment: int = 0 + self.meridiem: str | None = None + + async def async_setup(self) -> bool: + """Set up the Sentence Decoder.""" + if not self.translator: + self.translator = TimeSentenceTranslator(self.hass, self.lang) + return True + + async def async_unload(self) -> bool: + """Unload the Sentence Decoder.""" + return True + + def decode(self, sentence: str) -> TimerTime | TimerInterval: + """Decode a time expression into a datetime object.""" + if not self.translator: + self.translator = TimeSentenceTranslator(self.hass, self.lang) + + translated = self.translator.translate() + + if self._is_interval(translated): + # Decode as interval + t = TimerInterval(sentence=sentence, translated=translated) + return self.decode_interval(t) + # Decode as time + t = TimerTime(sentence=sentence, translated=translated) + return self.decode_time(t) + + def decode_interval(self, t: TimerInterval) -> TimerInterval: + """Decode time intervals like '2 hours 30 minutes'.""" + + processed = " ".join(t.translated.split()) + interval = {} + last_processed_duration = None + + # Get interval parts from duration tags + remaining_sentence = processed + carry = "" + for duration in Durations: + if carry: + interval[duration] = carry + carry = "" + + if m := self.get_match(remaining_sentence, duration): + parts = remaining_sentence.split(m) + if len(parts) == 2: + # Handle if duration with no value. Assume 1 + if parts[0].strip() == "": + parts[0] = "1" + + # Handle if special interval in duration + part0 = parts[0].strip() + if self._is_number(part0): + interval[duration] = part0 + else: + for sm in SpecialMinutes: + if m := self.get_match(part0, sm): + carry = self._convert_special_minute( + duration=duration, special_minute=sm + ) + interval[duration] = part0.replace(m, "").strip() + break + remaining_sentence = parts[1].strip() + last_processed_duration = duration + + # if anything left in remaining sentence, see if it is special time and add to interval below last processed duration + # ie if last processed duration is hour, add to minutes + if remaining_sentence: + if m := self.get_match(remaining_sentence, list(SpecialMinutes)): + idx = list(Durations).index(last_processed_duration) + if idx + 1 < len(Durations): + next_duration = list(Durations)[idx + 1] + interval[next_duration] = self._convert_special_minute( + next_duration, m + ) + + # Set interval values on TimerInterval object + carry = 0 + for key, value in interval.items(): + try: + value = float(value) + except ValueError: + value = 0 + + if value != int(value): + # If decimal, add remainder to lower duration + idx = list(Durations).index(key) + if idx + 1 < len(Durations): + lower_duration = list(Durations)[idx + 1] + part = value - int(value) + setattr( + t, + lower_duration, + int(part * 24) if key == Durations.DAY else int(part * 60), + ) + setattr(t, key, int(value) if value else 0) + return t + + def decode_time(self, t: TimerTime) -> TimerTime: + """Decode specific time like '4:30 PM' or 'quarter past 3'.""" + + processed = t.translated.strip() + + adjustment = 0 + + # Extract day if mentioned + for day in Days: + if m := self.get_match(processed, day): + t.day = m + processed = processed.replace(m, "").strip() + break + + # Convert word intervals to time adjustments + for sm in SpecialMinutes: + if m := self.get_match(processed, sm): + processed = processed.replace(m, "").strip() + convert_to = SpecialMinuteConversion[sm.upper()] + adjustment = int(convert_to) if self._is_number(convert_to) else 0 + break + + # Extract meridiem if mentioned + for mer in Meridiem: + if m := self.get_match(processed, mer, whole_word=False): + t.meridiem = m + processed = processed.replace(m, "").strip() + break + + # Convert phrases like "20 past 4" to "4:20" + for addition in HourPrefixes: + if m := self.get_match(processed, addition): + parts = processed.split(m) + if len(parts) == 2: + first_part = parts[0].strip() + if self._is_number(first_part): + adjustment = ( + int(first_part) + if addition == HourPrefixes.PAST + else -int(first_part) + ) + # Adjustment may already have been set by special minutes + elif adjustment > 0: + if m == HourPrefixes.TO: + adjustment = -adjustment + + processed = parts[1].strip() + + # Special handling for "half [hour]" with no duration marker + parts = processed.split(" ") + if ( + len(parts) == 2 + and parts[0] == SpecialMinuteConversion.HALF + and self._is_number(parts[1]) + ): + adjustment = 30 + processed = parts[1].strip() + + # Ensure correct spacing + processed = " ".join(processed.split()) + + # Convert number to time ie 1600 to 16:00 + if self._is_number(processed) and len(processed) in [3, 4]: + if len(processed) == 3: + processed = f"{processed[0]}:{processed[1:]}" + else: + processed = f"{processed[:2]}:{processed[2:]}" + + # If just hour with no minutes, add :00 + if self._is_number(processed) and len(processed) in [1, 2]: + processed = f"{processed}:00" + + # Set final time field + try: + hours, minutes = processed.split(":") + hours = int(hours) + if hours < 12 and t.meridiem == Meridiem.PM: + hours += 12 + + tm = datetime.now().replace( + hour=hours, minute=int(minutes), second=0, microsecond=0 + ) + if adjustment != 0: + tm = tm + timedelta(minutes=adjustment) + t.time = tm.strftime("%H:%M") + except ValueError: + pass + + return t + + def _is_interval(self, s: str) -> bool: + durations = Durations + return any(self.get_match(s, duration) for duration in durations) + + def _is_number(self, s: str | None = None) -> bool: + """Check if string is a number. Including decimals.""" + if s is None or s == "": + return False + + allowed_chars = "0123456789." + return all(char in allowed_chars for char in s) + + def get_match( + self, s: str, options: str | list[str], whole_word: bool = True + ) -> str | None: + """Get first matching option in string.""" + if isinstance(options, str): + options = [options] + + s = f" {s.strip()} " + + for option in options: + if whole_word and f" {option} " in s: + return option + if not whole_word and option in s: + return option + return None + + def _convert_special_minute( + self, duration: Durations, special_minute: SpecialMinutes + ) -> str | None: + """Convert special minute like 'half' or 'quarter' to numeric value.""" + + if duration == Durations.DAY: + key = f"DAY_{special_minute.name}" + else: + key = special_minute.name + + try: + return SpecialMinuteConversion[key] + except KeyError: + return None + + +class TimeSentenceTranslator: + """Translate time sentences to english.""" + + def __init__(self, hass: HomeAssistant, locale: str = "en") -> None: + """Initialise.""" + self.hass = hass + self.locale = locale + self.lang: dict[str, Any] = {} + + def load_language_pack(self, lang: str) -> None: + """Load language pack.""" + # Get current path of this file + p = Path( + self.hass.config.path(DOMAIN), "translations", "timers", f"{lang}.json" + ) + + if not p.exists(): + p = Path(Path(__file__).parent, "translations", "timers", "en.json") + try: + with p.open(mode="r", encoding="utf-8") as f: + self.lang = json.load(f) + except json.JSONDecodeError: + _LOGGER.error("Error decoding language file %s", p) + except OSError: + _LOGGER.error("Error loading language file %s", p) + + def get_match(self, s: str, options: str | list[str]) -> str | None: + """Get first matching option in string.""" + if isinstance(options, str): + options = [options] + + s = f" {s.strip()} " + + for option in options: + if f" {option} " in s: + return option + return None + + def clean_sentence(self, s: str) -> str: + """Clean sentence by removing unwanted characters and words.""" + s = f" {s.strip()} " + + # Replace decimal separator with . + if self.lang.get("decimal_separator"): + s = s.replace(self.lang["decimal_separator"], ".") + + # Replace text + if rt := self.lang.get(LangPackKeys.REPLACE_TEXT): + for old, new in rt.items(): + s = s.replace(old, new) + + # Remove unwanted characters + for char in REMOVE_CHARS: + s = s.replace(char, "") + + # Remove unwanted words + if rw := self.lang.get(LangPackKeys.REMOVE_WORDS): + for word in rw: + s = s.replace(f" {word} ", " ") + + # Ensure 1 space between words + return " ".join(s.split()) + + def _order_lang_key_entries(self, lang_key: str) -> dict[str, Any]: + """Order entries in lang_key by length of entry, longest first.""" + if lang_key not in self.lang: + return {} + + sorted_keys = sorted(self.lang.get(lang_key), key=len, reverse=True) + return dict( + zip( + sorted_keys, + [self.lang.get(lang_key)[key] for key in sorted_keys], + strict=False, + ) + ) + + def translate(self, sentence: str) -> str: + """Load translation file and translate sentence.""" + if not self.lang: + self.load_language_pack(self.locale) + + # Make sentence lowercase for matching + s = sentence.lower() + + # Preprocess sentence to remove and replace words/text/symbols + s = self.clean_sentence(s) + + # Handle special cases like "quarter", "half", "oclock" + # Important this is first to stop three quarters being translated to 3 quarters + if sm := self._order_lang_key_entries(LangPackKeys.SPECIAL_MINUTES): + for sm, words in sm.items(): + if m := self.get_match(s, words): + s = s.replace(m, sm) + + # Replace variants with standard am/pm + if mr := self._order_lang_key_entries(LangPackKeys.MERIDIEM): + for mr, variants in mr.items(): + if m := self.get_match(s, variants): + s = s.replace(m, mr) + + # Replace days of the week + if dow := self._order_lang_key_entries(LangPackKeys.DAYS): + for day, variants in dow.items(): + if m := self.get_match(s, variants): + s = s.replace(m, day) # Remove day from time string + + # Replace language numbers with digits + if num := self._order_lang_key_entries(LangPackKeys.NUMBERS): + for digit, variants in num.items(): + if m := self.get_match(s, variants): + s = s.replace(m, str(digit)) + + # Replace duration words with standard duration + if dur := self._order_lang_key_entries(LangPackKeys.DURATIONS): + for duration, variants in dur.items(): + if m := self.get_match(s, variants): + s = s.replace(m, duration) + + # Replace any special additions like "past" or "to" + if hour_prefixes := self._order_lang_key_entries(LangPackKeys.HOUR_PREFIXES): + for addition, variants in hour_prefixes.items(): + if m := self.get_match(s, variants): + s = s.replace(m, addition) + + # Finally convert any text words to digits + if any(n for n in self.lang[LangPackKeys.NUMBERS] if n in s): + s = wordtodigits.convert(s) + return " ".join(s.split()) diff --git a/custom_components/view_assist/http_url.py b/custom_components/view_assist/core/http.py similarity index 75% rename from custom_components/view_assist/http_url.py rename to custom_components/view_assist/core/http.py index 1d1105d..5a3a966 100644 --- a/custom_components/view_assist/http_url.py +++ b/custom_components/view_assist/core/http.py @@ -1,13 +1,15 @@ """Handles HTTP functions.""" +from __future__ import annotations + import logging from pathlib import Path from homeassistant.components.http import StaticPathConfig from homeassistant.core import HomeAssistant -from .const import DOMAIN, URL_BASE, VA_SUB_DIRS -from .typed import VAConfigEntry +from ..const import DOMAIN, URL_BASE, VA_SUB_DIRS # noqa: TID252 +from ..typed import VAConfigEntry # noqa: TID252 _LOGGER = logging.getLogger(__name__) @@ -20,6 +22,16 @@ def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: self.hass = hass self.config = config + async def async_setup(self) -> bool: + """Set up the HTTP Manager.""" + await self.create_url_paths() + return True + + async def async_unload(self) -> bool: + """Unload the HTTP Manager.""" + # Currently nothing to unload + return True + async def _async_register_path(self, url: str, path: str): """Register resource path if not already registered.""" try: diff --git a/custom_components/view_assist/js_modules/__init__.py b/custom_components/view_assist/core/javascript.py similarity index 89% rename from custom_components/view_assist/js_modules/__init__.py rename to custom_components/view_assist/core/javascript.py index e9e2142..02c56d6 100644 --- a/custom_components/view_assist/js_modules/__init__.py +++ b/custom_components/view_assist/core/javascript.py @@ -1,5 +1,7 @@ """View Assist Javascript module registration.""" +from __future__ import annotations + import logging from pathlib import Path @@ -9,6 +11,7 @@ from homeassistant.helpers.event import async_call_later from ..const import DOMAIN, JSMODULES, URL_BASE # noqa: TID252 +from ..typed import VAConfigEntry # noqa: TID252 _LOGGER = logging.getLogger(__name__) @@ -18,12 +21,13 @@ class JSModuleRegistration: """Register Javascript modules.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: """Initialise.""" self.hass = hass + self.config = config self.lovelace: LovelaceData = self.hass.data.get("lovelace") - async def async_register(self): + async def async_setup(self) -> bool: """Register view_assist path.""" # Remove previous registration - can be removed after this version await self.async_unregister(URL_BASE) @@ -31,15 +35,23 @@ async def async_register(self): await self._async_register_path() if self.lovelace.mode == "storage": await self._async_wait_for_lovelace_resources() + return True + + async def async_unload(self) -> bool: + """Unload javascript module registration.""" + if self.lovelace.mode == "storage": + await self.async_unregister() + return True # install card resources async def _async_register_path(self): """Register resource path if not already registered.""" try: + path = Path(self.hass.config.path(f"custom_components/{DOMAIN}/js_modules")) await self.hass.http.async_register_static_paths( - [StaticPathConfig(JS_URL, Path(__file__).parent, False)] + [StaticPathConfig(JS_URL, path, False)] ) - _LOGGER.debug("Registered resource path from %s", Path(__file__).parent) + _LOGGER.debug("Registered resource path from %s", path) except RuntimeError: # Runtime error is likley this is already registered. _LOGGER.debug("Resource path already registered") diff --git a/custom_components/view_assist/core/services.py b/custom_components/view_assist/core/services.py new file mode 100644 index 0000000..280ea54 --- /dev/null +++ b/custom_components/view_assist/core/services.py @@ -0,0 +1,62 @@ +"""Integration services.""" + +from __future__ import annotations + +from asyncio import TimerHandle +import logging + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback + +from ..const import ATTR_EVENT_DATA, ATTR_EVENT_NAME, DOMAIN # noqa: TID252 +from ..typed import VAConfigEntry # noqa: TID252 + +_LOGGER = logging.getLogger(__name__) + + +BROADCAST_EVENT_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_EVENT_NAME): str, + vol.Required(ATTR_EVENT_DATA): dict, + } +) + + +class Services: + """Class to manage services.""" + + def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: + """Initialise.""" + self.hass = hass + self.config = config + + self.navigate_task: dict[str, TimerHandle] = {} + + async def async_setup(self) -> bool: + """Initialise VA services.""" + + self.hass.services.async_register( + DOMAIN, + "broadcast_event", + self._handle_broadcast_event, + schema=BROADCAST_EVENT_SERVICE_SCHEMA, + ) + return True + + async def async_unload(self) -> bool: + """Stop the services.""" + self.hass.services.async_remove(DOMAIN, "broadcast_event") + return True + + @callback + def _handle_broadcast_event(self, call: ServiceCall): + """Fire an event with the provided name and data. + + name: View Assist Broadcast Event + description: Immediately fires an event with the provided name and data + """ + event_name = call.data.get(ATTR_EVENT_NAME) + event_data = call.data.get(ATTR_EVENT_DATA, {}) + # Fire the event + self.hass.bus.fire(event_name, event_data) diff --git a/custom_components/view_assist/core/templates.py b/custom_components/view_assist/core/templates.py new file mode 100644 index 0000000..289bd49 --- /dev/null +++ b/custom_components/view_assist/core/templates.py @@ -0,0 +1,133 @@ +"""Adds template functions to HA.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from typing import Any + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.template import Template, TemplateEnvironment + +from ..helpers import ( # noqa: TID252 + get_config_entry_by_config_data_value, + get_entities_by_attr_filter, + get_mimic_entity_id, + get_sensor_entity_from_instance, +) +from ..typed import VAConfigEntry # noqa: TID252 + +DEFAULT_UNAVAILABLE_STATES = [ + STATE_UNKNOWN, + STATE_UNAVAILABLE, + "", + None, +] + +_LOGGER = logging.getLogger(__name__) + + +class TemplatesManager: + """Class to manage template related functionality.""" + + def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: + """Initialize the TemplatesManager.""" + self.hass = hass + self.config = config + + async def async_setup(self) -> bool: + """Set up the TemplatesManager.""" + + def is_safe_callable(self: TemplateEnvironment, obj) -> bool: + return isinstance( + obj, + (ViewAssistEntities, ViewAssistEntity), + ) or self.ct_original_is_safe_callable(obj) + + def patch_environment(env: TemplateEnvironment) -> None: + env.globals["view_assist_entities"] = ViewAssistEntities(self.hass) + env.globals["view_assist_entity"] = ViewAssistEntity(self.hass) + + def patched_init( + self: TemplateEnvironment, + hass_param: HomeAssistant | None, + limited: bool | None = False, + strict: bool | None = False, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: + self.ct_original__init__(hass_param, limited, strict, log_fn) + patch_environment(self) + + if not hasattr(TemplateEnvironment, "ct_original__init__"): + TemplateEnvironment.ct_original__init__ = TemplateEnvironment.__init__ + TemplateEnvironment.__init__ = patched_init + + if not hasattr(TemplateEnvironment, "ct_original_is_safe_callable"): + TemplateEnvironment.ct_original_is_safe_callable = ( + TemplateEnvironment.is_safe_callable + ) + TemplateEnvironment.is_safe_callable = is_safe_callable + + tpl = Template("", self.hass) + tpl._strict = False # noqa: SLF001 + tpl._limited = False # noqa: SLF001 + patch_environment(tpl._env) # noqa: SLF001 + tpl._strict = True # noqa: SLF001 + tpl._limited = False # noqa: SLF001 + patch_environment(tpl._env) # noqa: SLF001 + + return True + + async def async_unload(self) -> bool: + """Unload the TemplatesManager.""" + return True + + +# Template functions +class ViewAssistEntities: + """Get entities or attr by attr filter.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Init.""" + self._hass = hass + + def __call__( + self, + filter: dict[str, Any] | None = None, + exclude: dict[str, Any] | None = None, + attr: str | None = None, + ) -> list[str]: + "Call." + entities = get_entities_by_attr_filter(self._hass, filter, exclude) + if attr: + return [ + self._hass.states.get(entity).attributes.get(attr) + for entity in entities + ] + return entities + + def __repr__(self) -> str: + """Print.""" + return "