diff --git a/.gitignore b/.gitignore index 8263d3b..66f6e04 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,6 @@ ENV/ env.bak/ venv.bak/ -.vscode \ No newline at end of file +# IDEs +.vscode +.idea/ diff --git a/README.md b/README.md index 0e40ba2..84df65e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Welcome -Welcome to the long awaited, much anticipated View Assist integration beta! Some of the notable improvements include: +Welcome to the long awaited, much anticipated View Assist integration! Some of the notable improvements include: * All configuration done within the integration. The days of editing YAML files are over! * The View Assist dashboard is now autocreated when a View Assist device with visual output is configured. This includes assets like default images and soundfiles to be downloaded and preconfigured for use @@ -15,17 +15,8 @@ Welcome to the long awaited, much anticipated View Assist integration beta! Som A HUGE thank you goes out to Mark Parker @msp1974 for his MASSIVE help with making this a reality. Mark has written the majority of the integration with my guidance. You should check out his [Home Assistant Integration Examples](https://github.com/msp1974/HAIntegrationExamples) Github if you are intestered in creating your own integration. His work has propelled View Assist to first class in very short order. We would not be where we are today without his continued efforts and the hours and hours he has put in to make View Assist better! Thanks again Mark! - - # Install -## Notes for existing VA users - -**A BIG warning for folks who will be updating. This is a major rewrite so you will be starting from scratch for the most part. You will definitely want to do a backup of your current VA settings and views and possibly save a copy of your current dashboard to avoid from losing something you would like to keep!** - -You will want to delete your View Assist dashboard before installing. I suggest that you save your dashboard as a text file (Raw configuration copy/paste). You will need to UPDATE your View Assist blueprints using the new blueprints. This is done by importing the new version of the blueprint and choosing to update the existing. This SHOULD allow for you to keep all settings but be warned that this is beta so problems may exist with keeping these settings in some cases. - - ## HACS * Install HACS if you have not already * Open HACS and click three dots in right corner -> Custom Repositories -> then paste `https://github.com/dinki/view_assist_integration/` in 'Repository' and choose type 'Integration' then click 'Add' @@ -42,6 +33,10 @@ This integration can be installed by downloading the [view_assist](https://githu Questions, problems, concerns? Reach out to us on Discord or use the 'Issues' above +## Development +To develop this integration, you will need Python 3.13.0 or higher, as homeassistant 2024.2.0+ requires it. You will also need to install the dependencies referenced in the [requirements.txt](custom_components/requirements.txt) file. +In addition, if you want to run the tests, you will need to install the dependencies referenced in the [test_requirements.txt](custom_components/test_requirements.txt) file. + # Help -Need help? Hop on our Discord channel and we'll give you a boost! +Need help? Check our [View Assist Wiki](https://dinki.github.io/View-Assist/) for the most up-to-date documentation. You can also hop on our [View Assist Discord Server](https://discord.gg/3WXXfGAf8T) and we'll give you a boost! diff --git a/custom_components/requirements.txt b/custom_components/requirements.txt new file mode 100644 index 0000000..d284350 --- /dev/null +++ b/custom_components/requirements.txt @@ -0,0 +1,4 @@ +mutagen~=1.47.0 +voluptuous~=0.15.2 +homeassistant~=2025.4.4 +wordtodigits~=1.0.2 \ No newline at end of file diff --git a/custom_components/test_requirements.txt b/custom_components/test_requirements.txt new file mode 100644 index 0000000..e360736 --- /dev/null +++ b/custom_components/test_requirements.txt @@ -0,0 +1,2 @@ +pytest~=8.3.5 +pytest-asyncio~=0.26.0 \ No newline at end of file diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 605cddb..327df3f 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -10,28 +10,59 @@ 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_FONT_STYLE, + CONF_HIDE_HEADER, + CONF_HIDE_SIDEBAR, CONF_MIC_TYPE, + 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, + DEFAULT_VALUES, DOMAIN, OPTION_KEY_MIGRATIONS, - RuntimeData, - VAConfigEntry, - VAEvent, - VAType, ) -from .dashboard import DASHBOARD_MANAGER, DashboardManager 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 _LOGGER = logging.getLogger(__name__) @@ -39,6 +70,15 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] +def migrate_to_section(entry: VAConfigEntry, params: list[str]): + """Build a section for the config entry.""" + section = {} + for param in params: + if entry.options.get(param): + section[param] = entry.options.pop(param) + return section + + async def async_migrate_entry( hass: HomeAssistant, entry: VAConfigEntry, @@ -51,9 +91,8 @@ async def async_migrate_entry( entry.minor_version, entry.options, ) - new_options = {} - if entry.minor_version == 1 and entry.options: - new_options = {**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: @@ -62,12 +101,77 @@ async def async_migrate_entry( if entry.minor_version < 3 and entry.options: # Remove mic_type key if "mic_type" in entry.options: - new_options = {**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 new_options != entry.options: hass.config_entries.async_update_entry( - entry, options=new_options, minor_version=3, version=1 + entry, options=new_options, minor_version=4, version=1 ) _LOGGER.debug( @@ -87,9 +191,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VAConfigEntry): # Add runtime data to config entry to have place to store data and # make accessible throughout integration - if not is_master_entry: - entry.runtime_data = RuntimeData() - set_runtime_data_from_config(entry) + 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)) @@ -118,11 +220,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VAConfigEntry): await load_common_display_functions(hass, entry) else: - # Add runtime data to config entry to have place to store data and - # make accessible throughout integration - entry.runtime_data = RuntimeData() - set_runtime_data_from_config(entry) - # Add config change listener entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -139,10 +236,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: VAConfigEntry): ) # Fire display device registration to setup display if first time config - async_dispatcher_send( - hass, - f"{DOMAIN}_{get_device_name_from_id(hass, entry.runtime_data.display_device)}_registered", - ) + 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", + ) return True @@ -159,6 +257,10 @@ async def load_common_functions(hass: HomeAssistant, entry: VAConfigEntry): 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() @@ -167,6 +269,16 @@ async def load_common_functions(hass: HomeAssistant, entry: VAConfigEntry): 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.""" @@ -183,30 +295,127 @@ async def setup_frontend(*args): http = HTTPManager(hass, entry) await http.create_url_paths() - dm = DashboardManager(hass, entry) - hass.data[DOMAIN][DASHBOARD_MANAGER] = dm - await dm.setup_dashboard() - async_at_started(hass, setup_frontend) -def set_runtime_data_from_config(config_entry: VAConfigEntry): +def set_runtime_data_for_config( + hass: HomeAssistant, config_entry: VAConfigEntry, is_master: bool = False +): """Set config.runtime_data attributes from matching config values.""" - config_sources = [config_entry.data, config_entry.options] - for source in config_sources: - for k, v in source.items(): - if hasattr(config_entry.runtime_data, k): - # This is a fix for config lists being a string - if isinstance(getattr(config_entry.runtime_data, k), list): - setattr(config_entry.runtime_data, k, ensure_list(v)) - else: - setattr(config_entry.runtime_data, k, v) + 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)): + 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) @@ -219,6 +428,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: VAConfigEntry): _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) diff --git a/custom_components/view_assist/alarm_repeater.py b/custom_components/view_assist/alarm_repeater.py index 26717df..235c47a 100644 --- a/custom_components/view_assist/alarm_repeater.py +++ b/custom_components/view_assist/alarm_repeater.py @@ -7,6 +7,7 @@ import math import time from typing import Any +import voluptuous as vol import mutagen import requests @@ -18,18 +19,43 @@ MediaType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse +from homeassistant.helpers import entity, selector from homeassistant.helpers.entity_component import DATA_INSTANCES, EntityComponent from homeassistant.helpers.network import get_url -from .const import BROWSERMOD_DOMAIN +from .const import ( + ATTR_MAX_REPEATS, + ATTR_MEDIA_FILE, + ATTR_RESUME_MEDIA, + BROWSERMOD_DOMAIN, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) ALARMS = "alarms" +ALARM_SOUND_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig(integration=DOMAIN) + ), + vol.Required(ATTR_MEDIA_FILE): str, + vol.Optional(ATTR_RESUME_MEDIA, default=True): bool, + vol.Optional(ATTR_MAX_REPEATS, default=0): int, + } +) + +STOP_ALARM_SOUND_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig(integration=DOMAIN) + ), + } +) + @dataclass class PlayingMedia: @@ -55,6 +81,36 @@ def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: self.announcement_in_progress: bool = False + self.hass.services.async_register( + DOMAIN, + "sound_alarm", + self._async_handle_alarm_sound, + schema=ALARM_SOUND_SERVICE_SCHEMA, + ) + + self.hass.services.async_register( + DOMAIN, + "cancel_sound_alarm", + self._async_handle_stop_alarm_sound, + schema=STOP_ALARM_SOUND_SERVICE_SCHEMA, + ) + + async def _async_handle_alarm_sound(self, call: ServiceCall) -> ServiceResponse: + """Handle alarm sound.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + media_file = call.data.get(ATTR_MEDIA_FILE) + resume_media = call.data.get(ATTR_RESUME_MEDIA) + max_repeats = call.data.get(ATTR_MAX_REPEATS) + + return await self.alarm_sound( + entity_id, media_file, "music", resume_media, max_repeats + ) + + async def _async_handle_stop_alarm_sound(self, call: ServiceCall): + """Handle stop alarm sound.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + await self.cancel_alarm_sound(entity_id) + def _get_entity_from_entity_id(self, entity_id: str): """Get entity object from entity_id.""" domain = entity_id.partition(".")[0] diff --git a/custom_components/view_assist/assets/__init__.py b/custom_components/view_assist/assets/__init__.py new file mode 100644 index 0000000..0596cf2 --- /dev/null +++ b/custom_components/view_assist/assets/__init__.py @@ -0,0 +1,331 @@ +"""Assets manager for VA.""" + +import asyncio +from enum import StrEnum +import logging +from typing import Any + +from awesomeversion import AwesomeVersion +import voluptuous as vol + +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.storage import Store +from homeassistant.util import dt as dt_util, timedelta + +from ..const import ( # noqa: TID252 + ATTR_ASSET_CLASS, + ATTR_BACKUP_CURRENT_ASSET, + ATTR_DOWNLOAD_FROM_REPO, + DOMAIN, + VA_ADD_UPDATE_ENTITY_EVENT, + VERSION_CHECK_INTERVAL, +) +from ..typed import VAConfigEntry # noqa: TID252 +from .base import AssetManagerException, BaseAssetManager +from .blueprints import BlueprintManager +from .dashboard import DashboardManager +from .views import ViewManager + +_LOGGER = logging.getLogger(__name__) + +ASSETS_MANAGER = "assets_manager" + + +class AssetClass(StrEnum): + """Asset class.""" + + DASHBOARD = "dashboard" + VIEW = "views" + BLUEPRINT = "blueprints" + + +# Dashboard must be listed first to ensure it is loaded/created +# first during onboarding +ASSET_CLASS_MANAGERS = { + AssetClass.DASHBOARD: DashboardManager, + AssetClass.VIEW: ViewManager, + AssetClass.BLUEPRINT: BlueprintManager, +} + +LOAD_ASSET_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ASSET_CLASS): vol.In( + [AssetClass.DASHBOARD, AssetClass.VIEW, AssetClass.BLUEPRINT] + ), + vol.Required(ATTR_NAME): str, + vol.Required(ATTR_DOWNLOAD_FROM_REPO, default=False): bool, + vol.Required(ATTR_BACKUP_CURRENT_ASSET, default=False): bool, + } +) + +SAVE_ASSET_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ASSET_CLASS): vol.In([AssetClass.VIEW, AssetClass.BLUEPRINT]), + vol.Required(ATTR_NAME): str, + } +) + + +class AssetsManagerStorage: + """Class to manager timer store.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialise.""" + self.hass = hass + self.data: dict[str, Any] = {} + self.store = Store(hass, 1, f"{DOMAIN}.assets") + self.lock = asyncio.Lock() + + async def _save(self): + """Save store.""" + await self.lock.acquire() + self.data["last_updated"] = dt_util.now().isoformat() + # Order dict for reading + data = self.data.copy() + last_updated = data.pop("last_updated") + if data.get("last_commit"): + last_commit = data.pop("last_commit") + else: + last_commit = {} + data = { + "last_updated": last_updated, + "last_commit": last_commit, + **dict(sorted(data.items(), key=lambda x: x[0].lower())), + } + await self.store.async_save(data) + self.lock.release() + + async def load(self, force: bool = False): + """Load dashboard data from store.""" + if self.data and not force: + return self.data + try: + if data := await self.store.async_load(): + self.data = data + else: + self.data = {} + except Exception as ex: # noqa: BLE001 + _LOGGER.error("Error loading asset store. Error is %s", ex) + self.data = {} + return self.data + + async def update(self, asset_class: str, id: str | None, data: dict[str, Any]): + """Update store.""" + self.data.setdefault(asset_class, {}) + if id is not None: + self.data[asset_class][id] = data + else: + self.data[asset_class] = data + await self._save() + + async def update_last_commit(self, asset_class: str, last_commit: str): + """Update last commit date.""" + self.data.setdefault("last_commit", {}) + self.data["last_commit"][asset_class] = last_commit + await self._save() + + +class AssetsManager: + """Class to manage VA asset installs/updates/deletes etc.""" + + def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: + """Initialise.""" + self.hass = hass + self.config = config + self.store = AssetsManagerStorage(hass) + self.managers: dict[str, BaseAssetManager] = {} + self.data: dict[str, Any] = {} + + async def async_setup(self) -> None: + """Set up the AssetManager.""" + self.data = await self.store.load() + + # Setup managers + for asset_class, manager in ASSET_CLASS_MANAGERS.items(): + self.managers[asset_class] = manager( + self.hass, self.config, self.data.get(asset_class) + ) + + # Onboard managers + await self.onboard_managers() + + # Setup managers + for manager in self.managers.values(): + await manager.async_setup() + + # Add update action + self.hass.services.async_register( + DOMAIN, "update_versions", self._async_handle_update_versions_service_call + ) + + # Add load asset action + self.hass.services.async_register( + DOMAIN, + "load_asset", + self._async_handle_load_asset_service_call, + schema=LOAD_ASSET_SERVICE_SCHEMA, + ) + + # Add save asset action + self.hass.services.async_register( + DOMAIN, + "save_asset", + self._async_handle_save_asset_service_call, + schema=SAVE_ASSET_SERVICE_SCHEMA, + ) + + # Experimental - schedule update of asset latest versions + if self.config.runtime_data.integration.enable_updates: + self.config.async_on_unload( + async_track_time_interval( + self.hass, + self.async_update_version_info, + timedelta(minutes=VERSION_CHECK_INTERVAL), + ) + ) + + async def _async_handle_update_versions_service_call(self, call: ServiceCall): + """Handle update of the view versions.""" + try: + await self.async_update_version_info(force=True) + except AssetManagerException as ex: + raise HomeAssistantError(ex) from ex + + async def _async_handle_load_asset_service_call(self, call: ServiceCall): + """Handle load of a view from view_assist dir.""" + + asset_class = call.data.get(ATTR_ASSET_CLASS) + asset_name = call.data.get(ATTR_NAME) + download = call.data.get(ATTR_DOWNLOAD_FROM_REPO, False) + backup = call.data.get(ATTR_BACKUP_CURRENT_ASSET, False) + + try: + await self.async_install_or_update( + asset_class, asset_name, download=download, backup_existing=backup + ) + except AssetManagerException as ex: + raise HomeAssistantError(ex) from ex + + async def _async_handle_save_asset_service_call(self, call: ServiceCall): + """Handle save of a view to view_assist dir.""" + + asset_class = call.data.get(ATTR_ASSET_CLASS) + asset_name = call.data.get(ATTR_NAME) + + try: + if manager := self.managers.get(asset_class): + await manager.async_save(asset_name) + except AssetManagerException as ex: + raise HomeAssistantError(ex) from ex + + async def onboard_managers(self) -> None: + """Onboard the user if not yet setup.""" + # Check if onboarding is needed and if so, run it + for asset_class, manager in self.managers.items(): + if not self.data.get(asset_class): + _LOGGER.debug("Onboarding %s", asset_class) + result = await manager.async_onboard() + + if result: + _LOGGER.debug("Onboarding result %s - %s", asset_class, result) + self.data[asset_class] = result + await self.store.update(asset_class, None, result) + + async def async_update_version_info( + self, asset_class: AssetClass | None = None, force: bool = False + ) -> None: + """Update latest versions for assets.""" + + # Throttle updates to once every VERSION_CHECK_INTERVAL minutes + if not force and self.data and "last_updated" in self.data: + last_updated = dt_util.parse_datetime(self.data["last_updated"]) + if last_updated and dt_util.utcnow() - last_updated - timedelta( + seconds=30 + ) < timedelta(minutes=VERSION_CHECK_INTERVAL): + return + + _LOGGER.debug( + "Updating latest versions for %s", + f"{asset_class} asset class" if asset_class else "all asset classes", + ) + + managers = self.managers + if asset_class and asset_class in self.managers: + managers = {k: v for k, v in self.managers.items() if k == asset_class} + + for asset_class, manager in managers.items(): # noqa: PLR1704 + # 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 + + 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 + await self.store.update_last_commit(asset_class, repo_last_commit) + + 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"], + ) + + await self.store.update(asset_class, None, self.data[asset_class]) + _LOGGER.debug("Latest versions updated") + + async def get_installed_version(self, asset_class: AssetClass, name: str) -> str: + """Get version info for asset.""" + if self.managers.get(asset_class): + return await self.managers[asset_class].async_get_installed_version(name) + return None + + async def async_install_or_update( + self, + asset_class: str, + name: str, + download: bool = False, + backup_existing: bool = False, + ): + """Install asset.""" + if manager := self.managers.get(asset_class): + # Install the asset + status = await manager.async_install_or_update( + name, download=download, backup_existing=backup_existing + ) + + if status.installed: + # Update the store with the new version + self.data[asset_class][name] = { + "installed": status.version, + "latest": status.latest_version, + } + await self.store.update(asset_class, name, self.data[asset_class][name]) + + def _fire_updates_update( + 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}, + ) diff --git a/custom_components/view_assist/assets/base.py b/custom_components/view_assist/assets/base.py new file mode 100644 index 0000000..6d02e57 --- /dev/null +++ b/custom_components/view_assist/assets/base.py @@ -0,0 +1,90 @@ +"""Base Asset Manager class.""" + +from dataclasses import dataclass +from typing import Any + +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 .download_manager import DownloadManager + + +class AssetManagerException(Exception): + """A asset manager exception.""" + + +@dataclass +class InstallStatus: + """Installed status.""" + + installed: bool = False + version: str | None = None + latest_version: str | None = None + + +class BaseAssetManager: + """Base class for asset managers.""" + + def __init__( + self, hass: HomeAssistant, config: VAConfigEntry, data: dict[str, Any] + ) -> None: + """Initialise.""" + self.hass = hass + self.config = config + self.data = data + self.download_manager = DownloadManager(hass) + self.onboarding: bool = False + + async def async_setup(self) -> None: + """Set up the AssetManager.""" + + async def async_onboard(self) -> dict[str, Any]: + """Onboard the asset manager.""" + return {} + + async def async_get_installed_version(self, name: str) -> str | None: + """Get installed version of asset.""" + if self.data and id in self.data: + return self.data[id]["installed"] + return None + + async def async_get_last_commit(self) -> str | None: + """Get if the repo has a new update.""" + raise NotImplementedError + + 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]: + """Update versions from repo.""" + raise NotImplementedError + + def is_installed(self, name: str) -> bool: + """Return if asset is installed.""" + if self.data and name in self.data: + return self.data[name]["installed"] is not None + return False + + async def async_install_or_update( + self, + name: str, + download: bool = False, + backup_existing: bool = False, + ) -> InstallStatus: + """Install or update asset.""" + raise NotImplementedError + + async def async_save(self, name: str) -> bool: + """Save asset.""" + raise NotImplementedError + + def _update_install_progress(self, name: str, progress: int): + """Update progress of view download.""" + async_dispatcher_send( + self.hass, + VA_ASSET_UPDATE_PROGRESS, + {"name": name, "progress": progress}, + ) diff --git a/custom_components/view_assist/assets/blueprints.py b/custom_components/view_assist/assets/blueprints.py new file mode 100644 index 0000000..2901bac --- /dev/null +++ b/custom_components/view_assist/assets/blueprints.py @@ -0,0 +1,304 @@ +"""Blueprint manager for View Assist.""" + +import asyncio +import logging +from pathlib import Path +import re +from typing import Any + +import voluptuous as vol + +from homeassistant.components.blueprint import errors, importer, models +from homeassistant.const import ATTR_NAME +from homeassistant.helpers import config_validation as cv +from homeassistant.util.yaml import load_yaml_dict + +from ..const import ( # noqa: TID252 + BLUEPRINT_GITHUB_PATH, + COMMUNITY_VIEWS_DIR, + DOMAIN, + GITHUB_BRANCH, + GITHUB_REPO, +) +from .base import AssetManagerException, BaseAssetManager, InstallStatus + +LOAD_BLUEPRINT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_NAME): cv.ensure_list, + } +) + +_LOGGER = logging.getLogger(__name__) + +BLUEPRINT_MANAGER = "blueprint_manager" + + +class BlueprintManager(BaseAssetManager): + """Manage blueprints for View Assist.""" + + async def async_onboard(self) -> None: + """Load blueprints for initialisation.""" + # Check if onboarding is needed and if so, run it + if not self.data: + # Load all blueprints + self.onboarding = True + bp_versions = {} + + # Ensure the blueprint automations domain has been loaded + # issue 134 + try: + async with asyncio.timeout(30): + while not self.hass.data.get("blueprint", {}).get("automation"): + _LOGGER.debug( + "Blueprint automations domain not loaded yet - waiting" + ) + await asyncio.sleep(1) + except TimeoutError: + _LOGGER.error( + "Timed out waiting for blueprint automations domain to load" + ) + return None + + blueprints = await self._get_blueprint_list() + for name in blueprints: + try: + if self.is_installed(name): + # blueprint already exists + installed_version = await self.async_get_installed_version(name) + latest_version = await self.async_get_latest_version(name) + _LOGGER.debug( + "Blueprint %s already installed. Registering version - %s", + name, + installed_version, + ) + + bp_versions[name] = { + "installed": installed_version, + "latest": latest_version, + } + + continue + + result = await self.async_install_or_update( + name=name, download=True + ) + if result.installed: + # Set installed version to latest if BP already existed + # and therefore we did not overwrite. + # This is to support update notifications for users migrating + # from prior versions of this integration. + + bp_versions[name] = { + "installed": result.version, + "latest": result.latest_version, + } + + except AssetManagerException as ex: + _LOGGER.error("Failed to load blueprint %s: %s", name, ex) + continue + self.onboarding = False + return bp_versions + return None + + async def async_get_last_commit(self) -> str | None: + """Get if the repo has a new update.""" + return await self.download_manager.get_last_commit_id( + f"{BLUEPRINT_GITHUB_PATH}" + ) + + async def async_get_latest_version(self, name: str) -> str: + """Get the latest version of a blueprint.""" + if bp := await self._get_blueprint_from_repo(name): + return self._read_blueprint_version(bp.blueprint.metadata) + return None + + async def async_get_installed_version(self, name: str) -> str | None: + """Get the installed version of a blueprint.""" + path = Path( + self.hass.config.path(models.BLUEPRINT_FOLDER), + "automation", + "dinki", + f"blueprint-{name.replace('_', '').lower()}.yaml", + ) + if path.exists(): + if data := await self.hass.async_add_executor_job(load_yaml_dict, path): + blueprint = models.Blueprint(data, schema=importer.BLUEPRINT_SCHEMA) + return self._read_blueprint_version(blueprint.metadata) + return None + + async def async_get_version_info( + self, update_from_repo: bool = True + ) -> dict[str, str]: + """Get the latest versions of blueprints.""" + # Get the latest versions of blueprints + bp_versions = {} + if blueprints := await self._get_blueprint_list(): + for name in blueprints: + installed_version = await self.async_get_installed_version(name) + latest_version = ( + await self.async_get_latest_version(name) + if update_from_repo + else self.data.get(name, {}).get("latest") + ) + bp_versions[name] = { + "installed": installed_version, + "latest": latest_version, + } + return bp_versions + + def is_installed(self, name: str) -> bool: + """Return blueprint exists.""" + path = Path( + self.hass.config.path(models.BLUEPRINT_FOLDER), + "automation", + "dinki", + f"blueprint-{name.replace('_', '').lower()}.yaml", + ) + return path.exists() + + async def async_install_or_update( + self, + name, + download: bool = False, + backup_existing: bool = False, + ) -> InstallStatus: + """Install or update blueprint.""" + success = False + installed = self.is_installed(name) + + _LOGGER.debug("%s blueprint %s", "Updating" if installed else "Adding", name) + + self._update_install_progress(name, 10) + + if not download: + raise AssetManagerException( + "Download is required to install or update a blueprint" + ) + + if backup_existing and installed: + _LOGGER.debug("Backing up existing blueprint %s", name) + await self.async_save(name) + + self._update_install_progress(name, 30) + + # Install blueprint + _LOGGER.debug("Downloading blueprint %s", name) + bp = await self._get_blueprint_from_repo(name) + + self._update_install_progress(name, 60) + + domain_blueprints: models.DomainBlueprints = self.hass.data["blueprint"].get( + bp.blueprint.domain + ) + if domain_blueprints is None: + raise AssetManagerException( + f"Invalid blueprint domain for {name}: {bp.blueprint.domain}" + ) + + path = bp.suggested_filename + if not path.endswith(".yaml"): + path = f"{path}.yaml" + + try: + _LOGGER.debug("Installing blueprint %s", path) + await domain_blueprints.async_add_blueprint( + bp.blueprint, path, allow_override=True + ) + success = True + except errors.FileAlreadyExists as ex: + if not self.onboarding: + raise AssetManagerException( + f"Error downloading blueprint {bp.suggested_filename} - already exists. Use overwrite=True to overwrite" + ) from ex + success = self.onboarding + except OSError as ex: + raise AssetManagerException( + f"Failed to download blueprint {bp.suggested_filename}: {ex}" + ) from ex + + self._update_install_progress(name, 90) + + # Return install status + version = self._read_blueprint_version(bp.blueprint.metadata) + self._update_install_progress(name, 100) + _LOGGER.debug( + "Blueprint %s successfully installed - version %s", + name, + version, + ) + return InstallStatus( + installed=success, + version=version, + latest_version=version, + ) + + async def async_save(self, name: str) -> bool: + """Save asset.""" + # Save blueprint to file in config/view_assist/blueprints + bp_file = f"blueprint-{name.replace(' ', '').replace('_', '').lower()}.yaml" + bp_path = Path( + self.hass.config.path(models.BLUEPRINT_FOLDER), + "automation", + "dinki", + bp_file, + ) + if bp_path.exists(): + backup_path = Path( + self.hass.config.path(DOMAIN), + "blueprints", + name.replace(" ", "_"), + bp_file.replace(".yaml", ".saved.yaml"), + ) + await self.hass.async_add_executor_job( + self._copy_file_to_dir, bp_path, backup_path + ) + _LOGGER.debug("Blueprint %s saved to %s", name, backup_path) + return True + + raise AssetManagerException(f"Error saving blueprint {name} - does not exist") + + 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: + f.write(source_file.read_bytes()) + except OSError as ex: + raise AssetManagerException( + f"Error copying {source_file} to {dest_file}: {ex}" + ) from ex + + async def _get_blueprint_list(self) -> list[str]: + """Get the list of blueprints from repo.""" + if data := await self.download_manager.async_get_dir_listing( + BLUEPRINT_GITHUB_PATH + ): + return [ + bp.name + for bp in data + if bp.type == "dir" + if bp.name != COMMUNITY_VIEWS_DIR + ] + return [] + + def _read_blueprint_version(self, blueprint_config: dict[str, Any]) -> str: + """Get view version from config.""" + if blueprint_config.get("description"): + match = re.search(r"\bv\s?(\d+(\.\d+)+)\b", blueprint_config["description"]) + return match.group(1) if match else "0.0.0" + return "0.0.0" + + def _get_blueprint_path(self, bp_name: str) -> str: + """Get the URL for a blueprint.""" + return f"{BLUEPRINT_GITHUB_PATH}/{bp_name}/blueprint-{bp_name.replace('_', '').lower()}.yaml" + + async def _get_blueprint_from_repo(self, name: str) -> importer.ImportedBlueprint: + """Get the blueprint from the repo.""" + try: + path = self._get_blueprint_path(name) + url = f"https://raw.githubusercontent.com/{GITHUB_REPO}/{GITHUB_BRANCH}/{path}" + return await importer.fetch_blueprint_from_github_url(self.hass, url) + except Exception as ex: + raise AssetManagerException( + f"Error downloading blueprint {name} - {ex}" + ) from ex diff --git a/custom_components/view_assist/assets/dashboard.py b/custom_components/view_assist/assets/dashboard.py new file mode 100644 index 0000000..bdb8ac3 --- /dev/null +++ b/custom_components/view_assist/assets/dashboard.py @@ -0,0 +1,393 @@ +"""Assets manager for dashboard.""" + +from __future__ import annotations + +import logging +import operator +from pathlib import Path +from typing import Any + +from homeassistant.components.lovelace import ( + CONF_ICON, + CONF_REQUIRE_ADMIN, + CONF_SHOW_IN_SIDEBAR, + CONF_TITLE, + CONF_URL_PATH, + LovelaceData, + dashboard, +) +from homeassistant.const import CONF_ID, CONF_MODE, CONF_TYPE, EVENT_LOVELACE_UPDATED +from homeassistant.core import Event, HomeAssistant +from homeassistant.util.yaml import load_yaml_dict, parse_yaml, save_yaml + +from ..const import ( # noqa: TID252 + DASHBOARD_DIR, + DASHBOARD_NAME, + DASHBOARD_VIEWS_GITHUB_PATH, + DOMAIN, +) +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 .base import AssetManagerException, BaseAssetManager, InstallStatus + +_LOGGER = logging.getLogger(__name__) + + +class DashboardManager(BaseAssetManager): + """Class to manage dashboard assets.""" + + def __init__( + self, hass: HomeAssistant, config: VAConfigEntry, data: dict[str, Any] + ) -> None: + """Initialise.""" + super().__init__(hass, config, data) + self.ignore_change_events = False + + async def async_setup(self) -> None: + """Set up the AssetManager.""" + + # Experimental - listen for dashboard change and write out changes + self.config.async_on_unload( + self.hass.bus.async_listen(EVENT_LOVELACE_UPDATED, self._dashboard_changed) + ) + + async def async_onboard(self) -> dict[str, Any] | None: + """Onboard the user if not yet setup.""" + name = "dashboard" + db_version = {} + + if self.is_installed(name): + # Ensure dashboard file exists + await self._download_dashboard(cancel_if_exists=True) + + # Update user-dashboard diff file + await self._dashboard_changed( + Event("lovelace_updated", {"url_path": self._dashboard_key}) + ) + + # Migration to update management of already installed dashboard + installed_version = await self.async_get_installed_version(name) + latest_version = await self.async_get_latest_version(name) + _LOGGER.debug( + "Dashboard already installed. Registering version - %s", + installed_version, + ) + db_version[name] = { + "installed": installed_version, + "latest": latest_version, + } + return db_version + + self.onboarding = True + # Check if onboarding is needed and if so, run it + _LOGGER.debug("Installing dashboard") + self.ignore_change_events = True + status = {} + result = await self.async_install_or_update( + name=DASHBOARD_NAME, + download=True, + backup_existing=False, + ) + if result.installed: + db_version[name] = { + "installed": result.version, + "latest": result.latest_version, + } + self.ignore_change_events = False + self.onboarding = False + + return status + + async def async_get_last_commit(self) -> str | None: + """Get if the repo has a new update.""" + return await self.download_manager.get_last_commit_id( + f"{DASHBOARD_VIEWS_GITHUB_PATH}/{DASHBOARD_DIR}" + ) + + async def async_get_installed_version(self, name: str) -> str | None: + """Get installed version of asset.""" + lovelace: LovelaceData = self.hass.data["lovelace"] + dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( + self._dashboard_key + ) + # Load dashboard config data + if dashboard_store: + dashboard_config = await dashboard_store.async_load(False) + return self._read_dashboard_version(dashboard_config) + return None + + async def async_get_latest_version(self, name: str) -> dict[str, Any]: + """Get latest version of asset from repo.""" + dashboard_path = f"{DASHBOARD_VIEWS_GITHUB_PATH}/{DASHBOARD_DIR}/{name}.yaml" + dashboard_data = await self.download_manager.get_file_contents(dashboard_path) + # Parse yaml string to json + dashboard_data = parse_yaml(dashboard_data) + return self._read_dashboard_version(dashboard_data) + + async def async_get_version_info( + self, update_from_repo: bool = True + ) -> dict[str, Any]: + """Get dashboard version from repo.""" + return { + "dashboard": { + "installed": await self.async_get_installed_version(DASHBOARD_DIR), + "latest": await self.async_get_latest_version(DASHBOARD_DIR) + if update_from_repo + else self.data.get("dashboard", {}).get("latest"), + } + } + + def is_installed(self, name: str) -> bool: + """Return blueprint exists.""" + lovelace: LovelaceData = self.hass.data["lovelace"] + return self._dashboard_key in lovelace.dashboards + + async def async_install_or_update( + self, name: str, download: bool = False, backup_existing: bool = False + ) -> InstallStatus: + """Install or update dashboard.""" + success = False + installed = self.is_installed("dashboard") + + _LOGGER.debug("%s dashboard", "Updating" if installed else "Adding") + + self._update_install_progress("dashboard", 10) + + # Download view if required + downloaded = False + if download: + # Download dashboard + _LOGGER.debug("Downloading dashboard") + downloaded = await self._download_dashboard() + if not downloaded: + raise AssetManagerException("Unable to download dashboard") + + self._update_install_progress("dashboard", 50) + + dashboard_file_path = Path( + self.hass.config.path(DOMAIN), + f"{DASHBOARD_DIR}/{DASHBOARD_DIR}.yaml", + ) + if not Path(dashboard_file_path).exists(): + # No dashboard file + raise AssetManagerException( + f"Dashboard file not found: {dashboard_file_path}" + ) + + # Ignore change events during update/install + self.ignore_change_events = True + + if not self.is_installed(self._dashboard_key): + _LOGGER.debug("Installing dashboard") + mock_connection = MockWSConnection(self.hass) + if mock_connection.execute_ws_func( + "lovelace/dashboards/create", + { + CONF_ID: 1, + CONF_TYPE: "lovelace/dashboards/create", + CONF_ICON: "mdi:glasses", + CONF_TITLE: DASHBOARD_NAME, + CONF_URL_PATH: self._dashboard_key, + CONF_MODE: "storage", + CONF_SHOW_IN_SIDEBAR: True, + CONF_REQUIRE_ADMIN: False, + }, + ): + # Get lovelace (frontend) config data + lovelace: LovelaceData = self.hass.data["lovelace"] + + # Load dashboard config file from path + dashboard_file_path = Path( + self.hass.config.path(DOMAIN), + f"{DASHBOARD_DIR}/{DASHBOARD_DIR}.yaml", + ) + + if new_dashboard_config := await self.hass.async_add_executor_job( + load_yaml_dict, dashboard_file_path + ): + self._update_install_progress("dashboard", 70) + await lovelace.dashboards[self._dashboard_key].async_save( + new_dashboard_config + ) + self._update_install_progress("dashboard", 80) + + installed_version = self._read_dashboard_version( + new_dashboard_config + ) + success = True + else: + raise AssetManagerException( + f"Dashboard config file not found: {dashboard_file_path}" + ) + else: + raise AssetManagerException( + f"Unable to create dashboard {self._dashboard_key}" + ) + else: + _LOGGER.debug("Updating dashboard") + if new_dashboard_config := await self.hass.async_add_executor_job( + load_yaml_dict, dashboard_file_path + ): + lovelace: LovelaceData = self.hass.data["lovelace"] + dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( + self._dashboard_key + ) + # Load dashboard config data + if dashboard_store: + old_dashboard_config = await dashboard_store.async_load(False) + + # Copy views to updated dashboard + new_dashboard_config["views"] = old_dashboard_config.get("views") + + # Apply + await dashboard_store.async_save(new_dashboard_config) + self._update_install_progress("dashboard", 80) + await self._apply_user_dashboard_changes() + self._update_install_progress("dashboard", 90) + + installed_version = self._read_dashboard_version( + new_dashboard_config + ) + success = True + else: + raise AssetManagerException("Error getting dashboard store") + else: + raise AssetManagerException( + f"Dashboard config file not found: {dashboard_file_path}" + ) + + self._update_install_progress("dashboard", 100) + self.ignore_change_events = False + _LOGGER.debug( + "Dashboard successfully installed - version %s", + installed_version, + ) + return InstallStatus( + installed=success, + version=installed_version, + latest_version=installed_version + if downloaded and success + else await self.async_get_latest_version(DASHBOARD_DIR), + ) + + async def async_save(self, name: str) -> bool: + """Save asset.""" + # Dashboard automatically saves differences when changed + return True + + @property + def _dashboard_key(self) -> str: + """Return path for dashboard name.""" + return DASHBOARD_NAME.replace(" ", "-").lower() + + def _read_dashboard_version(self, dashboard_config: dict[str, Any]) -> str: + """Get view version from config.""" + if dashboard_config: + try: + if variables := get_key( + "button_card_templates.variable_template.variables", + dashboard_config, + ): + return variables.get("dashboardversion", "0.0.0") + except KeyError: + _LOGGER.debug("Dashboard version not found") + return "0.0.0" + + async def _download_dashboard(self, cancel_if_exists: bool = False) -> bool: + """Download dashboard file.""" + # Ensure download to path exists + base = self.hass.config.path(f"{DOMAIN}/{DASHBOARD_DIR}") + + if cancel_if_exists and Path(base, f"{DASHBOARD_DIR}.yaml").exists(): + return False + + # Validate view dir on repo + dir_url = f"{DASHBOARD_VIEWS_GITHUB_PATH}/{DASHBOARD_DIR}" + if await self.download_manager.async_dir_exists(dir_url): + # Download dashboard files + await self.download_manager.async_download_dir(dir_url, base) + return True + + async def _dashboard_changed(self, event: Event): + # If in dashboard build mode, ignore changes + if self.ignore_change_events: + return + + if event.data["url_path"] == self._dashboard_key: + try: + lovelace: LovelaceData = self.hass.data["lovelace"] + dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( + self._dashboard_key + ) + # Load dashboard config data + if dashboard_store: + dashboard_config = await dashboard_store.async_load(False) + + # Remove views from dashboard config for saving + dashboard_only = dashboard_config.copy() + dashboard_only["views"] = [{"title": "Home"}] + + file_path = Path(self.hass.config.config_dir, DOMAIN, DASHBOARD_DIR) + file_path.mkdir(parents=True, exist_ok=True) + + if diffs := await self._compare_dashboard_to_master(dashboard_only): + await self.hass.async_add_executor_job( + save_yaml, + Path(file_path, "user_dashboard.yaml"), + diffs, + ) + + except Exception as ex: # noqa: BLE001 + _LOGGER.error("Error saving dashboard. Error is %s", ex) + + async def _compare_dashboard_to_master( + self, comp_dash: dict[str, Any] + ) -> dict[str, Any]: + """Compare dashboard dict to master and return differences.""" + # Get master dashboard + base = self.hass.config.path(DOMAIN) + dashboard_file_path = f"{base}/{DASHBOARD_DIR}/{DASHBOARD_DIR}.yaml" + + if not Path(dashboard_file_path).exists(): + # No master dashboard + return None + + # Load dashboard config file from path + if master_dashboard := await self.hass.async_add_executor_job( + load_yaml_dict, dashboard_file_path + ): + if not operator.eq(master_dashboard, comp_dash): + diffs = dictdiff.diff(master_dashboard, comp_dash, expand=True) + return differ_to_json(diffs) + return None + + async def _apply_user_dashboard_changes(self): + """Apply a user_dashboard changes file to master dashboard.""" + + # Get master dashboard + base = self.hass.config.path(DOMAIN) + user_dashboard_file_path = f"{base}/{DASHBOARD_DIR}/user_dashboard.yaml" + + if not Path(user_dashboard_file_path).exists(): + # No master dashboard + return + + # Load dashboard config file from path + _LOGGER.debug("Applying user changes to dashboard") + if user_dashboard := await self.hass.async_add_executor_job( + load_yaml_dict, user_dashboard_file_path + ): + lovelace: LovelaceData = self.hass.data["lovelace"] + dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( + self._dashboard_key + ) + # Load dashboard config data + if dashboard_store: + dashboard_config = await dashboard_store.async_load(False) + + # Apply + user_changes = json_to_dictdiffer(user_dashboard) + updated_dashboard = dictdiff.patch(user_changes, dashboard_config) + await dashboard_store.async_save(updated_dashboard) diff --git a/custom_components/view_assist/assets/download_manager.py b/custom_components/view_assist/assets/download_manager.py new file mode 100644 index 0000000..1d558a3 --- /dev/null +++ b/custom_components/view_assist/assets/download_manager.py @@ -0,0 +1,261 @@ +"""Download manager for View Assist assets.""" + +from dataclasses import dataclass +import logging +from pathlib import Path +from typing import Any +import urllib.parse + +from aiohttp import ContentTypeError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from ..const import DOMAIN, GITHUB_BRANCH, GITHUB_REPO # noqa: TID252 + +_LOGGER = logging.getLogger(__name__) + +GITHUB_TOKEN_FILE = "github.token" +MAX_DIR_DEPTH = 5 + + +class AssetManagerException(Exception): + """A asset manager exception.""" + + +@dataclass +class GithubFileDir: + """A github dir or file object.""" + + name: str + type: str + path: str + download_url: str | None = None + + +class GithubAPIException(Exception): + """A github api exception.""" + + +class GithubRateLimitException(GithubAPIException): + """A github rate limit exception.""" + + +class GithubNotFoundException(GithubAPIException): + """A github not found exception.""" + + +class GitHubAPI: + """Class to handle basic Github repo rest commands.""" + + def __init__(self, hass: HomeAssistant, repo: str) -> None: + """Initialise.""" + self.hass = hass + self.repo = repo + self.branch: str = GITHUB_BRANCH + self.api_base = f"https://api.github.com/repos/{self.repo}" + self.path_base = f"https://github.com/{self.repo}/tree/{self.branch}" + self.raw_base = f"https://raw.githubusercontent.com/{self.repo}/{self.branch}" + + def _get_token(self): + # Use HACs token if available + if hacs := self.hass.data.get("hacs", {}): + try: + return hacs.configuration.token + except AttributeError: + _LOGGER.debug("HACS is installed but token not available") + # Otherwise use the token file in the config directory if exists + token_file = self.hass.config.path(f"{DOMAIN}/{GITHUB_TOKEN_FILE}") + if Path(token_file).exists(): + with Path(token_file).open("r", encoding="utf-8") as f: + return f.read() + return None + + async def _rest_request( + self, url: str, data_as_text: bool = False + ) -> str | dict | list | None: + """Return rest request data.""" + session = async_get_clientsession(self.hass) + + kwargs = {} + if self.api_base in url: + if token := await self.hass.async_add_executor_job(self._get_token): + kwargs["headers"] = {"authorization": f"Bearer {token}"} + # _LOGGER.debug("Making api request with auth token - %s", url) + # else: + # _LOGGER.debug("Making api request without auth token - %s", url) + + async with session.get(url, **kwargs) as resp: + if resp.status == 200: + try: + return await resp.json() + except ContentTypeError: + if data_as_text: + return await resp.text() + return await resp.read() + + elif resp.status == 403: + # Rate limit + raise GithubRateLimitException( + "Github api rate limit exceeded for this hour. You may need to add a personal access token to authenticate and increase the limit" + ) + elif resp.status == 404: + raise GithubNotFoundException( + f"Path not found on this repository. {url}" + ) + else: + raise GithubAPIException(await resp.json()) + return None + + async def async_get_last_commit(self, path: str) -> dict[str, Any] | None: + """Get the last commit id for a file.""" + try: + url = f"{self.api_base}/commits?path={path}&per_page=1" + # _LOGGER.debug("Getting last commit for %s", url) + if raw_data := await self._rest_request(url): + if isinstance(raw_data, list): + # If the url is a directory, get the first file + if raw_data and isinstance(raw_data[0], dict): + return raw_data[0] + return raw_data + except GithubAPIException as ex: + _LOGGER.error(ex) + return None + + async def validate_path(self, path: str) -> bool: + """Check if a path exists in the repo.""" + try: + url = f"{self.path_base}/{path}" + await self._rest_request(url) + except GithubNotFoundException: + _LOGGER.debug("Path not found: %s", path) + return False + except GithubAPIException as ex: + _LOGGER.error("Error validating path. Error is %s", ex) + return False + else: + return True + + async def get_dir_listing(self, path: str) -> list[GithubFileDir]: + """Get github repo dir listing.""" + + path = urllib.parse.quote(path) + url_path = f"{self.api_base}/contents/{path}?ref={self.branch}" + + try: + if raw_data := await self._rest_request(url_path): + return [ + GithubFileDir(e["name"], e["type"], e["path"], e["download_url"]) + for e in raw_data + ] + except GithubAPIException as ex: + _LOGGER.error(ex) + return None + + async def get_file_contents( + self, path: str, data_as_text: bool = False + ) -> bytes | None: + """Download file.""" + path = urllib.parse.quote(path) + url_path = f"{self.raw_base}/{path}?ref={self.branch}" + + if file_data := await self._rest_request(url_path, data_as_text=data_as_text): + return file_data + _LOGGER.debug("Failed to download file") + return None + + +class DownloadManager: + """Class to handle file downloads from github repo.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialise.""" + self.hass = hass + self.github = GitHubAPI(hass, GITHUB_REPO) + + def _save_binary_to_file(self, data: bytes, file_path: str, file_name: str): + """Save binary data to file.""" + Path(file_path).mkdir(parents=True, exist_ok=True) + Path.write_bytes(Path(file_path, file_name), data) + + async def async_dir_exists(self, dir_url: str) -> bool: + """Check if a directory exists.""" + try: + return await self.github.validate_path(dir_url) + except GithubAPIException: + return False + + async def async_get_dir_listing(self, dir_url: str) -> list[GithubFileDir] | None: + """Get github repo dir listing.""" + try: + if dir_listing := await self.github.get_dir_listing(dir_url): + return dir_listing + except GithubAPIException as ex: + raise AssetManagerException( + f"Error getting directory listing for {dir_url}. Error is {ex}" + ) from ex + return None + + async def async_download_dir( + self, download_dir_path: str, save_path: str, depth: int = 1 + ) -> bool: + """Download all files in a directory.""" + try: + if dir_listing := await self.github.get_dir_listing(download_dir_path): + _LOGGER.debug("Downloading %s", download_dir_path) + # Recurse directories + for entry in dir_listing: + if entry.type == "dir" and depth <= MAX_DIR_DEPTH: + await self.async_download_dir( + entry.path, + f"{save_path}/{entry.name}", + depth=depth + 1, + ) + elif entry.type == "file": + _LOGGER.debug( + "Downloading file %s", f"{download_dir_path}/{entry.name}" + ) + if file_data := await self.github.get_file_contents( + entry.path, data_as_text=False + ): + await self.hass.async_add_executor_job( + self._save_binary_to_file, + file_data, + save_path, + entry.name, + ) + else: + raise AssetManagerException( + f"Error downloading {entry.name} from the github repository." + ) + return True + except GithubAPIException as ex: + raise AssetManagerException( + f"Error downloading {download_dir_path} from the github repository. Error is {ex}" + ) from ex + else: + return False + + async def get_file_contents(self, file_path: str) -> str | None: + """Get the contents of a file.""" + try: + if file_data := await self.github.get_file_contents( + file_path, data_as_text=True + ): + return file_data + except GithubAPIException as ex: + raise AssetManagerException( + f"Error downloading {file_path} from the github repository. Error is {ex}" + ) from ex + return None + + async def get_last_commit_id(self, path: str) -> str | None: + """Get the last commit date for a file.""" + try: + if commit_data := await self.github.async_get_last_commit(path): + return commit_data["sha"][:7] + except GithubAPIException as ex: + raise AssetManagerException( + f"Error getting last commit for {path}. Error is {ex}" + ) from ex + return None diff --git a/custom_components/view_assist/assets/views.py b/custom_components/view_assist/assets/views.py new file mode 100644 index 0000000..ef8f9c1 --- /dev/null +++ b/custom_components/view_assist/assets/views.py @@ -0,0 +1,413 @@ +"""Assets manager for views.""" + +import logging +from pathlib import Path +from typing import Any + +from homeassistant.components.lovelace import LovelaceData, dashboard +from homeassistant.const import EVENT_PANELS_UPDATED +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.yaml import load_yaml_dict, parse_yaml, save_yaml + +from ..const import ( # noqa: TID252 + COMMUNITY_VIEWS_DIR, + DASHBOARD_NAME, + DASHBOARD_VIEWS_GITHUB_PATH, + DEFAULT_VIEW, + DOMAIN, + VIEWS_DIR, +) +from .base import AssetManagerException, BaseAssetManager, InstallStatus + +_LOGGER = logging.getLogger(__name__) + + +class ViewManager(BaseAssetManager): + """Class to manage view assets.""" + + async def async_onboard(self) -> dict[str, Any] | None: + """Onboard the user if not yet setup.""" + # Check if onboarding is needed and if so, run it + if not self.data: + self.onboarding = True + vw_versions = {} + views = await self._async_get_view_list() + for view in views: + # If dashboard and views exist and we are just migrating to managed views + if await self.async_is_installed(view): + # Download latest version of view + await self._download_view(view, cancel_if_exists=True) + + installed_version = await self.async_get_installed_version(view) + latest_version = await self.async_get_latest_version(view) + _LOGGER.debug( + "View %s already installed. Registering version - %s", + view, + installed_version, + ) + vw_versions[view] = { + "installed": installed_version, + "latest": latest_version, + } + continue + + # Install view from already downloaded file or repo + result = await self.async_install_or_update(view, download=True) + if result.installed: + vw_versions[view] = { + "installed": result.version, + "latest": result.latest_version, + } + + # Delete Home view from default dashboard + await self.delete_view("home") + + self.onboarding = False + return vw_versions + return None + + async def async_get_last_commit(self) -> str | None: + """Get if the repo has a new update.""" + return await self.download_manager.get_last_commit_id( + f"{DASHBOARD_VIEWS_GITHUB_PATH}/{VIEWS_DIR}" + ) + + async def async_get_installed_version(self, name: str) -> str | None: + """Get installed version of asset.""" + if view_config := await self._async_get_view_config(name): + # Get installed version from config + return self._read_view_version(name, view_config) + return None + + async def async_get_latest_version(self, name: str) -> str | None: + """Get latest version of asset.""" + view_path = f"{DASHBOARD_VIEWS_GITHUB_PATH}/{VIEWS_DIR}/{name}/{name}.yaml" + if view_data := await self.download_manager.get_file_contents(view_path): + # Parse yaml string to json + try: + view_data = parse_yaml(view_data) + return self._read_view_version(name, view_data) + except HomeAssistantError: + _LOGGER.error("Failed to parse view %s", name) + return None + + async def async_get_version_info( + self, update_from_repo: bool = True + ) -> dict[str, Any]: + """Update versions from repo.""" + # Get the latest versions of blueprints + vw_versions = {} + if blueprints := await self._async_get_view_list(): + for name in blueprints: + installed_version = await self.async_get_installed_version(name) + latest_version = ( + await self.async_get_latest_version(name) + if update_from_repo + else self.data.get(name, {}).get("latest") + ) + vw_versions[name] = { + "installed": installed_version, + "latest": latest_version, + } + return vw_versions + + async def async_is_installed(self, name): + """Return if asset is installed.""" + return await self._async_get_view_index(name) > 0 + + async def async_install_or_update( + self, + name: str, + download: bool = False, + backup_existing: bool = False, + ) -> InstallStatus: + """Install or update asset.""" + + self._update_install_progress(name, 0) + success = False + installed_version = None + + view_index = await self._async_get_view_index(name) + file_path = Path(self.hass.config.path(DOMAIN), VIEWS_DIR, name) + + _LOGGER.debug("%s view %s", "Updating" if view_index else "Adding", name) + + self._update_install_progress(name, 10) + + if view_index > 0 and backup_existing: + # Backup existing view + _LOGGER.debug("Backing up existing view %s", name) + await self.async_save(name) + + self._update_install_progress(name, 30) + + # Download view if required + downloaded = False + # Don't download if file exists during onboarding + if self.onboarding and Path(file_path, f"{name}.yaml").exists(): + _LOGGER.debug("View file already exists for %s. Not downloading", name) + downloaded = True + elif download: + # Download view files from github repo + _LOGGER.debug("Downloading view %s", name) + downloaded = await self._download_view(name) + if not downloaded: + raise AssetManagerException( + f"Unable to download view {name}. Please check the view name and try again." + ) + + self._update_install_progress(name, 50) + + # Install view + try: + _LOGGER.debug("Installing view %s", name) + # Load in order of existence - user view version (for later feature), default version, saved version + file: Path = None + file_options = [f"user_{name}.yaml", f"{name}.yaml", f"{name}.saved.yaml"] + + for file_option in file_options: + if Path(file_path, file_option).exists(): + file = Path(file_path, file_option) + break + + if file: + new_view_config = await self.hass.async_add_executor_job( + load_yaml_dict, file + ) + else: + raise AssetManagerException( + f"Unable to install view {name}. Unable to find a yaml file" + ) + except OSError as ex: + raise AssetManagerException( + f"Unable to install view {name}. Error is {ex}" + ) from ex + + self._update_install_progress(name, 60) + + # Get lovelace (frontend) config data + lovelace: LovelaceData = self.hass.data["lovelace"] + # Get access to dashboard store + dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( + self._dashboard_key + ) + + # Load dashboard config data + if new_view_config and dashboard_store: + dashboard_config = await dashboard_store.async_load(False) + + # Create new view and add it to dashboard + new_view = { + "type": "panel", + "title": name.title(), + "path": name, + "cards": [new_view_config], + } + + if not dashboard_config["views"]: + dashboard_config["views"] = [new_view] + elif view_index: + dashboard_config["views"][view_index - 1] = new_view + elif name == DEFAULT_VIEW: + # Insert default view as first view in list + dashboard_config["views"].insert(0, new_view) + else: + dashboard_config["views"].append(new_view) + + self._update_install_progress(name, 90) + + # Save dashboard config back to HA + await dashboard_store.async_save(dashboard_config) + self.hass.bus.async_fire(EVENT_PANELS_UPDATED) + + success = True + + # Update installed version info + installed_version = self._read_view_version(name, new_view_config) + self._update_install_progress(name, 100) + + _LOGGER.debug( + "View %s successfully installed - version %s", + name, + installed_version, + ) + return InstallStatus( + installed=success, + version=installed_version, + latest_version=installed_version + if downloaded and success + else await self.async_get_latest_version(name), + ) + + async def async_save(self, name: str) -> bool: + """Backup a view to a file.""" + + # Get lovelace (frontend) config data + lovelace: LovelaceData = self.hass.data["lovelace"] + + # Get access to dashboard store + dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( + self._dashboard_key + ) + + # Load dashboard config data + if dashboard_store: + dashboard_config = await dashboard_store.async_load(False) + + # Make list of existing view names for this dashboard + for view in dashboard_config["views"]: + if view.get("path") == name.lower(): + file_path = Path( + self.hass.config.path(DOMAIN), VIEWS_DIR, name.lower() + ) + file_name = f"{name.lower()}.saved.yaml" + + if view.get("cards", []): + # Ensure path exists + file_path.mkdir(parents=True, exist_ok=True) + return await self.hass.async_add_executor_job( + save_yaml, + Path(file_path, file_name), + view.get("cards", [])[0], + ) + + raise AssetManagerException(f"No view data to save for {name} view") + return False + + async def _async_get_view_list(self) -> list[str]: + """Get the list of views from repo.""" + if data := await self.download_manager.async_get_dir_listing( + f"{DASHBOARD_VIEWS_GITHUB_PATH}/{VIEWS_DIR}" + ): + return [ + view.name + for view in data + if view.type == "dir" + if view.name != COMMUNITY_VIEWS_DIR + ] + return [] + + @property + def _dashboard_key(self) -> str: + """Return path for dashboard name.""" + return DASHBOARD_NAME.replace(" ", "-").lower() + + @property + def _dashboard_exists(self) -> bool: + """Return if dashboard exists.""" + lovelace: LovelaceData = self.hass.data["lovelace"] + return self._dashboard_key in lovelace.dashboards + + @property + def _installed_views(self) -> list[str]: + """Return installed views.""" + return self.data.keys() + + def _read_view_version(self, view: str, view_config: dict[str, Any]) -> str: + """Get view version from config.""" + if view_config: + try: + if variables := view_config.get("variables"): + return variables.get( + f"{view}version", variables.get(f"{view}cardversion", "0.0.0") + ) + except KeyError: + _LOGGER.debug("View %s version not found", view) + return "0.0.0" + + async def _async_get_view_index(self, view: str) -> int: + """Return index of view if view exists.""" + lovelace: LovelaceData = self.hass.data["lovelace"] + dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( + self._dashboard_key + ) + # Load dashboard config data + if dashboard_store: + dashboard_config = await dashboard_store.async_load(False) + if not dashboard_config["views"]: + return 0 + + for index, ex_view in enumerate(dashboard_config["views"]): + if ex_view.get("path") == view: + return index + 1 + return 0 + + async def _async_get_view_config(self, view: str) -> dict[str, Any]: + """Get view config.""" + lovelace: LovelaceData = self.hass.data["lovelace"] + dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( + self._dashboard_key + ) + # Load dashboard config data + if dashboard_store: + dashboard_config = await dashboard_store.async_load(False) + for ex_view in dashboard_config["views"]: + if ex_view.get("path") == view: + if cards := ex_view.get("cards", []): + if isinstance(cards, list): + # Get first card in list + return cards[0] + return {} + + async def _download_view( + self, + view_name: str, + community_view: bool = False, + cancel_if_exists: bool = False, + ): + """Download view files from a github repo directory.""" + + # Ensure download to path exists + base = self.hass.config.path(f"{DOMAIN}/{VIEWS_DIR}") + if community_view: + dir_url = f"{DASHBOARD_VIEWS_GITHUB_PATH}/{VIEWS_DIR}/{COMMUNITY_VIEWS_DIR}/{view_name}" + else: + dir_url = f"{DASHBOARD_VIEWS_GITHUB_PATH}/{VIEWS_DIR}/{view_name}" + + if cancel_if_exists and Path(base, view_name, f"{view_name}.yaml").exists(): + return False + + # Validate view dir on repo + if await self.download_manager.async_dir_exists(dir_url): + # Create view directory + Path(base, view_name).mkdir(parents=True, exist_ok=True) + + # Download view files + success = await self.download_manager.async_download_dir( + dir_url, Path(base, view_name) + ) + + # Validate yaml file and install view + if success and Path(base, view_name, f"{view_name}.yaml").exists(): + _LOGGER.debug("Downloaded %s", view_name) + return True + + _LOGGER.error("Failed to download %s", view_name) + return False + + async def delete_view(self, view: str): + """Delete view.""" + + # Get lovelace (frontend) config data + lovelace: LovelaceData = self.hass.data["lovelace"] + + # Get access to dashboard store + dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( + self._dashboard_key + ) + + # Load dashboard config data + if dashboard_store: + dashboard_config = await dashboard_store.async_load(True) + + # Remove view with title of home + modified = False + for index, ex_view in enumerate(dashboard_config["views"]): + if ex_view.get("title", "").lower() == view.lower(): + dashboard_config["views"].pop(index) + modified = True + break + + # Save dashboard config back to HA + if modified: + await dashboard_store.async_save(dashboard_config) diff --git a/custom_components/view_assist/config_flow.py b/custom_components/view_assist/config_flow.py index 2cd79b1..53f7d1e 100644 --- a/custom_components/view_assist/config_flow.py +++ b/custom_components/view_assist/config_flow.py @@ -10,14 +10,17 @@ 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 +from homeassistant.const import CONF_MODE, CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers.selector import ( - DeviceSelector, - DeviceSelectorConfig, + BooleanSelector, EntityFilterSelectorConfig, EntitySelector, EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -27,119 +30,100 @@ BROWSERMOD_DOMAIN, CONF_ASSIST_PROMPT, CONF_BACKGROUND, + CONF_BACKGROUND_MODE, + CONF_BACKGROUND_SETTINGS, CONF_DASHBOARD, - CONF_DEV_MIMIC, + CONF_DEVELOPER_DEVICE, + CONF_DEVELOPER_MIMIC_DEVICE, CONF_DISPLAY_DEVICE, + CONF_DISPLAY_SETTINGS, CONF_DO_NOT_DISTURB, + CONF_DUCKING_VOLUME, + CONF_ENABLE_UPDATES, CONF_FONT_STYLE, - CONF_HIDE_HEADER, - CONF_HIDE_SIDEBAR, CONF_HOME, CONF_INTENT, CONF_INTENT_DEVICE, + CONF_LIST, CONF_MEDIAPLAYER_DEVICE, + CONF_MENU_CONFIG, + CONF_MENU_ITEMS, + CONF_MENU_TIMEOUT, CONF_MIC_DEVICE, CONF_MIC_UNMUTE, CONF_MUSIC, CONF_MUSICPLAYER_DEVICE, - 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_USE_24H_TIME, + CONF_TIME_FORMAT, CONF_USE_ANNOUNCE, CONF_VIEW_TIMEOUT, CONF_WEATHER_ENTITY, - DEFAULT_ASSIST_PROMPT, - DEFAULT_DASHBOARD, - DEFAULT_DND, - DEFAULT_FONT_STYLE, - DEFAULT_HIDE_HEADER, - DEFAULT_HIDE_SIDEBAR, - DEFAULT_MIC_UNMUTE, - DEFAULT_MODE, DEFAULT_NAME, - DEFAULT_ROTATE_BACKGROUND, - DEFAULT_ROTATE_BACKGROUND_INTERVAL, - DEFAULT_ROTATE_BACKGROUND_PATH, - DEFAULT_ROTATE_BACKGROUND_SOURCE, - DEFAULT_STATUS_ICON_SIZE, - DEFAULT_STATUS_ICONS, DEFAULT_TYPE, - DEFAULT_USE_24H_TIME, - DEFAULT_USE_ANNOUNCE, - DEFAULT_VIEW_BACKGROUND, - DEFAULT_VIEW_HOME, - DEFAULT_VIEW_INTENT, - DEFAULT_VIEW_MUSIC, - DEFAULT_VIEW_TIMEOUT, - DEFAULT_WEATHER_ENITITY, + DEFAULT_VALUES, DOMAIN, REMOTE_ASSIST_DISPLAY_DOMAIN, VAAssistPrompt, - VAConfigEntry, VAIconSizes, - VAType, ) -from .helpers import ( - get_devices_for_domain, - get_master_config_entry, - get_sensor_entity_from_instance, +from .helpers import get_devices_for_domain, get_master_config_entry +from .typed import ( + VABackgroundMode, + VAConfigEntry, + VAMenuConfig, + VAScreenMode, + VATimeFormat, + VAType, ) _LOGGER = logging.getLogger(__name__) -BASE_SCHEMA = { - vol.Required(CONF_NAME): str, - vol.Required(CONF_MIC_DEVICE): EntitySelector( - EntitySelectorConfig( - filter=[ - EntityFilterSelectorConfig( - integration="esphome", domain=ASSIST_SAT_DOMAIN - ), - EntityFilterSelectorConfig( - integration="hassmic", domain=[SENSOR_DOMAIN, ASSIST_SAT_DOMAIN] - ), - EntityFilterSelectorConfig( - integration="stream_assist", - domain=[SENSOR_DOMAIN, ASSIST_SAT_DOMAIN], - ), - EntityFilterSelectorConfig( - integration="wyoming", domain=ASSIST_SAT_DOMAIN - ), - ] - ) - ), - vol.Required(CONF_MEDIAPLAYER_DEVICE): EntitySelector( - EntitySelectorConfig(domain=MEDIAPLAYER_DOMAIN) - ), - vol.Required(CONF_MUSICPLAYER_DEVICE): EntitySelector( - EntitySelectorConfig(domain=MEDIAPLAYER_DOMAIN) - ), - vol.Optional(CONF_INTENT_DEVICE, default=vol.UNDEFINED): EntitySelector( - EntitySelectorConfig(domain=SENSOR_DOMAIN) - ), -} - -DISPLAY_SCHEMA = { - vol.Required(CONF_DISPLAY_DEVICE): DeviceSelector( - DeviceSelectorConfig( - filter=[ - EntityFilterSelectorConfig(integration=BROWSERMOD_DOMAIN), - EntityFilterSelectorConfig( - integration=REMOTE_ASSIST_DISPLAY_DOMAIN, - ), - ], - ) - ), - vol.Required(CONF_DEV_MIMIC, default=False): bool, -} +MASTER_FORM_DESCRIPTION = "Values here will be used when no value is set on the View Assist satellite device configuration" +DEVICE_FORM_DESCRIPTION = ( + "Setting values here will override the master config settings for this device" +) + +BASE_DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_MIC_DEVICE): EntitySelector( + EntitySelectorConfig( + filter=[ + EntityFilterSelectorConfig( + integration="esphome", domain=ASSIST_SAT_DOMAIN + ), + EntityFilterSelectorConfig( + integration="hassmic", domain=[SENSOR_DOMAIN, ASSIST_SAT_DOMAIN] + ), + EntityFilterSelectorConfig( + integration="stream_assist", + domain=[SENSOR_DOMAIN, ASSIST_SAT_DOMAIN], + ), + EntityFilterSelectorConfig( + integration="wyoming", domain=ASSIST_SAT_DOMAIN + ), + ] + ) + ), + vol.Required(CONF_MEDIAPLAYER_DEVICE): EntitySelector( + EntitySelectorConfig(domain=MEDIAPLAYER_DOMAIN) + ), + vol.Required(CONF_MUSICPLAYER_DEVICE): EntitySelector( + EntitySelectorConfig(domain=MEDIAPLAYER_DOMAIN) + ), + vol.Optional(CONF_INTENT_DEVICE, default=vol.UNDEFINED): EntitySelector( + EntitySelectorConfig(domain=SENSOR_DOMAIN) + ), + } +) -def get_display_schema( +def get_display_devices( hass: HomeAssistant, config: VAConfigEntry | None = None ) -> dict[str, Any]: """Get display device options.""" @@ -157,17 +141,14 @@ def get_display_schema( # Add current setting if not already in list if config is not None: - if config.runtime_data.display_device not in display_devices: - display_devices[config.runtime_data.display_device] = ( - config.runtime_data.display_device - ) - - # Set a dummy device for initial setup - if not display_devices: - display_devices = {"dummy": "dummy"} + 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 - options = [ + return [ { "value": key, "label": value, @@ -175,40 +156,205 @@ def get_display_schema( for key, value in display_devices.items() ] - return ( - { - vol.Required(CONF_DISPLAY_DEVICE): SelectSelector( - SelectSelectorConfig( - options=options, - mode=SelectSelectorMode.DROPDOWN, + +def get_dashboard_options_schema(config_entry: VAConfigEntry | None) -> vol.Schema: + """Return schema for dashboard options.""" + is_master = ( + config_entry is not None + and config_entry.data[CONF_TYPE] == VAType.MASTER_CONFIG + ) + + # Modify any option lists + if is_master: + background_source_options = [ + e.value for e in VABackgroundMode if e != VABackgroundMode.LINKED + ] + background_extra = {} + else: + background_source_options = [e.value for e in VABackgroundMode] + background_extra = { + vol.Optional(CONF_ROTATE_BACKGROUND_LINKED_ENTITY): ( + EntitySelector( + EntitySelectorConfig( + integration=DOMAIN, + domain=SENSOR_DOMAIN, + exclude_entities=[], + ) ) - ), - vol.Required(CONF_DEV_MIMIC, default=False): bool, + ) } - if config is None - else { - vol.Required( - CONF_DISPLAY_DEVICE, - default=config.data.get(CONF_DISPLAY_DEVICE, vol.UNDEFINED), - ): SelectSelector( + + BASE = { + vol.Optional(CONF_DASHBOARD): str, + vol.Optional(CONF_HOME): str, + vol.Optional(CONF_MUSIC): str, + vol.Optional(CONF_INTENT): str, + vol.Optional(CONF_LIST): str, + } + BACKGROUND_SETTINGS = { + vol.Optional(CONF_BACKGROUND_MODE): SelectSelector( + SelectSelectorConfig( + translation_key="rotate_backgound_source_selector", + options=background_source_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_BACKGROUND): str, + vol.Optional(CONF_ROTATE_BACKGROUND_PATH): str, + vol.Optional(CONF_ROTATE_BACKGROUND_INTERVAL): int, + } + + DISPLAY_SETTINGS = { + vol.Optional(CONF_ASSIST_PROMPT): SelectSelector( + SelectSelectorConfig( + translation_key="assist_prompt_selector", + options=[e.value for e in VAAssistPrompt], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_STATUS_ICON_SIZE): SelectSelector( + SelectSelectorConfig( + translation_key="status_icons_size_selector", + options=[e.value for e in VAIconSizes], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_FONT_STYLE): str, + vol.Optional(CONF_STATUS_ICONS): SelectSelector( + SelectSelectorConfig( + translation_key="status_icons_selector", + options=[], + mode=SelectSelectorMode.LIST, + multiple=True, + custom_value=True, + ) + ), + vol.Optional(CONF_MENU_CONFIG): SelectSelector( + SelectSelectorConfig( + translation_key="menu_config_selector", + options=[e.value for e in VAMenuConfig], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_MENU_ITEMS): SelectSelector( + SelectSelectorConfig( + translation_key="menu_icons_selector", + options=[], + mode=SelectSelectorMode.LIST, + multiple=True, + custom_value=True, + ) + ), + vol.Optional(CONF_MENU_TIMEOUT): int, + vol.Optional(CONF_TIME_FORMAT): SelectSelector( + SelectSelectorConfig( + options=[e.value for e in VATimeFormat], + mode=SelectSelectorMode.DROPDOWN, + translation_key="lookup_selector", + ) + ), + vol.Optional(CONF_SCREEN_MODE): SelectSelector( + SelectSelectorConfig( + options=[e.value for e in VAScreenMode], + mode=SelectSelectorMode.DROPDOWN, + translation_key="lookup_selector", + ) + ), + } + + BACKGROUND_SETTINGS.update(background_extra) + + schema = BASE + schema[vol.Required(CONF_BACKGROUND_SETTINGS)] = section( + vol.Schema(BACKGROUND_SETTINGS), options=SectionConfig(collapsed=True) + ) + schema[vol.Required(CONF_DISPLAY_SETTINGS)] = section( + vol.Schema(DISPLAY_SETTINGS), options=SectionConfig(collapsed=True) + ) + return vol.Schema(schema) + + +DEFAULT_OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_WEATHER_ENTITY): EntitySelector( + EntitySelectorConfig(domain=WEATHER_DOMAIN) + ), + vol.Optional(CONF_MODE): str, + vol.Optional(CONF_VIEW_TIMEOUT): NumberSelector( + NumberSelectorConfig(min=5, max=999, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_DO_NOT_DISTURB): SelectSelector( + SelectSelectorConfig( + options=["on", "off"], + mode=SelectSelectorMode.DROPDOWN, + translation_key="lookup_selector", + ) + ), + vol.Optional(CONF_USE_ANNOUNCE): SelectSelector( + SelectSelectorConfig( + options=["on", "off"], + mode=SelectSelectorMode.DROPDOWN, + translation_key="lookup_selector", + ) + ), + vol.Optional(CONF_MIC_UNMUTE): SelectSelector( + SelectSelectorConfig( + options=["on", "off"], + mode=SelectSelectorMode.DROPDOWN, + translation_key="lookup_selector", + ) + ), + vol.Optional(CONF_DUCKING_VOLUME): NumberSelector( + NumberSelectorConfig( + min=0, + max=100, + step=1.0, + mode=NumberSelectorMode.BOX, + ) + ), + } +) + +INTEGRATION_OPTIONS_SCHEMA = vol.Schema( + {vol.Optional(CONF_ENABLE_UPDATES): BooleanSelector()} +) + + +def get_developer_options_schema( + hass: HomeAssistant, config_entry: VAConfigEntry | None +) -> vol.Schema: + """Return schema for dashboard options.""" + return vol.Schema( + { + vol.Optional(CONF_DEVELOPER_DEVICE): SelectSelector( SelectSelectorConfig( - options=options, + options=get_display_devices(hass, config_entry), mode=SelectSelectorMode.DROPDOWN, ) ), - vol.Required( - CONF_DEV_MIMIC, - default=config.data.get(CONF_DEV_MIMIC, False), - ): bool, + vol.Optional(CONF_DEVELOPER_MIMIC_DEVICE): EntitySelector( + EntitySelectorConfig(integration=DOMAIN, domain=Platform.SENSOR) + ), } ) +def get_suggested_option_values(config: VAConfigEntry) -> dict[str, Any]: + """Get suggested values for the config entry.""" + if config.data[CONF_TYPE] == VAType.MASTER_CONFIG: + option_values = DEFAULT_VALUES.copy() + for option in DEFAULT_VALUES: + if config.options.get(option) is not None: + option_values[option] = config.options.get(option) + return option_values + return config.options + + class ViewAssistConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for View Assist.""" VERSION = 1 - MINOR_VERSION = 3 + MINOR_VERSION = 5 @staticmethod @callback @@ -278,9 +424,18 @@ async def async_step_options(self, user_input=None): # Define the schema based on the selected type if self.type == VAType.VIEW_AUDIO: - data_schema = vol.Schema({**BASE_SCHEMA, **get_display_schema(self.hass)}) + data_schema = BASE_DEVICE_SCHEMA.extend( + { + vol.Required(CONF_DISPLAY_DEVICE): SelectSelector( + SelectSelectorConfig( + options=get_display_devices(self.hass), + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ) else: # audio_only - data_schema = vol.Schema(BASE_SCHEMA) + data_schema = BASE_DEVICE_SCHEMA # Show the form for the selected type return self.async_show_form(step_id="options", data_schema=data_schema) @@ -319,21 +474,18 @@ async def async_step_init(self, user_input=None): menu_options=["main_config", "dashboard_options", "default_options"], ) if self.va_type == VAType.MASTER_CONFIG: - return await self.async_step_master_config() + return self.async_show_menu( + step_id="init", + menu_options=[ + "integration_options", + "dashboard_options", + "default_options", + "developer_options", + ], + ) return await self.async_step_main_config() - async def async_step_master_config(self, user_input=None): - """Handle master config flow.""" - if user_input is not None: - # This is just updating the core config so update config_entry.data - options = self.config_entry.options | user_input - return self.async_create_entry(data=options) - - data_schema = vol.Schema({}) - # Show the form for the selected type - return self.async_show_form(step_id="master_config", data_schema=data_schema) - async def async_step_main_config(self, user_input=None): """Handle main config flow.""" @@ -344,263 +496,129 @@ async def async_step_main_config(self, user_input=None): self.config_entry, data=user_input ) return self.async_create_entry(data=None) - # Define the schema based on the selected type - BASE_OPTIONS = { - vol.Required(CONF_NAME, default=self.config_entry.data[CONF_NAME]): str, - vol.Required( - CONF_MIC_DEVICE, default=self.config_entry.data[CONF_MIC_DEVICE] - ): EntitySelector( - EntitySelectorConfig( - filter=[ - EntityFilterSelectorConfig( - integration="esphome", domain=ASSIST_SAT_DOMAIN - ), - EntityFilterSelectorConfig( - integration="hassmic", - domain=[SENSOR_DOMAIN, ASSIST_SAT_DOMAIN], - ), - EntityFilterSelectorConfig( - integration="stream_assist", - domain=[SENSOR_DOMAIN, ASSIST_SAT_DOMAIN], - ), - EntityFilterSelectorConfig( - integration="wyoming", domain=ASSIST_SAT_DOMAIN - ), - ] - ) - ), - vol.Required( - CONF_MEDIAPLAYER_DEVICE, - default=self.config_entry.data[CONF_MEDIAPLAYER_DEVICE], - ): EntitySelector(EntitySelectorConfig(domain=MEDIAPLAYER_DOMAIN)), - vol.Required( - CONF_MUSICPLAYER_DEVICE, - default=self.config_entry.data[CONF_MUSICPLAYER_DEVICE], - ): EntitySelector(EntitySelectorConfig(domain=MEDIAPLAYER_DOMAIN)), - vol.Optional( - CONF_INTENT_DEVICE, - description={ - "suggested_value": self.config_entry.data.get(CONF_INTENT_DEVICE) - }, - ): EntitySelector(EntitySelectorConfig(domain=SENSOR_DOMAIN)), - } if self.va_type == VAType.VIEW_AUDIO: - data_schema = vol.Schema( - {**BASE_OPTIONS, **get_display_schema(self.hass, self.config_entry)} + data_schema = BASE_DEVICE_SCHEMA.extend( + { + vol.Required(CONF_DISPLAY_DEVICE): SelectSelector( + SelectSelectorConfig( + options=get_display_devices(self.hass, self.config_entry), + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema, self.config_entry.data ) else: # audio_only - data_schema = vol.Schema(BASE_OPTIONS) + data_schema = self.add_suggested_values_to_schema( + BASE_DEVICE_SCHEMA, self.config_entry.data + ) # Show the form for the selected type - return self.async_show_form(step_id="main_config", data_schema=data_schema) + return self.async_show_form( + step_id="main_config", + data_schema=data_schema, + description_placeholders={"name": self.config_entry.title}, + ) async def async_step_dashboard_options(self, user_input=None): """Handle dashboard options flow.""" + data_schema = self.add_suggested_values_to_schema( + get_dashboard_options_schema(self.config_entry), + get_suggested_option_values(self.config_entry), + ) + if user_input is not None: # This is just updating the core config so update config_entry.data options = self.config_entry.options | user_input + for o in data_schema.schema: + if o not in user_input: + options.pop(o, None) return self.async_create_entry(data=options) - data_schema = vol.Schema( - { - vol.Optional( - CONF_DASHBOARD, - default=self.config_entry.options.get( - CONF_DASHBOARD, DEFAULT_DASHBOARD - ), - ): str, - vol.Optional( - CONF_HOME, - default=self.config_entry.options.get(CONF_HOME, DEFAULT_VIEW_HOME), - ): str, - vol.Optional( - CONF_MUSIC, - default=self.config_entry.options.get( - CONF_MUSIC, DEFAULT_VIEW_MUSIC - ), - ): str, - vol.Optional( - CONF_INTENT, - default=self.config_entry.options.get( - CONF_INTENT, DEFAULT_VIEW_INTENT - ), - ): str, - vol.Optional( - CONF_BACKGROUND, - default=self.config_entry.options.get( - CONF_BACKGROUND, DEFAULT_VIEW_BACKGROUND - ), - ): str, - vol.Optional( - CONF_ROTATE_BACKGROUND, - default=self.config_entry.options.get( - CONF_ROTATE_BACKGROUND, DEFAULT_ROTATE_BACKGROUND - ), - ): bool, - vol.Optional( - CONF_ROTATE_BACKGROUND_SOURCE, - default=self.config_entry.options.get( - CONF_ROTATE_BACKGROUND_SOURCE, - DEFAULT_ROTATE_BACKGROUND_SOURCE, - ), - ): SelectSelector( - SelectSelectorConfig( - translation_key="rotate_backgound_source_selector", - options=[ - "local_sequence", - "local_random", - "download", - "link_to_entity", - ], - mode=SelectSelectorMode.LIST, - ) - ), - vol.Optional( - CONF_ROTATE_BACKGROUND_PATH, - default=self.config_entry.options.get( - CONF_ROTATE_BACKGROUND_PATH, - DEFAULT_ROTATE_BACKGROUND_PATH, - ), - ): str, - vol.Optional( - CONF_ROTATE_BACKGROUND_LINKED_ENTITY, - default=self.config_entry.options.get( - CONF_ROTATE_BACKGROUND_LINKED_ENTITY, vol.UNDEFINED - ), - ): EntitySelector( - EntitySelectorConfig( - integration=DOMAIN, - domain=SENSOR_DOMAIN, - exclude_entities=[ - get_sensor_entity_from_instance( - self.hass, self.config_entry.entry_id - ) - ], - ) - ), - vol.Optional( - CONF_ROTATE_BACKGROUND_INTERVAL, - default=self.config_entry.options.get( - CONF_ROTATE_BACKGROUND_INTERVAL, - DEFAULT_ROTATE_BACKGROUND_INTERVAL, - ), - ): int, - vol.Optional( - CONF_ASSIST_PROMPT, - default=self.config_entry.options.get( - CONF_ASSIST_PROMPT, DEFAULT_ASSIST_PROMPT - ), - ): SelectSelector( - SelectSelectorConfig( - translation_key="assist_prompt_selector", - options=[e.value for e in VAAssistPrompt], - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Optional( - CONF_STATUS_ICON_SIZE, - default=self.config_entry.options.get( - CONF_STATUS_ICON_SIZE, DEFAULT_STATUS_ICON_SIZE - ), - ): SelectSelector( - SelectSelectorConfig( - translation_key="status_icons_size_selector", - options=[e.value for e in VAIconSizes], - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Optional( - CONF_FONT_STYLE, - default=self.config_entry.options.get( - CONF_FONT_STYLE, DEFAULT_FONT_STYLE - ), - ): str, - vol.Optional( - CONF_STATUS_ICONS, - default=self.config_entry.options.get( - CONF_STATUS_ICONS, DEFAULT_STATUS_ICONS - ), - ): SelectSelector( - SelectSelectorConfig( - translation_key="status_icons_selector", - options=[], - mode=SelectSelectorMode.LIST, - multiple=True, - custom_value=True, - ) - ), - vol.Optional( - CONF_USE_24H_TIME, - default=self.config_entry.options.get( - CONF_USE_24H_TIME, DEFAULT_USE_24H_TIME - ), - ): bool, - vol.Optional( - CONF_HIDE_SIDEBAR, - default=self.config_entry.options.get( - CONF_HIDE_SIDEBAR, DEFAULT_HIDE_SIDEBAR - ), - ): bool, - vol.Optional( - CONF_HIDE_HEADER, - default=self.config_entry.options.get( - CONF_HIDE_HEADER, DEFAULT_HIDE_HEADER - ), - ): bool, - } - ) - - # Show the form for the selected type + # Show the form return self.async_show_form( - step_id="dashboard_options", data_schema=data_schema + step_id="dashboard_options", + data_schema=data_schema, + description_placeholders={ + "name": self.config_entry.title, + "description": MASTER_FORM_DESCRIPTION + if self.config_entry.data[CONF_TYPE] == VAType.MASTER_CONFIG + else DEVICE_FORM_DESCRIPTION, + }, ) async def async_step_default_options(self, user_input=None): """Handle default options flow.""" + + data_schema = self.add_suggested_values_to_schema( + DEFAULT_OPTIONS_SCHEMA, get_suggested_option_values(self.config_entry) + ) + if user_input is not None: # This is just updating the core config so update config_entry.data options = self.config_entry.options | user_input + for o in data_schema.schema: + if o not in user_input: + options.pop(o, None) return self.async_create_entry(data=options) - data_schema = vol.Schema( - { - vol.Optional( - CONF_WEATHER_ENTITY, - default=self.config_entry.options.get( - CONF_WEATHER_ENTITY, DEFAULT_WEATHER_ENITITY - ), - ): EntitySelector(EntitySelectorConfig(domain=WEATHER_DOMAIN)), - vol.Optional( - CONF_MODE, - default=self.config_entry.options.get(CONF_MODE, DEFAULT_MODE), - ): str, - vol.Optional( - CONF_VIEW_TIMEOUT, - default=self.config_entry.options.get( - CONF_VIEW_TIMEOUT, DEFAULT_VIEW_TIMEOUT - ), - ): int, - vol.Optional( - CONF_DO_NOT_DISTURB, - default=self.config_entry.options.get( - CONF_DO_NOT_DISTURB, DEFAULT_DND - ), - ): bool, - vol.Optional( - CONF_USE_ANNOUNCE, - default=self.config_entry.options.get( - CONF_USE_ANNOUNCE, DEFAULT_USE_ANNOUNCE - ), - ): bool, - vol.Optional( - CONF_MIC_UNMUTE, - default=self.config_entry.options.get( - CONF_MIC_UNMUTE, DEFAULT_MIC_UNMUTE - ), - ): bool, - } + # Show the form + return self.async_show_form( + step_id="default_options", + data_schema=data_schema, + description_placeholders={ + "name": self.config_entry.title, + "description": MASTER_FORM_DESCRIPTION + if self.config_entry.data[CONF_TYPE] == VAType.MASTER_CONFIG + else DEVICE_FORM_DESCRIPTION, + }, ) - # Show the form for the selected type - return self.async_show_form(step_id="default_options", data_schema=data_schema) + async def async_step_integration_options(self, user_input=None): + """Handle integration options flow.""" + + data_schema = self.add_suggested_values_to_schema( + INTEGRATION_OPTIONS_SCHEMA, + get_suggested_option_values(self.config_entry), + ) + + if user_input is not None: + # This is just updating the core config so update config_entry.data + options = self.config_entry.options | user_input + for o in data_schema.schema: + if o not in user_input: + options.pop(o, None) + return self.async_create_entry(data=options) + + # Show the form + return self.async_show_form( + step_id="integration_options", + data_schema=data_schema, + description_placeholders={"name": self.config_entry.title}, + ) + + async def async_step_developer_options(self, user_input=None): + """Handle default options flow.""" + + data_schema = self.add_suggested_values_to_schema( + get_developer_options_schema(self.hass, self.config_entry), + get_suggested_option_values(self.config_entry), + ) + + if user_input is not None: + # This is just updating the core config so update config_entry.data + options = self.config_entry.options | user_input + for o in data_schema.schema: + if o not in user_input: + options.pop(o, None) + return self.async_create_entry(data=options) + + # Show the form + return self.async_show_form( + step_id="developer_options", + data_schema=data_schema, + description_placeholders={"name": self.config_entry.title}, + ) diff --git a/custom_components/view_assist/const.py b/custom_components/view_assist/const.py index 7b3ab94..1beebd5 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -1,42 +1,36 @@ """Integration classes and constants.""" -from dataclasses import dataclass from enum import StrEnum -from typing import Any -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MODE + +from .typed import ( + VAAssistPrompt, + VABackgroundMode, + VAIconSizes, + VAMenuConfig, + VAScreenMode, + VATimeFormat, +) DOMAIN = "view_assist" GITHUB_REPO = "dinki/View-Assist" GITHUB_BRANCH = "main" GITHUB_TOKEN_FILE = "github.token" -GITHUB_PATH = "View Assist dashboard and views" +DASHBOARD_VIEWS_GITHUB_PATH = "View Assist dashboard and views" +BLUEPRINT_GITHUB_PATH = "View_Assist_custom_sentences" VIEWS_DIR = "views" COMMUNITY_VIEWS_DIR = "community_contributions" DASHBOARD_DIR = "dashboard" - DASHBOARD_NAME = "View Assist" + DEFAULT_VIEW = "clock" -DEFAULT_VIEWS = [ - "alarm", - "camera", - "clock", - "info", - "infopic", - "intent", - "list", - "locate", - "music", - "sports", - "thermostat", - "weather", - "webpage", -] CYCLE_VIEWS = ["music", "info", "weather", "clock"] BROWSERMOD_DOMAIN = "browser_mod" REMOTE_ASSIST_DISPLAY_DOMAIN = "remote_assist_display" CUSTOM_CONVERSATION_DOMAIN = "custom_conversation" +HASSMIC_DOMAIN = "hassmic" USE_VA_NAVIGATION_FOR_BROWSERMOD = True IMAGE_PATH = "images" @@ -51,9 +45,9 @@ "version": "1.0.10", }, ] - - -type VAConfigEntry = ConfigEntry[RuntimeData] +VERSION_CHECK_INTERVAL = ( + 120 # mins between checks for updated versions of dashboard and views +) class VAMode(StrEnum): @@ -76,102 +70,109 @@ class VAMode(StrEnum): } -class VAType(StrEnum): - """Sensor type enum.""" - - MASTER_CONFIG = "master_config" - VIEW_AUDIO = "view_audio" - AUDIO_ONLY = "audio_only" - - -class VAAssistPrompt(StrEnum): - """Assist prompt types enum.""" - - BLUR_POPUP = "blur_pop_up" - FLASHING_BAR = "flashing_bar" - - -class VAIconSizes(StrEnum): - """Icon size options enum.""" - - SMALL = "6vw" - MEDIUM = "7vw" - LARGE = "8vw" - - -class VADisplayType(StrEnum): - """Display types.""" - - BROWSERMOD = "browser_mod" - REMOTE_ASSIST_DISPLAY = "remote_assist_display" - - # Config keys CONF_MIC_DEVICE = "mic_device" CONF_MEDIAPLAYER_DEVICE = "mediaplayer_device" CONF_MUSICPLAYER_DEVICE = "musicplayer_device" CONF_DISPLAY_DEVICE = "display_device" CONF_INTENT_DEVICE = "intent_device" + CONF_DASHBOARD = "dashboard" CONF_HOME = "home" CONF_INTENT = "intent" +CONF_LIST = "list_view" CONF_MUSIC = "music" +CONF_BACKGROUND_SETTINGS = "background_settings" +CONF_BACKGROUND_MODE = "background_mode" CONF_BACKGROUND = "background" +CONF_ROTATE_BACKGROUND_PATH = "rotate_background_path" +CONF_ROTATE_BACKGROUND_LINKED_ENTITY = "rotate_background_linked_entity" +CONF_ROTATE_BACKGROUND_INTERVAL = "rotate_background_interval" + +CONF_DISPLAY_SETTINGS = "display_settings" CONF_ASSIST_PROMPT = "assist_prompt" CONF_STATUS_ICON_SIZE = "status_icons_size" CONF_FONT_STYLE = "font_style" CONF_STATUS_ICONS = "status_icons" -CONF_USE_24H_TIME = "use_24_hour_time" +CONF_MENU_CONFIG = "menu_config" +CONF_MENU_ITEMS = "menu_items" +CONF_MENU_TIMEOUT = "menu_timeout" +CONF_TIME_FORMAT = "time_format" +CONF_SCREEN_MODE = "screen_mode" + CONF_WEATHER_ENTITY = "weather_entity" CONF_VIEW_TIMEOUT = "view_timeout" CONF_DO_NOT_DISTURB = "do_not_disturb" CONF_USE_ANNOUNCE = "use_announce" CONF_MIC_UNMUTE = "micunmute" +CONF_DUCKING_VOLUME = "ducking_volume" + +CONF_ENABLE_UPDATES = "enable_updates" +CONF_DEVELOPER_DEVICE = "developer_device" +CONF_DEVELOPER_MIMIC_DEVICE = "developer_mimic_device" + + +# Legacy +CONF_MIC_TYPE = "mic_type" +CONF_USE_24H_TIME = "use_24_hour_time" CONF_DEV_MIMIC = "dev_mimic" CONF_HIDE_HEADER = "hide_header" CONF_HIDE_SIDEBAR = "hide_sidebar" CONF_ROTATE_BACKGROUND = "rotate_background" CONF_ROTATE_BACKGROUND_SOURCE = "rotate_background_source" -CONF_ROTATE_BACKGROUND_PATH = "rotate_background_path" -CONF_ROTATE_BACKGROUND_LINKED_ENTITY = "rotate_background_linked_entity" -CONF_ROTATE_BACKGROUND_INTERVAL = "rotate_background_interval" -CONF_MIC_TYPE = "mic_type" + + +DEFAULT_VALUES = { + # Dashboard options + CONF_DASHBOARD: "/view-assist", + CONF_HOME: "/view-assist/clock", + CONF_MUSIC: "/view-assist/music", + CONF_INTENT: "/view-assist/intent", + CONF_LIST: "/view-assist/list", + CONF_BACKGROUND_SETTINGS: { + CONF_BACKGROUND_MODE: VABackgroundMode.DEFAULT_BACKGROUND, + CONF_BACKGROUND: "/view_assist/dashboard/background.jpg", + CONF_ROTATE_BACKGROUND_PATH: f"{IMAGE_PATH}/backgrounds", + CONF_ROTATE_BACKGROUND_LINKED_ENTITY: "", + CONF_ROTATE_BACKGROUND_INTERVAL: 60, + }, + CONF_DISPLAY_SETTINGS: { + CONF_ASSIST_PROMPT: VAAssistPrompt.BLUR_POPUP, + CONF_STATUS_ICON_SIZE: VAIconSizes.LARGE, + CONF_FONT_STYLE: "Roboto", + CONF_STATUS_ICONS: [], + CONF_MENU_CONFIG: VAMenuConfig.DISABLED, + CONF_MENU_ITEMS: ["home", "weather"], + CONF_MENU_TIMEOUT: 10, + CONF_TIME_FORMAT: VATimeFormat.HOUR_12, + CONF_SCREEN_MODE: VAScreenMode.HIDE_HEADER_SIDEBAR, + }, + # Default options + CONF_WEATHER_ENTITY: "weather.home", + CONF_MODE: VAMode.NORMAL, + CONF_VIEW_TIMEOUT: 20, + CONF_DO_NOT_DISTURB: "off", + CONF_USE_ANNOUNCE: "off", + CONF_MIC_UNMUTE: "off", + CONF_DUCKING_VOLUME: 35, + # Default integration options + CONF_ENABLE_UPDATES: True, + # Default developer otions + CONF_DEVELOPER_DEVICE: "", + CONF_DEVELOPER_MIMIC_DEVICE: "", +} # Config default values DEFAULT_NAME = "View Assist" -DEFAULT_TYPE = VAType.VIEW_AUDIO -DEFAULT_DASHBOARD = "/view-assist" -DEFAULT_VIEW_HOME = "/view-assist/clock" -DEFAULT_VIEW_MUSIC = "/view-assist/music" -DEFAULT_VIEW_INTENT = "/view-assist/intent" +DEFAULT_TYPE = "view_audio" DEFAULT_VIEW_INFO = "info" -DEFAULT_VIEW_BACKGROUND = "/view_assist/dashboard/background.jpg" -DEFAULT_ASSIST_PROMPT = VAAssistPrompt.BLUR_POPUP -DEFAULT_STATUS_ICON_SIZE = VAIconSizes.LARGE -DEFAULT_FONT_STYLE = "Roboto" -DEFAULT_STATUS_ICONS = [] -DEFAULT_USE_24H_TIME = False -DEFAULT_WEATHER_ENITITY = "weather.home" -DEFAULT_MODE = "normal" -DEFAULT_VIEW_TIMEOUT = 20 -DEFAULT_DND = False -DEFAULT_USE_ANNOUNCE = True -DEFAULT_MIC_UNMUTE = False -DEFAULT_HIDE_SIDEBAR = True -DEFAULT_HIDE_HEADER = True -DEFAULT_ROTATE_BACKGROUND = False -DEFAULT_ROTATE_BACKGROUND_SOURCE = "local_sequence" -DEFAULT_ROTATE_BACKGROUND_PATH = f"{IMAGE_PATH}/backgrounds" -DEFAULT_ROTATE_BACKGROUND_INTERVAL = 60 + # Service attributes ATTR_EVENT_NAME = "event_name" ATTR_EVENT_DATA = "event_data" ATTR_PATH = "path" ATTR_DEVICE = "device" -ATTR_REDOWNLOAD_FROM_REPO = "download_from_repo" -ATTR_COMMUNITY_VIEW = "community_view" -ATTR_BACKUP_CURRENT_VIEW = "backup_current_view" ATTR_EXTRA = "extra" ATTR_TYPE = "type" ATTR_TIMER_ID = "timer_id" @@ -180,67 +181,17 @@ class VADisplayType(StrEnum): ATTR_MEDIA_FILE = "media_file" ATTR_RESUME_MEDIA = "resume_media" ATTR_MAX_REPEATS = "max_repeats" +ATTR_ASSET_CLASS = "asset_class" +ATTR_BACKUP_CURRENT_ASSET = "backup_current_asset" +ATTR_DOWNLOAD_FROM_REPO = "download_from_repo" VA_ATTRIBUTE_UPDATE_EVENT = "va_attr_update_event_{}" VA_BACKGROUND_UPDATE_EVENT = "va_background_update_{}" +VA_ASSET_UPDATE_PROGRESS = "va_asset_update_progress" +VA_ADD_UPDATE_ENTITY_EVENT = "va_add_update_entity_event" CC_CONVERSATION_ENDED_EVENT = f"{CUSTOM_CONVERSATION_DOMAIN}_conversation_ended" -class RuntimeData: - """Class to hold your data.""" - - def __init__(self) -> None: - """Initialise runtime data.""" - - # Default config - self.type: VAType | None = None - self.name: str = "" - self.mic_device: str = "" - self.mediaplayer_device: str = "" - self.musicplayer_device: str = "" - self.display_device: str = "" - self.intent_device: str = "" - self.dev_mimic: bool = False - - # Dashboard options - self.dashboard: str = DEFAULT_DASHBOARD - self.home: str = DEFAULT_VIEW_HOME - self.music: str = DEFAULT_VIEW_MUSIC - self.intent: str = DEFAULT_VIEW_INTENT - self.background: str = DEFAULT_VIEW_BACKGROUND - self.rotate_background: bool = False - self.rotate_background_source: str = "local" - self.rotate_background_path: str = "" - self.rotate_background_linked_entity: str = "" - self.rotate_background_interval: int = 60 - self.assist_prompt: VAAssistPrompt = DEFAULT_ASSIST_PROMPT - self.status_icons_size: VAIconSizes = DEFAULT_STATUS_ICON_SIZE - self.font_style: str = DEFAULT_FONT_STYLE - self.status_icons: list[str] = DEFAULT_STATUS_ICONS - self.use_24_hour_time: bool = DEFAULT_USE_24H_TIME - - # Default options - self.weather_entity: str = DEFAULT_WEATHER_ENITITY - self.mode: str = DEFAULT_MODE - self.view_timeout: int = DEFAULT_VIEW_TIMEOUT - self.hide_sidebar: bool = DEFAULT_HIDE_SIDEBAR - self.hide_header: bool = DEFAULT_HIDE_HEADER - self.do_not_disturb: bool = DEFAULT_DND - self.use_announce: bool = DEFAULT_USE_ANNOUNCE - self.mic_unmute: bool = DEFAULT_MIC_UNMUTE - - # Extra data for holding key/value pairs passed in by set_state service call - self.extra_data: dict[str, Any] = {} - - -@dataclass -class VAEvent: - """View Assist event.""" - - event_name: str - payload: dict | None = None - - # TODO: Remove this when BP/Views updated OPTION_KEY_MIGRATIONS = { "blur pop up": "blur_pop_up", diff --git a/custom_components/view_assist/dashboard.py b/custom_components/view_assist/dashboard.py deleted file mode 100644 index 9f3a90f..0000000 --- a/custom_components/view_assist/dashboard.py +++ /dev/null @@ -1,661 +0,0 @@ -"""Manage views - download, apply, backup, restore.""" - -from dataclasses import dataclass -import logging -import operator -from os import PathLike -from pathlib import Path -from typing import Any -import urllib.parse - -from aiohttp import ContentTypeError - -from homeassistant.components.lovelace import ( - CONF_ICON, - CONF_REQUIRE_ADMIN, - CONF_SHOW_IN_SIDEBAR, - CONF_TITLE, - CONF_URL_PATH, - LovelaceData, - dashboard, -) -from homeassistant.const import ( - CONF_ID, - CONF_MODE, - CONF_TYPE, - EVENT_LOVELACE_UPDATED, - EVENT_PANELS_UPDATED, -) -from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.util.yaml import load_yaml_dict, save_yaml - -from .const import ( - COMMUNITY_VIEWS_DIR, - DASHBOARD_DIR, - DASHBOARD_NAME, - DEFAULT_VIEW, - DEFAULT_VIEWS, - DOMAIN, - GITHUB_BRANCH, - GITHUB_PATH, - GITHUB_REPO, - GITHUB_TOKEN_FILE, - VIEWS_DIR, - VAConfigEntry, - VAEvent, -) -from .helpers import differ_to_json, json_to_dictdiffer -from .utils import dictdiff -from .websocket import MockWSConnection - -_LOGGER = logging.getLogger(__name__) - -GITHUB_REPO_API = "https://api.github.com/repos" -MAX_DIR_DEPTH = 5 -DASHBOARD_FILE = "dashboard.yaml" -DASHBOARD_MANAGER = "dashboard_manager" - - -@dataclass -class GithubFileDir: - """A github dir or file object.""" - - name: str - type: str - url: str - download_url: str | None = None - - -class GithubAPIException(Exception): - """A github api exception.""" - - -class DownloadManagerException(Exception): - """A download manager exception.""" - - -class DashboardManagerException(Exception): - """A dashboard manager exception.""" - - -class GitHubAPI: - """Class to handle basic Github repo rest commands.""" - - def __init__(self, hass: HomeAssistant, repo: str) -> None: - """Initialise.""" - self.hass = hass - self.repo = repo - self.branch: str = GITHUB_BRANCH - self.api_base = f"{GITHUB_REPO_API}/{self.repo}/contents/" - - def _get_token(self): - token_file = self.hass.config.path(f"{DOMAIN}/{GITHUB_TOKEN_FILE}") - if Path(token_file).exists(): - with Path(token_file).open("r", encoding="utf-8") as f: - return f.read() - return None - - async def _rest_request(self, url: str) -> str | dict | list | None: - """Return rest request data.""" - session = async_get_clientsession(self.hass) - - kwargs = {} - if self.api_base in url: - if token := await self.hass.async_add_executor_job(self._get_token): - kwargs["headers"] = {"authorization": f"Bearer {token}"} - _LOGGER.debug("Making api request with auth token - %s", url) - else: - _LOGGER.debug("Making api request without auth token - %s", url) - - async with session.get(url, **kwargs) as resp: - if resp.status == 200: - try: - return await resp.json() - except ContentTypeError: - return await resp.read() - elif resp.status == 403: - # Rate limit - raise GithubAPIException( - "Github api rate limit exceeded for this hour. You may need to add a personal access token to authenticate and increase the limit" - ) - elif resp.status == 404: - raise GithubAPIException(f"Path not found on this repository. {url}") - else: - raise GithubAPIException(await resp.json()) - return None - - async def get_dir_listing(self, dir_url: str) -> list[GithubFileDir]: - """Get github repo dir listing.""" - # if dir passed is full url - - base_url = f"{GITHUB_REPO_API}/{self.repo}/contents/" - if not dir_url.startswith(base_url): - dir_url = urllib.parse.quote(dir_url) - url_path = ( - f"{GITHUB_REPO_API}/{self.repo}/contents/{dir_url}?ref={self.branch}" - ) - else: - url_path = dir_url - - try: - if raw_data := await self._rest_request(url_path): - return [ - GithubFileDir(e["name"], e["type"], e["url"], e["download_url"]) - for e in raw_data - ] - except GithubAPIException as ex: - _LOGGER.error(ex) - return None - - async def download_file(self, download_url: str) -> bytes | None: - """Download file.""" - if file_data := await self._rest_request(download_url): - return file_data - _LOGGER.debug("Failed to download file") - return None - - -class DownloadManager: - """Class to handle file downloads from github repo.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialise.""" - self.hass = hass - self.github = GitHubAPI(hass, GITHUB_REPO) - - def _save_binary_to_file(self, data: bytes, file_path: str, file_name: str): - """Save binary data to file.""" - Path(file_path).mkdir(parents=True, exist_ok=True) - Path.write_bytes(Path(file_path, file_name), data) - - async def _download_dir(self, dir_url: str, dir_path: str, depth: int = 1) -> bool: - """Download all files in a directory.""" - try: - if dir_listing := await self.github.get_dir_listing(dir_url): - _LOGGER.debug("Downloading %s", dir_url) - # Recurse directories - for entry in dir_listing: - if entry.type == "dir" and depth <= MAX_DIR_DEPTH: - await self._download_dir( - f"{dir_url}/{entry.name}", - f"{dir_path}/{entry.name}", - depth=depth + 1, - ) - elif entry.type == "file": - _LOGGER.debug("Downloading file %s", f"{dir_url}/{entry.name}") - if file_data := await self.github.download_file( - entry.download_url - ): - await self.hass.async_add_executor_job( - self._save_binary_to_file, - file_data, - dir_path, - entry.name, - ) - else: - raise DownloadManagerException( - f"Error downloading {entry.name} from the github repository." - ) - return True - except GithubAPIException as ex: - _LOGGER.error(ex) - else: - return False - - async def download_dashboard(self): - """Download dashboard file.""" - # Ensure download to path exists - base = self.hass.config.path(f"{DOMAIN}/{DASHBOARD_DIR}") - - # Validate view dir on repo - dir_url = f"{GITHUB_PATH}/{DASHBOARD_DIR}" - if await self.github.get_dir_listing(dir_url): - # Download view files - await self._download_dir(dir_url, base) - - async def download_view( - self, - view_name: str, - community_view: bool = False, - ): - """Download files from a github repo directory.""" - - # Ensure download to path exists - base = self.hass.config.path(f"{DOMAIN}/{VIEWS_DIR}") - if community_view: - dir_url = f"{GITHUB_PATH}/{VIEWS_DIR}/{COMMUNITY_VIEWS_DIR}/{view_name}" - msg_text = "community view" - else: - dir_url = f"{GITHUB_PATH}/{VIEWS_DIR}/{view_name}" - msg_text = "view" - - _LOGGER.debug("Downloading %s - %s", msg_text, view_name) - - # Validate view dir on repo - if await self.github.get_dir_listing(dir_url): - # Create view directory - Path(base, view_name).mkdir(parents=True, exist_ok=True) - - # Download view files - return await self._download_dir(dir_url, Path(base, view_name)) - return False - - -class DashboardManager: - """Class to manage VA dashboard and views.""" - - def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: - """Initialise.""" - self.hass = hass - self.config = config - self.download_manager = DownloadManager(hass) - self.build_mode: bool = False - - # Experimental - listen for dashboard change and write out changes - config.async_on_unload( - hass.bus.async_listen(EVENT_LOVELACE_UPDATED, self._dashboard_changed) - ) - - async def _save_to_yaml_file( - self, - file_path: str | PathLike, - data: dict[str, Any], - ) -> bool: - """Save dict to yaml file.""" - - await self.hass.async_add_executor_job( - save_yaml, - file_path, - data, - ) - return True - - @property - def dashboard_key(self) -> str: - """Return path for dashboard name.""" - return DASHBOARD_NAME.replace(" ", "-").lower() - - @property - def dashboard_exists(self) -> bool: - """Return if dashboard exists.""" - lovelace: LovelaceData = self.hass.data["lovelace"] - return self.dashboard_key in lovelace.dashboards - - async def setup_dashboard(self): - """Config VA dashboard.""" - if not self.dashboard_exists: - _LOGGER.debug("Initialising View Assist dashboard") - self.build_mode = True - - # Download dashboard - base = self.hass.config.path(DOMAIN) - dashboard_file_path = f"{base}/{DASHBOARD_DIR}/{DASHBOARD_DIR}.yaml" - view_base = f"{base}/{VIEWS_DIR}" - - if not Path(dashboard_file_path).exists(): - await self.download_manager.download_dashboard() - - # Download initial views - _LOGGER.debug("Downloading default views") - for view in DEFAULT_VIEWS: - if not Path(view_base, view).exists(): - await self.download_manager.download_view(view) - - # Load dashboard - _LOGGER.debug("Adding dashboard") - await self.add_dashboard(DASHBOARD_NAME, dashboard_file_path) - await self._apply_user_dashboard_changes() - - # Load views that have successfully downloaded plus others already in directory - _LOGGER.debug("Adding views") - for view in await self.hass.async_add_executor_job(Path(view_base).iterdir): - try: - await self.add_update_view( - view.name, - download_from_repo=False, - ) - except DashboardManagerException as ex: - _LOGGER.warning(ex) - - # Remove home view - await self.delete_view("home") - - # Finish - self.build_mode = False - - # Fire refresh event - async_dispatcher_send(self.hass, f"{DOMAIN}_event", VAEvent("reload")) - - else: - _LOGGER.debug( - "View Assist dashboard already exists, skipping initialisation" - ) - - async def _dashboard_changed(self, event: Event): - # If in dashboard build mode, ignore changes - if self.build_mode: - return - - if event.data["url_path"] == self.dashboard_key: - try: - lovelace: LovelaceData = self.hass.data["lovelace"] - dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( - self.dashboard_key - ) - # Load dashboard config data - if dashboard_store: - dashboard_config = await dashboard_store.async_load(False) - - # Remove views from dashboard config for saving - dashboard_only = dashboard_config.copy() - dashboard_only["views"] = [{"title": "Home"}] - - file_path = Path(self.hass.config.config_dir, DOMAIN, DASHBOARD_DIR) - file_path.mkdir(parents=True, exist_ok=True) - - if diffs := await self._compare_dashboard_to_master(dashboard_only): - await self._save_to_yaml_file( - Path(file_path, "user_dashboard.yaml"), - diffs, - ) - except Exception as ex: # noqa: BLE001 - _LOGGER.error("Error saving dashboard. Error is %s", ex) - - async def add_dashboard(self, dashboard_name: str, dashboard_path: str): - """Create dashboard.""" - - if not self.dashboard_exists: - mock_connection = MockWSConnection(self.hass) - if mock_connection.execute_ws_func( - "lovelace/dashboards/create", - { - CONF_ID: 1, - CONF_TYPE: "lovelace/dashboards/create", - CONF_ICON: "mdi:glasses", - CONF_TITLE: dashboard_name, - CONF_URL_PATH: self.dashboard_key, - CONF_MODE: "storage", - CONF_SHOW_IN_SIDEBAR: True, - CONF_REQUIRE_ADMIN: False, - }, - ): - # Get lovelace (frontend) config data - lovelace: LovelaceData = self.hass.data["lovelace"] - - # Load dashboard config file from path - if dashboard_config := await self.hass.async_add_executor_job( - load_yaml_dict, dashboard_path - ): - await lovelace.dashboards[self.dashboard_key].async_save( - dashboard_config - ) - - async def update_dashboard( - self, - download_from_repo: bool = False, - ): - """Download latest dashboard from github repository and apply.""" - - # download dashboard - no backup - if download_from_repo: - await self.download_manager.download_dashboard() - - # Apply new dashboard to HA - base = self.hass.config.path(DOMAIN) - dashboard_file_path = f"{base}/{DASHBOARD_DIR}/{DASHBOARD_DIR}.yaml" - - if not Path(dashboard_file_path).exists(): - # No master dashboard - return - - # Load dashboard config file from path - if updated_dashboard_config := await self.hass.async_add_executor_job( - load_yaml_dict, dashboard_file_path - ): - lovelace: LovelaceData = self.hass.data["lovelace"] - dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( - self.dashboard_key - ) - # Load dashboard config data - if dashboard_store: - dashboard_config = await dashboard_store.async_load(False) - - # Copy views to updated dashboard - updated_dashboard_config["views"] = dashboard_config.get("views") - - # Apply - self.build_mode = True - await dashboard_store.async_save(updated_dashboard_config) - await self._apply_user_dashboard_changes() - self.build_mode = False - - async def _compare_dashboard_to_master( - self, comp_dash: dict[str, Any] - ) -> dict[str, Any]: - """Compare dashboard dict to master and return differences.""" - # Get master dashboard - base = self.hass.config.path(DOMAIN) - dashboard_file_path = f"{base}/{DASHBOARD_DIR}/{DASHBOARD_DIR}.yaml" - - if not Path(dashboard_file_path).exists(): - # No master dashboard - return None - - # Load dashboard config file from path - if master_dashboard := await self.hass.async_add_executor_job( - load_yaml_dict, dashboard_file_path - ): - if not operator.eq(master_dashboard, comp_dash): - diffs = dictdiff.diff(master_dashboard, comp_dash, expand=True) - return differ_to_json(diffs) - return None - - async def _apply_user_dashboard_changes(self): - """Apply a user_dashboard changes file to master dashboard.""" - - # Get master dashboard - base = self.hass.config.path(DOMAIN) - user_dashboard_file_path = f"{base}/{DASHBOARD_DIR}/user_dashboard.yaml" - - if not Path(user_dashboard_file_path).exists(): - # No master dashboard - return - - # Load dashboard config file from path - _LOGGER.debug("Applying user changes to dashboard") - if user_dashboard := await self.hass.async_add_executor_job( - load_yaml_dict, user_dashboard_file_path - ): - lovelace: LovelaceData = self.hass.data["lovelace"] - dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( - self.dashboard_key - ) - # Load dashboard config data - if dashboard_store: - dashboard_config = await dashboard_store.async_load(False) - - # Apply - user_changes = json_to_dictdiffer(user_dashboard) - updated_dashboard = dictdiff.patch(user_changes, dashboard_config) - await dashboard_store.async_save(updated_dashboard) - - async def view_exists(self, view: str) -> int: - """Return index of view if view exists.""" - lovelace: LovelaceData = self.hass.data["lovelace"] - dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( - self.dashboard_key - ) - # Load dashboard config data - if dashboard_store: - dashboard_config = await dashboard_store.async_load(False) - if not dashboard_config["views"]: - return 0 - - for index, ex_view in enumerate(dashboard_config["views"]): - if ex_view.get("path") == view: - return index + 1 - return 0 - - async def add_update_view( - self, - name: str, - download_from_repo: bool = False, - community_view: bool = False, - backup_current_view: bool = False, - ) -> bool: - """Load a view file into the dashboard from the view_assist view folder.""" - - # Block trying to download the community contributions folder - if name == COMMUNITY_VIEWS_DIR: - raise DashboardManagerException( - f"{name} is not not a valid view name. Please select a view from within that folder" # noqa: S608 - ) - - # Return 1 based view index. If 0, view doesn't exist - view_index = await self.view_exists(name) - - if download_from_repo: - result = await self.download_manager.download_view( - name, - community_view=community_view, - ) - if not result: - raise DashboardManagerException(f"Failed to download {name} view") - - # Install view from file. - try: - file_path = Path(self.hass.config.path(DOMAIN), VIEWS_DIR, name) - - # Load in order of existence - user view version (for later feature), default version, saved version - file: Path - file_options = [f"user_{name}.yaml", f"{name}.yaml", f"{name}.saved.yaml"] - - if download_from_repo: - file = Path(file_path, f"{name}.yaml") - - for file_option in file_options: - if Path(file_path, file_option).exists(): - file = Path(file_path, file_option) - break - - if file: - _LOGGER.debug("Loading view %s from %s", name, file) - new_view_config = await self.hass.async_add_executor_job( - load_yaml_dict, file - ) - else: - raise DashboardManagerException( - f"Unable to load view {name}. Unable to find a yaml file" - ) - except OSError as ex: - raise DashboardManagerException( - f"Unable to load view {name}. Error is {ex}" - ) from ex - - # Get lovelace (frontend) config data - lovelace: LovelaceData = self.hass.data["lovelace"] - # Get access to dashboard store - dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( - self.dashboard_key - ) - - # Load dashboard config data - if new_view_config and dashboard_store: - dashboard_config = await dashboard_store.async_load(False) - - # Save existing view to file - if backup_current_view: - await self.save_view(name) - - _LOGGER.debug("Adding view %s to dashboard", name) - # Create new view and add it to dashboard - new_view = { - "type": "panel", - "title": name.title(), - "path": name, - "cards": [new_view_config], - } - - if not dashboard_config["views"]: - dashboard_config["views"] = [new_view] - elif view_index: - dashboard_config["views"][view_index - 1] = new_view - elif name == DEFAULT_VIEW: - # Insert default view as first view in list - dashboard_config["views"].insert(0, new_view) - else: - dashboard_config["views"].append(new_view) - modified = True - - # Save dashboard config back to HA - if modified: - self.build_mode = True - await dashboard_store.async_save(dashboard_config) - self.hass.bus.async_fire(EVENT_PANELS_UPDATED) - self.build_mode = False - return True - return False - - async def save_view(self, view_name: str) -> bool: - """Backup a view to a file.""" - - # Get lovelace (frontend) config data - lovelace: LovelaceData = self.hass.data["lovelace"] - - # Get access to dashboard store - dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( - self.dashboard_key - ) - - # Load dashboard config data - if dashboard_store: - dashboard_config = await dashboard_store.async_load(False) - - # Make list of existing view names for this dashboard - for view in dashboard_config["views"]: - if view.get("path") == view_name.lower(): - file_path = Path( - self.hass.config.path(DOMAIN), VIEWS_DIR, view_name.lower() - ) - file_name = f"{view_name.lower()}.saved.yaml" - - if view.get("cards", []): - # Ensure path exists - file_path.mkdir(parents=True, exist_ok=True) - return await self._save_to_yaml_file( - Path(file_path, file_name), - view.get("cards", [])[0], - ) - raise DashboardManagerException( - f"No view data to save for {view_name} view" - ) - return False - - async def delete_view(self, view: str): - """Delete view.""" - - # Get lovelace (frontend) config data - lovelace: LovelaceData = self.hass.data["lovelace"] - - # Get access to dashboard store - dashboard_store: dashboard.LovelaceStorage = lovelace.dashboards.get( - self.dashboard_key - ) - - # Load dashboard config data - if dashboard_store: - dashboard_config = await dashboard_store.async_load(True) - - # Remove view with title of home - modified = False - for index, ex_view in enumerate(dashboard_config["views"]): - if ex_view.get("title", "").lower() == view.lower(): - dashboard_config["views"].pop(index) - modified = True - break - - # Save dashboard config back to HA - if modified: - await dashboard_store.async_save(dashboard_config) diff --git a/custom_components/view_assist/docs/timers.md b/custom_components/view_assist/docs/timers.md index a6f6c15..1490ec7 100644 --- a/custom_components/view_assist/docs/timers.md +++ b/custom_components/view_assist/docs/timers.md @@ -219,6 +219,22 @@ or where [action] is one of started, cancelled, warning, expired, snoozed +## Translation Instructions: +1. Copy the file [timers_en.py](../translations/timers/timers_en.py) and rename it to the language you want to translate to. +2. Translate the text in the file to your language. Please be careful to not change the name of the variables. There are three types of sections: + - Needs Translation: These are word and phrases that need to be translated. + - Might Need Translation: These are words and phrases that might need to be translated. They use words from the previous section to form sentences. If they make sense in your language, you can leave them as is. + - Likely no translation: These are words and phrases that are likely to be the same in your language, since they refer to fixed intervals of time. You can leave them as is. +3. Import the translation file into [timers.py](../timers.py) and add the language to the list of supported languages (involves updating all level top level dicts). + - Also update the `TimerLanguage` class with your new language code. +4. Update Tests + - Import the translation file into [test_timers_all_languages.py](../tests/test_timers_all_languages.py) and add the language to the list of supported languages (involves updating all level top level dicts). + - Duplicate the [test_timers_en.py](../tests/test_timers_en.py) file and rename it to the language you want to test. + - Run tests to confirm things work as expected. +5. Update the language field in the [services.yaml](../services.yaml) file with the language code you added (needs to match the value defined in the `TimerLanguage` class). + - Needs to be done for the `set_timer` and `snooze_timer` services. +6. Move code to your Home Assistant custom component directory and test it out. + ## Outstanding TODOs - Create an expired timer clean up routine - they are currently only deleted by timer_cancel and restart of HA - Allow setting of timer expiry warning event when setting timer diff --git a/custom_components/view_assist/entity_listeners.py b/custom_components/view_assist/entity_listeners.py index 114f481..3515f59 100644 --- a/custom_components/view_assist/entity_listeners.py +++ b/custom_components/view_assist/entity_listeners.py @@ -6,6 +6,8 @@ import logging import random +from homeassistant.components.assist_satellite.entity import AssistSatelliteState +from homeassistant.components.media_player import MediaPlayerState from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_MODE from homeassistant.core import ( @@ -30,28 +32,29 @@ CYCLE_VIEWS, DEFAULT_VIEW_INFO, DOMAIN, + HASSMIC_DOMAIN, REMOTE_ASSIST_DISPLAY_DOMAIN, USE_VA_NAVIGATION_FOR_BROWSERMOD, VA_ATTRIBUTE_UPDATE_EVENT, VA_BACKGROUND_UPDATE_EVENT, - VAConfigEntry, - VADisplayType, - VAEvent, VAMode, ) from .helpers import ( async_get_download_image, async_get_filesystem_images, + ensure_menu_button_at_end, + get_config_entry_by_entity_id, get_device_name_from_id, get_display_type_from_browser_id, get_entity_attribute, get_entity_id_from_conversation_device_id, + get_hassmic_pipeline_status_entity_id, get_key, get_mute_switch_entity_id, get_revert_settings_for_mode, get_sensor_entity_from_instance, - make_url_from_file_path, ) +from .typed import VABackgroundMode, VAConfigEntry, VADisplayType, VAEvent _LOGGER = logging.getLogger(__name__) @@ -69,10 +72,7 @@ def __init__(self, hass: HomeAssistant, config_entry: VAConfigEntry) -> None: self.cycle_view_task: Task | None = None self.rotate_background_task: Task | None = None - # Add microphone mute switch listener - mute_switch = get_mute_switch_entity_id( - hass, config_entry.runtime_data.mic_device - ) + self.music_player_volume: float | None = None # Add browser navigate service listener config_entry.async_on_unload( @@ -91,7 +91,37 @@ def __init__(self, hass: HomeAssistant, config_entry: VAConfigEntry) -> None: ) ) - # Add mic mute switch listener + # Add mic device/wake word entity listening listner for volume ducking + if config_entry.runtime_data.default.ducking_volume is not None: + mic_integration = get_config_entry_by_entity_id( + self.hass, self.config_entry.runtime_data.core.mic_device + ).domain + if mic_integration == HASSMIC_DOMAIN: + entity_id = get_hassmic_pipeline_status_entity_id( + hass, self.config_entry.runtime_data.core.mic_device + ) + else: + entity_id = self.config_entry.runtime_data.core.mic_device + + if entity_id: + _LOGGER.debug("Listening for mic device %s", entity_id) + config_entry.async_on_unload( + async_track_state_change_event( + hass, + entity_id, + self._async_on_mic_state_change, + ) + ) + else: + _LOGGER.warning( + "Unable to find entity for pipeline status for %s", + self.config_entry.runtime_data.core.mic_device, + ) + + # Add microphone mute switch listener + mute_switch = get_mute_switch_entity_id( + hass, config_entry.runtime_data.core.mic_device + ) if mute_switch: config_entry.async_on_unload( async_track_state_change_event( @@ -138,23 +168,26 @@ async def _after_ha_start(self, *args): await asyncio.sleep(0.1) # Run display rotate task if set for device - if self.config_entry.runtime_data.rotate_background: + if ( + self.config_entry.runtime_data.dashboard.background_settings.background_mode + != VABackgroundMode.DEFAULT_BACKGROUND + ): # Set task based on mode if ( - self.config_entry.runtime_data.rotate_background_source - == "link_to_entity" + self.config_entry.runtime_data.dashboard.background_settings.background_mode + == VABackgroundMode.LINKED ): - if self.config_entry.runtime_data.rotate_background_linked_entity: + if self.config_entry.runtime_data.dashboard.background_settings.rotate_background_linked_entity: _LOGGER.debug( "Starting rotate background linked image listener for %s, linked to %s", - self.config_entry.runtime_data.name, - self.config_entry.runtime_data.rotate_background_linked_entity, + self.config_entry.runtime_data.core.name, + self.config_entry.runtime_data.dashboard.background_settings.rotate_background_linked_entity, ) # Add listener for background changes self.config_entry.async_on_unload( self.hass.bus.async_listen( VA_BACKGROUND_UPDATE_EVENT.format( - self.config_entry.runtime_data.rotate_background_linked_entity + self.config_entry.runtime_data.dashboard.background_settings.rotate_background_linked_entity ), self.async_set_background_image, ) @@ -163,25 +196,25 @@ async def _after_ha_start(self, *args): await self.async_set_background_image( get_entity_attribute( self.hass, - self.config_entry.runtime_data.rotate_background_linked_entity, + self.config_entry.runtime_data.dashboard.background_settings.rotate_background_linked_entity, "background", ) ) else: _LOGGER.warning( "%s is set to link its background image but no linked entity provided", - self.config_entry.runtime_data.name, + self.config_entry.runtime_data.core.name, ) else: _LOGGER.debug( "Starting rotate background image task for %s", - self.config_entry.runtime_data.name, + self.config_entry.runtime_data.core.name, ) self.rotate_background_task = ( self.config_entry.async_create_background_task( self.hass, self.async_background_image_rotation_task(), - f"{self.config_entry.runtime_data.name} rotate image task", + f"{self.config_entry.runtime_data.core.name} rotate image task", ) ) @@ -220,19 +253,34 @@ async def async_browser_navigate( if not is_revert_action: self._cancel_display_revert_task() + # Store current path in entity attributes to help menu filtering + entity_id = get_sensor_entity_from_instance( + self.hass, self.config_entry.entry_id + ) + + # Update current path attribute + await self.hass.services.async_call( + DOMAIN, + "set_state", + { + "entity_id": entity_id, + "current_path": path, + }, + ) + # Do navigation and set revert if needed browser_id = get_device_name_from_id( - self.hass, self.config_entry.runtime_data.display_device + self.hass, self.config_entry.runtime_data.core.display_device ) display_type = get_display_type_from_browser_id(self.hass, browser_id) _LOGGER.debug( "Navigating: %s, browser_id: %s, path: %s, display_type: %s, mode: %s", - self.config_entry.runtime_data.name, + self.config_entry.runtime_data.core.name, browser_id, path, display_type, - self.config_entry.runtime_data.mode, + self.config_entry.runtime_data.default.mode, ) # If using BrowserMod @@ -282,17 +330,17 @@ async def async_browser_navigate( # Find required revert action revert, revert_view = get_revert_settings_for_mode( - self.config_entry.runtime_data.mode + self.config_entry.runtime_data.default.mode ) revert_path = ( - getattr(self.config_entry.runtime_data, revert_view) + getattr(self.config_entry.runtime_data.dashboard, revert_view) if revert_view else None ) # Set revert action if required if revert and path != revert_path: - timeout = self.config_entry.runtime_data.view_timeout + timeout = self.config_entry.runtime_data.default.view_timeout _LOGGER.debug("Adding revert to %s in %ss", revert_path, timeout) self.revert_view_task = self.hass.async_create_task( self._display_revert_delay(revert_path, timeout) @@ -303,27 +351,32 @@ async def async_cycle_display_view(self, views: list[str]): view_index = 0 _LOGGER.debug("Cycle display started") - while self.config_entry.runtime_data.mode == VAMode.CYCLE: + while self.config_entry.runtime_data.default.mode == VAMode.CYCLE: view_index = view_index % len(views) _LOGGER.debug("Cycling to view: %s", views[view_index]) await self.async_browser_navigate( - f"{self.config_entry.runtime_data.dashboard}/{views[view_index]}", + f"{self.config_entry.runtime_data.dashboard.dashboard}/{views[view_index]}", ) view_index += 1 - await asyncio.sleep(self.config_entry.runtime_data.view_timeout) + await asyncio.sleep(self.config_entry.runtime_data.default.view_timeout) async def async_background_image_rotation_task(self): """Task to get background image for image rotation.""" - source = self.config_entry.runtime_data.rotate_background_source - path = self.config_entry.runtime_data.rotate_background_path - interval = self.config_entry.runtime_data.rotate_background_interval + source = ( + self.config_entry.runtime_data.dashboard.background_settings.background_mode + ) + path = self.config_entry.runtime_data.dashboard.background_settings.rotate_background_path + interval = self.config_entry.runtime_data.dashboard.background_settings.rotate_background_interval image_index = 0 # Clean path path.removeprefix("/").removesuffix("/") try: - if source in ["local_sequence", "local_random"]: + if source in [ + VABackgroundMode.LOCAL_SEQUENCE, + VABackgroundMode.LOCAL_RANDOM, + ]: image_list = await async_get_filesystem_images(self.hass, path) if not image_list: return @@ -345,7 +398,12 @@ async def async_background_image_rotation_task(self): else: return - image_url = make_url_from_file_path(self.hass, image) + image_url = ( + image.as_uri() + .replace("file://", "") + .replace(self.hass.config.config_dir, "") + ) + # Add parameter to override cache image_url = f"{image_url}?v={dt.now().strftime('%Y%m%d%H%M%S')}" @@ -371,7 +429,7 @@ async def async_set_background_image(self, image_url_or_event: str | Event): ) _LOGGER.debug( "Setting %s background image to %s", - self.config_entry.runtime_data.name, + self.config_entry.runtime_data.core.name, image_url, ) @@ -395,6 +453,96 @@ def update_entity(self): # Actions for monitoring changes to external entities # --------------------------------------------------------------------------------------- + async def _async_on_mic_state_change( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle mic state change event for volume ducking.""" + + # If not change to mic state, exit function + if ( + not event.data.get("old_state") + or event.data["old_state"].state == event.data["new_state"].state + ): + return + + music_player_entity_id = self.config_entry.runtime_data.core.musicplayer_device + mic_integration = get_config_entry_by_entity_id( + self.hass, self.config_entry.runtime_data.core.mic_device + ).domain + music_player_integration = get_config_entry_by_entity_id( + self.hass, music_player_entity_id + ).domain + + _LOGGER.debug( + "Mic state change: %s: %s->%s", + mic_integration, + event.data["old_state"].state, + event.data["new_state"].state, + ) + + if mic_integration == "esphome" and music_player_integration == "esphome": + # HA VPE already supports volume ducking + return + + if ( + self.hass.states.get(music_player_entity_id).state + != MediaPlayerState.PLAYING + ): + return + + old_state = event.data["old_state"].state + new_state = event.data["new_state"].state + ducking_volume = self.config_entry.runtime_data.default.ducking_volume / 100 + + if (mic_integration == HASSMIC_DOMAIN and old_state == "wake_word-start") or ( + mic_integration != HASSMIC_DOMAIN + and new_state == AssistSatelliteState.LISTENING + ): + if music_player_volume := self.hass.states.get( + music_player_entity_id + ).attributes.get("volume_level"): + self.music_player_volume = music_player_volume + + if self.hass.states.get(music_player_entity_id): + if music_player_volume > ducking_volume: + _LOGGER.debug("Ducking music player volume: %s", ducking_volume) + await self.hass.services.async_call( + "media_player", + "volume_set", + { + "entity_id": music_player_entity_id, + "volume_level": ducking_volume, + }, + ) + elif ( + (mic_integration == HASSMIC_DOMAIN and new_state == "wake_word-start") + or ( + mic_integration != HASSMIC_DOMAIN + and new_state == AssistSatelliteState.IDLE + ) + ) and self.music_player_volume is not None: + if self.hass.states.get(music_player_entity_id): + await asyncio.sleep(2) + _LOGGER.debug( + "Restoring music player volume: %s", self.music_player_volume + ) + # Restore gradually to avoid sudden volume change + for i in range(1, 11): + volume = min(self.music_player_volume, ducking_volume + (i * 0.1)) + await self.hass.services.async_call( + "media_player", + "volume_set", + { + "entity_id": music_player_entity_id, + "volume_level": volume, + }, + blocking=True, + ) + if volume == self.music_player_volume: + break + await asyncio.sleep(0.25) + self.music_player_volume = None + @callback def _async_on_mic_change(self, event: Event[EventStateChangedData]) -> None: mic_mute_new_state = event.data["new_state"].state @@ -407,14 +555,17 @@ def _async_on_mic_change(self, event: Event[EventStateChangedData]) -> None: return _LOGGER.debug("MIC MUTE: %s", mic_mute_new_state) - status_icons = self.config_entry.runtime_data.status_icons.copy() + d = self.config_entry.runtime_data.dashboard.display_settings + status_icons = d.status_icons.copy() if mic_mute_new_state == "on" and "mic" not in status_icons: status_icons.append("mic") elif mic_mute_new_state == "off" and "mic" in status_icons: status_icons.remove("mic") - self.config_entry.runtime_data.status_icons = status_icons + ensure_menu_button_at_end(status_icons) + + d.status_icons = status_icons self.update_entity() @callback @@ -434,18 +585,17 @@ def _async_on_mediaplayer_device_mute_change( return _LOGGER.debug("MP MUTE: %s", mp_mute_new_state) - status_icons = ( - self.config_entry.runtime_data.status_icons.copy() - if self.config_entry.runtime_data.status_icons - else [] - ) + d = self.config_entry.runtime_data.dashboard.display_settings + status_icons = d.status_icons.copy() if d.status_icons else [] if mp_mute_new_state and "mediaplayer" not in status_icons: status_icons.append("mediaplayer") elif not mp_mute_new_state and "mediaplayer" in status_icons: status_icons.remove("mediaplayer") - self.config_entry.runtime_data.status_icons = status_icons + ensure_menu_button_at_end(status_icons) + + d.status_icons = status_icons self.update_entity() async def _async_cc_on_conversation_ended_handler(self, event: Event): @@ -524,6 +674,16 @@ async def _async_on_intent_device_change( for entity in filtered_list ] + todo_entities = ( + [ + item["id"] + for item in changed_entities + if item.get("id", "").startswith("todo") + ] + if changed_entities + else [] + ) + # Check to make sure filtered_entities is not empty before proceeding if filtered_entities: await self.hass.services.async_call( @@ -534,7 +694,24 @@ async def _async_on_intent_device_change( "intent_entities": filtered_entities, }, ) - await self.async_browser_navigate(self.config_entry.runtime_data.intent) + await self.async_browser_navigate( + self.config_entry.runtime_data.dashboard.intent + ) + # If there are no filtered entities but there is a todo entity, show the list view + elif todo_entities: + await self.hass.services.async_call( + DOMAIN, + "set_state", + service_data={ + "entity_id": entity_id, + # If there are somehow multiple affected lists, just use the first one + "list": todo_entities[0], + }, + ) + await self.async_browser_navigate( + self.config_entry.runtime_data.dashboard.list_view + ) + else: word_count = len(speech_text.split()) message_font_size = ["10vw", "8vw", "6vw", "4vw"][ @@ -551,7 +728,7 @@ async def _async_on_intent_device_change( }, ) await self.async_browser_navigate( - f"{self.config_entry.runtime_data.dashboard}/{DEFAULT_VIEW_INFO}" + f"{self.config_entry.runtime_data.dashboard.dashboard}/{DEFAULT_VIEW_INFO}" ) # --------------------------------------------------------------------------------------- @@ -586,24 +763,37 @@ async def _async_on_dnd_device_state_change(self, event: Event) -> None: # This is called from our set_service event listener and therefore event data is # slightly different. See set_state_changed_attribute above dnd_new_state = event.data["new_value"] + d = self.config_entry.runtime_data.dashboard.display_settings _LOGGER.debug("DND STATE: %s", dnd_new_state) - status_icons = self.config_entry.runtime_data.status_icons.copy() + status_icons = d.status_icons.copy() if dnd_new_state and "dnd" not in status_icons: status_icons.append("dnd") elif not dnd_new_state and "dnd" in status_icons: status_icons.remove("dnd") - self.config_entry.runtime_data.status_icons = status_icons + ensure_menu_button_at_end(status_icons) + + d.status_icons = status_icons self.update_entity() async def _async_on_mode_state_change(self, event: Event) -> None: """Set mode status icon.""" new_mode = event.data["new_value"] + r = self.config_entry.runtime_data + d = r.dashboard.display_settings _LOGGER.debug("MODE STATE: %s", new_mode) - status_icons = self.config_entry.runtime_data.status_icons.copy() + + # Get current status icons directly from entity state + entity_id = get_sensor_entity_from_instance( + self.hass, self.config_entry.entry_id + ) + if entity := self.hass.states.get(entity_id): + status_icons = list(entity.attributes.get("status_icons", [])) + else: + status_icons = d.status_icons.copy() modes = [VAMode.HOLD, VAMode.CYCLE] @@ -616,7 +806,18 @@ async def _async_on_mode_state_change(self, event: Event) -> None: if new_mode in modes and new_mode not in status_icons: status_icons.append(new_mode) - self.config_entry.runtime_data.status_icons = status_icons + ensure_menu_button_at_end(status_icons) + + # Store the updated status icons in the display settings + d.status_icons = status_icons + + # Update entity state + await self.hass.services.async_call( + DOMAIN, + "set_state", + service_data={"entity_id": entity_id, "status_icons": status_icons}, + ) + self.update_entity() if new_mode != VAMode.CYCLE: @@ -626,24 +827,12 @@ async def _async_on_mode_state_change(self, event: Event) -> None: if new_mode == VAMode.NORMAL: # Add navigate to default view - await self.async_browser_navigate(self.config_entry.runtime_data.home) + await self.async_browser_navigate(r.dashboard.home) _LOGGER.debug("NAVIGATE TO: %s", new_mode) elif new_mode == VAMode.MUSIC: # Add navigate to music view - await self.async_browser_navigate(self.config_entry.runtime_data.music) - - # -------------------------------------------- - # Service call option - # -------------------------------------------- - # await self.hass.services.async_call( - # "switch", - # "turn_on", - # { - # "entity_id": "switch.android_satellite_viewassist_office_wyoming_mute" - # }, - # ) - + await self.async_browser_navigate(r.dashboard.music) _LOGGER.debug("NAVIGATE TO: %s", new_mode) elif new_mode == VAMode.CYCLE: diff --git a/custom_components/view_assist/helpers.py b/custom_components/view_assist/helpers.py index e2c47cb..44804ff 100644 --- a/custom_components/view_assist/helpers.py +++ b/custom_components/view_assist/helpers.py @@ -14,18 +14,16 @@ from .const import ( BROWSERMOD_DOMAIN, - CONF_DEV_MIMIC, CONF_DISPLAY_DEVICE, DOMAIN, + HASSMIC_DOMAIN, IMAGE_PATH, RANDOM_IMAGE_URL, REMOTE_ASSIST_DISPLAY_DOMAIN, VAMODE_REVERTS, - VAConfigEntry, - VADisplayType, VAMode, - VAType, ) +from .typed import VAConfigEntry, VADisplayType, VAType _LOGGER = logging.getLogger(__name__) @@ -44,6 +42,37 @@ def get_integration_entries( ] +def get_entity_list( + hass: HomeAssistant, + integration: str | list[str] | None = None, + domain: str | list[str] | None = None, + append: str | list[str] | None = None, +) -> list[str]: + """Get the entity ids of devices not in dnd mode.""" + if append: + matched_entities = ensure_list(append) + else: + matched_entities = [] + # Stop full list of entities returning + if not integration and not domain: + return matched_entities + + if domain and isinstance(domain, str): + domain = [domain] + + if integration and isinstance(integration, str): + integration = [integration] + + entity_registry = er.async_get(hass) + for entity_info, entity_id in entity_registry.entities._index.items(): # noqa: SLF001 + if integration and entity_info[1] not in integration: + continue + if domain and entity_info[0] not in domain: + continue + matched_entities.append(entity_id) + return matched_entities + + def is_first_instance( hass: HomeAssistant, config: VAConfigEntry, display_instance_only: bool = False ): @@ -73,6 +102,110 @@ def ensure_list(value: str | list[str]): return [] +def ensure_menu_button_at_end(status_icons: list[str]) -> None: + """Ensure menu button is always the rightmost (last) status icon.""" + if "menu" in status_icons: + status_icons.remove("menu") + status_icons.append("menu") + + +def normalize_status_items(raw_input: Any) -> str | list[str] | None: + """Normalize and validate status item input. + + Handles various input formats: + - Single string + - List of strings + - JSON string representing a list + - Dictionary with attributes + + Returns: + - Single string + - List of strings + - None if invalid input + """ + import json + + if raw_input is None: + return None + + if isinstance(raw_input, str): + if raw_input.startswith("[") and raw_input.endswith("]"): + try: + parsed = json.loads(raw_input) + if isinstance(parsed, list): + string_items = [str(item) for item in parsed if item] + return string_items if string_items else None + return None + except json.JSONDecodeError: + return raw_input if raw_input else None + return raw_input if raw_input else None + + if isinstance(raw_input, list): + string_items = [str(item) for item in raw_input if item] + return string_items if string_items else None + + if isinstance(raw_input, dict): + if "id" in raw_input: + return str(raw_input["id"]) + if "name" in raw_input: + return str(raw_input["name"]) + if "value" in raw_input: + return str(raw_input["value"]) + + return None + + +def arrange_status_icons( + menu_items: list[str], system_icons: list[str], show_menu_button: bool = False +) -> list[str]: + """Arrange status icons in the correct order.""" + result = [item for item in menu_items if item != "menu"] + + for icon in system_icons: + if icon != "menu" and icon not in result: + result.append(icon) + + if show_menu_button: + ensure_menu_button_at_end(result) + + return result + + +def update_status_icons( + current_icons: list[str], + add_icons: list[str] = None, + remove_icons: list[str] = None, + menu_items: list[str] = None, + show_menu_button: bool = False, +) -> list[str]: + """Update a status icons list by adding and/or removing icons.""" + result = current_icons.copy() + + if remove_icons: + for icon in remove_icons: + if icon == "menu" and show_menu_button: + continue + if icon in result: + result.remove(icon) + + if add_icons: + for icon in add_icons: + if icon not in result: + if icon != "menu": + result.append(icon) + + if menu_items is not None: + system_icons = [ + icon for icon in result if icon not in menu_items and icon != "menu" + ] + menu_icon_list = [icon for icon in result if icon in menu_items] + result = arrange_status_icons(menu_icon_list, system_icons, show_menu_button) + elif show_menu_button: + ensure_menu_button_at_end(result) + + return result + + def get_entity_attribute(hass: HomeAssistant, entity_id: str, attribute: str) -> Any: """Get attribute from entity by entity_id.""" if entity := hass.states.get(entity_id): @@ -197,7 +330,7 @@ def get_entity_id_from_conversation_device_id( ) -> str | None: """Get the view assist entity id for a device id relating to the mic entity.""" for entry in get_integration_entries(hass): - mic_entity_id = entry.runtime_data.mic_device + mic_entity_id = entry.runtime_data.core.mic_device entity_registry = er.async_get(hass) mic_entity = entity_registry.async_get(mic_entity_id) if mic_entity.device_id == device_id: @@ -205,16 +338,26 @@ def get_entity_id_from_conversation_device_id( return None -def get_mimic_entity_id(hass: HomeAssistant) -> str: +def get_mimic_entity_id(hass: HomeAssistant, browser_id: str | None = None) -> str: """Get mimic entity id.""" # If we reach here, no match for browser_id was found - if mimic_entry_ids := [ - entry.entry_id - for entry in get_integration_entries(hass) - if entry.data.get(CONF_DEV_MIMIC) - ]: - return get_sensor_entity_from_instance(hass, mimic_entry_ids[0]) - return None + master_entry = get_master_config_entry(hass) + if browser_id: + if get_display_type_from_browser_id(hass, browser_id) == "native": + if ( + master_entry.runtime_data.developer_settings.developer_device + == browser_id + ): + return ( + master_entry.runtime_data.developer_settings.developer_mimic_device + ) + return None + + device_id = get_device_id_from_name(hass, browser_id) + if master_entry.runtime_data.developer_settings.developer_device == device_id: + return master_entry.runtime_data.developer_settings.developer_mimic_device + return None + return master_entry.runtime_data.developer_settings.developer_mimic_device def get_entity_id_by_browser_id(hass: HomeAssistant, browser_id: str) -> str: @@ -257,6 +400,24 @@ def get_mute_switch_entity_id(hass: HomeAssistant, mic_entity_id: str) -> str | return None +def get_hassmic_pipeline_status_entity_id( + hass: HomeAssistant, mic_entity_id: str +) -> str | None: + """Get the wakeword entity id for a hassmic device relating to the mic entity.""" + entity_registry = er.async_get(hass) + if mic_entity := entity_registry.async_get(mic_entity_id): + if mic_entity.platform != HASSMIC_DOMAIN: + return None + device_id = mic_entity.device_id + device_entities = er.async_entries_for_device(entity_registry, device_id) + for entity in device_entities: + if entity.domain == "sensor" and entity.entity_id.endswith( + "_pipeline_state" + ): + return entity.entity_id + return None + + def get_display_type_from_browser_id( hass: HomeAssistant, browser_id: str ) -> VADisplayType: @@ -272,7 +433,7 @@ def get_display_type_from_browser_id( return VADisplayType.BROWSERMOD if entry.domain == REMOTE_ASSIST_DISPLAY_DOMAIN: return VADisplayType.REMOTE_ASSIST_DISPLAY - return None + return "native" def get_revert_settings_for_mode(mode: VAMode) -> tuple: @@ -328,12 +489,13 @@ def get_key( ) -> dict[str, dict | str | int] | str | int: """Try to get a deep value from a dict based on a dot-notation.""" - dn_list = dot_notation_path.split(".") - try: + if "." in dot_notation_path: + dn_list = dot_notation_path.split(".") + else: + dn_list = [dot_notation_path] return reduce(dict.get, dn_list, data) - except (TypeError, KeyError) as ex: - _LOGGER.error("TYPE ERROR: %s - %s", dn_list, ex) + except (TypeError, KeyError): return None @@ -354,9 +516,7 @@ def get_download_image( ) -> Path: """Get url from unsplash random image endpoint.""" path = Path(hass.config.config_dir, DOMAIN, save_path) - filename = ( - f"downloaded_{config.entry_id.lower()}_{slugify(config.runtime_data.name)}.jpg" - ) + filename = f"downloaded_{config.entry_id.lower()}_{slugify(config.runtime_data.core.name)}.jpg" image: Path | None = None try: @@ -410,12 +570,6 @@ def get_filesystem_images(hass: HomeAssistant, fs_path: str) -> list[Path]: return image_list -def make_url_from_file_path(hass: HomeAssistant, path: Path) -> str: - """Make a url from the file path.""" - url = path.as_uri() - return url.replace("file://", "").replace(hass.config.config_dir, "") - - def differ_to_json(diffs: list) -> dict: """Convert dictdiffer output to json for saving to file.""" output = {} diff --git a/custom_components/view_assist/http_url.py b/custom_components/view_assist/http_url.py index cdbb906..1d1105d 100644 --- a/custom_components/view_assist/http_url.py +++ b/custom_components/view_assist/http_url.py @@ -6,7 +6,8 @@ from homeassistant.components.http import StaticPathConfig from homeassistant.core import HomeAssistant -from .const import DOMAIN, URL_BASE, VA_SUB_DIRS, VAConfigEntry +from .const import DOMAIN, URL_BASE, VA_SUB_DIRS +from .typed import VAConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/view_assist/js_modules/__init__.py b/custom_components/view_assist/js_modules/__init__.py index b26044f..e9e2142 100644 --- a/custom_components/view_assist/js_modules/__init__.py +++ b/custom_components/view_assist/js_modules/__init__.py @@ -1,7 +1,6 @@ """View Assist Javascript module registration.""" import logging -import os from pathlib import Path from homeassistant.components.http import StaticPathConfig @@ -144,7 +143,7 @@ def remove_gzip_files(self): path = self.hass.config.path(f"custom_components/{DOMAIN}/js_modules") gzip_files = [ - filename for filename in os.listdir(path) if filename.endswith(".gz") + file.name for file in Path(path).iterdir() if file.name.endswith(".gz") ] for file in gzip_files: diff --git a/custom_components/view_assist/manifest.json b/custom_components/view_assist/manifest.json index d6088ef..379449e 100644 --- a/custom_components/view_assist/manifest.json +++ b/custom_components/view_assist/manifest.json @@ -3,11 +3,11 @@ "name": "View Assist", "codeowners": ["@dinki"], "config_flow": true, - "dependencies": ["http","lovelace"], + "dependencies": ["http","lovelace","blueprint"], "documentation": "https://github.com/dinki/view_assist_integration", "integration_type": "device", "iot_class": "calculated", "issue_tracker": "https://github.com/dinki/view_assist_integration/issues", "requirements": ["wordtodigits==1.0.2"], - "version": "2025.4.2" + "version": "2025.5.1" } diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py new file mode 100644 index 0000000..820f5a3 --- /dev/null +++ b/custom_components/view_assist/menu_manager.py @@ -0,0 +1,584 @@ +"""Menu manager for View Assist.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +import logging +from typing import Any + +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + CONF_DISPLAY_SETTINGS, + CONF_MENU_CONFIG, + CONF_MENU_ITEMS, + CONF_MENU_TIMEOUT, + CONF_STATUS_ICONS, + DEFAULT_VALUES, + DOMAIN, + VAMode, +) +from .helpers import ( + arrange_status_icons, + ensure_menu_button_at_end, + get_config_entry_by_entity_id, + get_master_config_entry, + get_sensor_entity_from_instance, + normalize_status_items, + update_status_icons, +) +from .typed import VAConfigEntry, VAEvent, VAMenuConfig + +_LOGGER = logging.getLogger(__name__) + +StatusItemType = str | list[str] + + +@dataclass +class MenuState: + """Structured representation of a menu's state.""" + + entity_id: str + active: bool = False + configured_items: list[str] = field(default_factory=list) + status_icons: list[str] = field(default_factory=list) + system_icons: list[str] = field(default_factory=list) + menu_timeout: asyncio.Task | None = None + item_timeouts: dict[tuple[str, str, bool], asyncio.Task] = field( + default_factory=dict + ) + + +class MenuManager: + """Class to manage View Assist menus.""" + + def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: + """Initialize menu manager.""" + self.hass = hass + self.config = config + self._menu_states: dict[str, MenuState] = {} + self._pending_updates: dict[str, dict[str, Any]] = {} + self._update_event = asyncio.Event() + self._update_task: asyncio.Task | None = None + self._initialized = False + + config.async_on_unload(self.cleanup) + self.hass.bus.async_listen_once( + "homeassistant_started", self._initialize_on_startup + ) + + async def _initialize_on_startup(self, _event: Event) -> None: + """Initialize when Home Assistant has fully started.""" + if self._initialized: + return + + self._update_task = self.config.async_create_background_task( + self.hass, self._update_processor(), name="VA Menu Manager" + ) + + # Initialize existing entities + for entry_id in [ + e.entry_id for e in self.hass.config_entries.async_entries(DOMAIN) + ]: + entity_id = get_sensor_entity_from_instance(self.hass, entry_id) + if entity_id: + self._get_or_create_state(entity_id) + + self._initialized = True + + def _get_or_create_state(self, entity_id: str) -> MenuState: + """Get or create a MenuState for the entity.""" + if entity_id not in self._menu_states: + self._menu_states[entity_id] = MenuState(entity_id=entity_id) + state = self.hass.states.get(entity_id) + + if state: + menu_items = self._get_config_value( + entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_ITEMS}", [] + ) + self._menu_states[entity_id].configured_items = menu_items or [] + + status_icons = state.attributes.get(CONF_STATUS_ICONS, []) + self._menu_states[entity_id].status_icons = ( + status_icons if status_icons else [] + ) + self._menu_states[entity_id].active = state.attributes.get( + "menu_active", False + ) + + self._menu_states[entity_id].system_icons = [ + icon + for icon in self._menu_states[entity_id].status_icons + if icon not in self._menu_states[entity_id].configured_items + and icon != "menu" + ] + + return self._menu_states[entity_id] + + def _get_config_value(self, entity_id: str, key: str, default: Any = None) -> Any: + """Get configuration value with hierarchy: entity > master > default.""" + # Check entity config + entity_config = get_config_entry_by_entity_id(self.hass, entity_id) + + # Check entity config first + if entity_config: + # Check direct key + if key in entity_config.options: + return entity_config.options[key] + + # Check nested key + if "." in key: + section, setting = key.split(".") + if ( + section in entity_config.options + and isinstance(entity_config.options[section], dict) + and setting in entity_config.options[section] + ): + return entity_config.options[section][setting] + + # Check master config + master_config = get_master_config_entry(self.hass) + if master_config: + # Check direct key + if key in master_config.options: + return master_config.options[key] + + # Check nested key + if "." in key: + section, setting = key.split(".") + if ( + section in master_config.options + and isinstance(master_config.options[section], dict) + and setting in master_config.options[section] + ): + return master_config.options[section][setting] + + # Check defaults + if key in DEFAULT_VALUES: + return DEFAULT_VALUES[key] + + if "." in key: + section, setting = key.split(".") + if ( + section in DEFAULT_VALUES + and isinstance(DEFAULT_VALUES[section], dict) + and setting in DEFAULT_VALUES[section] + ): + return DEFAULT_VALUES[section][setting] + + return default + + def _refresh_system_icons(self, entity_id: str, menu_state: MenuState) -> list[str]: + """Refresh system icons from current entity state.""" + state = self.hass.states.get(entity_id) + if not state: + return menu_state.system_icons + + modes = [VAMode.HOLD, VAMode.CYCLE] + + # Get current status_icons excluding menu items and mode icons + current_status_icons = state.attributes.get(CONF_STATUS_ICONS, []) + system_icons = [ + icon + for icon in current_status_icons + if icon not in menu_state.configured_items + and icon != "menu" + and icon not in modes + ] + + # Add current mode if it exists + current_mode = state.attributes.get("mode") + if current_mode in modes and current_mode not in system_icons: + system_icons.append(current_mode) + + menu_state.system_icons = system_icons + return system_icons + + async def toggle_menu( + self, entity_id: str, show: bool | None = None, timeout: int | None = None + ) -> None: + """Toggle menu visibility for an entity.""" + await self._ensure_initialized() + + # Validate entity and config + config_entry = get_config_entry_by_entity_id(self.hass, entity_id) + if not config_entry: + _LOGGER.error("Config entry not found for %s", entity_id) + return + + # Get menu configuration + menu_config = self._get_config_value( + entity_id, + f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_CONFIG}", + VAMenuConfig.DISABLED, + ) + + # Check if menu is enabled + if menu_config == VAMenuConfig.DISABLED: + _LOGGER.warning("Menu is not enabled for %s", entity_id) + return + + state = self.hass.states.get(entity_id) + if not state: + _LOGGER.warning("Entity %s not found", entity_id) + return + + # Get menu state and settings + menu_state = self._get_or_create_state(entity_id) + current_active = menu_state.active + show = show if show is not None else not current_active + + self._cancel_timeout(entity_id) + + # Check if menu button should be shown + show_menu_button = menu_config == VAMenuConfig.ENABLED_VISIBLE + + # Always refresh system icons to ensure we have latest state + system_icons = self._refresh_system_icons(entity_id, menu_state) + + # Apply the menu state change + changes = {} + if show: + # Show menu + updated_icons = arrange_status_icons( + menu_state.configured_items, system_icons, show_menu_button + ) + menu_state.active = True + menu_state.status_icons = updated_icons + changes = {"status_icons": updated_icons, "menu_active": True} + + # Handle timeout + if timeout is not None: + self._setup_timeout(entity_id, timeout) + else: + menu_timeout = self._get_config_value( + entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_TIMEOUT}", 0 + ) + if menu_timeout > 0: + self._setup_timeout(entity_id, menu_timeout) + else: + # Hide menu + updated_icons = system_icons.copy() + if show_menu_button: + ensure_menu_button_at_end(updated_icons) + + menu_state.active = False + menu_state.status_icons = updated_icons + changes = {"status_icons": updated_icons, "menu_active": False} + + # Apply changes + if changes: + await self._update_entity_state(entity_id, changes) + + # Notify via dispatcher + if config_entry: + async_dispatcher_send( + self.hass, + f"{DOMAIN}_{config_entry.entry_id}_event", + VAEvent("menu_update", {"menu_active": show}), + ) + + async def add_status_item( + self, + entity_id: str, + status_item: StatusItemType, + menu: bool = False, + timeout: int | None = None, + ) -> None: + """Add status item(s) to the entity's status icons or menu items.""" + # Normalize input and validate + items = normalize_status_items(status_item) + if isinstance(items, str): + items = [items] + elif not items: + _LOGGER.warning("No valid items to add") + return + + config_entry = get_config_entry_by_entity_id(self.hass, entity_id) + if not config_entry: + _LOGGER.warning("No config entry found for entity %s", entity_id) + return + + await self._ensure_initialized() + menu_state = self._get_or_create_state(entity_id) + + # Get menu configuration + menu_config = self._get_config_value( + entity_id, + f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_CONFIG}", + VAMenuConfig.DISABLED, + ) + + # Check if menu button should be shown + show_menu_button = menu_config == VAMenuConfig.ENABLED_VISIBLE + + changes = {} + if menu: + # Add to menu items + updated_items = menu_state.configured_items.copy() + changed = False + + for item in items: + if item not in updated_items: + updated_items.append(item) + changed = True + + if changed: + menu_state.configured_items = updated_items + changes["menu_items"] = updated_items + await self._save_to_config_entry_options( + entity_id, CONF_MENU_ITEMS, updated_items + ) + + # Update icons if menu is active + if menu_state.active: + updated_icons = arrange_status_icons( + updated_items, menu_state.system_icons, show_menu_button + ) + menu_state.status_icons = updated_icons + changes["status_icons"] = updated_icons + else: + # Add to status icons + updated_icons = update_status_icons( + menu_state.status_icons, + add_icons=items, + menu_items=menu_state.configured_items if menu_state.active else None, + show_menu_button=show_menu_button, + ) + + if updated_icons != menu_state.status_icons: + menu_state.status_icons = updated_icons + changes["status_icons"] = updated_icons + await self._save_to_config_entry_options( + entity_id, CONF_STATUS_ICONS, updated_icons + ) + + # Apply changes + if changes: + await self._update_entity_state(entity_id, changes) + + # Set up timeouts if needed + if timeout is not None: + for item in items: + await self._setup_item_timeout(entity_id, item, timeout, menu) + + async def remove_status_item( + self, entity_id: str, status_item: StatusItemType, from_menu: bool = False + ) -> None: + """Remove status item(s) from the entity's status icons or menu items.""" + # Normalize input and validate + items = normalize_status_items(status_item) + if isinstance(items, str): + items = [items] + elif not items: + _LOGGER.warning("No valid items to remove") + return + + config_entry = get_config_entry_by_entity_id(self.hass, entity_id) + if not config_entry: + return + + await self._ensure_initialized() + menu_state = self._get_or_create_state(entity_id) + + # Get menu configuration + menu_config = self._get_config_value( + entity_id, + f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_CONFIG}", + VAMenuConfig.DISABLED, + ) + + # Check if menu button should be shown + show_menu_button = menu_config == VAMenuConfig.ENABLED_VISIBLE + + changes = {} + if from_menu: + # Remove from menu items + updated_items = [ + item for item in menu_state.configured_items if item not in items + ] + + if updated_items != menu_state.configured_items: + menu_state.configured_items = updated_items + changes["menu_items"] = updated_items + await self._save_to_config_entry_options( + entity_id, CONF_MENU_ITEMS, updated_items + ) + + # Update icons if menu is active + if menu_state.active: + updated_icons = arrange_status_icons( + updated_items, menu_state.system_icons, show_menu_button + ) + menu_state.status_icons = updated_icons + changes["status_icons"] = updated_icons + else: + # Remove from status icons + updated_icons = update_status_icons( + menu_state.status_icons, + remove_icons=items, + menu_items=menu_state.configured_items if menu_state.active else None, + show_menu_button=show_menu_button, + ) + + if updated_icons != menu_state.status_icons: + menu_state.status_icons = updated_icons + changes["status_icons"] = updated_icons + await self._save_to_config_entry_options( + entity_id, CONF_STATUS_ICONS, updated_icons + ) + + # Apply changes and cancel timeouts + if changes: + await self._update_entity_state(entity_id, changes) + + for item in items: + self._cancel_item_timeout(entity_id, item, from_menu) + + async def _save_to_config_entry_options( + self, entity_id: str, option_key: str, value: list[str] + ) -> None: + """Save options to config entry for persistence.""" + config_entry = get_config_entry_by_entity_id(self.hass, entity_id) + if not config_entry: + _LOGGER.warning("Cannot save %s - config entry not found", option_key) + return + + try: + new_options = dict(config_entry.options) + + new_options[option_key] = value + self.hass.config_entries.async_update_entry( + config_entry, options=new_options + ) + except Exception as err: # noqa: BLE001 + _LOGGER.error("Error saving config entry options: %s", str(err)) + + def _setup_timeout(self, entity_id: str, timeout: int) -> None: + """Setup timeout for menu.""" + menu_state = self._get_or_create_state(entity_id) + self._cancel_timeout(entity_id) + + async def _timeout_task() -> None: + try: + await asyncio.sleep(timeout) + await self.toggle_menu(entity_id, False) + except asyncio.CancelledError: + pass + + menu_state.menu_timeout = self.config.async_create_background_task( + self.hass, _timeout_task(), name=f"VA Menu Timeout {entity_id}" + ) + + def _cancel_timeout(self, entity_id: str) -> None: + """Cancel any existing timeout for an entity.""" + if entity_id in self._menu_states and self._menu_states[entity_id].menu_timeout: + menu_timeout = self._menu_states[entity_id].menu_timeout + if not menu_timeout.done(): + menu_timeout.cancel() + self._menu_states[entity_id].menu_timeout = None + + async def _setup_item_timeout( + self, entity_id: str, menu_item: str, timeout: int, is_menu_item: bool = False + ) -> None: + """Set up a timeout for a specific menu item.""" + menu_state = self._get_or_create_state(entity_id) + item_key = (entity_id, menu_item, is_menu_item) + self._cancel_item_timeout(entity_id, menu_item, is_menu_item) + + async def _item_timeout_task() -> None: + try: + await asyncio.sleep(timeout) + await self.remove_status_item(entity_id, menu_item, is_menu_item) + except asyncio.CancelledError: + pass + + menu_state.item_timeouts[item_key] = self.config.async_create_background_task( + self.hass, + _item_timeout_task(), + name=f"VA Item Timeout {entity_id} {menu_item}", + ) + + def _cancel_item_timeout( + self, entity_id: str, menu_item: str, is_menu_item: bool = False + ) -> None: + """Cancel timeout for a specific menu item.""" + if entity_id not in self._menu_states: + return + + menu_state = self._menu_states[entity_id] + item_key = (entity_id, menu_item, is_menu_item) + + if ( + item_key in menu_state.item_timeouts + and not menu_state.item_timeouts[item_key].done() + ): + menu_state.item_timeouts[item_key].cancel() + menu_state.item_timeouts.pop(item_key) + + async def _update_entity_state( + self, entity_id: str, changes: dict[str, Any] + ) -> None: + """Queue entity state update.""" + if not changes: + return + + if entity_id not in self._pending_updates: + self._pending_updates[entity_id] = {} + + self._pending_updates[entity_id].update(changes) + self._update_event.set() + + async def _update_processor(self) -> None: + """Process updates as they arrive.""" + try: + while True: + await self._update_event.wait() + self._update_event.clear() + + updates = self._pending_updates.copy() + self._pending_updates.clear() + + for entity_id, changes in updates.items(): + if changes: + changes["entity_id"] = entity_id + try: + await self.hass.services.async_call( + DOMAIN, "set_state", changes + ) + except Exception as err: # noqa: BLE001 + _LOGGER.error("Error updating %s: %s", entity_id, str(err)) + + except asyncio.CancelledError: + pass + + async def _ensure_initialized(self) -> None: + """Ensure the menu manager is initialized.""" + if not self._initialized: + self._update_task = self.config.async_create_background_task( + self.hass, self._update_processor(), name="VA Menu Manager" + ) + + for entry_id in [ + e.entry_id for e in self.hass.config_entries.async_entries(DOMAIN) + ]: + entity_id = get_sensor_entity_from_instance(self.hass, entry_id) + if entity_id: + self._get_or_create_state(entity_id) + + self._initialized = True + + async def cleanup(self) -> None: + """Clean up resources.""" + for menu_state in self._menu_states.values(): + if menu_state.menu_timeout and not menu_state.menu_timeout.done(): + menu_state.menu_timeout.cancel() + + for timeout in menu_state.item_timeouts.values(): + if not timeout.done(): + timeout.cancel() + + if self._update_task and not self._update_task.done(): + self._update_task.cancel() diff --git a/custom_components/view_assist/sensor.py b/custom_components/view_assist/sensor.py index 894d9d7..b90cf44 100644 --- a/custom_components/view_assist/sensor.py +++ b/custom_components/view_assist/sensor.py @@ -18,10 +18,10 @@ OPTION_KEY_MIGRATIONS, VA_ATTRIBUTE_UPDATE_EVENT, VA_BACKGROUND_UPDATE_EVENT, - VAConfigEntry, ) from .helpers import get_device_id_from_entity_id, get_mute_switch_entity_id from .timers import VATimers +from .typed import VAConfigEntry, VATimeFormat _LOGGER = logging.getLogger(__name__) @@ -52,14 +52,14 @@ def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: self.hass = hass self.config = config - self._attr_name = config.runtime_data.name - self._type = config.runtime_data.type + self._attr_name = config.runtime_data.core.name + self._type = config.runtime_data.core.type self._attr_unique_id = f"{self._attr_name}_vasensor" self._attr_native_value = "" self._attribute_listeners: dict[str, Callable] = {} self._voice_device_id = get_device_id_from_entity_id( - self.hass, self.config.runtime_data.mic_device + self.hass, self.config.runtime_data.core.mic_device ) async def async_added_to_hass(self) -> None: @@ -96,31 +96,40 @@ def extra_state_attributes(self) -> dict[str, Any]: r = self.config.runtime_data attrs = { - "type": r.type, - "mic_device": r.mic_device, - "mic_device_id": get_device_id_from_entity_id(self.hass, r.mic_device), - "mute_switch": get_mute_switch_entity_id(self.hass, r.mic_device), - "mediaplayer_device": r.mediaplayer_device, - "musicplayer_device": r.musicplayer_device, - "mode": r.mode, - "view_timeout": r.view_timeout, - "do_not_disturb": r.do_not_disturb, - "status_icons": r.status_icons, - "status_icons_size": r.status_icons_size, - "assist_prompt": self.get_option_key_migration_value(r.assist_prompt), - "font_style": r.font_style, - "use_24_hour_time": r.use_24_hour_time, - "use_announce": r.use_announce, - "background": r.background, - "weather_entity": r.weather_entity, + # Core settings + "type": r.core.type, + "mic_device": r.core.mic_device, + "mic_device_id": get_device_id_from_entity_id(self.hass, r.core.mic_device), + "mute_switch": get_mute_switch_entity_id(self.hass, r.core.mic_device), + "mediaplayer_device": r.core.mediaplayer_device, + "musicplayer_device": r.core.musicplayer_device, "voice_device_id": self._voice_device_id, + # Dashboard settings + "status_icons": r.dashboard.display_settings.status_icons, + "status_icons_size": r.dashboard.display_settings.status_icons_size, + "menu_config": r.dashboard.display_settings.menu_config, + "menu_items": r.dashboard.display_settings.menu_items, + "menu_active": self._get_menu_active_state(), + "assist_prompt": self.get_option_key_migration_value( + r.dashboard.display_settings.assist_prompt + ), + "font_style": r.dashboard.display_settings.font_style, + "use_24_hour_time": r.dashboard.display_settings.time_format + == VATimeFormat.HOUR_24, + "background": r.dashboard.background_settings.background, + # Default settings + "mode": r.default.mode, + "view_timeout": r.default.view_timeout, + "do_not_disturb": r.default.do_not_disturb, + "use_announce": r.default.use_announce, + "weather_entity": r.default.weather_entity, } # Only add these attributes if they exist - if r.display_device: - attrs["display_device"] = r.display_device - if r.intent_device: - attrs["intent_device"] = r.intent_device + if r.core.display_device: + attrs["display_device"] = r.core.display_device + if r.core.intent_device: + attrs["intent_device"] = r.core.intent_device # Add extra_data attributes from runtime data attrs.update(self.config.runtime_data.extra_data) @@ -136,11 +145,10 @@ def set_entity_state(self, **kwargs): continue if k == "state": self._attr_native_value = v - continue # Fire event if value changes to entity listener - if hasattr(self.config.runtime_data, k): - old_val = getattr(self.config.runtime_data, k) + if hasattr(self.config.runtime_data.default, k): + old_val = getattr(self.config.runtime_data.default, k) elif self.config.runtime_data.extra_data.get(k) is not None: old_val = self.config.runtime_data.extra_data[k] else: @@ -158,13 +166,24 @@ def set_entity_state(self, **kwargs): ) # Set the value of named vartiables or add/update to extra_data dict - if hasattr(self.config.runtime_data, k): - setattr(self.config.runtime_data, k, v) + if hasattr(self.config.runtime_data.default, k): + setattr(self.config.runtime_data.default, k, v) else: self.config.runtime_data.extra_data[k] = v self.schedule_update_ha_state(True) + def _get_menu_active_state(self) -> bool: + """Get the menu active state from menu manager.""" + menu_manager = self.hass.data[DOMAIN].get("menu_manager") + if not menu_manager: + return False + + if hasattr(menu_manager, "_menu_states") and self.entity_id in menu_manager._menu_states: + return menu_manager._menu_states[self.entity_id].active + + return False + @property def icon(self): """Return the icon of the sensor.""" diff --git a/custom_components/view_assist/services.py b/custom_components/view_assist/services.py index 9969746..e7d8bd0 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -4,6 +4,7 @@ import logging import voluptuous as vol +from homeassistant.components.conversation import ATTR_LANGUAGE from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_NAME, ATTR_TIME from homeassistant.core import ( @@ -69,6 +70,7 @@ vol.Optional(ATTR_NAME): str, vol.Required(ATTR_TIME): str, vol.Optional(ATTR_EXTRA): vol.Schema({}, extra=vol.ALLOW_EXTRA), + vol.Required(ATTR_LANGUAGE): str, } ) @@ -86,6 +88,7 @@ { vol.Required(ATTR_TIMER_ID): str, vol.Required(ATTR_TIME): str, + vol.Required(ATTR_LANGUAGE): str, } ) @@ -306,8 +309,9 @@ async def async_handle_set_timer(self, call: ServiceCall) -> ServiceResponse: name = call.data.get(ATTR_NAME) timer_time = call.data.get(ATTR_TIME) extra_data = call.data.get(ATTR_EXTRA) + language = call.data.get(ATTR_LANGUAGE) - sentence, timer_info = decode_time_sentence(timer_time) + sentence, timer_info = decode_time_sentence(timer_time, language) _LOGGER.debug("Time decode: %s -> %s", sentence, timer_info) if entity_id is None and device_id is None: mimic_device = get_mimic_entity_id(self.hass) @@ -332,6 +336,7 @@ async def async_handle_set_timer(self, call: ServiceCall) -> ServiceResponse: timer_info=timer_info, name=name, extra_info=extra_info, + language=language, ) return {"timer_id": timer_id, "timer": timer, "response": response} @@ -341,8 +346,9 @@ async def async_handle_snooze_timer(self, call: ServiceCall) -> ServiceResponse: """Handle a set timer service call.""" timer_id = call.data.get(ATTR_TIMER_ID) timer_time = call.data.get(ATTR_TIME) + language = call.data.get(ATTR_LANGUAGE) - _, timer_info = decode_time_sentence(timer_time) + _, timer_info = decode_time_sentence(timer_time, language) if timer_info: t: VATimers = self.hass.data[DOMAIN][TIMERS] diff --git a/custom_components/view_assist/services.yaml b/custom_components/view_assist/services.yaml index e670ace..af0e713 100644 --- a/custom_components/view_assist/services.yaml +++ b/custom_components/view_assist/services.yaml @@ -76,6 +76,18 @@ set_timer: required: true selector: text: + language: + name: Language + description: The desired language to analyze the time sentence + default: en + selector: + select: + mode: dropdown + options: + - label: English + value: en + - label: German + value: de cancel_timer: name: "Cancel timer" description: "Cancel running timer" @@ -150,12 +162,22 @@ snooze_timer: text: time: name: "Snooze duration" - description: "The snooze duration in minutes" + description: "The snooze duration in a sentence (e.g. 5 minutes)" required: true selector: - number: - min: 1 - mode: box + text: + language: + name: Language + description: The desired language to analyze the snooze duration + default: en + selector: + select: + mode: dropdown + options: + - label: English + value: en + - label: German + value: de sound_alarm: name: "Sound alarm" description: "Sound alarm on a media device with an attempt to restore any already playing media" @@ -208,44 +230,149 @@ broadcast_event: required: true selector: text: -load_view: - name: "Load View" - description: "Install a view from the View Assist views directory or repository" +load_asset: + name: "Load asset" + description: "Install an asset from the View Assist directory or repository" fields: + asset_class: + name: "Asset class" + description: "The type of asset to load" + required: true + selector: + select: + options: + - "dashboard" + - "views" + - "blueprints" name: - name: "View name" - description: "The name of the view" + name: "Name" + description: "The name of the asset" required: true selector: text: download_from_repo: name: "Download from respository" description: "Download from the github repository, overwriting any existing copy" - required: false + required: true default: false selector: boolean: - community_view: - name: "Community view" - description: "If this should be downloaded from the community views folder" - required: false + backup_current_asset: + name: "Backup existing" + description: "Backup existing before updating" + required: true default: false selector: boolean: - backup_current_view: - name: "Backup current view" - description: "Backup yaml of view if it exists before updating" +save_asset: + name: "Save asset" + description: "Save asset to the View Assist directory" + fields: + asset_class: + name: "Asset class" + description: "The type of asset to load" + required: true + selector: + select: + options: + - "views" + - "blueprints" + name: + name: "Name" + description: "The name of the asset" + required: true + selector: + text: +toggle_menu: + name: Toggle menu + description: Show or hide the menu for a View Assist entity + fields: + entity_id: + name: "Entity ID" + description: "The entity ID of the View Assist device" + required: true + selector: + entity: + integration: view_assist + domain: + - sensor + show: + name: "Show" + description: "Whether to show (true) or hide (false) the menu" + required: false + default: true + selector: + boolean: + timeout: + name: "Timeout" + description: "Optional timeout in seconds to automatically close the menu (overrides configured timeout)" + required: false + selector: + number: + min: 1 + max: 300 + step: 1 +add_status_item: + name: Add status item + description: Add one or more items to the menu/status bar of a View Assist entity + fields: + entity_id: + name: "Entity ID" + description: "The entity ID of the View Assist device" + required: true + selector: + entity: + integration: view_assist + domain: + - sensor + status_item: + name: "Status Item(s)" + description: "The status item(s) to add. Can be a single item or a list of items. Each item can be a system item name like 'weather' or a custom action format like 'view:weather|cloud'" + required: true + selector: + object: + menu: + name: "Add to Menu Items" + description: "If true, adds the item(s) to the configured menu items list that appears when the menu is toggled. If false, adds to status icons (always visible)." required: false default: false selector: boolean: -save_view: - name: "Save View" - description: "Save a view to the View Assist views directory" + timeout: + name: "Timeout" + description: "Optional timeout in seconds after which the item(s) will be automatically removed" + required: false + selector: + number: + min: 1 + max: 3600 + step: 1 +remove_status_item: + name: Remove status item + description: Remove one or more items from the menu/status bar of a View Assist entity fields: - name: - name: "View name" - description: "The name of the view" + entity_id: + name: "Entity ID" + description: "The entity ID of the View Assist device" required: true selector: - text: + entity: + integration: view_assist + domain: + - sensor + status_item: + name: "Status Item(s)" + description: "The status item(s) to remove. Can be a single item or a list of items." + required: true + selector: + object: + from_menu_items: + name: "Remove from Menu Items" + description: "If true, removes the item(s) from the configured menu items list. If false, removes from status icons." + required: false + default: false + selector: + boolean: +update_versions: + name: "Update version info" + description: "Get the latest version info of the dashboard, views and blueprints from the github repo" diff --git a/custom_components/view_assist/tests/__init__.py b/custom_components/view_assist/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/view_assist/tests/test_timer_creation.py b/custom_components/view_assist/tests/test_timer_creation.py new file mode 100644 index 0000000..d9e1959 --- /dev/null +++ b/custom_components/view_assist/tests/test_timer_creation.py @@ -0,0 +1,71 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime, timedelta +from custom_components.view_assist.timers import VATimers, TimerClass, TimerInterval, TimerLanguage, TimerStatus, Timer + + +@pytest.fixture +def mock_hass(): + """Mock Home Assistant instance.""" + hass = MagicMock() + hass.config.time_zone = "UTC" + hass.bus.async_fire = MagicMock() + return hass + +@pytest.fixture +def mock_store(): + """Mock Home Assistant Store.""" + store = MagicMock() + store.async_save = AsyncMock() + store.async_load = AsyncMock(return_value=None) + return store + +@pytest.fixture +def va_timers(mock_hass, mock_store): + """Initialize VATimers with mocked dependencies.""" + timers = VATimers(mock_hass, MagicMock()) + timers.store.store = mock_store + return timers + +@pytest.mark.asyncio +async def test_timer_creation_and_fetching(va_timers, mock_hass, mock_store): + # Mock current time + now = datetime.now() + + # Mock timer interval + timer_interval = TimerInterval(minutes=5) + + # Add a timer + timer_id, timer_output, encoded_time = await va_timers.add_timer( + timer_class=TimerClass.TIMER, + device_or_entity_id="test.entity", + timer_info=timer_interval, + name="Test Timer", + pre_expire_warning=10, + start=False, + extra_info={}, + language=TimerLanguage.EN, + ) + + # Verify the timer was saved + assert timer_id is not None + assert timer_output["name"] == "Test Timer" + assert timer_output["timer_class"] == TimerClass.TIMER + assert timer_output["expiry"]["interval"]["minutes"] == 5 + + # Verify the timer is in the store + assert timer_id in va_timers.store.timers + + # Fetch the timer + fetched_timers = va_timers.get_timers(timer_id=timer_id) + assert len(fetched_timers) == 1 + assert fetched_timers[0]["name"] == "Test Timer" + assert fetched_timers[0]["language"] == TimerLanguage.EN + + # Verify the expiry time is correctly formatted + expected_time = fetched_timers[0]["expiry"]["time"] + assert isinstance(expected_time, str) + assert ":" in expected_time # Ensure it looks like a time string (e.g., "12:34:56") + + # Verify that no events were fired since the timer was not started + mock_hass.bus.async_fire.assert_not_called() diff --git a/custom_components/view_assist/tests/test_timers_all_languages.py b/custom_components/view_assist/tests/test_timers_all_languages.py new file mode 100644 index 0000000..6aae801 --- /dev/null +++ b/custom_components/view_assist/tests/test_timers_all_languages.py @@ -0,0 +1,121 @@ +import pytest +from datetime import datetime, timedelta +from custom_components.view_assist.timers import get_datetime_from_timer_time, TimerTime, TimerLanguage, \ + decode_time_sentence, TimerInterval +from custom_components.view_assist.translations.timers import timers_en, timers_de # Add more languages as needed + +# Map languages to their corresponding modules +LANGUAGE_MODULES = { + TimerLanguage.EN: timers_en, # Add more languages here + TimerLanguage.DE: timers_de, +} + +# Test sentences which should work in all languages +@pytest.mark.parametrize("language", LANGUAGE_MODULES.keys()) +@pytest.mark.parametrize( + "input_sentence_func,expected_output_func", + [ + # Test intervals + ( + lambda vars: f"5 {vars['MINUTE_PLURAL']}", + lambda vars: TimerInterval(minutes=5), + ), + ( + lambda vars: f"1 {vars['MINUTE_SINGULAR']}", + lambda vars: TimerInterval(minutes=1), + ), + ( + lambda vars: f"1 {vars['HOUR_SINGULAR']}", + lambda vars: TimerInterval(hours=1), + ), + ( + lambda vars: f"2 {vars['HOUR_PLURAL']}", + lambda vars: TimerInterval(hours=2), + ), + ( + lambda vars: f"1 {vars['DAY_SINGULAR']} 3 {vars['HOUR_PLURAL']}", + lambda vars: TimerInterval(days=1, hours=3), + ), + ], +) +def test_decode_time_sentence(input_sentence_func, expected_output_func, language): + # Load language-specific variables + language_vars = { + key: getattr(LANGUAGE_MODULES[language], key) + for key in dir(LANGUAGE_MODULES[language]) + if not key.startswith("__") + } + + # Generate input_sentence and expected_output using the language-specific variables + input_sentence = input_sentence_func(language_vars) + expected_output = expected_output_func(language_vars) + + # Run the test + _, result = decode_time_sentence(input_sentence, language) + assert result == expected_output + +# Test incorrect inputs +@pytest.mark.parametrize("language", LANGUAGE_MODULES.keys()) +def test_decode_time_sentence_invalid(language): + # Load language-specific variables + language_vars = { + key: getattr(LANGUAGE_MODULES[language], key) + for key in dir(LANGUAGE_MODULES[language]) + if not key.startswith("__") + } + + # Test invalid inputs + invalid_inputs = [ + # "random text", + "12345", + "", + "unknown time format", + ] + for sentence in invalid_inputs: + _, result = decode_time_sentence(sentence, language) + assert result is None + +# Test function to decode time sentences +@pytest.mark.parametrize("language", LANGUAGE_MODULES.keys()) +@pytest.mark.parametrize( + "timer_time_func,expected_datetime_func", + [ + # Test specific day (e.g., Monday) + ( + lambda vars: TimerTime(day=vars["WEEKDAYS"][0], hour=9, minute=0, second=0, meridiem="am"), # Monday + lambda vars: (datetime.now() + timedelta(days=(7 - datetime.now().weekday() + 0) % 7)).replace(hour=9, minute=0, second=0, microsecond=0), + ), + # Test "tomorrow" + ( + lambda vars: TimerTime(day=list(vars["SPECIAL_DAYS"].keys())[1], hour=8, minute=0, second=0, meridiem="am"), # Tomorrow + lambda vars: (datetime.now() + timedelta(days=1)).replace(hour=8, minute=0, second=0, microsecond=0), + ), + # Test "today" (rolls over to tomorrow if time has passed) + ( + lambda vars: TimerTime(day=list(vars["SPECIAL_DAYS"].keys())[0], hour=10, minute=30, second=0, meridiem="am"), # Today + lambda vars: datetime.now().replace(hour=10, minute=30, second=0, microsecond=0) + if datetime.now() < datetime.now().replace(hour=10, minute=30, second=0, microsecond=0) + else (datetime.now() + timedelta(days=1)).replace(hour=10, minute=30, second=0, microsecond=0), + ), + # Test "next Tuesday" + ( + lambda vars: TimerTime(day=f"{vars['REFERENCES']['next']} {vars['WEEKDAYS'][1]}", hour=7, minute=15, second=0, meridiem="am"), # Next Tuesday + lambda vars: (datetime.now() + timedelta(days=(1 - datetime.now().weekday() + 7) % 7)).replace(hour=7, minute=15, second=0, microsecond=0), + ), + ], +) +def test_get_datetime_from_timer_time_days(timer_time_func, expected_datetime_func, language): + # Load language-specific variables + language_vars = { + key: getattr(LANGUAGE_MODULES[language], key) + for key in dir(LANGUAGE_MODULES[language]) + if not key.startswith("__") + } + + # Generate timer_time and expected_datetime using the language-specific variables + timer_time = timer_time_func(language_vars) + expected_datetime = expected_datetime_func(language_vars) + + # Run the test + result = get_datetime_from_timer_time(timer_time, language, context_time=True) + assert result == expected_datetime, f"Expected {expected_datetime}, but got {result}" \ No newline at end of file diff --git a/custom_components/view_assist/tests/test_timers_de.py b/custom_components/view_assist/tests/test_timers_de.py new file mode 100644 index 0000000..7144f04 --- /dev/null +++ b/custom_components/view_assist/tests/test_timers_de.py @@ -0,0 +1,66 @@ +import pytest +import datetime as dt +from custom_components.view_assist.timers import decode_time_sentence, TimerTime, TimerInterval, encode_datetime_to_human, TimerLanguage + +@pytest.mark.parametrize( + "input_sentence,language,expected_output", + [ + # Testintervalle + # ("5 Minuten", TimerLanguage.DE, TimerInterval(minutes=5)), + # ("1 Minute", TimerLanguage.DE, TimerInterval(minutes=1)), + # ("1 Stunde", TimerLanguage.DE, TimerInterval(hours=1)), + # ("2 Stunden", TimerLanguage.DE, TimerInterval(hours=2)), + # ("1 Tag 3 Stunden", TimerLanguage.DE, TimerInterval(days=1, hours=3)), + # ("1 Sekunde", TimerLanguage.DE, TimerInterval(seconds=1)), + # ("30 Sekunden", TimerLanguage.DE, TimerInterval(seconds=30)), + # ("2 Tage 1 Stunde 20 Minuten", TimerLanguage.DE, TimerInterval(days=2, hours=1, minutes=20)), + # + # # Kurzschreibweise für Intervalle + # ("5m", TimerLanguage.DE, TimerInterval(minutes=5)), + # ("2h", TimerLanguage.DE, TimerInterval(hours=2)), + # ("1d 3h", TimerLanguage.DE, TimerInterval(days=1, hours=3)), + # ("30s", TimerLanguage.DE, TimerInterval(seconds=30)), + # ("2d 1h 20m", TimerLanguage.DE, TimerInterval(days=2, hours=1, minutes=20)), + # + # # Test spezifische Zeiten + # ("10:30 Uhr", TimerLanguage.DE, TimerTime(hour=10, minute=30)), + # ("viertel nach 3", TimerLanguage.DE, TimerTime(hour=3, minute=15)), + # ("halb 12", TimerLanguage.DE, TimerTime(hour=12, minute=30)), + # ("20 vor 4", TimerLanguage.DE, TimerTime(hour=3, minute=40)), + # ("Montag um 10:00 Uhr", TimerLanguage.DE, TimerTime(day="montag", hour=10, minute=0)), + # ("nächsten Dienstag um 10:00 Uhr", TimerLanguage.DE, TimerTime(day="nächsten dienstag", hour=10, minute=0)), + + # # Test Sonderfälle + # ("Mitternacht", TimerLanguage.DE, TimerTime(hour=0, minute=0)), + # ("Mittag", TimerLanguage.DE, TimerTime(hour=12, minute=0)), + # + # # Zusätzliche Beispiele aus den Regex-Kommentaren + # ("um 10:30 Uhr", TimerLanguage.DE, TimerTime(hour=10, minute=30)), + # ("um viertel nach 3", TimerLanguage.DE, TimerTime(hour=3, minute=15)), + # ("um halb 12", TimerLanguage.DE, TimerTime(hour=12, minute=30)), + # ("um 20 vor 4", TimerLanguage.DE, TimerTime(hour=3, minute=40)), + ("um Mitternacht", TimerLanguage.DE, TimerTime(hour=0, minute=0)), + ("am Mittag", TimerLanguage.DE, TimerTime(hour=12, minute=0)), + ], +) +def test_decode_time_sentence(input_sentence, language, expected_output): + _, result = decode_time_sentence(input_sentence, language) + assert result == expected_output + + +@pytest.mark.parametrize( + "timer_type,timer_dt,language,h24format,expected_output", + [ + # Test TimerInterval (zukünftige Intervalle) + ("TimerInterval", dt.datetime.now() + dt.timedelta(days=1, hours=2, minutes=30), TimerLanguage.DE, False, "1 Tag 2 Stunden und 30 Minuten"), + ("TimerInterval", dt.datetime.now() + dt.timedelta(hours=5, minutes=15), TimerLanguage.DE, False, "5 Stunden und 15 Minuten"), + ("TimerInterval", dt.datetime.now() + dt.timedelta(seconds=45), TimerLanguage.DE, False, "45 Sekunden"), + + # Test TimerTime (spezifische Zeiten im 12h- und 24h-Format) + ("TimerTime", dt.datetime(2023, 10, 5, 14, 30), TimerLanguage.DE, False, "Donnerstag um 2:30 PM"), + ("TimerTime", dt.datetime(2023, 10, 5, 14, 30), TimerLanguage.DE, True, "Donnerstag um 14:30"), + ], +) +def test_encode_datetime_to_human(timer_type, timer_dt, language, h24format, expected_output): + result = encode_datetime_to_human(timer_type, timer_dt, language, h24format) + assert result == expected_output \ No newline at end of file diff --git a/custom_components/view_assist/tests/test_timers_en.py b/custom_components/view_assist/tests/test_timers_en.py new file mode 100644 index 0000000..1be8d4c --- /dev/null +++ b/custom_components/view_assist/tests/test_timers_en.py @@ -0,0 +1,66 @@ +import pytest +import datetime as dt +from custom_components.view_assist.timers import decode_time_sentence, TimerTime, TimerInterval, encode_datetime_to_human, TimerLanguage + +@pytest.mark.parametrize( + "input_sentence,language,expected_output", + [ + # Test intervals + ("5 minutes", TimerLanguage.EN, TimerInterval(minutes=5)), + ("1 minute", TimerLanguage.EN, TimerInterval(minutes=1)), + ("1 hour", TimerLanguage.EN, TimerInterval(hours=1)), + ("2 hours", TimerLanguage.EN, TimerInterval(hours=2)), + ("1 day 3 hours", TimerLanguage.EN, TimerInterval(days=1, hours=3)), + ("1 second", TimerLanguage.EN, TimerInterval(seconds=1)), + ("30 seconds", TimerLanguage.EN, TimerInterval(seconds=30)), + ("2 days 1 hour 20 minutes", TimerLanguage.EN, TimerInterval(days=2, hours=1, minutes=20)), + + # Test shorthand intervals + ("5m", TimerLanguage.EN, TimerInterval(minutes=5)), + ("2h", TimerLanguage.EN, TimerInterval(hours=2)), + ("1d 3h", TimerLanguage.EN, TimerInterval(days=1, hours=3)), + ("30s", TimerLanguage.EN, TimerInterval(seconds=30)), + ("2d 1h 20m", TimerLanguage.EN, TimerInterval(days=2, hours=1, minutes=20)), + + # Test specific times + ("10:30 AM", TimerLanguage.EN, TimerTime(hour=10, minute=30, meridiem="am")), + ("quarter past 3", TimerLanguage.EN, TimerTime(hour=3, minute=15)), + ("half past 12", TimerLanguage.EN, TimerTime(hour=12, minute=30)), + ("20 to 4 PM", TimerLanguage.EN, TimerTime(hour=3, minute=40, meridiem="pm")), + ("Monday at 10:00 AM", TimerLanguage.EN, TimerTime(day="monday", hour=10, minute=0, meridiem="am")), + ("next Tuesday at 10:00 AM", TimerLanguage.EN, TimerTime(day="next tuesday", hour=10, minute=0, meridiem="am")), + + # Test special cases + ("midnight", TimerLanguage.EN, TimerTime(hour=0, minute=0, meridiem="am")), + ("noon", TimerLanguage.EN, TimerTime(hour=12, minute=0, meridiem="pm")), + + # Additional examples from regex comments + ("at 10:30 AM", TimerLanguage.EN, TimerTime(hour=10, minute=30, meridiem="am")), + ("at quarter past 3", TimerLanguage.EN, TimerTime(hour=3, minute=15)), + ("at half past 12", TimerLanguage.EN, TimerTime(hour=12, minute=30)), + ("at 20 to 4 PM", TimerLanguage.EN, TimerTime(hour=3, minute=40, meridiem="pm")), + ("at midnight", TimerLanguage.EN, TimerTime(hour=0, minute=0, meridiem="am")), + ("at noon", TimerLanguage.EN, TimerTime(hour=12, minute=0, meridiem="pm")), + ], +) +def test_decode_time_sentence(input_sentence, language, expected_output): + _, result = decode_time_sentence(input_sentence, language) + assert result == expected_output + + +@pytest.mark.parametrize( + "timer_type,timer_dt,language,h24format,expected_output", + [ + # Test TimerInterval (future intervals) + ("TimerInterval", dt.datetime.now() + dt.timedelta(days=1, hours=2, minutes=30), TimerLanguage.EN, False, "1 day 2 hours and 30 minutes"), + ("TimerInterval", dt.datetime.now() + dt.timedelta(hours=5, minutes=15), TimerLanguage.EN, False, "5 hours and 15 minutes"), + ("TimerInterval", dt.datetime.now() + dt.timedelta(seconds=45), TimerLanguage.EN, False, "45 seconds"), + + # Test TimerTime (specific times in 12h and 24h formats) + ("TimerTime", dt.datetime(2023, 10, 5, 14, 30), TimerLanguage.EN, False, "Thursday at 2:30 PM"), + ("TimerTime", dt.datetime(2023, 10, 5, 14, 30), TimerLanguage.EN, True, "Thursday at 14:30"), + ], +) +def test_encode_datetime_to_human(timer_type, timer_dt, language, h24format, expected_output): + result = encode_datetime_to_human(timer_type, timer_dt, language, h24format) + assert result == expected_output \ No newline at end of file diff --git a/custom_components/view_assist/timers.py b/custom_components/view_assist/timers.py index 2dd605a..a6217d8 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -16,61 +16,139 @@ import voluptuous as vol import wordtodigits +from homeassistant.components.conversation import ATTR_LANGUAGE from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, valid_entity_id +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_NAME, ATTR_TIME +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + valid_entity_id, +) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.util import ulid as ulid_util -from .const import DOMAIN -from .helpers import get_entity_id_from_conversation_device_id +from .const import ( + ATTR_EXTRA, + ATTR_INCLUDE_EXPIRED, + ATTR_REMOVE_ALL, + ATTR_TIMER_ID, + ATTR_TYPE, + DOMAIN, +) +from .helpers import get_entity_id_from_conversation_device_id, get_mimic_entity_id + +from .translations.timers import timers_en +from .translations.timers import timers_de + +from .translations.timers import timers_en +from .translations.timers import timers_de _LOGGER = logging.getLogger(__name__) +SET_TIMER_SERVICE_SCHEMA = vol.Schema( + { + vol.Exclusive(ATTR_ENTITY_ID, "target"): cv.entity_id, + vol.Exclusive(ATTR_DEVICE_ID, "target"): vol.Any(cv.string, None), + vol.Required(ATTR_TYPE): str, + vol.Optional(ATTR_NAME): str, + vol.Required(ATTR_TIME): str, + vol.Optional(ATTR_EXTRA): vol.Schema({}, extra=vol.ALLOW_EXTRA), + vol.Required(ATTR_LANGUAGE): str, + } +) + + +CANCEL_TIMER_SERVICE_SCHEMA = vol.Schema( + { + vol.Exclusive(ATTR_TIMER_ID, "target"): str, + vol.Exclusive(ATTR_ENTITY_ID, "target"): cv.entity_id, + vol.Exclusive(ATTR_DEVICE_ID, "target"): vol.Any(cv.string, None), + vol.Exclusive(ATTR_REMOVE_ALL, "target"): bool, + } +) + +SNOOZE_TIMER_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_TIMER_ID): str, + vol.Required(ATTR_TIME): str, + vol.Required(ATTR_LANGUAGE): str, + } +) + +GET_TIMERS_SERVICE_SCHEMA = vol.Schema( + { + vol.Exclusive(ATTR_TIMER_ID, "target"): str, + vol.Exclusive(ATTR_ENTITY_ID, "target"): cv.entity_id, + vol.Exclusive(ATTR_DEVICE_ID, "target"): vol.Any(cv.string, None), + vol.Optional(ATTR_NAME): str, + vol.Optional(ATTR_INCLUDE_EXPIRED, default=False): bool, + } +) + # Event name prefixes VA_EVENT_PREFIX = "va_timer_{}" VA_COMMAND_EVENT_PREFIX = "va_timer_command_{}" TIMERS = "timers" TIMERS_STORE_NAME = f"{DOMAIN}.{TIMERS}" -WEEKDAYS = [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", -] -SPECIAL_DAYS = { - "today": 0, - "tomorrow": 1, +# Translation Imports +WEEKDAYS = { + "en": timers_en.WEEKDAYS, # Add more languages here + "de": timers_de.WEEKDAYS, } - SPECIAL_HOURS = { - "midnight": 0, - "noon": 12, + "en": timers_en.SPECIAL_HOURS, # Add more languages here + "de": timers_de.SPECIAL_HOURS, } + HOUR_FRACTIONS = { - "1/4": 15, - "quarter": 15, - "1/2": 30, - "half": 30, - "3/4": 45, - "three quarters": 45, + "en": timers_en.HOUR_FRACTIONS, # Add more languages here + "de": timers_de.HOUR_FRACTIONS, } -AMPM = ["am", "pm"] + SPECIAL_AMPM = { - "morning": "am", - "tonight": "pm", - "afternoon": "pm", - "evening": "pm", + "en": timers_en.SPECIAL_AMPM, # Add more languages here + "de": timers_de.SPECIAL_AMPM, } DIRECT_REPLACE = { - "a day": "1 day", - "an hour": "1 hour", + "en": timers_en.DIRECT_REPLACE, # Add more languages here + "de": timers_de.DIRECT_REPLACE, +} + +REFERENCES = { + "en": timers_en.REFERENCES, # Add more languages here + "de": timers_de.REFERENCES, +} + +SINGULARS = { + "en": timers_en.SINGULARS, # Add more languages here + "de": timers_de.SINGULARS, +} + +PLURAL_MAPPING = { + "en": timers_en.PLURAL_MAPPING, # Add more languages here + "de": timers_de.PLURAL_MAPPING, +} + +REGEXES = { + "en": timers_en.REGEXES, # Add more languages here + "de": timers_de.REGEXES, +} + +REGEX_DAYS = { + "en": timers_en.REGEX_DAYS, # Add more languages here + "de": timers_de.REGEX_DAYS, +} + +INTERVAL_DETECTION_REGEX = { + "en": timers_en.INTERVAL_DETECTION_REGEX, # Add more languages here + "de": timers_de.INTERVAL_DETECTION_REGEX, } @@ -122,6 +200,12 @@ class TimerEvent(StrEnum): SNOOZED = "snoozed" CANCELLED = "cancelled" +class TimerLanguage(StrEnum): + """Language enums.""" + + EN = "en" + DE = "de" + @dataclass class Timer: @@ -138,141 +222,19 @@ class Timer: updated_at: int = 0 status: TimerStatus = field(default_factory=TimerStatus.INACTIVE) extra_info: dict[str, Any] | None = None + language: TimerLanguage = field(default_factory=TimerLanguage.EN) -REGEX_DAYS = ( - r"(?i)\b(" - + ( - "|".join(WEEKDAYS + list(SPECIAL_DAYS)) - + "|" - + "|".join(f"Next {weekday}" for weekday in WEEKDAYS) - ) - + ")" -) - -# Find a time in the string and split into day, hours, mins and secs -# 10:15 AM -# 1600 -# 15:24 -# Monday at 10:00 AM -REGEX_TIME = ( - r"(?i)\b(" - + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) - + "|" - + ("|".join([f"next {day}" for day in WEEKDAYS])) - + r")?[ ]?(?:at)?[ ]?([01]?[0-9]|2[0-3]):?([0-5][0-9])(?::([0-9][0-9]))?[ ]?(?:this)?[ ]?(" - + "|".join(AMPM + list(SPECIAL_AMPM)) - + r")?\b" -) -REGEX_ALT_TIME = ( - r"(?i)\b(" - + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) - + "|" - + ("|".join([f"next {day}" for day in WEEKDAYS])) - + r")?[ ]?(?:at)?[ ]?" - + r"(" - + "|".join(list(SPECIAL_HOURS)) - + r")()()()" -) - -# Allow natural language times -# quarter past 11 -# 20 past five -# half past 12 -# half past twelve -# twenty to four -# twenty to four AM -# twenty to four PM -# 20 to 4:00 PM -REGEX_SUPER_TIME = ( - r"(?i)\b(?P" - + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) - + r")?[ ]?(?:at)?[ ]?(\d+|" - + "|".join(list(HOUR_FRACTIONS)) - + r")\s(to|past)\s(\d+|" - + ("|".join(SPECIAL_HOURS)) - + r")(?::\d+)?[ ]?(" - + "|".join(AMPM + list(SPECIAL_AMPM)) - + r")?\b" -) - - -# Find an interval in human readbale form and decode into days, hours, minutes, seconds. -# 5 minutes 30 seconds -# 5 minutes -# 2 hours 30 minutes -# 30 seconds -# 2 days 1 hour 20 minutes -# 1 day 20 minutes -REGEX_INTERVAL = ( - r"(?i)\b" # noqa: ISC003 - + r"(?:(\d+) days?)?" - + r"[ ]?(?:and)?[ ]?(?:([01]?[0-9]|2[0-3]]) hours?)?" - + r"[ ]?(?:and)?[ ]?(?:([0-9]?[0-9]?[0-9]) minutes?)?" - + r"[ ]?(?:and)?[ ]?(?:(\d+) seconds?)?\b" -) - - -# All natural language intervals -# 2 1/2 hours -# 2 and a half hours -# two and a half hours -# one and a quarter hours -# 1 1/2 minutes -# three quarters of an hour -# 3/4 of an hour -# half an hour -# 1/2 an hour -# quarter of an hour -# 1/4 of an hour -REGEX_SUPER_HOUR_INTERVAL = ( - r"()(\d+)?" # noqa: ISC003 - + r"[ ]?(?:and a)?[ ]?(" - + "|".join(HOUR_FRACTIONS) - + r")[ ](?:an|of an)?[ ]?(?:hours?)()" -) - -REGEX_SUPER_MIN_INTERVAL = ( - r"()()(\d+)?" # noqa: ISC003 - + r"[ ]?(?:and a)?[ ]?(" - + "|".join(HOUR_FRACTIONS) - + r")[ ](?:an|of an)?[ ]?(?:minutes?)" -) - -REGEX_ALT_SUPER_INTERVAL = ( - r"()" # noqa: ISC003 - + r"(?:([01]?[0-9]|2[0-3]]|an) hours?)?" - + r"(?:[ ]?(?:and a?)?[ ]?)?" - + r"(" - + "|".join(HOUR_FRACTIONS) - + r")?()" -) - -REGEXES = { - "interval": { - "base": REGEX_INTERVAL, - "super_hour": REGEX_SUPER_HOUR_INTERVAL, - "super_min": REGEX_SUPER_MIN_INTERVAL, - "alt_super": REGEX_ALT_SUPER_INTERVAL, - }, - "time": { - "base": REGEX_TIME, - "alt_base": REGEX_ALT_TIME, - "super": REGEX_SUPER_TIME, - }, -} - +def _is_interval(sentence, language: TimerLanguage) -> bool: + return re.search(INTERVAL_DETECTION_REGEX[language], sentence) is not None -def _is_interval(sentence) -> bool: - return re.search(r"\bdays?|hours?|minutes?|seconds?", sentence) is not None - -def _is_super(sentence: str, is_interval: bool) -> bool: +def _is_super(sentence: str, is_interval: bool, language: TimerLanguage) -> bool: if is_interval: - return re.search(r"\b" + "|".join(HOUR_FRACTIONS), sentence) is not None + return re.search(r"\b" + "|".join(HOUR_FRACTIONS[language]), sentence) is not None return ( re.search( - r"\b(?:" + "|".join(list(HOUR_FRACTIONS) + list(SPECIAL_HOURS)) + ")", + r"\b(?:" + "|".join(list(HOUR_FRACTIONS[language]) + list(SPECIAL_HOURS[language])) + ")", sentence, ) is not None @@ -300,27 +262,27 @@ def _format_time_numbers(time_list: list[str | int]) -> list[int]: return time_list -def decode_time_sentence(sentence: str): +def decode_time_sentence(sentence: str, language: TimerLanguage): """Decode a time or interval sentence. Returns a TimerTime or TimerInterval object """ decoded = None - is_interval = _is_interval(sentence) - is_super = _is_super(sentence, is_interval) + is_interval = _is_interval(sentence, language) + is_super = _is_super(sentence, is_interval, language) _LOGGER.debug("%s is of type %s", sentence, "interval" if is_interval else "time") # Convert all word numbers to ints - if not sentence.startswith("three quarters"): + if not sentence.startswith("three quarters"): # TODO Figure out why this is needed sentence = wordtodigits.convert(sentence) # Direct replace parts of the string to help decoding - for repl_item, repl_str in DIRECT_REPLACE.items(): + for repl_item, repl_str in DIRECT_REPLACE[language].items(): if repl_item in sentence: sentence = sentence.replace(repl_item, repl_str) - for idx, regex in REGEXES["interval" if is_interval else "time"].items(): + for idx, regex in REGEXES[language]["interval" if is_interval else "time"].items(): if is_super and idx == "base": continue match = re.match(regex, sentence) @@ -332,22 +294,22 @@ def decode_time_sentence(sentence: str): # if day is blank look if we need to populate if not is_interval and not decoded[0]: - if day_text := re.findall(REGEX_DAYS, sentence): + if day_text := re.findall(REGEX_DAYS[language], sentence): decoded[0] = day_text[0].lower() # If has special hours, set meridiem - if decoded[1] in list(SPECIAL_HOURS): - decoded[4] = "am" if SPECIAL_HOURS[decoded[1]] < 12 else "pm" + if decoded[1] in list(SPECIAL_HOURS[language]): + decoded[4] = "am" if SPECIAL_HOURS[language][decoded[1]] < 12 else "pm" # now iterate and replace text numbers with numbers for i, v in enumerate(decoded): if i > 0: with contextlib.suppress(KeyError): - decoded[i] = SPECIAL_HOURS[v] + decoded[i] = SPECIAL_HOURS[language][v.lower()] with contextlib.suppress(KeyError): - decoded[i] = HOUR_FRACTIONS[v] + decoded[i] = HOUR_FRACTIONS[language][v.lower()] with contextlib.suppress(KeyError): - decoded[i] = SPECIAL_AMPM[v] + decoded[i] = SPECIAL_AMPM[language][v.lower()] # Make time objects if is_interval: @@ -363,13 +325,13 @@ def decode_time_sentence(sentence: str): # Handle super time (which is in different format) return sentence, TimerTime( day=decoded[0], - hour=decoded[3] - 1 if decoded[2] == "to" else decoded[3], - minute=60 - decoded[1] if decoded[2] == "to" else decoded[1], + hour=decoded[3] - 1 if decoded[2] == REFERENCES[language]["to"] else decoded[3], + minute=60 - decoded[1] if decoded[2] == REFERENCES[language]["to"] else decoded[1], second=0, meridiem=decoded[4], ) _LOGGER.warning( - "Time senstence decoder - Unable to decode: %s -> %s", sentence, None + "Time sentence decoder - Unable to decode: %s -> %s", sentence, None ) return sentence, None @@ -386,30 +348,30 @@ def get_datetime_from_timer_interval(interval: TimerInterval) -> dt.datetime: def get_datetime_from_timer_time( - set_time: TimerTime, context_time: bool = True + set_time: TimerTime, language: TimerLanguage, context_time: bool = True ) -> dt.datetime: """Return datetime from TimerTime object.""" - def _calc_days_add(day: str, dt_now: dt.datetime) -> int: + def _calc_days_add(day: str, dt_now: dt.datetime, language: TimerLanguage) -> int: """Get number of days to add for required weekday from now.""" has_next = False # Deal with the likes of next wednesday - if "next" in day: + if REFERENCES[language]["next"] in day: has_next = True - day = day.replace("next", "").strip() + day = day.replace(REFERENCES[language]["next"], "").strip() - if day in WEEKDAYS: + if day in WEEKDAYS[language]: # monday is weekday 0 current_weekday = dt_now.weekday() - set_weekday = WEEKDAYS.index(day) + set_weekday = WEEKDAYS[language].index(day) # Check for 'next' prefix to day or if day less than today (assume next week) if set_weekday < current_weekday or has_next: return (7 - current_weekday) + set_weekday return set_weekday - current_weekday - if day == "tomorrow": # or "tomorrow" in sentence: + if day == REFERENCES[language]["tomorrow"]: # or "tomorrow" in sentence: return 1 return 0 @@ -428,7 +390,7 @@ def _calc_days_add(day: str, dt_now: dt.datetime) -> int: ) # Set the timer_dt day - if add_days := _calc_days_add(set_time.day, timer_dt): + if add_days := _calc_days_add(set_time.day, timer_dt, language): timer_dt = timer_dt + dt.timedelta(days=add_days) # Apply fixed context @@ -447,7 +409,7 @@ def _calc_days_add(day: str, dt_now: dt.datetime) -> int: return timer_dt -def get_named_day(timer_dt: dt.datetime, dt_now: dt.datetime) -> str: +def get_named_day(timer_dt: dt.datetime, dt_now: dt.datetime, language: TimerLanguage) -> str: """Return a named day or date.""" days_diff = timer_dt.day - dt_now.day if days_diff == 0: @@ -455,7 +417,7 @@ def get_named_day(timer_dt: dt.datetime, dt_now: dt.datetime) -> str: if days_diff == 1: return "Tomorrow" if days_diff < 7: - return f"{WEEKDAYS[timer_dt.weekday()]}".title() + return f"{WEEKDAYS[language][timer_dt.weekday()]}".title() return timer_dt.strftime("%-d %B") @@ -475,13 +437,17 @@ def get_formatted_time(timer_dt: dt.datetime, h24format: bool = False) -> str: def encode_datetime_to_human( timer_type: str, timer_dt: dt.datetime, + language: TimerLanguage, h24format: bool = False, ) -> str: """Encode datetime into human speech sentence.""" def declension(term: str, qty: int) -> str: if qty > 1: - return f"{term}s" + if term in PLURAL_MAPPING[language]: + return PLURAL_MAPPING[language][term] + else: + return f"{term}s" return term dt_now = dt.datetime.now() @@ -495,19 +461,19 @@ def declension(term: str, qty: int) -> str: response = [] if days: - response.append(f"{days} {declension('day', days)}") + response.append(f"{days} {declension(SINGULARS[language]['day'], days)}") if hours: - response.append(f"{hours} {declension('hour', hours)}") + response.append(f"{hours} {declension(SINGULARS[language]['hour'], hours)}") if minutes: - response.append(f"{minutes} {declension('minute', minutes)}") + response.append(f"{minutes} {declension(SINGULARS[language]['minute'], minutes)}") if seconds: - response.append(f"{seconds} {declension('second', seconds)}") + response.append(f"{seconds} {declension(SINGULARS[language]['second'], seconds)}") # Now create sentence duration: str = "" for i, entry in enumerate(response): if i == len(response) - 1 and duration: - duration += " and " + entry + duration += f" {REFERENCES[language]['and']} " + entry else: duration += " " + entry @@ -515,9 +481,9 @@ def declension(term: str, qty: int) -> str: if timer_type == "TimerTime": # do date bit - today, tomorrow, day of week if in next 7 days, date - output_date = get_named_day(timer_dt, dt_now) + output_date = get_named_day(timer_dt, dt_now, language) output_time = get_formatted_time(timer_dt, h24format) - return f"{output_date} at {output_time}" + return f"{output_date} {REFERENCES[language]['at']} {output_time}" return timer_dt @@ -622,6 +588,128 @@ def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: self.store = VATimerStore(hass) self.timer_tasks: dict[str, asyncio.Task] = {} + # Init services + self.hass.services.async_register( + DOMAIN, + "set_timer", + self._async_handle_set_timer, + schema=SET_TIMER_SERVICE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + + self.hass.services.async_register( + DOMAIN, + "snooze_timer", + self._async_handle_snooze_timer, + schema=SNOOZE_TIMER_SERVICE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + + self.hass.services.async_register( + DOMAIN, + "cancel_timer", + self._async_handle_cancel_timer, + schema=CANCEL_TIMER_SERVICE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + + self.hass.services.async_register( + DOMAIN, + "get_timers", + self._async_handle_get_timers, + schema=GET_TIMERS_SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + async def _async_handle_set_timer(self, call: ServiceCall) -> ServiceResponse: + """Handle a set timer service call.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + device_id = call.data.get(ATTR_DEVICE_ID) + timer_type = call.data.get(ATTR_TYPE) + name = call.data.get(ATTR_NAME) + timer_time = call.data.get(ATTR_TIME) + extra_data = call.data.get(ATTR_EXTRA) + language = call.data.get(ATTR_LANGUAGE) + + sentence, timer_info = decode_time_sentence(timer_time, language) + _LOGGER.debug("Time decode: %s -> %s", sentence, timer_info) + if entity_id is None and device_id is None: + mimic_device = get_mimic_entity_id(self.hass) + if mimic_device: + entity_id = mimic_device + _LOGGER.warning( + "Using the set mimic entity %s to set timer as no entity or device id provided to the set timer service", + mimic_device, + ) + else: + raise vol.InInvalid("entity_id or device_id is required") + + extra_info = {"sentence": sentence} + if extra_data: + extra_info.update(extra_data) + + if timer_info: + timer_id, timer, response = await self.add_timer( + timer_class=timer_type, + device_or_entity_id=entity_id if entity_id else device_id, + timer_info=timer_info, + name=name, + extra_info=extra_info, + language=language, + ) + + return {"timer_id": timer_id, "timer": timer, "response": response} + return {"error": "unable to decode time or interval information"} + + async def _async_handle_snooze_timer(self, call: ServiceCall) -> ServiceResponse: + """Handle a set timer service call.""" + timer_id = call.data.get(ATTR_TIMER_ID) + timer_time = call.data.get(ATTR_TIME) + language = call.data.get(ATTR_LANGUAGE) + + _, timer_info = decode_time_sentence(timer_time, language) + + if timer_info: + timer_id, timer, response = await self.snooze_timer( + timer_id, + timer_info, + ) + + return {"timer_id": timer_id, "timer": timer, "response": response} + return {"error": "unable to decode time or interval information"} + + async def _async_handle_cancel_timer(self, call: ServiceCall) -> ServiceResponse: + """Handle a cancel timer service call.""" + timer_id = call.data.get(ATTR_TIMER_ID) + entity_id = call.data.get(ATTR_ENTITY_ID) + device_id = call.data.get(ATTR_DEVICE_ID) + cancel_all = call.data.get(ATTR_REMOVE_ALL, False) + + if any([timer_id, entity_id, device_id, cancel_all]): + result = await self.cancel_timer( + timer_id=timer_id, + device_or_entity_id=entity_id if entity_id else device_id, + cancel_all=cancel_all, + ) + return {"result": result} + return {"error": "no attribute supplied"} + + async def _async_handle_get_timers(self, call: ServiceCall) -> ServiceResponse: + """Handle a cancel timer service call.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + device_id = call.data.get(ATTR_DEVICE_ID) + timer_id = call.data.get(ATTR_TIMER_ID) + name = call.data.get(ATTR_NAME) + include_expired = call.data.get(ATTR_INCLUDE_EXPIRED, False) + + result = self.get_timers( + timer_id=timer_id, + device_or_entity_id=entity_id if entity_id else device_id, + name=name, + include_expired=include_expired, + ) + return {"result": result} + async def load(self): """Load data store.""" await self.store.load() @@ -691,6 +779,7 @@ async def add_timer( pre_expire_warning: int = 10, start: bool = True, extra_info: dict[str, Any] | None = None, + language: TimerLanguage = TimerLanguage.EN, ) -> tuple: """Add timer to store.""" @@ -704,7 +793,7 @@ async def add_timer( # calculate expiry time from TimerTime or TimerInterval timer_info_class = timer_info.__class__.__name__ if timer_info_class == "TimerTime": - expiry = get_datetime_from_timer_time(timer_info) + expiry = get_datetime_from_timer_time(timer_info, language) elif timer_info_class == "TimerInterval": expiry = get_datetime_from_timer_interval(timer_info) else: @@ -729,6 +818,7 @@ async def add_timer( updated_at=time_now_unix, status=TimerStatus.INACTIVE, extra_info=extra_info, + language=language, ) self.store.timers[timer_id] = timer @@ -737,7 +827,7 @@ async def add_timer( if start: await self.start_timer(timer_id, timer) - encoded_time = encode_datetime_to_human(timer_info_class, expiry) + encoded_time = encode_datetime_to_human(timer_info_class, expiry, language) return timer_id, self.format_timer_output(timer), encoded_time return None, None, "already exists" @@ -811,9 +901,9 @@ async def snooze_timer(self, timer_id: str, duration: TimerInterval): await self.start_timer(timer_id, timer) await self._fire_event(timer_id, TimerEvent.SNOOZED) - encoded_duration = encode_datetime_to_human("TimerInterval", expiry) + encoded_duration = encode_datetime_to_human("TimerInterval", expiry, timer.language) - return timer_id, timer.to_dict(), encoded_duration + return timer_id, self.format_timer_output(timer), encoded_duration return None, None, "unable to snooze" async def cancel_timer( @@ -922,11 +1012,12 @@ def expires_in_interval(expires_at: int) -> dict[str, Any]: "seconds": int(seconds), } - def dynamic_remaining(timer_type: TimerClass, expires_at: int) -> str: + def dynamic_remaining(timer_type: TimerClass, expires_at: int, language: TimerLanguage) -> str: """Generate dynamic name.""" return encode_datetime_to_human( timer_type, dt.datetime.fromtimestamp(expires_at), + language ) dt_now = dt.datetime.now(self.tz) @@ -945,14 +1036,15 @@ def dynamic_remaining(timer_type: TimerClass, expires_at: int) -> str: "expiry": { "seconds": math.ceil(expires_in_seconds(timer.expires_at)), "interval": expires_in_interval(timer.expires_at), - "day": get_named_day(dt_expiry, dt_now), + "day": get_named_day(dt_expiry, dt_now, timer.language), "time": get_formatted_time(dt_expiry), - "text": dynamic_remaining(timer.timer_type, timer.expires_at), + "text": dynamic_remaining(timer.timer_type, timer.expires_at, timer.language), }, "created_at": dt.datetime.fromtimestamp(timer.created_at, self.tz), "updated_at": dt.datetime.fromtimestamp(timer.updated_at, self.tz), "status": timer.status, "extra_info": timer.extra_info, + "language": timer.language, } async def _wait_for_timer( diff --git a/custom_components/view_assist/translations/__init__.py b/custom_components/view_assist/translations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/view_assist/translations/en.json b/custom_components/view_assist/translations/en.json index bebae42..0d3ae40 100644 --- a/custom_components/view_assist/translations/en.json +++ b/custom_components/view_assist/translations/en.json @@ -14,7 +14,7 @@ "step": { "master_config": { "title": "Master Configuration", - "description": "This adds a master configuration instance of View Assist\n\nIt must be added before you can setup any View Assist device instances if a new install or any more View Assist devices instances if an existing install\n\nPlease restart Home Assistant once you have added this. You may also need to refresh your Home Assistant devices to enable the full functionality" + "description": "This adds a master configuration instance of View Assist\n\nIt must be added before you can setup any View Assist device instances if a new install or any more View Assist devices instances if an existing install\n\nYou may need to refresh any existing View Assist devices to enable the full functionality" }, "options": { "title": "Configure a View Assist device", @@ -41,21 +41,19 @@ }, "options": { "step": { - "master_config": { - "title": "Master Configuration", - "description": "Master configuration options will be available here in future versions" - }, "init": { - "title": "Reconfigure device", + "title": "Configuration", "description": "Select which options to amend", "menu_options": { + "integration_options": "Integration Options", "main_config": "Core Device Configuration", "dashboard_options": "Dashboard Options", - "default_options": "Default Options" + "default_options": "Default Options", + "developer_options": "Developer Options" } }, "main_config": { - "title": "Core Device Configuration", + "title": "{name} Core Device Configuration", "description": "", "data": { "name": "Satellite Name", @@ -77,63 +75,108 @@ } }, "dashboard_options": { - "title": "Dashboard Options", - "description": "", + "title": "{name} Dashboard Options", + "description": "{description}", "data": { "dashboard": "Dashboard", "home": "Home screen", "music": "Music view", "intent": "Intent view", - "background": "Default background", - "rotate_background": "Enable image rotation", - "rotate_background_source": "Image source", - "rotate_background_path": "Image path", - "rotate_background_linked_entity": "Linked entity", - "rotate_background_interval": "Rotation interval", - "assist_prompt": "Assist prompt", - "status_icons_size": "Status icon size", - "font_style": "Font style", - "status_icons": "Launch icons", - "use_24_hour_time": "Use 24h time", - "hide_sidebar": "Hide sidemenu", - "hide_header": "Hide header bar" + "list_view": "List view" }, "data_description": { "dashboard": "The base dashboard for View Assist (do not include trailing slash)", "home": "The screen to return to after timeout", "music": "The view to return to when in music mode", "intent": "The view to display for default HA actions for displaying those entities", - "background": "The default background image url", - "rotate_background_path": "Load images from in local mode, save images to in download mode, ignored in linked mode. A path under config/view_assist", - "rotate_background_linked_entity": "View Assist entity to link the background to", - "rotate_background_interval": "Interval in minutes to rotate the background", - "assist_prompt": "The Assist notification prompt style to use for wake word detection and intent processing", - "status_icons_size": "Size of the icons in the status icon display", - "font_style": "The default font to use for this satellite device. Font name must match perfectly and be available", - "status_icons": "Advanced option! List of custom launch icons to set on start up. Do not change this if you do not know what you are doing", - "use_24_hour_time": "Sets clock display to 24 hour time when enabled", - "hide_sidebar": "Hide the sidemenu on the display via View Assist", - "hide_header": "Hide the header on the display via View Assist" + "list_view": "The view to display when updating a list" + }, + "sections": { + "background_settings": { + "name": "Background Settings", + "description": "Options for the background image", + "data": { + "background_mode": "Background image source", + "background": "Default background", + "rotate_background_path": "Image path", + "rotate_background_linked_entity": "Linked entity", + "rotate_background_interval": "Rotation interval" + }, + "data_description": { + "background": "The default background image url", + "rotate_background_path": "Load images from in local mode, save images to in download mode, ignored in linked mode. A path under config/view_assist", + "rotate_background_linked_entity": "View Assist entity to link the background to", + "rotate_background_interval": "Interval in minutes to rotate the background" + } + }, + "display_settings": { + "name": "Display Settings", + "description": "Options for the display device", + "data": { + "assist_prompt": "Assist prompt", + "status_icons_size": "Status icon size", + "font_style": "Font style", + "status_icons": "Launch icons", + "menu_config": "Menu configuration", + "menu_items": "Menu items", + "menu_timeout": "Menu timeout", + "time_format": "Time format", + "screen_mode": "Show/hide header and sidebar" + }, + "data_description": { + "assist_prompt": "The Assist notification prompt style to use for wake word detection and intent processing", + "status_icons_size": "Size of the icons in the status icon display", + "font_style": "The default font to use for this satellite device. Font name must match perfectly and be available", + "status_icons": "Advanced option! List of custom launch icons to set on start up. Do not change this if you do not know what you are doing", + "menu_config": "Configure the menu behavior", + "menu_items": "List of items to show in the menu when activated", + "menu_timeout": "Time in seconds before menu automatically closes (0 to disable timeout)", + "time_format": "Sets clock display time format", + "screen_mode": "Show or hide the header and sidebar" + } + } } }, "default_options": { - "title": "Default Options", - "description": "", + "title": "{name} Default Options", + "description": "{description}", "data": { "weather_entity": "Weather entity to use for conditons display", "mic_type": "The integration handling microphone input", "mode": "Default Mode", "view_timeout": "View Timeout", - "do_not_disturb": "Do not disturb default on", + "do_not_disturb": "Enable do not disturb at startup", "use_announce": "Disable announce on this device", - "micunmute": "Unmute microphone on HA start/restart" + "micunmute": "Unmute microphone on HA start/restart", + "ducking_volume": "Volume Ducking" }, "data_description": { "mode": "The default mode for this satellite device", "view_timeout": "The default time out value for this satellite device in seconds before returning to default view", "do_not_disturb": "Default state for do not disturb mode on HA restart", "use_announce": "Some media player devices, like BrowserMod, cannot use the Home Assistant announce feature while media is playing. This option allows for turning off announce messages if problems arise. Default is on.", - "micunmute": "Helpful for Stream Assist devices" + "micunmute": "Helpful for Stream Assist devices", + "ducking_volume": "Lower media playback volume to this level when Assist is active" + } + }, + "integration_options": { + "title": "{name} Integration Options", + "data": { + "enable_updates": "Enable update notifications" + }, + "data_description": { + "enable_updates": "Enable or disable update notifications for the dashboard, views and blueprints" + } + }, + "developer_options": { + "title": "{name} Developer Options", + "data": { + "developer_device": "Developer device", + "developer_mimic_device": "Mimic device" + }, + "data_description": { + "developer_device": "The browser id of the device you wish to use for development", + "developer_mimic_device": "The device to mimic for development" } } } @@ -151,12 +194,19 @@ "flashing_bar": "Flashing bar at bottom" } }, - "status_icons_size_selector": { - "options": { - "6vw": "Small", - "7vw": "Medium", - "8vw": "Large" - } + "status_icons_size_selector": { + "options": { + "6vw": "Small", + "7vw": "Medium", + "8vw": "Large" + } + }, + "menu_config_selector": { + "options": { + "menu_disabled": "Menu disabled", + "menu_enabled_button_visible": "Menu enabled, button visible", + "menu_enabled_button_hidden": "Menu enabled, button hidden" + } }, "mic_type_selector": { "options": { @@ -174,11 +224,27 @@ }, "rotate_backgound_source_selector": { "options": { - "local_sequence": "Local file path sequence", - "local_random": "Local file path random", - "download": "Download random image from Unsplash", - "link_to_entity": "Linked to another View Assist device" + "default_background": "Default background", + "local_sequence": "Sequenced image from local file path", + "local_random": "Random image from local file path", + "download": "Random image from unsplash.com", + "link_to_entity": "Mirror another View Assist device" + } + }, + "menu_icons_selector": { + "options": {} + }, + "lookup_selector": { + "options": { + "hour_12": "12 Hour", + "hour_24": "24 Hour", + "on": "On", + "off": "Off", + "hide_header_sidebar": "Hide header and side menu", + "hide_header": "Hide header", + "hide_sidebar": "Hide side menu", + "no_hide": "Do not hide elements" } } } -} \ No newline at end of file +} diff --git a/custom_components/view_assist/translations/timers/__init__.py b/custom_components/view_assist/translations/timers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/view_assist/translations/timers/timers_de.py b/custom_components/view_assist/translations/timers/timers_de.py new file mode 100644 index 0000000..3de947f --- /dev/null +++ b/custom_components/view_assist/translations/timers/timers_de.py @@ -0,0 +1,230 @@ +##################### +# NEEDS TRANSLATION # +##################### +DAY_SINGULAR = "Tag" +DAY_PLURAL = "Tage" +DAY_SHORT = "d" +HOUR_SINGULAR = "Stunde" +HOUR_PLURAL = "Stunden" +HOUR_SHORT = "h" +MINUTE_SINGULAR = "Minute" +MINUTE_PLURAL = "Minuten" +MINUTE_SHORT = "m" +SECOND_SINGULAR = "Sekunde" +SECOND_PLURAL = "Sekunden" +SECOND_SHORT = "s" + +WEEKDAYS = [ + "montag", + "dienstag", + "mittwoch", + "donnerstag", + "freitag", + "samstag", + "sonntag", +] +SPECIAL_DAYS = { + "heute": 0, + "morgen": 1, +} +REFERENCES = { + "next": "nächsten", + "tomorrow": "morgen", + "this": "dieser", + "at": "um", + "and": "und", + "to": "vor", + "past": "nach", +} +SPECIAL_HOURS = { + "mitternacht": 0, + "mittag": 12, +} +HOUR_FRACTIONS = { + "1/4": 15, + "viertel": 15, + "1/2": 30, + "halb": 30, + "3/4": 45, + "dreiviertel": 45, + "drei viertel": 45, +} +AMPM = ["am", "pm"] +SPECIAL_AMPM = { + "morgens": "am", + "heute abend": "pm", + "nachmittags": "pm", + "abends": "pm", +} +# Phrases that will be string replaced, irrespective of the regex +DIRECT_REPLACE = { + "ein tag": "1 tag", + "eine stunde": "1 stunde", +} + +# Allow natural language times +# viertel nach 11 +# 20 nach fünf +# halb 12 +# halb zwölf +# zwanzig vor vier +# zwanzig vor vier morgens +# zwanzig vor vier abends +# 20 vor 4:00 abends +# um Mitternacht +REGEX_SUPER_TIME = ( + rf"(?i)\b(?P<{DAY_SINGULAR}>" + + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) + + r")?[ ]?(?:um|am)?[ ]?(" + + "|".join(list(HOUR_FRACTIONS) + list(SPECIAL_HOURS)) + + r"|[01]?[0-9]|2[0-3])\s?(" + + rf"{REFERENCES['to']}|{REFERENCES['past']})?\s?([0-5]?[0-9])?(?::[0-5][0-9])?[ ]?(" + + "|".join(AMPM + list(SPECIAL_AMPM)) + + r")?\b" +) + + +# All natural language intervals +# 2 1/2 Stunden +# 2 und eine halbe Stunde +# zwei und eine halbe Stunde +# eine und eine viertel Stunde +# 1 1/2 Minuten +# drei Viertel einer Stunde +# 3/4 einer Stunde +# eine halbe Stunde +# 1/2 einer Stunde +# ein Viertel einer Stunde +# 1/4 einer Stunde +REGEX_SUPER_HOUR_INTERVAL = ( + r"()(\d+)?" # noqa: ISC003 + + r"[ ]?(?:und eine)?[ ]?(" # Übersetzung der Wortfolge + + "|".join(HOUR_FRACTIONS) + + r")[ ](?:einer|von einer)?[ ]?(?:stunden?)()" # Übersetzung der Wortfolge +) + +REGEX_SUPER_MIN_INTERVAL = ( + r"()()(\d+)?" # noqa: ISC003 + + r"[ ]?(?:und eine)?[ ]?(" # Übersetzung der Wortfolge + + "|".join(HOUR_FRACTIONS) + + r")[ ](?:einer|von einer)?[ ]?(?:minuten?)" # Übersetzung der Wortfolge +) + +REGEX_ALT_SUPER_INTERVAL = ( + r"()" # noqa: ISC003 + + r"(?:([01]?[0-9]|2[0-3]]|eine) stunden?)?" # Übersetzung der Wortfolge + + r"(?:[ ]?(?:und eine?)?[ ]?)?" # Übersetzung der Wortfolge + + r"(" + + "|".join(HOUR_FRACTIONS) + + r")?()" +) + +########################## +# MIGHT NEED TRANSLATION # +########################## +# This regex matches days of the week, special day references (e.g., "today", "tomorrow"), +# and phrases like "next Monday". It is case-insensitive and ensures matches are word-bound. +# Examples of matches: +# - "Monday", "Tuesday", "Wednesday" (from WEEKDAYS) +# - "today", "tomorrow" (from SPECIAL_DAYS) +# - "next Monday", "next Friday" (constructed using REFERENCES['next'] and WEEKDAYS) +REGEX_DAYS = ( + r"(?i)\b(" + + ( + "|".join(WEEKDAYS + list(SPECIAL_DAYS)) + + "|" + + "|".join(f"{REFERENCES['next']} {weekday}" for weekday in WEEKDAYS) # Might need translating + ) + + ")" +) + +# Find a time in the string and split into day, hours, mins and secs +# 10:15 AM +# 1600 +# 15:24 +# Monday at 10:00 AM +REGEX_TIME = ( + r"(?i)\b(" + + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) + + "|" + + ("|".join([f"{REFERENCES['next']} {day}" for day in WEEKDAYS])) # Might need translating + + rf")?[ ]?(?:{REFERENCES['at']})?[ ]?([01]?[0-9]|2[0-3]):?([0-5][0-9])(?::([0-9][0-9]))?[ ]?(?:{REFERENCES['this']})?[ ]?(" # Wording or Word Sequence needs translating + + "|".join(AMPM + list(SPECIAL_AMPM)) + + r")?\b" +) +REGEX_ALT_TIME = ( + r"(?i)\b(" + + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) + + "|" + + ("|".join([f"{REFERENCES['next']} {day}" for day in WEEKDAYS])) + + r")?[ ]?(?:um|am)?[ ]?" + + r"(" + + "|".join(list(SPECIAL_HOURS)) + + r")()()()" +) + +######################### +# LIKELY NO TRANSLATION # +######################### + +# Find an interval in human readbale form and decode into days, hours, minutes, seconds. +# 5 minutes 30 seconds +# 5 minutes +# 2 hours 30 minutes +# 30 seconds +# 2 days 1 hour 20 minutes +# 1 day 20 minutes +# 5m +# 2h +# 1d 3h +# 30s +# 2d 1h 20m +REGEX_INTERVAL = ( + r"(?i)\b" + rf"(?:(?P<{DAY_PLURAL}>\d+)\s*(?:{DAY_SHORT}|{DAY_PLURAL}?))?\s*" + rf"(?:(?P<{HOUR_PLURAL}>\d+)\s*(?:{HOUR_SHORT}|{HOUR_PLURAL}?))?\s*" + rf"(?:(?P<{MINUTE_PLURAL}>\d+)\s*(?:{MINUTE_SHORT}|{MINUTE_PLURAL}?))?\s*" + rf"(?:(?P<{SECOND_PLURAL}>\d+)\s*(?:{SECOND_SHORT}|{SECOND_PLURAL}?))?" + r"\b" +) + +# Regex to detect intervals in a string +INTERVAL_DETECTION_REGEX = ( + rf"(?i)\b\d+\s*(" + rf"{DAY_SHORT}|{DAY_SINGULAR}|{DAY_PLURAL}|" + rf"{HOUR_SHORT}|{HOUR_SINGULAR}|{HOUR_PLURAL}|" + rf"{MINUTE_SHORT}|{MINUTE_SINGULAR}|{MINUTE_PLURAL}|" + rf"{SECOND_SHORT}|{SECOND_SINGULAR}|{SECOND_PLURAL}" + r")\b" +) + +# Dictionary to hold all regexes of this language +REGEXES = { + "interval": { + "base": REGEX_INTERVAL, + "super_hour": REGEX_SUPER_HOUR_INTERVAL, + "super_min": REGEX_SUPER_MIN_INTERVAL, + "alt_super": REGEX_ALT_SUPER_INTERVAL, + }, + "time": { + "base": REGEX_TIME, + "alt_base": REGEX_ALT_TIME, + "super": REGEX_SUPER_TIME, + }, +} + +# Dictionary to hold all singulars of this language +SINGULARS = { + "day": DAY_SINGULAR, + "hour": HOUR_SINGULAR, + "minute": MINUTE_SINGULAR, + "second": SECOND_SINGULAR, +} + +# Dictionary to hold all plural forms of this language +PLURAL_MAPPING = { + DAY_SINGULAR: DAY_PLURAL, + HOUR_SINGULAR: HOUR_PLURAL, + MINUTE_SINGULAR: MINUTE_PLURAL, + SECOND_SINGULAR: SECOND_PLURAL, +} \ No newline at end of file diff --git a/custom_components/view_assist/translations/timers/timers_en.py b/custom_components/view_assist/translations/timers/timers_en.py new file mode 100644 index 0000000..8f944be --- /dev/null +++ b/custom_components/view_assist/translations/timers/timers_en.py @@ -0,0 +1,229 @@ +##################### +# NEEDS TRANSLATION # +##################### +DAY_SINGULAR = "day" +DAY_PLURAL = "days" +DAY_SHORT = "d" +HOUR_SINGULAR = "hour" +HOUR_PLURAL = "hours" +HOUR_SHORT = "h" +MINUTE_SINGULAR = "minute" +MINUTE_PLURAL = "minutes" +MINUTE_SHORT = "m" +SECOND_SINGULAR = "second" +SECOND_PLURAL = "seconds" +SECOND_SHORT = "s" + +WEEKDAYS = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +] +SPECIAL_DAYS = { + "today": 0, + "tomorrow": 1, +} +REFERENCES = { + "next": "next", + "tomorrow": "tomorrow", + "this": "this", + "at": "at", + "and": "and", + "to": "to", + "past": "past", +} +SPECIAL_HOURS = { + "midnight": 0, + "noon": 12, +} +HOUR_FRACTIONS = { + "1/4": 15, + "quarter": 15, + "1/2": 30, + "half": 30, + "3/4": 45, + "three quarters": 45, +} +AMPM = ["am", "pm"] +SPECIAL_AMPM = { + "morning": "am", + "tonight": "pm", + "afternoon": "pm", + "evening": "pm", +} +# Phrases that will be string replaced, irrespective of the regex +DIRECT_REPLACE = { + "a day": "1 day", + "an hour": "1 hour", +} + +# Allow natural language times +# quarter past 11 +# 20 past five +# half past 12 +# half past twelve +# twenty to four +# twenty to four AM +# twenty to four PM +# 20 to 4:00 PM +REGEX_SUPER_TIME = ( + rf"(?i)\b(?P<{DAY_SINGULAR}>" + + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) + + r")?[ ]?(?:at)?[ ]?(\d+|" # Wording or Word Sequence needs translating + + "|".join(list(HOUR_FRACTIONS)) + + rf")\s({REFERENCES['to']}|{REFERENCES['past']})\s(\d+|" + + ("|".join(SPECIAL_HOURS)) + + r")(?::\d+)?[ ]?(" + + "|".join(AMPM + list(SPECIAL_AMPM)) + + r")?\b" +) + + +# All natural language intervals +# 2 1/2 hours +# 2 and a half hours +# two and a half hours +# one and a quarter hours +# 1 1/2 minutes +# three quarters of an hour +# 3/4 of an hour +# half an hour +# 1/2 an hour +# quarter of an hour +# 1/4 of an hour +REGEX_SUPER_HOUR_INTERVAL = ( + r"()(\d+)?" # noqa: ISC003 + + r"[ ]?(?:and a)?[ ]?(" # Wording or Word Sequence needs translating + + "|".join(HOUR_FRACTIONS) + + r")[ ](?:an|of an)?[ ]?(?:hours?)()" # Wording or Word Sequence needs translating +) + +REGEX_SUPER_MIN_INTERVAL = ( + r"()()(\d+)?" # noqa: ISC003 + + r"[ ]?(?:and a)?[ ]?(" # Wording or Word Sequence needs translating + + "|".join(HOUR_FRACTIONS) + + r")[ ](?:an|of an)?[ ]?(?:minutes?)" # Wording or Word Sequence needs translating +) + +REGEX_ALT_SUPER_INTERVAL = ( + r"()" # noqa: ISC003 + + r"(?:([01]?[0-9]|2[0-3]]|an) hours?)?" # Wording or Word Sequence needs translating + + r"(?:[ ]?(?:and a?)?[ ]?)?" # Wording or Word Sequence needs translating + + r"(" + + "|".join(HOUR_FRACTIONS) + + r")?()" +) + +########################## +# MIGHT NEED TRANSLATION # +########################## +# This regex matches days of the week, special day references (e.g., "today", "tomorrow"), +# and phrases like "next Monday". It is case-insensitive and ensures matches are word-bound. +# Examples of matches: +# - "Monday", "Tuesday", "Wednesday" (from WEEKDAYS) +# - "today", "tomorrow" (from SPECIAL_DAYS) +# - "next Monday", "next Friday" (constructed using REFERENCES['next'] and WEEKDAYS) +REGEX_DAYS = ( + r"(?i)\b(" + + ( + "|".join(WEEKDAYS + list(SPECIAL_DAYS)) + + "|" + + "|".join(f"{REFERENCES['next']} {weekday}" for weekday in WEEKDAYS) # Might need translating + ) + + ")" +) + +# Find a time in the string and split into day, hours, mins and secs +# 10:15 AM +# 1600 +# 15:24 +# Monday at 10:00 AM +REGEX_TIME = ( + r"(?i)\b(" + + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) + + "|" + + ("|".join([f"{REFERENCES['next']} {day}" for day in WEEKDAYS])) # Might need translating + + rf")?[ ]?(?:{REFERENCES['at']})?[ ]?([01]?[0-9]|2[0-3]):?([0-5][0-9])(?::([0-9][0-9]))?[ ]?(?:{REFERENCES['this']})?[ ]?(" # Wording or Word Sequence needs translating + + "|".join(AMPM + list(SPECIAL_AMPM)) + + r")?\b" +) +REGEX_ALT_TIME = ( + r"(?i)\b(" + + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) + + "|" + + ("|".join([f"{REFERENCES['next']} {day}" for day in WEEKDAYS])) # Might need translating + + rf")?[ ]?(?:{REFERENCES['at']})?[ ]?" + + r"(" + + "|".join(list(SPECIAL_HOURS)) + + r")()()()" +) + +######################### +# LIKELY NO TRANSLATION # +######################### + +# Find an interval in human readbale form and decode into days, hours, minutes, seconds. +# 5 minutes 30 seconds +# 5 minutes +# 2 hours 30 minutes +# 30 seconds +# 2 days 1 hour 20 minutes +# 1 day 20 minutes +# 5m +# 2h +# 1d 3h +# 30s +# 2d 1h 20m +REGEX_INTERVAL = ( + r"(?i)\b" + rf"(?:(?P<{DAY_PLURAL}>\d+)\s*(?:{DAY_SHORT}|{DAY_PLURAL}?))?\s*" + rf"(?:(?P<{HOUR_PLURAL}>\d+)\s*(?:{HOUR_SHORT}|{HOUR_PLURAL}?))?\s*" + rf"(?:(?P<{MINUTE_PLURAL}>\d+)\s*(?:{MINUTE_SHORT}|{MINUTE_PLURAL}?))?\s*" + rf"(?:(?P<{SECOND_PLURAL}>\d+)\s*(?:{SECOND_SHORT}|{SECOND_PLURAL}?))?" + r"\b" +) + +# Regex to detect intervals in a string +INTERVAL_DETECTION_REGEX = ( + rf"(?i)\b\d+\s*(" + rf"{DAY_SHORT}|{DAY_SINGULAR}|{DAY_PLURAL}|" + rf"{HOUR_SHORT}|{HOUR_SINGULAR}|{HOUR_PLURAL}|" + rf"{MINUTE_SHORT}|{MINUTE_SINGULAR}|{MINUTE_PLURAL}|" + rf"{SECOND_SHORT}|{SECOND_SINGULAR}|{SECOND_PLURAL}" + r")\b" +) + +# Dictionary to hold all regexes of this language +REGEXES = { + "interval": { + "base": REGEX_INTERVAL, + "super_hour": REGEX_SUPER_HOUR_INTERVAL, + "super_min": REGEX_SUPER_MIN_INTERVAL, + "alt_super": REGEX_ALT_SUPER_INTERVAL, + }, + "time": { + "base": REGEX_TIME, + "alt_base": REGEX_ALT_TIME, + "super": REGEX_SUPER_TIME, + }, +} + +# Dictionary to hold all singulars of this language +SINGULARS = { + "day": DAY_SINGULAR, + "hour": HOUR_SINGULAR, + "minute": MINUTE_SINGULAR, + "second": SECOND_SINGULAR, +} + +# Dictionary to hold all plural forms of this language +PLURAL_MAPPING = { + DAY_SINGULAR: DAY_PLURAL, + HOUR_SINGULAR: HOUR_PLURAL, + MINUTE_SINGULAR: MINUTE_PLURAL, + SECOND_SINGULAR: SECOND_PLURAL, +} \ No newline at end of file diff --git a/custom_components/view_assist/typed.py b/custom_components/view_assist/typed.py new file mode 100644 index 0000000..23a9455 --- /dev/null +++ b/custom_components/view_assist/typed.py @@ -0,0 +1,189 @@ +"""Types for View Assist.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + +from homeassistant.config_entries import ConfigEntry + +type VAConfigEntry = ConfigEntry[MasterConfigRuntimeData | DeviceRuntimeData] + + +class VAType(StrEnum): + """Sensor type enum.""" + + MASTER_CONFIG = "master_config" + VIEW_AUDIO = "view_audio" + AUDIO_ONLY = "audio_only" + + +class VATimeFormat(StrEnum): + """Time format enum.""" + + HOUR_12 = "hour_12" + HOUR_24 = "hour_24" + + +class VAScreenMode(StrEnum): + """Screen mode enum.""" + + NO_HIDE = "no_hide" + HIDE_HEADER = "hide_header" + HIDE_SIDEBAR = "hide_sidebar" + HIDE_HEADER_SIDEBAR = "hide_header_sidebar" + + +class VAAssistPrompt(StrEnum): + """Assist prompt types enum.""" + + BLUR_POPUP = "blur_pop_up" + FLASHING_BAR = "flashing_bar" + + +class VAIconSizes(StrEnum): + """Icon size options enum.""" + + SMALL = "6vw" + MEDIUM = "7vw" + LARGE = "8vw" + + +class VADisplayType(StrEnum): + """Display types.""" + + BROWSERMOD = "browser_mod" + REMOTE_ASSIST_DISPLAY = "remote_assist_display" + + +class VABackgroundMode(StrEnum): + """Background mode enum.""" + + DEFAULT_BACKGROUND = "default_background" + LOCAL_SEQUENCE = "local_sequence" + LOCAL_RANDOM = "local_random" + DOWNLOAD_RANDOM = "download" + LINKED = "link_to_entity" + + +class VAMenuConfig(StrEnum): + """Menu configuration options enum.""" + + DISABLED = "menu_disabled" + ENABLED_VISIBLE = "menu_enabled_button_visible" + ENABLED_HIDDEN = "menu_enabled_button_hidden" + + +@dataclass +class IntegrationConfig: + """Class to hold integration config data.""" + + enable_updates: bool = True + + +@dataclass +class DeviceCoreConfig: + """Class to hold core config data.""" + + type: VAType | None = None + name: str | None = None + mic_device: str | None = None + mediaplayer_device: str | None = None + musicplayer_device: str | None = None + intent_device: str | None = None + display_device: str | None = None + dev_mimic: bool | None = None + + +@dataclass +class BackgroundConfig: + "Background settings class." + + background_mode: str | None = None + background: str | None = None + rotate_background_path: str | None = None + rotate_background_linked_entity: str | None = None + rotate_background_interval: int | None = None + + +@dataclass +class DisplayConfig: + """Display settings class.""" + + assist_prompt: VAAssistPrompt | None = None + status_icons_size: VAIconSizes | None = None + font_style: str | None = None + status_icons: list[str] = field(default_factory=list) + menu_config: VAMenuConfig = VAMenuConfig.DISABLED + menu_items: list[str] = field(default_factory=list) + menu_timeout: int = 10 + time_format: VATimeFormat | None = None + screen_mode: VAScreenMode | None = None + + +@dataclass +class DashboardConfig: + """Class to hold dashboard config data.""" + + dashboard: str | None = None + home: str | None = None + music: str | None = None + intent: str | None = None + list_view: str | None = None + background_settings: BackgroundConfig = field(default_factory=BackgroundConfig) + display_settings: DisplayConfig = field(default_factory=DisplayConfig) + + +@dataclass +class DefaultConfig: + """Class to hold default config data.""" + + weather_entity: str | None = None + mode: str | None = None + view_timeout: int | None = None + do_not_disturb: bool | None = None + use_announce: bool | None = None + mic_unmute: bool | None = None + ducking_volume: int | None = None + + +@dataclass +class DeveloperConfig: + """Class to hold developer config data.""" + + developer_device: str | None = None + developer_mimic_device: str | None = None + + +class MasterConfigRuntimeData: + """Class to hold master config data.""" + + def __init__(self) -> None: + """Initialize runtime data.""" + self.integration: IntegrationConfig = IntegrationConfig() + self.dashboard: DashboardConfig = DashboardConfig() + self.default: DefaultConfig = DefaultConfig() + self.developer_settings: DeveloperConfig = DeveloperConfig() + # Extra data for holding key/value pairs passed in by set_state service call + self.extra_data: dict[str, Any] = {} + + +class DeviceRuntimeData: + """Class to hold runtime data.""" + + def __init__(self) -> None: + """Initialize runtime data.""" + self.core: DeviceCoreConfig = DeviceCoreConfig() + self.dashboard: DashboardConfig = DashboardConfig() + self.default: DefaultConfig = DefaultConfig() + # Extra data for holding key/value pairs passed in by set_state service call + self.extra_data: dict[str, Any] = {} + + +@dataclass +class VAEvent: + """View Assist event.""" + + event_name: str + payload: dict | None = None diff --git a/custom_components/view_assist/update.py b/custom_components/view_assist/update.py new file mode 100644 index 0000000..bb14e4a --- /dev/null +++ b/custom_components/view_assist/update.py @@ -0,0 +1,205 @@ +"""Update entities for HACS.""" + +from __future__ import annotations + +import logging +from typing import Any + +from awesomeversion import AwesomeVersion + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.core import HomeAssistant, HomeAssistantError, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .assets import ( + ASSETS_MANAGER, + VA_ADD_UPDATE_ENTITY_EVENT, + AssetClass, + AssetsManager, +) +from .assets.base import AssetManagerException +from .const import ( + BLUEPRINT_GITHUB_PATH, + DASHBOARD_DIR, + DASHBOARD_VIEWS_GITHUB_PATH, + DOMAIN, + GITHUB_BRANCH, + GITHUB_REPO, + VA_ASSET_UPDATE_PROGRESS, + VIEWS_DIR, +) +from .typed import VAConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: VAConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up update platform.""" + am: AssetsManager = hass.data[DOMAIN][ASSETS_MANAGER] + + async def async_add_remove_update_entity( + data: dict[str, Any], startup: bool = False + ) -> None: + """Add or remove update entity.""" + asset_class: AssetClass = data.get("asset_class") + name: str = data.get("name") + remove: bool = data.get("remove") + + unique_id = f"{DOMAIN}_{asset_class}_{name}" + entity_reg = er.async_get(hass) + + if remove: + if entity_id := entity_reg.async_get_entity_id("update", DOMAIN, unique_id): + entity_reg.async_remove(entity_id) + return + + # Add new update entity + entity_id = entity_reg.async_get_entity_id("update", DOMAIN, unique_id) + if not entity_id or startup: + async_add_entities( + [ + VAUpdateEntity( + am=am, + asset_class=asset_class, + name=name, + ) + ] + ) + + entry.async_on_unload( + async_dispatcher_connect( + hass, VA_ADD_UPDATE_ENTITY_EVENT, async_add_remove_update_entity + ) + ) + + # Set update entities on restart + for asset_class in AssetClass: + if not am.data or am.data.get(asset_class) is None: + continue + + for name in am.data[asset_class]: + installed = am.store.data[asset_class][name].get("installed", "0.0.0") + latest = am.store.data[asset_class][name].get("latest", "0.0.0") + + await async_add_remove_update_entity( + { + "asset_class": asset_class, + "name": name, + "remove": AwesomeVersion(installed) >= latest, + }, + startup=True, + ) + + +class VAUpdateEntity(UpdateEntity): + """Update entities for repositories downloaded with HACS.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) + + def __init__(self, am: AssetsManager, asset_class: AssetClass, name: str) -> None: + """Initialize.""" + self.am = am + self._asset_class = asset_class + self._name = name + + self._attr_supported_features = ( + (self._attr_supported_features | UpdateEntityFeature.BACKUP) + if self._asset_class != AssetClass.DASHBOARD + else self._attr_supported_features + ) + + @property + def name(self) -> str | None: + """Return the name.""" + if self._asset_class == AssetClass.DASHBOARD: + return f"View Assist - {self._name.replace('_', ' ').title()}" + return f"View Assist - {self._name.replace('_', ' ').title()} {self._asset_class.removesuffix('s')}" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{DOMAIN}_{self._asset_class}_{self._name}" + + @property + def latest_version(self) -> str: + """Return latest version of the entity.""" + return self.am.store.data[self._asset_class][self._name]["latest"] + + @property + def release_url(self) -> str: + """Return the URL of the release page.""" + base = f"https://github.com/{GITHUB_REPO}/tree/{GITHUB_BRANCH}" + if self._asset_class == AssetClass.DASHBOARD: + return f"{base}/{DASHBOARD_VIEWS_GITHUB_PATH}/{DASHBOARD_DIR}/dashboard" + if self._asset_class == AssetClass.VIEW: + return f"{base}/{DASHBOARD_VIEWS_GITHUB_PATH}/{VIEWS_DIR}/{self._name}" + if self._asset_class == AssetClass.BLUEPRINT: + return f"{base}/{BLUEPRINT_GITHUB_PATH}/{self._name}" + return base + + @property + def installed_version(self) -> str: + """Return downloaded version of the entity.""" + return self.am.store.data[self._asset_class][self._name]["installed"] + + @property + def release_summary(self) -> str | None: + """Return the release summary.""" + if self._asset_class == AssetClass.DASHBOARD: + return "Updating the dashboard will attempt to keep any changes you have made to it" + if self._asset_class == AssetClass.VIEW: + return "Updating this view will overwrite any changes you have made to it" + if self._asset_class == AssetClass.BLUEPRINT: + return "Updating this blueprint will overwrite any changes you have made to it" + return None + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend.""" + return f"https://brands.home-assistant.io/_/{DOMAIN}/icon.png" + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + try: + await self.am.async_install_or_update( + self._asset_class, + self._name, + download=True, + backup_existing=backup, + ) + except AssetManagerException as exception: + raise HomeAssistantError(exception) from exception + + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + # TODO: Get release notes from markdown readme + return self.release_summary + + async def async_added_to_hass(self) -> None: + """Register for status events.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + VA_ASSET_UPDATE_PROGRESS, + self._update_download_progress, + ) + ) + + @callback + def _update_download_progress(self, data: dict) -> None: + """Update the download progress.""" + if data["name"] != self._name: + return + self._attr_in_progress = data["progress"] + self.async_write_ha_state() diff --git a/custom_components/view_assist/websocket.py b/custom_components/view_assist/websocket.py index 9026668..d27702b 100644 --- a/custom_components/view_assist/websocket.py +++ b/custom_components/view_assist/websocket.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DOMAIN, VAEvent +from .const import DOMAIN from .helpers import ( get_config_entry_by_entity_id, get_device_id_from_entity_id, @@ -25,6 +25,7 @@ get_mimic_entity_id, ) from .timers import TIMERS, VATimers +from .typed import VAEvent, VAScreenMode _LOGGER = logging.getLogger(__name__) @@ -101,7 +102,7 @@ def get_entity_id(browser_id): entity = get_entity_id_by_browser_id(hass, browser_id) if not entity: - if entity := get_mimic_entity_id(hass): + if entity := get_mimic_entity_id(hass, browser_id): mimic = True return entity, mimic @@ -291,28 +292,30 @@ async def get_data( "browser_id": browser_id, "entity_id": entity_id, "mimic_device": mimic, - "name": data.name, - "mic_entity_id": data.mic_device, + "name": data.core.name, + "mic_entity_id": data.core.mic_device, "mic_device_id": get_device_id_from_entity_id( - hass, data.mic_device + hass, data.core.mic_device ), - "mediaplayer_entity_id": data.mediaplayer_device, + "mediaplayer_entity_id": data.core.mediaplayer_device, "mediaplayer_device_id": get_device_id_from_entity_id( - hass, data.mediaplayer_device + hass, data.core.mediaplayer_device ), - "musicplayer_entity_id": data.musicplayer_device, + "musicplayer_entity_id": data.core.musicplayer_device, "musicplayer_device_id": get_device_id_from_entity_id( - hass, data.musicplayer_device + hass, data.core.musicplayer_device ), - "display_device_id": data.display_device, + "display_device_id": data.core.display_device, "timers": timer_info, - "background": data.background, - "dashboard": data.dashboard, - "home": data.home, - "music": data.music, - "intent": data.intent, - "hide_sidebar": data.hide_sidebar, - "hide_header": data.hide_header, + "background": data.dashboard.background_settings.background, + "dashboard": data.dashboard.dashboard, + "home": data.dashboard.home, + "music": data.dashboard.music, + "intent": data.dashboard.intent, + "hide_sidebar": data.dashboard.display_settings.screen_mode + in [VAScreenMode.HIDE_HEADER_SIDEBAR, VAScreenMode.HIDE_SIDEBAR], + "hide_header": data.dashboard.display_settings.screen_mode + in [VAScreenMode.HIDE_HEADER_SIDEBAR, VAScreenMode.HIDE_HEADER], } except Exception: # noqa: BLE001 output = {}