From f1584001df2b98b9f5cf3113e7d669e35a7532e0 Mon Sep 17 00:00:00 2001 From: dualityps <4126225+ticstyle@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:23:01 +0200 Subject: [PATCH 1/9] Implement GeoZones Lovelace dashboard orchestration This module orchestrates the Lovelace dashboard for GeoZones, generating YAML configuration and managing the sidebar panel. --- custom_components/geozones/dashboard.py | 157 ++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 custom_components/geozones/dashboard.py diff --git a/custom_components/geozones/dashboard.py b/custom_components/geozones/dashboard.py new file mode 100644 index 0000000..6394bdb --- /dev/null +++ b/custom_components/geozones/dashboard.py @@ -0,0 +1,157 @@ +# custom_components/geozones/dashboard.py +"""Lovelace dashboard orchestration module for GeoZones.""" + +import logging +import os + +import aiofiles # type: ignore[import-untyped] + +from homeassistant.components.frontend import ( + async_register_built_in_panel, + async_remove_panel, +) +from homeassistant.components.lovelace.dashboard import LovelaceYAML +from homeassistant.core import HomeAssistant + +from .const import CONF_SOURCE_TRACKER, DOMAIN + +_LOGGER = logging.getLogger(__name__) +DASHBOARD_REL_PATH = "custom_components/geozones/geozones_dashboard.yaml" + + +async def async_generate_dashboard_yaml(hass: HomeAssistant) -> str: + """Generate and write the Lovelace dashboard YAML configuration file.""" + dashboard_path = hass.config.path(DASHBOARD_REL_PATH) + os.makedirs(os.path.dirname(dashboard_path), exist_ok=True) + + entities_yaml_lines: list[str] = [] + entries = hass.config_entries.async_entries(DOMAIN) + + if entries: + first_entry = entries[0] + source_tracker = first_entry.data.get(CONF_SOURCE_TRACKER, "") + if source_tracker: + slug = source_tracker.split(".")[-1] + entities_yaml_lines.extend( + [ + ( + f" - entity: select.geozones_{slug}_custom_zones\n" + " name: Select Custom Zone" + ), + ( + f" - entity: button.geozones_{slug}_mark_location\n" + " name: Mark Current Location" + ), + ( + f" - entity: button.geozones_{slug}_remove_zone\n" + " name: Remove Selected Zone" + ), + ( + f" - entity: button.geozones_{slug}_reload\n" + " name: Reload Layer" + ), + ] + ) + + if not entities_yaml_lines: + entities_block = " - entity: device_tracker.geozones" + else: + entities_block = "\n".join(entities_yaml_lines) + + yaml_content = f"""title: GeoZones +views: + - title: GeoZones + path: geozones-overview + icon: mdi:map-marker-radius + type: masonry + cards: + - type: markdown + title: "π Active Tracking Overview" + content: | + {{%- set trackers = states.device_tracker | selectattr('entity_id', 'search', '^device_tracker\\\\.geozones_') | list -%}} + {{%- if trackers | length > 0 -%}} + {{%- for t in trackers %}} + ### π± {{{{ t.name }}}} + * **Current Zone:** `{{{{ t.state }}}}` + * **Source Target:** `{{{{ state_attr(t.entity_id, 'source_entity_id') or t.entity_id }}}}` + + **Active inside zones:** + {{%- set zones = state_attr(t.entity_id, 'containing_zones') -%}} + {{%- if zones and zones | length > 0 -%}} + {{%- for zone in zones %}} + - {{{{ zone }}}} + {{%- endfor -%}} + {{%- else %}} + *Not inside any custom zones.* + {{%- endif %}} + + {{%- if not loop.last %}} + --- + {{%- endif %}} + {{%- endfor -%}} + {{%- else %}} + *No active GeoZones trackers detected.* + {{%- endif -%}} + + - type: entities + title: "βοΈ Custom Zone Manager" + show_header_toggle: false + entities: +{entities_block} + + - type: markdown + title: "πΊοΈ All Available Zones" + content: | + {{%- set ns = namespace(zones=[]) -%}} + {{%- set trackers = states.device_tracker | selectattr('entity_id', 'search', '^device_tracker\\\\.geozones_') | list -%}} + {{%- for t in trackers -%}} + {{%- set lz = state_attr(t.entity_id, 'loaded_zones') or state_attr(t.entity_id, 'available_zones') or state_attr(t.entity_id, 'all_zones') or state_attr(t.entity_id, 'zones') or [] -%}} + {{%- set ns.zones = ns.zones + lz -%}} + {{%- endfor -%}} + {{%- set unique_zones = ns.zones | unique | sort -%}} + {{%- if unique_zones | length > 0 -%}} + Total loaded zones across all active files: **{{{{ unique_zones | length }}}}** + + {{%- for z in unique_zones %}} + - {{{{ z }}}} + {{%- endfor -%}} + {{%- else %}} + *No loaded zones available across active trackers.* + {{%- endif -%}} +""" + + async with aiofiles.open(dashboard_path, mode="w", encoding="utf-8") as file: + await file.write(yaml_content) + + return DASHBOARD_REL_PATH + + +async def async_setup_dashboard(hass: HomeAssistant) -> None: + """Set up and register the GeoZones sidebar panel dashboard.""" + rel_path = await async_generate_dashboard_yaml(hass) + + if "lovelace" in hass.data and hasattr(hass.data["lovelace"], "dashboards"): + hass.data["lovelace"].dashboards["geozones"] = LovelaceYAML( + hass, "geozones", {"mode": "yaml", "filename": rel_path} + ) + + async_register_built_in_panel( + hass, + component_name="lovelace", + sidebar_title="GeoZones", + sidebar_icon="mdi:map-marker-path", + frontend_url_path="geozones", + config={ + "mode": "yaml", + "title": "GeoZones", + "icon": "mdi:map-marker-path", + }, + require_admin=False, + ) + + +async def async_remove_dashboard(hass: HomeAssistant) -> None: + """Remove the GeoZones sidebar panel dashboard.""" + async_remove_panel(hass, "geozones") + if "lovelace" in hass.data and hasattr(hass.data["lovelace"], "dashboards"): + hass.data["lovelace"].dashboards.pop("geozones", None) From 86e9c6d683bdbf5f125539e037483ad3c3e2c136 Mon Sep 17 00:00:00 2001 From: ticstyle <4126225+ticstyle@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:23:15 +0000 Subject: [PATCH 2/9] chore: sync translations and auto-format with Ruff --- custom_components/geozones/dashboard.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/geozones/dashboard.py b/custom_components/geozones/dashboard.py index 6394bdb..eb545b5 100644 --- a/custom_components/geozones/dashboard.py +++ b/custom_components/geozones/dashboard.py @@ -5,7 +5,6 @@ import os import aiofiles # type: ignore[import-untyped] - from homeassistant.components.frontend import ( async_register_built_in_panel, async_remove_panel, From 71f5f1a65c37ce5b43dd8f6b536c17b99bb6acf7 Mon Sep 17 00:00:00 2001 From: dualityps <4126225+ticstyle@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:23:27 +0200 Subject: [PATCH 3/9] Refactor GeoZones component and remove dashboard generation --- custom_components/geozones/__init__.py | 150 ++----------------------- 1 file changed, 8 insertions(+), 142 deletions(-) diff --git a/custom_components/geozones/__init__.py b/custom_components/geozones/__init__.py index d227686..6f2ee14 100644 --- a/custom_components/geozones/__init__.py +++ b/custom_components/geozones/__init__.py @@ -1,18 +1,12 @@ # custom_components/geozones/__init__.py """The GeoZones Component initialization runtime orchestration module.""" -import logging -import os from datetime import datetime +import logging from typing import Any -import aiofiles # type: ignore[import-untyped] import voluptuous as vol -from homeassistant.components.frontend import ( - async_register_built_in_panel, - async_remove_panel, -) -from homeassistant.components.lovelace.dashboard import LovelaceYAML + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall @@ -29,6 +23,7 @@ DEFAULT_USE_CUSTOM_ZONES, DOMAIN, ) +from .dashboard import async_remove_dashboard, async_setup_dashboard from .utils import ( async_add_custom_zone, async_ensure_custom_zones_file, @@ -45,16 +40,14 @@ ] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -DASHBOARD_REL_PATH = "custom_components/geozones/geozones_dashboard.yaml" def _get_active_select_zone_name(hass: HomeAssistant) -> str | None: """Extract selected zone name from any registered GeoZones select entity.""" for state in hass.states.async_all("select"): - if state.entity_id.startswith("select.geozones_") and state.state not in ( - None, - "unknown", - "unavailable", + if ( + state.entity_id.startswith("select.geozones_") + and state.state not in (None, "unknown", "unavailable") ): return state.state return None @@ -75,112 +68,6 @@ async def _async_reprocess_all_entries(hass: HomeAssistant) -> None: async_dispatcher_send(hass, f"{DOMAIN}_reload_{entry.entry_id}") -async def _async_generate_dashboard_yaml(hass: HomeAssistant) -> str: - """Generate and write the Lovelace dashboard YAML configuration file.""" - dashboard_path = hass.config.path(DASHBOARD_REL_PATH) - os.makedirs(os.path.dirname(dashboard_path), exist_ok=True) - - entities_yaml_lines: list[str] = [] - entries = hass.config_entries.async_entries(DOMAIN) - - if entries: - first_entry = entries[0] - source_tracker = first_entry.data.get(CONF_SOURCE_TRACKER, "") - if source_tracker: - slug = source_tracker.split(".")[-1] - entities_yaml_lines.extend( - [ - ( - f" - entity: select.geozones_{slug}_custom_zones\n" - " name: Select Custom Zone" - ), - ( - f" - entity: button.geozones_{slug}_mark_location\n" - " name: Mark Current Location" - ), - ( - f" - entity: button.geozones_{slug}_remove_zone\n" - " name: Remove Selected Zone" - ), - ( - f" - entity: button.geozones_{slug}_reload\n" - " name: Reload Layer" - ), - ] - ) - - if not entities_yaml_lines: - entities_block = " - entity: device_tracker.geozones" - else: - entities_block = "\n".join(entities_yaml_lines) - - yaml_content = f"""title: GeoZones -views: - - title: GeoZonesOverview - path: geozones-overview - icon: mdi:map-marker-radius - type: masonry - cards: - - type: markdown - title: "π Active Tracking Overview" - content: | - {{%- set trackers = states.device_tracker | selectattr('entity_id', 'search', '^device_tracker\\\\.geozones_') | list -%}} - {{%- if trackers | length > 0 -%}} - {{%- for t in trackers %}} - ### π± {{{{ t.name }}}} - * **Current Zone:** `{{{{ t.state }}}}` - * **Source Target:** `{{{{ state_attr(t.entity_id, 'source_entity_id') or t.entity_id }}}}` - - **Active inside zones:** - {{%- set zones = state_attr(t.entity_id, 'containing_zones') -%}} - {{%- if zones and zones | length > 0 -%}} - {{%- for zone in zones %}} - - {{{{ zone }}}} - {{%- endfor -%}} - {{%- else %}} - *Not inside any custom zones.* - {{%- endif %}} - - {{%- if not loop.last %}} - --- - {{%- endif %}} - {{%- endfor -%}} - {{%- else %}} - *No active GeoZones trackers detected.* - {{%- endif -%}} - - - type: entities - title: "βοΈ Custom Zone Manager" - show_header_toggle: false - entities: -{entities_block} - - - type: markdown - title: "πΊοΈ All Loaded Zones" - content: | - {{%- set ns = namespace(zones=[]) -%}} - {{%- set trackers = states.device_tracker | selectattr('entity_id', 'search', '^device_tracker\\\\.geozones_') | list -%}} - {{%- for t in trackers -%}} - {{%- set cz = state_attr(t.entity_id, 'containing_zones') or [] -%}} - {{%- set lz = state_attr(t.entity_id, 'loaded_zones') or state_attr(t.entity_id, 'all_zones') or state_attr(t.entity_id, 'zones') or [] -%}} - {{%- set ns.zones = ns.zones + cz + lz -%}} - {{%- endfor -%}} - {{%- set unique_zones = ns.zones | unique | sort -%}} - {{%- if unique_zones | length > 0 -%}} - {{%- for z in unique_zones %}} - - {{{{ z }}}} - {{%- endfor -%}} - {{%- else %}} - *No loaded zones detected across active trackers.* - {{%- endif -%}} -""" - - async with aiofiles.open(dashboard_path, mode="w", encoding="utf-8") as file: - await file.write(yaml_content) - - return DASHBOARD_REL_PATH - - async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Set up the GeoZones component domain and register custom actions.""" await async_ensure_custom_zones_file(hass) @@ -317,26 +204,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not hass.data[DOMAIN].get("panel_registered"): hass.data[DOMAIN]["panel_registered"] = True try: - rel_path = await _async_generate_dashboard_yaml(hass) - - if "lovelace" in hass.data and hasattr(hass.data["lovelace"], "dashboards"): - hass.data["lovelace"].dashboards["geozones"] = LovelaceYAML( - hass, "geozones", {"mode": "yaml", "filename": rel_path} - ) - - async_register_built_in_panel( - hass, - component_name="lovelace", - sidebar_title="GeoZones", - sidebar_icon="mdi:map-marker-path", - frontend_url_path="geozones", - config={ - "mode": "yaml", - "title": "GeoZones", - "icon": "mdi:map-marker-path", - }, - require_admin=False, - ) + await async_setup_dashboard(hass) except ValueError: _LOGGER.debug("GeoZones panel already registered") except OSError as err: @@ -384,9 +252,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if e.entry_id != entry.entry_id ] if not remaining_entries and hass.data[DOMAIN].get("panel_registered"): - async_remove_panel(hass, "geozones") - if "lovelace" in hass.data and hasattr(hass.data["lovelace"], "dashboards"): - hass.data["lovelace"].dashboards.pop("geozones", None) + await async_remove_dashboard(hass) hass.data[DOMAIN]["panel_registered"] = False return unload_ok From a5fa5c3022fc345df1cd7b1a3f16cf032f6d9d4f Mon Sep 17 00:00:00 2001 From: ticstyle <4126225+ticstyle@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:23:40 +0000 Subject: [PATCH 4/9] chore: sync translations and auto-format with Ruff --- custom_components/geozones/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/geozones/__init__.py b/custom_components/geozones/__init__.py index 6f2ee14..847b9cb 100644 --- a/custom_components/geozones/__init__.py +++ b/custom_components/geozones/__init__.py @@ -1,12 +1,11 @@ # custom_components/geozones/__init__.py """The GeoZones Component initialization runtime orchestration module.""" -from datetime import datetime import logging +from datetime import datetime from typing import Any import voluptuous as vol - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall @@ -45,9 +44,10 @@ def _get_active_select_zone_name(hass: HomeAssistant) -> str | None: """Extract selected zone name from any registered GeoZones select entity.""" for state in hass.states.async_all("select"): - if ( - state.entity_id.startswith("select.geozones_") - and state.state not in (None, "unknown", "unavailable") + if state.entity_id.startswith("select.geozones_") and state.state not in ( + None, + "unknown", + "unavailable", ): return state.state return None From da89bf98437e34fafa33f7e4214981525f21446d Mon Sep 17 00:00:00 2001 From: dualityps <4126225+ticstyle@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:30:23 +0200 Subject: [PATCH 5/9] Refactor GeoZones dashboard YAML generation --- custom_components/geozones/dashboard.py | 59 +++++++++++++++++++------ 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/custom_components/geozones/dashboard.py b/custom_components/geozones/dashboard.py index eb545b5..cc7ffff 100644 --- a/custom_components/geozones/dashboard.py +++ b/custom_components/geozones/dashboard.py @@ -5,10 +5,12 @@ import os import aiofiles # type: ignore[import-untyped] + from homeassistant.components.frontend import ( async_register_built_in_panel, async_remove_panel, ) +from homeassistant.components.http import StaticPathConfig from homeassistant.components.lovelace.dashboard import LovelaceYAML from homeassistant.core import HomeAssistant @@ -16,6 +18,7 @@ _LOGGER = logging.getLogger(__name__) DASHBOARD_REL_PATH = "custom_components/geozones/geozones_dashboard.yaml" +LOCAL_LOGO_URL = "/geozones_static/logo.png" async def async_generate_dashboard_yaml(hass: HomeAssistant) -> str: @@ -28,25 +31,37 @@ async def async_generate_dashboard_yaml(hass: HomeAssistant) -> str: if entries: first_entry = entries[0] - source_tracker = first_entry.data.get(CONF_SOURCE_TRACKER, "") - if source_tracker: + first_tracker = first_entry.data.get(CONF_SOURCE_TRACKER, "") + + if first_tracker: + first_slug = first_tracker.split(".")[-1] + entities_yaml_lines.append( + f" - entity: select.geozones_{first_slug}_custom_zones\n" + " name: Select Custom Zone" + ) + entities_yaml_lines.append(" - type: divider") + + for entry in entries: + source_tracker = entry.data.get(CONF_SOURCE_TRACKER, "") + if not source_tracker: + continue slug = source_tracker.split(".")[-1] + entities_yaml_lines.append( + f" - entity: button.geozones_{slug}_mark_location\n" + f" name: Mark Current Location for {slug}" + ) + + if first_tracker: + first_slug = first_tracker.split(".")[-1] entities_yaml_lines.extend( [ + " - type: divider", ( - f" - entity: select.geozones_{slug}_custom_zones\n" - " name: Select Custom Zone" - ), - ( - f" - entity: button.geozones_{slug}_mark_location\n" - " name: Mark Current Location" - ), - ( - f" - entity: button.geozones_{slug}_remove_zone\n" + f" - entity: button.geozones_{first_slug}_remove_zone\n" " name: Remove Selected Zone" ), ( - f" - entity: button.geozones_{slug}_reload\n" + f" - entity: button.geozones_{first_slug}_reload\n" " name: Reload Layer" ), ] @@ -59,14 +74,18 @@ async def async_generate_dashboard_yaml(hass: HomeAssistant) -> str: yaml_content = f"""title: GeoZones views: - - title: GeoZones - path: geozones-overview + - title: Overview + path: overview icon: mdi:map-marker-radius type: masonry cards: - type: markdown title: "π Active Tracking Overview" content: | +