From 21dbb7edb6084a6975024a226bcfb46be4191335 Mon Sep 17 00:00:00 2001 From: bharvey88 Date: Thu, 23 Apr 2026 14:32:40 -0500 Subject: [PATCH] feat: auto-create Lovelace view on first setup On first config entry, append a "Zone Mapper" view to the default storage-mode Lovelace dashboard containing a placeholder card. Users no longer have to add the card by hand after installing the integration. - Options flow adds an `auto_create_view` toggle (default true) - YAML-mode dashboards are never rewritten; they log a paste-ready snippet instead - Seeding is gated by a per-entry flag so deleting the view doesn't cause it to be recreated on reload - Handles dashboards with no stored config (ConfigNotFound) by seeding a minimal config that preserves the default auto-generated first view via an "original-states" strategy - Resolves the default dashboard via the "lovelace" key (modern HA) with a fallback to the legacy None key Bumps manifest to 1.1.0. --- .github/workflows/validate.yml | 2 + README.md | 33 ++-- custom_components/zone_mapper/__init__.py | 42 ++++- custom_components/zone_mapper/config_flow.py | 54 +++++- custom_components/zone_mapper/const.py | 10 ++ custom_components/zone_mapper/frontend.py | 163 ++++++++++++++++++ custom_components/zone_mapper/manifest.json | 3 +- .../zone_mapper/translations/en.json | 16 +- 8 files changed, 292 insertions(+), 31 deletions(-) create mode 100644 custom_components/zone_mapper/frontend.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 12ddfec..465191e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -6,9 +6,11 @@ on: - cron: "0 0 * * *" push: branches: + - Main - main pull_request: branches: + - Main - main permissions: {} diff --git a/README.md b/README.md index 75d771a..72392e1 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ Backend for the Zone Mapper Lovelace card. Persists zone definitions and exposes per‑zone occupancy sensors based on tracked X/Y entities. -> [!WARNING] -> This integration requires the Zone Mapper lovelace card for functionality. Install it from HACS or from [the repository.](https://github.com/ApolloAutomation/zone-mapper-card) +Starting with 1.1.0, this integration seeds a "Zone Mapper" view with the card on your default dashboard the first time it's set up, so you don't have to drop the card in by hand. The companion [Zone Mapper card](https://github.com/ApolloAutomation/zone-mapper-card) still needs to be installed separately via HACS. ## Features @@ -14,11 +13,10 @@ Backend for the Zone Mapper Lovelace card. Persists zone definitions and exposes - Restores zones, tracked entities, and rotation after Home Assistant restarts - Listens for and processes updates from the card via a single service - Auto‑discovers and (re)loads platforms at startup based on existing entities +- Seeds a "Zone Mapper" view with the card on the default dashboard the first time the integration is set up (storage‑mode dashboards only, opt‑out available in integration options) ## Installation -There are two ways to install this integration. Both this integration and [the lovelace card](https://github.com/ApolloAutomation/zone-mapper-card) **must** be installed - ### With HACS (Recommended) HACS is like an app store for Home Assistant. It makes installing and updating custom integrations much easier. Here's how to install using HACS: @@ -28,27 +26,22 @@ HACS is like an app store for Home Assistant. It makes installing and updating c - If HACS is not installed yet, download it following the instructions on [https://hacs.xyz/docs/use/download/download/](https://hacs.xyz/docs/use/download/download/) - Follow the HACS initial configuration guide at [https://hacs.xyz/docs/configuration/basic](https://hacs.xyz/docs/configuration/basic) -- **Add this custom repository to HACS:** +- **Add both custom repositories to HACS:** - Go to `HACS` in your Home Assistant sidebar - - CLick on the 3 dots in the upper right corner + - Click on the 3 dots in the upper right corner - Click "Custom repositories" - - Add this URL to the repository: [https://github.com/ApolloAutomation/zone-mapper](https://github.com/ApolloAutomation/zone-mapper) - - Select `Integration` for the type - - Click the `ADD` button + - Add this URL as type `Integration`: [https://github.com/ApolloAutomation/zone-mapper](https://github.com/ApolloAutomation/zone-mapper) + - Add this URL as type `Dashboard`: [https://github.com/ApolloAutomation/zone-mapper-card](https://github.com/ApolloAutomation/zone-mapper-card) - **Install Zone Mapper:** - - Go to `HACS` in your Home Assistant sidebar - - Search for `Zone Mapper` in HACS - - Click on the card when you find it - - Click the `Download` button at the bottom right - - Repeat for lovelace card - - Restart Home Assistant - - Go to `Devices and Services` + - In HACS, search for `Zone Mapper` and download both the integration and the card + - Restart Home Assistant when prompted + - Go to `Settings` → `Devices and Services` - Click `Add Integration` - - Search `Zone Mapper` - - Add `Zone Mapper` + - Search `Zone Mapper` and add it + - Open the new "Zone Mapper" view on your default dashboard, pick the device and target entities on the card, and start drawing ### Manual Installation @@ -58,7 +51,7 @@ HACS is like an app store for Home Assistant. It makes installing and updating c /config/custom_components/zone_mapper ``` -2. Add entry to your `configuration.yaml`: +2. (Optional) add entry to your `configuration.yaml` if you want YAML-mode setup: ```yaml zone_mapper: @@ -66,7 +59,7 @@ zone_mapper: 3. Restart Home Assistant. -4. Companion card: download `zone-mapper-card.js` from [the card repo](https://github.com/ApolloAutomation/zone-mapper-card) under `/config/www` and add it as a Dashboard Resource. +4. Install the companion [Zone Mapper card](https://github.com/ApolloAutomation/zone-mapper-card) (download `zone-mapper-card.js` to `/config/www` and add it as a Dashboard Resource). ## Troubleshooting diff --git a/custom_components/zone_mapper/__init__.py b/custom_components/zone_mapper/__init__.py index 3b4e3ac..fbabea6 100644 --- a/custom_components/zone_mapper/__init__.py +++ b/custom_components/zone_mapper/__init__.py @@ -34,14 +34,17 @@ ATTR_X_MIN, ATTR_Y_MAX, ATTR_Y_MIN, + CONF_AUTO_CREATE_VIEW, COORD_SENSOR_UNIQUE_ID_FMT, DATA_LOCATIONS, DATA_PLATFORMS_LOADED, + DEFAULT_AUTO_CREATE_VIEW, DOMAIN, EVENT_ZONE_UPDATED, POLYGON_MAX_POINTS, POLYGON_MIN_POINTS, PRESENCE_SENSOR_UNIQUE_ID_FMT, + SEEDED_DEFAULT_VIEW_FLAG, SERVICE_UPDATE_ZONE, SHAPE_ELLIPSE, SHAPE_NONE, @@ -57,6 +60,7 @@ WARN_RECT_INVALID, WARN_RECT_NON_NUM, ) +from .frontend import async_seed_default_view _LOGGER = logging.getLogger(__name__) @@ -488,15 +492,45 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _auto_create_view_enabled(entry: ConfigEntry) -> bool: + value = entry.options.get(CONF_AUTO_CREATE_VIEW, DEFAULT_AUTO_CREATE_VIEW) + return bool(value) + + +async def _schedule_view_seeding(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Seed the default Lovelace view once HA is fully started.""" + if entry.data.get(SEEDED_DEFAULT_VIEW_FLAG): + return + if not _auto_create_view_enabled(entry): + return + + async def _run(_event: Event | None = None) -> None: + if entry.data.get(SEEDED_DEFAULT_VIEW_FLAG): + return + if not _auto_create_view_enabled(entry): + return + seeded = await async_seed_default_view(hass) + if seeded: + hass.config_entries.async_update_entry( + entry, + data={**entry.data, SEEDED_DEFAULT_VIEW_FLAG: True}, + ) + + if getattr(hass, "is_running", False): + hass.async_create_task(_run(None)) + else: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """ Set up Zone Mapper from a config entry. - This registers the service (if not already present) and bootstraps any - restored entities from the registry so platforms load without requiring YAML. + Registers the update service, bootstraps any restored entities, and (once) + seeds a default Lovelace view so new users see the card immediately + (assuming the zone-mapper-card frontend is installed via HACS). """ _get_integration_data(hass) - _ = entry # Ensure service is registered only once across YAML and UI setups. if not hass.services.has_service(DOMAIN, SERVICE_UPDATE_ZONE): @@ -514,6 +548,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, bootstrap_cb) + await _schedule_view_seeding(hass, entry) + return True diff --git a/custom_components/zone_mapper/config_flow.py b/custom_components/zone_mapper/config_flow.py index a119f8c..b697828 100644 --- a/custom_components/zone_mapper/config_flow.py +++ b/custom_components/zone_mapper/config_flow.py @@ -1,16 +1,27 @@ """ -Config flow for Zone Mapper. +Config and options flow for Zone Mapper. -This integration is UI-configurable and requires no options. The flow simply -creates a single entry so users don't need to add `zone_mapper:` to YAML. +The integration is UI-configurable and has a single setup step that just +creates the singleton entry. The options flow exposes a single toggle for the +first-run auto-view seeding. """ from __future__ import annotations +from typing import TYPE_CHECKING + import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import callback + +from .const import ( + CONF_AUTO_CREATE_VIEW, + DEFAULT_AUTO_CREATE_VIEW, + DOMAIN, +) -from .const import DOMAIN +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry class ZoneMapperConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -22,7 +33,6 @@ async def async_step_user( self, user_input: dict | None = None ) -> config_entries.ConfigFlowResult: """Show a confirmation form and create the entry when submitted.""" - # Prevent multiple entries; this integration is singleton. if self._async_current_entries(): return self.async_abort(reason="already_configured") @@ -31,5 +41,37 @@ async def async_step_user( self._abort_if_unique_id_configured() return self.async_create_entry(title="Zone Mapper", data={}) - # Show a confirmation form with no fields return self.async_show_form(step_id="user", data_schema=vol.Schema({})) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> ZoneMapperOptionsFlow: + """Return the options flow for this entry.""" + return ZoneMapperOptionsFlow(config_entry) + + +class ZoneMapperOptionsFlow(config_entries.OptionsFlow): + """Options flow with a single toggle for auto-view seeding.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Store the entry the options apply to.""" + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict | None = None + ) -> config_entries.ConfigFlowResult: + """Prompt for the auto-view toggle and save it.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + current = self._config_entry.options.get( + CONF_AUTO_CREATE_VIEW, DEFAULT_AUTO_CREATE_VIEW + ) + schema = vol.Schema( + { + vol.Required(CONF_AUTO_CREATE_VIEW, default=current): bool, + } + ) + return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/zone_mapper/const.py b/custom_components/zone_mapper/const.py index bd35d2d..74e8dae 100644 --- a/custom_components/zone_mapper/const.py +++ b/custom_components/zone_mapper/const.py @@ -47,6 +47,16 @@ COORD_SENSOR_UNIQUE_ID_FMT = "zone_mapper_{location}_zone_{zone_id}" PRESENCE_SENSOR_UNIQUE_ID_FMT = "zone_mapper_{location}_zone_{zone_id}_presence" +# Options flow / auto-view seeding +CONF_AUTO_CREATE_VIEW = "auto_create_view" +DEFAULT_AUTO_CREATE_VIEW = True +SEEDED_DEFAULT_VIEW_FLAG = "seeded_default_view" +AUTO_VIEW_TITLE = "Zone Mapper" +AUTO_VIEW_PATH = "zone-mapper" +AUTO_VIEW_ICON = "mdi:map-marker-radius" +AUTO_VIEW_PLACEHOLDER_LOCATION = "Home" +CARD_TYPE = "custom:zone-mapper-card" + # Log / warning templates WARN_POLY_INSUFFICIENT = ( "Polygon zone %s in location '%s' has insufficient points (<3); clearing zone." diff --git a/custom_components/zone_mapper/frontend.py b/custom_components/zone_mapper/frontend.py new file mode 100644 index 0000000..6c16bcc --- /dev/null +++ b/custom_components/zone_mapper/frontend.py @@ -0,0 +1,163 @@ +""" +Optional auto-creation of a Lovelace view for the Zone Mapper card. + +The `zone-mapper-card` frontend is installed separately via HACS. This module +only handles seeding a "Zone Mapper" view on the default storage-mode dashboard +the first time a config entry is set up, so users don't have to drop the card +into a view by hand. YAML-mode dashboards are never rewritten. + +Lovelace internal APIs (``hass.data["lovelace"]``) are not part of HA's public +contract, so the seeding code is wrapped in a broad try/except: if HA ever +changes the shape of these internals, the integration logs a warning and keeps +working without the auto-view. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from .const import ( + AUTO_VIEW_ICON, + AUTO_VIEW_PATH, + AUTO_VIEW_PLACEHOLDER_LOCATION, + AUTO_VIEW_TITLE, + CARD_TYPE, +) + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +def _placeholder_view() -> dict[str, Any]: + """Return a Lovelace view dict containing a single placeholder card.""" + return { + "title": AUTO_VIEW_TITLE, + "path": AUTO_VIEW_PATH, + "icon": AUTO_VIEW_ICON, + "cards": [ + { + "type": CARD_TYPE, + "location": AUTO_VIEW_PLACEHOLDER_LOCATION, + } + ], + } + + +def _config_contains_card(config: dict[str, Any]) -> bool: + for view in config.get("views", []) or []: + for card in view.get("cards", []) or []: + if isinstance(card, dict) and card.get("type") == CARD_TYPE: + return True + return False + + +def _resolve_default_dashboard(hass: HomeAssistant) -> object | None: + """Return the default Lovelace dashboard object, or None if unavailable.""" + lovelace_data = hass.data.get("lovelace") + if lovelace_data is None: + _LOGGER.debug("Zone Mapper: lovelace data not available yet.") + return None + dashboards = getattr(lovelace_data, "dashboards", None) + if not isinstance(dashboards, dict): + _LOGGER.warning( + "Zone Mapper: unexpected lovelace dashboards shape; skipping" + " auto-view seeding." + ) + return None + # In modern HA the default Overview is registered under the "lovelace" key. + # The legacy `None` key can hold a stale/phantom LovelaceStorage — prefer + # "lovelace" so we read and write the dashboard the user actually sees. + return dashboards.get("lovelace") or dashboards.get(None) + + +async def async_seed_default_view(hass: HomeAssistant) -> bool: # noqa: PLR0911 + """ + Append a Zone Mapper view to the default dashboard if one isn't present. + + Returns True when the config entry should be marked as seeded (either the + view was added, an existing view already contains the card, the dashboard + is YAML-mode, or an unrecoverable error occurred and we want to stop + retrying). Returns False only when the environment isn't ready yet and we + should try again on the next setup. + """ + try: + default = _resolve_default_dashboard(hass) + if default is None: + return False + + mode = getattr(default, "mode", None) + if mode == "yaml": + _LOGGER.info( + "Zone Mapper: default dashboard is YAML-mode; not rewriting it." + " Add this card manually to any view:\n" + " - type: %s\n location: %s", + CARD_TYPE, + AUTO_VIEW_PLACEHOLDER_LOCATION, + ) + return True + + async_load = getattr(default, "async_load", None) + async_save = getattr(default, "async_save", None) + if async_load is None or async_save is None: + _LOGGER.warning( + "Zone Mapper: default dashboard missing load/save hooks;" + " skipping auto-view seeding." + ) + return True + + try: + config = await async_load(force=False) + except Exception as exc: + # ConfigNotFound: default dashboard has no stored config yet; HA + # still serves its auto-generated overview. Seed a config that + # preserves that behavior via an original-states strategy view, + # then append the Zone Mapper view alongside it. + if type(exc).__name__ != "ConfigNotFound": + raise + _LOGGER.info( + "Zone Mapper: default dashboard has no stored config; seeding" + " one with an auto-generated first view and the Zone Mapper" + " view." + ) + config = { + "views": [ + { + "title": "Home", + "strategy": {"type": "original-states"}, + } + ] + } + + if not isinstance(config, dict): + _LOGGER.debug( + "Zone Mapper: dashboard config not a dict (%s); skipping seed.", + type(config).__name__, + ) + return True + + if _config_contains_card(config): + _LOGGER.debug("Zone Mapper: default dashboard already contains the card.") + return True + + views = list(config.get("views") or []) + views.append(_placeholder_view()) + await async_save({**config, "views": views}) + except Exception: # noqa: BLE001 + _LOGGER.warning( + "Zone Mapper: could not seed default Lovelace view; continuing" + " without auto-view. Add `type: %s` manually to any dashboard.", + CARD_TYPE, + exc_info=True, + ) + return True + else: + _LOGGER.info( + "Zone Mapper: added '%s' view to the default dashboard. Delete it" + " from the dashboard editor if you prefer to lay out the card" + " yourself; it won't be re-added.", + AUTO_VIEW_TITLE, + ) + return True diff --git a/custom_components/zone_mapper/manifest.json b/custom_components/zone_mapper/manifest.json index ed0455c..7deb971 100644 --- a/custom_components/zone_mapper/manifest.json +++ b/custom_components/zone_mapper/manifest.json @@ -6,9 +6,10 @@ "@WestwardWinds" ], "config_flow": true, + "dependencies": ["frontend", "http", "lovelace"], "documentation": "https://github.com/ApolloAutomation/zone-mapper", "integration_type": "service", "iot_class": "local_push", "issue_tracker": "https://github.com/ApolloAutomation/zone-mapper/issues", - "version": "1.0.1" + "version": "1.1.0" } \ No newline at end of file diff --git a/custom_components/zone_mapper/translations/en.json b/custom_components/zone_mapper/translations/en.json index 9591cea..53bef8c 100644 --- a/custom_components/zone_mapper/translations/en.json +++ b/custom_components/zone_mapper/translations/en.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "If you need help with the configuration have a look here: https://github.com/ApolloAutomation/zone-mapper", + "description": "Create the Zone Mapper entry. On first setup, a Zone Mapper view with the card is added to your default dashboard unless you turn that off in the integration options afterwards.", "data": { "username": "Username", "password": "Password" @@ -25,6 +25,20 @@ "already_configured": "This entry is already configured." } }, + "options": { + "step": { + "init": { + "title": "Zone Mapper options", + "description": "Configure first-run behavior.", + "data": { + "auto_create_view": "Auto-create a Zone Mapper view on first setup" + }, + "data_description": { + "auto_create_view": "Appends a 'Zone Mapper' view with the card to your default dashboard the first time this integration is set up. Turn off before adding the integration to skip this. Once the view is created (or skipped), it won't be re-added." + } + } + } + }, "exceptions": { "authentication_failed": { "message": "Authentication failed during data update."