From 35a9f4e91d53b6b840001609204644fc50c474f2 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Fri, 18 Apr 2025 17:31:44 +0100 Subject: [PATCH 01/92] added: master config defaults --- custom_components/view_assist/__init__.py | 235 ++++++- custom_components/view_assist/config_flow.py | 655 ++++++++---------- custom_components/view_assist/const.py | 205 +++--- custom_components/view_assist/dashboard.py | 3 +- .../view_assist/entity_listeners.py | 111 ++- custom_components/view_assist/helpers.py | 59 +- custom_components/view_assist/http_url.py | 3 +- custom_components/view_assist/sensor.py | 56 +- custom_components/view_assist/services.py | 2 +- .../view_assist/translations/en.json | 114 ++- custom_components/view_assist/typed.py | 168 +++++ custom_components/view_assist/websocket.py | 37 +- 12 files changed, 984 insertions(+), 664 deletions(-) create mode 100644 custom_components/view_assist/typed.py diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 605cddb..3899a67 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -1,6 +1,8 @@ """View Assist custom integration.""" +from functools import reduce import logging +from typing import Any from homeassistant import config_entries from homeassistant.const import CONF_TYPE, Platform @@ -11,19 +13,36 @@ from .alarm_repeater import ALARMS, VAAlarmRepeater 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_master_config_entry, is_first_instance, ) @@ -32,6 +51,17 @@ from .services import VAServices from .templates import setup_va_templates from .timers import TIMERS, VATimers +from .typed import ( + DeviceCoreConfig, + DeviceRuntimeData, + MasterConfigRuntimeData, + VABackgroundMode, + VAConfigEntry, + VAEvent, + VAScreenMode, + VATimeFormat, + VAType, +) from .websocket import async_register_websockets _LOGGER = logging.getLogger(__name__) @@ -39,6 +69,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 +90,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 +100,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 +190,8 @@ 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) + _LOGGER.debug("Runtime Data: %s", entry.runtime_data.__dict__) # 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)) @@ -141,7 +238,7 @@ 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", + f"{DOMAIN}_{get_device_name_from_id(hass, entry.runtime_data.core.display_device)}_registered", ) return True @@ -190,23 +287,105 @@ async def setup_frontend(*args): async_at_started(hass, setup_frontend) -def set_runtime_data_from_config(config_entry: VAConfigEntry): +def set_runtime_data_for_config( # noqa: C901 + 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_dn(dn_attr: str, data: dict[str, Any]): + """Get dotted notation attribute from config entry options dict.""" + try: + if "." in dn_attr: + dn_list = dn_attr.split(".") + else: + dn_list = [dn_attr] + return reduce(dict.get, dn_list, data) + except (TypeError, KeyError): + return None + + def get_config_value( + attr: str, is_master: bool = False + ) -> str | float | list | None: + value = get_dn(attr, dict(config_entry.options)) + if not value and not is_master: + value = get_dn(attr, dict(master_config_options)) + if not value: + value = get_dn(attr, DEFAULT_VALUES) + + # This is a fix for config lists being a string + if isinstance(attr, list): + value = ensure_list(value) + return value + + 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), + ) + + # Default options - doesn't yet handle sections + for attr in r.default.__dict__: + if value := get_config_value(attr, is_master=True): + setattr(r.default, 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 + # 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), + ) + + # Default options - doesn't yet handle sections + for attr in r.default.__dict__: + if value := get_config_value(attr): + 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) diff --git a/custom_components/view_assist/config_flow.py b/custom_components/view_assist/config_flow.py index 2cd79b1..0fc34cd 100644 --- a/custom_components/view_assist/config_flow.py +++ b/custom_components/view_assist/config_flow.py @@ -12,12 +12,14 @@ from homeassistant.config_entries import ConfigFlow, OptionsFlow from homeassistant.const import CONF_MODE, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers.selector import ( - DeviceSelector, - DeviceSelectorConfig, EntityFilterSelectorConfig, EntitySelector, EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -27,13 +29,15 @@ 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_FONT_STYLE, - CONF_HIDE_HEADER, - CONF_HIDE_SIDEBAR, CONF_HOME, CONF_INTENT, CONF_INTENT_DEVICE, @@ -42,104 +46,65 @@ 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, 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, -} +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 +122,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 +137,175 @@ 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, + } + 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_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", + ) + ), + } +) + + +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) + ), } ) +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): + 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 +375,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 +425,17 @@ 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=[ + "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 +446,96 @@ 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}, ) 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}, ) - # Show the form for the selected type - return self.async_show_form(step_id="default_options", data_schema=data_schema) + 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 0bc21b2..ce68e25 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -1,10 +1,16 @@ """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, + VAScreenMode, + VATimeFormat, +) DOMAIN = "view_assist" GITHUB_REPO = "dinki/View-Assist" @@ -53,9 +59,6 @@ ] -type VAConfigEntry = ConfigEntry[RuntimeData] - - class VAMode(StrEnum): """View Assist modes.""" @@ -76,93 +79,112 @@ 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_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_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_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_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_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", + # 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_DASHBOARD = "/view-assist" +# DEFAULT_VIEW_HOME = "/view-assist/clock" +# DEFAULT_VIEW_MUSIC = "/view-assist/music" +# DEFAULT_VIEW_INTENT = "/view-assist/intent" 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 +# 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 = "off" +# DEFAULT_USE_ANNOUNCE = "off" +# DEFAULT_MIC_UNMUTE = "off" +# 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" @@ -186,61 +208,6 @@ class VADisplayType(StrEnum): 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 index 9f3a90f..ef1a423 100644 --- a/custom_components/view_assist/dashboard.py +++ b/custom_components/view_assist/dashboard.py @@ -43,10 +43,9 @@ GITHUB_REPO, GITHUB_TOKEN_FILE, VIEWS_DIR, - VAConfigEntry, - VAEvent, ) from .helpers import differ_to_json, json_to_dictdiffer +from .typed import VAConfigEntry, VAEvent from .utils import dictdiff from .websocket import MockWSConnection diff --git a/custom_components/view_assist/entity_listeners.py b/custom_components/view_assist/entity_listeners.py index 08886f3..5c56f83 100644 --- a/custom_components/view_assist/entity_listeners.py +++ b/custom_components/view_assist/entity_listeners.py @@ -34,9 +34,6 @@ USE_VA_NAVIGATION_FOR_BROWSERMOD, VA_ATTRIBUTE_UPDATE_EVENT, VA_BACKGROUND_UPDATE_EVENT, - VAConfigEntry, - VADisplayType, - VAEvent, VAMode, ) from .helpers import ( @@ -52,6 +49,7 @@ get_sensor_entity_from_instance, make_url_from_file_path, ) +from .typed import VABackgroundMode, VAConfigEntry, VADisplayType, VAEvent _LOGGER = logging.getLogger(__name__) @@ -71,7 +69,7 @@ def __init__(self, hass: HomeAssistant, config_entry: VAConfigEntry) -> None: # Add microphone mute switch listener mute_switch = get_mute_switch_entity_id( - hass, config_entry.runtime_data.mic_device + hass, config_entry.runtime_data.core.mic_device ) # Add browser navigate service listener @@ -138,23 +136,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 +164,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", ) ) @@ -222,17 +223,17 @@ async def async_browser_navigate( # 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 +283,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 +304,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 @@ -371,7 +377,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, ) @@ -407,14 +413,15 @@ 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 + d.status_icons = status_icons self.update_entity() @callback @@ -434,18 +441,15 @@ 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 + d.status_icons = status_icons self.update_entity() async def _async_cc_on_conversation_ended_handler(self, event: Event): @@ -534,7 +538,9 @@ 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 + ) else: word_count = len(speech_text.split()) message_font_size = ["14vw", "8vw", "6vw", "4vw"][ @@ -551,7 +557,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 +592,27 @@ 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 + 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() + status_icons = d.status_icons.copy() modes = [VAMode.HOLD, VAMode.CYCLE] @@ -616,7 +625,7 @@ 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 + d.status_icons = status_icons self.update_entity() if new_mode != VAMode.CYCLE: @@ -626,24 +635,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..a7c6c79 100644 --- a/custom_components/view_assist/helpers.py +++ b/custom_components/view_assist/helpers.py @@ -14,18 +14,15 @@ from .const import ( BROWSERMOD_DOMAIN, - CONF_DEV_MIMIC, CONF_DISPLAY_DEVICE, 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 +41,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 ): @@ -197,7 +225,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 +233,15 @@ 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 master_entry.runtime_data.developer_settings.developer_device == browser_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: @@ -272,7 +299,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: @@ -354,9 +381,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: 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/sensor.py b/custom_components/view_assist/sensor.py index 894d9d7..4dbff11 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,37 @@ 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, + "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) diff --git a/custom_components/view_assist/services.py b/custom_components/view_assist/services.py index 9969746..551c885 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -38,7 +38,6 @@ ATTR_TIMER_ID, ATTR_TYPE, DOMAIN, - VAConfigEntry, ) from .dashboard import ( DASHBOARD_MANAGER, @@ -48,6 +47,7 @@ ) from .helpers import get_mimic_entity_id from .timers import TIMERS, VATimers, decode_time_sentence +from .typed import VAConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/view_assist/translations/en.json b/custom_components/view_assist/translations/en.json index bebae42..5ac555d 100644 --- a/custom_components/view_assist/translations/en.json +++ b/custom_components/view_assist/translations/en.json @@ -46,16 +46,17 @@ "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": { "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,54 +78,69 @@ } }, "dashboard_options": { - "title": "Dashboard Options", + "title": "{name} Dashboard Options", "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" + "intent": "Intent 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" + "intent": "The view to display for default HA actions for displaying those entities" + }, + "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", + "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", + "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": "Setting values here will override the master config settings for this device", "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" }, @@ -135,6 +151,17 @@ "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" } + }, + "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" + } } } }, @@ -174,10 +201,23 @@ }, "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" + } + }, + "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" } } } diff --git a/custom_components/view_assist/typed.py b/custom_components/view_assist/typed.py new file mode 100644 index 0000000..ee547c6 --- /dev/null +++ b/custom_components/view_assist/typed.py @@ -0,0 +1,168 @@ +"""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" + + +@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) + 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 + 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 + + +@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.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/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 = {} From a0385c10d276c21a9bb02e20536f37954223cbef Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Fri, 18 Apr 2025 18:07:08 +0100 Subject: [PATCH 02/92] fix: handle if master config instance is deleted --- custom_components/view_assist/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 3899a67..5b8dba5 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -352,7 +352,11 @@ def get_config_value( else: r = config_entry.runtime_data = DeviceRuntimeData() r.core = DeviceCoreConfig(**config_entry.data) - master_config_options = get_master_config_entry(hass).options + 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): From 866a4e1615ca13eca714637e5d3a107f36be0b58 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Fri, 18 Apr 2025 18:07:23 +0100 Subject: [PATCH 03/92] fix: correct descriptions --- custom_components/view_assist/config_flow.py | 19 +++++++++++++++++-- .../view_assist/translations/en.json | 10 +++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/custom_components/view_assist/config_flow.py b/custom_components/view_assist/config_flow.py index 0fc34cd..812c3da 100644 --- a/custom_components/view_assist/config_flow.py +++ b/custom_components/view_assist/config_flow.py @@ -69,6 +69,11 @@ _LOGGER = logging.getLogger(__name__) +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, @@ -492,7 +497,12 @@ async def async_step_dashboard_options(self, user_input=None): return self.async_show_form( step_id="dashboard_options", data_schema=data_schema, - description_placeholders={"name": self.config_entry.title}, + 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): @@ -514,7 +524,12 @@ async def async_step_default_options(self, user_input=None): return self.async_show_form( step_id="default_options", data_schema=data_schema, - description_placeholders={"name": self.config_entry.title}, + 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_developer_options(self, user_input=None): diff --git a/custom_components/view_assist/translations/en.json b/custom_components/view_assist/translations/en.json index 5ac555d..9ea96cb 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,10 +41,6 @@ }, "options": { "step": { - "master_config": { - "title": "Master Configuration", - "description": "Master configuration options will be available here in future versions" - }, "init": { "title": "Configuration", "description": "Select which options to amend", @@ -79,7 +75,7 @@ }, "dashboard_options": { "title": "{name} Dashboard Options", - "description": "", + "description": "{description}", "data": { "dashboard": "Dashboard", "home": "Home screen", @@ -134,7 +130,7 @@ }, "default_options": { "title": "{name} Default Options", - "description": "Setting values here will override the master config settings for this device", + "description": "{description}", "data": { "weather_entity": "Weather entity to use for conditons display", "mic_type": "The integration handling microphone input", From 725debb2bf1a48af003df6bc85b4d8210f365b21 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Fri, 18 Apr 2025 18:23:45 +0100 Subject: [PATCH 04/92] refactor: remove commented out constants --- custom_components/view_assist/const.py | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/custom_components/view_assist/const.py b/custom_components/view_assist/const.py index ce68e25..9f776b5 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -162,29 +162,8 @@ class VAMode(StrEnum): # Config default values DEFAULT_NAME = "View Assist" DEFAULT_TYPE = "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_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 = "off" -# DEFAULT_USE_ANNOUNCE = "off" -# DEFAULT_MIC_UNMUTE = "off" -# 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" From 3adef1395edf50a681da624a19ee31edd2a27282 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Sat, 19 Apr 2025 15:49:21 +0100 Subject: [PATCH 05/92] fix: setting mode via set_state service did not update runtime data --- custom_components/view_assist/sensor.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/custom_components/view_assist/sensor.py b/custom_components/view_assist/sensor.py index 4dbff11..226bf42 100644 --- a/custom_components/view_assist/sensor.py +++ b/custom_components/view_assist/sensor.py @@ -142,11 +142,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: @@ -164,8 +163,8 @@ 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 From 3265e27fb9559f471b468a119718dbb1fa20734b Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Mon, 21 Apr 2025 17:47:27 +0100 Subject: [PATCH 06/92] fix: failed to set mimic device if non native browser id --- custom_components/view_assist/helpers.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/custom_components/view_assist/helpers.py b/custom_components/view_assist/helpers.py index a7c6c79..522dc89 100644 --- a/custom_components/view_assist/helpers.py +++ b/custom_components/view_assist/helpers.py @@ -238,9 +238,21 @@ def get_mimic_entity_id(hass: HomeAssistant, browser_id: str | None = None) -> s # If we reach here, no match for browser_id was found master_entry = get_master_config_entry(hass) if browser_id: - if master_entry.runtime_data.developer_settings.developer_device == 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 From 008dee6e201ebf653d2cd82d0c65fff204df1ef1 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:33:33 -0400 Subject: [PATCH 07/92] Create menu_manager.py - Add menu management system for dynamic status bar and menu items Introduces a dedicated MenuManager class to handle menu state, toggling, timeouts, and dynamic status items. Supports adding/removing items programmatically. --- custom_components/view_assist/menu_manager.py | 534 ++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 custom_components/view_assist/menu_manager.py diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py new file mode 100644 index 0000000..b3a898b --- /dev/null +++ b/custom_components/view_assist/menu_manager.py @@ -0,0 +1,534 @@ +"""Menu manager for View Assist.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +import logging +from typing import Any, Dict, List, Optional, Tuple, Union + +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + CONF_DISPLAY_SETTINGS, + CONF_ENABLE_MENU, + CONF_ENABLE_MENU_TIMEOUT, + CONF_MENU_ITEMS, + CONF_MENU_TIMEOUT, + CONF_SHOW_MENU_BUTTON, + CONF_STATUS_ICONS, + DEFAULT_VALUES, + DOMAIN, +) +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 + +_LOGGER = logging.getLogger(__name__) + +# Maximum number of update iterations before restarting the processor +MAX_UPDATE_ITERATIONS = 10000 +StatusItemType = Union[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: Optional[asyncio.Task] = 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: Optional[asyncio.Task] = 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 + _LOGGER.debug("Menu manager initialized") + + 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) + 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 + + async def toggle_menu( + self, entity_id: str, show: Optional[bool] = None, timeout: Optional[int] = None + ) -> None: + """Toggle menu visibility for an entity.""" + # Ensure initialization + 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 + + enable_menu = self._get_config_value( + entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_ENABLE_MENU}", False + ) + + if not enable_menu: + _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) + + show_menu_button = self._get_config_value( + entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_SHOW_MENU_BUTTON}", False + ) + + # Update system icons from current status + 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" + ] + menu_state.system_icons = system_icons + + # 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) + elif self._get_config_value( + entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_ENABLE_MENU_TIMEOUT}", False + ): + timeout_value = self._get_config_value( + entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_TIMEOUT}", 10 + ) + self._setup_timeout(entity_id, timeout_value) + 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_menu_item( + self, + entity_id: str, + status_item: StatusItemType, + menu: bool = False, + timeout: Optional[int] = 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 + + # Ensure initialization + await self._ensure_initialized() + menu_state = self._get_or_create_state(entity_id) + show_menu_button = self._get_config_value( + entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_SHOW_MENU_BUTTON}", False + ) + + 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_menu_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 + + # Ensure initialization + await self._ensure_initialized() + menu_state = self._get_or_create_state(entity_id) + show_menu_button = self._get_config_value( + entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_SHOW_MENU_BUTTON}", False + ) + + 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: + _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_menu_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 with a fixed iteration limit.""" + iterations = 0 + + while iterations < MAX_UPDATE_ITERATIONS: + iterations += 1 + + try: + 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: + _LOGGER.error("Error updating %s: %s", + entity_id, str(err)) + + except asyncio.CancelledError: + break + except Exception as err: + _LOGGER.error("Unexpected error: %s", str(err)) + await asyncio.sleep(1) + + # Restart if reached iteration limit + if iterations >= MAX_UPDATE_ITERATIONS: + self._update_task = self.config.async_create_background_task( + self.hass, self._update_processor(), name="VA Menu Manager" + ) + + 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() From 6756d779b93b71d915e3aeef9d7c03561f190505 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:34:57 -0400 Subject: [PATCH 08/92] Initialize menu manager in component setup Add menu manager initialization to common functions and store in component data. --- custom_components/view_assist/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 5b8dba5..3208f02 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -48,6 +48,7 @@ ) 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 @@ -63,6 +64,7 @@ VAType, ) from .websocket import async_register_websockets +from . import sensor _LOGGER = logging.getLogger(__name__) @@ -256,6 +258,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() From 12ca959a8f63f28a60d2d36d365572245f496929 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:35:56 -0400 Subject: [PATCH 09/92] Update config flow with menu configuration options Add menu-related configuration options to the UI flow including enable menu, timeout settings, and display options. --- custom_components/view_assist/config_flow.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/custom_components/view_assist/config_flow.py b/custom_components/view_assist/config_flow.py index 812c3da..4c6de51 100644 --- a/custom_components/view_assist/config_flow.py +++ b/custom_components/view_assist/config_flow.py @@ -37,11 +37,15 @@ CONF_DISPLAY_DEVICE, CONF_DISPLAY_SETTINGS, CONF_DO_NOT_DISTURB, + CONF_ENABLE_MENU, + CONF_ENABLE_MENU_TIMEOUT, CONF_FONT_STYLE, CONF_HOME, CONF_INTENT, CONF_INTENT_DEVICE, CONF_MEDIAPLAYER_DEVICE, + CONF_MENU_ITEMS, + CONF_MENU_TIMEOUT, CONF_MIC_DEVICE, CONF_MIC_UNMUTE, CONF_MUSIC, @@ -50,6 +54,7 @@ CONF_ROTATE_BACKGROUND_LINKED_ENTITY, CONF_ROTATE_BACKGROUND_PATH, CONF_SCREEN_MODE, + CONF_SHOW_MENU_BUTTON, CONF_STATUS_ICON_SIZE, CONF_STATUS_ICONS, CONF_TIME_FORMAT, @@ -214,6 +219,19 @@ def get_dashboard_options_schema(config_entry: VAConfigEntry | None) -> vol.Sche custom_value=True, ) ), + vol.Optional(CONF_ENABLE_MENU): bool, + vol.Optional(CONF_MENU_ITEMS): SelectSelector( + SelectSelectorConfig( + translation_key="menu_icons_selector", + options=[], + mode=SelectSelectorMode.LIST, + multiple=True, + custom_value=True, + ) + ), + vol.Optional(CONF_SHOW_MENU_BUTTON): bool, + vol.Optional(CONF_ENABLE_MENU_TIMEOUT): bool, + vol.Optional(CONF_MENU_TIMEOUT): int, vol.Optional(CONF_TIME_FORMAT): SelectSelector( SelectSelectorConfig( options=[e.value for e in VATimeFormat], From 00acc7978ef3cc5dd86572d65f996b90aefb561d Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:42:21 -0400 Subject: [PATCH 10/92] Add menu configuration constants and defaults Define menu-related configuration constants and set default values for menu functionality. --- custom_components/view_assist/const.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/custom_components/view_assist/const.py b/custom_components/view_assist/const.py index 65ad303..747f6e0 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -102,6 +102,11 @@ class VAMode(StrEnum): CONF_STATUS_ICON_SIZE = "status_icons_size" CONF_FONT_STYLE = "font_style" CONF_STATUS_ICONS = "status_icons" +CONF_ENABLE_MENU = "enable_menu" +CONF_MENU_ITEMS = "menu_items" +CONF_SHOW_MENU_BUTTON = "show_menu_button" +CONF_ENABLE_MENU_TIMEOUT = "enable_menu_timeout" +CONF_MENU_TIMEOUT = "menu_timeout" CONF_TIME_FORMAT = "time_format" CONF_SCREEN_MODE = "screen_mode" @@ -144,6 +149,11 @@ class VAMode(StrEnum): CONF_STATUS_ICON_SIZE: VAIconSizes.LARGE, CONF_FONT_STYLE: "Roboto", CONF_STATUS_ICONS: [], + CONF_ENABLE_MENU: False, + CONF_MENU_ITEMS: ["home", "weather"], + CONF_SHOW_MENU_BUTTON: False, + CONF_ENABLE_MENU_TIMEOUT: False, + CONF_MENU_TIMEOUT: 10, CONF_TIME_FORMAT: VATimeFormat.HOUR_12, CONF_SCREEN_MODE: VAScreenMode.HIDE_HEADER_SIDEBAR, }, From 18aa3e51ca9d5e679f2b9ce757e573ce2a0e4f03 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:44:24 -0400 Subject: [PATCH 11/92] Integrate menu support with entity listeners Add path tracking for menu filtering and ensure menu buttons maintain position when status icons change. --- .../view_assist/entity_listeners.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/custom_components/view_assist/entity_listeners.py b/custom_components/view_assist/entity_listeners.py index 3c1d7ae..49f2a0e 100644 --- a/custom_components/view_assist/entity_listeners.py +++ b/custom_components/view_assist/entity_listeners.py @@ -39,6 +39,7 @@ from .helpers import ( async_get_download_image, async_get_filesystem_images, + ensure_menu_button_at_end, get_device_name_from_id, get_display_type_from_browser_id, get_entity_attribute, @@ -221,6 +222,21 @@ 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.core.display_device @@ -421,6 +437,8 @@ def _async_on_mic_change(self, event: Event[EventStateChangedData]) -> None: elif mic_mute_new_state == "off" and "mic" in status_icons: status_icons.remove("mic") + ensure_menu_button_at_end(status_icons) + d.status_icons = status_icons self.update_entity() @@ -449,6 +467,8 @@ def _async_on_mediaplayer_device_mute_change( elif not mp_mute_new_state and "mediaplayer" in status_icons: status_icons.remove("mediaplayer") + ensure_menu_button_at_end(status_icons) + d.status_icons = status_icons self.update_entity() @@ -601,6 +621,8 @@ async def _async_on_dnd_device_state_change(self, event: Event) -> None: elif not dnd_new_state and "dnd" in status_icons: status_icons.remove("dnd") + ensure_menu_button_at_end(status_icons) + d.status_icons = status_icons self.update_entity() @@ -625,6 +647,8 @@ 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) + ensure_menu_button_at_end(status_icons) + d.status_icons = status_icons self.update_entity() From 925900d3ca594214699e5dfb41da5df30f10b359 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:47:09 -0400 Subject: [PATCH 12/92] Add helper functions for menu management Implement utility functions for status icon arrangement, normalization, and menu item management. --- custom_components/view_assist/helpers.py | 100 ++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/custom_components/view_assist/helpers.py b/custom_components/view_assist/helpers.py index 4cae391..80c65d5 100644 --- a/custom_components/view_assist/helpers.py +++ b/custom_components/view_assist/helpers.py @@ -3,7 +3,7 @@ from functools import reduce import logging from pathlib import Path -from typing import Any +from typing import Any, List, Optional, Union import requests @@ -100,6 +100,104 @@ def ensure_list(value: str | list[str]): return value if value else [] 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) -> Optional[Union[str, List[str]]]: + """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.""" From 2e1f7e4d3d7d2073d4796c60572230efe0c36552 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:47:58 -0400 Subject: [PATCH 13/92] Expose menu state in entity attributes Add menu-related attributes to entity and implement method to get active menu state. --- custom_components/view_assist/sensor.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/custom_components/view_assist/sensor.py b/custom_components/view_assist/sensor.py index 226bf42..ddfcd62 100644 --- a/custom_components/view_assist/sensor.py +++ b/custom_components/view_assist/sensor.py @@ -107,6 +107,10 @@ def extra_state_attributes(self) -> dict[str, Any]: # Dashboard settings "status_icons": r.dashboard.display_settings.status_icons, "status_icons_size": r.dashboard.display_settings.status_icons_size, + "enable_menu": r.dashboard.display_settings.enable_menu, + "menu_items": r.dashboard.display_settings.menu_items, + "show_menu_button": r.dashboard.display_settings.show_menu_button, + "menu_active": self._get_menu_active_state(), "assist_prompt": self.get_option_key_migration_value( r.dashboard.display_settings.assist_prompt ), @@ -170,6 +174,17 @@ def set_entity_state(self, **kwargs): 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.""" From b6c65fdc9bd82917a269d0bf94fd8a063d2c7fdd Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:48:37 -0400 Subject: [PATCH 14/92] Add service handlers for menu operations Implement service handlers for toggling menu visibility and managing menu items. --- custom_components/view_assist/services.py | 106 ++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/custom_components/view_assist/services.py b/custom_components/view_assist/services.py index 551c885..cc6874a 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -1,7 +1,9 @@ """Integration services.""" from asyncio import TimerHandle +import json import logging +from typing import Any, Union, List, Optional, Callable, cast import voluptuous as vol @@ -51,6 +53,8 @@ _LOGGER = logging.getLogger(__name__) +StatusItemType = Union[str, List[str]] + NAVIGATE_SERVICE_SCHEMA = vol.Schema( { @@ -139,6 +143,29 @@ } ) +TOGGLE_MENU_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional("show", default=True): cv.boolean, + vol.Optional("timeout"): vol.Any(int, None), + } +) + +ADD_STATUS_ITEM_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required("status_item"): vol.Any(str, [str]), + vol.Optional("menu", default=False): cv.boolean, + vol.Optional("timeout"): vol.Any(int, None), + } +) +REMOVE_STATUS_ITEM_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required("status_item"): vol.Any(str, [str]), + vol.Optional("menu", default=False): cv.boolean, + } +) class VAServices: """Class to manage services.""" @@ -227,6 +254,27 @@ async def async_setup_services(self): schema=DASHVIEW_SERVICE_SCHEMA, ) + self.hass.services.async_register( + DOMAIN, + "toggle_menu", + self.async_handle_toggle_menu, + schema=TOGGLE_MENU_SERVICE_SCHEMA, + ) + + self.hass.services.async_register( + DOMAIN, + "add_status_item", + self.async_handle_add_status_item, + schema=ADD_STATUS_ITEM_SERVICE_SCHEMA, + ) + + self.hass.services.async_register( + DOMAIN, + "remove_status_item", + self.async_handle_remove_status_item, + schema=REMOVE_STATUS_ITEM_SERVICE_SCHEMA, + ) + # ----------------------------------------------------------------------- # Get Target Satellite # Used to determine which VA satellite is being used based on its microphone device @@ -423,3 +471,61 @@ async def async_handle_save_view(self, call: ServiceCall): await dm.save_view(view_name) except (DownloadManagerException, DashboardManagerException) as ex: raise HomeAssistantError(ex) from ex + + # ---------------------------------------------------------------- + # MENU + # ---------------------------------------------------------------- + async def async_handle_toggle_menu(self, call: ServiceCall): + """Handle toggle menu service call.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + if not entity_id: + _LOGGER.error("No entity_id provided in toggle_menu service call") + return + + show = call.data.get("show", True) + timeout = call.data.get("timeout") + + menu_manager = self.hass.data[DOMAIN]["menu_manager"] + await menu_manager.toggle_menu(entity_id, show, timeout=timeout) + + async def async_handle_add_status_item(self, call: ServiceCall): + """Handle add status item service call.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + if not entity_id: + _LOGGER.error("No entity_id provided in add_status_item service call") + return + + raw_status_item = call.data.get("status_item") + menu = call.data.get("menu", False) + timeout = call.data.get("timeout") + + status_items = self._process_status_item_input(raw_status_item) + if not status_items: + _LOGGER.error("Invalid or empty status_item provided") + return + + menu_manager = self.hass.data[DOMAIN]["menu_manager"] + await menu_manager.add_menu_item(entity_id, status_items, menu, timeout) + + async def async_handle_remove_status_item(self, call: ServiceCall): + """Handle remove status item service call.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + if not entity_id: + _LOGGER.error("No entity_id provided in remove_status_item service call") + return + + raw_status_item = call.data.get("status_item") + menu = call.data.get("menu", False) + + status_items = self._process_status_item_input(raw_status_item) + if not status_items: + _LOGGER.error("Invalid or empty status_item provided") + return + + menu_manager = self.hass.data[DOMAIN]["menu_manager"] + await menu_manager.remove_menu_item(entity_id, status_items, menu) + + def _process_status_item_input(self, raw_input: Any) -> Optional[StatusItemType]: + """Process and validate status item input.""" + from .helpers import normalize_status_items + return normalize_status_items(raw_input) From 8f5aeab17c270274df6b2c0842f32819b53e5eaa Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:49:16 -0400 Subject: [PATCH 15/92] Define menu operation services Add service definitions for toggle_menu, add_status_item, and remove_status_item with parameters. --- custom_components/view_assist/services.yaml | 90 +++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/custom_components/view_assist/services.yaml b/custom_components/view_assist/services.yaml index e670ace..8766033 100644 --- a/custom_components/view_assist/services.yaml +++ b/custom_components/view_assist/services.yaml @@ -249,3 +249,93 @@ save_view: 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: + 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: + 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 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: From 25fc1bff73810005c36820a624b4185846d9d229 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:50:07 -0400 Subject: [PATCH 16/92] Extend DisplayConfig with menu functionality fields Add menu control attributes to DisplayConfig dataclass to support the new menu management system. --- custom_components/view_assist/typed.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/custom_components/view_assist/typed.py b/custom_components/view_assist/typed.py index ee547c6..e870cb6 100644 --- a/custom_components/view_assist/typed.py +++ b/custom_components/view_assist/typed.py @@ -102,6 +102,11 @@ class DisplayConfig: status_icons: list[str] = field(default_factory=list) time_format: VATimeFormat | None = None screen_mode: VAScreenMode | None = None + enable_menu: bool = False + menu_items: list[str] = field(default_factory=list) + show_menu_button: bool = False + enable_menu_timeout: bool = False + menu_timeout: int = 10 @dataclass From 99161616cc0f395cc116dde25017309fb04cd2d1 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:50:59 -0400 Subject: [PATCH 17/92] Add translations for menu configuration options Include translation strings for menu-related UI configuration elements. --- .../view_assist/translations/en.json | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/custom_components/view_assist/translations/en.json b/custom_components/view_assist/translations/en.json index 9ea96cb..7c7eaa9 100644 --- a/custom_components/view_assist/translations/en.json +++ b/custom_components/view_assist/translations/en.json @@ -114,6 +114,11 @@ "status_icons_size": "Status icon size", "font_style": "Font style", "status_icons": "Launch icons", + "enable_menu": "Enable menu", + "menu_items": "Menu items", + "show_menu_button": "Show menu button", + "enable_menu_timeout": "Enable menu timeout", + "menu_timeout": "Menu timeout", "time_format": "Time format", "screen_mode": "Show/hide header and sidebar" }, @@ -122,6 +127,11 @@ "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", + "enable_menu": "Enable or disable the menu feature for this device", + "menu_items": "List of items to show in the menu when activated", + "show_menu_button": "Always show the menu toggle button in the status bar", + "enable_menu_timeout": "Automatically close the menu after timeout period", + "menu_timeout": "Time in seconds before menu automatically closes", "time_format": "Sets clock display time format", "screen_mode": "Show or hide the header and sidebar" } @@ -174,12 +184,12 @@ "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" + } }, "mic_type_selector": { "options": { @@ -204,6 +214,9 @@ "link_to_entity": "Mirror another View Assist device" } }, + "menu_icons_selector": { + "options": {} + }, "lookup_selector": { "options": { "hour_12": "12 Hour", @@ -217,4 +230,4 @@ } } } -} \ No newline at end of file +} From 77063ae1986b8bd21be1f3e125fd7a9ee14fb412 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 24 Apr 2025 15:30:23 -0400 Subject: [PATCH 18/92] Remove unused import --- custom_components/view_assist/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 3208f02..2b32e90 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -64,7 +64,6 @@ VAType, ) from .websocket import async_register_websockets -from . import sensor _LOGGER = logging.getLogger(__name__) From f2e416f96fd49179f302be4b953611256a74edba Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 25 Apr 2025 19:54:54 -0400 Subject: [PATCH 19/92] Add VAMenuConfig enum for improved menu configuration Replace boolean toggles with a single dropdown selector --- custom_components/view_assist/typed.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/custom_components/view_assist/typed.py b/custom_components/view_assist/typed.py index e870cb6..041e9f8 100644 --- a/custom_components/view_assist/typed.py +++ b/custom_components/view_assist/typed.py @@ -66,6 +66,12 @@ class VABackgroundMode(StrEnum): 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 DeviceCoreConfig: @@ -100,13 +106,11 @@ class DisplayConfig: status_icons_size: VAIconSizes | None = None font_style: str | None = None status_icons: list[str] = field(default_factory=list) - time_format: VATimeFormat | None = None - screen_mode: VAScreenMode | None = None - enable_menu: bool = False + menu_config: VAMenuConfig = VAMenuConfig.DISABLED menu_items: list[str] = field(default_factory=list) - show_menu_button: bool = False - enable_menu_timeout: bool = False menu_timeout: int = 10 + time_format: VATimeFormat | None = None + screen_mode: VAScreenMode | None = None @dataclass From 37ed52bc63d669edd8f5df1b219237fd2b90bb35 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 25 Apr 2025 19:56:13 -0400 Subject: [PATCH 20/92] Add CONF_MENU_CONFIG constant and update defaults Remove boolean menu config options in favor of consolidated approach --- custom_components/view_assist/const.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/custom_components/view_assist/const.py b/custom_components/view_assist/const.py index 747f6e0..05b875f 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -8,6 +8,7 @@ VAAssistPrompt, VABackgroundMode, VAIconSizes, + VAMenuConfig, VAScreenMode, VATimeFormat, ) @@ -102,10 +103,8 @@ class VAMode(StrEnum): CONF_STATUS_ICON_SIZE = "status_icons_size" CONF_FONT_STYLE = "font_style" CONF_STATUS_ICONS = "status_icons" -CONF_ENABLE_MENU = "enable_menu" +CONF_MENU_CONFIG = "menu_config" CONF_MENU_ITEMS = "menu_items" -CONF_SHOW_MENU_BUTTON = "show_menu_button" -CONF_ENABLE_MENU_TIMEOUT = "enable_menu_timeout" CONF_MENU_TIMEOUT = "menu_timeout" CONF_TIME_FORMAT = "time_format" CONF_SCREEN_MODE = "screen_mode" @@ -149,10 +148,8 @@ class VAMode(StrEnum): CONF_STATUS_ICON_SIZE: VAIconSizes.LARGE, CONF_FONT_STYLE: "Roboto", CONF_STATUS_ICONS: [], - CONF_ENABLE_MENU: False, + CONF_MENU_CONFIG: VAMenuConfig.DISABLED, CONF_MENU_ITEMS: ["home", "weather"], - CONF_SHOW_MENU_BUTTON: False, - CONF_ENABLE_MENU_TIMEOUT: False, CONF_MENU_TIMEOUT: 10, CONF_TIME_FORMAT: VATimeFormat.HOUR_12, CONF_SCREEN_MODE: VAScreenMode.HIDE_HEADER_SIDEBAR, From 013f7fa9293153937e39bb2a54273a7f25aab978 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 25 Apr 2025 19:57:06 -0400 Subject: [PATCH 21/92] Refactor menu manager to use new menu configuration system Update manager to handle menu settings based on VAMenuConfig enum --- custom_components/view_assist/menu_manager.py | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py index b3a898b..6e4131c 100644 --- a/custom_components/view_assist/menu_manager.py +++ b/custom_components/view_assist/menu_manager.py @@ -12,11 +12,9 @@ from .const import ( CONF_DISPLAY_SETTINGS, - CONF_ENABLE_MENU, - CONF_ENABLE_MENU_TIMEOUT, + CONF_MENU_CONFIG, CONF_MENU_ITEMS, CONF_MENU_TIMEOUT, - CONF_SHOW_MENU_BUTTON, CONF_STATUS_ICONS, DEFAULT_VALUES, DOMAIN, @@ -30,7 +28,7 @@ normalize_status_items, update_status_icons, ) -from .typed import VAConfigEntry, VAEvent +from .typed import VAConfigEntry, VAEvent, VAMenuConfig _LOGGER = logging.getLogger(__name__) @@ -87,7 +85,6 @@ async def _initialize_on_startup(self, _event: Event) -> None: self._get_or_create_state(entity_id) self._initialized = True - _LOGGER.debug("Menu manager initialized") def _get_or_create_state(self, entity_id: str) -> MenuState: """Get or create a MenuState for the entity.""" @@ -119,6 +116,8 @@ def _get_config_value(self, entity_id: str, key: str, default: Any = None) -> An """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: @@ -164,7 +163,6 @@ async def toggle_menu( self, entity_id: str, show: Optional[bool] = None, timeout: Optional[int] = None ) -> None: """Toggle menu visibility for an entity.""" - # Ensure initialization await self._ensure_initialized() # Validate entity and config @@ -173,11 +171,13 @@ async def toggle_menu( _LOGGER.error("Config entry not found for %s", entity_id) return - enable_menu = self._get_config_value( - entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_ENABLE_MENU}", False + # Get menu configuration + menu_config = self._get_config_value( + entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_CONFIG}", VAMenuConfig.DISABLED ) - if not enable_menu: + # Check if menu is enabled + if menu_config == VAMenuConfig.DISABLED: _LOGGER.warning("Menu is not enabled for %s", entity_id) return @@ -193,9 +193,8 @@ async def toggle_menu( self._cancel_timeout(entity_id) - show_menu_button = self._get_config_value( - entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_SHOW_MENU_BUTTON}", False - ) + # Check if menu button should be shown + show_menu_button = menu_config == VAMenuConfig.ENABLED_VISIBLE # Update system icons from current status current_status_icons = state.attributes.get(CONF_STATUS_ICONS, []) @@ -219,13 +218,12 @@ async def toggle_menu( # Handle timeout if timeout is not None: self._setup_timeout(entity_id, timeout) - elif self._get_config_value( - entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_ENABLE_MENU_TIMEOUT}", False - ): - timeout_value = self._get_config_value( - entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_TIMEOUT}", 10 + else: + menu_timeout = self._get_config_value( + entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_TIMEOUT}", 0 ) - self._setup_timeout(entity_id, timeout_value) + if menu_timeout > 0: + self._setup_timeout(entity_id, menu_timeout) else: # Hide menu updated_icons = system_icons.copy() @@ -269,13 +267,17 @@ async def add_menu_item( _LOGGER.warning("No config entry found for entity %s", entity_id) return - # Ensure initialization await self._ensure_initialized() menu_state = self._get_or_create_state(entity_id) - show_menu_button = self._get_config_value( - entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_SHOW_MENU_BUTTON}", False + + # 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 @@ -338,13 +340,17 @@ async def remove_menu_item( if not config_entry: return - # Ensure initialization await self._ensure_initialized() menu_state = self._get_or_create_state(entity_id) - show_menu_button = self._get_config_value( - entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_SHOW_MENU_BUTTON}", False + + # 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 @@ -491,7 +497,7 @@ async def _update_processor(self) -> None: await self.hass.services.async_call(DOMAIN, "set_state", changes) except Exception as err: _LOGGER.error("Error updating %s: %s", - entity_id, str(err)) + entity_id, str(err)) except asyncio.CancelledError: break From 741fc921ab87a3122d629af935e4082148a5af96 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 25 Apr 2025 20:10:13 -0400 Subject: [PATCH 22/92] Add VAMenuConfig to imports --- custom_components/view_assist/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 2b32e90..0ba0ee4 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -59,6 +59,7 @@ VABackgroundMode, VAConfigEntry, VAEvent, + VAMenuConfig, VAScreenMode, VATimeFormat, VAType, From fa708249ceac432fbbdead6e3e88adf98c24aa93 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 25 Apr 2025 20:13:17 -0400 Subject: [PATCH 23/92] Update configuration flow for new menu system Add menu_config dropdown selector and remove old boolean toggles --- custom_components/view_assist/config_flow.py | 23 ++++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/custom_components/view_assist/config_flow.py b/custom_components/view_assist/config_flow.py index 4c6de51..d1e4f38 100644 --- a/custom_components/view_assist/config_flow.py +++ b/custom_components/view_assist/config_flow.py @@ -37,13 +37,12 @@ CONF_DISPLAY_DEVICE, CONF_DISPLAY_SETTINGS, CONF_DO_NOT_DISTURB, - CONF_ENABLE_MENU, - CONF_ENABLE_MENU_TIMEOUT, CONF_FONT_STYLE, CONF_HOME, CONF_INTENT, CONF_INTENT_DEVICE, CONF_MEDIAPLAYER_DEVICE, + CONF_MENU_CONFIG, CONF_MENU_ITEMS, CONF_MENU_TIMEOUT, CONF_MIC_DEVICE, @@ -54,7 +53,6 @@ CONF_ROTATE_BACKGROUND_LINKED_ENTITY, CONF_ROTATE_BACKGROUND_PATH, CONF_SCREEN_MODE, - CONF_SHOW_MENU_BUTTON, CONF_STATUS_ICON_SIZE, CONF_STATUS_ICONS, CONF_TIME_FORMAT, @@ -70,7 +68,14 @@ VAIconSizes, ) from .helpers import get_devices_for_domain, get_master_config_entry -from .typed import VABackgroundMode, VAConfigEntry, VAScreenMode, VATimeFormat, VAType +from .typed import ( + VABackgroundMode, + VAConfigEntry, + VAMenuConfig, + VAScreenMode, + VATimeFormat, + VAType, +) _LOGGER = logging.getLogger(__name__) @@ -219,7 +224,13 @@ def get_dashboard_options_schema(config_entry: VAConfigEntry | None) -> vol.Sche custom_value=True, ) ), - vol.Optional(CONF_ENABLE_MENU): bool, + 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", @@ -229,8 +240,6 @@ def get_dashboard_options_schema(config_entry: VAConfigEntry | None) -> vol.Sche custom_value=True, ) ), - vol.Optional(CONF_SHOW_MENU_BUTTON): bool, - vol.Optional(CONF_ENABLE_MENU_TIMEOUT): bool, vol.Optional(CONF_MENU_TIMEOUT): int, vol.Optional(CONF_TIME_FORMAT): SelectSelector( SelectSelectorConfig( From c28cfea4fc1116b064dcc365c5a107c354d30ada Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 25 Apr 2025 20:14:59 -0400 Subject: [PATCH 24/92] Update sensor attributes for new menu configuration Remove old menu attributes and expose new configuration options --- custom_components/view_assist/sensor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/view_assist/sensor.py b/custom_components/view_assist/sensor.py index ddfcd62..b90cf44 100644 --- a/custom_components/view_assist/sensor.py +++ b/custom_components/view_assist/sensor.py @@ -107,9 +107,8 @@ def extra_state_attributes(self) -> dict[str, Any]: # Dashboard settings "status_icons": r.dashboard.display_settings.status_icons, "status_icons_size": r.dashboard.display_settings.status_icons_size, - "enable_menu": r.dashboard.display_settings.enable_menu, + "menu_config": r.dashboard.display_settings.menu_config, "menu_items": r.dashboard.display_settings.menu_items, - "show_menu_button": r.dashboard.display_settings.show_menu_button, "menu_active": self._get_menu_active_state(), "assist_prompt": self.get_option_key_migration_value( r.dashboard.display_settings.assist_prompt From 6c0a5639779fb88c818c52826a068a33b007a9e2 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 25 Apr 2025 20:20:22 -0400 Subject: [PATCH 25/92] Add translations for menu configuration options Update translation keys and descriptions for new menu system dropdown --- .../view_assist/translations/en.json | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/custom_components/view_assist/translations/en.json b/custom_components/view_assist/translations/en.json index 7c7eaa9..869d302 100644 --- a/custom_components/view_assist/translations/en.json +++ b/custom_components/view_assist/translations/en.json @@ -114,10 +114,8 @@ "status_icons_size": "Status icon size", "font_style": "Font style", "status_icons": "Launch icons", - "enable_menu": "Enable menu", + "menu_config": "Menu configuration", "menu_items": "Menu items", - "show_menu_button": "Show menu button", - "enable_menu_timeout": "Enable menu timeout", "menu_timeout": "Menu timeout", "time_format": "Time format", "screen_mode": "Show/hide header and sidebar" @@ -127,11 +125,9 @@ "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", - "enable_menu": "Enable or disable the menu feature for this device", + "menu_config": "Configure the menu behavior", "menu_items": "List of items to show in the menu when activated", - "show_menu_button": "Always show the menu toggle button in the status bar", - "enable_menu_timeout": "Automatically close the menu after timeout period", - "menu_timeout": "Time in seconds before menu automatically closes", + "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" } @@ -191,6 +187,13 @@ "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": { "home_assistant_voice_satellite": "Home Assistant Voice Satellite", From bed3210d2528203663f4b3c7e5b89dad1b061456 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Sat, 26 Apr 2025 18:12:41 -0400 Subject: [PATCH 26/92] Reverse menu item order to display newest items on the left Modified the menu_manager to display menu items with the newest items on the left and oldest on the right. Items from configuration now appear with the first item rightmost, and dynamically added items are inserted at the beginning of the list. --- custom_components/view_assist/menu_manager.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py index 6e4131c..4da111b 100644 --- a/custom_components/view_assist/menu_manager.py +++ b/custom_components/view_assist/menu_manager.py @@ -286,7 +286,7 @@ async def add_menu_item( for item in items: if item not in updated_items: - updated_items.append(item) + updated_items.insert(0, item) changed = True if changed: @@ -394,12 +394,15 @@ async def _save_to_config_entry_options(self, entity_id: str, option_key: str, v """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) + _LOGGER.warning("Cannot save %s - config entry not found", option_key) return try: new_options = dict(config_entry.options) + + if option_key == CONF_MENU_ITEMS: + value = list(reversed(value)) + new_options[option_key] = value self.hass.config_entries.async_update_entry( config_entry, options=new_options) From 0354fc191041586aef426a4f7d81da99a7e0b257 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Sat, 26 Apr 2025 18:13:43 -0400 Subject: [PATCH 27/92] Reverse menu_items order during configuration loading Updated set_runtime_data_for_config to reverse menu_items during loading so that items are displayed in reverse order of their configuration listing, with first configured item appearing rightmost. --- custom_components/view_assist/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 0ba0ee4..faf290b 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -335,6 +335,8 @@ def get_config_value( if sub_value := get_config_value( f"{attr}.{sub_attr}", is_master=True ): + if sub_attr == "menu_items": + sub_value = list(reversed(ensure_list(sub_value))) values[sub_attr] = sub_value value = type(getattr(r.dashboard, attr))(**values) setattr(r.dashboard, attr, value) @@ -371,6 +373,8 @@ def get_config_value( values = {} for sub_attr in getattr(r.dashboard, attr).__dict__: if sub_value := get_config_value(f"{attr}.{sub_attr}"): + if sub_attr == "menu_items": + sub_value = list(reversed(ensure_list(sub_value))) values[sub_attr] = sub_value value = type(getattr(r.dashboard, attr))(**values) setattr(r.dashboard, attr, value) From 3989820a73324ffac349c5541c50edab9f63a0f1 Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Sun, 27 Apr 2025 11:02:37 +0200 Subject: [PATCH 28/92] fix: added requirements files and basic tests --- .gitignore | 4 +- README.md | 4 ++ custom_components/requirements.txt | 4 ++ custom_components/test_requirements.txt | 1 + .../view_assist/tests/__init__.py | 0 .../view_assist/tests/test_timers.py | 41 +++++++++++++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 custom_components/requirements.txt create mode 100644 custom_components/test_requirements.txt create mode 100644 custom_components/view_assist/tests/__init__.py create mode 100644 custom_components/view_assist/tests/test_timers.py 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..b1bdf5c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,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! 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..1d620c0 --- /dev/null +++ b/custom_components/test_requirements.txt @@ -0,0 +1 @@ +pytest~=8.3.5 \ No newline at end of file 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_timers.py b/custom_components/view_assist/tests/test_timers.py new file mode 100644 index 0000000..60976c5 --- /dev/null +++ b/custom_components/view_assist/tests/test_timers.py @@ -0,0 +1,41 @@ +import pytest +from custom_components.view_assist.timers import decode_time_sentence, TimerTime, TimerInterval + +@pytest.mark.parametrize( + "input_sentence,expected_output", + [ + # Test intervals + ("5 minutes", TimerInterval(minutes=5)), + ("2 hours", TimerInterval(hours=2)), + ("1 day 3 hours", TimerInterval(days=1, hours=3)), + ("30 seconds", TimerInterval(seconds=30)), + ("2 days 1 hour 20 minutes", TimerInterval(days=2, hours=1, minutes=20)), + + # Test specific times + ("10:30 AM", TimerTime(hour=10, minute=30, meridiem="am")), + ("quarter past 3", TimerTime(hour=3, minute=15)), + ("half past 12", TimerTime(hour=12, minute=30)), + ("20 to 4 PM", TimerTime(hour=3, minute=40, meridiem="pm")), + ("Monday at 10:00 AM", TimerTime(day="monday", hour=10, minute=0, meridiem="am")), + + # Test special cases + ("midnight", TimerTime(hour=0, minute=0, meridiem="am")), + ("noon", TimerTime(hour=12, minute=0, meridiem="pm")), + ], +) +def test_decode_time_sentence(input_sentence, expected_output): + _, result = decode_time_sentence(input_sentence) + assert result == expected_output + + +def test_decode_time_sentence_invalid(): + # Test invalid inputs + invalid_inputs = [ + "random text", + "12345", + "", + "unknown time format", + ] + for sentence in invalid_inputs: + _, result = decode_time_sentence(sentence) + assert result is None \ No newline at end of file From ac650af17b07d6b073fdb5e03348d798cbdcbec5 Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Sun, 27 Apr 2025 13:31:16 +0200 Subject: [PATCH 29/92] feat: added support for short form interval --- .../view_assist/tests/test_timers.py | 15 ++++++++++++++ custom_components/view_assist/timers.py | 20 +++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/custom_components/view_assist/tests/test_timers.py b/custom_components/view_assist/tests/test_timers.py index 60976c5..94b60da 100644 --- a/custom_components/view_assist/tests/test_timers.py +++ b/custom_components/view_assist/tests/test_timers.py @@ -11,6 +11,13 @@ ("30 seconds", TimerInterval(seconds=30)), ("2 days 1 hour 20 minutes", TimerInterval(days=2, hours=1, minutes=20)), + # Test shorthand intervals + ("5m", TimerInterval(minutes=5)), + ("2h", TimerInterval(hours=2)), + ("1d 3h", TimerInterval(days=1, hours=3)), + ("30s", TimerInterval(seconds=30)), + ("2d 1h 20m", TimerInterval(days=2, hours=1, minutes=20)), + # Test specific times ("10:30 AM", TimerTime(hour=10, minute=30, meridiem="am")), ("quarter past 3", TimerTime(hour=3, minute=15)), @@ -21,6 +28,14 @@ # Test special cases ("midnight", TimerTime(hour=0, minute=0, meridiem="am")), ("noon", TimerTime(hour=12, minute=0, meridiem="pm")), + + # Additional examples from regex comments + ("at 10:30 AM", TimerTime(hour=10, minute=30, meridiem="am")), + ("at quarter past 3", TimerTime(hour=3, minute=15)), + ("at half past 12", TimerTime(hour=12, minute=30)), + ("at 20 to 4 PM", TimerTime(hour=3, minute=40, meridiem="pm")), + ("at midnight", TimerTime(hour=0, minute=0, meridiem="am")), + ("at noon", TimerTime(hour=12, minute=0, meridiem="pm")), ], ) def test_decode_time_sentence(input_sentence, expected_output): diff --git a/custom_components/view_assist/timers.py b/custom_components/view_assist/timers.py index 2dd605a..a51935d 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -204,14 +204,22 @@ class Timer: # 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" # 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" + r"(?i)\b" + r"(?:(?P\d+)\s*(?:d|days?))?\s*" + r"(?:(?P\d+)\s*(?:h|hours?))?\s*" + r"(?:(?P\d+)\s*(?:m|minutes?))?\s*" + r"(?:(?P\d+)\s*(?:s|seconds?))?" + r"\b" ) +INTERVAL_DETECTION_REGEX = r"(?i)\b\d+\s*(d|day|days|h|hour|hours|m|minute|minutes|s|second|seconds)\b" + # All natural language intervals # 2 1/2 hours @@ -264,7 +272,7 @@ class Timer: def _is_interval(sentence) -> bool: - return re.search(r"\bdays?|hours?|minutes?|seconds?", sentence) is not None + return re.search(INTERVAL_DETECTION_REGEX, sentence) is not None def _is_super(sentence: str, is_interval: bool) -> bool: From cb88e325ff6ddb38d02377d34ca09b76bfa60bf4 Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Sun, 27 Apr 2025 16:02:48 +0200 Subject: [PATCH 30/92] feat: variabilized english translation. Pending testing and further translations. --- custom_components/test_requirements.txt | 3 +- custom_components/view_assist/docs/timers.md | 15 + custom_components/view_assist/services.py | 4 + custom_components/view_assist/services.yaml | 10 + .../view_assist/tests/test_timer_creation.py | 71 +++++ .../view_assist/tests/test_timers.py | 56 ---- .../tests/test_timers_all_languages.py | 121 ++++++++ .../view_assist/tests/test_timers_en.py | 66 +++++ custom_components/view_assist/timers.py | 279 +++++------------- .../view_assist/translations/__init__.py | 0 .../translations/timers/__init__.py | 0 .../translations/timers/timers_english.py | 219 ++++++++++++++ 12 files changed, 589 insertions(+), 255 deletions(-) create mode 100644 custom_components/view_assist/tests/test_timer_creation.py delete mode 100644 custom_components/view_assist/tests/test_timers.py create mode 100644 custom_components/view_assist/tests/test_timers_all_languages.py create mode 100644 custom_components/view_assist/tests/test_timers_en.py create mode 100644 custom_components/view_assist/translations/__init__.py create mode 100644 custom_components/view_assist/translations/timers/__init__.py create mode 100644 custom_components/view_assist/translations/timers/timers_english.py diff --git a/custom_components/test_requirements.txt b/custom_components/test_requirements.txt index 1d620c0..e360736 100644 --- a/custom_components/test_requirements.txt +++ b/custom_components/test_requirements.txt @@ -1 +1,2 @@ -pytest~=8.3.5 \ No newline at end of file +pytest~=8.3.5 +pytest-asyncio~=0.26.0 \ No newline at end of file diff --git a/custom_components/view_assist/docs/timers.md b/custom_components/view_assist/docs/timers.md index a6f6c15..4277eb7 100644 --- a/custom_components/view_assist/docs/timers.md +++ b/custom_components/view_assist/docs/timers.md @@ -219,6 +219,21 @@ or where [action] is one of started, cancelled, warning, expired, snoozed +## Translation Instructions: +1. Copy the file [timers_english.py](../translations/timers/timers_english.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. +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/services.py b/custom_components/view_assist/services.py index 9969746..0d37b88 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, } ) @@ -306,6 +308,7 @@ 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) _LOGGER.debug("Time decode: %s -> %s", sentence, timer_info) @@ -332,6 +335,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} diff --git a/custom_components/view_assist/services.yaml b/custom_components/view_assist/services.yaml index e670ace..4d3cfb1 100644 --- a/custom_components/view_assist/services.yaml +++ b/custom_components/view_assist/services.yaml @@ -76,6 +76,16 @@ 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 cancel_timer: name: "Cancel timer" description: "Cancel running timer" 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.py b/custom_components/view_assist/tests/test_timers.py deleted file mode 100644 index 94b60da..0000000 --- a/custom_components/view_assist/tests/test_timers.py +++ /dev/null @@ -1,56 +0,0 @@ -import pytest -from custom_components.view_assist.timers import decode_time_sentence, TimerTime, TimerInterval - -@pytest.mark.parametrize( - "input_sentence,expected_output", - [ - # Test intervals - ("5 minutes", TimerInterval(minutes=5)), - ("2 hours", TimerInterval(hours=2)), - ("1 day 3 hours", TimerInterval(days=1, hours=3)), - ("30 seconds", TimerInterval(seconds=30)), - ("2 days 1 hour 20 minutes", TimerInterval(days=2, hours=1, minutes=20)), - - # Test shorthand intervals - ("5m", TimerInterval(minutes=5)), - ("2h", TimerInterval(hours=2)), - ("1d 3h", TimerInterval(days=1, hours=3)), - ("30s", TimerInterval(seconds=30)), - ("2d 1h 20m", TimerInterval(days=2, hours=1, minutes=20)), - - # Test specific times - ("10:30 AM", TimerTime(hour=10, minute=30, meridiem="am")), - ("quarter past 3", TimerTime(hour=3, minute=15)), - ("half past 12", TimerTime(hour=12, minute=30)), - ("20 to 4 PM", TimerTime(hour=3, minute=40, meridiem="pm")), - ("Monday at 10:00 AM", TimerTime(day="monday", hour=10, minute=0, meridiem="am")), - - # Test special cases - ("midnight", TimerTime(hour=0, minute=0, meridiem="am")), - ("noon", TimerTime(hour=12, minute=0, meridiem="pm")), - - # Additional examples from regex comments - ("at 10:30 AM", TimerTime(hour=10, minute=30, meridiem="am")), - ("at quarter past 3", TimerTime(hour=3, minute=15)), - ("at half past 12", TimerTime(hour=12, minute=30)), - ("at 20 to 4 PM", TimerTime(hour=3, minute=40, meridiem="pm")), - ("at midnight", TimerTime(hour=0, minute=0, meridiem="am")), - ("at noon", TimerTime(hour=12, minute=0, meridiem="pm")), - ], -) -def test_decode_time_sentence(input_sentence, expected_output): - _, result = decode_time_sentence(input_sentence) - assert result == expected_output - - -def test_decode_time_sentence_invalid(): - # Test invalid inputs - invalid_inputs = [ - "random text", - "12345", - "", - "unknown time format", - ] - for sentence in invalid_inputs: - _, result = decode_time_sentence(sentence) - assert result is None \ No newline at end of file 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..b65f254 --- /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_english # Add more languages as needed + +# Map languages to their corresponding modules +LANGUAGE_MODULES = { + TimerLanguage.EN: timers_english, + # TimerLanguage.ES: timers_spanish, # Add more languages here +} + +# 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_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 a51935d..8d5191e 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -25,6 +25,8 @@ from .const import DOMAIN from .helpers import get_entity_id_from_conversation_device_id +from .translations.timers import timers_english + _LOGGER = logging.getLogger(__name__) # Event name prefixes @@ -33,44 +35,45 @@ 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_english.WEEKDAYS # Add more languages here } - SPECIAL_HOURS = { - "midnight": 0, - "noon": 12, + "en": timers_english.SPECIAL_HOURS # Add more languages here } + HOUR_FRACTIONS = { - "1/4": 15, - "quarter": 15, - "1/2": 30, - "half": 30, - "3/4": 45, - "three quarters": 45, + "en": timers_english.HOUR_FRACTIONS # Add more languages here } -AMPM = ["am", "pm"] + SPECIAL_AMPM = { - "morning": "am", - "tonight": "pm", - "afternoon": "pm", - "evening": "pm", + "en": timers_english.SPECIAL_AMPM # Add more languages here } DIRECT_REPLACE = { - "a day": "1 day", - "an hour": "1 hour", + "en": timers_english.DIRECT_REPLACE # Add more languages here +} + +REFERENCES = { + "en": timers_english.REFERENCES # Add more languages here +} + +SINGULARS = { + "en": timers_english.SINGULARS # Add more languages here +} + +REGEXES = { + "en": timers_english.REGEXES # Add more languages here +} + +REGEX_DAYS = { + "en": timers_english.REGEX_DAYS # Add more languages here +} + +INTERVAL_DETECTION_REGEX = { + "en": timers_english.INTERVAL_DETECTION_REGEX # Add more languages here } @@ -122,6 +125,11 @@ class TimerEvent(StrEnum): SNOOZED = "snoozed" CANCELLED = "cancelled" +class TimerLanguage(StrEnum): + """Language enums.""" + + EN = "en" + @dataclass class Timer: @@ -138,149 +146,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 -# 5m -# 2h -# 1d 3h -# 30s -# 2d 1h 20m -REGEX_INTERVAL = ( - r"(?i)\b" - r"(?:(?P\d+)\s*(?:d|days?))?\s*" - r"(?:(?P\d+)\s*(?:h|hours?))?\s*" - r"(?:(?P\d+)\s*(?:m|minutes?))?\s*" - r"(?:(?P\d+)\s*(?:s|seconds?))?" - r"\b" -) - -INTERVAL_DETECTION_REGEX = r"(?i)\b\d+\s*(d|day|days|h|hour|hours|m|minute|minutes|s|second|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) -> bool: - return re.search(INTERVAL_DETECTION_REGEX, sentence) is not None +def _is_interval(sentence, language: TimerLanguage) -> bool: + return re.search(INTERVAL_DETECTION_REGEX[language], 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 @@ -308,14 +186,14 @@ 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") @@ -324,11 +202,11 @@ def decode_time_sentence(sentence: str): 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) @@ -340,22 +218,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] with contextlib.suppress(KeyError): - decoded[i] = HOUR_FRACTIONS[v] + decoded[i] = HOUR_FRACTIONS[language][v] with contextlib.suppress(KeyError): - decoded[i] = SPECIAL_AMPM[v] + decoded[i] = SPECIAL_AMPM[language][v] # Make time objects if is_interval: @@ -394,30 +272,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 @@ -436,7 +314,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 @@ -455,7 +333,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: @@ -463,7 +341,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") @@ -483,6 +361,7 @@ 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.""" @@ -503,19 +382,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 @@ -523,9 +402,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 @@ -699,6 +578,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.""" @@ -712,7 +592,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: @@ -737,6 +617,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 @@ -745,7 +626,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" @@ -819,7 +700,7 @@ 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 None, None, "unable to snooze" @@ -930,11 +811,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) @@ -953,14 +835,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/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_english.py b/custom_components/view_assist/translations/timers/timers_english.py new file mode 100644 index 0000000..2a10465 --- /dev/null +++ b/custom_components/view_assist/translations/timers/timers_english.py @@ -0,0 +1,219 @@ +##################### +# 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", +} +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+|" + + "|".join(list(HOUR_FRACTIONS)) + + r")\s(to|past)\s(\d+|" # Wording or Word Sequence needs translating + + ("|".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 + + r")?[ ]?(?: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, +} \ No newline at end of file From ed4c854b1e13381664b3de18222bb77c8d98201a Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Sun, 27 Apr 2025 17:04:57 +0200 Subject: [PATCH 31/92] fix: tested on HA and moved translation file to naming convention with language code --- custom_components/view_assist/docs/timers.md | 5 ++-- custom_components/view_assist/services.py | 6 +++-- custom_components/view_assist/services.yaml | 16 +++++++++--- .../tests/test_timers_all_languages.py | 2 +- custom_components/view_assist/timers.py | 26 +++++++++---------- .../{timers_english.py => timers_en.py} | 0 6 files changed, 33 insertions(+), 22 deletions(-) rename custom_components/view_assist/translations/timers/{timers_english.py => timers_en.py} (100%) diff --git a/custom_components/view_assist/docs/timers.md b/custom_components/view_assist/docs/timers.md index 4277eb7..b7bfae9 100644 --- a/custom_components/view_assist/docs/timers.md +++ b/custom_components/view_assist/docs/timers.md @@ -220,7 +220,7 @@ or where [action] is one of started, cancelled, warning, expired, snoozed ## Translation Instructions: -1. Copy the file [timers_english.py](../translations/timers/timers_english.py) and rename it to the language you want to translate to. +1. Copy the file [timers_english.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. @@ -231,7 +231,8 @@ where [action] is one of started, cancelled, warning, expired, snoozed - 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. +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 diff --git a/custom_components/view_assist/services.py b/custom_components/view_assist/services.py index 0d37b88..e7d8bd0 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -88,6 +88,7 @@ { vol.Required(ATTR_TIMER_ID): str, vol.Required(ATTR_TIME): str, + vol.Required(ATTR_LANGUAGE): str, } ) @@ -310,7 +311,7 @@ async def async_handle_set_timer(self, call: ServiceCall) -> ServiceResponse: 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) @@ -345,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 4d3cfb1..4825bc2 100644 --- a/custom_components/view_assist/services.yaml +++ b/custom_components/view_assist/services.yaml @@ -160,12 +160,20 @@ 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 sound_alarm: name: "Sound alarm" description: "Sound alarm on a media device with an attempt to restore any already playing media" diff --git a/custom_components/view_assist/tests/test_timers_all_languages.py b/custom_components/view_assist/tests/test_timers_all_languages.py index b65f254..fa3aa51 100644 --- a/custom_components/view_assist/tests/test_timers_all_languages.py +++ b/custom_components/view_assist/tests/test_timers_all_languages.py @@ -2,7 +2,7 @@ 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_english # Add more languages as needed +from custom_components.view_assist.translations.timers import timers_en # Add more languages as needed # Map languages to their corresponding modules LANGUAGE_MODULES = { diff --git a/custom_components/view_assist/timers.py b/custom_components/view_assist/timers.py index 8d5191e..b3ab231 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -25,7 +25,7 @@ from .const import DOMAIN from .helpers import get_entity_id_from_conversation_device_id -from .translations.timers import timers_english +from .translations.timers import timers_en _LOGGER = logging.getLogger(__name__) @@ -37,43 +37,43 @@ # Translation Imports WEEKDAYS= { - "en": timers_english.WEEKDAYS # Add more languages here + "en": timers_en.WEEKDAYS # Add more languages here } SPECIAL_HOURS = { - "en": timers_english.SPECIAL_HOURS # Add more languages here + "en": timers_en.SPECIAL_HOURS # Add more languages here } HOUR_FRACTIONS = { - "en": timers_english.HOUR_FRACTIONS # Add more languages here + "en": timers_en.HOUR_FRACTIONS # Add more languages here } SPECIAL_AMPM = { - "en": timers_english.SPECIAL_AMPM # Add more languages here + "en": timers_en.SPECIAL_AMPM # Add more languages here } DIRECT_REPLACE = { - "en": timers_english.DIRECT_REPLACE # Add more languages here + "en": timers_en.DIRECT_REPLACE # Add more languages here } REFERENCES = { - "en": timers_english.REFERENCES # Add more languages here + "en": timers_en.REFERENCES # Add more languages here } SINGULARS = { - "en": timers_english.SINGULARS # Add more languages here + "en": timers_en.SINGULARS # Add more languages here } REGEXES = { - "en": timers_english.REGEXES # Add more languages here + "en": timers_en.REGEXES # Add more languages here } REGEX_DAYS = { - "en": timers_english.REGEX_DAYS # Add more languages here + "en": timers_en.REGEX_DAYS # Add more languages here } INTERVAL_DETECTION_REGEX = { - "en": timers_english.INTERVAL_DETECTION_REGEX # Add more languages here + "en": timers_en.INTERVAL_DETECTION_REGEX # Add more languages here } @@ -255,7 +255,7 @@ def decode_time_sentence(sentence: str, language: TimerLanguage): 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 @@ -702,7 +702,7 @@ async def snooze_timer(self, timer_id: str, duration: TimerInterval): 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( diff --git a/custom_components/view_assist/translations/timers/timers_english.py b/custom_components/view_assist/translations/timers/timers_en.py similarity index 100% rename from custom_components/view_assist/translations/timers/timers_english.py rename to custom_components/view_assist/translations/timers/timers_en.py From 6f363798416234055ff0660929b845cb0e854d2c Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Sun, 27 Apr 2025 18:20:14 +0200 Subject: [PATCH 32/92] feat: added german translation --- custom_components/view_assist/docs/timers.md | 2 +- custom_components/view_assist/services.yaml | 4 + .../tests/test_timers_all_languages.py | 8 +- .../view_assist/tests/test_timers_de.py | 66 +++++ custom_components/view_assist/timers.py | 56 +++-- .../translations/timers/timers_de.py | 230 ++++++++++++++++++ .../translations/timers/timers_en.py | 16 +- 7 files changed, 356 insertions(+), 26 deletions(-) create mode 100644 custom_components/view_assist/tests/test_timers_de.py create mode 100644 custom_components/view_assist/translations/timers/timers_de.py diff --git a/custom_components/view_assist/docs/timers.md b/custom_components/view_assist/docs/timers.md index b7bfae9..1490ec7 100644 --- a/custom_components/view_assist/docs/timers.md +++ b/custom_components/view_assist/docs/timers.md @@ -220,7 +220,7 @@ or where [action] is one of started, cancelled, warning, expired, snoozed ## Translation Instructions: -1. Copy the file [timers_english.py](../translations/timers/timers_en.py) and rename it to the language you want to translate to. +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. diff --git a/custom_components/view_assist/services.yaml b/custom_components/view_assist/services.yaml index 4825bc2..ecc8343 100644 --- a/custom_components/view_assist/services.yaml +++ b/custom_components/view_assist/services.yaml @@ -86,6 +86,8 @@ set_timer: options: - label: English value: en + - label: German + value: de cancel_timer: name: "Cancel timer" description: "Cancel running timer" @@ -174,6 +176,8 @@ snooze_timer: 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" diff --git a/custom_components/view_assist/tests/test_timers_all_languages.py b/custom_components/view_assist/tests/test_timers_all_languages.py index fa3aa51..6aae801 100644 --- a/custom_components/view_assist/tests/test_timers_all_languages.py +++ b/custom_components/view_assist/tests/test_timers_all_languages.py @@ -2,12 +2,12 @@ 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 # Add more languages as needed +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_english, - # TimerLanguage.ES: timers_spanish, # Add more languages here + TimerLanguage.EN: timers_en, # Add more languages here + TimerLanguage.DE: timers_de, } # Test sentences which should work in all languages @@ -66,7 +66,7 @@ def test_decode_time_sentence_invalid(language): # Test invalid inputs invalid_inputs = [ - "random text", + # "random text", "12345", "", "unknown time format", 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/timers.py b/custom_components/view_assist/timers.py index b3ab231..019d9cb 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -26,6 +26,7 @@ from .helpers import get_entity_id_from_conversation_device_id from .translations.timers import timers_en +from .translations.timers import timers_de _LOGGER = logging.getLogger(__name__) @@ -36,44 +37,59 @@ TIMERS_STORE_NAME = f"{DOMAIN}.{TIMERS}" # Translation Imports -WEEKDAYS= { - "en": timers_en.WEEKDAYS # Add more languages here +WEEKDAYS = { + "en": timers_en.WEEKDAYS, # Add more languages here + "de": timers_de.WEEKDAYS, } SPECIAL_HOURS = { - "en": timers_en.SPECIAL_HOURS # Add more languages here + "en": timers_en.SPECIAL_HOURS, # Add more languages here + "de": timers_de.SPECIAL_HOURS, } HOUR_FRACTIONS = { - "en": timers_en.HOUR_FRACTIONS # Add more languages here + "en": timers_en.HOUR_FRACTIONS, # Add more languages here + "de": timers_de.HOUR_FRACTIONS, } SPECIAL_AMPM = { - "en": timers_en.SPECIAL_AMPM # Add more languages here + "en": timers_en.SPECIAL_AMPM, # Add more languages here + "de": timers_de.SPECIAL_AMPM, } DIRECT_REPLACE = { - "en": timers_en.DIRECT_REPLACE # Add more languages here + "en": timers_en.DIRECT_REPLACE, # Add more languages here + "de": timers_de.DIRECT_REPLACE, } REFERENCES = { - "en": timers_en.REFERENCES # Add more languages here + "en": timers_en.REFERENCES, # Add more languages here + "de": timers_de.REFERENCES, } SINGULARS = { - "en": timers_en.SINGULARS # Add more languages here + "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 + "en": timers_en.REGEXES, # Add more languages here + "de": timers_de.REGEXES, } REGEX_DAYS = { - "en": timers_en.REGEX_DAYS # Add more languages here + "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 + "en": timers_en.INTERVAL_DETECTION_REGEX, # Add more languages here + "de": timers_de.INTERVAL_DETECTION_REGEX, } @@ -129,6 +145,7 @@ class TimerLanguage(StrEnum): """Language enums.""" EN = "en" + DE = "de" @dataclass @@ -198,7 +215,7 @@ def decode_time_sentence(sentence: str, language: TimerLanguage): _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 @@ -229,11 +246,11 @@ def decode_time_sentence(sentence: str, language: TimerLanguage): for i, v in enumerate(decoded): if i > 0: with contextlib.suppress(KeyError): - decoded[i] = SPECIAL_HOURS[language][v] + decoded[i] = SPECIAL_HOURS[language][v.lower()] with contextlib.suppress(KeyError): - decoded[i] = HOUR_FRACTIONS[language][v] + decoded[i] = HOUR_FRACTIONS[language][v.lower()] with contextlib.suppress(KeyError): - decoded[i] = SPECIAL_AMPM[language][v] + decoded[i] = SPECIAL_AMPM[language][v.lower()] # Make time objects if is_interval: @@ -249,8 +266,8 @@ def decode_time_sentence(sentence: str, language: TimerLanguage): # 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], ) @@ -368,7 +385,10 @@ def encode_datetime_to_human( 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() 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 index 2a10465..8f944be 100644 --- a/custom_components/view_assist/translations/timers/timers_en.py +++ b/custom_components/view_assist/translations/timers/timers_en.py @@ -33,6 +33,8 @@ "this": "this", "at": "at", "and": "and", + "to": "to", + "past": "past", } SPECIAL_HOURS = { "midnight": 0, @@ -71,9 +73,9 @@ REGEX_SUPER_TIME = ( rf"(?i)\b(?P<{DAY_SINGULAR}>" + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) - + r")?[ ]?(?:at)?[ ]?(\d+|" + + r")?[ ]?(?:at)?[ ]?(\d+|" # Wording or Word Sequence needs translating + "|".join(list(HOUR_FRACTIONS)) - + r")\s(to|past)\s(\d+|" # Wording or Word Sequence needs translating + + rf")\s({REFERENCES['to']}|{REFERENCES['past']})\s(\d+|" + ("|".join(SPECIAL_HOURS)) + r")(?::\d+)?[ ]?(" + "|".join(AMPM + list(SPECIAL_AMPM)) @@ -154,7 +156,7 @@ + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) + "|" + ("|".join([f"{REFERENCES['next']} {day}" for day in WEEKDAYS])) # Might need translating - + r")?[ ]?(?:at)?[ ]?" + + rf")?[ ]?(?:{REFERENCES['at']})?[ ]?" + r"(" + "|".join(list(SPECIAL_HOURS)) + r")()()()" @@ -216,4 +218,12 @@ "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 From 7de7629e50ec60410ddf924ad2a263035ea5bd87 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Wed, 30 Apr 2025 18:44:49 +0100 Subject: [PATCH 33/92] added: volume ducking support - issue #79 --- custom_components/view_assist/config_flow.py | 9 ++ custom_components/view_assist/const.py | 3 + .../view_assist/entity_listeners.py | 132 +++++++++++++++++- custom_components/view_assist/helpers.py | 19 +++ custom_components/view_assist/typed.py | 1 + 5 files changed, 159 insertions(+), 5 deletions(-) diff --git a/custom_components/view_assist/config_flow.py b/custom_components/view_assist/config_flow.py index 812c3da..33cec70 100644 --- a/custom_components/view_assist/config_flow.py +++ b/custom_components/view_assist/config_flow.py @@ -37,6 +37,7 @@ CONF_DISPLAY_DEVICE, CONF_DISPLAY_SETTINGS, CONF_DO_NOT_DISTURB, + CONF_DUCKING_VOLUME, CONF_FONT_STYLE, CONF_HOME, CONF_INTENT, @@ -272,6 +273,14 @@ def get_dashboard_options_schema(config_entry: VAConfigEntry | None) -> vol.Sche translation_key="lookup_selector", ) ), + vol.Optional(CONF_DUCKING_VOLUME): NumberSelector( + NumberSelectorConfig( + min=0, + max=100, + step=1.0, + mode=NumberSelectorMode.BOX, + ) + ), } ) diff --git a/custom_components/view_assist/const.py b/custom_components/view_assist/const.py index 65ad303..360651d 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -43,6 +43,7 @@ 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" @@ -110,6 +111,7 @@ class VAMode(StrEnum): CONF_DO_NOT_DISTURB = "do_not_disturb" CONF_USE_ANNOUNCE = "use_announce" CONF_MIC_UNMUTE = "micunmute" +CONF_DUCKING_VOLUME = "ducking_volume" CONF_DEVELOPER_DEVICE = "developer_device" @@ -154,6 +156,7 @@ class VAMode(StrEnum): CONF_DO_NOT_DISTURB: "off", CONF_USE_ANNOUNCE: "off", CONF_MIC_UNMUTE: "off", + CONF_DUCKING_VOLUME: 2, # Default developer otions CONF_DEVELOPER_DEVICE: "", CONF_DEVELOPER_MIMIC_DEVICE: "", diff --git a/custom_components/view_assist/entity_listeners.py b/custom_components/view_assist/entity_listeners.py index 3c1d7ae..eb94b63 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,6 +32,7 @@ CYCLE_VIEWS, DEFAULT_VIEW_INFO, DOMAIN, + HASSMIC_DOMAIN, REMOTE_ASSIST_DISPLAY_DOMAIN, USE_VA_NAVIGATION_FOR_BROWSERMOD, VA_ATTRIBUTE_UPDATE_EVENT, @@ -39,10 +42,12 @@ from .helpers import ( async_get_download_image, async_get_filesystem_images, + 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, @@ -67,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.core.mic_device - ) + self.music_player_volume: float | None = None # Add browser navigate service listener config_entry.async_on_unload( @@ -89,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( @@ -401,6 +433,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 diff --git a/custom_components/view_assist/helpers.py b/custom_components/view_assist/helpers.py index 4cae391..1f9cbe7 100644 --- a/custom_components/view_assist/helpers.py +++ b/custom_components/view_assist/helpers.py @@ -16,6 +16,7 @@ BROWSERMOD_DOMAIN, CONF_DISPLAY_DEVICE, DOMAIN, + HASSMIC_DOMAIN, IMAGE_PATH, RANDOM_IMAGE_URL, REMOTE_ASSIST_DISPLAY_DOMAIN, @@ -295,6 +296,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: diff --git a/custom_components/view_assist/typed.py b/custom_components/view_assist/typed.py index ee547c6..3886efc 100644 --- a/custom_components/view_assist/typed.py +++ b/custom_components/view_assist/typed.py @@ -126,6 +126,7 @@ class DefaultConfig: do_not_disturb: bool | None = None use_announce: bool | None = None mic_unmute: bool | None = None + ducking_volume: int | None = None @dataclass From 7fe115253b2790911a0326554a17a91263d014fd Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:35:46 -0400 Subject: [PATCH 34/92] Remove MAX_UPDATE_ITERATIONS from menu processor --- custom_components/view_assist/menu_manager.py | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py index 4da111b..7ba5d72 100644 --- a/custom_components/view_assist/menu_manager.py +++ b/custom_components/view_assist/menu_manager.py @@ -32,8 +32,6 @@ _LOGGER = logging.getLogger(__name__) -# Maximum number of update iterations before restarting the processor -MAX_UPDATE_ITERATIONS = 10000 StatusItemType = Union[str, List[str]] @@ -480,13 +478,9 @@ async def _update_entity_state( self._update_event.set() async def _update_processor(self) -> None: - """Process updates with a fixed iteration limit.""" - iterations = 0 - - while iterations < MAX_UPDATE_ITERATIONS: - iterations += 1 - - try: + """Process updates as they arrive.""" + try: + while True: await self._update_event.wait() self._update_event.clear() @@ -499,20 +493,10 @@ async def _update_processor(self) -> None: try: await self.hass.services.async_call(DOMAIN, "set_state", changes) except Exception as err: - _LOGGER.error("Error updating %s: %s", - entity_id, str(err)) + _LOGGER.error("Error updating %s: %s", entity_id, str(err)) - except asyncio.CancelledError: - break - except Exception as err: - _LOGGER.error("Unexpected error: %s", str(err)) - await asyncio.sleep(1) - - # Restart if reached iteration limit - if iterations >= MAX_UPDATE_ITERATIONS: - self._update_task = self.config.async_create_background_task( - self.hass, self._update_processor(), name="VA Menu Manager" - ) + except asyncio.CancelledError: + pass async def _ensure_initialized(self) -> None: """Ensure the menu manager is initialized.""" From 736c124921b7ab365bf1c902e81d8886589726c4 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:56:48 -0400 Subject: [PATCH 35/92] Update typing hints --- custom_components/view_assist/menu_manager.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py index 7ba5d72..7c8ae78 100644 --- a/custom_components/view_assist/menu_manager.py +++ b/custom_components/view_assist/menu_manager.py @@ -5,7 +5,7 @@ import asyncio from dataclasses import dataclass, field import logging -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) -StatusItemType = Union[str, List[str]] +StatusItemType = str | list[str] @dataclass @@ -41,11 +41,11 @@ class MenuState: 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: Optional[asyncio.Task] = None - item_timeouts: Dict[Tuple[str, str, bool], asyncio.Task] = field( + 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 ) @@ -57,10 +57,10 @@ 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._menu_states: dict[str, MenuState] = {} + self._pending_updates: dict[str, dict[str, Any]] = {} self._update_event = asyncio.Event() - self._update_task: Optional[asyncio.Task] = None + self._update_task: asyncio.Task | None = None self._initialized = False config.async_on_unload(self.cleanup) @@ -158,7 +158,7 @@ def _get_config_value(self, entity_id: str, key: str, default: Any = None) -> An return default async def toggle_menu( - self, entity_id: str, show: Optional[bool] = None, timeout: Optional[int] = None + self, entity_id: str, show: bool | None = None, timeout: int | None = None ) -> None: """Toggle menu visibility for an entity.""" await self._ensure_initialized() @@ -249,7 +249,7 @@ async def add_menu_item( entity_id: str, status_item: StatusItemType, menu: bool = False, - timeout: Optional[int] = None, + timeout: int | None = None, ) -> None: """Add status item(s) to the entity's status icons or menu items.""" # Normalize input and validate @@ -388,7 +388,7 @@ async def remove_menu_item( 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: + 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: @@ -397,14 +397,14 @@ async def _save_to_config_entry_options(self, entity_id: str, option_key: str, v try: new_options = dict(config_entry.options) - + if option_key == CONF_MENU_ITEMS: - value = list(reversed(value)) - + value.reverse() + new_options[option_key] = value self.hass.config_entries.async_update_entry( config_entry, options=new_options) - except Exception as err: + 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: @@ -465,7 +465,7 @@ def _cancel_item_timeout( menu_state.item_timeouts.pop(item_key) async def _update_entity_state( - self, entity_id: str, changes: Dict[str, Any] + self, entity_id: str, changes: dict[str, Any] ) -> None: """Queue entity state update.""" if not changes: @@ -492,7 +492,7 @@ async def _update_processor(self) -> None: changes["entity_id"] = entity_id try: await self.hass.services.async_call(DOMAIN, "set_state", changes) - except Exception as err: + except Exception as err: # noqa: BLE001 _LOGGER.error("Error updating %s: %s", entity_id, str(err)) except asyncio.CancelledError: From ebf6a5a770929c53383984128fad218b3fa67fd0 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:02:21 -0400 Subject: [PATCH 36/92] Improve formatting --- custom_components/view_assist/menu_manager.py | 112 ++++++++++++------ 1 file changed, 77 insertions(+), 35 deletions(-) diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py index 7c8ae78..223489d 100644 --- a/custom_components/view_assist/menu_manager.py +++ b/custom_components/view_assist/menu_manager.py @@ -65,7 +65,8 @@ def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: config.async_on_unload(self.cleanup) self.hass.bus.async_listen_once( - "homeassistant_started", self._initialize_on_startup) + "homeassistant_started", self._initialize_on_startup + ) async def _initialize_on_startup(self, _event: Event) -> None: """Initialize when Home Assistant has fully started.""" @@ -77,7 +78,9 @@ async def _initialize_on_startup(self, _event: Event) -> None: ) # Initialize existing entities - for entry_id in [e.entry_id for e in self.hass.config_entries.async_entries(DOMAIN)]: + 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) @@ -97,13 +100,16 @@ def _get_or_create_state(self, entity_id: str) -> MenuState: 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].status_icons = ( + status_icons if status_icons else [] + ) self._menu_states[entity_id].active = state.attributes.get( - "menu_active", False) + "menu_active", False + ) self._menu_states[entity_id].system_icons = [ - icon for icon in self._menu_states[entity_id].status_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" ] @@ -124,9 +130,11 @@ def _get_config_value(self, entity_id: str, key: str, default: Any = None) -> An # 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]): + 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 @@ -139,9 +147,11 @@ def _get_config_value(self, entity_id: str, key: str, default: Any = None) -> An # 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]): + 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 @@ -150,9 +160,11 @@ def _get_config_value(self, entity_id: str, key: str, default: Any = None) -> An if "." in key: section, setting = key.split(".") - if (section in DEFAULT_VALUES and - isinstance(DEFAULT_VALUES[section], dict) and - setting in DEFAULT_VALUES[section]): + if ( + section in DEFAULT_VALUES + and isinstance(DEFAULT_VALUES[section], dict) + and setting in DEFAULT_VALUES[section] + ): return DEFAULT_VALUES[section][setting] return default @@ -171,7 +183,9 @@ async def toggle_menu( # Get menu configuration menu_config = self._get_config_value( - entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_CONFIG}", VAMenuConfig.DISABLED + entity_id, + f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_CONFIG}", + VAMenuConfig.DISABLED, ) # Check if menu is enabled @@ -197,7 +211,8 @@ async def toggle_menu( # Update system icons from current status current_status_icons = state.attributes.get(CONF_STATUS_ICONS, []) system_icons = [ - icon for icon in current_status_icons + icon + for icon in current_status_icons if icon not in menu_state.configured_items and icon != "menu" ] menu_state.system_icons = system_icons @@ -270,7 +285,9 @@ async def add_menu_item( # Get menu configuration menu_config = self._get_config_value( - entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_CONFIG}", VAMenuConfig.DISABLED + entity_id, + f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_CONFIG}", + VAMenuConfig.DISABLED, ) # Check if menu button should be shown @@ -290,7 +307,9 @@ async def add_menu_item( 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) + await self._save_to_config_entry_options( + entity_id, CONF_MENU_ITEMS, updated_items + ) # Update icons if menu is active if menu_state.active: @@ -305,13 +324,15 @@ async def add_menu_item( 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 + 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) + await self._save_to_config_entry_options( + entity_id, CONF_STATUS_ICONS, updated_icons + ) # Apply changes if changes: @@ -343,7 +364,9 @@ async def remove_menu_item( # Get menu configuration menu_config = self._get_config_value( - entity_id, f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_CONFIG}", VAMenuConfig.DISABLED + entity_id, + f"{CONF_DISPLAY_SETTINGS}.{CONF_MENU_CONFIG}", + VAMenuConfig.DISABLED, ) # Check if menu button should be shown @@ -353,12 +376,15 @@ async def remove_menu_item( if from_menu: # Remove from menu items updated_items = [ - item for item in menu_state.configured_items if item not in 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) + await self._save_to_config_entry_options( + entity_id, CONF_MENU_ITEMS, updated_items + ) # Update icons if menu is active if menu_state.active: @@ -373,13 +399,15 @@ async def remove_menu_item( 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 + 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) + await self._save_to_config_entry_options( + entity_id, CONF_STATUS_ICONS, updated_icons + ) # Apply changes and cancel timeouts if changes: @@ -388,11 +416,14 @@ async def remove_menu_item( 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: + 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) + _LOGGER.warning( + "Cannot save %s - config entry not found", option_key) return try: @@ -403,7 +434,8 @@ async def _save_to_config_entry_options(self, entity_id: str, option_key: str, v new_options[option_key] = value self.hass.config_entries.async_update_entry( - config_entry, options=new_options) + config_entry, options=new_options + ) except Exception as err: # noqa: BLE001 _LOGGER.error("Error saving config entry options: %s", str(err)) @@ -447,7 +479,9 @@ async def _item_timeout_task() -> None: 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}" + self.hass, + _item_timeout_task(), + name=f"VA Item Timeout {entity_id} {menu_item}", ) def _cancel_item_timeout( @@ -460,7 +494,10 @@ def _cancel_item_timeout( 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(): + 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) @@ -491,9 +528,12 @@ async def _update_processor(self) -> None: 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)) + 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 @@ -505,7 +545,9 @@ async def _ensure_initialized(self) -> None: 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)]: + 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: From 8100700e1f85a9dde82a2ade1b5ff33e99a0a781 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:34:53 -0400 Subject: [PATCH 37/92] Update typing hints --- custom_components/view_assist/helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/view_assist/helpers.py b/custom_components/view_assist/helpers.py index 80c65d5..d20c5e8 100644 --- a/custom_components/view_assist/helpers.py +++ b/custom_components/view_assist/helpers.py @@ -3,7 +3,7 @@ from functools import reduce import logging from pathlib import Path -from typing import Any, List, Optional, Union +from typing import Any import requests @@ -106,7 +106,7 @@ def ensure_menu_button_at_end(status_icons: list[str]) -> None: status_icons.remove("menu") status_icons.append("menu") -def normalize_status_items(raw_input: Any) -> Optional[Union[str, List[str]]]: +def normalize_status_items(raw_input: Any) -> str | list[str] | None: """Normalize and validate status item input. Handles various input formats: From 725e3bc7841c7650d809cc483a61703a6e96f83a Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:46:56 -0400 Subject: [PATCH 38/92] Improve formatting --- custom_components/view_assist/helpers.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/custom_components/view_assist/helpers.py b/custom_components/view_assist/helpers.py index d20c5e8..8e4b88f 100644 --- a/custom_components/view_assist/helpers.py +++ b/custom_components/view_assist/helpers.py @@ -152,8 +152,9 @@ def normalize_status_items(raw_input: Any) -> str | list[str] | None: return None -def arrange_status_icons(menu_items: list[str], system_icons: list[str], - show_menu_button: bool = False) -> list[str]: +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"] @@ -167,11 +168,13 @@ def arrange_status_icons(menu_items: list[str], system_icons: list[str], 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]: +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() @@ -190,7 +193,8 @@ def update_status_icons(current_icons: list[str], if menu_items is not None: system_icons = [ - icon for icon in result if icon not in menu_items and icon != "menu"] + 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) @@ -199,6 +203,7 @@ def update_status_icons(current_icons: list[str], 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): From b0902ba6ee8312a32638fff448a6a16079e5a923 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Wed, 30 Apr 2025 18:32:49 -0400 Subject: [PATCH 39/92] Update typing hints --- custom_components/view_assist/services.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/custom_components/view_assist/services.py b/custom_components/view_assist/services.py index cc6874a..6bb800b 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -3,7 +3,7 @@ from asyncio import TimerHandle import json import logging -from typing import Any, Union, List, Optional, Callable, cast +from typing import Any import voluptuous as vol @@ -53,8 +53,6 @@ _LOGGER = logging.getLogger(__name__) -StatusItemType = Union[str, List[str]] - NAVIGATE_SERVICE_SCHEMA = vol.Schema( { @@ -525,7 +523,7 @@ async def async_handle_remove_status_item(self, call: ServiceCall): menu_manager = self.hass.data[DOMAIN]["menu_manager"] await menu_manager.remove_menu_item(entity_id, status_items, menu) - def _process_status_item_input(self, raw_input: Any) -> Optional[StatusItemType]: + def _process_status_item_input(self, raw_input: Any) -> str | list[str] | None: """Process and validate status item input.""" from .helpers import normalize_status_items return normalize_status_items(raw_input) From f75dcf8d2791bc7c7fc1302b456a9a8e12624f12 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Wed, 30 Apr 2025 18:38:52 -0400 Subject: [PATCH 40/92] Improve formatting --- custom_components/view_assist/services.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/custom_components/view_assist/services.py b/custom_components/view_assist/services.py index 6bb800b..71456e2 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -165,6 +165,7 @@ } ) + class VAServices: """Class to manage services.""" @@ -482,7 +483,7 @@ async def async_handle_toggle_menu(self, call: ServiceCall): show = call.data.get("show", True) timeout = call.data.get("timeout") - + menu_manager = self.hass.data[DOMAIN]["menu_manager"] await menu_manager.toggle_menu(entity_id, show, timeout=timeout) @@ -490,13 +491,14 @@ async def async_handle_add_status_item(self, call: ServiceCall): """Handle add status item service call.""" entity_id = call.data.get(ATTR_ENTITY_ID) if not entity_id: - _LOGGER.error("No entity_id provided in add_status_item service call") + _LOGGER.error( + "No entity_id provided in add_status_item service call") return raw_status_item = call.data.get("status_item") menu = call.data.get("menu", False) timeout = call.data.get("timeout") - + status_items = self._process_status_item_input(raw_status_item) if not status_items: _LOGGER.error("Invalid or empty status_item provided") @@ -509,7 +511,8 @@ async def async_handle_remove_status_item(self, call: ServiceCall): """Handle remove status item service call.""" entity_id = call.data.get(ATTR_ENTITY_ID) if not entity_id: - _LOGGER.error("No entity_id provided in remove_status_item service call") + _LOGGER.error( + "No entity_id provided in remove_status_item service call") return raw_status_item = call.data.get("status_item") @@ -526,4 +529,5 @@ async def async_handle_remove_status_item(self, call: ServiceCall): def _process_status_item_input(self, raw_input: Any) -> str | list[str] | None: """Process and validate status item input.""" from .helpers import normalize_status_items + return normalize_status_items(raw_input) From 80bc3d2e328384f56922dbce2167bba4e2851557 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Sat, 3 May 2025 17:15:24 +0100 Subject: [PATCH 41/92] added: update notifications for views and dashboard --- custom_components/view_assist/__init__.py | 18 +- custom_components/view_assist/config_flow.py | 32 ++- custom_components/view_assist/const.py | 9 +- custom_components/view_assist/dashboard.py | 265 +++++++++++++++++- .../view_assist/translations/en.json | 10 + custom_components/view_assist/typed.py | 8 + custom_components/view_assist/update.py | 163 +++++++++++ 7 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 custom_components/view_assist/update.py diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 5b8dba5..56f7ba2 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -284,6 +284,13 @@ async def setup_frontend(*args): hass.data[DOMAIN][DASHBOARD_MANAGER] = dm await dm.setup_dashboard() + # Request 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_at_started(hass, setup_frontend) @@ -307,9 +314,9 @@ def get_config_value( attr: str, is_master: bool = False ) -> str | float | list | None: value = get_dn(attr, dict(config_entry.options)) - if not value and not is_master: + if value is None and not is_master: value = get_dn(attr, dict(master_config_options)) - if not value: + if value is None: value = get_dn(attr, DEFAULT_VALUES) # This is a fix for config lists being a string @@ -345,6 +352,12 @@ def get_config_value( if value := get_config_value(attr, is_master=True): setattr(r.default, attr, value) + # 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): @@ -402,6 +415,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/config_flow.py b/custom_components/view_assist/config_flow.py index 33cec70..cb8b0a5 100644 --- a/custom_components/view_assist/config_flow.py +++ b/custom_components/view_assist/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers.selector import ( + BooleanSelector, EntityFilterSelectorConfig, EntitySelector, EntitySelectorConfig, @@ -38,6 +39,7 @@ CONF_DISPLAY_SETTINGS, CONF_DO_NOT_DISTURB, CONF_DUCKING_VOLUME, + CONF_ENABLE_UPDATES, CONF_FONT_STYLE, CONF_HOME, CONF_INTENT, @@ -284,6 +286,10 @@ def get_dashboard_options_schema(config_entry: VAConfigEntry | None) -> vol.Sche } ) +INTEGRATION_OPTIONS_SCHEMA = vol.Schema( + {vol.Optional(CONF_ENABLE_UPDATES): BooleanSelector()} +) + def get_developer_options_schema( hass: HomeAssistant, config_entry: VAConfigEntry | None @@ -309,7 +315,7 @@ def get_suggested_option_values(config: VAConfigEntry) -> dict[str, Any]: if config.data[CONF_TYPE] == VAType.MASTER_CONFIG: option_values = DEFAULT_VALUES.copy() for option in DEFAULT_VALUES: - if config.options.get(option): + if config.options.get(option) is not None: option_values[option] = config.options.get(option) return option_values return config.options @@ -442,6 +448,7 @@ async def async_step_init(self, user_input=None): return self.async_show_menu( step_id="init", menu_options=[ + "integration_options", "dashboard_options", "default_options", "developer_options", @@ -541,6 +548,29 @@ async def async_step_default_options(self, user_input=None): }, ) + 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.""" diff --git a/custom_components/view_assist/const.py b/custom_components/view_assist/const.py index 360651d..7dc2541 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -20,7 +20,6 @@ VIEWS_DIR = "views" COMMUNITY_VIEWS_DIR = "community_contributions" DASHBOARD_DIR = "dashboard" - DASHBOARD_NAME = "View Assist" DEFAULT_VIEW = "clock" DEFAULT_VIEWS = [ @@ -58,6 +57,9 @@ "version": "1.0.10", }, ] +VERSION_CHECK_INTERVAL = ( + 120 # mins between checks for updated versions of dashboard and views +) class VAMode(StrEnum): @@ -113,7 +115,7 @@ class VAMode(StrEnum): 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" @@ -157,6 +159,8 @@ class VAMode(StrEnum): CONF_USE_ANNOUNCE: "off", CONF_MIC_UNMUTE: "off", CONF_DUCKING_VOLUME: 2, + # Default integration options + CONF_ENABLE_UPDATES: True, # Default developer otions CONF_DEVELOPER_DEVICE: "", CONF_DEVELOPER_MIMIC_DEVICE: "", @@ -187,6 +191,7 @@ class VAMode(StrEnum): VA_ATTRIBUTE_UPDATE_EVENT = "va_attr_update_event_{}" VA_BACKGROUND_UPDATE_EVENT = "va_background_update_{}" +VA_VIEW_DOWNLOAD_PROGRESS = "va_view_download_progress" CC_CONVERSATION_ENDED_EVENT = f"{CUSTOM_CONVERSATION_DOMAIN}_conversation_ended" diff --git a/custom_components/view_assist/dashboard.py b/custom_components/view_assist/dashboard.py index ef1a423..cdba641 100644 --- a/custom_components/view_assist/dashboard.py +++ b/custom_components/view_assist/dashboard.py @@ -1,6 +1,7 @@ """Manage views - download, apply, backup, restore.""" from dataclasses import dataclass +from datetime import timedelta import logging import operator from os import PathLike @@ -29,7 +30,11 @@ 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 homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.start import async_at_started +from homeassistant.helpers.storage import Store +from homeassistant.util import dt as dt_util +from homeassistant.util.yaml import load_yaml_dict, parse_yaml, save_yaml from .const import ( COMMUNITY_VIEWS_DIR, @@ -42,9 +47,11 @@ GITHUB_PATH, GITHUB_REPO, GITHUB_TOKEN_FILE, + VA_VIEW_DOWNLOAD_PROGRESS, + VERSION_CHECK_INTERVAL, VIEWS_DIR, ) -from .helpers import differ_to_json, json_to_dictdiffer +from .helpers import differ_to_json, get_key, json_to_dictdiffer from .typed import VAConfigEntry, VAEvent from .utils import dictdiff from .websocket import MockWSConnection @@ -90,13 +97,22 @@ def __init__(self, hass: HomeAssistant, repo: str) -> None: self.api_base = f"{GITHUB_REPO_API}/{self.repo}/contents/" 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) -> str | dict | list | 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) @@ -113,7 +129,10 @@ async def _rest_request(self, url: str) -> str | dict | list | None: 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 GithubAPIException( @@ -148,9 +167,13 @@ async def get_dir_listing(self, dir_url: str) -> list[GithubFileDir]: _LOGGER.error(ex) return None - async def download_file(self, download_url: str) -> bytes | None: + async def download_file( + self, download_url: str, data_as_text: bool = False + ) -> bytes | None: """Download file.""" - if file_data := await self._rest_request(download_url): + if file_data := await self._rest_request( + download_url, data_as_text=data_as_text + ): return file_data _LOGGER.debug("Failed to download file") return None @@ -203,6 +226,25 @@ async def _download_dir(self, dir_url: str, dir_path: str, depth: int = 1) -> bo else: return False + async def get_dashboard_version(self) -> str | None: + """Get dashboard version from repo.""" + dashboard_url = f"https://raw.githubusercontent.com/{GITHUB_REPO}/{GITHUB_BRANCH}/{GITHUB_PATH}/{DASHBOARD_DIR}/{DASHBOARD_DIR}.yaml" + dashboard_data = await self.github.download_file( + dashboard_url, data_as_text=True + ) + if dashboard_data: + try: + # Parse yaml string to json + dashboard_data = parse_yaml(dashboard_data) + if variables := get_key( + "button_card_templates.variable_template.variables", + dashboard_data, + ): + return variables.get("dashboardversion", "0.0.0") + except KeyError: + _LOGGER.debug("Dashboard version not found") + return "0.0.0" + async def download_dashboard(self): """Download dashboard file.""" # Ensure download to path exists @@ -214,6 +256,22 @@ async def download_dashboard(self): # Download view files await self._download_dir(dir_url, base) + async def get_view_version(self, view: str) -> str | None: + """Get dashboard version from repo.""" + view_url = f"https://raw.githubusercontent.com/{GITHUB_REPO}/{GITHUB_BRANCH}/{GITHUB_PATH}/{VIEWS_DIR}/{view}/{view}.yaml" + view_data = await self.github.download_file(view_url, data_as_text=True) + # Parse yaml string to json + view_data = parse_yaml(view_data) + if view_data: + try: + if variables := view_data.get("variables"): + return variables.get( + f"{view}version", variables.get(f"{view}cardversion", "0.0.0") + ) + except KeyError: + _LOGGER.debug("Dashboard version not found") + return "0.0.0" + async def download_view( self, view_name: str, @@ -242,6 +300,45 @@ async def download_view( return False +class DashboardManagerStorage: + """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}.dashboard") + + async def _save(self): + """Save store.""" + self.data["last_updated"] = dt_util.now().isoformat() + await self.store.async_save(self.data) + + 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 dashboard store. Error is %s", ex) + self.data = {} + return self.data + + async def update_dashboard(self, data: dict[str, Any]): + """Update store.""" + self.data["dashboard"] = data + await self._save() + + async def update_views(self, data: dict[str, Any]): + """Update store.""" + self.data["views"] = data + await self._save() + + class DashboardManager: """Class to manage VA dashboard and views.""" @@ -250,6 +347,7 @@ def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: self.hass = hass self.config = config self.download_manager = DownloadManager(hass) + self.store = DashboardManagerStorage(hass) self.build_mode: bool = False # Experimental - listen for dashboard change and write out changes @@ -257,6 +355,17 @@ def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: hass.bus.async_listen(EVENT_LOVELACE_UPDATED, self._dashboard_changed) ) + # Experimental - schedule update of dashboard view versions + if self.config.runtime_data.integration.enable_updates: + async_at_started(hass, self._update_dashboard_view_versions) + config.async_on_unload( + async_track_time_interval( + hass, + self._update_dashboard_view_versions, + timedelta(minutes=VERSION_CHECK_INTERVAL), + ) + ) + async def _save_to_yaml_file( self, file_path: str | PathLike, @@ -337,6 +446,9 @@ async def _dashboard_changed(self, event: Event): if self.build_mode: return + _LOGGER.debug("Dashboard version - %s", await self.get_dashboard_version()) + _LOGGER.debug("Installed views - %s", await self.get_installed_views()) + if event.data["url_path"] == self.dashboard_key: try: lovelace: LovelaceData = self.hass.data["lovelace"] @@ -400,6 +512,11 @@ async def update_dashboard( # download dashboard - no backup if download_from_repo: await self.download_manager.download_dashboard() + async_dispatcher_send( + self.hass, + VA_VIEW_DOWNLOAD_PROGRESS, + {"view": "dashboard", "progress": 33}, + ) # Apply new dashboard to HA base = self.hass.config.path(DOMAIN) @@ -427,7 +544,31 @@ async def update_dashboard( # Apply self.build_mode = True await dashboard_store.async_save(updated_dashboard_config) + async_dispatcher_send( + self.hass, + VA_VIEW_DOWNLOAD_PROGRESS, + {"view": "dashboard", "progress": 66}, + ) await self._apply_user_dashboard_changes() + async_dispatcher_send( + self.hass, + VA_VIEW_DOWNLOAD_PROGRESS, + {"view": "dashboard", "progress": 90}, + ) + + # Update installed version info + if self.config.runtime_data.integration.enable_updates: + dashboard_info = { + "installed": await self.get_dashboard_version(), + "latest": await self.download_manager.get_dashboard_version(), + } + await self.store.update_dashboard(dashboard_info) + + async_dispatcher_send( + self.hass, + VA_VIEW_DOWNLOAD_PROGRESS, + {"view": "dashboard", "progress": 100}, + ) self.build_mode = False async def _compare_dashboard_to_master( @@ -480,6 +621,92 @@ async def _apply_user_dashboard_changes(self): updated_dashboard = dictdiff.patch(user_changes, dashboard_config) await dashboard_store.async_save(updated_dashboard) + async def get_dashboard_version(self) -> str | None: + """Get the version of the dashboard.""" + # 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: + if dashboard_config := await dashboard_store.async_load(False): + 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" + + def get_view_version(self, view_name: str, view_dict: dict[str, Any]) -> str | None: + """Get the version of a view from view dict.""" + try: + if cards := view_dict.get("cards"): + if variables := cards[0].get("variables"): + # View variable can be in multiple formats + if version := variables.get(f"{view_name}cardversion"): + return version + if version := variables.get(f"{view_name}version"): + return version + except KeyError: + _LOGGER.debug("View version not found") + return "0.0.0" + + async def get_installed_views(self) -> list[str]: + """Return list of views and version in dashboard.""" + output = {} + 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 view in dashboard_config["views"]: + view_name = view.get("path") + if view_name not in output: + output[view_name] = self.get_view_version(view_name, view) + return output + + async def _update_dashboard_view_versions( + self, now: Event | None = None, force: bool = False + ): + """Update the version of the views in the dashboard.""" + # check if last updated within last hour + data = await self.store.load() + if not force and data and "last_updated" in data: + last_updated = dt_util.parse_datetime(data["last_updated"]) + if last_updated and dt_util.utcnow() - last_updated - timedelta( + seconds=30 + ) < timedelta(minutes=VERSION_CHECK_INTERVAL): + return + + _LOGGER.debug("Updating dashboard view versions") + # Dashboard + installed_dashboard = await self.get_dashboard_version() + if installed_dashboard: + latest_dashboard = await self.download_manager.get_dashboard_version() + await self.store.update_dashboard( + {"installed": installed_dashboard, "latest": latest_dashboard} + ) + + # Views + view_info: dict[str, Any] = {} + installed_views = await self.get_installed_views() + for view in installed_views: + latest_version = await self.download_manager.get_view_version(view) + view_info[view] = { + "installed": installed_views[view], + "latest": latest_version, + } + await self.store.update_views(view_info) + async def view_exists(self, view: str) -> int: """Return index of view if view exists.""" lovelace: LovelaceData = self.hass.data["lovelace"] @@ -522,6 +749,9 @@ async def add_update_view( ) if not result: raise DashboardManagerException(f"Failed to download {name} view") + async_dispatcher_send( + self.hass, VA_VIEW_DOWNLOAD_PROGRESS, {"view": name, "progress": 33} + ) # Install view from file. try: @@ -544,6 +774,9 @@ async def add_update_view( new_view_config = await self.hass.async_add_executor_job( load_yaml_dict, file ) + async_dispatcher_send( + self.hass, VA_VIEW_DOWNLOAD_PROGRESS, {"view": name, "progress": 66} + ) else: raise DashboardManagerException( f"Unable to load view {name}. Unable to find a yaml file" @@ -594,6 +827,28 @@ async def add_update_view( await dashboard_store.async_save(dashboard_config) self.hass.bus.async_fire(EVENT_PANELS_UPDATED) self.build_mode = False + + # Update installed version info + if self.config.runtime_data.integration.enable_updates: + views_info = self.store.data["views"] + if name in views_info: + views_info[name]["installed"] = self.get_view_version( + name, new_view + ) + else: + views_info[name] = { + "installed": self.get_view_version(name, new_view), + "latest": await self.download_manager.get_view_version( + name + ), + } + await self.store.update_views(views_info) + + async_dispatcher_send( + self.hass, + VA_VIEW_DOWNLOAD_PROGRESS, + {"view": name, "progress": 100}, + ) return True return False diff --git a/custom_components/view_assist/translations/en.json b/custom_components/view_assist/translations/en.json index 9ea96cb..23a7fbb 100644 --- a/custom_components/view_assist/translations/en.json +++ b/custom_components/view_assist/translations/en.json @@ -45,6 +45,7 @@ "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", @@ -148,6 +149,15 @@ "micunmute": "Helpful for Stream Assist devices" } }, + "integration_options": { + "title": "{name} Integration Options", + "data": { + "enable_updates": "Enable updates" + }, + "data_description": { + "enable_updates": "Enable or disable update notifications for the dashboard and views" + } + }, "developer_options": { "title": "{name} Developer Options", "data": { diff --git a/custom_components/view_assist/typed.py b/custom_components/view_assist/typed.py index 3886efc..b732b2c 100644 --- a/custom_components/view_assist/typed.py +++ b/custom_components/view_assist/typed.py @@ -67,6 +67,13 @@ class VABackgroundMode(StrEnum): LINKED = "link_to_entity" +@dataclass +class IntegrationConfig: + """Class to hold integration config data.""" + + enable_updates: bool = True + + @dataclass class DeviceCoreConfig: """Class to hold core config data.""" @@ -142,6 +149,7 @@ class MasterConfigRuntimeData: def __init__(self) -> None: """Initialize runtime data.""" + self.integration: IntegrationConfig = IntegrationConfig() self.dashboard: DashboardConfig = DashboardConfig() self.default: DefaultConfig = DefaultConfig() self.developer_settings: DeveloperConfig = DeveloperConfig() diff --git a/custom_components/view_assist/update.py b/custom_components/view_assist/update.py new file mode 100644 index 0000000..8a566df --- /dev/null +++ b/custom_components/view_assist/update.py @@ -0,0 +1,163 @@ +"""Update entities for HACS.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.core import HomeAssistant, HomeAssistantError, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + GITHUB_BRANCH, + GITHUB_PATH, + GITHUB_REPO, + VA_VIEW_DOWNLOAD_PROGRESS, + VIEWS_DIR, +) +from .dashboard import DASHBOARD_MANAGER, DashboardManager +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.""" + update_sensors = [] + dm = hass.data[DOMAIN][DASHBOARD_MANAGER] + data = await dm.store.load() + + # Wait upto 5s for view data to be created on first run + for _ in range(5): + if data.get("views") is not None: + break + data = await dm.store.load() + await asyncio.sleep(1) + + if data.get("views"): + update_sensors = [ + VAUpdateEntity( + dm=dm, + view=view, + ) + for view in data["views"] + ] + else: + _LOGGER.error("Unable to load view version information") + + if data.get("dashboard"): + update_sensors.append( + VAUpdateEntity( + dm=dm, + view="dashboard", + ) + ) + else: + _LOGGER.error("Unable to load dashboard version information") + + async_add_entities(update_sensors) + + +class VAUpdateEntity(UpdateEntity): + """Update entities for repositories downloaded with HACS.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) + + def __init__(self, dm: DashboardManager, view: str) -> None: + """Initialize.""" + self.dm = dm + self.view = view + + self._attr_supported_features = ( + (self._attr_supported_features | UpdateEntityFeature.BACKUP) + if view != "dashboard" + else self._attr_supported_features + ) + + @property + def name(self) -> str | None: + """Return the name.""" + if self.view == "dashboard": + return f"View Assist - {self.view}" + return f"View Assist - {self.view} view" + + @property + def latest_version(self) -> str: + """Return latest version of the entity.""" + if self.view == "dashboard": + return self.dm.store.data["dashboard"]["latest"] + return self.dm.store.data["views"][self.view]["latest"] + + @property + def release_url(self) -> str: + """Return the URL of the release page.""" + return f"https://github.com/{GITHUB_REPO}/tree/{GITHUB_BRANCH}/{GITHUB_PATH}/{VIEWS_DIR}/{self.view}" + + @property + def installed_version(self) -> str: + """Return downloaded version of the entity.""" + if self.view == "dashboard": + return self.dm.store.data["dashboard"]["installed"] + return self.dm.store.data["views"][self.view]["installed"] + + @property + def release_summary(self) -> str | None: + """Return the release summary.""" + if self.view == "dashboard": + return "Updating the dashboard will attempt to keep any changes you have made to it" + return "Updating this view will overwrite any changes you have made to it" + + @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: + if self.view == "dashboard": + # Install dashboard + await self.dm.update_dashboard(download_from_repo=True) + else: + # Install view + await self.dm.add_update_view( + self.view, download_from_repo=True, backup_current_view=backup + ) + except Exception 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_VIEW_DOWNLOAD_PROGRESS, + self._update_download_progress, + ) + ) + + @callback + def _update_download_progress(self, data: dict) -> None: + """Update the download progress.""" + if data["view"] != self.view: + return + self._attr_in_progress = data["progress"] + self.async_write_ha_state() From cd32872e88f5095a78b9d8cfa9ece756c9d73936 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Sat, 3 May 2025 17:56:12 +0100 Subject: [PATCH 42/92] fix: add unique id --- custom_components/view_assist/update.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/custom_components/view_assist/update.py b/custom_components/view_assist/update.py index 8a566df..b79d999 100644 --- a/custom_components/view_assist/update.py +++ b/custom_components/view_assist/update.py @@ -91,6 +91,13 @@ def name(self) -> str | None: return f"View Assist - {self.view}" return f"View Assist - {self.view} view" + @property + def unique_id(self) -> str: + """Return a unique ID.""" + if self.view == "dashboard": + return f"{DOMAIN}_{self.view}" + return f"{DOMAIN}_{self.view}_view" + @property def latest_version(self) -> str: """Return latest version of the entity.""" From 2b6d0ac5dad2807fd465544f4185e84f928f213c Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Sat, 3 May 2025 18:29:26 +0100 Subject: [PATCH 43/92] fix: device config can load empty --- custom_components/view_assist/__init__.py | 105 ++++++++-------------- custom_components/view_assist/helpers.py | 9 +- 2 files changed, 41 insertions(+), 73 deletions(-) diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 56f7ba2..12ecd9a 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -43,6 +43,7 @@ ensure_list, get_device_name_from_id, get_integration_entries, + get_key, get_master_config_entry, is_first_instance, ) @@ -294,63 +295,31 @@ async def setup_frontend(*args): async_at_started(hass, setup_frontend) -def set_runtime_data_for_config( # noqa: C901 +def set_runtime_data_for_config( hass: HomeAssistant, config_entry: VAConfigEntry, is_master: bool = False ): """Set config.runtime_data attributes from matching config values.""" - def get_dn(dn_attr: str, data: dict[str, Any]): - """Get dotted notation attribute from config entry options dict.""" - try: - if "." in dn_attr: - dn_list = dn_attr.split(".") - else: - dn_list = [dn_attr] - return reduce(dict.get, dn_list, data) - except (TypeError, KeyError): - return None - def get_config_value( attr: str, is_master: bool = False ) -> str | float | list | None: - value = get_dn(attr, dict(config_entry.options)) - if value is None and not is_master: - value = get_dn(attr, dict(master_config_options)) - if value is None: - value = get_dn(attr, DEFAULT_VALUES) + 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), - ) - - # Default options - doesn't yet handle sections - for attr in r.default.__dict__: - if value := get_config_value(attr, is_master=True): - setattr(r.default, attr, value) # Integration options for attr in r.integration.__dict__: @@ -365,34 +334,32 @@ def get_config_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), - ) - # Default options - doesn't yet handle sections - for attr in r.default.__dict__: - if value := get_config_value(attr): - setattr(r.default, attr, value) + # 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): diff --git a/custom_components/view_assist/helpers.py b/custom_components/view_assist/helpers.py index 1f9cbe7..786f485 100644 --- a/custom_components/view_assist/helpers.py +++ b/custom_components/view_assist/helpers.py @@ -385,12 +385,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 From 7949735f1c90bb77837a2b6ffea6b96c12816160 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Sat, 3 May 2025 18:30:11 +0100 Subject: [PATCH 44/92] refactor: remove unused imports --- custom_components/view_assist/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 12ecd9a..a679233 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -1,8 +1,6 @@ """View Assist custom integration.""" -from functools import reduce import logging -from typing import Any from homeassistant import config_entries from homeassistant.const import CONF_TYPE, Platform From 9929c3c37c26c1019f9b8e39eb653ae04817330c Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Sun, 4 May 2025 15:51:28 +0100 Subject: [PATCH 45/92] added: version update service --- custom_components/view_assist/dashboard.py | 14 +++++++++++--- custom_components/view_assist/services.py | 12 ++++++++++++ custom_components/view_assist/services.yaml | 3 +++ custom_components/view_assist/update.py | 9 +++++---- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/custom_components/view_assist/dashboard.py b/custom_components/view_assist/dashboard.py index cdba641..6e387cf 100644 --- a/custom_components/view_assist/dashboard.py +++ b/custom_components/view_assist/dashboard.py @@ -357,11 +357,11 @@ def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None: # Experimental - schedule update of dashboard view versions if self.config.runtime_data.integration.enable_updates: - async_at_started(hass, self._update_dashboard_view_versions) + async_at_started(hass, self.update_dashboard_view_versions) config.async_on_unload( async_track_time_interval( hass, - self._update_dashboard_view_versions, + self.update_dashboard_view_versions, timedelta(minutes=VERSION_CHECK_INTERVAL), ) ) @@ -674,7 +674,7 @@ async def get_installed_views(self) -> list[str]: output[view_name] = self.get_view_version(view_name, view) return output - async def _update_dashboard_view_versions( + async def update_dashboard_view_versions( self, now: Event | None = None, force: bool = False ): """Update the version of the views in the dashboard.""" @@ -707,6 +707,14 @@ async def _update_dashboard_view_versions( } await self.store.update_views(view_info) + if force: + # Fire refresh event + async_dispatcher_send( + self.hass, + VA_VIEW_DOWNLOAD_PROGRESS, + {"view": "all"}, + ) + async def view_exists(self, view: str) -> int: """Return index of view if view exists.""" lovelace: LovelaceData = self.hass.data["lovelace"] diff --git a/custom_components/view_assist/services.py b/custom_components/view_assist/services.py index 551c885..ea831f1 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -227,6 +227,10 @@ async def async_setup_services(self): schema=DASHVIEW_SERVICE_SCHEMA, ) + self.hass.services.async_register( + DOMAIN, "update_versions", self.async_handle_update_versions + ) + # ----------------------------------------------------------------------- # Get Target Satellite # Used to determine which VA satellite is being used based on its microphone device @@ -423,3 +427,11 @@ async def async_handle_save_view(self, call: ServiceCall): await dm.save_view(view_name) except (DownloadManagerException, DashboardManagerException) as ex: raise HomeAssistantError(ex) from ex + + async def async_handle_update_versions(self, call: ServiceCall): + """Handle update of the view versions.""" + dm: DashboardManager = self.hass.data[DOMAIN][DASHBOARD_MANAGER] + try: + await dm.update_dashboard_view_versions(force=True) + except (DownloadManagerException, DashboardManagerException) as ex: + raise HomeAssistantError(ex) from ex diff --git a/custom_components/view_assist/services.yaml b/custom_components/view_assist/services.yaml index e670ace..2a952f9 100644 --- a/custom_components/view_assist/services.yaml +++ b/custom_components/view_assist/services.yaml @@ -249,3 +249,6 @@ save_view: required: true selector: text: +update_versions: + name: "Update version info" + description: "Get the latest version info of the dashboard and views from the github repo" diff --git a/custom_components/view_assist/update.py b/custom_components/view_assist/update.py index b79d999..c86d0aa 100644 --- a/custom_components/view_assist/update.py +++ b/custom_components/view_assist/update.py @@ -164,7 +164,8 @@ async def async_added_to_hass(self) -> None: @callback def _update_download_progress(self, data: dict) -> None: """Update the download progress.""" - if data["view"] != self.view: - return - self._attr_in_progress = data["progress"] - self.async_write_ha_state() + if data["view"] == self.view: + self._attr_in_progress = data["progress"] + self.async_write_ha_state() + elif data["view"] == "all": + self.schedule_update_ha_state(force_refresh=True) From 935ef000028b5466f53af2013a772085f82d83cc Mon Sep 17 00:00:00 2001 From: Michelle Avery Date: Mon, 5 May 2025 10:17:54 -0400 Subject: [PATCH 46/92] Add support for todo entities in changed entities --- custom_components/view_assist/config_flow.py | 2 ++ custom_components/view_assist/const.py | 2 ++ .../view_assist/entity_listeners.py | 25 +++++++++++++++++++ custom_components/view_assist/typed.py | 1 + 4 files changed, 30 insertions(+) diff --git a/custom_components/view_assist/config_flow.py b/custom_components/view_assist/config_flow.py index cb8b0a5..f0f3b41 100644 --- a/custom_components/view_assist/config_flow.py +++ b/custom_components/view_assist/config_flow.py @@ -44,6 +44,7 @@ CONF_HOME, CONF_INTENT, CONF_INTENT_DEVICE, + CONF_LIST, CONF_MEDIAPLAYER_DEVICE, CONF_MIC_DEVICE, CONF_MIC_UNMUTE, @@ -178,6 +179,7 @@ def get_dashboard_options_schema(config_entry: VAConfigEntry | None) -> vol.Sche 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( diff --git a/custom_components/view_assist/const.py b/custom_components/view_assist/const.py index 7dc2541..7209912 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -92,6 +92,7 @@ class VAMode(StrEnum): 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" @@ -136,6 +137,7 @@ class VAMode(StrEnum): 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", diff --git a/custom_components/view_assist/entity_listeners.py b/custom_components/view_assist/entity_listeners.py index eb94b63..950679f 100644 --- a/custom_components/view_assist/entity_listeners.py +++ b/custom_components/view_assist/entity_listeners.py @@ -650,6 +650,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( @@ -663,6 +673,21 @@ async def _async_on_intent_device_change( 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"][ diff --git a/custom_components/view_assist/typed.py b/custom_components/view_assist/typed.py index b732b2c..135af6d 100644 --- a/custom_components/view_assist/typed.py +++ b/custom_components/view_assist/typed.py @@ -119,6 +119,7 @@ class DashboardConfig: 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) From 5d36b9ca533e0b0a24a5767f13595c85d791d103 Mon Sep 17 00:00:00 2001 From: Michelle Avery Date: Tue, 6 May 2025 11:39:55 -0400 Subject: [PATCH 47/92] Update translations for list view --- custom_components/view_assist/translations/en.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/custom_components/view_assist/translations/en.json b/custom_components/view_assist/translations/en.json index 23a7fbb..0323c6e 100644 --- a/custom_components/view_assist/translations/en.json +++ b/custom_components/view_assist/translations/en.json @@ -81,13 +81,15 @@ "dashboard": "Dashboard", "home": "Home screen", "music": "Music view", - "intent": "Intent view" + "intent": "Intent view", + "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" + "intent": "The view to display for default HA actions for displaying those entities", + "list_view": "The view to display when updating a list" }, "sections": { "background_settings": { From 1f2e5f2c015fd9848451a9ebf0b7bd6b81c9b01a Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Thu, 8 May 2025 17:32:56 +0100 Subject: [PATCH 48/92] fix: remove update devices from mimic device selection --- custom_components/view_assist/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/view_assist/config_flow.py b/custom_components/view_assist/config_flow.py index f0f3b41..a77d987 100644 --- a/custom_components/view_assist/config_flow.py +++ b/custom_components/view_assist/config_flow.py @@ -10,7 +10,7 @@ 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 ( @@ -306,7 +306,7 @@ def get_developer_options_schema( ) ), vol.Optional(CONF_DEVELOPER_MIMIC_DEVICE): EntitySelector( - EntitySelectorConfig(integration=DOMAIN) + EntitySelectorConfig(integration=DOMAIN, domain=Platform.SENSOR) ), } ) From 983ac8856408b34cc30e7a2484fcc928f2b6e361 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 8 May 2025 15:26:44 -0400 Subject: [PATCH 49/92] Rename functions to better describe use add_menu_item becomes add_status_item remove_menu_item becomes remove_status_item --- custom_components/view_assist/menu_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py index 223489d..77ce3c1 100644 --- a/custom_components/view_assist/menu_manager.py +++ b/custom_components/view_assist/menu_manager.py @@ -259,7 +259,7 @@ async def toggle_menu( VAEvent("menu_update", {"menu_active": show}), ) - async def add_menu_item( + async def add_status_item( self, entity_id: str, status_item: StatusItemType, @@ -343,7 +343,7 @@ async def add_menu_item( for item in items: await self._setup_item_timeout(entity_id, item, timeout, menu) - async def remove_menu_item( + 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.""" @@ -474,7 +474,7 @@ async def _setup_item_timeout( async def _item_timeout_task() -> None: try: await asyncio.sleep(timeout) - await self.remove_menu_item(entity_id, menu_item, is_menu_item) + await self.remove_status_item(entity_id, menu_item, is_menu_item) except asyncio.CancelledError: pass From 127fdacc6a42ab2f27b89f3585051f0c90611b19 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 8 May 2025 15:43:42 -0400 Subject: [PATCH 50/92] Use new names add_menu_item becomes add_status_item remove_menu_item becomes remove_status_item --- custom_components/view_assist/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/view_assist/services.py b/custom_components/view_assist/services.py index 71456e2..9685c29 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -505,7 +505,7 @@ async def async_handle_add_status_item(self, call: ServiceCall): return menu_manager = self.hass.data[DOMAIN]["menu_manager"] - await menu_manager.add_menu_item(entity_id, status_items, menu, timeout) + await menu_manager.add_status_item(entity_id, status_items, menu, timeout) async def async_handle_remove_status_item(self, call: ServiceCall): """Handle remove status item service call.""" @@ -524,7 +524,7 @@ async def async_handle_remove_status_item(self, call: ServiceCall): return menu_manager = self.hass.data[DOMAIN]["menu_manager"] - await menu_manager.remove_menu_item(entity_id, status_items, menu) + await menu_manager.remove_status_item(entity_id, status_items, menu) def _process_status_item_input(self, raw_input: Any) -> str | list[str] | None: """Process and validate status item input.""" From 2e13db86260312a9219b3330bf72ee186a8aa56d Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 8 May 2025 16:10:29 -0400 Subject: [PATCH 51/92] Fix mode icon persistence when toggling menu Adds _refresh_system_icons method to properly sync icon state. Special handling for mode icons ensures they're correctly displayed. Always refreshes system icons before menu operations to prevent stale state. --- custom_components/view_assist/menu_manager.py | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py index 77ce3c1..c40eed0 100644 --- a/custom_components/view_assist/menu_manager.py +++ b/custom_components/view_assist/menu_manager.py @@ -18,6 +18,7 @@ CONF_STATUS_ICONS, DEFAULT_VALUES, DOMAIN, + VAMode, ) from .helpers import ( arrange_status_icons, @@ -169,6 +170,32 @@ def _get_config_value(self, entity_id: str, key: str, default: Any = None) -> An 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: @@ -208,14 +235,8 @@ async def toggle_menu( # Check if menu button should be shown show_menu_button = menu_config == VAMenuConfig.ENABLED_VISIBLE - # Update system icons from current status - 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" - ] - menu_state.system_icons = system_icons + # 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 = {} @@ -301,7 +322,7 @@ async def add_status_item( for item in items: if item not in updated_items: - updated_items.insert(0, item) + updated_items.append(item) changed = True if changed: @@ -533,7 +554,7 @@ async def _update_processor(self) -> None: ) except Exception as err: # noqa: BLE001 _LOGGER.error("Error updating %s: %s", - entity_id, str(err)) + entity_id, str(err)) except asyncio.CancelledError: pass From 5a7f3399c8eacf3e9a687220a9c8ea9638ab14b1 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 8 May 2025 16:32:50 -0400 Subject: [PATCH 52/92] Fix status icon persistence after mode changes Gets status icons directly from entity state when handling mode changes. Ensures consistent handling of mode icons in the status bar regardless of menu state when toggling between modes. --- .../view_assist/entity_listeners.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/custom_components/view_assist/entity_listeners.py b/custom_components/view_assist/entity_listeners.py index 49f2a0e..14138a4 100644 --- a/custom_components/view_assist/entity_listeners.py +++ b/custom_components/view_assist/entity_listeners.py @@ -634,7 +634,13 @@ async def _async_on_mode_state_change(self, event: Event) -> None: d = r.dashboard.display_settings _LOGGER.debug("MODE STATE: %s", new_mode) - status_icons = d.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] @@ -649,7 +655,19 @@ async def _async_on_mode_state_change(self, event: Event) -> None: 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: From 6a231dd08bff7bbf04a7f1cdd0df7539f934a368 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Thu, 8 May 2025 17:50:00 -0400 Subject: [PATCH 53/92] fix: correct menu item ordering in add_status_item service Remove menu item reversal in _save_to_config_entry_options method to maintain consistent menu item ordering. This fixes an issue where menu items would appear in reverse order after using the add_status_item service and return to normal order after removal. --- custom_components/view_assist/menu_manager.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py index c40eed0..46173fb 100644 --- a/custom_components/view_assist/menu_manager.py +++ b/custom_components/view_assist/menu_manager.py @@ -450,9 +450,6 @@ async def _save_to_config_entry_options( try: new_options = dict(config_entry.options) - if option_key == CONF_MENU_ITEMS: - value.reverse() - new_options[option_key] = value self.hass.config_entries.async_update_entry( config_entry, options=new_options From 83c51d330626dbcecb06eeb4a598a1cdd2d088f5 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 9 May 2025 14:58:10 -0400 Subject: [PATCH 54/92] Remove menu list reversal --- custom_components/view_assist/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index faf290b..0ba0ee4 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -335,8 +335,6 @@ def get_config_value( if sub_value := get_config_value( f"{attr}.{sub_attr}", is_master=True ): - if sub_attr == "menu_items": - sub_value = list(reversed(ensure_list(sub_value))) values[sub_attr] = sub_value value = type(getattr(r.dashboard, attr))(**values) setattr(r.dashboard, attr, value) @@ -373,8 +371,6 @@ def get_config_value( values = {} for sub_attr in getattr(r.dashboard, attr).__dict__: if sub_value := get_config_value(f"{attr}.{sub_attr}"): - if sub_attr == "menu_items": - sub_value = list(reversed(ensure_list(sub_value))) values[sub_attr] = sub_value value = type(getattr(r.dashboard, attr))(**values) setattr(r.dashboard, attr, value) From 0be887f9a8602b405ef6041992286e05048944d6 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 9 May 2025 15:12:00 -0400 Subject: [PATCH 55/92] Fix: accidental upload from wrong branch Accidentally uploaded this file from the wrong dev branch. This fixes that mistake --- .../view_assist/entity_listeners.py | 158 +----------------- 1 file changed, 6 insertions(+), 152 deletions(-) diff --git a/custom_components/view_assist/entity_listeners.py b/custom_components/view_assist/entity_listeners.py index f0967d9..14138a4 100644 --- a/custom_components/view_assist/entity_listeners.py +++ b/custom_components/view_assist/entity_listeners.py @@ -6,8 +6,6 @@ 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 ( @@ -32,7 +30,6 @@ CYCLE_VIEWS, DEFAULT_VIEW_INFO, DOMAIN, - HASSMIC_DOMAIN, REMOTE_ASSIST_DISPLAY_DOMAIN, USE_VA_NAVIGATION_FOR_BROWSERMOD, VA_ATTRIBUTE_UPDATE_EVENT, @@ -41,13 +38,12 @@ ) 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, @@ -72,7 +68,10 @@ def __init__(self, hass: HomeAssistant, config_entry: VAConfigEntry) -> None: self.cycle_view_task: Task | None = None self.rotate_background_task: Task | None = None - self.music_player_volume: float | None = None + # Add microphone mute switch listener + mute_switch = get_mute_switch_entity_id( + hass, config_entry.runtime_data.core.mic_device + ) # Add browser navigate service listener config_entry.async_on_unload( @@ -91,37 +90,7 @@ def __init__(self, hass: HomeAssistant, config_entry: VAConfigEntry) -> None: ) ) - # 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 - ) + # Add mic mute switch listener if mute_switch: config_entry.async_on_unload( async_track_state_change_event( @@ -448,96 +417,6 @@ 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 @@ -669,16 +548,6 @@ 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( @@ -692,21 +561,6 @@ async def _async_on_intent_device_change( 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"][ From f71a1c1454c917e536a37792d623f818f31af830 Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 9 May 2025 15:18:01 -0400 Subject: [PATCH 56/92] Revert --- .../view_assist/entity_listeners.py | 158 +++++++++++++++++- 1 file changed, 152 insertions(+), 6 deletions(-) diff --git a/custom_components/view_assist/entity_listeners.py b/custom_components/view_assist/entity_listeners.py index 14138a4..f0967d9 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,6 +32,7 @@ CYCLE_VIEWS, DEFAULT_VIEW_INFO, DOMAIN, + HASSMIC_DOMAIN, REMOTE_ASSIST_DISPLAY_DOMAIN, USE_VA_NAVIGATION_FOR_BROWSERMOD, VA_ATTRIBUTE_UPDATE_EVENT, @@ -38,12 +41,13 @@ ) 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, @@ -68,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.core.mic_device - ) + self.music_player_volume: float | None = None # Add browser navigate service listener config_entry.async_on_unload( @@ -90,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( @@ -417,6 +448,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 @@ -548,6 +669,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( @@ -561,6 +692,21 @@ async def _async_on_intent_device_change( 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"][ From aaf211b9b0bfae10a82fc94119efd15947bceb7f Mon Sep 17 00:00:00 2001 From: Flab <118143714+Flight-Lab@users.noreply.github.com> Date: Fri, 9 May 2025 15:55:23 -0400 Subject: [PATCH 57/92] Fix syntax error Accidentally combined multiple services in one registry when merging the dev branch. This commit fixes that. --- custom_components/view_assist/services.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/view_assist/services.py b/custom_components/view_assist/services.py index 7ab3053..3e1a285 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -272,6 +272,9 @@ async def async_setup_services(self): "remove_status_item", self.async_handle_remove_status_item, schema=REMOVE_STATUS_ITEM_SERVICE_SCHEMA, + ) + + self.hass.services.async_register( DOMAIN, "update_versions", self.async_handle_update_versions ) @@ -540,4 +543,3 @@ async def async_handle_update_versions(self, call: ServiceCall): await dm.update_dashboard_view_versions(force=True) except (DownloadManagerException, DashboardManagerException) as ex: raise HomeAssistantError(ex) from ex - From b8072d6848581f72287e97fcc5f4e246c8677560 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Mon, 19 May 2025 12:36:52 +0100 Subject: [PATCH 58/92] added: asset manager for dashboard, views and blueprints --- custom_components/view_assist/__init__.py | 24 +- .../view_assist/assets/__init__.py | 298 ++++++ custom_components/view_assist/assets/base.py | 86 ++ .../view_assist/assets/blueprints.py | 273 ++++++ .../view_assist/assets/dashboard.py | 361 +++++++ .../view_assist/assets/download_manager.py | 250 +++++ custom_components/view_assist/assets/views.py | 394 ++++++++ custom_components/view_assist/const.py | 28 +- custom_components/view_assist/dashboard.py | 923 ------------------ custom_components/view_assist/services.py | 86 -- custom_components/view_assist/services.yaml | 58 +- .../view_assist/translations/en.json | 4 +- custom_components/view_assist/update.py | 164 ++-- 13 files changed, 1813 insertions(+), 1136 deletions(-) create mode 100644 custom_components/view_assist/assets/__init__.py create mode 100644 custom_components/view_assist/assets/base.py create mode 100644 custom_components/view_assist/assets/blueprints.py create mode 100644 custom_components/view_assist/assets/dashboard.py create mode 100644 custom_components/view_assist/assets/download_manager.py create mode 100644 custom_components/view_assist/assets/views.py delete mode 100644 custom_components/view_assist/dashboard.py diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index a679233..1998e2d 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -10,6 +10,7 @@ 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, @@ -35,7 +36,6 @@ DOMAIN, OPTION_KEY_MIGRATIONS, ) -from .dashboard import DASHBOARD_MANAGER, DashboardManager from .entity_listeners import EntityListeners from .helpers import ( ensure_list, @@ -190,7 +190,6 @@ 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 set_runtime_data_for_config(hass, entry, is_master_entry) - _LOGGER.debug("Runtime Data: %s", entry.runtime_data.__dict__) # Add config change listener entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -263,6 +262,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.""" @@ -279,17 +288,6 @@ 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() - - # Request 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_at_started(hass, setup_frontend) diff --git a/custom_components/view_assist/assets/__init__.py b/custom_components/view_assist/assets/__init__.py new file mode 100644 index 0000000..04e9fd1 --- /dev/null +++ b/custom_components/view_assist/assets/__init__.py @@ -0,0 +1,298 @@ +"""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 + self.data = { + "last_updated": self.data.pop("last_updated"), + **dict(sorted(self.data.items(), key=lambda x: x[0].lower())), + } + await self.store.async_save(self.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() + + +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 + if version_info := await manager.async_get_version_info(): + 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..8135ed0 --- /dev/null +++ b/custom_components/view_assist/assets/base.py @@ -0,0 +1,86 @@ +"""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_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, view: str, progress: int): + """Update progress of view download.""" + async_dispatcher_send( + self.hass, + VA_ASSET_UPDATE_PROGRESS, + {"view": view, "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..88bb7ce --- /dev/null +++ b/custom_components/view_assist/assets/blueprints.py @@ -0,0 +1,273 @@ +"""Blueprint manager for View Assist.""" + +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 = {} + 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_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) -> 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) + 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 ValueError("Invalid 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://github.com/{GITHUB_REPO}/blob/{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..2bfb1f3 --- /dev/null +++ b/custom_components/view_assist/assets/dashboard.py @@ -0,0 +1,361 @@ +"""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.build_mode = 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): + # 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") + 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.onboarding = False + + return status + + 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) -> 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), + } + } + + 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}" + ) + + 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 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( + dashboard_config + ) + self._update_install_progress("dashboard", 80) + + installed_version = self._read_dashboard_version(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 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 + dashboard_config["views"] = dashboard_config.get("views") + + # Apply + await dashboard_store.async_save(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(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) + _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) -> bool: + """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"{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.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.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..36782bb --- /dev/null +++ b/custom_components/view_assist/assets/download_manager.py @@ -0,0 +1,250 @@ +"""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 diff --git a/custom_components/view_assist/assets/views.py b/custom_components/view_assist/assets/views.py new file mode 100644 index 0000000..2ed5b21 --- /dev/null +++ b/custom_components/view_assist/assets/views.py @@ -0,0 +1,394 @@ +"""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): + 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_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) -> 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) + 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, + ): + """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}" + + # 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/const.py b/custom_components/view_assist/const.py index 7209912..b0a9026 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -16,27 +16,14 @@ 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" @@ -179,9 +166,6 @@ class VAMode(StrEnum): 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" @@ -190,10 +174,14 @@ class VAMode(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_VIEW_DOWNLOAD_PROGRESS = "va_view_download_progress" +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" diff --git a/custom_components/view_assist/dashboard.py b/custom_components/view_assist/dashboard.py deleted file mode 100644 index 6e387cf..0000000 --- a/custom_components/view_assist/dashboard.py +++ /dev/null @@ -1,923 +0,0 @@ -"""Manage views - download, apply, backup, restore.""" - -from dataclasses import dataclass -from datetime import timedelta -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.helpers.event import async_track_time_interval -from homeassistant.helpers.start import async_at_started -from homeassistant.helpers.storage import Store -from homeassistant.util import dt as dt_util -from homeassistant.util.yaml import load_yaml_dict, parse_yaml, 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, - VA_VIEW_DOWNLOAD_PROGRESS, - VERSION_CHECK_INTERVAL, - VIEWS_DIR, -) -from .helpers import differ_to_json, get_key, json_to_dictdiffer -from .typed import VAConfigEntry, VAEvent -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): - # 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 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, data_as_text: bool = False - ) -> bytes | None: - """Download file.""" - if file_data := await self._rest_request( - download_url, 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 _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 get_dashboard_version(self) -> str | None: - """Get dashboard version from repo.""" - dashboard_url = f"https://raw.githubusercontent.com/{GITHUB_REPO}/{GITHUB_BRANCH}/{GITHUB_PATH}/{DASHBOARD_DIR}/{DASHBOARD_DIR}.yaml" - dashboard_data = await self.github.download_file( - dashboard_url, data_as_text=True - ) - if dashboard_data: - try: - # Parse yaml string to json - dashboard_data = parse_yaml(dashboard_data) - if variables := get_key( - "button_card_templates.variable_template.variables", - dashboard_data, - ): - return variables.get("dashboardversion", "0.0.0") - except KeyError: - _LOGGER.debug("Dashboard version not found") - return "0.0.0" - - 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 get_view_version(self, view: str) -> str | None: - """Get dashboard version from repo.""" - view_url = f"https://raw.githubusercontent.com/{GITHUB_REPO}/{GITHUB_BRANCH}/{GITHUB_PATH}/{VIEWS_DIR}/{view}/{view}.yaml" - view_data = await self.github.download_file(view_url, data_as_text=True) - # Parse yaml string to json - view_data = parse_yaml(view_data) - if view_data: - try: - if variables := view_data.get("variables"): - return variables.get( - f"{view}version", variables.get(f"{view}cardversion", "0.0.0") - ) - except KeyError: - _LOGGER.debug("Dashboard version not found") - return "0.0.0" - - 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 DashboardManagerStorage: - """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}.dashboard") - - async def _save(self): - """Save store.""" - self.data["last_updated"] = dt_util.now().isoformat() - await self.store.async_save(self.data) - - 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 dashboard store. Error is %s", ex) - self.data = {} - return self.data - - async def update_dashboard(self, data: dict[str, Any]): - """Update store.""" - self.data["dashboard"] = data - await self._save() - - async def update_views(self, data: dict[str, Any]): - """Update store.""" - self.data["views"] = data - await self._save() - - -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.store = DashboardManagerStorage(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) - ) - - # Experimental - schedule update of dashboard view versions - if self.config.runtime_data.integration.enable_updates: - async_at_started(hass, self.update_dashboard_view_versions) - config.async_on_unload( - async_track_time_interval( - hass, - self.update_dashboard_view_versions, - timedelta(minutes=VERSION_CHECK_INTERVAL), - ) - ) - - 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 - - _LOGGER.debug("Dashboard version - %s", await self.get_dashboard_version()) - _LOGGER.debug("Installed views - %s", await self.get_installed_views()) - - 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() - async_dispatcher_send( - self.hass, - VA_VIEW_DOWNLOAD_PROGRESS, - {"view": "dashboard", "progress": 33}, - ) - - # 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) - async_dispatcher_send( - self.hass, - VA_VIEW_DOWNLOAD_PROGRESS, - {"view": "dashboard", "progress": 66}, - ) - await self._apply_user_dashboard_changes() - async_dispatcher_send( - self.hass, - VA_VIEW_DOWNLOAD_PROGRESS, - {"view": "dashboard", "progress": 90}, - ) - - # Update installed version info - if self.config.runtime_data.integration.enable_updates: - dashboard_info = { - "installed": await self.get_dashboard_version(), - "latest": await self.download_manager.get_dashboard_version(), - } - await self.store.update_dashboard(dashboard_info) - - async_dispatcher_send( - self.hass, - VA_VIEW_DOWNLOAD_PROGRESS, - {"view": "dashboard", "progress": 100}, - ) - 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 get_dashboard_version(self) -> str | None: - """Get the version of the dashboard.""" - # 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: - if dashboard_config := await dashboard_store.async_load(False): - 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" - - def get_view_version(self, view_name: str, view_dict: dict[str, Any]) -> str | None: - """Get the version of a view from view dict.""" - try: - if cards := view_dict.get("cards"): - if variables := cards[0].get("variables"): - # View variable can be in multiple formats - if version := variables.get(f"{view_name}cardversion"): - return version - if version := variables.get(f"{view_name}version"): - return version - except KeyError: - _LOGGER.debug("View version not found") - return "0.0.0" - - async def get_installed_views(self) -> list[str]: - """Return list of views and version in dashboard.""" - output = {} - 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 view in dashboard_config["views"]: - view_name = view.get("path") - if view_name not in output: - output[view_name] = self.get_view_version(view_name, view) - return output - - async def update_dashboard_view_versions( - self, now: Event | None = None, force: bool = False - ): - """Update the version of the views in the dashboard.""" - # check if last updated within last hour - data = await self.store.load() - if not force and data and "last_updated" in data: - last_updated = dt_util.parse_datetime(data["last_updated"]) - if last_updated and dt_util.utcnow() - last_updated - timedelta( - seconds=30 - ) < timedelta(minutes=VERSION_CHECK_INTERVAL): - return - - _LOGGER.debug("Updating dashboard view versions") - # Dashboard - installed_dashboard = await self.get_dashboard_version() - if installed_dashboard: - latest_dashboard = await self.download_manager.get_dashboard_version() - await self.store.update_dashboard( - {"installed": installed_dashboard, "latest": latest_dashboard} - ) - - # Views - view_info: dict[str, Any] = {} - installed_views = await self.get_installed_views() - for view in installed_views: - latest_version = await self.download_manager.get_view_version(view) - view_info[view] = { - "installed": installed_views[view], - "latest": latest_version, - } - await self.store.update_views(view_info) - - if force: - # Fire refresh event - async_dispatcher_send( - self.hass, - VA_VIEW_DOWNLOAD_PROGRESS, - {"view": "all"}, - ) - - 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") - async_dispatcher_send( - self.hass, VA_VIEW_DOWNLOAD_PROGRESS, {"view": name, "progress": 33} - ) - - # 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 - ) - async_dispatcher_send( - self.hass, VA_VIEW_DOWNLOAD_PROGRESS, {"view": name, "progress": 66} - ) - 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 - - # Update installed version info - if self.config.runtime_data.integration.enable_updates: - views_info = self.store.data["views"] - if name in views_info: - views_info[name]["installed"] = self.get_view_version( - name, new_view - ) - else: - views_info[name] = { - "installed": self.get_view_version(name, new_view), - "latest": await self.download_manager.get_view_version( - name - ), - } - await self.store.update_views(views_info) - - async_dispatcher_send( - self.hass, - VA_VIEW_DOWNLOAD_PROGRESS, - {"view": name, "progress": 100}, - ) - 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/services.py b/custom_components/view_assist/services.py index ea831f1..38327aa 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -12,7 +12,6 @@ ServiceResponse, SupportsResponse, ) -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, entity_registry as er, @@ -22,8 +21,6 @@ from .alarm_repeater import ALARMS, VAAlarmRepeater from .const import ( - ATTR_BACKUP_CURRENT_VIEW, - ATTR_COMMUNITY_VIEW, ATTR_DEVICE, ATTR_EVENT_DATA, ATTR_EVENT_NAME, @@ -32,19 +29,12 @@ ATTR_MAX_REPEATS, ATTR_MEDIA_FILE, ATTR_PATH, - ATTR_REDOWNLOAD_FROM_REPO, ATTR_REMOVE_ALL, ATTR_RESUME_MEDIA, ATTR_TIMER_ID, ATTR_TYPE, DOMAIN, ) -from .dashboard import ( - DASHBOARD_MANAGER, - DashboardManager, - DashboardManagerException, - DownloadManagerException, -) from .helpers import get_mimic_entity_id from .timers import TIMERS, VATimers, decode_time_sentence from .typed import VAConfigEntry @@ -125,20 +115,6 @@ } ) -DASHVIEW_SERVICE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_NAME): str, - } -) -LOAD_DASHVIEW_SERVICE_SCHEMA = DASHVIEW_SERVICE_SCHEMA.extend( - { - vol.Required(ATTR_NAME): str, - vol.Required(ATTR_REDOWNLOAD_FROM_REPO, default=False): bool, - vol.Optional(ATTR_COMMUNITY_VIEW, default=False): bool, - vol.Required(ATTR_BACKUP_CURRENT_VIEW, default=False): bool, - } -) - class VAServices: """Class to manage services.""" @@ -213,24 +189,6 @@ async def async_setup_services(self): schema=BROADCAST_EVENT_SERVICE_SCHEMA, ) - self.hass.services.async_register( - DOMAIN, - "load_view", - self.async_handle_load_view, - schema=LOAD_DASHVIEW_SERVICE_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - "save_view", - self.async_handle_save_view, - schema=DASHVIEW_SERVICE_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, "update_versions", self.async_handle_update_versions - ) - # ----------------------------------------------------------------------- # Get Target Satellite # Used to determine which VA satellite is being used based on its microphone device @@ -391,47 +349,3 @@ async def async_handle_get_timers(self, call: ServiceCall) -> ServiceResponse: include_expired=include_expired, ) return {"result": result} - - # ---------------------------------------------------------------- - # VIEWS - # ---------------------------------------------------------------- - async def async_handle_load_view(self, call: ServiceCall): - """Handle load of a view from view_assist dir.""" - - view_name = call.data.get(ATTR_NAME) - download = call.data.get(ATTR_REDOWNLOAD_FROM_REPO, False) - community_view = call.data.get(ATTR_COMMUNITY_VIEW, False) - backup = call.data.get(ATTR_BACKUP_CURRENT_VIEW, False) - - dm: DashboardManager = self.hass.data[DOMAIN][DASHBOARD_MANAGER] - try: - if view_name == "dashboard": - await dm.update_dashboard(download_from_repo=download) - else: - await dm.add_update_view( - name=view_name, - download_from_repo=download, - community_view=community_view, - backup_current_view=backup, - ) - except (DownloadManagerException, DashboardManagerException) as ex: - raise HomeAssistantError(ex) from ex - - async def async_handle_save_view(self, call: ServiceCall): - """Handle saving view to view_assit dir.""" - - view_name = call.data.get(ATTR_NAME) - - dm: DashboardManager = self.hass.data[DOMAIN][DASHBOARD_MANAGER] - try: - await dm.save_view(view_name) - except (DownloadManagerException, DashboardManagerException) as ex: - raise HomeAssistantError(ex) from ex - - async def async_handle_update_versions(self, call: ServiceCall): - """Handle update of the view versions.""" - dm: DashboardManager = self.hass.data[DOMAIN][DASHBOARD_MANAGER] - try: - await dm.update_dashboard_view_versions(force=True) - except (DownloadManagerException, DashboardManagerException) as ex: - raise HomeAssistantError(ex) from ex diff --git a/custom_components/view_assist/services.yaml b/custom_components/view_assist/services.yaml index 2a952f9..9f43694 100644 --- a/custom_components/view_assist/services.yaml +++ b/custom_components/view_assist/services.yaml @@ -208,47 +208,59 @@ 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 - default: false - selector: - boolean: - community_view: - name: "Community view" - description: "If this should be downloaded from the community views folder" - required: false + required: true default: false selector: boolean: - backup_current_view: - name: "Backup current view" - description: "Backup yaml of view if it exists before updating" - required: false + backup_current_asset: + name: "Backup existing" + description: "Backup existing before updating" + required: true default: false selector: boolean: -save_view: - name: "Save View" - description: "Save a view to the View Assist views directory" +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: "View name" - description: "The name of the view" + name: "Name" + description: "The name of the asset" required: true selector: text: update_versions: name: "Update version info" - description: "Get the latest version info of the dashboard and views from the github repo" + description: "Get the latest version info of the dashboard, views and blueprints from the github repo" diff --git a/custom_components/view_assist/translations/en.json b/custom_components/view_assist/translations/en.json index 0323c6e..105676e 100644 --- a/custom_components/view_assist/translations/en.json +++ b/custom_components/view_assist/translations/en.json @@ -154,10 +154,10 @@ "integration_options": { "title": "{name} Integration Options", "data": { - "enable_updates": "Enable updates" + "enable_updates": "Enable update notifications" }, "data_description": { - "enable_updates": "Enable or disable update notifications for the dashboard and views" + "enable_updates": "Enable or disable update notifications for the dashboard, views and blueprints" } }, "developer_options": { diff --git a/custom_components/view_assist/update.py b/custom_components/view_assist/update.py index c86d0aa..1fd5c36 100644 --- a/custom_components/view_assist/update.py +++ b/custom_components/view_assist/update.py @@ -2,24 +2,34 @@ from __future__ import annotations -import asyncio import logging from typing import Any +from awesomeversion import AwesomeVersion + +from config.custom_components.view_assist.assets import ( + ASSETS_MANAGER, + VA_ADD_UPDATE_ENTITY_EVENT, + AssetClass, + AssetsManager, +) 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.base import AssetManagerException from .const import ( + BLUEPRINT_GITHUB_PATH, + DASHBOARD_DIR, + DASHBOARD_VIEWS_GITHUB_PATH, DOMAIN, GITHUB_BRANCH, - GITHUB_PATH, GITHUB_REPO, - VA_VIEW_DOWNLOAD_PROGRESS, + VA_ASSET_UPDATE_PROGRESS, VIEWS_DIR, ) -from .dashboard import DASHBOARD_MANAGER, DashboardManager from .typed import VAConfigEntry _LOGGER = logging.getLogger(__name__) @@ -29,39 +39,54 @@ async def async_setup_entry( hass: HomeAssistant, entry: VAConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up update platform.""" - update_sensors = [] - dm = hass.data[DOMAIN][DASHBOARD_MANAGER] - data = await dm.store.load() - - # Wait upto 5s for view data to be created on first run - for _ in range(5): - if data.get("views") is not None: - break - data = await dm.store.load() - await asyncio.sleep(1) - - if data.get("views"): - update_sensors = [ - VAUpdateEntity( - dm=dm, - view=view, - ) - for view in data["views"] - ] - else: - _LOGGER.error("Unable to load view version information") - - if data.get("dashboard"): - update_sensors.append( - VAUpdateEntity( - dm=dm, - view="dashboard", - ) + am: AssetsManager = hass.data[DOMAIN][ASSETS_MANAGER] + + async def async_add_remove_update_entity(data: dict[str, Any]) -> 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}" + if remove: + entity_reg = er.async_get(hass) + if entity_id := entity_reg.async_get_entity_id("update", DOMAIN, unique_id): + entity_reg.async_remove(entity_id) + return + + # Add new update entity + async_add_entities( + [ + VAUpdateEntity( + am=am, + asset_class=asset_class, + name=name, + ) + ] ) - else: - _LOGGER.error("Unable to load dashboard version information") - async_add_entities(update_sensors) + 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, + } + ) class VAUpdateEntity(UpdateEntity): @@ -73,56 +98,62 @@ class VAUpdateEntity(UpdateEntity): | UpdateEntityFeature.RELEASE_NOTES ) - def __init__(self, dm: DashboardManager, view: str) -> None: + def __init__(self, am: AssetsManager, asset_class: AssetClass, name: str) -> None: """Initialize.""" - self.dm = dm - self.view = view + self.am = am + self._asset_class = asset_class + self._name = name self._attr_supported_features = ( (self._attr_supported_features | UpdateEntityFeature.BACKUP) - if view != "dashboard" + if self._asset_class != AssetClass.DASHBOARD else self._attr_supported_features ) @property def name(self) -> str | None: """Return the name.""" - if self.view == "dashboard": - return f"View Assist - {self.view}" - return f"View Assist - {self.view} view" + 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.""" - if self.view == "dashboard": - return f"{DOMAIN}_{self.view}" - return f"{DOMAIN}_{self.view}_view" + return f"{DOMAIN}_{self._asset_class}_{self._name}" @property def latest_version(self) -> str: """Return latest version of the entity.""" - if self.view == "dashboard": - return self.dm.store.data["dashboard"]["latest"] - return self.dm.store.data["views"][self.view]["latest"] + return self.am.store.data[self._asset_class][self._name]["latest"] @property def release_url(self) -> str: """Return the URL of the release page.""" - return f"https://github.com/{GITHUB_REPO}/tree/{GITHUB_BRANCH}/{GITHUB_PATH}/{VIEWS_DIR}/{self.view}" + 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.""" - if self.view == "dashboard": - return self.dm.store.data["dashboard"]["installed"] - return self.dm.store.data["views"][self.view]["installed"] + return self.am.store.data[self._asset_class][self._name]["installed"] @property def release_summary(self) -> str | None: """Return the release summary.""" - if self.view == "dashboard": + if self._asset_class == AssetClass.DASHBOARD: return "Updating the dashboard will attempt to keep any changes you have made to it" - return "Updating this view will overwrite 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: @@ -134,15 +165,13 @@ async def async_install( ) -> None: """Install an update.""" try: - if self.view == "dashboard": - # Install dashboard - await self.dm.update_dashboard(download_from_repo=True) - else: - # Install view - await self.dm.add_update_view( - self.view, download_from_repo=True, backup_current_view=backup - ) - except Exception as exception: + 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: @@ -156,7 +185,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - VA_VIEW_DOWNLOAD_PROGRESS, + VA_ASSET_UPDATE_PROGRESS, self._update_download_progress, ) ) @@ -164,8 +193,5 @@ async def async_added_to_hass(self) -> None: @callback def _update_download_progress(self, data: dict) -> None: """Update the download progress.""" - if data["view"] == self.view: - self._attr_in_progress = data["progress"] - self.async_write_ha_state() - elif data["view"] == "all": - self.schedule_update_ha_state(force_refresh=True) + self._attr_in_progress = data["progress"] + self.async_write_ha_state() From 4d205b2427e50b95444ef947443b4717259aa564 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Mon, 19 May 2025 13:10:41 +0100 Subject: [PATCH 59/92] refactor: correct linting --- custom_components/view_assist/typed.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/view_assist/typed.py b/custom_components/view_assist/typed.py index 92dffb1..23a9455 100644 --- a/custom_components/view_assist/typed.py +++ b/custom_components/view_assist/typed.py @@ -66,10 +66,12 @@ class VABackgroundMode(StrEnum): DOWNLOAD_RANDOM = "download" LINKED = "link_to_entity" + class VAMenuConfig(StrEnum): """Menu configuration options enum.""" + DISABLED = "menu_disabled" - ENABLED_VISIBLE = "menu_enabled_button_visible" + ENABLED_VISIBLE = "menu_enabled_button_visible" ENABLED_HIDDEN = "menu_enabled_button_hidden" From be1734a9b66e0a33689571e08b96caf2012d2c46 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Mon, 19 May 2025 13:11:03 +0100 Subject: [PATCH 60/92] fix: prevent duplicate entities --- custom_components/view_assist/update.py | 32 +++++++++++++++---------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/custom_components/view_assist/update.py b/custom_components/view_assist/update.py index 1fd5c36..156df7b 100644 --- a/custom_components/view_assist/update.py +++ b/custom_components/view_assist/update.py @@ -41,29 +41,34 @@ async def async_setup_entry( """Set up update platform.""" am: AssetsManager = hass.data[DOMAIN][ASSETS_MANAGER] - async def async_add_remove_update_entity(data: dict[str, Any]) -> None: + 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: - entity_reg = er.async_get(hass) if entity_id := entity_reg.async_get_entity_id("update", DOMAIN, unique_id): entity_reg.async_remove(entity_id) return # Add new update entity - async_add_entities( - [ - VAUpdateEntity( - am=am, - asset_class=asset_class, - name=name, - ) - ] - ) + 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( @@ -85,7 +90,8 @@ async def async_add_remove_update_entity(data: dict[str, Any]) -> None: "asset_class": asset_class, "name": name, "remove": AwesomeVersion(installed) >= latest, - } + }, + startup=True, ) @@ -193,5 +199,7 @@ async def async_added_to_hass(self) -> None: @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() From e43fa3b8cc5b1496840bd0e6464e3d4671fa61f0 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Mon, 19 May 2025 13:11:20 +0100 Subject: [PATCH 61/92] refactor: change parameter view to name --- custom_components/view_assist/assets/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/view_assist/assets/base.py b/custom_components/view_assist/assets/base.py index 8135ed0..bd0cdd8 100644 --- a/custom_components/view_assist/assets/base.py +++ b/custom_components/view_assist/assets/base.py @@ -77,10 +77,10 @@ async def async_save(self, name: str) -> bool: """Save asset.""" raise NotImplementedError - def _update_install_progress(self, view: str, progress: int): + def _update_install_progress(self, name: str, progress: int): """Update progress of view download.""" async_dispatcher_send( self.hass, VA_ASSET_UPDATE_PROGRESS, - {"view": view, "progress": progress}, + {"name": name, "progress": progress}, ) From d0b7fe7a14205f53285931e8e5afe16b6ff481c0 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Mon, 19 May 2025 13:16:13 +0100 Subject: [PATCH 62/92] refactor: fix linting issues --- custom_components/view_assist/helpers.py | 5 +++-- custom_components/view_assist/menu_manager.py | 11 ++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/custom_components/view_assist/helpers.py b/custom_components/view_assist/helpers.py index 49f81f7..196d273 100644 --- a/custom_components/view_assist/helpers.py +++ b/custom_components/view_assist/helpers.py @@ -101,12 +101,14 @@ def ensure_list(value: str | list[str]): return value if value else [] 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. @@ -197,8 +199,7 @@ def update_status_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) + result = arrange_status_icons(menu_icon_list, system_icons, show_menu_button) elif show_menu_button: ensure_menu_button_at_end(result) diff --git a/custom_components/view_assist/menu_manager.py b/custom_components/view_assist/menu_manager.py index 46173fb..820f5a3 100644 --- a/custom_components/view_assist/menu_manager.py +++ b/custom_components/view_assist/menu_manager.py @@ -183,7 +183,7 @@ def _refresh_system_icons(self, entity_id: str, menu_state: MenuState) -> list[s system_icons = [ icon for icon in current_status_icons - if icon not in menu_state.configured_items + if icon not in menu_state.configured_items and icon != "menu" and icon not in modes ] @@ -443,8 +443,7 @@ async def _save_to_config_entry_options( """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) + _LOGGER.warning("Cannot save %s - config entry not found", option_key) return try: @@ -550,8 +549,7 @@ async def _update_processor(self) -> None: DOMAIN, "set_state", changes ) except Exception as err: # noqa: BLE001 - _LOGGER.error("Error updating %s: %s", - entity_id, str(err)) + _LOGGER.error("Error updating %s: %s", entity_id, str(err)) except asyncio.CancelledError: pass @@ -566,8 +564,7 @@ async def _ensure_initialized(self) -> None: 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) + entity_id = get_sensor_entity_from_instance(self.hass, entry_id) if entity_id: self._get_or_create_state(entity_id) From 1514dc1185b23d136ba1fd997ef23b2d978fa405 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Mon, 19 May 2025 13:22:50 +0100 Subject: [PATCH 63/92] fix: merge and linting issues --- custom_components/view_assist/entity_listeners.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/view_assist/entity_listeners.py b/custom_components/view_assist/entity_listeners.py index f0967d9..0ff44c1 100644 --- a/custom_components/view_assist/entity_listeners.py +++ b/custom_components/view_assist/entity_listeners.py @@ -41,6 +41,7 @@ ) 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, @@ -257,7 +258,7 @@ async def async_browser_navigate( 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, @@ -782,7 +783,9 @@ async def _async_on_mode_state_change(self, event: Event) -> None: _LOGGER.debug("MODE STATE: %s", new_mode) # Get current status icons directly from entity state - entity_id = get_sensor_entity_from_instance(self.hass, self.config_entry.entry_id) + 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: @@ -808,10 +811,7 @@ async def _async_on_mode_state_change(self, event: Event) -> None: await self.hass.services.async_call( DOMAIN, "set_state", - service_data={ - "entity_id": entity_id, - "status_icons": status_icons - }, + service_data={"entity_id": entity_id, "status_icons": status_icons}, ) self.update_entity() From 5cd9599ecd0adbfebc48bd70106b4c025aa8d002 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Mon, 19 May 2025 18:55:14 +0100 Subject: [PATCH 64/92] fix: remove no longer needed function --- custom_components/view_assist/services.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/custom_components/view_assist/services.py b/custom_components/view_assist/services.py index 1a7ab3f..2c24665 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -458,10 +458,3 @@ def _process_status_item_input(self, raw_input: Any) -> str | list[str] | None: return normalize_status_items(raw_input) - async def async_handle_update_versions(self, call: ServiceCall): - """Handle update of the view versions.""" - dm: DashboardManager = self.hass.data[DOMAIN][DASHBOARD_MANAGER] - try: - await dm.update_dashboard_view_versions(force=True) - except (DownloadManagerException, DashboardManagerException) as ex: - raise HomeAssistantError(ex) from ex From 2a859ee65710622280e3e6d8d97c7003345989af Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Mon, 19 May 2025 18:57:08 +0100 Subject: [PATCH 65/92] refactor: improve version update performance --- .../view_assist/assets/__init__.py | 40 ++++++++++++++++--- custom_components/view_assist/assets/base.py | 4 ++ .../view_assist/assets/blueprints.py | 16 +++++++- .../view_assist/assets/dashboard.py | 14 ++++++- .../view_assist/assets/download_manager.py | 12 ++++++ custom_components/view_assist/assets/views.py | 17 +++++++- 6 files changed, 92 insertions(+), 11 deletions(-) diff --git a/custom_components/view_assist/assets/__init__.py b/custom_components/view_assist/assets/__init__.py index 04e9fd1..33ad159 100644 --- a/custom_components/view_assist/assets/__init__.py +++ b/custom_components/view_assist/assets/__init__.py @@ -85,11 +85,15 @@ async def _save(self): await self.lock.acquire() self.data["last_updated"] = dt_util.now().isoformat() # Order dict for reading - self.data = { - "last_updated": self.data.pop("last_updated"), - **dict(sorted(self.data.items(), key=lambda x: x[0].lower())), + data = self.data.copy() + last_updated = data.pop("last_updated") + last_commit = data.pop("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(self.data) + await self.store.async_save(data) self.lock.release() async def load(self, force: bool = False): @@ -115,6 +119,12 @@ async def update(self, asset_class: str, id: str | None, data: dict[str, Any]): 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.""" @@ -245,7 +255,27 @@ async def async_update_version_info( managers = {k: v for k, v in self.managers.items() if k == asset_class} for asset_class, manager in managers.items(): # noqa: PLR1704 - if version_info := await manager.async_get_version_info(): + # Reduces download by only getting version from repo if the last commit date is greater than + # we have stored + update_from_repo = True + if self.data.get("last_commit"): + if repo_last_commit := await manager.async_get_last_commit(): + stored_last_commit = self.data.get("last_commit").get(asset_class) + if repo_last_commit == stored_last_commit: + _LOGGER.debug( + "No new updates in repo for %s", + asset_class, + ) + update_from_repo = False + + 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 diff --git a/custom_components/view_assist/assets/base.py b/custom_components/view_assist/assets/base.py index bd0cdd8..6d02e57 100644 --- a/custom_components/view_assist/assets/base.py +++ b/custom_components/view_assist/assets/base.py @@ -50,6 +50,10 @@ async def async_get_installed_version(self, name: str) -> str | None: 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 diff --git a/custom_components/view_assist/assets/blueprints.py b/custom_components/view_assist/assets/blueprints.py index 88bb7ce..45465eb 100644 --- a/custom_components/view_assist/assets/blueprints.py +++ b/custom_components/view_assist/assets/blueprints.py @@ -83,6 +83,12 @@ async def async_onboard(self) -> None: 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): @@ -103,14 +109,20 @@ async def async_get_installed_version(self, name: str) -> str | None: return self._read_blueprint_version(blueprint.metadata) return None - async def async_get_version_info(self) -> dict[str, str]: + 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) + 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, diff --git a/custom_components/view_assist/assets/dashboard.py b/custom_components/view_assist/assets/dashboard.py index 2bfb1f3..b42b5be 100644 --- a/custom_components/view_assist/assets/dashboard.py +++ b/custom_components/view_assist/assets/dashboard.py @@ -89,6 +89,12 @@ async def async_onboard(self) -> dict[str, Any] | None: 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"] @@ -109,12 +115,16 @@ async def async_get_latest_version(self, name: str) -> dict[str, Any]: dashboard_data = parse_yaml(dashboard_data) return self._read_dashboard_version(dashboard_data) - async def async_get_version_info(self) -> dict[str, Any]: + async def async_get_version_info( + self, update_from_repo: bool = True + ) -> dict[str, Any]: """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), + "latest": await self.async_get_latest_version(DASHBOARD_DIR) + if update_from_repo + else self.data.get("dashboard", {}).get("latest"), } } diff --git a/custom_components/view_assist/assets/download_manager.py b/custom_components/view_assist/assets/download_manager.py index 36782bb..bad2edd 100644 --- a/custom_components/view_assist/assets/download_manager.py +++ b/custom_components/view_assist/assets/download_manager.py @@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util from ..const import DOMAIN, GITHUB_BRANCH, GITHUB_REPO # noqa: TID252 @@ -248,3 +249,14 @@ async def get_file_contents(self, file_path: str) -> str | None: 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 index 2ed5b21..f6fc8f3 100644 --- a/custom_components/view_assist/assets/views.py +++ b/custom_components/view_assist/assets/views.py @@ -7,6 +7,7 @@ from homeassistant.components.lovelace import LovelaceData, dashboard from homeassistant.const import EVENT_PANELS_UPDATED from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util from homeassistant.util.yaml import load_yaml_dict, parse_yaml, save_yaml from ..const import ( # noqa: TID252 @@ -63,6 +64,12 @@ async def async_onboard(self) -> dict[str, Any] | None: 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): @@ -82,14 +89,20 @@ async def async_get_latest_version(self, name: str) -> str | None: _LOGGER.error("Failed to parse view %s", name) return None - async def async_get_version_info(self) -> dict[str, Any]: + async def async_get_version_info( + self, update_from_repo: bool = True + ) -> dict[str, Any]: """Update versions from repo.""" # 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) + 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, From 61b19f89584543de40ec171cbf396b5caab73654 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Wed, 21 May 2025 13:13:51 +0100 Subject: [PATCH 66/92] fix: add blueprint to dependancies --- custom_components/view_assist/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/view_assist/manifest.json b/custom_components/view_assist/manifest.json index d6088ef..8f6e2a8 100644 --- a/custom_components/view_assist/manifest.json +++ b/custom_components/view_assist/manifest.json @@ -3,7 +3,7 @@ "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", From 8650fb178bbd031777a7eb39ccf71852afcb7a7e Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Wed, 21 May 2025 17:52:54 +0100 Subject: [PATCH 67/92] refactor: remove use of os.path --- custom_components/view_assist/js_modules/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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: From 5c93aaf4207acdb816e02fcb5a160c1e04536738 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Wed, 21 May 2025 17:53:32 +0100 Subject: [PATCH 68/92] refactor: move service handling into functional class --- .../view_assist/alarm_repeater.py | 64 ++++- custom_components/view_assist/services.py | 238 +----------------- custom_components/view_assist/timers.py | 178 ++++++++++++- 3 files changed, 239 insertions(+), 241 deletions(-) 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/services.py b/custom_components/view_assist/services.py index 2c24665..b085480 100644 --- a/custom_components/view_assist/services.py +++ b/custom_components/view_assist/services.py @@ -1,19 +1,13 @@ """Integration services.""" from asyncio import TimerHandle -import json import logging from typing import Any import voluptuous as vol -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_NAME, ATTR_TIME -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse from homeassistant.helpers import ( config_validation as cv, entity_registry as er, @@ -26,19 +20,12 @@ ATTR_DEVICE, ATTR_EVENT_DATA, ATTR_EVENT_NAME, - ATTR_EXTRA, - ATTR_INCLUDE_EXPIRED, ATTR_MAX_REPEATS, ATTR_MEDIA_FILE, ATTR_PATH, - ATTR_REMOVE_ALL, ATTR_RESUME_MEDIA, - ATTR_TIMER_ID, - ATTR_TYPE, DOMAIN, ) -from .helpers import get_mimic_entity_id -from .timers import TIMERS, VATimers, decode_time_sentence from .typed import VAConfigEntry _LOGGER = logging.getLogger(__name__) @@ -53,62 +40,6 @@ } ) -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), - } -) - - -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, - } -) - -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, - } -) - -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) - ), - } -) BROADCAST_EVENT_SERVICE_SCHEMA = vol.Schema( { @@ -162,52 +93,6 @@ async def async_setup_services(self): schema=NAVIGATE_SERVICE_SCHEMA, ) - 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, - ) - - 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, - ) - self.hass.services.async_register( DOMAIN, "broadcast_event", @@ -257,25 +142,6 @@ async def async_handle_broadcast_event(self, call: ServiceCall): # Fire the event self.hass.bus.fire(event_name, event_data) - 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) - - alarms: VAAlarmRepeater = self.hass.data[DOMAIN][ALARMS] - return await alarms.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) - - alarms: VAAlarmRepeater = self.hass.data[DOMAIN][ALARMS] - await alarms.cancel_alarm_sound(entity_id) - # ----------------------------------------------------------------------- # Handle Navigation # Used to determine how to change the view on the VA device @@ -304,99 +170,6 @@ async def async_handle_navigate(self, call: ServiceCall): {"path": path}, ) - # ---------------------------------------------------------------- - # TIMERS - # ---------------------------------------------------------------- - 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) - - sentence, timer_info = decode_time_sentence(timer_time) - _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: - t: VATimers = self.hass.data[DOMAIN][TIMERS] - timer_id, timer, response = await t.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, - ) - - 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) - - _, timer_info = decode_time_sentence(timer_time) - - if timer_info: - t: VATimers = self.hass.data[DOMAIN][TIMERS] - timer_id, timer, response = await t.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]): - t: VATimers = self.hass.data[DOMAIN][TIMERS] - result = await t.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) - - t: VATimers = self.hass.data[DOMAIN][TIMERS] - result = t.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} - # ---------------------------------------------------------------- # MENU # ---------------------------------------------------------------- @@ -417,8 +190,7 @@ async def async_handle_add_status_item(self, call: ServiceCall): """Handle add status item service call.""" entity_id = call.data.get(ATTR_ENTITY_ID) if not entity_id: - _LOGGER.error( - "No entity_id provided in add_status_item service call") + _LOGGER.error("No entity_id provided in add_status_item service call") return raw_status_item = call.data.get("status_item") @@ -437,8 +209,7 @@ async def async_handle_remove_status_item(self, call: ServiceCall): """Handle remove status item service call.""" entity_id = call.data.get(ATTR_ENTITY_ID) if not entity_id: - _LOGGER.error( - "No entity_id provided in remove_status_item service call") + _LOGGER.error("No entity_id provided in remove_status_item service call") return raw_status_item = call.data.get("status_item") @@ -457,4 +228,3 @@ def _process_status_item_input(self, raw_input: Any) -> str | list[str] | None: from .helpers import normalize_status_items return normalize_status_items(raw_input) - diff --git a/custom_components/view_assist/timers.py b/custom_components/view_assist/timers.py index 2dd605a..56374ce 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -18,15 +18,68 @@ import wordtodigits 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 _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), + } +) + + +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, + } +) + +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_{}" @@ -622,6 +675,125 @@ 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) + + sentence, timer_info = decode_time_sentence(timer_time) + _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, + ) + + 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) + + _, timer_info = decode_time_sentence(timer_time) + + 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() From 52556196fc414301a9e222cb0641a5724546d17c Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Wed, 21 May 2025 17:53:49 +0100 Subject: [PATCH 69/92] refactor: simplify code --- custom_components/view_assist/entity_listeners.py | 8 ++++++-- custom_components/view_assist/helpers.py | 6 ------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/custom_components/view_assist/entity_listeners.py b/custom_components/view_assist/entity_listeners.py index 0ff44c1..3515f59 100644 --- a/custom_components/view_assist/entity_listeners.py +++ b/custom_components/view_assist/entity_listeners.py @@ -53,7 +53,6 @@ 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 @@ -399,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')}" diff --git a/custom_components/view_assist/helpers.py b/custom_components/view_assist/helpers.py index 196d273..44804ff 100644 --- a/custom_components/view_assist/helpers.py +++ b/custom_components/view_assist/helpers.py @@ -570,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 = {} From 285e7112db877a4217d0dde855db0e53e1ed7512 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Fri, 23 May 2025 23:47:30 +0100 Subject: [PATCH 70/92] fix: error onboarding version management --- custom_components/view_assist/assets/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/view_assist/assets/__init__.py b/custom_components/view_assist/assets/__init__.py index 33ad159..01dd29f 100644 --- a/custom_components/view_assist/assets/__init__.py +++ b/custom_components/view_assist/assets/__init__.py @@ -87,7 +87,7 @@ async def _save(self): # Order dict for reading data = self.data.copy() last_updated = data.pop("last_updated") - last_commit = data.pop("last_commit") + last_commit = data.pop("last_commit", "0") data = { "last_updated": last_updated, "last_commit": last_commit, From 1d88f7531537f7ad6566b1fd72d9336465699a54 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Sat, 24 May 2025 02:01:34 +0100 Subject: [PATCH 71/92] fix: correct import path --- custom_components/view_assist/update.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/view_assist/update.py b/custom_components/view_assist/update.py index 156df7b..bb14e4a 100644 --- a/custom_components/view_assist/update.py +++ b/custom_components/view_assist/update.py @@ -7,18 +7,18 @@ from awesomeversion import AwesomeVersion -from config.custom_components.view_assist.assets import ( - ASSETS_MANAGER, - VA_ADD_UPDATE_ENTITY_EVENT, - AssetClass, - AssetsManager, -) 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, From b63386ba008823849dc4e50dbcfbc12d3ab642b6 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Sat, 24 May 2025 02:01:47 +0100 Subject: [PATCH 72/92] refactor: remove unused import --- custom_components/view_assist/assets/download_manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/view_assist/assets/download_manager.py b/custom_components/view_assist/assets/download_manager.py index bad2edd..1d558a3 100644 --- a/custom_components/view_assist/assets/download_manager.py +++ b/custom_components/view_assist/assets/download_manager.py @@ -10,7 +10,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import dt as dt_util from ..const import DOMAIN, GITHUB_BRANCH, GITHUB_REPO # noqa: TID252 From 9428e252a37e91597877778d94b389c08fab26b0 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Sat, 24 May 2025 02:02:06 +0100 Subject: [PATCH 73/92] fix: correct last_commit key on onboard --- custom_components/view_assist/assets/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/view_assist/assets/__init__.py b/custom_components/view_assist/assets/__init__.py index 33ad159..0596cf2 100644 --- a/custom_components/view_assist/assets/__init__.py +++ b/custom_components/view_assist/assets/__init__.py @@ -87,7 +87,10 @@ async def _save(self): # Order dict for reading data = self.data.copy() last_updated = data.pop("last_updated") - last_commit = data.pop("last_commit") + if data.get("last_commit"): + last_commit = data.pop("last_commit") + else: + last_commit = {} data = { "last_updated": last_updated, "last_commit": last_commit, From 6863d08d08aa4270a5c6e0b4fdf808a5d93cd9c9 Mon Sep 17 00:00:00 2001 From: Donny F Date: Mon, 26 May 2025 09:53:37 -0500 Subject: [PATCH 74/92] Add volume ducking definition --- custom_components/view_assist/translations/en.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/view_assist/translations/en.json b/custom_components/view_assist/translations/en.json index 96111a5..a7f3835 100644 --- a/custom_components/view_assist/translations/en.json +++ b/custom_components/view_assist/translations/en.json @@ -147,7 +147,8 @@ "view_timeout": "View Timeout", "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": "Lower media playback volume to this level when Assist is active" }, "data_description": { "mode": "The default mode for this satellite device", From 0c40408d95d054b67fd81138f0de7aad13a922f8 Mon Sep 17 00:00:00 2001 From: Donny F Date: Mon, 26 May 2025 09:56:55 -0500 Subject: [PATCH 75/92] Additional definition for volume ducking --- custom_components/view_assist/translations/en.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/view_assist/translations/en.json b/custom_components/view_assist/translations/en.json index a7f3835..0d3ae40 100644 --- a/custom_components/view_assist/translations/en.json +++ b/custom_components/view_assist/translations/en.json @@ -148,14 +148,15 @@ "do_not_disturb": "Enable do not disturb at startup", "use_announce": "Disable announce on this device", "micunmute": "Unmute microphone on HA start/restart", - "ducking_volume": "Lower media playback volume to this level when Assist is active" + "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": { From 28405da9efe1e2b1ef9b98c6fca4634a8139adcc Mon Sep 17 00:00:00 2001 From: Donny F Date: Mon, 26 May 2025 10:32:00 -0500 Subject: [PATCH 76/92] Change default ducking value Default of '2' was like having it totally muted. I think this may be due to the units but not sure. Range of 1-10 the two value would be fine but range of 1-100 it is way too restrictive. --- custom_components/view_assist/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/view_assist/const.py b/custom_components/view_assist/const.py index a60fcf2..1beebd5 100644 --- a/custom_components/view_assist/const.py +++ b/custom_components/view_assist/const.py @@ -154,7 +154,7 @@ class VAMode(StrEnum): CONF_DO_NOT_DISTURB: "off", CONF_USE_ANNOUNCE: "off", CONF_MIC_UNMUTE: "off", - CONF_DUCKING_VOLUME: 2, + CONF_DUCKING_VOLUME: 35, # Default integration options CONF_ENABLE_UPDATES: True, # Default developer otions From 508a1450086fad820a06685f6ba746da461e9b56 Mon Sep 17 00:00:00 2001 From: Donny F Date: Mon, 26 May 2025 15:18:36 -0500 Subject: [PATCH 77/92] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0e40ba2..b214974 100644 --- a/README.md +++ b/README.md @@ -44,4 +44,4 @@ Questions, problems, concerns? Reach out to us on Discord or use the 'Issues' a # 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! From a58c38d959350f8e631f7af17a5e425e3220df2c Mon Sep 17 00:00:00 2001 From: Donny F Date: Mon, 26 May 2025 15:44:47 -0500 Subject: [PATCH 78/92] Update README.md --- README.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/README.md b/README.md index b214974..8dcfd4d 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' From f3da8974c315997f6d04ad02c7fc749e4f75ad69 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Tue, 27 May 2025 15:51:30 +0100 Subject: [PATCH 79/92] improved blueprint install handling - issue #134 --- .../view_assist/assets/blueprints.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/custom_components/view_assist/assets/blueprints.py b/custom_components/view_assist/assets/blueprints.py index 45465eb..89a4bfa 100644 --- a/custom_components/view_assist/assets/blueprints.py +++ b/custom_components/view_assist/assets/blueprints.py @@ -1,5 +1,6 @@ """Blueprint manager for View Assist.""" +import asyncio import logging from pathlib import Path import re @@ -42,6 +43,22 @@ async def async_onboard(self) -> None: # Load all blueprints self.onboarding = True bp_versions = {} + + # Ensure the blueprint automations domain has been loaded + # issue 134 + try: + async with asyncio.timeout(10): + while not self.hass.data["blueprint"].get("automation"): + _LOGGER.debug( + "Blueprint automations domain 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: @@ -174,7 +191,9 @@ async def async_install_or_update( bp.blueprint.domain ) if domain_blueprints is None: - raise ValueError("Invalid blueprint domain") + raise AssetManagerException( + f"Invalid blueprint domain for {name}: {bp.blueprint.domain}" + ) path = bp.suggested_filename if not path.endswith(".yaml"): @@ -271,13 +290,13 @@ def _read_blueprint_version(self, blueprint_config: dict[str, Any]) -> str: 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" + 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://github.com/{GITHUB_REPO}/blob/{GITHUB_BRANCH}{path}" + 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( From 8a912cbfd1cb39afb9adb063fa03753ef3f6c5fe Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Tue, 27 May 2025 15:51:52 +0100 Subject: [PATCH 80/92] fix: ensure dashboard file exists locally during onboarding --- custom_components/view_assist/assets/dashboard.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/custom_components/view_assist/assets/dashboard.py b/custom_components/view_assist/assets/dashboard.py index b42b5be..d64a7d9 100644 --- a/custom_components/view_assist/assets/dashboard.py +++ b/custom_components/view_assist/assets/dashboard.py @@ -57,7 +57,14 @@ 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): + # Download latest dashboard file and create user-dashboard diff file + await self._download_dashboard() + 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) From 6a824de5c59fc6974348d19bd2db6eb4770ecca2 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Tue, 27 May 2025 15:52:13 +0100 Subject: [PATCH 81/92] fix: ensure view file exists locally during onboarding --- custom_components/view_assist/assets/views.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/custom_components/view_assist/assets/views.py b/custom_components/view_assist/assets/views.py index f6fc8f3..ef8f9c1 100644 --- a/custom_components/view_assist/assets/views.py +++ b/custom_components/view_assist/assets/views.py @@ -7,7 +7,6 @@ from homeassistant.components.lovelace import LovelaceData, dashboard from homeassistant.const import EVENT_PANELS_UPDATED from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt as dt_util from homeassistant.util.yaml import load_yaml_dict, parse_yaml, save_yaml from ..const import ( # noqa: TID252 @@ -36,6 +35,9 @@ async def async_onboard(self) -> dict[str, Any] | None: 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( @@ -351,6 +353,7 @@ 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.""" @@ -361,6 +364,9 @@ async def _download_view( 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 From a887abd9147e6bd3efe415911965df1ce4107f1a Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Tue, 27 May 2025 16:23:17 +0100 Subject: [PATCH 82/92] fix: do not update dashboard file if exists --- custom_components/view_assist/assets/dashboard.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/custom_components/view_assist/assets/dashboard.py b/custom_components/view_assist/assets/dashboard.py index d64a7d9..0b1f369 100644 --- a/custom_components/view_assist/assets/dashboard.py +++ b/custom_components/view_assist/assets/dashboard.py @@ -59,8 +59,10 @@ async def async_onboard(self) -> dict[str, Any] | None: db_version = {} if self.is_installed(name): - # Download latest dashboard file and create user-dashboard diff file - await self._download_dashboard() + # 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}) ) @@ -283,11 +285,14 @@ def _read_dashboard_version(self, dashboard_config: dict[str, Any]) -> str: _LOGGER.debug("Dashboard version not found") return "0.0.0" - async def _download_dashboard(self) -> bool: + 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): From c10dcbca19ba961126559b86ae51d69f3cecf0ee Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Tue, 27 May 2025 17:10:59 +0100 Subject: [PATCH 83/92] fix: sometimes blueprint domain not loaded --- custom_components/view_assist/assets/blueprints.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/view_assist/assets/blueprints.py b/custom_components/view_assist/assets/blueprints.py index 89a4bfa..2901bac 100644 --- a/custom_components/view_assist/assets/blueprints.py +++ b/custom_components/view_assist/assets/blueprints.py @@ -47,10 +47,10 @@ async def async_onboard(self) -> None: # Ensure the blueprint automations domain has been loaded # issue 134 try: - async with asyncio.timeout(10): - while not self.hass.data["blueprint"].get("automation"): + async with asyncio.timeout(30): + while not self.hass.data.get("blueprint", {}).get("automation"): _LOGGER.debug( - "Blueprint automations domain loaded yet - waiting" + "Blueprint automations domain not loaded yet - waiting" ) await asyncio.sleep(1) except TimeoutError: From 2dbc8b9e5d24be3bc4cdccf67ab8b17743d1f50b Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Tue, 27 May 2025 18:12:41 +0100 Subject: [PATCH 84/92] fix: dashboard not updating from repo --- .../view_assist/assets/dashboard.py | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/custom_components/view_assist/assets/dashboard.py b/custom_components/view_assist/assets/dashboard.py index 0b1f369..bdb8ac3 100644 --- a/custom_components/view_assist/assets/dashboard.py +++ b/custom_components/view_assist/assets/dashboard.py @@ -43,7 +43,7 @@ def __init__( ) -> None: """Initialise.""" super().__init__(hass, config, data) - self.build_mode = False + self.ignore_change_events = False async def async_setup(self) -> None: """Set up the AssetManager.""" @@ -83,6 +83,7 @@ async def async_onboard(self) -> dict[str, Any] | None: 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, @@ -94,6 +95,7 @@ async def async_onboard(self) -> dict[str, Any] | None: "installed": result.version, "latest": result.latest_version, } + self.ignore_change_events = False self.onboarding = False return status @@ -174,6 +176,9 @@ async def async_install_or_update( 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) @@ -199,16 +204,18 @@ async def async_install_or_update( f"{DASHBOARD_DIR}/{DASHBOARD_DIR}.yaml", ) - if dashboard_config := await self.hass.async_add_executor_job( + 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( - dashboard_config + new_dashboard_config ) self._update_install_progress("dashboard", 80) - installed_version = self._read_dashboard_version(dashboard_config) + installed_version = self._read_dashboard_version( + new_dashboard_config + ) success = True else: raise AssetManagerException( @@ -220,7 +227,7 @@ async def async_install_or_update( ) else: _LOGGER.debug("Updating dashboard") - if dashboard_config := await self.hass.async_add_executor_job( + if new_dashboard_config := await self.hass.async_add_executor_job( load_yaml_dict, dashboard_file_path ): lovelace: LovelaceData = self.hass.data["lovelace"] @@ -229,18 +236,20 @@ async def async_install_or_update( ) # Load dashboard config data if dashboard_store: - dashboard_config = await dashboard_store.async_load(False) + old_dashboard_config = await dashboard_store.async_load(False) # Copy views to updated dashboard - dashboard_config["views"] = dashboard_config.get("views") + new_dashboard_config["views"] = old_dashboard_config.get("views") # Apply - await dashboard_store.async_save(dashboard_config) + 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(dashboard_config) + installed_version = self._read_dashboard_version( + new_dashboard_config + ) success = True else: raise AssetManagerException("Error getting dashboard store") @@ -250,6 +259,7 @@ async def async_install_or_update( ) self._update_install_progress("dashboard", 100) + self.ignore_change_events = False _LOGGER.debug( "Dashboard successfully installed - version %s", installed_version, @@ -302,7 +312,7 @@ async def _download_dashboard(self, cancel_if_exists: bool = False) -> bool: async def _dashboard_changed(self, event: Event): # If in dashboard build mode, ignore changes - if self.build_mode: + if self.ignore_change_events: return if event.data["url_path"] == self._dashboard_key: From 83829c624f653fd2387d87fe4f9dd44769e04436 Mon Sep 17 00:00:00 2001 From: Mark Parker Date: Tue, 27 May 2025 18:37:56 +0100 Subject: [PATCH 85/92] fix: error loading audio only device --- custom_components/view_assist/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/custom_components/view_assist/__init__.py b/custom_components/view_assist/__init__.py index 788f691..327df3f 100644 --- a/custom_components/view_assist/__init__.py +++ b/custom_components/view_assist/__init__.py @@ -236,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.core.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 From fbb77e6c6651c15eadaa47ed7faf85cf29771835 Mon Sep 17 00:00:00 2001 From: Donny F Date: Tue, 27 May 2025 14:47:42 -0500 Subject: [PATCH 86/92] Update manifest.json --- custom_components/view_assist/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/view_assist/manifest.json b/custom_components/view_assist/manifest.json index 8f6e2a8..379449e 100644 --- a/custom_components/view_assist/manifest.json +++ b/custom_components/view_assist/manifest.json @@ -9,5 +9,5 @@ "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" } From a7d1a1fb3dc001809f5f24be04a74651ff4950ad Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Sun, 27 Apr 2025 11:02:37 +0200 Subject: [PATCH 87/92] fix: added requirements files and basic tests --- .gitignore | 4 +- README.md | 4 ++ custom_components/requirements.txt | 4 ++ custom_components/test_requirements.txt | 1 + .../view_assist/tests/__init__.py | 0 .../view_assist/tests/test_timers.py | 41 +++++++++++++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 custom_components/requirements.txt create mode 100644 custom_components/test_requirements.txt create mode 100644 custom_components/view_assist/tests/__init__.py create mode 100644 custom_components/view_assist/tests/test_timers.py 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 8dcfd4d..84df65e 100644 --- a/README.md +++ b/README.md @@ -33,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? 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..1d620c0 --- /dev/null +++ b/custom_components/test_requirements.txt @@ -0,0 +1 @@ +pytest~=8.3.5 \ No newline at end of file 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_timers.py b/custom_components/view_assist/tests/test_timers.py new file mode 100644 index 0000000..60976c5 --- /dev/null +++ b/custom_components/view_assist/tests/test_timers.py @@ -0,0 +1,41 @@ +import pytest +from custom_components.view_assist.timers import decode_time_sentence, TimerTime, TimerInterval + +@pytest.mark.parametrize( + "input_sentence,expected_output", + [ + # Test intervals + ("5 minutes", TimerInterval(minutes=5)), + ("2 hours", TimerInterval(hours=2)), + ("1 day 3 hours", TimerInterval(days=1, hours=3)), + ("30 seconds", TimerInterval(seconds=30)), + ("2 days 1 hour 20 minutes", TimerInterval(days=2, hours=1, minutes=20)), + + # Test specific times + ("10:30 AM", TimerTime(hour=10, minute=30, meridiem="am")), + ("quarter past 3", TimerTime(hour=3, minute=15)), + ("half past 12", TimerTime(hour=12, minute=30)), + ("20 to 4 PM", TimerTime(hour=3, minute=40, meridiem="pm")), + ("Monday at 10:00 AM", TimerTime(day="monday", hour=10, minute=0, meridiem="am")), + + # Test special cases + ("midnight", TimerTime(hour=0, minute=0, meridiem="am")), + ("noon", TimerTime(hour=12, minute=0, meridiem="pm")), + ], +) +def test_decode_time_sentence(input_sentence, expected_output): + _, result = decode_time_sentence(input_sentence) + assert result == expected_output + + +def test_decode_time_sentence_invalid(): + # Test invalid inputs + invalid_inputs = [ + "random text", + "12345", + "", + "unknown time format", + ] + for sentence in invalid_inputs: + _, result = decode_time_sentence(sentence) + assert result is None \ No newline at end of file From 365e85049af7f81e5e0ee6ef59c17c930288d088 Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Sun, 27 Apr 2025 13:31:16 +0200 Subject: [PATCH 88/92] feat: added support for short form interval --- .../view_assist/tests/test_timers.py | 15 ++++++++++++++ custom_components/view_assist/timers.py | 20 +++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/custom_components/view_assist/tests/test_timers.py b/custom_components/view_assist/tests/test_timers.py index 60976c5..94b60da 100644 --- a/custom_components/view_assist/tests/test_timers.py +++ b/custom_components/view_assist/tests/test_timers.py @@ -11,6 +11,13 @@ ("30 seconds", TimerInterval(seconds=30)), ("2 days 1 hour 20 minutes", TimerInterval(days=2, hours=1, minutes=20)), + # Test shorthand intervals + ("5m", TimerInterval(minutes=5)), + ("2h", TimerInterval(hours=2)), + ("1d 3h", TimerInterval(days=1, hours=3)), + ("30s", TimerInterval(seconds=30)), + ("2d 1h 20m", TimerInterval(days=2, hours=1, minutes=20)), + # Test specific times ("10:30 AM", TimerTime(hour=10, minute=30, meridiem="am")), ("quarter past 3", TimerTime(hour=3, minute=15)), @@ -21,6 +28,14 @@ # Test special cases ("midnight", TimerTime(hour=0, minute=0, meridiem="am")), ("noon", TimerTime(hour=12, minute=0, meridiem="pm")), + + # Additional examples from regex comments + ("at 10:30 AM", TimerTime(hour=10, minute=30, meridiem="am")), + ("at quarter past 3", TimerTime(hour=3, minute=15)), + ("at half past 12", TimerTime(hour=12, minute=30)), + ("at 20 to 4 PM", TimerTime(hour=3, minute=40, meridiem="pm")), + ("at midnight", TimerTime(hour=0, minute=0, meridiem="am")), + ("at noon", TimerTime(hour=12, minute=0, meridiem="pm")), ], ) def test_decode_time_sentence(input_sentence, expected_output): diff --git a/custom_components/view_assist/timers.py b/custom_components/view_assist/timers.py index 56374ce..687f8ad 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -257,14 +257,22 @@ class Timer: # 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" # 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" + r"(?i)\b" + r"(?:(?P\d+)\s*(?:d|days?))?\s*" + r"(?:(?P\d+)\s*(?:h|hours?))?\s*" + r"(?:(?P\d+)\s*(?:m|minutes?))?\s*" + r"(?:(?P\d+)\s*(?:s|seconds?))?" + r"\b" ) +INTERVAL_DETECTION_REGEX = r"(?i)\b\d+\s*(d|day|days|h|hour|hours|m|minute|minutes|s|second|seconds)\b" + # All natural language intervals # 2 1/2 hours @@ -317,7 +325,7 @@ class Timer: def _is_interval(sentence) -> bool: - return re.search(r"\bdays?|hours?|minutes?|seconds?", sentence) is not None + return re.search(INTERVAL_DETECTION_REGEX, sentence) is not None def _is_super(sentence: str, is_interval: bool) -> bool: From 4908e7985e2a05ac5782f97295fb5e4efffd85b3 Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Sun, 27 Apr 2025 16:02:48 +0200 Subject: [PATCH 89/92] chor: rebase onto to main --- custom_components/test_requirements.txt | 3 +- custom_components/view_assist/docs/timers.md | 15 + custom_components/view_assist/services.yaml | 10 + .../view_assist/tests/test_timer_creation.py | 71 +++++ .../view_assist/tests/test_timers.py | 56 ---- .../tests/test_timers_all_languages.py | 121 ++++++++ .../view_assist/tests/test_timers_en.py | 66 +++++ custom_components/view_assist/timers.py | 279 +++++------------- .../view_assist/translations/__init__.py | 0 .../translations/timers/__init__.py | 0 .../translations/timers/timers_english.py | 219 ++++++++++++++ 11 files changed, 585 insertions(+), 255 deletions(-) create mode 100644 custom_components/view_assist/tests/test_timer_creation.py delete mode 100644 custom_components/view_assist/tests/test_timers.py create mode 100644 custom_components/view_assist/tests/test_timers_all_languages.py create mode 100644 custom_components/view_assist/tests/test_timers_en.py create mode 100644 custom_components/view_assist/translations/__init__.py create mode 100644 custom_components/view_assist/translations/timers/__init__.py create mode 100644 custom_components/view_assist/translations/timers/timers_english.py diff --git a/custom_components/test_requirements.txt b/custom_components/test_requirements.txt index 1d620c0..e360736 100644 --- a/custom_components/test_requirements.txt +++ b/custom_components/test_requirements.txt @@ -1 +1,2 @@ -pytest~=8.3.5 \ No newline at end of file +pytest~=8.3.5 +pytest-asyncio~=0.26.0 \ No newline at end of file diff --git a/custom_components/view_assist/docs/timers.md b/custom_components/view_assist/docs/timers.md index a6f6c15..4277eb7 100644 --- a/custom_components/view_assist/docs/timers.md +++ b/custom_components/view_assist/docs/timers.md @@ -219,6 +219,21 @@ or where [action] is one of started, cancelled, warning, expired, snoozed +## Translation Instructions: +1. Copy the file [timers_english.py](../translations/timers/timers_english.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. +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/services.yaml b/custom_components/view_assist/services.yaml index 9067da1..356b9f1 100644 --- a/custom_components/view_assist/services.yaml +++ b/custom_components/view_assist/services.yaml @@ -76,6 +76,16 @@ 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 cancel_timer: name: "Cancel timer" description: "Cancel running timer" 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.py b/custom_components/view_assist/tests/test_timers.py deleted file mode 100644 index 94b60da..0000000 --- a/custom_components/view_assist/tests/test_timers.py +++ /dev/null @@ -1,56 +0,0 @@ -import pytest -from custom_components.view_assist.timers import decode_time_sentence, TimerTime, TimerInterval - -@pytest.mark.parametrize( - "input_sentence,expected_output", - [ - # Test intervals - ("5 minutes", TimerInterval(minutes=5)), - ("2 hours", TimerInterval(hours=2)), - ("1 day 3 hours", TimerInterval(days=1, hours=3)), - ("30 seconds", TimerInterval(seconds=30)), - ("2 days 1 hour 20 minutes", TimerInterval(days=2, hours=1, minutes=20)), - - # Test shorthand intervals - ("5m", TimerInterval(minutes=5)), - ("2h", TimerInterval(hours=2)), - ("1d 3h", TimerInterval(days=1, hours=3)), - ("30s", TimerInterval(seconds=30)), - ("2d 1h 20m", TimerInterval(days=2, hours=1, minutes=20)), - - # Test specific times - ("10:30 AM", TimerTime(hour=10, minute=30, meridiem="am")), - ("quarter past 3", TimerTime(hour=3, minute=15)), - ("half past 12", TimerTime(hour=12, minute=30)), - ("20 to 4 PM", TimerTime(hour=3, minute=40, meridiem="pm")), - ("Monday at 10:00 AM", TimerTime(day="monday", hour=10, minute=0, meridiem="am")), - - # Test special cases - ("midnight", TimerTime(hour=0, minute=0, meridiem="am")), - ("noon", TimerTime(hour=12, minute=0, meridiem="pm")), - - # Additional examples from regex comments - ("at 10:30 AM", TimerTime(hour=10, minute=30, meridiem="am")), - ("at quarter past 3", TimerTime(hour=3, minute=15)), - ("at half past 12", TimerTime(hour=12, minute=30)), - ("at 20 to 4 PM", TimerTime(hour=3, minute=40, meridiem="pm")), - ("at midnight", TimerTime(hour=0, minute=0, meridiem="am")), - ("at noon", TimerTime(hour=12, minute=0, meridiem="pm")), - ], -) -def test_decode_time_sentence(input_sentence, expected_output): - _, result = decode_time_sentence(input_sentence) - assert result == expected_output - - -def test_decode_time_sentence_invalid(): - # Test invalid inputs - invalid_inputs = [ - "random text", - "12345", - "", - "unknown time format", - ] - for sentence in invalid_inputs: - _, result = decode_time_sentence(sentence) - assert result is None \ No newline at end of file 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..b65f254 --- /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_english # Add more languages as needed + +# Map languages to their corresponding modules +LANGUAGE_MODULES = { + TimerLanguage.EN: timers_english, + # TimerLanguage.ES: timers_spanish, # Add more languages here +} + +# 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_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 687f8ad..50687c1 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -40,6 +40,8 @@ ) from .helpers import get_entity_id_from_conversation_device_id, get_mimic_entity_id +from .translations.timers import timers_english + _LOGGER = logging.getLogger(__name__) SET_TIMER_SERVICE_SCHEMA = vol.Schema( @@ -86,44 +88,45 @@ 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_english.WEEKDAYS # Add more languages here } - SPECIAL_HOURS = { - "midnight": 0, - "noon": 12, + "en": timers_english.SPECIAL_HOURS # Add more languages here } + HOUR_FRACTIONS = { - "1/4": 15, - "quarter": 15, - "1/2": 30, - "half": 30, - "3/4": 45, - "three quarters": 45, + "en": timers_english.HOUR_FRACTIONS # Add more languages here } -AMPM = ["am", "pm"] + SPECIAL_AMPM = { - "morning": "am", - "tonight": "pm", - "afternoon": "pm", - "evening": "pm", + "en": timers_english.SPECIAL_AMPM # Add more languages here } DIRECT_REPLACE = { - "a day": "1 day", - "an hour": "1 hour", + "en": timers_english.DIRECT_REPLACE # Add more languages here +} + +REFERENCES = { + "en": timers_english.REFERENCES # Add more languages here +} + +SINGULARS = { + "en": timers_english.SINGULARS # Add more languages here +} + +REGEXES = { + "en": timers_english.REGEXES # Add more languages here +} + +REGEX_DAYS = { + "en": timers_english.REGEX_DAYS # Add more languages here +} + +INTERVAL_DETECTION_REGEX = { + "en": timers_english.INTERVAL_DETECTION_REGEX # Add more languages here } @@ -175,6 +178,11 @@ class TimerEvent(StrEnum): SNOOZED = "snoozed" CANCELLED = "cancelled" +class TimerLanguage(StrEnum): + """Language enums.""" + + EN = "en" + @dataclass class Timer: @@ -191,149 +199,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 -# 5m -# 2h -# 1d 3h -# 30s -# 2d 1h 20m -REGEX_INTERVAL = ( - r"(?i)\b" - r"(?:(?P\d+)\s*(?:d|days?))?\s*" - r"(?:(?P\d+)\s*(?:h|hours?))?\s*" - r"(?:(?P\d+)\s*(?:m|minutes?))?\s*" - r"(?:(?P\d+)\s*(?:s|seconds?))?" - r"\b" -) - -INTERVAL_DETECTION_REGEX = r"(?i)\b\d+\s*(d|day|days|h|hour|hours|m|minute|minutes|s|second|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) -> bool: - return re.search(INTERVAL_DETECTION_REGEX, sentence) is not None +def _is_interval(sentence, language: TimerLanguage) -> bool: + return re.search(INTERVAL_DETECTION_REGEX[language], 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 @@ -361,14 +239,14 @@ 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") @@ -377,11 +255,11 @@ def decode_time_sentence(sentence: str): 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) @@ -393,22 +271,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] with contextlib.suppress(KeyError): - decoded[i] = HOUR_FRACTIONS[v] + decoded[i] = HOUR_FRACTIONS[language][v] with contextlib.suppress(KeyError): - decoded[i] = SPECIAL_AMPM[v] + decoded[i] = SPECIAL_AMPM[language][v] # Make time objects if is_interval: @@ -447,30 +325,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 @@ -489,7 +367,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 @@ -508,7 +386,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: @@ -516,7 +394,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") @@ -536,6 +414,7 @@ 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.""" @@ -556,19 +435,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 @@ -576,9 +455,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 @@ -871,6 +750,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.""" @@ -884,7 +764,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: @@ -909,6 +789,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 @@ -917,7 +798,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" @@ -991,7 +872,7 @@ 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 None, None, "unable to snooze" @@ -1102,11 +983,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) @@ -1125,14 +1007,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/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_english.py b/custom_components/view_assist/translations/timers/timers_english.py new file mode 100644 index 0000000..2a10465 --- /dev/null +++ b/custom_components/view_assist/translations/timers/timers_english.py @@ -0,0 +1,219 @@ +##################### +# 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", +} +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+|" + + "|".join(list(HOUR_FRACTIONS)) + + r")\s(to|past)\s(\d+|" # Wording or Word Sequence needs translating + + ("|".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 + + r")?[ ]?(?: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, +} \ No newline at end of file From 82e0edb53f2d6473fb341fd6772d11471ccc3d41 Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Sun, 27 Apr 2025 17:04:57 +0200 Subject: [PATCH 90/92] fix: tested on HA and moved translation file to naming convention with language code --- custom_components/view_assist/docs/timers.md | 5 ++-- custom_components/view_assist/services.yaml | 16 +++++++++--- .../tests/test_timers_all_languages.py | 2 +- custom_components/view_assist/timers.py | 26 +++++++++---------- .../{timers_english.py => timers_en.py} | 0 5 files changed, 29 insertions(+), 20 deletions(-) rename custom_components/view_assist/translations/timers/{timers_english.py => timers_en.py} (100%) diff --git a/custom_components/view_assist/docs/timers.md b/custom_components/view_assist/docs/timers.md index 4277eb7..b7bfae9 100644 --- a/custom_components/view_assist/docs/timers.md +++ b/custom_components/view_assist/docs/timers.md @@ -220,7 +220,7 @@ or where [action] is one of started, cancelled, warning, expired, snoozed ## Translation Instructions: -1. Copy the file [timers_english.py](../translations/timers/timers_english.py) and rename it to the language you want to translate to. +1. Copy the file [timers_english.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. @@ -231,7 +231,8 @@ where [action] is one of started, cancelled, warning, expired, snoozed - 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. +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 diff --git a/custom_components/view_assist/services.yaml b/custom_components/view_assist/services.yaml index 356b9f1..e025e3f 100644 --- a/custom_components/view_assist/services.yaml +++ b/custom_components/view_assist/services.yaml @@ -160,12 +160,20 @@ 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 sound_alarm: name: "Sound alarm" description: "Sound alarm on a media device with an attempt to restore any already playing media" diff --git a/custom_components/view_assist/tests/test_timers_all_languages.py b/custom_components/view_assist/tests/test_timers_all_languages.py index b65f254..fa3aa51 100644 --- a/custom_components/view_assist/tests/test_timers_all_languages.py +++ b/custom_components/view_assist/tests/test_timers_all_languages.py @@ -2,7 +2,7 @@ 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_english # Add more languages as needed +from custom_components.view_assist.translations.timers import timers_en # Add more languages as needed # Map languages to their corresponding modules LANGUAGE_MODULES = { diff --git a/custom_components/view_assist/timers.py b/custom_components/view_assist/timers.py index 50687c1..7a6c62c 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -40,7 +40,7 @@ ) from .helpers import get_entity_id_from_conversation_device_id, get_mimic_entity_id -from .translations.timers import timers_english +from .translations.timers import timers_en _LOGGER = logging.getLogger(__name__) @@ -90,43 +90,43 @@ # Translation Imports WEEKDAYS= { - "en": timers_english.WEEKDAYS # Add more languages here + "en": timers_en.WEEKDAYS # Add more languages here } SPECIAL_HOURS = { - "en": timers_english.SPECIAL_HOURS # Add more languages here + "en": timers_en.SPECIAL_HOURS # Add more languages here } HOUR_FRACTIONS = { - "en": timers_english.HOUR_FRACTIONS # Add more languages here + "en": timers_en.HOUR_FRACTIONS # Add more languages here } SPECIAL_AMPM = { - "en": timers_english.SPECIAL_AMPM # Add more languages here + "en": timers_en.SPECIAL_AMPM # Add more languages here } DIRECT_REPLACE = { - "en": timers_english.DIRECT_REPLACE # Add more languages here + "en": timers_en.DIRECT_REPLACE # Add more languages here } REFERENCES = { - "en": timers_english.REFERENCES # Add more languages here + "en": timers_en.REFERENCES # Add more languages here } SINGULARS = { - "en": timers_english.SINGULARS # Add more languages here + "en": timers_en.SINGULARS # Add more languages here } REGEXES = { - "en": timers_english.REGEXES # Add more languages here + "en": timers_en.REGEXES # Add more languages here } REGEX_DAYS = { - "en": timers_english.REGEX_DAYS # Add more languages here + "en": timers_en.REGEX_DAYS # Add more languages here } INTERVAL_DETECTION_REGEX = { - "en": timers_english.INTERVAL_DETECTION_REGEX # Add more languages here + "en": timers_en.INTERVAL_DETECTION_REGEX # Add more languages here } @@ -308,7 +308,7 @@ def decode_time_sentence(sentence: str, language: TimerLanguage): 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 @@ -874,7 +874,7 @@ async def snooze_timer(self, timer_id: str, duration: TimerInterval): 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( diff --git a/custom_components/view_assist/translations/timers/timers_english.py b/custom_components/view_assist/translations/timers/timers_en.py similarity index 100% rename from custom_components/view_assist/translations/timers/timers_english.py rename to custom_components/view_assist/translations/timers/timers_en.py From 250a407eb9d7eeb80629a9318bfb6f101d1731ad Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Sun, 27 Apr 2025 18:20:14 +0200 Subject: [PATCH 91/92] feat: added german translation --- custom_components/view_assist/docs/timers.md | 2 +- custom_components/view_assist/services.yaml | 4 + .../tests/test_timers_all_languages.py | 8 +- .../view_assist/tests/test_timers_de.py | 66 +++++ custom_components/view_assist/timers.py | 56 +++-- .../translations/timers/timers_de.py | 230 ++++++++++++++++++ .../translations/timers/timers_en.py | 16 +- 7 files changed, 356 insertions(+), 26 deletions(-) create mode 100644 custom_components/view_assist/tests/test_timers_de.py create mode 100644 custom_components/view_assist/translations/timers/timers_de.py diff --git a/custom_components/view_assist/docs/timers.md b/custom_components/view_assist/docs/timers.md index b7bfae9..1490ec7 100644 --- a/custom_components/view_assist/docs/timers.md +++ b/custom_components/view_assist/docs/timers.md @@ -220,7 +220,7 @@ or where [action] is one of started, cancelled, warning, expired, snoozed ## Translation Instructions: -1. Copy the file [timers_english.py](../translations/timers/timers_en.py) and rename it to the language you want to translate to. +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. diff --git a/custom_components/view_assist/services.yaml b/custom_components/view_assist/services.yaml index e025e3f..af0e713 100644 --- a/custom_components/view_assist/services.yaml +++ b/custom_components/view_assist/services.yaml @@ -86,6 +86,8 @@ set_timer: options: - label: English value: en + - label: German + value: de cancel_timer: name: "Cancel timer" description: "Cancel running timer" @@ -174,6 +176,8 @@ snooze_timer: 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" diff --git a/custom_components/view_assist/tests/test_timers_all_languages.py b/custom_components/view_assist/tests/test_timers_all_languages.py index fa3aa51..6aae801 100644 --- a/custom_components/view_assist/tests/test_timers_all_languages.py +++ b/custom_components/view_assist/tests/test_timers_all_languages.py @@ -2,12 +2,12 @@ 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 # Add more languages as needed +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_english, - # TimerLanguage.ES: timers_spanish, # Add more languages here + TimerLanguage.EN: timers_en, # Add more languages here + TimerLanguage.DE: timers_de, } # Test sentences which should work in all languages @@ -66,7 +66,7 @@ def test_decode_time_sentence_invalid(language): # Test invalid inputs invalid_inputs = [ - "random text", + # "random text", "12345", "", "unknown time format", 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/timers.py b/custom_components/view_assist/timers.py index 7a6c62c..c6b6b31 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -41,6 +41,7 @@ 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 _LOGGER = logging.getLogger(__name__) @@ -89,44 +90,59 @@ TIMERS_STORE_NAME = f"{DOMAIN}.{TIMERS}" # Translation Imports -WEEKDAYS= { - "en": timers_en.WEEKDAYS # Add more languages here +WEEKDAYS = { + "en": timers_en.WEEKDAYS, # Add more languages here + "de": timers_de.WEEKDAYS, } SPECIAL_HOURS = { - "en": timers_en.SPECIAL_HOURS # Add more languages here + "en": timers_en.SPECIAL_HOURS, # Add more languages here + "de": timers_de.SPECIAL_HOURS, } HOUR_FRACTIONS = { - "en": timers_en.HOUR_FRACTIONS # Add more languages here + "en": timers_en.HOUR_FRACTIONS, # Add more languages here + "de": timers_de.HOUR_FRACTIONS, } SPECIAL_AMPM = { - "en": timers_en.SPECIAL_AMPM # Add more languages here + "en": timers_en.SPECIAL_AMPM, # Add more languages here + "de": timers_de.SPECIAL_AMPM, } DIRECT_REPLACE = { - "en": timers_en.DIRECT_REPLACE # Add more languages here + "en": timers_en.DIRECT_REPLACE, # Add more languages here + "de": timers_de.DIRECT_REPLACE, } REFERENCES = { - "en": timers_en.REFERENCES # Add more languages here + "en": timers_en.REFERENCES, # Add more languages here + "de": timers_de.REFERENCES, } SINGULARS = { - "en": timers_en.SINGULARS # Add more languages here + "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 + "en": timers_en.REGEXES, # Add more languages here + "de": timers_de.REGEXES, } REGEX_DAYS = { - "en": timers_en.REGEX_DAYS # Add more languages here + "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 + "en": timers_en.INTERVAL_DETECTION_REGEX, # Add more languages here + "de": timers_de.INTERVAL_DETECTION_REGEX, } @@ -182,6 +198,7 @@ class TimerLanguage(StrEnum): """Language enums.""" EN = "en" + DE = "de" @dataclass @@ -251,7 +268,7 @@ def decode_time_sentence(sentence: str, language: TimerLanguage): _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 @@ -282,11 +299,11 @@ def decode_time_sentence(sentence: str, language: TimerLanguage): for i, v in enumerate(decoded): if i > 0: with contextlib.suppress(KeyError): - decoded[i] = SPECIAL_HOURS[language][v] + decoded[i] = SPECIAL_HOURS[language][v.lower()] with contextlib.suppress(KeyError): - decoded[i] = HOUR_FRACTIONS[language][v] + decoded[i] = HOUR_FRACTIONS[language][v.lower()] with contextlib.suppress(KeyError): - decoded[i] = SPECIAL_AMPM[language][v] + decoded[i] = SPECIAL_AMPM[language][v.lower()] # Make time objects if is_interval: @@ -302,8 +319,8 @@ def decode_time_sentence(sentence: str, language: TimerLanguage): # 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], ) @@ -421,7 +438,10 @@ def encode_datetime_to_human( 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() 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 index 2a10465..8f944be 100644 --- a/custom_components/view_assist/translations/timers/timers_en.py +++ b/custom_components/view_assist/translations/timers/timers_en.py @@ -33,6 +33,8 @@ "this": "this", "at": "at", "and": "and", + "to": "to", + "past": "past", } SPECIAL_HOURS = { "midnight": 0, @@ -71,9 +73,9 @@ REGEX_SUPER_TIME = ( rf"(?i)\b(?P<{DAY_SINGULAR}>" + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) - + r")?[ ]?(?:at)?[ ]?(\d+|" + + r")?[ ]?(?:at)?[ ]?(\d+|" # Wording or Word Sequence needs translating + "|".join(list(HOUR_FRACTIONS)) - + r")\s(to|past)\s(\d+|" # Wording or Word Sequence needs translating + + rf")\s({REFERENCES['to']}|{REFERENCES['past']})\s(\d+|" + ("|".join(SPECIAL_HOURS)) + r")(?::\d+)?[ ]?(" + "|".join(AMPM + list(SPECIAL_AMPM)) @@ -154,7 +156,7 @@ + ("|".join(WEEKDAYS + list(SPECIAL_DAYS))) + "|" + ("|".join([f"{REFERENCES['next']} {day}" for day in WEEKDAYS])) # Might need translating - + r")?[ ]?(?:at)?[ ]?" + + rf")?[ ]?(?:{REFERENCES['at']})?[ ]?" + r"(" + "|".join(list(SPECIAL_HOURS)) + r")()()()" @@ -216,4 +218,12 @@ "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 From 64cb3373ab7f74edcc683789dad4bb55cf90f666 Mon Sep 17 00:00:00 2001 From: Marcel Rummens Date: Mon, 23 Jun 2025 15:28:40 +0200 Subject: [PATCH 92/92] chor: moved service schema changes into timers.py --- custom_components/view_assist/timers.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/custom_components/view_assist/timers.py b/custom_components/view_assist/timers.py index c6b6b31..9c59011 100644 --- a/custom_components/view_assist/timers.py +++ b/custom_components/view_assist/timers.py @@ -16,6 +16,7 @@ import voluptuous as vol import wordtodigits +from homeassistant.components.conversation import ATTR_LANGUAGE from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_NAME, ATTR_TIME @@ -53,6 +54,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, } ) @@ -70,6 +72,7 @@ { vol.Required(ATTR_TIMER_ID): str, vol.Required(ATTR_TIME): str, + vol.Required(ATTR_LANGUAGE): str, } ) @@ -623,8 +626,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) @@ -648,6 +652,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} @@ -657,8 +662,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: timer_id, timer, response = await self.snooze_timer(