From 520a63667db2a66c277f10251a2dc76ae9ac4031 Mon Sep 17 00:00:00 2001 From: Lorenzo Mattioli Date: Sat, 11 Jul 2026 15:55:12 +0200 Subject: [PATCH 1/2] feat: add config flow with YAML import and global settings (phase 1) Implements the first phase of the UI configuration discussed in #60: - config_flow.py: user step (guided setup for a fresh install), one-shot YAML import (existing setups appear in the UI unchanged), and reconfigure flow for global settings. Single entry enforced. - __init__.py: config entry lifecycle. A YAML-imported entry only mirrors settings for the UI (the legacy notify platform keeps owning notify.supernotify - no duplicate service). A UI-created entry loads the legacy notify platform via discovery, so a user with no YAML still gets notify.supernotify. - notify.py: async_get_service accepts config from the legacy YAML path or from a config entry via discovery_info; when starting from YAML it triggers the one-shot import. Non-JSON-serializable YAML artifacts (e.g. Jinja Template objects) are converted to their source strings before being stored in the entry (regression-tested). - strings.json + 11 translations for the new steps. - tests: config flow (user/import/reconfigure/serialization) and entry lifecycle. Deliveries, scenarios, recipients and cameras stay YAML-only in this phase; the flow only covers environment/global settings, by design, so the phasing can follow the plan in #60. --- custom_components/supernotify/__init__.py | 99 +++- custom_components/supernotify/config_flow.py | 269 +++++++++++ custom_components/supernotify/manifest.json | 7 +- custom_components/supernotify/notify.py | 66 ++- custom_components/supernotify/strings.json | 107 +++-- .../supernotify/translations/de.json | 53 ++- .../supernotify/translations/en.json | 105 +++-- .../supernotify/translations/es.json | 53 ++- .../supernotify/translations/fr.json | 53 ++- .../supernotify/translations/hi.json | 53 ++- .../supernotify/translations/it.json | 53 ++- .../supernotify/translations/ja.json | 53 ++- .../supernotify/translations/nl.json | 53 ++- .../supernotify/translations/pl.json | 53 ++- .../supernotify/translations/pt.json | 53 ++- .../supernotify/translations/zh-Hans.json | 53 ++- .../supernotify/test_config_flow.py | 425 ++++++++++++++++++ tests/components/supernotify/test_init.py | 68 +++ 18 files changed, 1552 insertions(+), 124 deletions(-) create mode 100755 custom_components/supernotify/config_flow.py create mode 100755 tests/components/supernotify/test_config_flow.py create mode 100644 tests/components/supernotify/test_init.py diff --git a/custom_components/supernotify/__init__.py b/custom_components/supernotify/__init__.py index 8f5208a3e..5e10c9843 100644 --- a/custom_components/supernotify/__init__.py +++ b/custom_components/supernotify/__init__.py @@ -1,9 +1,106 @@ -"""The Supernotify integration""" +"""The SuperNotify integration — Phase 1 __init__.py. + +This REPLACES the current 9-line __init__.py. It keeps the existing module-level +constants (imported by the rest of the package) and adds the config-entry +lifecycle while leaving the legacy YAML notify platform fully working. + +Phase 1 behaviour: + * `async_setup_entry` : when the entry was created via the UI (no YAML), load + the legacy notify platform from the entry through discovery, so a user with + no YAML still gets `notify.supernotify`. + * When the entry was imported from YAML, the legacy `notify:` platform already + provides the service, so we do NOT reload it (avoids a duplicate service). + The entry just mirrors the settings for the UI. + * `async_unload_entry` : tidy up. + +The YAML->entry import itself is triggered from notify.async_get_service (see +notify.py), because the SuperNotify config lives under a +legacy `notify:` platform, not under a top-level `supernotify:` domain — so there +is no domain-level async_setup hook to import from. + +NOTE (open point for jeyrb, design §4/§8): preserving the exact `notify.supernotify` +action while moving to a config entry is the delicate part. This implementation +takes the lowest-risk route (discovery-loaded legacy platform). A future phase +may switch to a NotifyEntity. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any from homeassistant.const import Platform +from homeassistant.helpers import discovery + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + from homeassistant.helpers.typing import ConfigType + +_LOGGER = logging.getLogger(__name__) DOMAIN = "supernotify" PLATFORMS = [Platform.NOTIFY] +# Platforms set up via the config entry (the notify platform is legacy/discovery). +_ENTRY_PLATFORMS = [Platform.SENSOR] TEMPLATE_DIR: str = "/config/templates/supernotify" MEDIA_DIR: str = "supernotify/media" + +# Marker stored in entry.data when the entry was created by importing YAML. +# Used to avoid double-loading the notify service (the legacy platform already +# provides it in that case). +ATTR_IMPORTED_FROM_YAML = "imported_from_yaml" + + +async def async_setup(_hass: HomeAssistant, _config: ConfigType) -> bool: + """YAML setup hook. + + The notify platform loads itself via async_get_service; nothing extra is + required here for Phase 1. + """ + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up SuperNotify from a config entry. + + Phase 1: the entry owns no runtime_data — the notify service is provided by + the legacy notify platform (either loaded from YAML, or loaded from this entry + via discovery for UI-only setups). runtime_data will become relevant when the + integration moves to a NotifyEntity in a later phase. + """ + # Diagnostic sensor (delivery inventory) — additive, independent of the notify + # service; set up for both imported and UI-only entries. + await hass.config_entries.async_forward_entry_setups(entry, _ENTRY_PLATFORMS) + + if entry.data.get(ATTR_IMPORTED_FROM_YAML): + # The legacy `notify:` platform already provides notify.supernotify from + # YAML. The entry only mirrors settings for the UI; do not reload. + _LOGGER.debug("SUPERNOTIFY entry imported from YAML; legacy notify platform owns the service") + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True + + # UI-created entry (no YAML): load the legacy notify platform via discovery, + # passing the merged config (as discovery_info) so async_get_service can build + # the service. The 5th arg is hass_config (the full HA config); we pass {} as + # this legacy notify platform does not read from it. + merged: dict[str, Any] = {**entry.data, **entry.options} + merged.pop(ATTR_IMPORTED_FROM_YAML, None) + hass.async_create_task(discovery.async_load_platform(hass, Platform.NOTIFY, DOMAIN, merged, {})) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry. + + No runtime_data to tear down in Phase 1; update listeners are removed + automatically via the async_on_unload registration in async_setup_entry. + """ + return await hass.config_entries.async_unload_platforms(entry, _ENTRY_PLATFORMS) + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the entry when its options change.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/supernotify/config_flow.py b/custom_components/supernotify/config_flow.py new file mode 100755 index 000000000..1e4090ea1 --- /dev/null +++ b/custom_components/supernotify/config_flow.py @@ -0,0 +1,269 @@ +"""Config flow for the SuperNotify integration — Phase 1. + +Phase 1 scope (see design/config_flow_design.md): + * Register SuperNotify as a config-entry-based integration. + * `async_step_user` : manual setup, global settings only. + * `async_step_import`: one-shot migration of an existing YAML configuration. + +Out of scope for Phase 1 (handled by YAML until Phase 2/3 subentries land): + * Delivery, scenario, recipient, camera, action-group management. + +Design decisions for Phase 1: + * Single config entry per HA instance (`unique_id == DOMAIN`). + * Global settings are stored in `entry.data`; imported deliveries/scenarios and + the other item collections are parked in `entry.options` untouched, so the + existing runtime keeps working without behaviour changes. + * The `notify.supernotify` action name is preserved (see __init__.py, which + loads the legacy notify platform from the entry via discovery). +""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_ENABLED +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from . import ATTR_IMPORTED_FROM_YAML, DOMAIN, MEDIA_DIR, TEMPLATE_DIR +from .const import ( + ATTR_DUPE_POLICY_MT, + ATTR_DUPE_POLICY_MTSLP, + ATTR_DUPE_POLICY_NONE, + CONF_ACTION_GROUPS, + CONF_ARCHIVE, + CONF_ARCHIVE_DAYS, + CONF_CAMERAS, + CONF_DELIVERY, + CONF_DUPE_CHECK, + CONF_DUPE_POLICY, + CONF_HOUSEKEEPING, + CONF_LINKS, + CONF_MEDIA_PATH, + CONF_MEDIA_STORAGE_DAYS, + CONF_MEDIA_URL_PREFIX, + CONF_MOBILE_DISCOVERY, + CONF_RECIPIENTS, + CONF_RECIPIENTS_DISCOVERY, + CONF_SCENARIOS, + CONF_SIZE, + CONF_SNOOZE, + CONF_SNOOZE_TIME, + CONF_TEMPLATE_PATH, + CONF_TRANSPORTS, + CONF_TTL, +) + +_LOGGER = logging.getLogger(__name__) + +# Keys collected by the Phase 1 "global settings" form. Defaults mirror the +# top-level voluptuous schema (schema.py PLATFORM_SCHEMA.extend) so that moving +# from YAML to the UI does not change behaviour. +_DUPE_POLICIES = [ATTR_DUPE_POLICY_MTSLP, ATTR_DUPE_POLICY_MT, ATTR_DUPE_POLICY_NONE] + + +def _global_settings_schema(defaults: dict[str, Any]) -> vol.Schema: + """Build the global-settings form schema, pre-filled with `defaults`.""" + return vol.Schema({ + # NB: use cv.string (not cv.path) — cv.path is not JSON-serializable + # by voluptuous_serialize for the config-flow frontend and raises + # "Unable to convert schema" (HTTP 500) when rendering the form. + vol.Optional(CONF_TEMPLATE_PATH, default=defaults.get(CONF_TEMPLATE_PATH, TEMPLATE_DIR)): cv.string, + vol.Optional(CONF_MEDIA_PATH, default=defaults.get(CONF_MEDIA_PATH, MEDIA_DIR)): cv.string, + vol.Optional( + CONF_MEDIA_URL_PREFIX, + default=defaults.get(CONF_MEDIA_URL_PREFIX, "/supernotify/media"), + ): cv.string, + vol.Optional(CONF_MOBILE_DISCOVERY, default=defaults.get(CONF_MOBILE_DISCOVERY, True)): cv.boolean, + vol.Optional( + CONF_RECIPIENTS_DISCOVERY, + default=defaults.get(CONF_RECIPIENTS_DISCOVERY, True), + ): cv.boolean, + # Archive: only the two most common knobs in Phase 1. + vol.Optional("archive_enabled", default=defaults.get("archive_enabled", False)): cv.boolean, + vol.Optional("archive_days", default=defaults.get("archive_days", 3)): cv.positive_int, + # Dupe check. + vol.Optional("dupe_ttl", default=defaults.get("dupe_ttl", 120)): cv.positive_int, + vol.Optional("dupe_size", default=defaults.get("dupe_size", 100)): cv.positive_int, + vol.Optional("dupe_policy", default=defaults.get("dupe_policy", ATTR_DUPE_POLICY_MTSLP)): SelectSelector( + SelectSelectorConfig( + options=[SelectOptionDict(value=p, label=p) for p in _DUPE_POLICIES], + mode=SelectSelectorMode.DROPDOWN, + translation_key="dupe_policy", + ) + ), + # Snooze + media retention. + vol.Optional("snooze_seconds", default=defaults.get("snooze_seconds", 60 * 60)): cv.positive_int, + vol.Optional("media_storage_days", default=defaults.get("media_storage_days", 7)): cv.positive_int, + }) + + +def _form_to_entry_data(user_input: dict[str, Any]) -> dict[str, Any]: + """Map the flat form fields into the nested runtime config structure. + + The nesting matches the YAML top-level keys. + """ + return { + CONF_TEMPLATE_PATH: user_input[CONF_TEMPLATE_PATH], + CONF_MEDIA_PATH: user_input[CONF_MEDIA_PATH], + CONF_MEDIA_URL_PREFIX: user_input[CONF_MEDIA_URL_PREFIX], + CONF_MOBILE_DISCOVERY: user_input[CONF_MOBILE_DISCOVERY], + CONF_RECIPIENTS_DISCOVERY: user_input[CONF_RECIPIENTS_DISCOVERY], + CONF_ARCHIVE: { + CONF_ENABLED: user_input["archive_enabled"], + CONF_ARCHIVE_DAYS: user_input["archive_days"], + }, + CONF_DUPE_CHECK: { + CONF_TTL: user_input["dupe_ttl"], + CONF_SIZE: user_input["dupe_size"], + CONF_DUPE_POLICY: user_input["dupe_policy"], + }, + CONF_SNOOZE: {CONF_SNOOZE_TIME: user_input["snooze_seconds"]}, + CONF_HOUSEKEEPING: {CONF_MEDIA_STORAGE_DAYS: user_input["media_storage_days"]}, + } + + +def _entry_data_to_form(data: dict[str, Any]) -> dict[str, Any]: + """Reverse of _form_to_entry_data: nested entry data back to flat form fields. + + Used so the reconfigure form is pre-filled with the current values. Falls + back to the same defaults used by the YAML schema. + """ + archive = data.get(CONF_ARCHIVE, {}) + dupe = data.get(CONF_DUPE_CHECK, {}) + snooze = data.get(CONF_SNOOZE, {}) + house = data.get(CONF_HOUSEKEEPING, {}) + return { + CONF_TEMPLATE_PATH: data.get(CONF_TEMPLATE_PATH, TEMPLATE_DIR), + CONF_MEDIA_PATH: data.get(CONF_MEDIA_PATH, MEDIA_DIR), + CONF_MEDIA_URL_PREFIX: data.get(CONF_MEDIA_URL_PREFIX, "/supernotify/media"), + CONF_MOBILE_DISCOVERY: data.get(CONF_MOBILE_DISCOVERY, True), + CONF_RECIPIENTS_DISCOVERY: data.get(CONF_RECIPIENTS_DISCOVERY, True), + "archive_enabled": archive.get(CONF_ENABLED, False), + "archive_days": archive.get(CONF_ARCHIVE_DAYS, 3), + "dupe_ttl": dupe.get(CONF_TTL, 120), + "dupe_size": dupe.get(CONF_SIZE, 100), + "dupe_policy": dupe.get(CONF_DUPE_POLICY, ATTR_DUPE_POLICY_MTSLP), + "snooze_seconds": snooze.get(CONF_SNOOZE_TIME, 60 * 60), + "media_storage_days": house.get(CONF_MEDIA_STORAGE_DAYS, 7), + } + + +class SupernotifyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for SuperNotify (Phase 1).""" + + VERSION = 1 + + async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + """Manual setup from the UI — global settings only.""" + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + + if user_input is not None: + return self.async_create_entry( + title="SuperNotify", + data=_form_to_entry_data(user_input), + options={}, + ) + + return self.async_show_form(step_id="user", data_schema=_global_settings_schema({})) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """One-shot migration of an existing YAML configuration. + + `import_data` is the YAML config already validated by SUPERNOTIFY_SCHEMA, + plus the ATTR_IMPORTED_FROM_YAML marker set by notify.async_get_service. + Global keys go into `entry.data`; the item collections (deliveries, + scenarios, recipients, cameras, links, action groups, transports) are + preserved verbatim in `entry.options` so the runtime stays identical. + + The marker is carried into `entry.data` so async_setup_entry knows the + legacy notify platform already owns the service and must NOT reload it + (otherwise notify.supernotify would be registered twice). + """ + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + + payload = dict(import_data) + imported = payload.pop(ATTR_IMPORTED_FROM_YAML, False) + global_data, item_options = _split_global_vs_items(payload) + # Make storable: SUPERNOTIFY_SCHEMA may have coerced template strings into + # Template objects that can't be JSON-serialized into the config entry. + global_data = _jsonify(global_data) + item_options = _jsonify(item_options) + global_data[ATTR_IMPORTED_FROM_YAML] = imported + _LOGGER.info("SUPERNOTIFY importing YAML configuration into a config entry") + return self.async_create_entry(title="SuperNotify (imported)", data=global_data, options=item_options) + + async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + """Let the user change the global settings of an existing entry. + + Global settings are config-entry *data* (not optional), so the HA + guidance is to use a reconfigure step rather than an OptionsFlow. On + submit we update the existing entry in place and reload — we never + create a second entry. The ATTR_IMPORTED_FROM_YAML marker is preserved. + """ + entry = self._get_reconfigure_entry() + + if user_input is not None: + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort(entry, data_updates=_form_to_entry_data(user_input)) + + return self.async_show_form( + step_id="reconfigure", + data_schema=_global_settings_schema(_entry_data_to_form(entry.data)), + ) + + +# Keys that represent configurable *items* (managed by YAML in Phase 1, and by +# subentries from Phase 2/3). Everything else in the YAML is a global setting. +_ITEM_KEYS = ( + CONF_DELIVERY, + CONF_SCENARIOS, + CONF_RECIPIENTS, + CONF_CAMERAS, + CONF_LINKS, + CONF_ACTION_GROUPS, + CONF_TRANSPORTS, +) + + +def _split_global_vs_items( + config: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + """Split a validated YAML config into (global settings, item collections).""" + global_data = {k: v for k, v in config.items() if k not in _ITEM_KEYS} + item_options = {k: v for k, v in config.items() if k in _ITEM_KEYS} + return global_data, item_options + + +def _jsonify(obj: Any) -> Any: + """Recursively make a config value JSON-serializable for config-entry storage. + + The YAML config arrives ALREADY validated by SUPERNOTIFY_SCHEMA, which coerces + fields such as volume_template / message_template into `Template` objects that + are not JSON-serializable. Storing them (e.g. when a subentry is added and HA + saves the whole entry) raises "Type is not JSON serializable: Template". + Templates become their source string; any other non-serializable value falls + back to str(). The runtime re-validates the raw config when it loads it. + """ + if isinstance(obj, dict): + return {k: _jsonify(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_jsonify(v) for v in obj] + if obj is None or isinstance(obj, (str, int, float, bool)): + return obj + template = getattr(obj, "template", None) # homeassistant Template -> source + return template if isinstance(template, str) else str(obj) diff --git a/custom_components/supernotify/manifest.json b/custom_components/supernotify/manifest.json index be72a0df6..bb7a1a70b 100644 --- a/custom_components/supernotify/manifest.json +++ b/custom_components/supernotify/manifest.json @@ -14,7 +14,7 @@ "codeowners": [ "@jey" ], - "config_flow": false, + "config_flow": true, "dependencies": [ "person" ], @@ -29,5 +29,6 @@ "cachetools", "httpx" ], - "version":"1.16.2" -} + "version": "1.16.2", + "single_config_entry": true +} \ No newline at end of file diff --git a/custom_components/supernotify/notify.py b/custom_components/supernotify/notify.py index a77a2405b..b7bba8222 100644 --- a/custom_components/supernotify/notify.py +++ b/custom_components/supernotify/notify.py @@ -33,7 +33,7 @@ from homeassistant.helpers.json import ExtendedJSONEncoder from homeassistant.helpers.reload import async_setup_reload_service -from . import DOMAIN, PLATFORMS +from . import ATTR_IMPORTED_FROM_YAML, DOMAIN, MEDIA_DIR, PLATFORMS, TEMPLATE_DIR from .archive import ARCHIVE_PURGE_MIN_INTERVAL, NotificationArchive from .common import DupeChecker, sanitize from .const import ( @@ -122,6 +122,19 @@ ] # No auto-discovery of transport plugins so manual class registration required here +def _with_item_defaults(source: dict[str, Any]) -> dict[str, Any]: + """Ensure item collections exist with empty defaults for UI-only entries.""" + merged = dict(source) + merged.setdefault(CONF_DELIVERY, {}) + merged.setdefault(CONF_SCENARIOS, {}) + merged.setdefault(CONF_RECIPIENTS, []) + merged.setdefault(CONF_CAMERAS, []) + merged.setdefault(CONF_LINKS, []) + merged.setdefault(CONF_ACTION_GROUPS, {}) + merged.setdefault(CONF_TRANSPORTS, {}) + return merged + + async def async_get_service( hass: HomeAssistant, config: ConfigType, @@ -129,31 +142,48 @@ async def async_get_service( ) -> SupernotifyAction: """Notify specific component setup - see async_setup_legacy in legacy BaseNotificationService""" _ = PLATFORM_SCHEMA # schema must be imported even if not used for HA platform detection - _ = discovery_info await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + # Phase 1 config flow: config may arrive from the legacy YAML notify platform + # (config) or from a config entry via discovery (discovery_info). + source = _with_item_defaults(discovery_info) if discovery_info else config + service = SupernotifyAction( hass, - deliveries=config[CONF_DELIVERY], - template_path=config[CONF_TEMPLATE_PATH], - media_path=config[CONF_MEDIA_PATH], - media_url_prefix=config.get(CONF_MEDIA_URL_PREFIX), - archive=config[CONF_ARCHIVE], - housekeeping=config[CONF_HOUSEKEEPING], - mobile_discovery=config[CONF_MOBILE_DISCOVERY], - recipients_discovery=config[CONF_RECIPIENTS_DISCOVERY], - recipients=config[CONF_RECIPIENTS], - mobile_actions=config[CONF_ACTION_GROUPS], - scenarios=config[CONF_SCENARIOS], - links=config[CONF_LINKS], - transport_configs=config[CONF_TRANSPORTS], - cameras=config[CONF_CAMERAS], - dupe_check=config[CONF_DUPE_CHECK], - snooze=config[CONF_SNOOZE], + deliveries=source.get(CONF_DELIVERY, {}), + template_path=source.get(CONF_TEMPLATE_PATH, TEMPLATE_DIR), + media_path=source.get(CONF_MEDIA_PATH, MEDIA_DIR), + media_url_prefix=source.get(CONF_MEDIA_URL_PREFIX), + archive=source.get(CONF_ARCHIVE, {}), + housekeeping=source.get(CONF_HOUSEKEEPING, {}), + mobile_discovery=source.get(CONF_MOBILE_DISCOVERY, True), + recipients_discovery=source.get(CONF_RECIPIENTS_DISCOVERY, True), + recipients=source.get(CONF_RECIPIENTS, []), + mobile_actions=source.get(CONF_ACTION_GROUPS, {}), + scenarios=source.get(CONF_SCENARIOS, {}), + links=source.get(CONF_LINKS, []), + transport_configs=source.get(CONF_TRANSPORTS, {}), + cameras=source.get(CONF_CAMERAS, []), + dupe_check=source.get(CONF_DUPE_CHECK, {}), + snooze=source.get(CONF_SNOOZE, {}), ) await service.initialize() + # When started from YAML (no discovery_info), mirror the settings into a + # config entry so they appear in the UI. Flag it imported so __init__ does + # not reload the service (the legacy platform already provides it). + # Guard: only trigger the import when no entry exists yet, otherwise every + # YAML reload would start a flow that just aborts as already_configured. + if discovery_info is None and not hass.config_entries.async_entries(DOMAIN): + from homeassistant.config_entries import SOURCE_IMPORT + + _import_payload = dict(config) + _import_payload[ATTR_IMPORTED_FROM_YAML] = True + hass.async_create_task( + hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data=_import_payload) + ) + def supplemental_action_enquire_configuration(_call: ServiceCall) -> dict[str, Any]: return { CONF_DELIVERY: config.get(CONF_DELIVERY, {}), diff --git a/custom_components/supernotify/strings.json b/custom_components/supernotify/strings.json index ff6410f2d..3e4503676 100644 --- a/custom_components/supernotify/strings.json +++ b/custom_components/supernotify/strings.json @@ -1,5 +1,5 @@ { - "title":"Supernotify", + "title": "Supernotify", "services": { "reload": { "name": "[%key:common::action::reload%]", @@ -13,54 +13,97 @@ "config": { "step": { "user": { - "title": "Environment", + "title": "SuperNotify", "data": { "template_path": "Template Path", "media_path": "Media Path", - "archive_path": "Archive Path" + "media_url_prefix": "Media URL prefix", + "mobile_discovery": "Auto-discover mobile apps", + "recipients_discovery": "Auto-discover recipients", + "archive_enabled": "Enable notification archive", + "archive_days": "Archive retention (days)", + "dupe_ttl": "Duplicate window (seconds)", + "dupe_size": "Duplicate cache size", + "dupe_policy": "Duplicate policy", + "snooze_seconds": "Default snooze (seconds)", + "media_storage_days": "Media retention (days)" }, "data_description": { "template_path": "Optional location where Jinja2 templates for HTML email are to be found", - "media_path": "Storage path for media created during notifications, for example camera snapshots created for attachments", - "archive_path": "Storage path for archived notifications, useful for debugging configuration, scenarios etc" + "media_path": "Storage path for media created during notifications, for example camera snapshots created for attachments" + }, + "description": "Set up SuperNotify global settings. Deliveries and scenarios are still configured in YAML for now and will get their own UI in a later phase." + }, + "reconfigure": { + "title": "SuperNotify settings", + "description": "Update SuperNotify global settings. Deliveries and scenarios are managed separately.", + "data": { + "template_path": "Template Path", + "media_path": "Media Path", + "media_url_prefix": "Media URL prefix", + "mobile_discovery": "Auto-discover mobile apps", + "recipients_discovery": "Auto-discover recipients", + "archive_enabled": "Enable notification archive", + "archive_days": "Archive retention (days)", + "dupe_ttl": "Duplicate window (seconds)", + "dupe_size": "Duplicate cache size", + "dupe_policy": "Duplicate policy", + "snooze_seconds": "Default snooze (seconds)", + "media_storage_days": "Media retention (days)" + }, + "data_description": { + "template_path": "Optional location where Jinja2 templates for HTML email are to be found", + "media_path": "Storage path for media created during notifications, for example camera snapshots created for attachments" } } + }, + "abort": { + "already_configured": "SuperNotify is already configured. Only one instance is supported.", + "reconfigure_successful": "SuperNotify settings updated.", + "unique_id_mismatch": "This does not match the existing SuperNotify configuration." } }, - "issues":{ - "scenario_delivery":{ - "title":"Invalid Delivery for Scenario", - "description":"Scenario definition {scenario} refers to a delivery {delivery} that doesn't exist in supernotify configuration" + "issues": { + "scenario_delivery": { + "title": "Invalid Delivery for Scenario", + "description": "Scenario definition {scenario} refers to a delivery {delivery} that doesn't exist in supernotify configuration" }, - "scenario_action_group":{ - "title":"Invalid Mobile Action Group for Scenario", - "description":"Scenario definition {scenario} refers to a delivery {action_group} that doesn't exist in supernotify configuration" + "scenario_action_group": { + "title": "Invalid Mobile Action Group for Scenario", + "description": "Scenario definition {scenario} refers to a delivery {action_group} that doesn't exist in supernotify configuration" }, - "scenario_condition":{ - "title":"Invalid Condition for Scenario", - "description":"Scenario definition {scenario} contains a condition that cannor be evaluated by Home Assistant" + "scenario_condition": { + "title": "Invalid Condition for Scenario", + "description": "Scenario definition {scenario} contains a condition that cannor be evaluated by Home Assistant" }, - "delivery_unknown_transport":{ - "title":"Unknown Transport for Delivery", - "description":"Delivery configuration {delivery} refers to a transport {transport} that is not provided by SuperNotify" + "delivery_unknown_transport": { + "title": "Unknown Transport for Delivery", + "description": "Delivery configuration {delivery} refers to a transport {transport} that is not provided by SuperNotify" }, - "media_path":{ - "title":"Media Path Cannot be Used", - "description":"A path ({path}) has been provided for holding media such as camera snapshots during notifications but cannot be used (error: {error})" + "media_path": { + "title": "Media Path Cannot be Used", + "description": "A path ({path}) has been provided for holding media such as camera snapshots during notifications but cannot be used (error: {error})" }, - "delivery_reserved_name":{ - "title":"Delivery Has Reserved Delivery Name", - "description":"The delivery {delivery} name is reserved for Supernotify use" + "delivery_reserved_name": { + "title": "Delivery Has Reserved Delivery Name", + "description": "The delivery {delivery} name is reserved for Supernotify use" }, - "delivery_invalid_action":{ - "title":"Delivery Has Invalid Action", - "description":"The delivery {delivery} has an invalid action {action}" + "delivery_invalid_action": { + "title": "Delivery Has Invalid Action", + "description": "The delivery {delivery} has an invalid action {action}" }, - "delivery_invalid_condition":{ - "title":"Delivery Has Invalid Condition", - "description":"The {delivery} delivery has an invalid condition {condition}" - + "delivery_invalid_condition": { + "title": "Delivery Has Invalid Condition", + "description": "The {delivery} delivery has an invalid condition {condition}" + } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "Same message & title, same or lower priority", + "dupe_policy_message_title_same": "Same message & title", + "dupe_policy_none": "Disabled" + } } - } } diff --git a/custom_components/supernotify/translations/de.json b/custom_components/supernotify/translations/de.json index 7117231cb..1341c32b1 100644 --- a/custom_components/supernotify/translations/de.json +++ b/custom_components/supernotify/translations/de.json @@ -13,18 +13,54 @@ "config": { "step": { "user": { - "title": "Umgebung", + "title": "SuperNotify", "data": { "template_path": "Vorlagenpfad", "media_path": "Medienpfad", - "archive_path": "Archivpfad" + "media_url_prefix": "Medien-URL-Präfix", + "mobile_discovery": "Mobile Apps automatisch erkennen", + "recipients_discovery": "Empfänger automatisch erkennen", + "archive_enabled": "Benachrichtigungsarchiv aktivieren", + "archive_days": "Archivaufbewahrung (Tage)", + "dupe_ttl": "Duplikatfenster (Sekunden)", + "dupe_size": "Duplikat-Cache-Größe", + "dupe_policy": "Duplikatrichtlinie", + "snooze_seconds": "Standard-Schlummerzeit (Sekunden)", + "media_storage_days": "Medienaufbewahrung (Tage)" }, "data_description": { "template_path": "Optionaler Speicherort für Jinja2-Vorlagen für HTML-E-Mails", - "media_path": "Speicherpfad für Medien, die während Benachrichtigungen erstellt werden, z. B. Kamera-Snapshots für Anhänge", - "archive_path": "Speicherpfad für archivierte Benachrichtigungen, nützlich zum Debuggen von Konfiguration, Szenarien usw." + "media_path": "Speicherpfad für Medien, die während Benachrichtigungen erstellt werden, z. B. Kamera-Snapshots für Anhänge" + }, + "description": "Globale SuperNotify-Einstellungen einrichten. Zustellungen und Szenarien werden vorerst weiterhin in YAML konfiguriert und erhalten in einer späteren Phase eine eigene Oberfläche." + }, + "reconfigure": { + "title": "SuperNotify-Einstellungen", + "description": "Globale SuperNotify-Einstellungen aktualisieren. Zustellungen und Szenarien werden separat verwaltet.", + "data": { + "template_path": "Vorlagenpfad", + "media_path": "Medienpfad", + "media_url_prefix": "Medien-URL-Präfix", + "mobile_discovery": "Mobile Apps automatisch erkennen", + "recipients_discovery": "Empfänger automatisch erkennen", + "archive_enabled": "Benachrichtigungsarchiv aktivieren", + "archive_days": "Archivaufbewahrung (Tage)", + "dupe_ttl": "Duplikatfenster (Sekunden)", + "dupe_size": "Duplikat-Cache-Größe", + "dupe_policy": "Duplikatrichtlinie", + "snooze_seconds": "Standard-Schlummerzeit (Sekunden)", + "media_storage_days": "Medienaufbewahrung (Tage)" + }, + "data_description": { + "template_path": "Optionaler Speicherort für Jinja2-Vorlagen für HTML-E-Mails", + "media_path": "Speicherpfad für Medien, die während Benachrichtigungen erstellt werden, z. B. Kamera-Snapshots für Anhänge" } } + }, + "abort": { + "already_configured": "SuperNotify ist bereits konfiguriert. Es wird nur eine Instanz unterstützt.", + "reconfigure_successful": "SuperNotify-Einstellungen aktualisiert.", + "unique_id_mismatch": "Dies stimmt nicht mit der vorhandenen SuperNotify-Konfiguration überein." } }, "issues": { @@ -60,5 +96,14 @@ "title": "Lieferung hat eine ungültige Bedingung", "description": "Die Lieferung {delivery} hat eine ungültige Bedingung {condition}" } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "Gleiche Nachricht und Titel, gleiche oder niedrigere Priorität", + "dupe_policy_message_title_same": "Gleiche Nachricht und Titel", + "dupe_policy_none": "Deaktiviert" + } + } } } diff --git a/custom_components/supernotify/translations/en.json b/custom_components/supernotify/translations/en.json index f9976a041..3e4503676 100644 --- a/custom_components/supernotify/translations/en.json +++ b/custom_components/supernotify/translations/en.json @@ -1,5 +1,5 @@ { - "title":"Supernotify", + "title": "Supernotify", "services": { "reload": { "name": "[%key:common::action::reload%]", @@ -13,52 +13,97 @@ "config": { "step": { "user": { - "title": "Environment", + "title": "SuperNotify", "data": { "template_path": "Template Path", "media_path": "Media Path", - "archive_path": "Archive Path" + "media_url_prefix": "Media URL prefix", + "mobile_discovery": "Auto-discover mobile apps", + "recipients_discovery": "Auto-discover recipients", + "archive_enabled": "Enable notification archive", + "archive_days": "Archive retention (days)", + "dupe_ttl": "Duplicate window (seconds)", + "dupe_size": "Duplicate cache size", + "dupe_policy": "Duplicate policy", + "snooze_seconds": "Default snooze (seconds)", + "media_storage_days": "Media retention (days)" }, "data_description": { "template_path": "Optional location where Jinja2 templates for HTML email are to be found", - "media_path": "Storage path for media created during notifications, for example camera snapshots created for attachments", - "archive_path": "Storage path for archived notifications, useful for debugging configuration, scenarios etc" + "media_path": "Storage path for media created during notifications, for example camera snapshots created for attachments" + }, + "description": "Set up SuperNotify global settings. Deliveries and scenarios are still configured in YAML for now and will get their own UI in a later phase." + }, + "reconfigure": { + "title": "SuperNotify settings", + "description": "Update SuperNotify global settings. Deliveries and scenarios are managed separately.", + "data": { + "template_path": "Template Path", + "media_path": "Media Path", + "media_url_prefix": "Media URL prefix", + "mobile_discovery": "Auto-discover mobile apps", + "recipients_discovery": "Auto-discover recipients", + "archive_enabled": "Enable notification archive", + "archive_days": "Archive retention (days)", + "dupe_ttl": "Duplicate window (seconds)", + "dupe_size": "Duplicate cache size", + "dupe_policy": "Duplicate policy", + "snooze_seconds": "Default snooze (seconds)", + "media_storage_days": "Media retention (days)" + }, + "data_description": { + "template_path": "Optional location where Jinja2 templates for HTML email are to be found", + "media_path": "Storage path for media created during notifications, for example camera snapshots created for attachments" } } + }, + "abort": { + "already_configured": "SuperNotify is already configured. Only one instance is supported.", + "reconfigure_successful": "SuperNotify settings updated.", + "unique_id_mismatch": "This does not match the existing SuperNotify configuration." } }, - "issues":{ - "scenario_delivery":{ - "title":"Invalid Delivery for Scenario", - "description":"Scenario definition {scenario} refers to a delivery {delivery} that doesn't exist in supernotify configuration" + "issues": { + "scenario_delivery": { + "title": "Invalid Delivery for Scenario", + "description": "Scenario definition {scenario} refers to a delivery {delivery} that doesn't exist in supernotify configuration" }, - "scenario_action_group":{ - "title":"Invalid Mobile Action Group for Scenario", - "description":"Scenario definition {scenario} refers to a delivery {action_group} that doesn't exist in supernotify configuration" + "scenario_action_group": { + "title": "Invalid Mobile Action Group for Scenario", + "description": "Scenario definition {scenario} refers to a delivery {action_group} that doesn't exist in supernotify configuration" }, - "scenario_condition":{ - "title":"Invalid Condition for Scenario", - "description":"Scenario definition {scenario} contains a condition that cannor be evaluated by Home Assistant" + "scenario_condition": { + "title": "Invalid Condition for Scenario", + "description": "Scenario definition {scenario} contains a condition that cannor be evaluated by Home Assistant" }, - "delivery_unknown_transport":{ - "title":"Unknown Transport for Delivery", - "description":"Delivery configuration {delivery} refers to a transport {transport} that is not provided by SuperNotify" + "delivery_unknown_transport": { + "title": "Unknown Transport for Delivery", + "description": "Delivery configuration {delivery} refers to a transport {transport} that is not provided by SuperNotify" }, - "media_path":{ - "title":"Media Path Cannot be Used", - "description":"A path ({path}) has been provided for holding media such as camera snapshots during notifications but cannot be used (error: {error})" + "media_path": { + "title": "Media Path Cannot be Used", + "description": "A path ({path}) has been provided for holding media such as camera snapshots during notifications but cannot be used (error: {error})" }, - "delivery_reserved_name":{ - "title":"Delivery Has Reserved Delivery Name", - "description":"The delivery {delivery} name is reserved for Supernotify use" + "delivery_reserved_name": { + "title": "Delivery Has Reserved Delivery Name", + "description": "The delivery {delivery} name is reserved for Supernotify use" }, - "delivery_invalid_action":{ - "title":"Delivery Has Invalid Action", - "description":"The delivery {delivery} has an invalid action {action}" + "delivery_invalid_action": { + "title": "Delivery Has Invalid Action", + "description": "The delivery {delivery} has an invalid action {action}" }, - "delivery_invalid_condition":{ - "title":"Delivery Has Invalid Condition", - "description":"The {delivery} delivery has an invalid condition {condition}" + "delivery_invalid_condition": { + "title": "Delivery Has Invalid Condition", + "description": "The {delivery} delivery has an invalid condition {condition}" + } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "Same message & title, same or lower priority", + "dupe_policy_message_title_same": "Same message & title", + "dupe_policy_none": "Disabled" + } } } } diff --git a/custom_components/supernotify/translations/es.json b/custom_components/supernotify/translations/es.json index 3b2fc005e..072ed0c8d 100644 --- a/custom_components/supernotify/translations/es.json +++ b/custom_components/supernotify/translations/es.json @@ -13,18 +13,54 @@ "config": { "step": { "user": { - "title": "Entorno", + "title": "SuperNotify", "data": { "template_path": "Ruta de plantillas", "media_path": "Ruta de medios", - "archive_path": "Ruta de archivos" + "media_url_prefix": "Prefijo de URL de medios", + "mobile_discovery": "Detección automática de apps móviles", + "recipients_discovery": "Detección automática de destinatarios", + "archive_enabled": "Habilitar archivo de notificaciones", + "archive_days": "Retención de archivo (días)", + "dupe_ttl": "Ventana de duplicados (segundos)", + "dupe_size": "Tamaño de caché de duplicados", + "dupe_policy": "Política de duplicados", + "snooze_seconds": "Posposición predeterminada (segundos)", + "media_storage_days": "Retención de medios (días)" }, "data_description": { "template_path": "Ubicación opcional de las plantillas Jinja2 para correos electrónicos HTML", - "media_path": "Ruta de almacenamiento para los medios creados durante las notificaciones, por ejemplo capturas de cámara para adjuntos", - "archive_path": "Ruta de almacenamiento para las notificaciones archivadas, útil para depurar configuración, escenarios, etc." + "media_path": "Ruta de almacenamiento para los medios creados durante las notificaciones, por ejemplo capturas de cámara para adjuntos" + }, + "description": "Configura los ajustes globales de SuperNotify. Las entregas y los escenarios siguen configurándose en YAML por ahora y tendrán su propia interfaz en una fase posterior." + }, + "reconfigure": { + "title": "Ajustes de SuperNotify", + "description": "Actualiza los ajustes globales de SuperNotify. Las entregas y los escenarios se gestionan por separado.", + "data": { + "template_path": "Ruta de plantillas", + "media_path": "Ruta de medios", + "media_url_prefix": "Prefijo de URL de medios", + "mobile_discovery": "Detección automática de apps móviles", + "recipients_discovery": "Detección automática de destinatarios", + "archive_enabled": "Habilitar archivo de notificaciones", + "archive_days": "Retención de archivo (días)", + "dupe_ttl": "Ventana de duplicados (segundos)", + "dupe_size": "Tamaño de caché de duplicados", + "dupe_policy": "Política de duplicados", + "snooze_seconds": "Posposición predeterminada (segundos)", + "media_storage_days": "Retención de medios (días)" + }, + "data_description": { + "template_path": "Ubicación opcional de las plantillas Jinja2 para correos electrónicos HTML", + "media_path": "Ruta de almacenamiento para los medios creados durante las notificaciones, por ejemplo capturas de cámara para adjuntos" } } + }, + "abort": { + "already_configured": "SuperNotify ya está configurado. Solo se admite una instancia.", + "reconfigure_successful": "Ajustes de SuperNotify actualizados.", + "unique_id_mismatch": "Esto no coincide con la configuración existente de SuperNotify." } }, "issues": { @@ -60,5 +96,14 @@ "title": "La entrega tiene una condición no válida", "description": "La entrega {delivery} tiene una condición no válida {condition}" } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "Mismo mensaje y título, prioridad igual o inferior", + "dupe_policy_message_title_same": "Mismo mensaje y título", + "dupe_policy_none": "Desactivada" + } + } } } diff --git a/custom_components/supernotify/translations/fr.json b/custom_components/supernotify/translations/fr.json index 538491716..f5f2cef35 100644 --- a/custom_components/supernotify/translations/fr.json +++ b/custom_components/supernotify/translations/fr.json @@ -13,18 +13,54 @@ "config": { "step": { "user": { - "title": "Environnement", + "title": "SuperNotify", "data": { "template_path": "Chemin des modèles", "media_path": "Chemin des médias", - "archive_path": "Chemin des archives" + "media_url_prefix": "Préfixe d'URL des médias", + "mobile_discovery": "Détection automatique des applications mobiles", + "recipients_discovery": "Détection automatique des destinataires", + "archive_enabled": "Activer l'archivage des notifications", + "archive_days": "Conservation des archives (jours)", + "dupe_ttl": "Fenêtre anti-doublons (secondes)", + "dupe_size": "Taille du cache des doublons", + "dupe_policy": "Politique des doublons", + "snooze_seconds": "Mise en veille par défaut (secondes)", + "media_storage_days": "Conservation des médias (jours)" }, "data_description": { "template_path": "Emplacement optionnel des modèles Jinja2 pour les e-mails HTML", - "media_path": "Chemin de stockage pour les médias créés lors des notifications, par exemple les captures de caméra pour les pièces jointes", - "archive_path": "Chemin de stockage pour les notifications archivées, utile pour déboguer la configuration, les scénarios, etc." + "media_path": "Chemin de stockage pour les médias créés lors des notifications, par exemple les captures de caméra pour les pièces jointes" + }, + "description": "Configurer les paramètres globaux de SuperNotify. Les livraisons et les scénarios restent pour l'instant configurés en YAML et auront leur propre interface dans une phase ultérieure." + }, + "reconfigure": { + "title": "Paramètres SuperNotify", + "description": "Mettre à jour les paramètres globaux de SuperNotify. Les livraisons et les scénarios se gèrent séparément.", + "data": { + "template_path": "Chemin des modèles", + "media_path": "Chemin des médias", + "media_url_prefix": "Préfixe d'URL des médias", + "mobile_discovery": "Détection automatique des applications mobiles", + "recipients_discovery": "Détection automatique des destinataires", + "archive_enabled": "Activer l'archivage des notifications", + "archive_days": "Conservation des archives (jours)", + "dupe_ttl": "Fenêtre anti-doublons (secondes)", + "dupe_size": "Taille du cache des doublons", + "dupe_policy": "Politique des doublons", + "snooze_seconds": "Mise en veille par défaut (secondes)", + "media_storage_days": "Conservation des médias (jours)" + }, + "data_description": { + "template_path": "Emplacement optionnel des modèles Jinja2 pour les e-mails HTML", + "media_path": "Chemin de stockage pour les médias créés lors des notifications, par exemple les captures de caméra pour les pièces jointes" } } + }, + "abort": { + "already_configured": "SuperNotify est déjà configuré. Une seule instance est prise en charge.", + "reconfigure_successful": "Paramètres SuperNotify mis à jour.", + "unique_id_mismatch": "Cela ne correspond pas à la configuration SuperNotify existante." } }, "issues": { @@ -60,5 +96,14 @@ "title": "La livraison a une condition invalide", "description": "La livraison {delivery} a une condition invalide {condition}" } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "Même message et titre, priorité égale ou inférieure", + "dupe_policy_message_title_same": "Même message et titre", + "dupe_policy_none": "Désactivée" + } + } } } diff --git a/custom_components/supernotify/translations/hi.json b/custom_components/supernotify/translations/hi.json index 7796f51df..267f64d72 100644 --- a/custom_components/supernotify/translations/hi.json +++ b/custom_components/supernotify/translations/hi.json @@ -13,18 +13,54 @@ "config": { "step": { "user": { - "title": "पर्यावरण", + "title": "SuperNotify", "data": { "template_path": "टेम्पलेट पथ", "media_path": "मीडिया पथ", - "archive_path": "संग्रह पथ" + "media_url_prefix": "मीडिया URL उपसर्ग", + "mobile_discovery": "मोबाइल ऐप्स स्वतः खोजें", + "recipients_discovery": "प्राप्तकर्ता स्वतः खोजें", + "archive_enabled": "अधिसूचना संग्रह सक्षम करें", + "archive_days": "संग्रह प्रतिधारण (दिन)", + "dupe_ttl": "डुप्लिकेट विंडो (सेकंड)", + "dupe_size": "डुप्लिकेट कैश आकार", + "dupe_policy": "डुप्लिकेट नीति", + "snooze_seconds": "डिफ़ॉल्ट स्नूज़ (सेकंड)", + "media_storage_days": "मीडिया प्रतिधारण (दिन)" }, "data_description": { "template_path": "HTML ईमेल के लिए Jinja2 टेम्पलेट का वैकल्पिक स्थान", - "media_path": "सूचनाओं के दौरान बनाए गए मीडिया के लिए संग्रहण पथ, उदाहरण के लिए संलग्नक के लिए बनाए गए कैमरा स्नैपशॉट", - "archive_path": "संग्रहीत सूचनाओं के लिए संग्रहण पथ, कॉन्फ़िगरेशन, परिदृश्य आदि डीबग करने के लिए उपयोगी" + "media_path": "सूचनाओं के दौरान बनाए गए मीडिया के लिए संग्रहण पथ, उदाहरण के लिए संलग्नक के लिए बनाए गए कैमरा स्नैपशॉट" + }, + "description": "SuperNotify की वैश्विक सेटिंग्स कॉन्फ़िगर करें। डिलीवरी और परिदृश्य फ़िलहाल YAML में ही कॉन्फ़िगर रहते हैं और बाद के चरण में इनका अपना इंटरफ़ेस मिलेगा।" + }, + "reconfigure": { + "title": "SuperNotify सेटिंग्स", + "description": "SuperNotify की वैश्विक सेटिंग्स अपडेट करें। डिलीवरी और परिदृश्य अलग से प्रबंधित किए जाते हैं।", + "data": { + "template_path": "टेम्पलेट पथ", + "media_path": "मीडिया पथ", + "media_url_prefix": "मीडिया URL उपसर्ग", + "mobile_discovery": "मोबाइल ऐप्स स्वतः खोजें", + "recipients_discovery": "प्राप्तकर्ता स्वतः खोजें", + "archive_enabled": "अधिसूचना संग्रह सक्षम करें", + "archive_days": "संग्रह प्रतिधारण (दिन)", + "dupe_ttl": "डुप्लिकेट विंडो (सेकंड)", + "dupe_size": "डुप्लिकेट कैश आकार", + "dupe_policy": "डुप्लिकेट नीति", + "snooze_seconds": "डिफ़ॉल्ट स्नूज़ (सेकंड)", + "media_storage_days": "मीडिया प्रतिधारण (दिन)" + }, + "data_description": { + "template_path": "HTML ईमेल के लिए Jinja2 टेम्पलेट का वैकल्पिक स्थान", + "media_path": "सूचनाओं के दौरान बनाए गए मीडिया के लिए संग्रहण पथ, उदाहरण के लिए संलग्नक के लिए बनाए गए कैमरा स्नैपशॉट" } } + }, + "abort": { + "already_configured": "SuperNotify पहले से कॉन्फ़िगर है। केवल एक इंस्टेंस समर्थित है।", + "reconfigure_successful": "SuperNotify सेटिंग्स अपडेट की गईं।", + "unique_id_mismatch": "यह मौजूदा SuperNotify कॉन्फ़िगरेशन से मेल नहीं खाता।" } }, "issues": { @@ -60,5 +96,14 @@ "title": "डिलीवरी की शर्त अमान्य है", "description": "डिलीवरी {delivery} में एक अमान्य शर्त {condition} है" } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "समान संदेश और शीर्षक, समान या निम्न प्राथमिकता", + "dupe_policy_message_title_same": "समान संदेश और शीर्षक", + "dupe_policy_none": "अक्षम" + } + } } } diff --git a/custom_components/supernotify/translations/it.json b/custom_components/supernotify/translations/it.json index c5cbfd5cb..0bcb398e6 100644 --- a/custom_components/supernotify/translations/it.json +++ b/custom_components/supernotify/translations/it.json @@ -13,18 +13,54 @@ "config": { "step": { "user": { - "title": "Ambiente", + "title": "SuperNotify", "data": { "template_path": "Percorso Template", "media_path": "Percorso Media", - "archive_path": "Percorso Archivio" + "media_url_prefix": "Prefisso URL media", + "mobile_discovery": "Rilevamento automatico app mobile", + "recipients_discovery": "Rilevamento automatico destinatari", + "archive_enabled": "Abilita archivio notifiche", + "archive_days": "Conservazione archivio (giorni)", + "dupe_ttl": "Finestra anti-duplicati (secondi)", + "dupe_size": "Dimensione cache duplicati", + "dupe_policy": "Politica duplicati", + "snooze_seconds": "Snooze predefinito (secondi)", + "media_storage_days": "Conservazione media (giorni)" }, "data_description": { "template_path": "Posizione opzionale dei template Jinja2 per le email HTML", - "media_path": "Percorso di archiviazione per i file multimediali creati durante le notifiche, ad esempio le istantanee delle telecamere create come allegati", - "archive_path": "Percorso di archiviazione per le notifiche archiviate, utile per il debug di configurazione, scenari ecc." + "media_path": "Percorso di archiviazione per i file multimediali creati durante le notifiche, ad esempio le istantanee delle telecamere create come allegati" + }, + "description": "Configura le impostazioni globali di SuperNotify. Delivery e scenari restano per ora in YAML e avranno una propria interfaccia in una fase successiva." + }, + "reconfigure": { + "title": "Impostazioni SuperNotify", + "description": "Aggiorna le impostazioni globali di SuperNotify. Delivery e scenari si gestiscono separatamente.", + "data": { + "template_path": "Percorso Template", + "media_path": "Percorso Media", + "media_url_prefix": "Prefisso URL media", + "mobile_discovery": "Rilevamento automatico app mobile", + "recipients_discovery": "Rilevamento automatico destinatari", + "archive_enabled": "Abilita archivio notifiche", + "archive_days": "Conservazione archivio (giorni)", + "dupe_ttl": "Finestra anti-duplicati (secondi)", + "dupe_size": "Dimensione cache duplicati", + "dupe_policy": "Politica duplicati", + "snooze_seconds": "Snooze predefinito (secondi)", + "media_storage_days": "Conservazione media (giorni)" + }, + "data_description": { + "template_path": "Posizione opzionale dei template Jinja2 per le email HTML", + "media_path": "Percorso di archiviazione per i file multimediali creati durante le notifiche, ad esempio le istantanee delle telecamere create come allegati" } } + }, + "abort": { + "already_configured": "SuperNotify è già configurato. È supportata una sola istanza.", + "reconfigure_successful": "Impostazioni SuperNotify aggiornate.", + "unique_id_mismatch": "Non corrisponde alla configurazione SuperNotify esistente." } }, "issues": { @@ -60,5 +96,14 @@ "title": "La Consegna Ha una Condizione Non Valida", "description": "La consegna {delivery} ha una condizione non valida {condition}" } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "Stesso messaggio e titolo, priorità uguale o inferiore", + "dupe_policy_message_title_same": "Stesso messaggio e titolo", + "dupe_policy_none": "Disattivata" + } + } } } diff --git a/custom_components/supernotify/translations/ja.json b/custom_components/supernotify/translations/ja.json index 73549b17c..1c9c16134 100644 --- a/custom_components/supernotify/translations/ja.json +++ b/custom_components/supernotify/translations/ja.json @@ -13,18 +13,54 @@ "config": { "step": { "user": { - "title": "環境", + "title": "SuperNotify", "data": { "template_path": "テンプレートパス", "media_path": "メディアパス", - "archive_path": "アーカイブパス" + "media_url_prefix": "メディア URL プレフィックス", + "mobile_discovery": "モバイルアプリを自動検出", + "recipients_discovery": "受信者を自動検出", + "archive_enabled": "通知アーカイブを有効化", + "archive_days": "アーカイブ保持日数(日)", + "dupe_ttl": "重複ウィンドウ(秒)", + "dupe_size": "重複キャッシュサイズ", + "dupe_policy": "重複ポリシー", + "snooze_seconds": "既定のスヌーズ(秒)", + "media_storage_days": "メディア保持日数(日)" }, "data_description": { "template_path": "HTMLメール用のJinja2テンプレートの保存場所(オプション)", - "media_path": "通知中に作成されたメディア(添付ファイル用のカメラスナップショットなど)の保存パス", - "archive_path": "アーカイブされた通知の保存パス。設定やシナリオのデバッグに役立ちます" + "media_path": "通知中に作成されたメディア(添付ファイル用のカメラスナップショットなど)の保存パス" + }, + "description": "SuperNotify のグローバル設定を行います。配信とシナリオは当面 YAML で設定し、後のフェーズで専用の UI が用意されます。" + }, + "reconfigure": { + "title": "SuperNotify の設定", + "description": "SuperNotify のグローバル設定を更新します。配信とシナリオは個別に管理されます。", + "data": { + "template_path": "テンプレートパス", + "media_path": "メディアパス", + "media_url_prefix": "メディア URL プレフィックス", + "mobile_discovery": "モバイルアプリを自動検出", + "recipients_discovery": "受信者を自動検出", + "archive_enabled": "通知アーカイブを有効化", + "archive_days": "アーカイブ保持日数(日)", + "dupe_ttl": "重複ウィンドウ(秒)", + "dupe_size": "重複キャッシュサイズ", + "dupe_policy": "重複ポリシー", + "snooze_seconds": "既定のスヌーズ(秒)", + "media_storage_days": "メディア保持日数(日)" + }, + "data_description": { + "template_path": "HTMLメール用のJinja2テンプレートの保存場所(オプション)", + "media_path": "通知中に作成されたメディア(添付ファイル用のカメラスナップショットなど)の保存パス" } } + }, + "abort": { + "already_configured": "SuperNotify は既に構成されています。サポートされるインスタンスは 1 つだけです。", + "reconfigure_successful": "SuperNotify の設定を更新しました。", + "unique_id_mismatch": "既存の SuperNotify 構成と一致しません。" } }, "issues": { @@ -60,5 +96,14 @@ "title": "配信の条件が無効です", "description": "配信 {delivery} には無効な条件 {condition} があります" } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "同じメッセージとタイトル、同等以下の優先度", + "dupe_policy_message_title_same": "同じメッセージとタイトル", + "dupe_policy_none": "無効" + } + } } } diff --git a/custom_components/supernotify/translations/nl.json b/custom_components/supernotify/translations/nl.json index d087d3de1..141abffd4 100644 --- a/custom_components/supernotify/translations/nl.json +++ b/custom_components/supernotify/translations/nl.json @@ -13,18 +13,54 @@ "config": { "step": { "user": { - "title": "Omgeving", + "title": "SuperNotify", "data": { "template_path": "Sjabloonpad", "media_path": "Mediapad", - "archive_path": "Archiefpad" + "media_url_prefix": "Media-URL-voorvoegsel", + "mobile_discovery": "Mobiele apps automatisch detecteren", + "recipients_discovery": "Ontvangers automatisch detecteren", + "archive_enabled": "Notificatiearchief inschakelen", + "archive_days": "Archiefbewaring (dagen)", + "dupe_ttl": "Duplicaatvenster (seconden)", + "dupe_size": "Grootte duplicaatcache", + "dupe_policy": "Duplicaatbeleid", + "snooze_seconds": "Standaard sluimertijd (seconden)", + "media_storage_days": "Mediabewaring (dagen)" }, "data_description": { "template_path": "Optionele locatie voor Jinja2-sjablonen voor HTML-e-mails", - "media_path": "Opslagpad voor media die tijdens meldingen zijn aangemaakt, zoals camera-snapshots voor bijlagen", - "archive_path": "Opslagpad voor gearchiveerde meldingen, handig voor het debuggen van configuratie, scenario's enz." + "media_path": "Opslagpad voor media die tijdens meldingen zijn aangemaakt, zoals camera-snapshots voor bijlagen" + }, + "description": "Globale SuperNotify-instellingen instellen. Bezorgingen en scenario's worden voorlopig nog in YAML geconfigureerd en krijgen in een latere fase een eigen interface." + }, + "reconfigure": { + "title": "SuperNotify-instellingen", + "description": "Werk de globale SuperNotify-instellingen bij. Bezorgingen en scenario's worden apart beheerd.", + "data": { + "template_path": "Sjabloonpad", + "media_path": "Mediapad", + "media_url_prefix": "Media-URL-voorvoegsel", + "mobile_discovery": "Mobiele apps automatisch detecteren", + "recipients_discovery": "Ontvangers automatisch detecteren", + "archive_enabled": "Notificatiearchief inschakelen", + "archive_days": "Archiefbewaring (dagen)", + "dupe_ttl": "Duplicaatvenster (seconden)", + "dupe_size": "Grootte duplicaatcache", + "dupe_policy": "Duplicaatbeleid", + "snooze_seconds": "Standaard sluimertijd (seconden)", + "media_storage_days": "Mediabewaring (dagen)" + }, + "data_description": { + "template_path": "Optionele locatie voor Jinja2-sjablonen voor HTML-e-mails", + "media_path": "Opslagpad voor media die tijdens meldingen zijn aangemaakt, zoals camera-snapshots voor bijlagen" } } + }, + "abort": { + "already_configured": "SuperNotify is al geconfigureerd. Er wordt slechts één instantie ondersteund.", + "reconfigure_successful": "SuperNotify-instellingen bijgewerkt.", + "unique_id_mismatch": "Dit komt niet overeen met de bestaande SuperNotify-configuratie." } }, "issues": { @@ -60,5 +96,14 @@ "title": "Bezorging heeft een ongeldige voorwaarde", "description": "Bezorging {delivery} heeft een ongeldige voorwaarde {condition}" } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "Zelfde bericht en titel, gelijke of lagere prioriteit", + "dupe_policy_message_title_same": "Zelfde bericht en titel", + "dupe_policy_none": "Uitgeschakeld" + } + } } } diff --git a/custom_components/supernotify/translations/pl.json b/custom_components/supernotify/translations/pl.json index 41a260437..21bbebde7 100644 --- a/custom_components/supernotify/translations/pl.json +++ b/custom_components/supernotify/translations/pl.json @@ -13,18 +13,54 @@ "config": { "step": { "user": { - "title": "Środowisko", + "title": "SuperNotify", "data": { "template_path": "Ścieżka szablonu", "media_path": "Ścieżka mediów", - "archive_path": "Ścieżka archiwum" + "media_url_prefix": "Prefiks adresu URL multimediów", + "mobile_discovery": "Automatyczne wykrywanie aplikacji mobilnych", + "recipients_discovery": "Automatyczne wykrywanie odbiorców", + "archive_enabled": "Włącz archiwum powiadomień", + "archive_days": "Przechowywanie archiwum (dni)", + "dupe_ttl": "Okno duplikatów (sekundy)", + "dupe_size": "Rozmiar pamięci podręcznej duplikatów", + "dupe_policy": "Zasady duplikatów", + "snooze_seconds": "Domyślna drzemka (sekundy)", + "media_storage_days": "Przechowywanie multimediów (dni)" }, "data_description": { "template_path": "Opcjonalna lokalizacja szablonów Jinja2 dla wiadomości e-mail HTML", - "media_path": "Ścieżka przechowywania mediów utworzonych podczas powiadomień, na przykład zdjęć z kamer do załączników", - "archive_path": "Ścieżka przechowywania zarchiwizowanych powiadomień, przydatna do debugowania konfiguracji, scenariuszy itp." + "media_path": "Ścieżka przechowywania mediów utworzonych podczas powiadomień, na przykład zdjęć z kamer do załączników" + }, + "description": "Skonfiguruj globalne ustawienia SuperNotify. Dostawy i scenariusze są na razie nadal konfigurowane w YAML i otrzymają własny interfejs w późniejszej fazie." + }, + "reconfigure": { + "title": "Ustawienia SuperNotify", + "description": "Zaktualizuj globalne ustawienia SuperNotify. Dostawy i scenariusze są zarządzane osobno.", + "data": { + "template_path": "Ścieżka szablonu", + "media_path": "Ścieżka mediów", + "media_url_prefix": "Prefiks adresu URL multimediów", + "mobile_discovery": "Automatyczne wykrywanie aplikacji mobilnych", + "recipients_discovery": "Automatyczne wykrywanie odbiorców", + "archive_enabled": "Włącz archiwum powiadomień", + "archive_days": "Przechowywanie archiwum (dni)", + "dupe_ttl": "Okno duplikatów (sekundy)", + "dupe_size": "Rozmiar pamięci podręcznej duplikatów", + "dupe_policy": "Zasady duplikatów", + "snooze_seconds": "Domyślna drzemka (sekundy)", + "media_storage_days": "Przechowywanie multimediów (dni)" + }, + "data_description": { + "template_path": "Opcjonalna lokalizacja szablonów Jinja2 dla wiadomości e-mail HTML", + "media_path": "Ścieżka przechowywania mediów utworzonych podczas powiadomień, na przykład zdjęć z kamer do załączników" } } + }, + "abort": { + "already_configured": "SuperNotify jest już skonfigurowany. Obsługiwana jest tylko jedna instancja.", + "reconfigure_successful": "Zaktualizowano ustawienia SuperNotify.", + "unique_id_mismatch": "To nie pasuje do istniejącej konfiguracji SuperNotify." } }, "issues": { @@ -60,5 +96,14 @@ "title": "Dostarczenie ma nieprawidłowy warunek", "description": "Dostarczenie {delivery} ma nieprawidłowy warunek {condition}" } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "Ta sama wiadomość i tytuł, taki sam lub niższy priorytet", + "dupe_policy_message_title_same": "Ta sama wiadomość i tytuł", + "dupe_policy_none": "Wyłączone" + } + } } } diff --git a/custom_components/supernotify/translations/pt.json b/custom_components/supernotify/translations/pt.json index 2f11cd6f3..8d9ae77f6 100644 --- a/custom_components/supernotify/translations/pt.json +++ b/custom_components/supernotify/translations/pt.json @@ -13,18 +13,54 @@ "config": { "step": { "user": { - "title": "Ambiente", + "title": "SuperNotify", "data": { "template_path": "Caminho do modelo", "media_path": "Caminho de mídia", - "archive_path": "Caminho do arquivo" + "media_url_prefix": "Prefixo de URL de média", + "mobile_discovery": "Deteção automática de apps móveis", + "recipients_discovery": "Deteção automática de destinatários", + "archive_enabled": "Ativar arquivo de notificações", + "archive_days": "Retenção de arquivo (dias)", + "dupe_ttl": "Janela de duplicados (segundos)", + "dupe_size": "Tamanho da cache de duplicados", + "dupe_policy": "Política de duplicados", + "snooze_seconds": "Adiamento predefinido (segundos)", + "media_storage_days": "Retenção de média (dias)" }, "data_description": { "template_path": "Localização opcional dos modelos Jinja2 para e-mails HTML", - "media_path": "Caminho de armazenamento para mídia criada durante notificações, por exemplo capturas de câmera para anexos", - "archive_path": "Caminho de armazenamento para notificações arquivadas, útil para depurar configuração, cenários etc." + "media_path": "Caminho de armazenamento para mídia criada durante notificações, por exemplo capturas de câmera para anexos" + }, + "description": "Configure as definições globais do SuperNotify. As entregas e os cenários continuam configurados em YAML por enquanto e terão a sua própria interface numa fase posterior." + }, + "reconfigure": { + "title": "Definições do SuperNotify", + "description": "Atualize as definições globais do SuperNotify. As entregas e os cenários são geridos separadamente.", + "data": { + "template_path": "Caminho do modelo", + "media_path": "Caminho de mídia", + "media_url_prefix": "Prefixo de URL de média", + "mobile_discovery": "Deteção automática de apps móveis", + "recipients_discovery": "Deteção automática de destinatários", + "archive_enabled": "Ativar arquivo de notificações", + "archive_days": "Retenção de arquivo (dias)", + "dupe_ttl": "Janela de duplicados (segundos)", + "dupe_size": "Tamanho da cache de duplicados", + "dupe_policy": "Política de duplicados", + "snooze_seconds": "Adiamento predefinido (segundos)", + "media_storage_days": "Retenção de média (dias)" + }, + "data_description": { + "template_path": "Localização opcional dos modelos Jinja2 para e-mails HTML", + "media_path": "Caminho de armazenamento para mídia criada durante notificações, por exemplo capturas de câmera para anexos" } } + }, + "abort": { + "already_configured": "O SuperNotify já está configurado. Apenas uma instância é suportada.", + "reconfigure_successful": "Definições do SuperNotify atualizadas.", + "unique_id_mismatch": "Isto não corresponde à configuração existente do SuperNotify." } }, "issues": { @@ -60,5 +96,14 @@ "title": "A entrega tem uma condição inválida", "description": "A entrega {delivery} tem uma condição inválida {condition}" } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "Mesma mensagem e título, prioridade igual ou inferior", + "dupe_policy_message_title_same": "Mesma mensagem e título", + "dupe_policy_none": "Desativada" + } + } } } diff --git a/custom_components/supernotify/translations/zh-Hans.json b/custom_components/supernotify/translations/zh-Hans.json index 9128d97a3..cd301d957 100644 --- a/custom_components/supernotify/translations/zh-Hans.json +++ b/custom_components/supernotify/translations/zh-Hans.json @@ -13,18 +13,54 @@ "config": { "step": { "user": { - "title": "环境", + "title": "SuperNotify", "data": { "template_path": "模板路径", "media_path": "媒体路径", - "archive_path": "存档路径" + "media_url_prefix": "媒体 URL 前缀", + "mobile_discovery": "自动发现移动应用", + "recipients_discovery": "自动发现收件人", + "archive_enabled": "启用通知归档", + "archive_days": "归档保留(天)", + "dupe_ttl": "去重窗口(秒)", + "dupe_size": "去重缓存大小", + "dupe_policy": "去重策略", + "snooze_seconds": "默认暂停(秒)", + "media_storage_days": "媒体保留(天)" }, "data_description": { "template_path": "HTML 电子邮件的 Jinja2 模板的可选存储位置", - "media_path": "通知期间创建的媒体(例如附件的摄像头快照)的存储路径", - "archive_path": "已存档通知的存储路径,用于调试配置、场景等" + "media_path": "通知期间创建的媒体(例如附件的摄像头快照)的存储路径" + }, + "description": "配置 SuperNotify 的全局设置。投递和场景目前仍在 YAML 中配置,将在后续阶段拥有各自的界面。" + }, + "reconfigure": { + "title": "SuperNotify 设置", + "description": "更新 SuperNotify 的全局设置。投递和场景单独管理。", + "data": { + "template_path": "模板路径", + "media_path": "媒体路径", + "media_url_prefix": "媒体 URL 前缀", + "mobile_discovery": "自动发现移动应用", + "recipients_discovery": "自动发现收件人", + "archive_enabled": "启用通知归档", + "archive_days": "归档保留(天)", + "dupe_ttl": "去重窗口(秒)", + "dupe_size": "去重缓存大小", + "dupe_policy": "去重策略", + "snooze_seconds": "默认暂停(秒)", + "media_storage_days": "媒体保留(天)" + }, + "data_description": { + "template_path": "HTML 电子邮件的 Jinja2 模板的可选存储位置", + "media_path": "通知期间创建的媒体(例如附件的摄像头快照)的存储路径" } } + }, + "abort": { + "already_configured": "SuperNotify 已配置。仅支持一个实例。", + "reconfigure_successful": "SuperNotify 设置已更新。", + "unique_id_mismatch": "这与现有的 SuperNotify 配置不匹配。" } }, "issues": { @@ -60,5 +96,14 @@ "title": "推送的条件无效", "description": "推送 {delivery} 包含无效条件 {condition}" } + }, + "selector": { + "dupe_policy": { + "options": { + "dupe_policy_message_title_same_or_lower_priority": "相同消息和标题,相同或更低优先级", + "dupe_policy_message_title_same": "相同消息和标题", + "dupe_policy_none": "已禁用" + } + } } } diff --git a/tests/components/supernotify/test_config_flow.py b/tests/components/supernotify/test_config_flow.py new file mode 100755 index 000000000..94677a2d8 --- /dev/null +++ b/tests/components/supernotify/test_config_flow.py @@ -0,0 +1,425 @@ +"""Tests for the SuperNotify config flow (Phase 1). + +Covers: + * async_step_user — form display, entry creation with correct nested mapping, + single-instance abort. + * async_step_import — YAML import, marker handling, global/items split. + * pure helpers — _split_global_vs_items, _form_to_entry_data. + +Pattern: pytest-homeassistant-custom-component (already a dev dependency). +File path in the package: tests/components/supernotify/test_config_flow.py +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_ENABLED +from homeassistant.data_entry_flow import FlowResultType +from pytest_homeassistant_custom_component.common import MockConfigEntry + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + +from custom_components.supernotify import ATTR_IMPORTED_FROM_YAML, DOMAIN +from custom_components.supernotify.config_flow import ( + _form_to_entry_data, + _split_global_vs_items, +) +from custom_components.supernotify.const import ( + ATTR_DUPE_POLICY_MT, + ATTR_DUPE_POLICY_MTSLP, + CONF_ARCHIVE, + CONF_ARCHIVE_DAYS, + CONF_DUPE_CHECK, + CONF_DUPE_POLICY, + CONF_HOUSEKEEPING, + CONF_MEDIA_PATH, + CONF_MEDIA_STORAGE_DAYS, + CONF_MEDIA_URL_PREFIX, + CONF_MOBILE_DISCOVERY, + CONF_RECIPIENTS_DISCOVERY, + CONF_SIZE, + CONF_SNOOZE, + CONF_SNOOZE_TIME, + CONF_TEMPLATE_PATH, + CONF_TTL, +) + +pytestmark = pytest.mark.usefixtures("enable_custom_integrations") + + +_VALID_USER_INPUT = { + CONF_TEMPLATE_PATH: "/config/templates/supernotify", + CONF_MEDIA_PATH: "supernotify/media", + CONF_MEDIA_URL_PREFIX: "/supernotify/media", + CONF_MOBILE_DISCOVERY: True, + CONF_RECIPIENTS_DISCOVERY: False, + "archive_enabled": True, + "archive_days": 5, + "dupe_ttl": 90, + "dupe_size": 50, + "dupe_policy": ATTR_DUPE_POLICY_MT, + "snooze_seconds": 1800, + "media_storage_days": 10, +} + + +# --------------------------------------------------------------------------- # +# async_step_user +# --------------------------------------------------------------------------- # +async def test_user_step_shows_form(hass: HomeAssistant) -> None: + """The user step shows a form when called without input.""" + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_user_step_creates_entry_with_nested_mapping(hass: HomeAssistant) -> None: + """Valid input creates an entry; flat form fields map to nested structure.""" + init = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) + result = await hass.config_entries.flow.async_configure(init["flow_id"], _VALID_USER_INPUT) + + assert result["type"] is FlowResultType.CREATE_ENTRY + data = result["data"] + assert data[CONF_TEMPLATE_PATH] == "/config/templates/supernotify" + assert data[CONF_MOBILE_DISCOVERY] is True + assert data[CONF_RECIPIENTS_DISCOVERY] is False + # Nested groups rebuilt from flat fields: + assert data[CONF_ARCHIVE] == {CONF_ENABLED: True, CONF_ARCHIVE_DAYS: 5} + assert data[CONF_DUPE_CHECK] == { + CONF_TTL: 90, + CONF_SIZE: 50, + CONF_DUPE_POLICY: ATTR_DUPE_POLICY_MT, + } + assert data[CONF_SNOOZE] == {CONF_SNOOZE_TIME: 1800} + assert data[CONF_HOUSEKEEPING] == {CONF_MEDIA_STORAGE_DAYS: 10} + + +async def test_user_step_single_instance(hass: HomeAssistant) -> None: + """A second setup is aborted — only one SuperNotify instance is supported.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) + assert result["type"] is FlowResultType.ABORT + # single_config_entry: true in the manifest makes HA core abort with + # "single_instance_allowed" BEFORE the flow's unique_id guard + # (which would abort with "already_configured") is reached. + assert result["reason"] == "single_instance_allowed" + + +# --------------------------------------------------------------------------- # +# async_step_import +# --------------------------------------------------------------------------- # +async def test_import_splits_global_and_items_and_keeps_marker( + hass: HomeAssistant, +) -> None: + """YAML import: globals into data, item collections into options, marker kept.""" + import_data = { + CONF_TEMPLATE_PATH: "/config/templates/supernotify", + CONF_MOBILE_DISCOVERY: True, + "delivery": {"alexa_announce": {"transport": "alexa"}}, + "scenarios": {"morning": {}}, + ATTR_IMPORTED_FROM_YAML: True, + } + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data=import_data) + + assert result["type"] is FlowResultType.CREATE_ENTRY + data = result["data"] + options = result["options"] + # Marker preserved so async_setup_entry won't double-load the service. + assert data[ATTR_IMPORTED_FROM_YAML] is True + # Globals in data, items NOT in data. + assert data[CONF_TEMPLATE_PATH] == "/config/templates/supernotify" + assert "delivery" not in data + # Item collections preserved verbatim in options. + assert options["delivery"] == {"alexa_announce": {"transport": "alexa"}} + assert options["scenarios"] == {"morning": {}} + + +async def test_import_without_marker_defaults_false(hass: HomeAssistant) -> None: + """Import with no marker stores imported_from_yaml=False.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_TEMPLATE_PATH: "/config/templates/supernotify"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][ATTR_IMPORTED_FROM_YAML] is False + + +async def test_import_single_instance(hass: HomeAssistant) -> None: + """A second import is aborted (single instance).""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ATTR_IMPORTED_FROM_YAML: True}, + ) + assert result["type"] is FlowResultType.ABORT + # See test_user_step_single_instance: HA core aborts single_config_entry + # integrations with "single_instance_allowed" (import flows included). + assert result["reason"] == "single_instance_allowed" + + +# --------------------------------------------------------------------------- # +# form schema structure +# --------------------------------------------------------------------------- # +def _schema_fields(result) -> set[str]: + """Field names exposed by a form's data_schema.""" + return {marker.schema for marker in result["data_schema"].schema} + + +def _schema_defaults(result) -> dict: + """Default values pre-filled in a form's data_schema.""" + return {marker.schema: marker.default() for marker in result["data_schema"].schema if marker.default is not vol.UNDEFINED} + + +_EXPECTED_FIELDS = { + CONF_TEMPLATE_PATH, + CONF_MEDIA_PATH, + CONF_MEDIA_URL_PREFIX, + CONF_MOBILE_DISCOVERY, + CONF_RECIPIENTS_DISCOVERY, + "archive_enabled", + "archive_days", + "dupe_ttl", + "dupe_size", + "dupe_policy", + "snooze_seconds", + "media_storage_days", +} + + +async def test_user_form_exposes_all_global_fields(hass: HomeAssistant) -> None: + """The user form schema contains every global-settings field.""" + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) + assert _schema_fields(result) >= _EXPECTED_FIELDS + + +async def test_reconfigure_form_prefilled_from_entry(hass: HomeAssistant) -> None: + """Pre-fill the reconfigure form with the entry's current values. + + Not the schema defaults. + """ + from custom_components.supernotify.config_flow import _form_to_entry_data + + initial = _form_to_entry_data(_VALID_USER_INPUT) # archive_days == 5 + initial[ATTR_IMPORTED_FROM_YAML] = True + entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=initial) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + defaults = _schema_defaults(result) + # Current entry value (5), not the schema default (3): + assert defaults["archive_days"] == 5 + assert defaults[CONF_TEMPLATE_PATH] == "/config/templates/supernotify" + assert defaults[CONF_MOBILE_DISCOVERY] is True + + +# --------------------------------------------------------------------------- # +# async_step_reconfigure +# --------------------------------------------------------------------------- # +async def test_reconfigure_updates_entry_in_place(hass: HomeAssistant) -> None: + """Reconfigure updates the existing entry in place. + + It does not create a new one, and preserves the imported-from-YAML marker. + """ + from custom_components.supernotify.config_flow import _form_to_entry_data + from custom_components.supernotify.const import CONF_ARCHIVE_DAYS + + initial = _form_to_entry_data(_VALID_USER_INPUT) + initial[ATTR_IMPORTED_FROM_YAML] = True + entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=initial) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + new_input = dict(_VALID_USER_INPUT) + new_input["archive_days"] = 9 + result = await hass.config_entries.flow.async_configure(result["flow_id"], new_input) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + # No second entry created. + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + # Data updated in place. + assert entry.data[CONF_ARCHIVE] == {CONF_ENABLED: True, CONF_ARCHIVE_DAYS: 9} + # Marker preserved through the update. + assert entry.data[ATTR_IMPORTED_FROM_YAML] is True + + +# --------------------------------------------------------------------------- # +# pure helpers (no hass required) +# --------------------------------------------------------------------------- # +def test_split_global_vs_items() -> None: + """Item keys go to options, everything else stays global.""" + config = { + CONF_TEMPLATE_PATH: "/x", + CONF_MOBILE_DISCOVERY: True, + "delivery": {"d": {}}, + "scenarios": {"s": {}}, + "recipients": [{"person": "person.a"}], + "cameras": [], + "links": [], + "action_groups": {}, + "transports": {}, + } + global_data, items = _split_global_vs_items(config) + + assert global_data == {CONF_TEMPLATE_PATH: "/x", CONF_MOBILE_DISCOVERY: True} + assert set(items) == { + "delivery", + "scenarios", + "recipients", + "cameras", + "links", + "action_groups", + "transports", + } + assert items["delivery"] == {"d": {}} + + +def test_form_to_entry_data_builds_nested_groups() -> None: + """Flat form fields are rebuilt into the nested config the runtime expects.""" + data = _form_to_entry_data(_VALID_USER_INPUT) + + assert data[CONF_ARCHIVE] == {CONF_ENABLED: True, CONF_ARCHIVE_DAYS: 5} + assert data[CONF_DUPE_CHECK] == { + CONF_TTL: 90, + CONF_SIZE: 50, + CONF_DUPE_POLICY: ATTR_DUPE_POLICY_MT, + } + assert data[CONF_SNOOZE] == {CONF_SNOOZE_TIME: 1800} + assert data[CONF_HOUSEKEEPING] == {CONF_MEDIA_STORAGE_DAYS: 10} + # No stray flat keys leaked into the nested structure. + assert "archive_enabled" not in data + assert "dupe_ttl" not in data + # Default dupe policy constant is a valid choice (sanity). + assert ATTR_DUPE_POLICY_MTSLP # imported/used marker + + +def test_entry_data_to_form_round_trips() -> None: + """_entry_data_to_form is the inverse of _form_to_entry_data.""" + from custom_components.supernotify.config_flow import _entry_data_to_form + + nested = _form_to_entry_data(_VALID_USER_INPUT) + flat = _entry_data_to_form(nested) + assert flat == _VALID_USER_INPUT + + +def test_entry_data_to_form_uses_defaults_when_empty() -> None: + """With empty data, the flat form falls back to schema defaults.""" + from custom_components.supernotify.config_flow import _entry_data_to_form + + flat = _entry_data_to_form({}) + assert flat["archive_enabled"] is False + assert flat["archive_days"] == 3 + assert flat["dupe_ttl"] == 120 + assert flat["snooze_seconds"] == 3600 + assert flat["media_storage_days"] == 7 + + +# --------------------------------------------------------------------------- # +# _jsonify — regression for "Type is not JSON serializable" on entry storage +# --------------------------------------------------------------------------- # +# SUPERNOTIFY_SCHEMA validates the imported YAML and coerces some delivery fields +# into non-JSON-serializable objects (Template, the MessageOnlyPolicy enum, a +# ConditionsChecker instance). Storing those in the config entry raised +# "Type is not JSON serializable: Template" the moment a subentry was added or the +# entry was reconfigured. _jsonify converts every non-primitive to a string so the +# entry can be persisted. These tests guard that fix. +def test_jsonify_makes_validated_config_serializable() -> None: + """_jsonify converts Template/enum/objects to strings, recursively. + + Primitives, dicts and lists stay intact; the result must be JSON-serializable. + """ + import json + from enum import Enum + + from custom_components.supernotify.config_flow import _jsonify + + class _Policy(Enum): + STANDARD = "STANDARD" + + class _ConditionsChecker: # stand-in for the runtime object + pass + + class _FakeTemplate: # mimics homeassistant Template: has a .template str + def __init__(self, template: str) -> None: + self.template = template + + raw = { + "delivery": { + "alexa": { + "volume": _FakeTemplate("{{ 0.2 }}"), + "message_usage": _Policy.STANDARD, + "conditions": _ConditionsChecker(), + "enabled": True, + "priority": ["high", "low"], + "nested": {"msg": _FakeTemplate("{{ notification_message }}")}, + } + }, + "count": 5, + "flag": False, + "empty": None, + } + out = _jsonify(raw) + + # The whole structure is now storable. + json.dumps(out) # must not raise + + alexa = out["delivery"]["alexa"] + assert alexa["volume"] == "{{ 0.2 }}" # Template -> source string + assert isinstance(alexa["message_usage"], str) # enum -> str + assert isinstance(alexa["conditions"], str) # object -> str + assert alexa["enabled"] is True # primitive preserved + assert alexa["priority"] == ["high", "low"] # list preserved + assert alexa["nested"]["msg"] == "{{ notification_message }}" # recursion + assert out["count"] == 5 + assert out["flag"] is False + assert out["empty"] is None + + +def test_jsonify_with_real_homeassistant_template() -> None: + """A real HA Template is reduced to its source string (not str(obj)).""" + from homeassistant.helpers.template import Template + + from custom_components.supernotify.config_flow import _jsonify + + out = _jsonify({"volume": Template("{{ 0.3 }}")}) + assert out["volume"] == "{{ 0.3 }}" + + +async def test_import_jsonifies_non_serializable_delivery(hass: HomeAssistant) -> None: + """Import of YAML with a Template yields JSON-serializable entry options. + + Regression for the 'Type is not JSON serializable: Template' crash on the + first entry update after import. + """ + import json + + from homeassistant.helpers.template import Template + + import_data = { + CONF_TEMPLATE_PATH: "/config/templates/supernotify", + "delivery": {"alexa": {"volume": Template("{{ 0.2 }}")}}, + ATTR_IMPORTED_FROM_YAML: True, + } + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data=import_data) + + assert result["type"] is FlowResultType.CREATE_ENTRY + # Both stores must be JSON-serializable, or the first entry update + # (subentry add, reconfigure) crashes the save of the whole entry. + json.dumps(result["data"]) # must not raise + json.dumps(result["options"]) # must not raise + # The Template survives as its source string, not as an object. + assert result["options"]["delivery"]["alexa"]["volume"] == "{{ 0.2 }}" diff --git a/tests/components/supernotify/test_init.py b/tests/components/supernotify/test_init.py new file mode 100644 index 000000000..7b6591e69 --- /dev/null +++ b/tests/components/supernotify/test_init.py @@ -0,0 +1,68 @@ +"""Tests for SuperNotify config-entry setup/unload (Phase 1). + +Convention: logic in __init__.py (async_setup_entry / async_unload_entry) is +tested in test_init.py. File path in the package: +tests/components/supernotify/test_init.py +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ENABLED +from pytest_homeassistant_custom_component.common import MockConfigEntry + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + +from custom_components.supernotify import ATTR_IMPORTED_FROM_YAML, DOMAIN +from custom_components.supernotify.const import ( + CONF_ARCHIVE, + CONF_ARCHIVE_DAYS, + CONF_TEMPLATE_PATH, +) + +pytestmark = pytest.mark.usefixtures("enable_custom_integrations") + + +_IMPORTED_ENTRY_DATA = { + CONF_TEMPLATE_PATH: "/config/templates/supernotify", + CONF_ARCHIVE: {CONF_ENABLED: False, CONF_ARCHIVE_DAYS: 3}, + ATTR_IMPORTED_FROM_YAML: True, +} + + +async def test_setup_and_unload_imported_entry(hass: HomeAssistant) -> None: + """An entry imported from YAML loads (mirroring settings) and unloads cleanly. + + The legacy notify platform owns the service, so async_setup_entry must NOT + forward/reload it — it should simply load the entry to LOADED state. + """ + entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=_IMPORTED_ENTRY_DATA) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_options_update_triggers_reload(hass: HomeAssistant) -> None: + """Updating the entry triggers the update listener (reload), staying LOADED.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=_IMPORTED_ENTRY_DATA) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + hass.config_entries.async_update_entry(entry, options={"delivery": {"x": {}}}) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + +# --------------------------------------------------------------------------- # +# --------------------------------------------------------------------------- # From 9455b780f61143cf8d8c876e0b272e096bd878b9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:02:14 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- custom_components/supernotify/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/supernotify/manifest.json b/custom_components/supernotify/manifest.json index bb7a1a70b..31f3ddb01 100644 --- a/custom_components/supernotify/manifest.json +++ b/custom_components/supernotify/manifest.json @@ -31,4 +31,4 @@ ], "version": "1.16.2", "single_config_entry": true -} \ No newline at end of file +}